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 + } + } + }) +})