From 443018cd8336994480634ee294605d4ce1753622 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:50:20 -0700 Subject: [PATCH] Support WSL dotfile-symlink write-back and lengthen redeem timeout - Preserve symlinked Codex config on WSL by writing through the existing file instead of atomic-rename, since \\wsl$ symlink metadata isn't reliably detected and rename would clobber the link. - Tighten new ~/.codex directory creation to 0700 (holds auth.json). - Give explicit reset-credit redemption a 30s backend timeout instead of the 10s background-poll default, since it's user-triggered. - Read sleeping-agent session state from the worktree's actual execution-host partition instead of always the local one, so the headless-wake check works correctly for SSH-hosted worktrees. - Isolate serve-sim watcher tests from the real $TMPDIR/serve-sim state file to avoid leaking unrelated events. --- .../codex/config-settings-promotion.test.ts | 4 +- src/main/codex/config-settings-promotion.ts | 22 +++++++++-- .../codex/hook-service-wsl-runtime.test.ts | 1 + .../emulator/serve-sim-state-watcher.test.ts | 17 +++++++-- .../rate-limits/codex-fetcher-backend.test.ts | 3 +- src/main/rate-limits/codex-fetcher.ts | 12 ++++-- src/main/runtime/orca-runtime.test.ts | 38 ++++++++++--------- src/main/runtime/orca-runtime.ts | 12 +++++- 8 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index 6ad42dae28b..f6ceabc362f 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -321,6 +321,9 @@ describe('codex settings write-back promotion', () => { syncSystemConfigIntoManagedCodexHome() expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600) + // The created ~/.codex itself is owner-only — it will also hold + // auth.json once the user signs in. + expect(statSync(join(tmpHome, '.codex')).mode & 0o777).toBe(0o700) } ) @@ -346,7 +349,6 @@ describe('codex settings write-back promotion', () => { () => { mkdirSync(join(tmpHome, '.codex'), { recursive: true }) const realConfigPath = join(tmpHome, 'dotfiles', 'config.toml') - mkdirSync(join(tmpHome, 'dotfiles'), { recursive: true }) symlinkSync(realConfigPath, systemConfigPath()) syncSystemConfigIntoManagedCodexHome() diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 51e1fc64980..7e8552bb9b6 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -10,6 +10,7 @@ import { } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { createTomlLineScanState, @@ -221,10 +222,25 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion } // Why: a genuinely fresh host has no ~/.codex yet; without the directory // the atomic write ENOENTs and the following mirror wipes the setting. - mkdirSync(systemHomePath, { recursive: true }) + // Owner-only: the directory holds auth.json and the full user config. + mkdirSync(systemHomePath, { recursive: true, mode: 0o700 }) const writeTarget = resolvePromotionWriteTarget(systemTomlPath) - const systemContent = existsSync(writeTarget.path) ? readFileSync(writeTarget.path, 'utf-8') : '' - writeFileAtomically(writeTarget.path, upsertTopLevelSettingsInContent(systemContent, updates), { + // Why: a dangling dotfile-manager symlink can point into a directory tree + // that has not been materialized yet; preserve the link and create its real + // parent so the atomic temp file can be written beside the target. + mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 }) + const targetExists = existsSync(writeTarget.path) + const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : '' + const nextContent = upsertTopLevelSettingsInContent(systemContent, updates) + if (targetExists && parseWslUncPath(writeTarget.path)) { + // Why: symlink metadata through the \\wsl$ 9P provider is not reliable + // enough for realpath/lstat detection, and an atomic rename would replace + // a WSL-side dotfile symlink with a plain file. Writing through the + // existing file preserves the Linux-side inode (and its mode). + writeFileSync(writeTarget.path, nextContent, 'utf-8') + return + } + writeFileAtomically(writeTarget.path, nextContent, { mode: writeTarget.mode }) } diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 2bbf2bdd20d..20accbb5859 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -213,6 +213,7 @@ describe('Codex WSL runtime hook install', () => { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}`, WSL_DISTRO_NAME: 'Ubuntu', + ORCA_AGENT_HOOK_ENDPOINT: '', ORCA_AGENT_HOOK_PORT: '43210', ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_PANE_KEY: 'pane-1', diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 006531dff20..bf9d7a61fa1 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -6,6 +6,17 @@ import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-s const TEST_UDID = '11111111-2222-3333-4444-555555555555' +let isolatedStateDirSeq = 0 +// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state +// file from an actual simulator session would leak extra events into tests +// that only exercise PTY ingestion. Point at a dir that never exists. +function createIsolatedWatcher(): ServeSimStateWatcher { + isolatedStateDirSeq += 1 + return new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`) + }) +} + async function waitForEvent( events: ServeSimStateDetectedEvent[], predicate: (event: ServeSimStateDetectedEvent) => boolean @@ -68,7 +79,7 @@ describe('ServeSimStateWatcher', () => { }) it('suppresses Orca-managed sessions only while they are marked managed', async () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -95,7 +106,7 @@ describe('ServeSimStateWatcher', () => { }) it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -122,7 +133,7 @@ describe('ServeSimStateWatcher', () => { }) it('dedupes one helper without hiding a later helper for the same simulator', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const firstPayload = JSON.stringify({ device: TEST_UDID, diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 7a801fd9456..8a0e2ee7028 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -148,7 +148,8 @@ describe('Codex backend rate-limit requests', () => { idempotencyKey: 'redeem-timeout' }) await vi.advanceTimersByTimeAsync(0) - expect(timeout).toHaveBeenCalledWith(10_000) + // Why: redeem is user-triggered, so it gets the longer redeem deadline. + expect(timeout).toHaveBeenCalledWith(30_000) expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8') timeoutController.abort(deadlineError) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 33321479425..8a3ed94bdff 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -34,6 +34,9 @@ const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 const BACKEND_TIMEOUT_MS = 10_000 +// Why: redeeming a reset credit is an explicit user action, not a background +// poll — give it more room before failing so a slow backend can still finish. +const REDEEM_BACKEND_TIMEOUT_MS = 30_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { @@ -289,8 +292,11 @@ function mapBackendRateLimitResetCredits( } } -function createBackendRequestSignal(callerSignal?: AbortSignal): AbortSignal { - const timeoutSignal = AbortSignal.timeout(BACKEND_TIMEOUT_MS) +function createBackendRequestSignal( + callerSignal?: AbortSignal, + timeoutMs = BACKEND_TIMEOUT_MS +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs) return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal } @@ -424,7 +430,7 @@ export async function consumeCodexRateLimitResetCredit(options: { if (!options.idempotencyKey.trim()) { throw new Error('Codex reset idempotency key is required') } - const signal = createBackendRequestSignal() + const signal = createBackendRequestSignal(undefined, REDEEM_BACKEND_TIMEOUT_MS) const auth = await getCodexBackendAuthHeaders(options, signal) if (!auth) { throw new Error('Codex not signed in') diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 264e2364dc1..f72d94c4f9f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14958,27 +14958,28 @@ describe('OrcaRuntimeService', () => { [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) } const resumeSleepingAgents = vi.fn() + const getWorkspaceSession = vi.fn(() => ({ + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'test', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + })) const runtime = new OrcaRuntimeService({ ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - getWorkspaceSession: () => ({ - ...getDefaultWorkspaceSession(), - sleepingAgentSessionsByPaneKey: { - 'tab-1:leaf-1': { - paneKey: 'tab-1:leaf-1', - tabId: 'tab-1', - worktreeId: TEST_WORKTREE_ID, - agent: 'codex', - providerSession: { key: 'session_id', id: 'session-1' }, - prompt: 'test', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - } - }) + getWorkspaceSession } as never) runtime.setNotifier({ worktreesChanged: vi.fn(), @@ -15008,6 +15009,9 @@ describe('OrcaRuntimeService', () => { expect(result.activated).toBe(true) expect(result.sleepingAgentWake).toBe('unsupported-headless') + // Why: sleeping records are host-partitioned; the check must read the + // repo's execution host partition, not always the local one. + expect(getWorkspaceSession).toHaveBeenCalledWith('local') expect(resumeSleepingAgents).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 25258b95c5a..43ea05133bb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -127,7 +127,11 @@ import type { WorkspaceSessionState, DirEntry } from '../../shared/types' -import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' @@ -12665,8 +12669,12 @@ export class OrcaRuntimeService { this.notifier?.resumeSleepingAgents?.(worktree.id) sleepingAgentWake = 'requested' } else if ( + // Why: sleeping records are partitioned by execution host; reading + // only the local partition would miss slept agents on SSH-host + // worktrees and skip the headless warning for them. Object.values( - this.store?.getWorkspaceSession?.().sleepingAgentSessionsByPaneKey ?? {} + this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) + .sleepingAgentSessionsByPaneKey ?? {} ).some((record) => record.worktreeId === worktree.id) ) { // Why: headless is only degraded when this worktree actually has a