Skip to content

project: NUL-byte project ID breaks fromDirectory and blocks TUI model selection #53991

Description

@layermedya

Summary

A project row whose id contains NUL bytes makes Project.fromDirectory fail with
ERR_INVALID_ARG_VALUE while building the shell snapshot path <data>/shell/<projectID>.
The directory can never be resolved again, migrateProjectId cannot self-repair it, and
the TUI stops offering model selection for that directory.

Environment

  • opencode version: 2.0.24 (desktop-bundled CLI); 1.18.30 on PATH
  • OS: Microsoft Windows 10 Pro, build 19045 (win32 x64)
  • Terminal: xterm-256color
  • Shell: C:\Windows\system32\cmd.exe
  • Install/channel: latest
  • Active plugins: @prevalentware/opencode-goal-plugin, superpowers@git+https://github.com/obra/superpowers.git

Reproduction

  1. Ensure a project row exists whose id value contains NUL bytes (see Additional Context).
  2. Start OpenCode in that project directory.
  3. Attempt to move a session into it:
    opencode session move <sessionID> <directory>
    
  4. Inspect ~/.local/share/opencode/log/opencode.log.

Expected Behavior

Project.fromDirectory should resolve the directory to a valid project ID and, if a
legacy ID differs, migrateProjectId should migrate sessions to the new ID and remove the
stale row. The TUI should offer model selection normally.

Actual Behavior

Project resolution aborts before any repair can happen:

level=WARN message="session move destination unavailable"
 directory="D:\\Program\\wamp64\\www\\firsatdev"
 cause="Cause([Die(TypeError [ERR_INVALID_ARG_VALUE]: The argument 'path' must be a string,
 Uint8Array, or URL without null bytes. Received
 \"C:\\Users\\<user>\\.local\\share\\opencode\\shell\\\\\\u0000\\u0000\\u0000\\u0000...\"))])"

In the TUI the directory loads but no model/agent selection is offered, so prompts are
rejected with "Select an agent and model before sending a prompt."

Additional Context

Corrupted rows. Two of 36 project rows have unusable IDs. Both were written at the
same instant (2026-09-15 20:36:44), suggesting a single batch write:

-- id is 63 NUL bytes (hex length 0x50)
SELECT worktree, hex(id) FROM project
 WHERE length(id) = 0;

-- D:/Program/wamp64/www/firsatdev  -> X'0000...' (63 bytes of 0x00)
-- D:/Program/wamp64/www/indirim26  -> '' (empty string)

All other 34 rows carry well-formed 40-character hex IDs.

Why the built-in repair cannot recover.

Project.fromDirectory calls migrateProjectId(data.previous, projectID), which starts:

const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (oldID, newID) {
  if (!oldID) return
  if (oldID === ProjectV2.ID.global) return
  if (oldID === newID) return      // <-- reached here
  ...
})

projectV2.resolve() keeps returning the same NUL-byte ID, so oldID === newID and the
function returns before repairing anything. A new row is never inserted because the
INSERT ... ON CONFLICT DO UPDATE targets the same primary key. Confirmed in the database:
fromDirectory was invoked for the affected directory many times and the row set never
changed.

This is not caused by the V1 to V2 migration. All 48 entries in the migration table
and all 21 rows in __drizzle_migrations completed at 2026-10-07 18:25:54, and
kv.migration.v1-v2 is {"phase":"completed"}. The corrupted rows were created
2026-09-15, 22 days earlier. There is no migration record dated 2026-09-15.

Not caused by a missing git remote. git remote add origin <url> followed by
re-resolving the directory did not produce a valid ID. Other projects in the same parent
directory also have no remote and still hold valid IDs.

Root cause (confirmed 2026-10-08). .git/opencode inside the affected repo
contained 40 NUL bytes (00 x 40) instead of a 40-char hex ID. Healthy projects
(eweb, indirim, ozdemirtas) store a valid hex ID there; projects without the file
(layermedya26, halilkorkut) resolve fine. Workaround: renaming .git to bak.git
makes resolve() treat the directory as non-git, producing a valid ID
(7254970e841d30b55228e1e3f5e24a8828b3e2c8) and the TUI offers model selection
again. The stale NUL-byte row remains in project until repaired.

Suggested fixes.

  1. Sanitize or reject IDs containing NUL bytes before they reach ProjectTable, both on
    write and on read.
  2. When a project cannot be resolved, surface the failure instead of silently degrading to
    "no model available".
  3. migrateProjectId should be able to repair an ID that is not a valid project ID, rather
    than treating "same old and new" as a no-op.
  4. Provide a supported repair path for corrupt rows that does not require the user to edit
    the SQLite database by hand.

Activity

  1. opencode-agent commented on Oct 8, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and for finding the root cause. I reproduced this on 2.0.24. In a git repo whose .git/opencode holds 40 NUL bytes, opencode run fails with UnexpectedStatus: 500, and the log shows the same path ... without null bytes error for <data>/shell/\u0000…. The same repo without that file resolves normally. It still happens on the latest v2 commit (38c955e), run from source.

    The cached ID in .git/opencode is used as the project ID without checking that it is valid. When the file is corrupted, project resolution fails every time and never repairs itself. Related: #49997 reported the same null-byte shell path on v2.0.10. It was closed without a linked fix.

    Until this is fixed, the workaround you found (renaming .git so the directory isn't treated as a git repo) works. The script below reproduces the problem.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#53991: a .git/opencode cache file full of NUL bytes
    # becomes the project ID, and project resolution then dies building <data>/shell/<id>.
    # Needs `opencode` (e.g. bun add -g @opencode/cli@2.0.24) on PATH.
    set -u
    export HOME=$(mktemp -d)            # isolated data dir
    PROJ=$(mktemp -d)
    cd "$PROJ"
    git init -q
    head -c 40 /dev/zero > .git/opencode   # corrupted cached project ID (40 x 0x00)
    
    opencode --version
    timeout 60 opencode run --standalone "hi" 2>&1 | head -3      # -> Error: UnexpectedStatus: 500
    
    echo "--- log ---"
    grep -a -h "without null bytes" -r "$HOME/.local/share/opencode/log" | head -1 | cut -c1-300
    opencode service stop >/dev/null 2>&1 || true

    Run it: bash repro.sh

  2. argszero commented on Oct 8, 2026

    @argszero

    I'd like to work on this.

    Confirming the root cause in the current v2 source, at the read rather than the write:

    • packages/core/src/project.ts reads the cached identity with cached() — fs.readFileString(path.join(dir, "opencode")), then value.trim(), then ID.make(value). trim() does not remove NUL bytes, so a 40-byte NUL file stays truthy and becomes the project ID. ProjectSchema.ID (packages/schema/src/project-id.ts) is a branded free-form string with no charset constraint, so nothing rejects it.
    • resolve() then prefers that value: const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo)). With no remote, id === previous === <NUL bytes>, which is why adding a remote is the only thing that changes it and why migrateProjectId sees oldID === newID and returns before repairing anything.
    • The first path built from the ID is the shell output directory, path.join(global.data, DIRECTORY, location.project.id) (packages/core/src/shell.ts), which is the ERR_INVALID_ARG_VALUE ... without null bytes line in your log.

    Intended shape, kept small:

    1. In cached(), treat a cached value that cannot be used as a project ID — one containing control bytes — as absent, so resolution continues to the remote / root-commit / global sources and yields the same valid ID you got by renaming .git. This covers the Mercurial cache read too, since hgDiscover goes through the same helper.
    2. A test in packages/core/test/project.test.ts next to the existing returns previous cached id from common dir case: a repo whose .git/opencode holds NUL bytes resolves to the root commit and reports no previous.

    Deliberately out of scope, so the diff stays reviewable: no write-back of the cache (resolving intentionally does not write it today), no change to ProjectSchema.ID itself (legacy values such as the old-id in the current tests stay valid), and no repair pass over project rows that are already corrupted.

    Reviewers: tell me if you would rather have the guard at the point of use (the shell directory) or on the schema side instead, and I will follow that.

  3. layermedya commented on Oct 8, 2026

    @layermedya
    Author

    Thanks for reproducing. Confirming from my side: after renaming .git away, the directory resolves to a valid ID and the TUI offers model selection again. Note the stale NUL-byte row stays in project and regenerates on every resolve until the cache file is fixed - happy to test the fix against my repro when ready.

  4. argszero commented on Oct 8, 2026

    @argszero

    @layermedya the fix is up: #54030.

    It makes the cache advisory rather than authoritative — Project.cached() now treats a value carrying control characters as absent, so resolution falls through to the normal sources instead of adopting the corrupt value as the project id. That is the same path your renaming-.git workaround takes, so the directory should now resolve to a valid id without touching the cache file, and the stale row should stop being re-derived on each resolve.

    You offered to test against your repro — the branch is argszero:corrupt-project-id if you want to try it before it lands. Thanks for the clear report and for pinning down the read rather than the write side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions