Skip to content

fix(core): stop distinct account files sharing one state document - #91

Open
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/account-path-alias
Open

fix(core): stop distinct account files sharing one state document#91
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/account-path-alias

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #89.

Two path-resolution defects in core/account-paths.ts, both ending with two documents fighting over one file. Branched from 9bf8f4c, independent of #87.

1. Suffix matching aliased distinct config files onto one state file

deriveStatePath tested configPath.endsWith('openai-auth.json'), which matches any filename ending in that string:

OPENCODE_OPENAI_AUTH_FILE state path (before)
/tmp/openai-auth.json /tmp/openai-auth-state.json
/tmp/team-openai-auth.json /tmp/openai-auth-state.json

Two rosters in one directory silently shared one state document, and the second writer overwrote the first's stored per-account fields. Now compares the basename exactly, so a custom filename derives its own .state.json sibling.

2. An explicit state path could equal the config path

getAccountStatePath accepted OPENCODE_OPENAI_AUTH_STATE_FILE without checking it differed from the config path. Pointing both variables at one file made a mutation write the config document and then the state document to it, replacing the roster with a state-only document. Now both are resolved and an equal pair is rejected.

Behaviour change worth noting before merge

A user whose config path is a non-canonical filename (for example /tmp/team-openai-auth.json) previously got state at /tmp/openai-auth-state.json and now gets /tmp/team-openai-auth.json.state.json. Their existing state document is orphaned, so they would appear logged out.

No migration is offered, deliberately: the previously shared state document cannot be safely attributed back to either roster, and guessing risks handing one roster's stored fields to another. Happy to add a one-time detection-and-warn path instead if you would prefer that.

The error message names only the variable the user actually set:

OPENCODE_OPENAI_AUTH_STATE_FILE resolves to the config path (/tmp/roster.json).
Set OPENCODE_OPENAI_AUTH_STATE_FILE to a different file.

Tests

1066 pass / 0 fail (1064 on 9bf8f4c, one redundant test removed, two added), tsc clean.

Both new tests were mutation-checked independently by a reviewer rather than trusted from the author — an earlier revision of this branch carried a test that could not fail, because its input mapped to the same output under both the old and new logic. It was deleted rather than kept as decoration.

test revert basenameendsWith remove the equality check return configPath instead of explicit
derives distinct state paths fail pass pass
refuses an explicit state path pass fail fail

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Stops distinct account config files from sharing one state document and prevents state from being written into the config file. Old: suffix-based matching mapped multiple config filenames to one openai-auth-state.json and allowed an explicit state path equal to the config; New: basename equality derives a per-file .state.json sibling and explicit paths that resolve/alias to the config are rejected with a clear error.

Review and rollout

  • In packages/opencode/src/core/account-paths.ts: deriveStatePath compares basename; new accountPathsCollide detects equality via resolve, realpath, and case-insensitive comparisons on Windows/macOS; getAccountStatePath uses it and throws if the explicit path aliases the config. Tests added in account-paths.test.ts for case-only aliases, symlinks, and broken links. Note: hardlinks/bind mounts can still collide.
  • Behavior change: non-canonical config filenames now write state to <config>.state.json; previously shared state becomes orphaned and users may appear logged out. No migration.

Written for commit c592c09. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/core/account-paths.ts Outdated
Comment thread packages/opencode/src/core/account-paths.ts Outdated
The state path was derived with a suffix test, so any config filename ending in the canonical name resolved to the same sibling state file. Two distinct rosters in one directory would share one state document and overwrite each other's stored fields.\n\nMatch the basename exactly, reject explicit state paths that resolve to the config path, and compare common case-insensitive aliases on Windows and macOS. Resolve realpath identities when available, falling back cleanly for absent files. Path identity remains defense-in-depth only: hardlinks and bind mounts cannot be distinguished this way. A custom filename now relocates its state to its own .state.json sibling; no migration is offered because the previously shared state cannot be safely attributed back to either roster.
@iceteaSA
iceteaSA force-pushed the fix/account-path-alias branch from 87500d5 to c592c09 Compare August 19, 2026 06:44
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Updated to c592c09 (force-push; previous head 87500d5 preserved on our side).

Both Cubic findings accepted, each with one adjustment to the proposed remedy.

Case-only aliases. Fixed, but not gated on win32 alone — case-insensitivity is a filesystem property, not an OS one, and macOS ships APFS case-insensitive by default, so a win32-only check would leave the bug reachable on darwin. Case-folding now applies on both. It errs strict: a case-SENSITIVE macOS volume will now report a collision that is not real, costing a file rename. We preferred that to failing open on the destructive case, but it is a heuristic rather than a filesystem probe.

Symlink aliasing. Fixed, with a constraint the suggestion did not account for: realpath requires existence, and the state file usually does not exist on first run, so a naive version throws ENOENT and breaks startup for every new user. The guard now compares lexically first, then filesystem identity where possible — realpathSync.native for existing files, realpathed parent directories when the file is absent — with a clean fallback, and only when the explicit override is set.

Since getAccountStatePath is on the startup path for five storage callers, an unexpected throw there would be worse than the bug being fixed. That was verified by probing rather than by reading the catch: 8/8 against the live module, covering missing files in missing parents, a broken symlink target, an unreadable mount, an unsearchable parent directory, empty strings, and an end-to-end call with a hostile config path.

Stated in-source rather than implied: this is defense-in-depth, not proof of distinctness — hardlinks and bind mounts defeat any pathname-based check.

One process note worth recording, since it affected what the evidence is worth. The first attempt at mutation proof was invalid: both new tests import the new accountPathsCollide helper, so reverting the source made the test file fail at import — a large red that proves only that the tests need the new export. It was redone with mutations that keep the API and change only behaviour (defeat case-folding → expected true, received false; defeat identity → expected throw, received the explicit path), each reddening only its own test.

A wiring test was also added: both original tests called the helper directly, so the call at account-paths.ts:95 could have been deleted with the suite staying green.

Gate: 1069 pass / 0 fail (1064 on 9bf8f4c), tsc clean. Re-reviewed after the fix: APPROVE 0 must / 0 should.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinct account config files can resolve to the same state file

1 participant