Repository navigation
project: NUL-byte project ID breaks fromDirectory and blocks TUI model selection #53991
Description
Activity
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)
#!/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.shI'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.- 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 theERR_INVALID_ARG_VALUE ... without null bytesline in your log.
Intended shape, kept small:
- 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 /globalsources and yields the same valid ID you got by renaming.git. This covers the Mercurial cache read too, sincehgDiscovergoes through the same helper. - A test in
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.
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.
@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.
Summary
A
projectrow whoseidcontains NUL bytes makesProject.fromDirectoryfail withERR_INVALID_ARG_VALUEwhile building the shell snapshot path<data>/shell/<projectID>.The directory can never be resolved again,
migrateProjectIdcannot self-repair it, andthe TUI stops offering model selection for that directory.
Environment
PATH@prevalentware/opencode-goal-plugin,superpowers@git+https://github.com/obra/superpowers.gitReproduction
projectrow exists whoseidvalue contains NUL bytes (see Additional Context).~/.local/share/opencode/log/opencode.log.Expected Behavior
Project.fromDirectoryshould resolve the directory to a valid project ID and, if alegacy ID differs,
migrateProjectIdshould migrate sessions to the new ID and remove thestale row. The TUI should offer model selection normally.
Actual Behavior
Project resolution aborts before any repair can happen:
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:
All other 34 rows carry well-formed 40-character hex IDs.
Why the built-in repair cannot recover.
Project.fromDirectorycallsmigrateProjectId(data.previous, projectID), which starts:projectV2.resolve()keeps returning the same NUL-byte ID, sooldID === newIDand thefunction returns before repairing anything. A new row is never inserted because the
INSERT ... ON CONFLICT DO UPDATEtargets the same primary key. Confirmed in the database:fromDirectorywas invoked for the affected directory many times and the row set neverchanged.
This is not caused by the V1 to V2 migration. All 48 entries in the
migrationtableand all 21 rows in
__drizzle_migrationscompleted at2026-10-07 18:25:54, andkv.migration.v1-v2is{"phase":"completed"}. The corrupted rows were created2026-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 byre-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/opencodeinside the affected repocontained 40 NUL bytes (
00x 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
.gittobak.gitmakes
resolve()treat the directory as non-git, producing a valid ID(
7254970e841d30b55228e1e3f5e24a8828b3e2c8) and the TUI offers model selectionagain. The stale NUL-byte row remains in
projectuntil repaired.Suggested fixes.
ProjectTable, both onwrite and on read.
"no model available".
migrateProjectIdshould be able to repair an ID that is not a valid project ID, ratherthan treating "same old and new" as a no-op.
the SQLite database by hand.