From c592c0905accf1eb03489344271b8150c31bb166 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:06:38 +0200 Subject: [PATCH] fix(core): stop distinct account files sharing one state document 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. --- packages/opencode/src/core/account-paths.ts | 60 +++++++++++- .../opencode/src/tests/account-paths.test.ts | 93 +++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/tests/account-paths.test.ts diff --git a/packages/opencode/src/core/account-paths.ts b/packages/opencode/src/core/account-paths.ts index 93ade33..a1704a6 100644 --- a/packages/opencode/src/core/account-paths.ts +++ b/packages/opencode/src/core/account-paths.ts @@ -1,5 +1,6 @@ +import { realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' /** * Where the account store lives. @@ -37,13 +38,66 @@ export function getAccountStoragePath() { /** Derive the state-file path from the config path without reading env vars. */ export function deriveStatePath(configPath: string): string { - return configPath.endsWith(ACCOUNT_FILE_NAME) + return basename(configPath) === ACCOUNT_FILE_NAME ? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME) : `${configPath}.state.json` } +function normalizePathForComparison(path: string, platform: NodeJS.Platform) { + const resolved = resolve(path) + return platform === 'win32' || platform === 'darwin' + ? resolved.toLowerCase() + : resolved +} + +function realpathForComparison(path: string): string | undefined { + try { + return realpathSync.native(path) + } catch { + try { + return join(realpathSync.native(dirname(path)), basename(path)) + } catch { + return undefined + } + } +} + +/** + * Detect path aliases without requiring either file to exist. This is defense + * in depth: hardlinks and bind mounts can still make distinct paths share a + * file, because neither is distinguishable through pathname identity. + */ +export function accountPathsCollide( + configPath: string, + statePath: string, + platform: NodeJS.Platform = process.platform, +) { + if ( + normalizePathForComparison(configPath, platform) === + normalizePathForComparison(statePath, platform) + ) { + return true + } + + const configIdentity = realpathForComparison(configPath) + const stateIdentity = realpathForComparison(statePath) + return ( + configIdentity !== undefined && + stateIdentity !== undefined && + normalizePathForComparison(configIdentity, platform) === + normalizePathForComparison(stateIdentity, platform) + ) +} + export function getAccountStatePath(configPath = getAccountStoragePath()) { const explicit = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE?.trim() - if (explicit) return explicit + if (explicit) { + if (accountPathsCollide(configPath, explicit)) { + throw new Error( + `OPENCODE_OPENAI_AUTH_STATE_FILE resolves to the config path (${resolve(configPath)}). Set OPENCODE_OPENAI_AUTH_STATE_FILE to a different file.`, + ) + } + return explicit + } return deriveStatePath(configPath) } diff --git a/packages/opencode/src/tests/account-paths.test.ts b/packages/opencode/src/tests/account-paths.test.ts new file mode 100644 index 0000000..fbf9afb --- /dev/null +++ b/packages/opencode/src/tests/account-paths.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'bun:test' +import { mkdirSync, mkdtempSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + accountPathsCollide, + deriveStatePath, + getAccountStatePath, +} from '../core/account-paths.ts' + +describe('account path resolution', () => { + it('derives distinct state paths for distinct config filenames', () => { + const directory = '/tmp/account-paths' + + expect(deriveStatePath(join(directory, 'openai-auth.json'))).toBe( + join(directory, 'openai-auth-state.json'), + ) + expect(deriveStatePath(join(directory, 'team-openai-auth.json'))).toBe( + join(directory, 'team-openai-auth.json.state.json'), + ) + }) + + it('refuses an explicit state path that resolves to the config path', () => { + const previousStatePath = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + const configPath = '/tmp/account-paths/roster.json' + + try { + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = + '/tmp/account-paths/./nested/../roster.json' + + expect(() => getAccountStatePath(configPath)).toThrow( + /OPENCODE_OPENAI_AUTH_STATE_FILE.*different file/, + ) + + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = + '/tmp/account-paths/roster-state.json' + expect(getAccountStatePath(configPath)).toBe( + '/tmp/account-paths/roster-state.json', + ) + } finally { + if (previousStatePath === undefined) { + delete process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + } else { + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = previousStatePath + } + } + }) + + it('treats case-only aliases as equal on common case-insensitive platforms', () => { + const root = mkdtempSync(join(tmpdir(), 'account-paths-case-')) + const configPath = join(root, 'auth.json') + const explicitStatePath = join(root, 'AUTH.JSON') + + expect(accountPathsCollide(configPath, explicitStatePath, 'darwin')).toBe( + true, + ) + }) + + it('falls back safely when a symlink target is broken', () => { + const root = mkdtempSync(join(tmpdir(), 'account-paths-broken-link-')) + const brokenPath = join(root, 'broken.json') + symlinkSync(join(root, 'missing.json'), brokenPath) + + expect( + accountPathsCollide(join(root, 'auth.json'), brokenPath, 'linux'), + ).toBe(false) + }) + + it('wires symlink aliases through the state-path entry point', () => { + const previousStatePath = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + const root = mkdtempSync(join(tmpdir(), 'account-paths-wiring-')) + const realDirectory = join(root, 'real') + const aliasDirectory = join(root, 'alias') + const configPath = join(aliasDirectory, 'auth.json') + const explicitStatePath = join(realDirectory, 'auth.json') + mkdirSync(realDirectory) + symlinkSync(realDirectory, aliasDirectory, 'dir') + + try { + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = explicitStatePath + expect(() => getAccountStatePath(configPath)).toThrow( + /OPENCODE_OPENAI_AUTH_STATE_FILE.*different file/, + ) + } finally { + if (previousStatePath === undefined) { + delete process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + } else { + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = previousStatePath + } + } + }) +})