From b78e50fe5a3bf111180ea64fbd5dd7076bf8aae4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:02:30 -0700 Subject: [PATCH 1/4] fix(agent-trust): write per-user trust under the home the launched agent reads The Cursor, Copilot, Qoder and Antigravity writers and the local Codex config list resolved ~ with os.homedir() at write time, so any test that reached them wrote into the developer's real ~/.codex, ~/.cursor, ~/.copilot or ~/.gemini. Each writer now takes the home, derived once from the launch env (HOME, or USERPROFILE on Windows, else this host's home) by launchedAgentHome, which the SSH relay already used. --- src/main/agent-trust-presets.test.ts | 38 ++++++++----------- src/main/agent-trust-presets.ts | 13 +++---- src/main/agent-workspace-trust.test.ts | 28 ++++++++++---- src/main/agent-workspace-trust.ts | 34 +++++++++-------- src/main/codex/codex-home-paths.ts | 4 +- ...dex-trust-config-concurrent-launch.test.ts | 10 ++++- .../execution-host-workspace-trust.test.ts | 1 + src/main/execution-host-workspace-trust.ts | 18 +++++++-- src/main/qoder/hook-service.test.ts | 4 +- src/main/qoder/workspace-trust.ts | 5 +-- src/relay/agent-workspace-trust-spawn.test.ts | 2 +- src/relay/agent-workspace-trust-spawn.ts | 8 +++- 12 files changed, 97 insertions(+), 68 deletions(-) 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-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-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/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/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, From 2d2f2c36005c2191f6dfbb697b934dc86df17667 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:48:07 -0700 Subject: [PATCH 2/4] test: give tests that wrote the real agent or Orca home a temp one The structured Codex adoption replay pre-trusted /repos/workspace-1 in the real ~/.codex/config.toml; it now runs with a temp HOME and userData. The Codex session-resume and WSL hook tests created Orca's managed Codex home under the live userData, and the Claude Agent Teams tests wrote their tmux shim into ~/.orca; each now runs against a temp userData or HOME. --- .../codex/codex-ai-vault-session-resume.test.ts | 11 +++++++++++ src/main/codex/codex-legacy-session-resume.test.ts | 11 +++++++++++ src/main/codex/hook-service-wsl-runtime.test.ts | 8 ++++++++ .../runtime/claude-agent-teams-shim-env.test.ts | 6 +++++- .../terminal-creation-and-readiness-part-04.spec.ts | 13 ++++++++++++- ...structured-agent-session-adoption-replay.test.ts | 5 +++++ 6 files changed, 52 insertions(+), 2 deletions(-) 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-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/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/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', () => { From 36ba9bca122bbada4e34eedaa165bb3b2c5ba07e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:48:07 -0700 Subject: [PATCH 3/4] test: fail any unit test that writes the real agent or Orca home A vitest setup file wraps the node:fs mutating calls and refuses a target under the account's real ~/.codex, ~/.claude(.json), ~/.orca, ~/.cursor, ~/.copilot, ~/.gemini, ~/.qoder or Orca userData, found through os.userInfo() so a test that swaps HOME cannot hide it. The refusal is recorded and rethrown after the test, since trust writers swallow errors. Reads are untouched. It stands down only while an opted-in real-agent suite's own switch is set. It also unsets what an Orca terminal exports toward the live app (userData, Codex and Claude homes, and the Codex launch preflight CLI, which a shell test would otherwise run), so a local run matches CI. --- config/scripts/bun-profile-change-scope.mjs | 3 +- ...vitest-real-agent-home-write-guard.test.ts | 84 +++++++ .../vitest-real-agent-home-write-guard.ts | 211 ++++++++++++++++++ config/tsconfig.node.json | 1 + config/vitest.config.ts | 1 + 5 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 config/scripts/vitest-real-agent-home-write-guard.test.ts create mode 100644 config/scripts/vitest-real-agent-home-write-guard.ts 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..14e527ad30ce --- /dev/null +++ b/config/scripts/vitest-real-agent-home-write-guard.ts @@ -0,0 +1,211 @@ +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 + } + 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 original.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'), From f1a64183f0da617bd0ab3cf6f70f4afc1c0c5040 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:50:02 -0700 Subject: [PATCH 4/4] test: type the guarded fs call from its narrowed original --- config/scripts/vitest-real-agent-home-write-guard.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/scripts/vitest-real-agent-home-write-guard.ts b/config/scripts/vitest-real-agent-home-write-guard.ts index 14e527ad30ce..40c19fbcabc7 100644 --- a/config/scripts/vitest-real-agent-home-write-guard.ts +++ b/config/scripts/vitest-real-agent-home-write-guard.ts @@ -150,6 +150,7 @@ function guard( 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')) { @@ -163,7 +164,7 @@ function guard( } throw error } - return original.apply(this, args) + return target.apply(this, args) } Object.defineProperty(api, name, { ...descriptor, value: guarded }) }