Repository navigation
Description
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
- Ensure a
projectrow exists whoseidvalue contains NUL bytes (see Additional Context). - Start OpenCode in that project directory.
- Attempt to move a session into it:
opencode session move <sessionID> <directory> - 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.
- Sanitize or reject IDs containing NUL bytes before they reach
ProjectTable, both on
write and on read. - When a project cannot be resolved, surface the failure instead of silently degrading to
"no model available". migrateProjectIdshould be able to repair an ID that is not a valid project ID, rather
than treating "same old and new" as a no-op.- Provide a supported repair path for corrupt rows that does not require the user to edit
the SQLite database by hand.
Activity
opencode-agent commentedon Oct 8, 2026
Thanks for the detailed report and for finding the root cause. I reproduced this on 2.0.24. In a git repo whose
.git/opencodeholds 40 NUL bytes,opencode runfails withUnexpectedStatus: 500, and the log shows the samepath ... without null byteserror for<data>/shell/\u0000…. The same repo without that file resolves normally. It still happens on the latestv2commit (38c955e), run from source.The cached ID in
.git/opencodeis 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
.gitso the directory isn't treated as a git repo) works. The script below reproduces the problem.Reproduction script (repro.sh)
Run it:
bash repro.shargszero commentedon Oct 8, 2026
I'd like to work on this.
Confirming the root cause in the current
v2source, at the read rather than the write:packages/core/src/project.tsreads the cached identity withcached()—fs.readFileString(path.join(dir, "opencode")), thenvalue.trim(), thenID.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 whymigrateProjectIdseesoldID === newIDand returns before repairing anything.path.join(global.data, DIRECTORY, location.project.id)(packages/core/src/shell.ts), which is theERR_INVALID_ARG_VALUE ... without null bytesline in your log.Intended shape, kept small:
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 /globalsources and yields the same valid ID you got by renaming.git. This covers the Mercurial cache read too, sincehgDiscovergoes through the same helper.packages/core/test/project.test.tsnext to the existingreturns previous cached id from common dircase: a repo whose.git/opencodeholds NUL bytes resolves to the root commit and reports noprevious.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.IDitself (legacy values such as theold-idin the current tests stay valid), and no repair pass overprojectrows 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.
layermedya commentedon Oct 8, 2026
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.
argszero commentedon Oct 8, 2026
@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-.gitworkaround 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-idif you want to try it before it lands. Thanks for the clear report and for pinning down the read rather than the write side.