diff --git a/config/scripts/bun-profile-change-scope.mjs b/config/scripts/bun-profile-change-scope.mjs index dc464a5dc030..b136876bb6eb 100644 --- a/config/scripts/bun-profile-change-scope.mjs +++ b/config/scripts/bun-profile-change-scope.mjs @@ -19,7 +19,8 @@ const BUILD_SCRIPTS = [ 'config/vitest.config.ts', 'config/scripts/happy-dom-offscreen-canvas.ts', 'config/scripts/happy-dom-mutation-observer-retention.ts', - 'config/scripts/vitest-host-ports-setup.ts' + 'config/scripts/vitest-host-ports-setup.ts', + 'config/scripts/vitest-real-agent-home-write-guard.ts' ] const ALWAYS_FILES = new Set([ 'package.json', diff --git a/config/scripts/vitest-real-agent-home-write-guard.test.ts b/config/scripts/vitest-real-agent-home-write-guard.test.ts new file mode 100644 index 000000000000..6a98c4aa5771 --- /dev/null +++ b/config/scripts/vitest-real-agent-home-write-guard.test.ts @@ -0,0 +1,84 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { homedir, tmpdir, userInfo } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { takeRealAgentHomeWriteViolations } from './vitest-real-agent-home-write-guard' + +// Why never-created paths: each sits under a missing folder or is a forced no-op removal, so even +// with the guard off (the ablation) nothing lands in the real home. +const missingRealFolder = (entry: string) => + join(userInfo().homedir, entry, `orca-vitest-guard-${randomUUID()}`) + +afterEach(() => { + takeRealAgentHomeWriteViolations() + vi.unstubAllEnvs() +}) + +describe('vitest real-agent-home write guard', () => { + it('refuses a named-import sync write under the real ~/.codex', () => { + const target = join(missingRealFolder('.codex'), 'config.toml') + expect(() => writeFileSync(target, '[projects."/tmp/x"]\n')).toThrow(/real-agent-home guard/) + expect(existsSync(target)).toBe(false) + }) + + it('refuses a promise write under a ~/.claude.json sibling', async () => { + const target = join(missingRealFolder('.claude.json'), 'x.tmp') + await expect(writeFile(target, '{}')).rejects.toThrow(/real-agent-home guard/) + expect(existsSync(target)).toBe(false) + }) + + it('refuses a removal that would otherwise succeed silently', () => { + expect(() => rmSync(missingRealFolder('.orca'), { recursive: true, force: true })).toThrow( + /real-agent-home guard/ + ) + }) + + it('still records a refusal the writer swallowed, so the test fails afterwards', () => { + try { + mkdirSync(join(missingRealFolder('.cursor'), 'projects')) + } catch { + // Trust writers swallow their errors like this. + } + expect(takeRealAgentHomeWriteViolations()).toEqual([expect.stringContaining('fs.mkdirSync')]) + }) + + it('protects the account home even when HOME points elsewhere', () => { + const tempHome = mkdtempSync(join(tmpdir(), 'orca-vitest-guard-home-')) + vi.stubEnv('HOME', tempHome) + vi.stubEnv('USERPROFILE', tempHome) + try { + expect(homedir()).not.toBe(userInfo().homedir) + expect(() => rmSync(missingRealFolder('.copilot'), { force: true })).toThrow( + /real-agent-home guard/ + ) + } finally { + rmSync(tempHome, { recursive: true, force: true }) + } + }) + + it('leaves a sibling that only shares an agent folder prefix alone', () => { + const sibling = join(userInfo().homedir, `.codex-orca-vitest-guard-${randomUUID()}`) + expect(() => rmSync(sibling, { force: true })).not.toThrow() + }) + + it('stands down while an opted-in real-agent suite switch is set', () => { + vi.stubEnv('ORCA_CODEX_TRUST_CONTRACT_BINARY', '/opt/codex') + expect(() => rmSync(missingRealFolder('.codex'), { force: true })).not.toThrow() + }) + + it('allows reads there and writes under a temp home', () => { + expect(() => openSync(join(missingRealFolder('.codex'), 'config.toml'), 'r')).toThrow(/ENOENT/) + const tempHome = mkdtempSync(join(tmpdir(), 'orca-vitest-guard-home-')) + try { + const target = join(tempHome, '.codex', 'config.toml') + mkdirSync(join(tempHome, '.codex')) + writeFileSync(target, 'x') + expect(existsSync(target)).toBe(true) + } finally { + rmSync(tempHome, { recursive: true, force: true }) + } + expect(takeRealAgentHomeWriteViolations()).toEqual([]) + }) +}) diff --git a/config/scripts/vitest-real-agent-home-write-guard.ts b/config/scripts/vitest-real-agent-home-write-guard.ts new file mode 100644 index 000000000000..40c19fbcabc7 --- /dev/null +++ b/config/scripts/vitest-real-agent-home-write-guard.ts @@ -0,0 +1,212 @@ +import fs from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' +import { userInfo } from 'node:os' +import { isAbsolute, join, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, afterEach } from 'vitest' + +/** + * Why: agent trust and hook writers resolve `~` at write time, so a unit test that reaches one + * edits the developer's real Codex, Claude or Orca config. Any write there fails the test instead. + * Child processes a test spawns are outside this guard. + */ + +type GuardState = { roots: string[]; violations: string[] } + +const AGENT_HOME_ENTRIES = [ + '.codex', + '.claude', + '.orca', + '.cursor', + '.copilot', + '.gemini', + '.qoder' +] +// Why a bare prefix: Claude's config file and its atomic-write temp siblings sit beside `~/.claude`. +const AGENT_HOME_FILE_PREFIXES = ['.claude.json'] +// Why: an Orca terminal exports these pointing at the live app and agent state; guarded, then unset. +const INHERITED_STATE_ENV = [ + 'ORCA_USER_DATA_PATH', + 'CODEX_HOME', + 'ORCA_CODEX_HOME', + 'CLAUDE_CONFIG_DIR' +] +// Why: an Orca terminal points this at the live app's CLI, which a test's child shell would run. +const INHERITED_LIVE_CLI_ENV = ['ORCA_CODEX_LAUNCH_PREFLIGHT'] +// Why: opted-in real-agent suites point a real CLI at its own home on purpose; each suite's own switch. +const REAL_AGENT_SUITE_SWITCHES = [ + 'ORCA_REAL_CLAUDE_SUPERVISED_STOP', + 'ORCA_CODEX_CONTRACT_BINARY', + 'ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY', + 'ORCA_CODEX_TRUST_CONTRACT_BINARY', + 'ORCA_REPRO_CODEX_BINARY' +] +const realAgentSuiteOptedIn = () => REAL_AGENT_SUITE_SWITCHES.some((name) => process.env[name]) +const foldCase = process.platform === 'darwin' || process.platform === 'win32' + +function normalize(path: string): string { + const resolved = resolve(path) + return foldCase ? resolved.toLowerCase() : resolved +} + +function realHomes(): string[] { + const homes = [process.env.HOME, process.env.USERPROFILE] + try { + // Why: HOME does not move this one, so a test that swaps HOME cannot hide the real home. + homes.unshift(userInfo().homedir) + } catch { + // No passwd entry (some containers): HOME is all there is. + } + return homes.filter((home): home is string => Boolean(home)) +} + +/** Folder roots end in a separator so `~/.orca` never covers `~/.orca-relay`; file prefixes do not. */ +function protectedRoots(): string[] { + const folders = realHomes().flatMap((home) => [ + ...AGENT_HOME_ENTRIES.map((entry) => join(home, entry)), + join(home, 'Library', 'Application Support', 'orca'), + join(home, '.config', 'orca'), + join(home, 'AppData', 'Roaming', 'orca') + ]) + for (const name of INHERITED_STATE_ENV) { + const value = process.env[name] + if (value && isAbsolute(value)) { + folders.push(value) + } + } + const filePrefixes = realHomes().flatMap((home) => + AGENT_HOME_FILE_PREFIXES.map((entry) => normalize(join(home, entry))) + ) + return [...new Set([...folders.map((folder) => normalize(folder) + sep), ...filePrefixes])] +} + +function targetPath(target: unknown): string | null { + if (typeof target === 'string') { + return target.startsWith('file:') ? fileURLToPath(target) : target + } + if (target instanceof URL) { + return fileURLToPath(target) + } + if (Buffer.isBuffer(target)) { + return target.toString() + } + return null +} + +function assertOutsideRealHome(state: GuardState, operation: string, target: unknown): void { + const path = targetPath(target) + if (path === null || realAgentSuiteOptedIn()) { + return + } + const normalized = normalize(path) + sep + const root = state.roots.find((candidate) => normalized.startsWith(candidate)) + if (!root) { + return + } + const error = new Error( + `[vitest real-agent-home guard] ${operation}(${path}) would write the real agent home under ${root}; give the test a temp home` + ) + // Why the stack: the writer that swallowed this is what the failing test has to fix. + state.violations.push(error.stack?.split('\n').slice(0, 12).join('\n') ?? error.message) + throw error +} + +const WRITE_OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT + +function opensForWrite(flags: unknown): boolean { + if (typeof flags === 'number') { + return (flags & WRITE_OPEN_FLAGS) !== 0 + } + return typeof flags === 'string' && /[wa+]/.test(flags) +} + +// Argument positions each mutating call writes to; `open` counts only when its flags write. +const GUARDED_TARGETS: Record = { + writeFile: [0], + appendFile: [0], + mkdir: [0], + mkdtemp: [0], + rename: [0, 1], + copyFile: [1], + cp: [1], + symlink: [1], + link: [1], + rm: [0], + rmdir: [0], + unlink: [0], + truncate: [0], + open: [0] +} + +function guard( + state: GuardState, + api: typeof fs | typeof fs.promises, + name: string, + positions: readonly number[], + label: string +): void { + const descriptor = Object.getOwnPropertyDescriptor(api, name) + const original: unknown = descriptor?.value + if (!descriptor || typeof original !== 'function') { + return + } + const target = original + function guarded(this: unknown, ...args: unknown[]): unknown { + try { + if (!name.startsWith('open') || opensForWrite(args[1] ?? 'r')) { + for (const position of positions) { + assertOutsideRealHome(state, label, args[position]) + } + } + } catch (error) { + if (api === fs.promises) { + return Promise.reject(error) + } + throw error + } + return target.apply(this, args) + } + Object.defineProperty(api, name, { ...descriptor, value: guarded }) +} + +function install(): GuardState { + const state: GuardState = { roots: protectedRoots(), violations: [] } + for (const [name, positions] of Object.entries(GUARDED_TARGETS)) { + guard(state, fs, name, positions, `fs.${name}`) + guard(state, fs, `${name}Sync`, positions, `fs.${name}Sync`) + guard(state, fs.promises, name, positions, `fs.promises.${name}`) + } + guard(state, fs, 'createWriteStream', [0], 'fs.createWriteStream') + // Why: named ESM imports of node:fs keep the originals until the builtin exports are re-synced. + syncBuiltinESMExports() + return state +} + +declare global { + // Why once per process: re-wrapping per test file would stack wrappers around wrappers. + var orcaRealAgentHomeWriteGuard: GuardState | undefined +} +const state = (globalThis.orcaRealAgentHomeWriteGuard ??= install()) +// Why after install: the guard keeps the inherited paths as roots; tests that need one set their own. +const inheritedEnvToUnset = realAgentSuiteOptedIn() + ? [] + : [...INHERITED_STATE_ENV, ...INHERITED_LIVE_CLI_ENV] +for (const name of inheritedEnvToUnset) { + delete process.env[name] +} + +/** Drains recorded violations; only the guard's own self-test should need this. */ +export function takeRealAgentHomeWriteViolations(): string[] { + return state.violations.splice(0) +} + +function failOnViolations(): void { + const violations = takeRealAgentHomeWriteViolations() + if (violations.length > 0) { + // Why rethrow here: trust writers swallow errors, so the throw at the call site can vanish. + throw new Error(`[vitest real-agent-home guard]\n${violations.join('\n')}`) + } +} + +afterEach(failOnViolations) +afterAll(failOnViolations) diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 0d3077d8f0e1..ff4e9a762764 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -5,6 +5,7 @@ "./build-plugins/**/*", "./scripts/vitest-host-ports-setup.ts", "./scripts/vitest-caller-identity-env-setup.ts", + "./scripts/vitest-real-agent-home-write-guard.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index d2a586169340..1f385dd7e5ab 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. setupFiles: [ + resolve('config/scripts/vitest-real-agent-home-write-guard.ts'), resolve('config/scripts/happy-dom-offscreen-canvas.ts'), resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), resolve('config/scripts/vitest-host-ports-setup.ts'), diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index c18499a5ae22..e956494fc763 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -31,15 +31,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:os', async () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - const actual = await vi.importActual('node:os') - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - const { markAntigravityWorkspaceTrusted, markCodexProjectTrusted, @@ -84,7 +75,7 @@ describe('markCursorWorkspaceTrusted', () => { it('writes ~/.cursor/projects//.workspace-trusted with the cwd payload', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) try { - markCursorWorkspaceTrusted(workspace) + markCursorWorkspaceTrusted(workspace, testState.fakeHomeDir) const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') const slugDirs = readdirSync(projectsDir) expect(slugDirs.length).toBe(1) @@ -101,12 +92,12 @@ describe('markCursorWorkspaceTrusted', () => { it('is idempotent — re-marking the same workspace does not overwrite trustedAt', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) try { - markCursorWorkspaceTrusted(workspace) + markCursorWorkspaceTrusted(workspace, testState.fakeHomeDir) const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') const slugDirs = readdirSync(projectsDir) const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted') const firstPayload = readFileSync(trustFile, 'utf-8') - markCursorWorkspaceTrusted(workspace) + markCursorWorkspaceTrusted(workspace, testState.fakeHomeDir) const secondPayload = readFileSync(trustFile, 'utf-8') expect(secondPayload).toBe(firstPayload) } finally { @@ -119,7 +110,7 @@ describe('markCopilotFolderTrusted', () => { it('appends the workspace to trustedFolders in ~/.copilot/config.json', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) try { - markCopilotFolderTrusted(workspace) + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') expect(existsSync(configPath)).toBe(true) const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) @@ -143,7 +134,7 @@ describe('markCopilotFolderTrusted', () => { trustedFolders: [realpath] }) ) - markCopilotFolderTrusted(workspace) + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) const parsed = JSON.parse( readFileSync(join(testState.fakeHomeDir, '.copilot', 'config.json'), 'utf-8') ) @@ -159,7 +150,7 @@ describe('markAntigravityWorkspaceTrusted', () => { it('appends the workspace to trustedWorkspaces in ~/.gemini/antigravity-cli/settings.json', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-agy-ws-')) try { - markAntigravityWorkspaceTrusted(workspace) + markAntigravityWorkspaceTrusted(workspace, testState.fakeHomeDir) const configPath = join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json') expect(existsSync(configPath)).toBe(true) const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) @@ -186,7 +177,7 @@ describe('markAntigravityWorkspaceTrusted', () => { trustedWorkspaces: [realpath] }) ) - markAntigravityWorkspaceTrusted(workspace) + markAntigravityWorkspaceTrusted(workspace, testState.fakeHomeDir) const parsed = JSON.parse( readFileSync( join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'), @@ -208,8 +199,8 @@ describe('markAntigravityWorkspaceTrusted', () => { const child = join(parent, 'child-worktree') try { mkdirSync(child, { recursive: true }) - markAntigravityWorkspaceTrusted(parent) - markAntigravityWorkspaceTrusted(child) + markAntigravityWorkspaceTrusted(parent, testState.fakeHomeDir) + markAntigravityWorkspaceTrusted(child, testState.fakeHomeDir) const parsed = JSON.parse( readFileSync( join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'), @@ -237,7 +228,10 @@ describe('markCodexProjectTrusted', () => { }) try { const held = runExclusivelyForCodexTrustConfig(configPath, () => grantHoldingTheFile) - const marked = markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) + const marked = markCodexProjectTrusted( + workspace, + getLocalCodexTrustConfigFiles(testState.fakeHomeDir) + ) await Promise.resolve() expect(existsSync(configPath)).toBe(false) @@ -283,7 +277,7 @@ describe('markCodexProjectTrusted', () => { } async function expectWorkspaceTrusted(workspace: string): Promise { - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir)) const runtimeHome = join(testState.userDataDir, 'codex-runtime-home', 'home') const [system, runtime] = [ join(testState.fakeHomeDir, '.codex', 'config.toml'), @@ -377,7 +371,7 @@ describe('markCodexProjectTrusted', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { const realpath = realpathSync.native(workspace) - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir)) const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const runtimeConfigPath = join( testState.userDataDir, @@ -431,7 +425,7 @@ describe('markCodexProjectTrusted', () => { 'utf-8' ) - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir)) const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8') const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8') diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index ffd1ca6b89a7..48f4c17ccb3d 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,5 +1,4 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' -import { homedir } from 'node:os' import { join } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' import { upsertProjectTrustLevel } from './codex/config-toml-trust' @@ -37,13 +36,13 @@ export type AgentTrustPreset = NonNullable * (versions/2026.04.17-787b533/index.ts: `_=".workspace-trusted"`, slug * derived via the same util that resolves `~/.cursor/projects/`). */ -export function markCursorWorkspaceTrusted(workspacePath: string): void { +export function markCursorWorkspaceTrusted(workspacePath: string, home: string): void { const absPath = canonicalize(workspacePath) const slug = cursorWorkspaceSlug(absPath) if (!slug) { return } - const trustDir = join(homedir(), '.cursor', 'projects', slug) + const trustDir = join(home, '.cursor', 'projects', slug) const trustFile = join(trustDir, '.workspace-trusted') if (existsSync(trustFile)) { return @@ -67,9 +66,9 @@ export function markCursorWorkspaceTrusted(workspacePath: string): void { * We append to the array in-place so unrelated config keys (loggedInUsers, * copilotTokens, etc.) survive untouched. */ -export function markCopilotFolderTrusted(workspacePath: string): void { +export function markCopilotFolderTrusted(workspacePath: string, home: string): void { const absPath = canonicalize(workspacePath) - const configDir = join(homedir(), '.copilot') + const configDir = join(home, '.copilot') const configPath = join(configDir, 'config.json') let config: Record = {} try { @@ -121,9 +120,9 @@ export function markCopilotFolderTrusted(workspacePath: string): void { * We append in-place so the sibling keys in the same file (model, permissions, * toolPermission, agentMode, …) survive untouched. */ -export function markAntigravityWorkspaceTrusted(workspacePath: string): void { +export function markAntigravityWorkspaceTrusted(workspacePath: string, home: string): void { const absPath = canonicalize(workspacePath) - const configDir = join(homedir(), '.gemini', 'antigravity-cli') + const configDir = join(home, '.gemini', 'antigravity-cli') const configPath = join(configDir, 'settings.json') let config: Record = {} try { diff --git a/src/main/agent-workspace-trust.test.ts b/src/main/agent-workspace-trust.test.ts index 2ed9d1d2a156..8aead85ff78e 100644 --- a/src/main/agent-workspace-trust.test.ts +++ b/src/main/agent-workspace-trust.test.ts @@ -16,10 +16,11 @@ const CODEX_CONFIG_FILES = ['/orca/codex-home/config.toml', '/home/u/.codex/conf const mocks = vi.hoisted(() => ({ codex: vi.fn<(path: string, configFiles: readonly string[]) => Promise>(async () => {}), - cursor: vi.fn<(path: string) => void>(), - copilot: vi.fn<(path: string) => void>(), - antigravity: vi.fn<(path: string) => void>(), - qoder: vi.fn<(path: string) => void>(), + cursor: vi.fn<(path: string, home: string) => void>(), + copilot: vi.fn<(path: string, home: string) => void>(), + antigravity: vi.fn<(path: string, home: string) => void>(), + qoder: vi.fn<(path: string, home: string) => void>(), + codexConfigFiles: vi.fn<(agentHome: string) => string[]>(() => CODEX_CONFIG_FILES), claudeGrant: vi.fn() })) @@ -30,7 +31,7 @@ vi.mock('./agent-trust-presets', () => ({ markAntigravityWorkspaceTrusted: mocks.antigravity })) vi.mock('./codex/codex-home-paths', () => ({ - getLocalCodexTrustConfigFiles: () => CODEX_CONFIG_FILES + getLocalCodexTrustConfigFiles: mocks.codexConfigFiles })) vi.mock('./qoder/workspace-trust', () => ({ markQoderWorkspaceTrusted: mocks.qoder })) vi.mock('./claude/claude-folder-trust-file', async (importOriginal) => { @@ -82,10 +83,21 @@ describe('applyAgentWorkspaceTrust on this machine', () => { await expect(applyAgentWorkspaceTrust(preset, WORKSPACE, local)).resolves.toEqual({}) expect(writer).toHaveBeenCalledWith( WORKSPACE, - ...(preset === 'codex' ? [CODEX_CONFIG_FILES] : []) + preset === 'codex' ? CODEX_CONFIG_FILES : homedir() ) }) + it('writes per-user trust under the home the launch env names, where the agent reads it', async () => { + const context = { ...local, env: { HOME: '/home/agent', USERPROFILE: '/home/agent' } } + for (const preset of ['codex', 'cursor', 'copilot', 'qoder', 'antigravity'] as const) { + await applyAgentWorkspaceTrust(preset, WORKSPACE, context) + } + expect(mocks.codexConfigFiles).toHaveBeenCalledWith('/home/agent') + for (const writer of [mocks.cursor, mocks.copilot, mocks.qoder, mocks.antigravity]) { + expect(writer).toHaveBeenCalledWith(WORKSPACE, '/home/agent') + } + }) + it('contains a rejected or throwing write so the launch proceeds', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) mocks.codex.mockRejectedValueOnce(new Error('write failed')) @@ -144,8 +156,8 @@ describe('applyAgentWorkspaceTrust on this machine', () => { await applyAgentWorkspaceTrust(preset, homedir(), noConfig) } expect(mocks.codex).toHaveBeenCalledWith(homedir(), CODEX_CONFIG_FILES) - expect(mocks.cursor).toHaveBeenCalledWith(homedir()) - expect(mocks.antigravity).toHaveBeenCalledWith(homedir()) + expect(mocks.cursor).toHaveBeenCalledWith(homedir(), homedir()) + expect(mocks.antigravity).toHaveBeenCalledWith(homedir(), homedir()) }) it('never pre-trusts a home reached through a symlink, since the writers store the realpath', async () => { diff --git a/src/main/agent-workspace-trust.ts b/src/main/agent-workspace-trust.ts index 58d39b361225..0fc52991e3c2 100644 --- a/src/main/agent-workspace-trust.ts +++ b/src/main/agent-workspace-trust.ts @@ -8,7 +8,7 @@ import { resolveLocalClaudeTrustConfig } from './claude/claude-folder-trust-file import type { ClaudeRuntimeAuthPreparation } from './claude-accounts/runtime-auth/runtime-auth-types' import type { AgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request' import { parseWslUncPath } from '../shared/wsl-paths' -import { applyWorkspaceTrustOnThisHost } from './execution-host-workspace-trust' +import { applyWorkspaceTrustOnThisHost, launchedAgentHome } from './execution-host-workspace-trust' import { getLocalCodexTrustConfigFiles } from './codex/codex-home-paths' import { getCachedWslHome } from './wsl-home-cache' @@ -62,19 +62,23 @@ export async function applyAgentWorkspaceTrust( if (preset !== 'claude' && isWslLaunch(workspacePath, context)) { return {} } - await applyWorkspaceTrustOnThisHost(preset, workspacePath, () => ({ - homes: localHomePaths(workspacePath, context), - claudeConfig: () => - resolveLocalClaudeTrustConfig({ - workspacePath, - env: { ...process.env, ...context.env }, - claudeAuth: context.claudeAuth, - wslDistro: context.wslDistro - }), - codexConfigFiles: getLocalCodexTrustConfigFiles, - // Why: Codex queues behind a config lane it shares with Orca's hook installs. - deadlineMs: - preset === 'codex' ? AGENT_TRUST_WRITE_DEADLINE_MS : SHORT_AGENT_TRUST_WRITE_DEADLINE_MS - })) + await applyWorkspaceTrustOnThisHost(preset, workspacePath, () => { + const agentHome = launchedAgentHome(context.env) + return { + homes: localHomePaths(workspacePath, context), + agentHome, + claudeConfig: () => + resolveLocalClaudeTrustConfig({ + workspacePath, + env: { ...process.env, ...context.env }, + claudeAuth: context.claudeAuth, + wslDistro: context.wslDistro + }), + codexConfigFiles: () => getLocalCodexTrustConfigFiles(agentHome), + // Why: Codex queues behind a config lane it shares with Orca's hook installs. + deadlineMs: + preset === 'codex' ? AGENT_TRUST_WRITE_DEADLINE_MS : SHORT_AGENT_TRUST_WRITE_DEADLINE_MS + } + }) return {} } diff --git a/src/main/codex/codex-ai-vault-session-resume.test.ts b/src/main/codex/codex-ai-vault-session-resume.test.ts index 172b4f34ec9a..a6376c2ff17e 100644 --- a/src/main/codex/codex-ai-vault-session-resume.test.ts +++ b/src/main/codex/codex-ai-vault-session-resume.test.ts @@ -5,6 +5,17 @@ import { dirname, join } from 'node:path' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { prepareCodexAiVaultSessionResume } from './codex-ai-vault-session-resume' +// Why: session backfill resolves Orca's managed Codex home from userData, which otherwise resolves to the live one. +let userDataRoot: string +beforeEach(() => { + userDataRoot = mkdtempSync(join(tmpdir(), 'orca-codex-resume-user-data-')) + vi.stubEnv('ORCA_USER_DATA_PATH', userDataRoot) +}) +afterEach(() => { + vi.unstubAllEnvs() + rmSync(userDataRoot, { recursive: true, force: true }) +}) + describe('prepareCodexAiVaultSessionResume', () => { let root: string let peerHome: string diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index c76aee5c1ea9..e838d11fe6b2 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -48,10 +48,10 @@ export function getOrcaManagedCodexHomePath(): string { } /** Config files an Orca-launched local Codex reads trust from, in the hook installer's lock order. */ -export function getLocalCodexTrustConfigFiles(): string[] { +export function getLocalCodexTrustConfigFiles(agentHome: string): string[] { return [ join(getOrcaManagedCodexHomePath(), 'config.toml'), - join(getSystemCodexHomePath(), 'config.toml') + join(agentHome, '.codex', 'config.toml') ] } diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts index a895653b0479..d4abb7d7cf54 100644 --- a/src/main/codex/codex-legacy-session-resume.test.ts +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -41,6 +41,17 @@ vi.mock('node:fs/promises', async (importOriginal) => { } }) +// Why: session backfill resolves Orca's managed Codex home from userData, which otherwise resolves to the live one. +let userDataRoot: string +beforeEach(() => { + userDataRoot = mkdtempSync(join(tmpdir(), 'orca-codex-resume-user-data-')) + vi.stubEnv('ORCA_USER_DATA_PATH', userDataRoot) +}) +afterEach(() => { + vi.unstubAllEnvs() + rmSync(userDataRoot, { recursive: true, force: true }) +}) + describe('prepareLegacySharedCodexSessionResume', () => { let root: string let legacyHome: string diff --git a/src/main/codex/codex-trust-config-concurrent-launch.test.ts b/src/main/codex/codex-trust-config-concurrent-launch.test.ts index 0159ce608d95..0132c4b267b1 100644 --- a/src/main/codex/codex-trust-config-concurrent-launch.test.ts +++ b/src/main/codex/codex-trust-config-concurrent-launch.test.ts @@ -208,7 +208,10 @@ describe('two Codex pane launches against one config.toml', () => { // Let the grant capture config.toml and start its session. await tick() await tick() - const marked = markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) + const marked = markCodexProjectTrusted( + workspace, + getLocalCodexTrustConfigFiles(testState.fakeHomeDir) + ) await tick() // The lane must hold the preset write back until rollback has run. expect(readFileSync(tomlPath, 'utf-8')).not.toContain('trust_level') @@ -398,7 +401,10 @@ describe('reentrancy under concurrency', () => { // write nested inside both. const outcome = await runExclusivelyForCodexTrustConfig(tomlPath, () => runExclusivelyForCodexTrustConfig(systemToml, async () => { - await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) + await markCodexProjectTrusted( + workspace, + getLocalCodexTrustConfigFiles(testState.fakeHomeDir) + ) return grantManagedCodexHookTrust(buildPlan(entries)) }) ) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index c052ad2f158a..ca70d40feadb 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -31,7 +31,15 @@ type HooksConfig = { let tempRoots: string[] = [] +beforeEach(() => { + // Why: the trust-grant ledger lives in Orca's userData, which otherwise resolves to the live one. + const userData = mkdtempSync(join(tmpdir(), 'orca-codex-wsl-hooks-userdata-')) + tempRoots.push(userData) + vi.stubEnv('ORCA_USER_DATA_PATH', userData) +}) + afterEach(() => { + vi.unstubAllEnvs() for (const root of tempRoots) { rmSync(root, { recursive: true, force: true }) } diff --git a/src/main/execution-host-workspace-trust.test.ts b/src/main/execution-host-workspace-trust.test.ts index c544f351ce83..a9603190923b 100644 --- a/src/main/execution-host-workspace-trust.test.ts +++ b/src/main/execution-host-workspace-trust.test.ts @@ -59,6 +59,7 @@ afterEach(() => { function thisHost(overrides: Partial = {}): () => WorkspaceTrustHost { return () => ({ homes: [state.home], + agentHome: state.home, claudeConfig: () => ({ configFile: join(state.home, '.claude.json'), keyStyle: 'posix' }), codexConfigFiles: () => [join(state.home, '.codex', 'config.toml')], deadlineMs: 1_500, diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts index 42f48e918cee..52562e68064c 100644 --- a/src/main/execution-host-workspace-trust.ts +++ b/src/main/execution-host-workspace-trust.ts @@ -1,4 +1,5 @@ import { realpathSync } from 'node:fs' +import { homedir } from 'node:os' import { resolve } from 'node:path' import { markQoderWorkspaceTrusted } from './qoder/workspace-trust' import { @@ -22,6 +23,8 @@ import { isTooBroadToPreTrust } from '../shared/home-or-filesystem-root' export type WorkspaceTrustHost = { /** Homes the agent may read trust under; with none known, an agent that inherits trust writes nothing. */ homes: readonly (string | null | undefined)[] + /** The home the launched agent resolves `~` to, where the per-user trust files live. */ + agentHome: string /** The config Claude reads on this host, or null when this host cannot tell. */ claudeConfig: () => ClaudeTrustConfigTarget | null /** Every config.toml the launched Codex may read, in the hook installer's lock order. */ @@ -29,6 +32,13 @@ export type WorkspaceTrustHost = { deadlineMs: number } +/** The home an agent launched with `launchEnv` on this host resolves `~` to. */ +export function launchedAgentHome( + launchEnv: Record | undefined +): string { + return (process.platform === 'win32' ? launchEnv?.USERPROFILE : launchEnv?.HOME) || homedir() +} + /** * Whether trust on a home or a disk root would also trust the folders below it, per each agent's * own lookup; such trust is never pre-written. Claude walks up parent folders (to the repo root, @@ -93,13 +103,13 @@ async function writePreset( case 'codex': return markCodexProjectTrusted(storedPath, host.codexConfigFiles()) case 'cursor': - return markCursorWorkspaceTrusted(storedPath) + return markCursorWorkspaceTrusted(storedPath, host.agentHome) case 'copilot': - return markCopilotFolderTrusted(storedPath) + return markCopilotFolderTrusted(storedPath, host.agentHome) case 'qoder': - return markQoderWorkspaceTrusted(storedPath) + return markQoderWorkspaceTrusted(storedPath, host.agentHome) case 'antigravity': - return markAntigravityWorkspaceTrusted(storedPath) + return markAntigravityWorkspaceTrusted(storedPath, host.agentHome) } } diff --git a/src/main/qoder/hook-service.test.ts b/src/main/qoder/hook-service.test.ts index 3af282ecd767..954a545c2b95 100644 --- a/src/main/qoder/hook-service.test.ts +++ b/src/main/qoder/hook-service.test.ts @@ -42,7 +42,7 @@ describe('Qoder managed configuration', () => { } expect(installed.hooks.TeammateIdle).toBeUndefined() expect(installed.statusLine).toBeUndefined() - markQoderWorkspaceTrusted('/new-workspace') + markQoderWorkspaceTrusted('/new-workspace', sandbox.home) const trusted = JSON.parse(readFileSync(path, 'utf8')) expect(trusted.permissions.trustDirectories).toEqual(['/existing', '/new-workspace']) expect(trusted.hooks).toEqual(installed.hooks) @@ -67,7 +67,7 @@ describe('Qoder managed configuration', () => { const path = join(sandbox.home, '.qoder', 'settings.json') writeFileSync(path, '{broken') expect(qoderHookService.install().state).toBe('error') - markQoderWorkspaceTrusted('/new-workspace') + markQoderWorkspaceTrusted('/new-workspace', sandbox.home) expect(readFileSync(path, 'utf8')).toBe('{broken') expect(withQoderTrustedWorkspace({ permissions: 'invalid' }, '/workspace')).toBeNull() expect( diff --git a/src/main/qoder/workspace-trust.ts b/src/main/qoder/workspace-trust.ts index 8862a76dd75f..bb11aaebfcfb 100644 --- a/src/main/qoder/workspace-trust.ts +++ b/src/main/qoder/workspace-trust.ts @@ -1,5 +1,4 @@ import { realpathSync } from 'node:fs' -import { homedir } from 'node:os' import { join } from 'node:path' import { isPlainObject, readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils' @@ -25,14 +24,14 @@ export function withQoderTrustedWorkspace( } } -export function markQoderWorkspaceTrusted(workspacePath: string): void { +export function markQoderWorkspaceTrusted(workspacePath: string, home: string): void { let canonicalPath = workspacePath try { canonicalPath = realpathSync.native(workspacePath) } catch { /* Keep the supplied path when absent. */ } - const configPath = join(homedir(), '.qoder', 'settings.json') + const configPath = join(home, '.qoder', 'settings.json') const config = readHooksJson(configPath) if (!config) { return diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index 7c62d2ae01a5..b0978ae25f8d 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { buildClaudeAgentTeamsLaunchPlan, ensureClaudeAgentTeamsShimDir, @@ -14,6 +14,7 @@ import { const roots: string[] = [] afterEach(async () => { + vi.unstubAllEnvs() await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))) roots.length = 0 }) @@ -31,6 +32,9 @@ describe('claude agent teams shim env', () => { it('builds native shim env only for direct Claude commands', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) roots.push(root) + // Why: the plan writes its tmux shim under ~/.orca. + vi.stubEnv('HOME', root) + vi.stubEnv('USERPROFILE', root) const cliName = process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev' const cliPath = join(root, cliName) await writeFile(cliPath, '#!/usr/bin/env sh\n', 'utf8') diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts index cb2bc0249bc1..3b3a61546d97 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { OrcaRuntimeService, electronMocks, @@ -11,6 +14,14 @@ import { wireHeadlessServeRuntime } from '../orca-runtime-test-scenario-builders describe('OrcaRuntimeService', () => { it('restores captured native Claude Agent Teams mode with fresh service env', async () => { setPlatform('linux') + // Why: native panes write their tmux shim under ~/.orca. + const home = await mkdtemp(join(tmpdir(), 'orca-agent-teams-home-')) + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + onTestFinished(async () => { + vi.unstubAllEnvs() + await rm(home, { recursive: true, force: true }) + }) const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' }) const runtimeStore = { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 4748c7dd5a67..96568a93cc88 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -94,6 +94,10 @@ async function call(dispatcher: RpcDispatcher, params: unknown, client = CLIENT) beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-adoption-rpc-replay-')) + // Why: the adopting create pre-trusts its folder in ~/.codex and Orca's managed Codex home. + vi.stubEnv('HOME', join(root, 'home')) + vi.stubEnv('USERPROFILE', join(root, 'home')) + vi.stubEnv('ORCA_USER_DATA_PATH', join(root, 'user-data')) }) afterEach(async () => { @@ -102,6 +106,7 @@ afterEach(async () => { await host?.close(SESSION) await rm(root, { recursive: true, force: true }) vi.restoreAllMocks() + vi.unstubAllEnvs() }) describe('committed adopting create RPC replay', () => { diff --git a/src/relay/agent-workspace-trust-spawn.test.ts b/src/relay/agent-workspace-trust-spawn.test.ts index bc970bdf2d24..d7f20d2961c4 100644 --- a/src/relay/agent-workspace-trust-spawn.test.ts +++ b/src/relay/agent-workspace-trust-spawn.test.ts @@ -18,7 +18,7 @@ import { linkGitWorktree, workspaceTrustWritten } from '../main/workspace-trust- const state = vi.hoisted(() => ({ home: '' })) -// Why: the non-Claude writers resolve their files from this process's home, as on a real relay. +// Why: a spawn env without HOME leaves the writers on this process's home, as on a real relay. vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal() return { ...actual, homedir: () => state.home } diff --git a/src/relay/agent-workspace-trust-spawn.ts b/src/relay/agent-workspace-trust-spawn.ts index 51eaaa28485e..b8af0dc7fe75 100644 --- a/src/relay/agent-workspace-trust-spawn.ts +++ b/src/relay/agent-workspace-trust-spawn.ts @@ -6,7 +6,10 @@ import { TUI_AGENT_CONFIG } from '../shared/tui-agent-config' import type { TuiAgent } from '../shared/tui-agent' import { resolveClaudeGlobalConfigFile } from '../main/claude/claude-folder-trust-file' import { SHORT_AGENT_TRUST_WRITE_DEADLINE_MS } from '../main/agent-trust-write-deadline' -import { applyWorkspaceTrustOnThisHost } from '../main/execution-host-workspace-trust' +import { + applyWorkspaceTrustOnThisHost, + launchedAgentHome +} from '../main/execution-host-workspace-trust' /** * Why here: this host owns the files the agent reads, so each writer's lock, re-read, atomic @@ -27,9 +30,10 @@ export async function applyRelayAgentWorkspaceTrust( } await applyWorkspaceTrustOnThisHost(preset, request.workspacePath, () => { const keyStyle = process.platform === 'win32' ? 'win32' : 'posix' - const homeDir = (keyStyle === 'win32' ? spawnEnv.USERPROFILE : spawnEnv.HOME) || homedir() + const homeDir = launchedAgentHome(spawnEnv) return { homes: [homeDir, homedir()], + agentHome: homeDir, claudeConfig: () => ({ configFile: resolveClaudeGlobalConfigFile({ env: spawnEnv,