diff --git a/src/main/startup/codex-session-resume-daemon-guard.test.ts b/src/main/startup/codex-session-resume-daemon-guard.test.ts new file mode 100644 index 00000000000..aecaadcf8b6 --- /dev/null +++ b/src/main/startup/codex-session-resume-daemon-guard.test.ts @@ -0,0 +1,145 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { VerifiedCodexResumeSource } from '../codex/codex-session-resume-preparation' +import { + CODEX_DAEMON_OVERRIDE_MARKER, + codexDaemonSocketPathExceedsLimit +} from '../codex/codex-daemon-socket-path-guard' + +const mocks = vi.hoisted(() => ({ + hooksEnabled: false, + systemHomePath: '', + sharedHomePath: '', + installForLaunchPrep: vi.fn(), + refreshRuntimeUserHooksForLaunchPrep: vi.fn(), + ensureRealHomeCodexHookState: vi.fn(async () => {}), + prepareCodexSessionResume: vi.fn(), + prepareLegacySharedCodexSessionResume: vi.fn() +})) + +vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) +vi.mock('../agent-trust-presets', () => ({ markCodexProjectTrusted: async () => {} })) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + installForLaunchPrep: mocks.installForLaunchPrep, + refreshRuntimeUserHooksForLaunchPrep: mocks.refreshRuntimeUserHooksForLaunchPrep + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabled: () => mocks.hooksEnabled +})) +vi.mock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + getSystemCodexHomePath: () => mocks.systemHomePath, + getOrcaManagedCodexHomePath: () => mocks.sharedHomePath +})) +vi.mock('../codex/codex-session-resume-preparation', () => ({ + prepareCodexSessionResume: mocks.prepareCodexSessionResume +})) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: mocks.prepareLegacySharedCodexSessionResume +})) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [], + resolveSelectedHostAccountCodexHomePathForResume: () => null + }, + store: { getSettings: () => ({}) } + } +})) + +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +// Why: long enough that the daemon socket overflows sun_path on every host OS. +const LONG_SEGMENT = 'a'.repeat(60) + +describe('Codex session resume daemon socket guard', () => { + let root: string + let accountHome: string + let warn: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + root = mkdtempSync(join(tmpdir(), 'orca-resume-guard-')) + accountHome = join(root, 'codex-accounts', LONG_SEGMENT, 'home') + mocks.systemHomePath = join(root, 'system', LONG_SEGMENT, '.codex') + mocks.sharedHomePath = join(root, 'codex-runtime-home', 'home') + for (const home of [accountHome, mocks.systemHomePath, mocks.sharedHomePath]) { + mkdirSync(home, { recursive: true }) + } + mocks.hooksEnabled = false + mocks.prepareLegacySharedCodexSessionResume.mockResolvedValue({ useRealCodexHome: false }) + mocks.prepareCodexSessionResume.mockImplementation( + async (args: { + resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise + }) => ({ + outcome: 'resume' as const, + codexHomePath: await args.resolveVerifiedResumeHome({ + homePath: accountHome, + transcriptPath: join(accountHome, 'sessions', 'abc.jsonl') + }) + }) + ) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + function resume(): ReturnType { + return prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: 'abc' }, + target: { runtime: 'host' }, + workspacePath: join(root, 'workspace') + }) + } + + it('guards the resumed account home when hook repair fails before mirroring config', async () => { + expect(codexDaemonSocketPathExceedsLimit(accountHome)).toBe(true) + writeFileSync(join(accountHome, 'config.toml'), 'model = "gpt-5"\n', 'utf-8') + mocks.hooksEnabled = true + mocks.installForLaunchPrep.mockRejectedValue(new Error('Could not parse Codex hooks.json')) + + const preparation = await resume() + + expect(preparation).toMatchObject({ outcome: 'resume', codexHomePath: accountHome }) + const config = readFileSync(join(accountHome, 'config.toml'), 'utf-8') + expect(config).toContain('model = "gpt-5"') + expect(config).toContain(`daemon_auto_start = false ${CODEX_DAEMON_OVERRIDE_MARKER}`) + }) + + it('guards the resumed account home when hooks are off and the refresh returns early', async () => { + mocks.refreshRuntimeUserHooksForLaunchPrep.mockResolvedValue({ + agent: 'codex', + state: 'error', + detail: 'Could not read system Codex hooks.json' + }) + + await resume() + + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).toHaveBeenCalledWith(accountHome) + expect(readFileSync(join(accountHome, 'config.toml'), 'utf-8')).toContain( + `daemon_auto_start = false ${CODEX_DAEMON_OVERRIDE_MARKER}` + ) + }) + + it('never writes the guard into the real Codex home a migrated resume runs in', async () => { + expect(codexDaemonSocketPathExceedsLimit(mocks.systemHomePath)).toBe(true) + mocks.prepareLegacySharedCodexSessionResume.mockResolvedValue({ useRealCodexHome: true }) + + const preparation = await resume() + + expect(preparation).toMatchObject({ codexHomePath: mocks.systemHomePath }) + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) + expect(existsSync(join(mocks.systemHomePath, 'config.toml'))).toBe(false) + }) +}) diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index 0c5e4598de1..1ce7f8c3a15 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -7,6 +7,7 @@ import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-ses import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { ensureCodexDaemonSocketGuard } from '../codex/codex-config-mirror' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { markCodexProjectTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' @@ -111,6 +112,10 @@ export async function prepareCodexSessionResumeForLaunch(args: { // Why: hook repair is best-effort; session provenance must still win over the currently selected home. console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) } + if (!isSystemHome) { + // Why: this pins the resumed pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard. + ensureCodexDaemonSocketGuard(resumeHome) + } return resumeHome } })