Repository navigation
Description
Description
Auth.set and Auth.remove read the entire auth.json, mutate one key, and rewrite the whole file with no atomic rename and no cross-process lock. With several opencode processes running, two writers touching different providers silently clobber each other — the loser's credential is gone from disk.
Two separate defects live in the same function. Both are data loss.
Correction (edited): an earlier version of this issue claimed MCP OAuth token refresh writes
auth.jsonautonomously. That is wrong.McpOAuthProvidertakes anMcpAuth.Interface, which targets a separate file,mcp-auth.json(packages/opencode/src/mcp/auth.ts). Every writer ofauth.jsonis user- or API-initiated. The defects below stand on their own; the corrected MCP detail actually strengthens the case, see "Prior art in this repo".
Defect 1 — unlocked whole-file read-modify-write
packages/opencode/src/auth/index.ts:
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all() // read WHOLE file
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600) // write WHOLE file
.pipe(Effect.mapError(fail("Failed to write auth data")))
})Auth.remove has the same shape. FSUtil.writeJson in packages/core/src/fs-util.ts is an in-place truncating write:
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
const content = JSON.stringify(data, null, 2)
yield* fs.writeFileString(path, content)
if (mode) yield* fs.chmod(path, mode)
})Two consequences:
- Lost update. Process A and process B both read the file, each adds a different provider, each writes back its own full snapshot. Whichever writes second wins and the other provider is gone. The five write paths —
auth login(cli/cmd/providers.ts), the HTTP auth set/remove handlers (server/routes/instance/httpapi/handlers/control.ts), and OAuth callback completion (provider/auth.ts) — can all be in flight in different processes at once. An OAuth callback landing while another process finishes a login is enough. - Torn write. No temp-file-plus-rename, so a crash, OOM kill, or full disk mid-write leaves
auth.jsontruncated or empty. Every credential, not just the one being written.
Prior art in this repo
The lock half of this is already solved one directory over. McpAuth — the service for mcp-auth.json — wraps its read-modify-write in a flock:
const mutate = Effect.fn("McpAuth.mutate")(function* (update: (data: AuthData) => AuthData | undefined) {
yield* Effect.gen(function* () {
const next = update(yield* read())
if (!next) return
yield* fs.writeJson(filepath, next, 0o600).pipe(Effect.orDie)
}).pipe(flock.withLock(lockKey), Effect.orDie)
})And the atomic half is solved in the TUI: packages/tui/src/util/persistence.ts has a correct temp-file-plus-rename writeJsonAtomic, which packages/tui/src/context/kv.tsx wraps in Flock.withLock.
So the MCP token store is locked, and the TUI's scratch key-value store is both locked and crash-safe. The provider credential store is neither. That looks like an oversight rather than a deliberate trade-off.
Defect 2 — OPENCODE_AUTH_CONTENT snapshot overwrites the file
Auth.all short-circuits to the environment variable and never reads the file:
const all = Effect.fn("Auth.all")(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
} catch (err) {}
}
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({}))))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})set and remove both start from all(). So when OPENCODE_AUTH_CONTENT is set, they read the env snapshot and then write that snapshot to the file — replacing real on-disk state with a point-in-time copy plus the one changed entry. Anything added to auth.json after the snapshot was taken is erased.
This is reachable in normal operation: workspace children are spawned with exactly that variable (packages/opencode/src/control-plane/workspace.ts):
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()),
OPENCODE_WORKSPACE_ID: config.id,
...
}so any auth login or HTTP auth set inside a workspace child rewrites the host's auth.json from the child's stale env copy.
Minor, same code path: env content is returned straight from JSON.parse while file content goes through Record.filterMap(... decode ...), so the env path skips schema validation entirely.
Suggested fix
Auth.set/Auth.removedo their read-modify-write against the file, notall(), so the env override stays a read-time concern and cannot be written back.all()andget()keep their current behavior — workspace credential propagation depends on the env winning on reads.- Route the write through an atomic temp-file-plus-rename and take a cross-process lock around the read-modify-write — matching what
McpAuthalready does formcp-auth.jsonand whatkv.tsxdoes withwriteJsonAtomic+Flock.withLock.
Happy to open the PR.
Steps to reproduce
Defect 1: run two opencode processes; have each write a different provider via auth login or the HTTP auth API at the same time. One provider is missing from auth.json afterwards.
Defect 2: set OPENCODE_AUTH_CONTENT to a snapshot missing a provider that exists in auth.json, then perform any credential write in that process. The provider absent from the snapshot is erased from the file.
OpenCode version
dev (reproduces on current dev)
Found while auditing whether a plugin can own credential custody for an external secret store. The custody question is separate; this is a pre-existing defect independent of it.
Activity
github-actions commentedon Aug 29, 2026
This issue might be a duplicate of existing issues. Please check:
auth.json(no lock between concurrent processes), which overlaps with Defect 1 here.Auth.all()bypasses schema validation forOPENCODE_AUTH_CONTENTand silently swallows JSON parse errors — covers the missing schema validation on the env-var path described in your Defect 2.auth.json/ JSON stores — covers the non-atomicwriteJsonpath (no temp-file-plus-rename) mentioned in Defect 1.Your report is more comprehensive and ties all three defects together with a concrete fix suggestion, so it may still be worth keeping open as a tracking issue — but please check the above to avoid duplicate work.
docs(custody): source the host-write race and collapse unobservable m…
neriousy commentedon Oct 19, 2026
v2 no longer stores provider credentials in a whole-file
auth.json. Each credential is its own row in the SQLite database, written inside a transaction (packages/core/src/credential.ts), so two processes saving different providers cannot overwrite each other and an interrupted write cannot wipe the whole store.OPENCODE_AUTH_CONTENTno longer exists, and an existing v1auth.jsonis imported once by a database migration. To get v2:npm i -g @opencode/cli@latestorcurl -fsSL https://opencode.ai/v2/install | bash(desktop: update the app). If you still see this on v2, please open a new issue with the details.