Merge origin/main into tmp/native-chat-recovery-main-integration

This commit is contained in:
Brennan Benson
2026-08-27 00:05:16 -07:00
111 changed files with 5496 additions and 1480 deletions
+16 -1
View File
@@ -8124,6 +8124,7 @@
"hidden remote worktree retention",
"remote terminal reveal and input",
"paired host relaunch with preserved daemon PTYs",
"paired host session inventory publication authority",
"manual server disconnect"
],
"platforms": ["macos", "linux", "windows"],
@@ -8135,13 +8136,14 @@
"https://github.com/stablyai/orca/issues/8652",
"https://github.com/stablyai/orca/pull/10625"
],
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. An empty headed session-tab inventory is authoritative only after the current renderer graph generation publishes a complete inventory and every execution host answers the PTY census; unpublished, resync-pending, transport-failed, and partially scoped inventories remain unverifiable, while genuinely authoritative empty and non-empty inventories settle and a scope-less legacy empty keeps its pre-authority best-effort settle so outdated hosts retain agent auto-resume. The unpublished-empty regression must keep the resume dispatch parked with exactly one daemon process/writer; restoring unconditional empty hydration must release one dispatch and create two live writers. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts",
@@ -8174,6 +8176,11 @@
"src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts",
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
"src/main/runtime/session-tabs-inventory-publication.test.ts",
"src/main/runtime/rpc/methods/session-tabs.test.ts",
"src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts",
"src/main/daemon/terminal-host-agent-session.test.ts",
"tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts",
"src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts",
"src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts",
"src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts",
@@ -8257,6 +8264,14 @@
"file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
"assertions": ["retries an existing subscriber when provider inventory becomes ready"]
},
{
"file": "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts",
"assertions": [
"unpublished empty inventory keeps resume dispatch at zero with the original daemon session, process id, and writer as the sole live writer",
"an authoritative empty inventory settles the mirror and releases exactly one resume dispatch with zero liveness probes",
"re-enabling unconditional empty hydration makes the identical oracle red with one resume dispatch, two session ids, two process ids, and two writer calls"
]
},
{
"file": "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts",
"assertions": [
@@ -22,6 +22,7 @@ import {
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
@@ -69,6 +70,7 @@ describe('CLI remote WebSocket transport', () => {
expect.objectContaining({
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
@@ -15,7 +15,8 @@ describe('preserveAgentAuthBeforeRestart', () => {
codexRuntimeHome: {
syncForCurrentSelection: vi.fn(() => {
calls.push('codex')
})
}),
syncActiveWslSelectionsBeforeRestart: vi.fn()
},
claudeRuntimeAuth: {
syncForCurrentSelection: vi.fn(async () => {
@@ -32,13 +33,35 @@ describe('preserveAgentAuthBeforeRestart', () => {
expect(calls).toEqual(['codex', 'claude', 'flush'])
})
it('runs Claude preservation after Codex and before the store flush', async () => {
it('runs WSL Codex preservation through the runtime service', async () => {
const syncForCurrentSelection = vi.fn()
const syncActiveWslSelectionsBeforeRestart = vi.fn()
await preserveAgentAuthBeforeRestart({
codexRuntimeHome: {
syncForCurrentSelection,
syncActiveWslSelectionsBeforeRestart
},
store: {
flushPendingOrThrowAsync: vi.fn()
}
})
expect(syncForCurrentSelection).toHaveBeenCalledTimes(1)
expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1)
expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1)
})
it('runs Claude preservation before WSL Codex preservation', async () => {
const calls: string[] = []
await preserveAgentAuthBeforeRestart({
codexRuntimeHome: {
syncForCurrentSelection: vi.fn(() => {
calls.push('codex-host')
}),
syncActiveWslSelectionsBeforeRestart: vi.fn(() => {
calls.push('codex-wsl')
})
},
claudeRuntimeAuth: {
@@ -53,80 +76,26 @@ describe('preserveAgentAuthBeforeRestart', () => {
}
})
expect(calls).toEqual(['codex-host', 'claude', 'flush'])
expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush'])
})
it('drains retained WSL Codex auth before flushing the store', async () => {
const calls: string[] = []
await preserveAgentAuthBeforeRestart({
codexRuntimeHome: {
syncForCurrentSelection: vi.fn(() => {
calls.push('codex-host')
}),
syncActiveWslSelectionsBeforeRestart: vi.fn(async () => {
calls.push('codex-wsl')
})
},
store: {
flushPendingOrThrowAsync: vi.fn(async () => {
calls.push('flush')
})
}
})
expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush'])
})
it('does not release restart while the bounded WSL drain is still running', async () => {
vi.useFakeTimers()
let finishWslDrain!: () => void
let settled = false
const flushPendingOrThrowAsync = vi.fn()
const preservation = preserveAgentAuthBeforeRestart({
codexRuntimeHome: {
syncForCurrentSelection: vi.fn(),
syncActiveWslSelectionsBeforeRestart: vi.fn(
() =>
new Promise<void>((resolve) => {
finishWslDrain = resolve
})
)
},
store: { flushPendingOrThrowAsync }
}).then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(2_000)
await vi.advanceTimersByTimeAsync(1)
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
expect(settled).toBe(false)
finishWslDrain()
await preservation
expect(settled).toBe(true)
})
it('continues after the bounded WSL drain fails without logging secrets', async () => {
it('continues after WSL Codex preservation fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const flushPendingOrThrowAsync = vi.fn()
await preserveAgentAuthBeforeRestart({
codexRuntimeHome: {
syncForCurrentSelection: vi.fn(),
syncActiveWslSelectionsBeforeRestart: vi.fn(async () => {
syncActiveWslSelectionsBeforeRestart: vi.fn(() => {
throw new Error('wsl-token-secret')
})
},
store: { flushPendingOrThrowAsync }
store: {
flushPendingOrThrowAsync
}
})
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(
'[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update'
)
expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret')
})
@@ -147,7 +116,8 @@ describe('preserveAgentAuthBeforeRestart', () => {
codexRuntimeHome: {
syncForCurrentSelection: vi.fn(() => {
throw new Error('codex-token-secret')
})
}),
syncActiveWslSelectionsBeforeRestart: vi.fn()
},
claudeRuntimeAuth: {
syncForCurrentSelection: vi.fn(async () => {
@@ -200,36 +170,6 @@ describe('preserveAgentAuthBeforeRestart', () => {
expect(calls).toEqual(['claude-start', 'flush', 'claude-finish'])
})
it('gives store persistence its own timeout after auth preservation expires', async () => {
vi.useFakeTimers()
let finishStoreFlush!: () => void
let settled = false
const preservation = preserveAgentAuthBeforeRestart({
claudeRuntimeAuth: {
syncForCurrentSelection: vi.fn(() => new Promise<void>(() => {}))
},
store: {
flushPendingOrThrowAsync: vi.fn(
() =>
new Promise<void>((resolve) => {
finishStoreFlush = resolve
})
)
}
}).then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(2_000)
await vi.advanceTimersByTimeAsync(1)
expect(settled).toBe(false)
finishStoreFlush()
await preservation
expect(settled).toBe(true)
})
it('bounds a store flush that never settles', async () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+30 -19
View File
@@ -4,8 +4,10 @@ import type { Store } from './persistence'
const AUTH_PRESERVATION_TIMEOUT_MS = 2_000
type CodexRuntimeAuthSync = Pick<CodexRuntimeHomeService, 'syncForCurrentSelection'> &
Partial<Pick<CodexRuntimeHomeService, 'syncActiveWslSelectionsBeforeRestart'>>
type CodexRuntimeAuthSync = Pick<
CodexRuntimeHomeService,
'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart'
>
type ClaudeRuntimeAuthSync = Pick<ClaudeRuntimeAuthService, 'syncForCurrentSelection'>
type ShutdownStore = Pick<Store, 'flushPendingOrThrowAsync'>
@@ -25,26 +27,35 @@ export async function preserveAgentAuthBeforeRestart({
claudeRuntimeAuth,
store
}: AgentAuthRestartPreservationOptions): Promise<void> {
runCodexPreservationStep(codexRuntimeHome)
// Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion.
const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome)
const startedAt = Date.now()
if (claudeRuntimeAuth) {
runCodexPreservationStep(codexRuntimeHome)
const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt))
if (claudeRuntimeAuth && remainingMs > 0) {
await runWithinLifecycleTimeout(
'Claude auth preservation',
() => claudeRuntimeAuth.syncForCurrentSelection(),
AUTH_PRESERVATION_TIMEOUT_MS
remainingMs
)
} else if (claudeRuntimeAuth) {
logStepTimeout('Claude auth preservation', 0)
}
const storePreservation = store
? runWithinLifecycleTimeout(
'Store persistence',
() => store.flushPendingOrThrowAsync(),
AUTH_PRESERVATION_TIMEOUT_MS
)
: Promise.resolve()
await Promise.all([wslCodexPreservation, storePreservation])
if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) {
runWslCodexPreservationStep(codexRuntimeHome)
} else if (codexRuntimeHome) {
logStepTimeout('Codex auth preservation', 0)
}
if (store) {
const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt))
await runWithinLifecycleTimeout(
'Store persistence',
() => store.flushPendingOrThrowAsync(),
storeRemainingMs
)
}
}
function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void {
@@ -55,11 +66,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null
}
}
async function runWslCodexPreservationStep(
function runWslCodexPreservationStep(
codexRuntimeHome: CodexRuntimeAuthSync | null | undefined
): Promise<void> {
): void {
try {
await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.()
codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart()
} catch (error) {
logStepFailure('Codex auth preservation', error)
}
@@ -78,7 +89,7 @@ async function runWithinLifecycleTimeout(
})
// Why: this timeout only releases the restart/update path. It does not
// cancel a sync that already started.
// cancel a sync that already started, and Codex sync is synchronous today.
const timeoutResult = new Promise<'timeout'>((resolve) => {
timeout = setTimeout(() => resolve('timeout'), timeoutMs)
})
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { _internals as codexInternals } from '../codex/hook-service'
import { buildPosixHookSpoolLines } from './hook-stdin-contract'
/** Managed hooks are installed into the user's agent config, so they also run when the
* agent is launched from a plain terminal. There they must be inert and silent. */
function runHook(dir: string, extraEnv: NodeJS.ProcessEnv = {}) {
const script = join(dir, 'codex-hook.sh')
writeFileSync(script, codexInternals.getManagedScript('posix'))
chmodSync(script, 0o755)
const clean: NodeJS.ProcessEnv = {}
for (const [k, v] of Object.entries(process.env)) {
if (!k.startsWith('ORCA_')) {
clean[k] = v
}
}
return spawnSync('/bin/sh', [script], {
input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n',
env: { ...clean, ...extraEnv },
timeout: 5000,
encoding: 'utf8'
})
}
describe('managed hook outside an Orca terminal', () => {
it('no Orca env at all: silent, exit 0, writes nothing', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-outside-'))
const res = runHook(dir)
expect(res.status).toBe(0)
expect(res.stdout).toBe('')
expect(res.stderr).toBe('')
expect(readdirSync(dir)).toEqual(['codex-hook.sh'])
})
it('pane key present but no endpoint: still silent and writes nothing', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-outside-partial-'))
const res = runHook(dir, { ORCA_PANE_KEY: 'tab:0', ORCA_TAB_ID: 'tab' })
expect(res.status).toBe(0)
expect(res.stdout).toBe('')
expect(res.stderr).toBe('')
expect(readdirSync(dir)).toEqual(['codex-hook.sh'])
})
it('endpoint points at a path that does not exist: silent, exit 0', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-outside-stale-'))
const res = runHook(dir, {
ORCA_AGENT_HOOK_ENDPOINT: join(dir, 'gone', 'deeper', 'endpoint.env'),
ORCA_PANE_KEY: 'tab:0'
})
expect(res.status).toBe(0)
expect(res.stdout).toBe('')
expect(res.stderr).toBe('')
// a stale env var must not create a spool tree for an Orca that is not installed here
expect(readdirSync(dir)).toEqual(['codex-hook.sh'])
})
it('readable endpoint without a pane key: silent, writes nothing', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-outside-readable-'))
const endpoint = join(dir, 'endpoint.env')
writeFileSync(endpoint, 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\n')
const res = runHook(dir, { ORCA_AGENT_HOOK_ENDPOINT: endpoint })
expect(res.status).toBe(0)
expect(res.stdout).toBe('')
expect(res.stderr).toBe('')
expect(readdirSync(dir).sort()).toEqual(['codex-hook.sh', 'endpoint.env'])
})
})
describe('antigravity out-of-band event name', () => {
it('records hookEventName and filters tool progress on it', () => {
const lines = buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT').join('\n')
expect(lines).toContain('"hookEventName":"%s"')
expect(lines).toContain('${ORCA_ANTIGRAVITY_EVENT:-}')
expect(lines).toContain('in PreToolUse|PostToolUse|PostToolUseFailure) return 0')
// payload-based filtering stays the default for every other provider
expect(buildPosixHookSpoolLines('codex').join('\n')).toContain('case "$payload" in')
})
})
@@ -22,6 +22,52 @@ export function buildPosixHookPayloadCapture(
]
}
/** Shell-side durable fallback shared by every POSIX managed hook.
* `eventNameVar` is for providers that send the event name out-of-band rather than in the
* payload JSON; without it both the progress filter and replay would miss the event name. */
export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): string[] {
// Why: the event name must be a printf ARG, not inlined in the single-quoted format,
// where a command substitution would be emitted literally.
const eventFormat = eventNameVar ? '"hookEventName":"%s",' : ''
const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : ''
const spoolRecordLine = " { printf '\\n{".concat(
eventFormat,
'"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'',
eventArg,
' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "',
source,
'")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :'
)
return [
'spool_hook_event() {',
eventNameVar
? ` case "\${${eventNameVar}:-}" in PreToolUse|PostToolUse|PostToolUseFailure) return 0 ;; esac`
: ' case "$payload" in *\'"PreToolUse"\'*|*\'"PostToolUse"\'*|*\'"PostToolUseFailure"\'*) return 0 ;; esac',
' [ -n "${ORCA_AGENT_HOOK_ENDPOINT:-}" ] || return 0',
// Why: an endpoint can linger in a parent shell after leaving Orca; without a pane key
// the record is un-attributable and would accumulate as pane-unknown.jsonl.
' [ -n "${ORCA_PANE_KEY:-}" ] || return 0',
// Why: a stale env var must not create a spool tree for an Orca that is not installed here.
' [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ] || return 0',
' spool_base=${ORCA_AGENT_HOOK_ENDPOINT%/*}',
' spool_dir="$spool_base/spool"',
' mkdir -p "$spool_dir" 2>/dev/null || return 0',
' chmod 700 "$spool_dir" 2>/dev/null || :',
" spool_id=$(printf %s \"${ORCA_PANE_KEY:-unknown}\" | tail -c 36 | tr '/:' '__')",
' spool_file="$spool_dir/pane-$spool_id.jsonl"',
' if [ -f "$spool_file" ] && find "$spool_file" -mtime +7 -print -quit 2>/dev/null | grep -q .; then : > "$spool_file"; fi',
' [ -f "$spool_file" ] || : > "$spool_file"',
' spool_size=$(wc -c < "$spool_file" 2>/dev/null || printf 0)',
' [ "$spool_size" -lt 5242880 ] || return 0',
' spool_now=$(date +%s 2>/dev/null || printf 0)',
' spool_now=$((spool_now * 1000))',
' spool_json_escape() { printf %s "$1" | sed \'s/\\\\/\\\\\\\\/g; s/"/\\\\"/g; s/[[:cntrl:]]/ /g\'; }',
spoolRecordLine,
' chmod 600 "$spool_file" 2>/dev/null || :',
'}'
]
}
export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin'
// Why: qualify the stdin reader because Windows searches the worktree for
// executables before PATH and hook payloads must not reach repo-local code.
@@ -63,6 +63,7 @@ describe('AgentHookServer listener replay', () => {
send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' })
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'coordinate reviewers',
model: 'gpt-5.4',
providerSession: { key: 'session_id', id: 'root-session' },
subagents: [expect.objectContaining({ id: 'child-b' })]
+67 -4
View File
@@ -116,6 +116,12 @@ import {
type AgentProviderSessionMetadata
} from '../../shared/agent-session-resume'
import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary'
import {
buildSpoolHookBody,
drainAgentHookSpool,
launchTokenHash,
type SpoolRecord
} from '../../shared/agent-hook-spool'
export type { AgentHookSource }
@@ -801,6 +807,44 @@ export class AgentHookServer {
}
}
/** Replay is durable evidence from a prior runtime, not a live observation. */
private withdrawReplayObservation(paneKey: string): void {
if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) {
this.notifyStatusChangeListeners()
}
}
private ingestSpoolRecord(record: SpoolRecord): void {
if (!isAgentHookSource(record.source)) {
return
}
const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record))
const normalized = this.normalizeLocalHookPayload(record.source, body)
if (!normalized.event) {
return
}
const replay = { ...normalized.event, isReplay: true as const }
const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, {
source: record.source,
hookEventName: replay.hookEventName,
isReplay: true,
hasExplicitPrompt: replay.hasExplicitPrompt,
launchToken: replay.launchToken
})
if (statusDisposition === 'suppress') {
return
}
const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay
if (statusDisposition === 'restart') {
this.observations.rebind(event.paneKey)
}
this.recordCurrentAuthorityObservation(event)
this.applyNormalizedStatus(event, normalized.onAccepted)
if (event.payload.state !== 'done') {
this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey))
}
}
setPaneStatusClearListener(listener: PaneStatusClearListener | null): void {
this.onPaneStatusCleared = listener
}
@@ -2259,14 +2303,14 @@ export class AgentHookServer {
claudeRunningNonAgentTask?: unknown
payload: unknown
},
connectionId: string
connectionId: string | null
): void {
// Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches.
if (typeof connectionId !== 'string') {
if (connectionId !== null && typeof connectionId !== 'string') {
return
}
const trimmedConnectionId = connectionId.trim()
if (trimmedConnectionId.length === 0) {
const trimmedConnectionId = connectionId?.trim() ?? null
if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) {
return
}
if (!envelope || typeof envelope.paneKey !== 'string') {
@@ -2286,6 +2330,14 @@ export class AgentHookServer {
if (!parsedPaneKey) {
return
}
// Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state.
if (envelope.isReplay === true) {
const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
const actualLaunchTokenHash = launchTokenHash(envelope.launchToken)
if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) {
return
}
}
if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') {
return
}
@@ -2515,6 +2567,15 @@ export class AgentHookServer {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record)
})
}
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'POST') {
res.writeHead(404)
@@ -3309,6 +3370,8 @@ export class AgentHookServer {
restoredUnconfirmed: _restoredUnconfirmed,
// Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload).
observation: _observation,
// Replay provenance is runtime-only and must not survive another restart.
isReplay: _isReplay,
launchToken,
...persistedPayload
} = enrichedPayload
+289
View File
@@ -0,0 +1,289 @@
import { describe, expect, it } from 'vitest'
import {
appendFileSync,
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
statSync,
writeFileSync
} from 'node:fs'
import { execFileSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
AGENT_HOOK_SPOOL_MAX_FILES,
drainAgentHookSpool,
launchTokenHash,
readSpoolRecords,
type SpoolRecord
} from '../../shared/agent-hook-spool'
import { AgentHookServer, _internals } from './server'
import { buildBody } from './server.test-fixtures'
import { _internals as codexInternals } from '../codex/hook-service'
import { makePaneKey } from '../../shared/stable-pane-id'
import { buildPosixHookSpoolLines } from './hook-stdin-contract'
describe('agent hook spool', () => {
it('appends each record with one printf write to prevent concurrent field interleaving', () => {
const spoolLine = buildPosixHookSpoolLines('codex').find((line) =>
line.includes('>> "$spool_file"')
)
expect(spoolLine).toBeDefined()
expect(spoolLine!.match(/printf/g)).toHaveLength(1)
expect(spoolLine).toContain('"$spool_now" "$payload"')
})
it('drops torn lines while retaining complete records', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-spool-'))
const file = join(dir, 'pane.jsonl')
writeFileSync(
file,
'\n{"paneKey":"tab:1","source":"codex","receivedAt":1,"payload":{}}\n{"paneKey":'
)
expect(readSpoolRecords(file, 1)).toHaveLength(1)
})
it('waits for a newline before replaying a complete-looking final record', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-spool-unterminated-'))
const spool = join(dir, 'spool')
mkdirSync(spool)
const file = join(spool, 'pane-live.jsonl')
const record = JSON.stringify({
paneKey: 'tab:live',
source: 'codex',
receivedAt: Date.now(),
payload: { state: 'done' }
})
writeFileSync(file, record)
const ingested: SpoolRecord[] = []
const options = {
endpointDir: dir,
getPersistedLaunchTokenHash: () => undefined,
ingest: (value: SpoolRecord) => ingested.push(value)
}
expect(readSpoolRecords(file)).toHaveLength(0)
expect(drainAgentHookSpool(options)).toBe(0)
expect(readFileSync(file, 'utf8')).toBe(record)
appendFileSync(file, '\n')
expect(drainAgentHookSpool(options)).toBe(1)
expect(ingested).toHaveLength(1)
expect(readFileSync(file)).toHaveLength(0)
})
it('does not let historical empty pane files starve newer records', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-spool-empty-files-'))
const spool = join(dir, 'spool')
mkdirSync(spool)
for (let index = 0; index < AGENT_HOOK_SPOOL_MAX_FILES; index += 1) {
writeFileSync(join(spool, `pane-empty-${index}.jsonl`), '')
}
const live = join(spool, 'pane-live.jsonl')
writeFileSync(
live,
`\n${JSON.stringify({ paneKey: 'tab:live', source: 'codex', receivedAt: Date.now(), payload: { state: 'done' } })}\n`
)
const ingested: SpoolRecord[] = []
drainAgentHookSpool({
endpointDir: dir,
getPersistedLaunchTokenHash: () => undefined,
ingest: (record) => ingested.push(record)
})
expect(ingested).toHaveLength(1)
expect(ingested[0]?.paneKey).toBe('tab:live')
})
it('rejects stale launch tokens before ingest and truncates in place', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-spool-'))
const spool = join(dir, 'spool')
mkdirSync(spool)
const file = join(spool, 'pane-1.jsonl')
writeFileSync(
file,
`\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'old', receivedAt: Date.now(), payload: { state: 'done' } })}\n`
)
const inode = statSync(file).ino
const ingested: unknown[] = []
drainAgentHookSpool({
endpointDir: dir,
getPersistedLaunchTokenHash: () => launchTokenHash('new')!,
ingest: (record) => ingested.push(record)
})
expect(ingested).toHaveLength(0)
expect(readFileSync(file)).toHaveLength(0)
expect(statSync(file).ino).toBe(inode)
})
it('ingests a record with the matching launch token', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-spool-'))
const spool = join(dir, 'spool')
mkdirSync(spool)
const file = join(spool, 'pane-1.jsonl')
writeFileSync(
file,
`\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'same', receivedAt: Date.now(), payload: { state: 'done' } })}\n`
)
const ingested: unknown[] = []
drainAgentHookSpool({
endpointDir: dir,
getPersistedLaunchTokenHash: () => launchTokenHash('same')!,
ingest: (record) => ingested.push(record)
})
expect(ingested).toHaveLength(1)
expect(ingested[0]).toMatchObject({ paneKey: 'tab:1', isReplay: true })
})
it('replays a spooled Codex SubagentStop through the server after restart', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-e2e-'))
const paneKey = makePaneKey('tab-spool', '00000000-0000-4000-8000-000000000001')
const launchToken = 'generation-token'
const first = new AgentHookServer()
await first.start({ env: 'production', userDataPath })
const started = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-spooled' }),
'production'
)!
first.ingestRemote(
{
paneKey,
source: 'codex',
hookEventName: 'SubagentStart',
launchToken,
payload: started.payload
},
'spool-test'
)
expect(first.getStatusSnapshot()).toHaveLength(1)
first.flushStatusPersistSync()
first.stop()
const spoolDir = join(userDataPath, 'agent-hooks', 'spool')
mkdirSync(spoolDir, { recursive: true })
writeFileSync(
join(spoolDir, 'pane-tab-spooled_0.jsonl'),
`\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStop', launchToken, receivedAt: Date.now(), payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' } })}\n`
)
const restarted = new AgentHookServer()
await restarted.start({ env: 'production', userDataPath })
try {
const snapshot = restarted.getStatusSnapshot()
expect(snapshot).toHaveLength(1)
expect(snapshot[0]!.subagents).toBeUndefined()
restarted.flushStatusPersistSync()
expect(readFileSync(restarted.lastStatusPath!, 'utf8')).not.toContain('isReplay')
} finally {
restarted.stop()
}
})
it('rejects a stale remote replay against the hydrated launch-token fence', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-remote-fence-'))
const paneKey = makePaneKey('tab-remote-fence', '00000000-0000-4000-8000-000000000003')
const first = new AgentHookServer()
const second = new AgentHookServer()
try {
await first.start({ env: 'production', userDataPath })
first.ingestRemote(
{
paneKey,
source: 'codex',
launchToken: 'old-generation',
payload: { state: 'working', agentType: 'codex', prompt: 'old' }
},
'ssh-1'
)
first.flushStatusPersistSync()
first.stop()
await second.start({ env: 'production', userDataPath })
second.ingestRemote(
{
paneKey,
source: 'codex',
launchToken: 'new-generation',
isReplay: true,
payload: { state: 'done', agentType: 'codex', prompt: 'stale completion' }
},
'ssh-2'
)
expect(second.getStatusSnapshot()[0]).toMatchObject({
paneKey,
state: 'working',
prompt: 'old'
})
} finally {
first.stop()
second.stop()
}
})
it('spools when the endpoint is present but the receiver is unavailable', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-spool-failure-'))
const endpointDir = join(dir, 'agent-hooks')
mkdirSync(endpointDir, { recursive: true })
const endpoint = join(endpointDir, 'endpoint.env')
writeFileSync(
endpoint,
'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\nORCA_AGENT_HOOK_ENV=production\nORCA_AGENT_HOOK_VERSION=1\n'
)
const script = join(dir, 'codex-hook.sh')
writeFileSync(script, codexInternals.getManagedScript('posix'))
chmodSync(script, 0o755)
execFileSync('/bin/sh', [script], {
input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n',
env: {
...process.env,
ORCA_AGENT_HOOK_ENDPOINT: endpoint,
ORCA_PANE_KEY: 'tab-failure:0',
ORCA_TAB_ID: 'tab-failure',
ORCA_AGENT_LAUNCH_TOKEN: 'generation-token'
},
timeout: 5000
})
const spoolFiles = readdirSync(join(endpointDir, 'spool'))
expect(spoolFiles).toHaveLength(1)
expect(readFileSync(join(endpointDir, 'spool', spoolFiles[0]!), 'utf8')).toContain(
'SubagentStop'
)
})
it('does not mark a non-terminal downtime replay as runtime-observed', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-observed-'))
const paneKey = makePaneKey('tab-observed', '00000000-0000-4000-8000-000000000002')
const launchToken = 'observed-generation'
const first = new AgentHookServer()
await first.start({ env: 'production', userDataPath })
const started = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-observed' }),
'production'
)!
first.ingestRemote(
{
paneKey,
source: 'codex',
hookEventName: 'SubagentStart',
launchToken,
payload: started.payload
},
null
)
first.flushStatusPersistSync()
first.stop()
const spoolDir = join(userDataPath, 'agent-hooks', 'spool')
mkdirSync(spoolDir, { recursive: true })
writeFileSync(
join(spoolDir, 'pane-observed.jsonl'),
`\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStart', launchToken, receivedAt: Date.now(), payload: started.payload })}\n`
)
const restarted = new AgentHookServer()
await restarted.start({ env: 'production', userDataPath })
try {
expect(restarted.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
} finally {
restarted.stop()
}
})
})
@@ -112,6 +112,39 @@ describe('parseAiVaultListResult', () => {
})
})
// #15036: a `kind` this build has never heard of used to fail the whole issue,
// and the fallback re-reported it as an unkinded entry — i.e. a newer host's
// whole-source failure came back to an older client as a skipped transcript.
it('keeps an issue whose kind this build does not know, as its own kinded row', () => {
const parsed = parseAiVaultListResult({
sessions: [validSession()],
issues: [
{
agent: 'opencode',
kind: 'source-from-a-newer-host',
path: '/home/ada/.local/share/opencode/opencode.db',
message: 'OpenCode history was skipped.'
}
],
scannedAt: '2026-07-27T00:00:00.000Z'
})
expect(parsed.issues).toEqual([
expect.objectContaining({ kind: 'scope', message: 'OpenCode history was skipped.' })
])
expect(parsed.issues.some((issue) => issue.message.includes('invalid'))).toBe(false)
})
it('leaves an unkinded issue unkinded so real skipped transcripts still count', () => {
const parsed = parseAiVaultListResult({
sessions: [validSession()],
issues: [{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }],
scannedAt: '2026-07-27T00:00:00.000Z'
})
expect(parsed.issues[0]?.kind).toBeUndefined()
})
it('rejects a malformed result envelope', () => {
expect(() => parseAiVaultListResult({ sessions: [] })).toThrow()
})
@@ -86,7 +86,13 @@ const aiVaultSessionSchema = z.object({
const aiVaultScanIssueSchema = z.object({
executionHostId: executionHostIdSchema.optional(),
agent: z.string().min(1),
kind: z.enum(['host', 'scope', 'notice']).optional(),
// Why (#15036): a `kind` this build has never heard of used to fail the whole
// issue, which the fallback re-reported as an *unkinded* "invalid entry" — i.e.
// a newer host's source-level failure came back as a skipped transcript. An
// unknown kind degrades to 'scope' (its own muted row); absent stays absent.
kind: z
.union([z.enum(['host', 'scope', 'notice']), z.string().transform(() => 'scope' as const)])
.optional(),
path: z.string(),
message: z.string()
})
@@ -3,9 +3,12 @@ import {
buildOpenCodeSqliteCandidatePath,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
import {
openCodeDatabaseScanIssue,
readOpenCodeDatabase
} from './session-scanner-opencode-sqlite-open'
import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
import type SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: the SQLite session-list query + reader lives in its own electron-free
@@ -18,12 +21,6 @@ type SessionRow = {
time_updated: number
}
function openReadonlyDatabase(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
db.pragma('query_only = ON')
return db
}
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
return (
tableExists(db, 'session') &&
@@ -48,6 +45,15 @@ function buildSessionListQuery(db: SyncDatabase, limited: boolean): string {
${limited ? 'LIMIT ?' : ''}`
}
function readSessionRows(db: SyncDatabase, limit: number): SessionRow[] {
if (!canReadOpenCodeSessions(db)) {
return []
}
const limited = Number.isFinite(limit)
const statement = db.prepare(buildSessionListQuery(db, limited))
return (limited ? statement.all(limit) : statement.all()) as SessionRow[]
}
function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
@@ -99,26 +105,17 @@ export async function listOpenCodeSqliteSessions(args: {
}): Promise<SessionFileCandidate[]> {
const candidates: SessionFileCandidate[] = []
for (const dbPath of args.dbPaths) {
let db: SyncDatabase | null = null
try {
db = openReadonlyDatabase(dbPath)
if (!canReadOpenCodeSessions(db)) {
continue
}
const limited = Number.isFinite(args.limit)
const statement = db.prepare(buildSessionListQuery(db, limited))
const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[]
const rows = readOpenCodeDatabase({
dbPath,
read: (db) => readSessionRows(db, args.limit)
})
for (const row of rows) {
candidates.push(rowToCandidate(row, dbPath))
}
} catch (err) {
args.issues.push({
agent: 'opencode',
path: dbPath,
message: errorMessage(err)
})
} finally {
db?.close()
// A whole DB failed, not one transcript: kinded so the panel says so.
args.issues.push(openCodeDatabaseScanIssue(dbPath, err))
}
}
return dedupeAndSortSqliteCandidates(candidates)
@@ -0,0 +1,238 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import { afterEach, describe, expect, it } from 'vitest'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import Database from '../sqlite/sync-database'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
import {
openCodeBusyTimeoutMs,
openCodeDatabaseScanIssue,
readOpenCodeDatabase
} from './session-scanner-opencode-sqlite-open'
// Reproduces #15036: OpenCode holds opencode.db open while it runs, so a scan
// lands mid-write. The reader had no busy timeout, failed in ~1 ms with the bare
// driver string "database is locked", and one failed DB emptied both scopes.
let tempDirs: string[] = []
let lockHolders: Worker[] = []
afterEach(async () => {
await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate()))
lockHolders = []
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
const SCHEMA = `
CREATE TABLE session (
id TEXT PRIMARY KEY,
parent_id TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
)
`
function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-open-'))
tempDirs.push(dir)
return dir
}
function seededDatabase(name: string, sessionId: string): string {
const path = join(tempDir(), name)
const db = new Database(path)
db.exec('PRAGMA journal_mode=DELETE')
db.exec(SCHEMA)
db.prepare('INSERT INTO session (id, time_created, time_updated) VALUES (?, ?, ?)').run(
sessionId,
1_700_000_000_000,
1_700_000_001_000
)
db.close()
return path
}
// The lock holder must live on another thread: sqlite3_busy_timeout sleeps
// synchronously, so a same-thread timer could never fire to release it.
const LOCK_HOLDER_SOURCE = `
const { parentPort, workerData } = require('node:worker_threads')
const { DatabaseSync } = require('node:sqlite')
const db = new DatabaseSync(workerData.path)
db.exec('BEGIN EXCLUSIVE')
db.exec("INSERT INTO session (id, time_created, time_updated) VALUES ('locked-write', 1, 1)")
parentPort.postMessage('locked')
setTimeout(() => {
db.exec('ROLLBACK')
db.close()
parentPort.postMessage('released')
}, workerData.holdMs)
`
async function holdWriteLock(path: string, holdMs: number): Promise<void> {
const worker = new Worker(LOCK_HOLDER_SOURCE, { eval: true, workerData: { path, holdMs } })
lockHolders.push(worker)
await new Promise<void>((resolve, reject) => {
worker.once('message', () => resolve())
worker.once('error', reject)
})
}
describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', () => {
it('reports the failure as a whole source, not as a skipped transcript', async () => {
const path = seededDatabase('opencode.db', 'session-a')
await holdWriteLock(path, 60_000)
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues })
expect(candidates).toEqual([])
expect(issues).toHaveLength(1)
// The panel counts only unkinded issues as skipped transcripts.
expect(issues[0]?.kind).toBe('scope')
expect(issues[0]?.message).toContain('OpenCode is writing to opencode.db')
expect(issues[0]?.message).not.toBe('database is locked')
})
it('still returns sessions from every other database', async () => {
const contended = seededDatabase('opencode.db', 'session-a')
const healthy = seededDatabase('opencode-alt.db', 'session-b')
await holdWriteLock(contended, 60_000)
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [contended, healthy],
limit: 10,
issues
})
expect(candidates).toHaveLength(1)
expect(candidates[0]?.file.path).toContain('session-b')
expect(issues).toHaveLength(1)
})
it('reads the sessions once the write finishes inside the busy timeout', async () => {
const path = seededDatabase('opencode.db', 'session-a')
// Long enough that only a real busy timeout — not a lucky fast open — survives it.
await holdWriteLock(path, 900)
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues })
expect(issues).toEqual([])
expect(candidates).toHaveLength(1)
expect(candidates[0]?.file.path).toContain('session-a')
})
})
describe('readOpenCodeDatabase', () => {
it('closes the handle on the success path', () => {
const path = seededDatabase('opencode.db', 'session-a')
let captured: Database.Database | null = null
const rows = readOpenCodeDatabase({
dbPath: path,
read: (db) => {
captured = db
return db.prepare('SELECT id FROM session').all()
}
})
expect(rows).toEqual([{ id: 'session-a' }])
expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i)
})
it('closes the handle when the read throws', () => {
const path = seededDatabase('opencode.db', 'session-a')
let captured: Database.Database | null = null
expect(() =>
readOpenCodeDatabase({
dbPath: path,
read: (db) => {
captured = db
throw new Error('read blew up')
}
})
).toThrow('read blew up')
expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i)
})
})
describe('openCodeBusyTimeoutMs', () => {
// Measured on a real Windows host against a real distro: a read over
// \\wsl.localhost fails whatever the timeout, and the wait is not bounded by
// it -- 1500 took ~2400 ms, 5000 took ~7250 ms, 0 failed in ~21 ms. Waiting
// there is dead time on every scan, and enough such databases would spend the
// list worker's whole deadline.
it('does not wait on a share where the wait provably cannot succeed', () => {
expect(openCodeBusyTimeoutMs('\\\\wsl.localhost\\Ubuntu\\home\\ada\\opencode.db')).toBe(0)
expect(openCodeBusyTimeoutMs('\\\\wsl$\\Ubuntu\\home\\ada\\opencode.db')).toBe(0)
})
it('still waits on a path where a writer really can be holding the lock', () => {
expect(openCodeBusyTimeoutMs('C:\\Users\\ada\\opencode.db')).toBe(1_500)
expect(openCodeBusyTimeoutMs('/home/ada/.local/share/opencode/opencode.db')).toBe(1_500)
})
})
describe('openCodeDatabaseScanIssue', () => {
const cantOpen = Object.assign(new Error('unable to open database file'), { errcode: 14 })
it('names the wal-index over a WSL share rather than repeating the driver string', () => {
const issue = openCodeDatabaseScanIssue(
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db',
cantOpen
)
expect(issue.kind).toBe('scope')
expect(issue.message).toContain('\\\\wsl.localhost')
// Checkpointing cannot fix a share that refuses SQLite's locks, so the copy
// must not send the user after the write-ahead log.
expect(issue.message).not.toContain('write-ahead log')
})
it('keeps the advice generic for a non-WSL path', () => {
const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', cantOpen)
expect(issue.message).not.toContain('wsl.localhost')
expect(issue.message).toContain('write-ahead log')
})
// Measured against a real Ubuntu-24.04 distro: Windows cannot take SQLite's
// locks over \\wsl.localhost at all. An idle, never-WAL database answers
// SQLITE_BUSY the same way, while the identical bytes read fine once copied
// to local disk — so no busy timeout and no journal mode changes the outcome.
it('does not blame a live writer for a lock-family error over a WSL share', () => {
const busy = Object.assign(new Error('database is locked'), { errcode: 5 })
const issue = openCodeDatabaseScanIssue(
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db',
busy
)
expect(issue.message).not.toContain('is writing to')
expect(issue.message).toContain('inside the distro')
})
it('still blames a live writer for the same error on a local path', () => {
const busy = Object.assign(new Error('database is locked'), { errcode: 5 })
const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', busy)
expect(issue.message).toContain('is writing to')
})
it('reports anything else with its underlying cause', () => {
const issue = openCodeDatabaseScanIssue(
'/home/ada/.local/share/opencode/opencode.db',
Object.assign(new Error('database disk image is malformed'), { errcode: 11 })
)
expect(issue.kind).toBe('scope')
expect(issue.message).not.toContain('write-ahead log')
expect(issue.message).toContain('database disk image is malformed')
})
})
@@ -0,0 +1,105 @@
import { basename } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { isWslUncPath } from '../../shared/wsl-paths'
import SyncDatabase from '../sqlite/sync-database'
import { classifySqliteReadFailure } from '../sqlite/sqlite-read-failure'
import { errorMessage } from './session-scanner-values'
// Why (#15036): the read used to inherit sqlite3's 0 ms busy timeout, so a
// genuinely contended open failed in ~1 ms. WAL readers almost never block on a
// writer, so this is insurance rather than the fix -- it stays a single bounded
// open, with no retry loop on top of sqlite's own busy handler.
const OPENCODE_SQLITE_BUSY_TIMEOUT_MS = 1_500
/**
* Open one OpenCode database for reading.
*
* Read-only, plus `query_only` as a belt-and-suspenders guard so a bug in a
* SELECT list can never mutate the user's opencode.db.
* @param dbPath - Absolute path to an opencode.db file.
* @returns An open handle the caller must close.
*/
/**
* Busy timeout for one database.
*
* Zero over the WSL share because waiting there is provably useless: Windows
* cannot take SQLite's locks over \\wsl.localhost at all, so the open fails
* whatever the timeout. Measured on a real host, the wait is not even bounded
* by the value — timeout 1500 took ~2400 ms and timeout 5000 took ~7250 ms,
* while 0 failed in ~21 ms. Paying that per database, per scan, buys nothing
* and enough such databases would spend the list worker's whole 30 s deadline.
* @param dbPath - Absolute path to an opencode.db file.
* @returns Milliseconds to let SQLite's busy handler wait.
*/
export function openCodeBusyTimeoutMs(dbPath: string): number {
return isWslUncPath(dbPath) ? 0 : OPENCODE_SQLITE_BUSY_TIMEOUT_MS
}
function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, {
readonly: true,
fileMustExist: true,
timeout: openCodeBusyTimeoutMs(dbPath)
})
db.pragma('query_only = ON')
return db
}
/**
* Run one synchronous read against an OpenCode database.
* @param args.dbPath - Absolute path to an opencode.db file.
* @param args.read - Synchronous reader; its result must be fully materialized.
* @returns The reader's value; rethrows whatever SQLite raised.
*/
export function readOpenCodeDatabase<T>(args: {
dbPath: string
read: (db: SyncDatabase) => T
}): T {
const db = openOpenCodeDatabaseReadonly(args.dbPath)
try {
return args.read(db)
} finally {
db.close()
}
}
/**
* Describe a whole-database read failure as a scan issue.
*
* Kinded so the panel renders this copy instead of counting a failed *source*
* as a skipped *transcript*; `scope` rather than a new member — see
* `aiVaultScanIssueSchema` in session-list-result-validation.ts.
* @param dbPath - Absolute path to the opencode.db that could not be read.
* @param error - The thrown value from the read.
* @returns A kinded scan issue with actionable copy.
*/
export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVaultScanIssue {
const name = basename(dbPath)
// `fileMustExist` already proved the database file is there before SQLite ran,
// so this needs no fs probe of its own — an extra sync stat on a 9p share is
// exactly the hang the WSL transcript gate exists to prevent.
const kind = classifySqliteReadFailure({ error, databaseFileExists: true })
// Why: measured against a real distro. Windows cannot take SQLite's file locks
// over \\wsl.localhost at all — an idle, never-WAL, nothing-attached database
// answers SQLITE_BUSY just the same, and a 5 s busy timeout does not change it,
// while the identical bytes copied to local disk open fine. So on this share a
// lock-family error never means "a writer holds it", and no timeout can help.
const overWslShare = isWslUncPath(dbPath)
const detail =
kind === 'contended' && !overWslShare
? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.`
: kind === 'unreadable'
? `OpenCode history in ${name} could not be read: ${errorMessage(error)}`
: `OpenCode history in ${name} could not be read. ${unreadableShareAdvice(dbPath)}`
return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail }
}
function unreadableShareAdvice(dbPath: string): string {
// Named only when the evidence supports it; a generic share gets generic copy.
// Deliberately not "flush the write-ahead log": checkpointing changes nothing
// here, and telling the user to try it would send them after a fix that cannot
// work. The share itself is the blocker.
return isWslUncPath(dbPath)
? 'Windows cannot open SQLite databases over the \\\\wsl.localhost share, so this history has to be read from inside the distro.'
: 'Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.'
}
@@ -94,8 +94,10 @@ export class OpenCodeSqliteWorkerClient {
return value.candidates
} catch (err) {
if (err instanceof OpenCodeSqliteWorkerUnavailableError) {
// Kinded: a whole source failed, not a transcript.
args.issues.push({
agent: 'opencode',
kind: 'scope',
path: args.dbPaths[0] ?? 'opencode.db',
message:
'OpenCode history was skipped because its background scanner could not start; the app remains responsive.'
@@ -106,6 +108,7 @@ export class OpenCodeSqliteWorkerClient {
// scan, surfaced as one scan issue rather than an unbounded stall.
args.issues.push({
agent: 'opencode',
kind: 'scope',
path: args.dbPaths[0] ?? 'opencode.db',
message: `OpenCode history scan did not complete: ${errorMessage(err)}`
})
@@ -9,8 +9,9 @@ import {
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
import { normalizeTitleText } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
import type SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: OpenCode 1.17.x migrated session storage from per-session JSON files
@@ -53,14 +54,6 @@ type PreviewRow = {
summary_body: string | null
}
function openReadonlyDatabase(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
// Why: belt-and-suspenders guard so a bug in the SELECT list can never
// mutate the user's opencode.db.
db.pragma('query_only = ON')
return db
}
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
return (
tableExists(db, 'session') &&
@@ -256,90 +249,97 @@ export async function parseOpenCodeSqliteSession(args: {
sessionId: string
platform: NodeJS.Platform
}): Promise<AiVaultSession | null> {
const { dbPath, sessionId, platform } = args
let db: SyncDatabase | null = null
try {
db = openReadonlyDatabase(dbPath)
if (!canReadOpenCodeSessions(db)) {
return null
}
const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined
if (!row || row.id !== sessionId) {
return null
}
return readOpenCodeDatabase({
dbPath: args.dbPath,
read: (db) => readSession({ db, ...args })
})
}
// Extracted so the open wrapper owns the handle's lifetime.
function readSession(args: {
db: SyncDatabase
dbPath: string
sessionId: string
platform: NodeJS.Platform
}): AiVaultSession | null {
const { db, dbPath, sessionId, platform } = args
if (!canReadOpenCodeSessions(db)) {
return null
}
const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined
if (!row || row.id !== sessionId) {
return null
}
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
? row.time_updated
: row.time_created
// Why: discovery uses a synthetic db#session path only for parser routing.
// The UI's log open/reveal actions need a real filesystem path.
const accumulator = createAccumulator({
agent: 'opencode',
file: {
path: dbPath,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
},
sessionId
})
accumulator.title = normalizeTitleText(row.title ?? '')
accumulator.cwd = row.directory
accumulator.model = extractModelId(row.model_json)
accumulator.totalTokens =
(row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0)
accumulator.messageCount = row.message_count ?? 0
updateTimeline(accumulator, row.time_created)
updateTimeline(accumulator, row.time_updated)
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
? row.time_updated
: row.time_created
// Why: discovery uses a synthetic db#session path only for parser routing.
// The UI's log open/reveal actions need a real filesystem path.
const accumulator = createAccumulator({
agent: 'opencode',
file: {
path: dbPath,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
},
sessionId
})
accumulator.title = normalizeTitleText(row.title ?? '')
accumulator.cwd = row.directory
accumulator.model = extractModelId(row.model_json)
accumulator.totalTokens =
(row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0)
accumulator.messageCount = row.message_count ?? 0
updateTimeline(accumulator, row.time_created)
updateTimeline(accumulator, row.time_updated)
const previewSql = buildPreviewQuery(db)
if (previewSql) {
// Why: SQL already dropped anything older than the newest-N window, so the
// accumulator never shifts and cannot detect the truncation itself. Ask for
// one extra row so an exactly-full window is not mistaken for a trimmed one.
const probedRows = db
.prepare(previewSql)
.all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[]
if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) {
accumulator.previewMessagesTruncated = true
const previewSql = buildPreviewQuery(db)
if (previewSql) {
// Why: SQL already dropped anything older than the newest-N window, so the
// accumulator never shifts and cannot detect the truncation itself. Ask for
// one extra row so an exactly-full window is not mistaken for a trimmed one.
const probedRows = db
.prepare(previewSql)
.all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[]
if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) {
accumulator.previewMessagesTruncated = true
}
// Newest-first, so the probe row to drop is the oldest one at the tail.
const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT)
// Why: query returns newest-first; push in chronological order so the
// accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT
// messages.
for (let i = previewRows.length - 1; i >= 0; i--) {
const previewRow = previewRows[i]
if (!previewRow) {
continue
}
// Newest-first, so the probe row to drop is the oldest one at the tail.
const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT)
// Why: query returns newest-first; push in chronological order so the
// accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT
// messages.
for (let i = previewRows.length - 1; i >= 0; i--) {
const previewRow = previewRows[i]
if (!previewRow) {
continue
}
const text = extractPartText(previewRow.part_data)
if (!text) {
continue
}
addPreviewMessage(accumulator, {
role: mapPreviewRole(previewRow.role),
text,
timestamp: previewRow.time_created,
// Preview window is newest-N; first-prompt is loaded separately below.
seedFirstUserPrompt: false
})
if (previewRow.role === 'user' && !accumulator.title) {
accumulator.title =
normalizeTitleText(previewRow.summary_title ?? '') ||
normalizeTitleText(previewRow.summary_body ?? '')
}
const text = extractPartText(previewRow.part_data)
if (!text) {
continue
}
addPreviewMessage(accumulator, {
role: mapPreviewRole(previewRow.role),
text,
timestamp: previewRow.time_created,
// Preview window is newest-N; first-prompt is loaded separately below.
seedFirstUserPrompt: false
})
if (previewRow.role === 'user' && !accumulator.title) {
accumulator.title =
normalizeTitleText(previewRow.summary_title ?? '') ||
normalizeTitleText(previewRow.summary_body ?? '')
}
}
}
// Why: list preview only joins the newest messages. On-demand copy needs the
// session's earliest real user text part, not a later turn still in the window.
if (shouldCaptureFullFirstUserPrompt()) {
accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId)
}
// Why: list preview only joins the newest messages. On-demand copy needs the
// session's earliest real user text part, not a later turn still in the window.
if (shouldCaptureFullFirstUserPrompt()) {
accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId)
}
return finalizeSession(accumulator, platform)
} finally {
db?.close()
}
return finalizeSession(accumulator, platform)
}
+4 -1
View File
@@ -1,5 +1,6 @@
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue,
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
@@ -55,10 +56,12 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// Why: some Antigravity events arrive without stdin but still need a
// status post, so the shared capture maps empty input to an object.
...buildPosixHookPayloadCapture('empty-object'),
...buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT'),
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
@@ -76,7 +79,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
+4 -1
View File
@@ -17,6 +17,7 @@ import {
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue,
WINDOWS_HOOK_STDIN_DRAIN_LABEL
@@ -96,6 +97,7 @@ function getManagedScript(
// Why: Claude-compatible permission hooks fail closed on empty stdout (#14818).
'printf "{}\\n"',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('claude'),
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
@@ -115,6 +117,7 @@ function getManagedScript(
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: post form fields because path-bearing payloads are unsafe in hand-built JSON.
@@ -129,7 +132,7 @@ function getManagedScript(
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
@@ -1,170 +0,0 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { _internals } from './legacy-wsl-runtime-auth-drain'
const isWindows = process.platform === 'win32'
const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n'
const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n'
const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n'
// Codex truncates before it writes, so a read landing mid-rotation sees this.
const TORN_AUTH = '{"tokens":{"exp'
const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n'
const TORN_CREDENTIALS = '{"server":'
function sha256(contents: string): string {
return createHash('sha256').update(contents).digest('hex')
}
/**
* Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen
* hash call can rewrite the source underneath the script. That is the only way
* to land Codex's in-place rotation inside the window the script itself opens.
*/
function runApplyScript(
options: {
rewriteSourceAfterHashCall?: number
rewriteBytes?: string
rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth'
sourceCredentials?: string
} = {}
): {
legacyAuth: string
status: number
targetAuth: string
targetCredentials: string | null
} {
const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-'))
const legacyHome = join(root, 'legacy')
const targetHome = join(root, 'account')
const binDir = join(root, 'bin')
for (const dir of [legacyHome, targetHome, binDir]) {
mkdirSync(dir, { recursive: true })
}
const legacyAuthPath = join(legacyHome, 'auth.json')
const targetAuthPath = join(targetHome, 'auth.json')
const legacyCredentialsPath = join(legacyHome, '.credentials.json')
const targetCredentialsPath = join(targetHome, '.credentials.json')
writeFileSync(legacyAuthPath, SOURCE_AUTH)
writeFileSync(targetAuthPath, TARGET_AUTH)
if (options.sourceCredentials !== undefined) {
writeFileSync(legacyCredentialsPath, options.sourceCredentials)
}
const counterPath = join(root, 'hash-calls')
writeFileSync(counterPath, '0')
const shimPath = join(binDir, 'sha256sum')
writeFileSync(
shimPath,
`#!/usr/bin/env node
const { createHash } = require('node:crypto')
const fs = require('node:fs')
const file = process.argv[process.argv.length - 1]
process.stdout.write(
createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n'
)
const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1
fs.writeFileSync(process.env.HASH_COUNTER, String(calls))
if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) {
fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES)
}
`
)
chmodSync(shimPath, 0o755)
let status = 0
try {
execFileSync(
'/bin/sh',
[
'-c',
_internals.applyLegacyAuthScript,
'sh',
legacyHome,
join(root, 'absent-active-home'),
join(root, 'absent-marker.json'),
targetHome,
sha256(SOURCE_AUTH),
sha256(TARGET_AUTH),
'1',
'0',
options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials)
],
{
encoding: 'utf8',
env: {
...process.env,
HASH_COUNTER: counterPath,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
REWRITE_AFTER: options.rewriteSourceAfterHashCall
? String(options.rewriteSourceAfterHashCall)
: '',
REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH,
REWRITE_TARGET:
options.rewriteTarget === 'source-credentials'
? legacyCredentialsPath
: options.rewriteTarget === 'target-auth'
? targetAuthPath
: legacyAuthPath
},
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 20_000
}
)
} catch (error) {
status = (error as { status?: number }).status ?? -1
}
return {
legacyAuth: readFileSync(legacyAuthPath, 'utf8'),
status,
targetAuth: readFileSync(targetAuthPath, 'utf8'),
targetCredentials: existsSync(targetCredentialsPath)
? readFileSync(targetCredentialsPath, 'utf8')
: null
}
}
describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => {
it('promotes the validated source into the account home', () => {
const outcome = runApplyScript()
expect(outcome.targetAuth).toBe(SOURCE_AUTH)
})
it('leaves the legacy home untouched while promoting', () => {
// One-directional: the promote step must never write back to the old home.
expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH)
})
it('refuses torn bytes and leaves the account home intact', () => {
// Hash call 1 is the source pre-check; rotating right after it means `cp`
// reads bytes freshness never judged. Pre-guard, those reached the target.
const outcome = runApplyScript({ rewriteSourceAfterHashCall: 1 })
expect(outcome.status).toBe(42)
expect(outcome.targetAuth).toBe(TARGET_AUTH)
})
it('refuses MCP credentials that changed after host validation', () => {
const outcome = runApplyScript({
rewriteSourceAfterHashCall: 2,
rewriteBytes: TORN_CREDENTIALS,
rewriteTarget: 'source-credentials',
sourceCredentials: SOURCE_CREDENTIALS
})
expect(outcome.status).toBe(43)
expect(outcome.targetCredentials).toBeNull()
})
it('does not overwrite auth changed after the destination hash check', () => {
const outcome = runApplyScript({
rewriteBytes: NEWER_AUTH,
rewriteSourceAfterHashCall: 4,
rewriteTarget: 'target-auth'
})
expect(outcome.status).toBe(39)
expect(outcome.targetAuth).toBe(NEWER_AUTH)
})
})
@@ -1,212 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { runWslProcessMock } = vi.hoisted(() => ({
runWslProcessMock: vi.fn()
}))
vi.mock('../wsl/wsl-runner', () => ({
runWslProcess: runWslProcessMock
}))
import {
_internals,
drainLegacyWslRuntimeAuth,
startLegacyWslRuntimeAuthDrain
} from './legacy-wsl-runtime-auth-drain'
import { readWslCodexAuths } from './wsl-codex-auth-batch-reader'
const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n'
const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n'
const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n'
function inspection(auth: string, credentials?: string): string {
return [
Buffer.from(auth).toString('base64'),
credentials === undefined ? 'missing' : 'present',
credentials === undefined ? '' : Buffer.from(credentials).toString('base64')
].join('\n')
}
function result(code: number, stdout = '') {
return {
code,
stdout,
stderr: '',
timedOut: false,
environmentResolved: true
}
}
describe('legacy WSL runtime auth drain', () => {
beforeEach(() => {
runWslProcessMock.mockReset()
_internals.resetDrainQueue()
})
it('promotes fresher auth guest-side while a legacy pane remains', async () => {
runWslProcessMock
.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
.mockResolvedValueOnce(result(0))
const resolveDestination = vi.fn(() => ({
authContents: STALE_AUTH,
linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home'
}))
await drainLegacyWslRuntimeAuth({
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: true,
resolveDestination
})
expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH)
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([
'/home/alice/.local/share/orca/codex-accounts/account-1/home',
expect.any(String),
expect.any(String),
'1',
'0',
'missing'
])
expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f')
expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=')
expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600')
})
it('does not write when freshness cannot be proven', async () => {
runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n')))
await drainLegacyWslRuntimeAuth({
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: false,
resolveDestination: () => ({
authContents: '{"tokens":{}}\n',
linuxHomePath: '/home/alice/.codex'
})
})
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
})
it('refuses a source with no unique destination', async () => {
runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
await drainLegacyWslRuntimeAuth({
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: false,
resolveDestination: () => null
})
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
})
it('retires stale legacy auth only after the last recorded pane exits', async () => {
runWslProcessMock
.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH)))
.mockResolvedValueOnce(result(0))
await drainLegacyWslRuntimeAuth({
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: false,
resolveDestination: () => ({
authContents: NEWER_AUTH,
linuxHomePath: '/home/alice/.codex'
})
})
expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing'])
})
it('keeps an absent source retryable while a legacy pane remains', async () => {
runWslProcessMock.mockResolvedValueOnce(result(21))
await drainLegacyWslRuntimeAuth({
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: true,
resolveDestination: vi.fn()
})
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
})
it('does nothing after the guest-side completion marker is present', async () => {
runWslProcessMock.mockResolvedValueOnce(result(20))
const resolveDestination = vi.fn()
await drainLegacyWslRuntimeAuth({
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: false,
resolveDestination
})
expect(resolveDestination).not.toHaveBeenCalled()
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
})
it('marks an absent source complete after every legacy pane exits', async () => {
runWslProcessMock.mockResolvedValueOnce(result(21)).mockResolvedValueOnce(result(0))
await drainLegacyWslRuntimeAuth({
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: false,
resolveDestination: vi.fn()
})
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([
'/home/alice/.local/share/orca/codex-runtime-home/home',
'/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home',
'/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json'
])
})
it('coalesces concurrent drain triggers instead of queueing every poll', async () => {
runWslProcessMock.mockImplementation(() => new Promise(() => {}))
const options = {
distro: 'Ubuntu',
guestHomeLinuxPath: '/home/alice',
legacyPanePresent: true,
resolveDestination: () => null
}
startLegacyWslRuntimeAuthDrain(options)
startLegacyWslRuntimeAuthDrain(options)
await Promise.resolve()
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
})
it('reads every candidate home in one bounded guest process', async () => {
runWslProcessMock.mockResolvedValueOnce(
result(
0,
[`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join(
'\n'
)
)
)
await expect(
readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'])
).resolves.toEqual([
{ kind: 'present', contents: SOURCE_AUTH },
{ kind: 'missing' },
{ kind: 'unreadable' }
])
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
expect(runWslProcessMock).toHaveBeenCalledWith(
expect.objectContaining({
args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'],
maxOutputBytes: 2 * 1024 * 1024,
timeoutMs: 5_000
})
)
})
})
@@ -1,319 +0,0 @@
import { createHash } from 'node:crypto'
import { posix as pathPosix } from 'node:path'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { runWslProcess } from '../wsl/wsl-runner'
import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity'
import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader'
const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json'
const MARKER_PRESENT_EXIT = 20
const SOURCE_AUTH_ABSENT_EXIT = 21
export type LegacyWslRuntimeAuthDestination = {
authContents: string
linuxHomePath: string
}
type LegacyWslRuntimeInspection = {
authContents: string
credentials: { kind: 'missing' } | { kind: 'present'; contents: string }
}
type LegacyWslRuntimeAuthDrainOptions = {
distro: string
guestHomeLinuxPath: string
legacyPanePresent: boolean
resolveDestination: (
runtimeAuthContents: string
) => LegacyWslRuntimeAuthDestination | null | Promise<LegacyWslRuntimeAuthDestination | null>
}
const drainQueueByDistro = new Map<string, Promise<void>>()
const completedDistroKeys = new Set<string>()
export function startLegacyWslRuntimeAuthDrain(
options: LegacyWslRuntimeAuthDrainOptions
): Promise<void> {
const key = options.distro.trim().toLowerCase()
if (completedDistroKeys.has(key)) {
return Promise.resolve()
}
// Coalesce launch/rate-limit callers while a drain is in flight. Queuing a
// new pass for every poll can otherwise build an unbounded promise chain
// while a legacy pane keeps the migration pending.
const inFlight = drainQueueByDistro.get(key)
if (inFlight) {
return inFlight
}
const next = drainLegacyWslRuntimeAuth(options)
.then((status) => {
if (status === 'complete') {
completedDistroKeys.add(key)
}
})
.catch((error) => {
console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error)
})
drainQueueByDistro.set(key, next)
void next.finally(() => {
if (drainQueueByDistro.get(key) === next) {
drainQueueByDistro.delete(key)
}
})
return next
}
export async function drainLegacyWslRuntimeAuth(
options: LegacyWslRuntimeAuthDrainOptions
): Promise<'complete' | 'pending'> {
const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath)
const inspection = await runWslProcess({
distro: options.distro,
loginPath: 'none',
script: INSPECT_LEGACY_AUTH_SCRIPT,
args: [paths.runtimeHome, paths.activeHome, paths.marker],
timeoutMs: 5_000,
maxOutputBytes: 2 * 1024 * 1024
})
if (inspection.code === MARKER_PRESENT_EXIT) {
return 'complete'
}
if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) {
if (!options.legacyPanePresent) {
await finalizeAbsentLegacyAuth(options.distro, paths)
return 'complete'
}
return 'pending'
}
assertSuccessfulDrainStep('inspect', inspection)
const inspected = parseLegacyRuntimeInspection(inspection.stdout)
if (!inspected) {
return 'pending'
}
const destination = await options.resolveDestination(inspected.authContents)
if (!destination) {
return 'pending'
}
const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents)
if (freshness === null) {
return 'pending'
}
const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents)
const result = await runWslProcess({
distro: options.distro,
loginPath: 'none',
script: APPLY_LEGACY_AUTH_SCRIPT,
args: [
paths.runtimeHome,
paths.activeHome,
paths.marker,
destination.linuxHomePath,
sha256(inspected.authContents),
sha256(destination.authContents),
promoteAuth ? '1' : '0',
options.legacyPanePresent ? '0' : '1',
inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing'
],
timeoutMs: 5_000,
maxOutputBytes: 16 * 1024
})
assertSuccessfulDrainStep('apply', result)
return options.legacyPanePresent ? 'pending' : 'complete'
}
function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null {
const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n')
const authContents = decodeWslBase64Payload(authBase64 ?? '')
if (authContents === null) {
return null
}
if (credentialsKind === 'missing') {
return { authContents, credentials: { kind: 'missing' } }
}
if (credentialsKind !== 'present') {
return null
}
const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '')
if (!credentialsContents || !isJsonObject(credentialsContents)) {
return null
}
return { authContents, credentials: { kind: 'present', contents: credentialsContents } }
}
function isJsonObject(contents: string): boolean {
try {
const value = JSON.parse(contents) as unknown
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
} catch {
return false
}
}
function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): {
activeHome: string
marker: string
runtimeHome: string
} {
const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS)
const runtimeRoot = pathPosix.dirname(runtimeHome)
return {
activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'),
marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME),
runtimeHome
}
}
async function finalizeAbsentLegacyAuth(
distro: string,
paths: ReturnType<typeof resolveLegacyRuntimePaths>
): Promise<void> {
const result = await runWslProcess({
distro,
loginPath: 'none',
script: FINALIZE_ABSENT_AUTH_SCRIPT,
args: [paths.runtimeHome, paths.activeHome, paths.marker],
timeoutMs: 5_000,
maxOutputBytes: 16 * 1024
})
assertSuccessfulDrainStep('finalize', result)
}
function assertSuccessfulDrainStep(
step: string,
result: { code: number | null; stderr: string; timedOut: boolean }
): void {
if (result.code === 0 && !result.timedOut) {
return
}
const detail = result.stderr.trim()
throw new Error(
`Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}`
)
}
function sha256(contents: string): string {
return createHash('sha256').update(contents).digest('hex')
}
const RESOLVE_LEGACY_HOME_SCRIPT = `
legacy_home="$1"
legacy_home_resolved=0
if [ -e "$1" ] || [ -L "$1" ]; then
legacy_home=$(readlink -f -- "$1") || exit 30
legacy_home_resolved=1
fi
if [ -e "$2" ] || [ -L "$2" ]; then
active_home=$(readlink -f -- "$2") || exit 31
if [ "$legacy_home_resolved" = 1 ]; then
[ "$active_home" = "$legacy_home" ] || exit 32
else
legacy_home="$active_home"
fi
fi
`
const INSPECT_LEGACY_AUTH_SCRIPT = `
set -eu
[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT}
${RESOLVE_LEGACY_HOME_SCRIPT}
source_auth="$legacy_home/auth.json"
[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT}
encode_file() {
encoded=$(base64 < "$1") || return 1
printf '%s' "$encoded" | tr -d '\n'
}
encode_file "$source_auth"
printf '\n'
source_credentials="$legacy_home/.credentials.json"
if [ -f "$source_credentials" ]; then
printf 'present\n'
encode_file "$source_credentials"
printf '\n'
elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then
printf 'missing\n\n'
else
exit 44
fi
`
const APPLY_LEGACY_AUTH_SCRIPT = `
set -eu
[ ! -f "$3" ] || exit 0
${RESOLVE_LEGACY_HOME_SCRIPT}
target_home=$(readlink -f -- "$4") || exit 33
[ "$legacy_home" != "$target_home" ] || exit 34
source_auth="$legacy_home/auth.json"
target_auth="$target_home/auth.json"
[ -f "$source_auth" ] || exit 35
[ -f "$target_auth" ] || exit 36
hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; }
[ "$(hash_file "$source_auth")" = "$5" ] || exit 37
[ "$(hash_file "$target_auth")" = "$6" ] || exit 38
umask 077
temporary_auth="$target_auth.orca-drain-$$"
temporary_credentials="$target_home/.credentials.json.orca-drain-$$"
temporary_previous_auth="$target_auth.orca-drain-previous-$$"
temporary_marker="$3.orca-drain-$$"
cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; }
trap cleanup EXIT HUP INT TERM
source_credentials="$legacy_home/.credentials.json"
target_credentials="$target_home/.credentials.json"
if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then
[ "$9" != missing ] || exit 43
[ "$(hash_file "$source_credentials")" = "$9" ] || exit 43
cp -- "$source_credentials" "$temporary_credentials"
chmod 600 "$temporary_credentials"
[ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43
[ "$(hash_file "$source_credentials")" = "$9" ] || exit 43
mv -n -- "$temporary_credentials" "$target_credentials"
elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then
[ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43
fi
if [ "$7" = 1 ]; then
cp -- "$source_auth" "$temporary_auth"
chmod 600 "$temporary_auth"
# Codex rewrites auth.json in place, so this copy is a second read: verify the
# bytes being promoted, not the ones freshness was judged on.
[ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42
[ "$(hash_file "$target_auth")" = "$6" ] || exit 39
# The hard link keeps the destination inode observable without creating a
# missing-path crash window. In-place writers update both names.
ln -- "$target_auth" "$temporary_previous_auth"
[ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39
mv -f -- "$temporary_auth" "$target_auth"
if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then
mv -f -- "$temporary_previous_auth" "$target_auth"
exit 39
fi
rm -- "$temporary_previous_auth"
fi
if [ "$8" = 1 ]; then
[ "$(hash_file "$source_auth")" = "$5" ] || exit 40
rm -- "$source_auth"
printf '%s\n' '{"completed":true}' > "$temporary_marker"
chmod 600 "$temporary_marker"
mv -f -- "$temporary_marker" "$3"
fi
`
const FINALIZE_ABSENT_AUTH_SCRIPT = `
set -eu
[ ! -f "$3" ] || exit 0
${RESOLVE_LEGACY_HOME_SCRIPT}
[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41
umask 077
temporary_marker="$3.orca-drain-$$"
trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM
printf '%s\n' '{"completed":true}' > "$temporary_marker"
chmod 600 "$temporary_marker"
mv -f -- "$temporary_marker" "$3"
`
export const _internals = {
applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT,
resetDrainQueue: (): void => {
drainQueueByDistro.clear()
completedDistroKeys.clear()
}
}
+360 -169
View File
@@ -1,4 +1,5 @@
/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
import {
appendFileSync,
copyFileSync,
@@ -17,6 +18,7 @@ import {
unlinkSync
} from 'node:fs'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { execFileSync } from 'node:child_process'
import {
dirname,
extname,
@@ -31,6 +33,7 @@ import { app } from 'electron'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { Store } from '../persistence'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import {
recoverInterruptedGuardedFileOperation,
removeFileAtomicallyIfUnchanged,
@@ -52,13 +55,16 @@ import {
resolveWslCodexSessionSourceHome
} from '../codex/codex-session-source-home'
import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge'
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
import {
prepareSystemConfigForFreshRuntimeMirror,
syncSystemConfigIntoManagedCodexHome
} from '../codex/codex-config-mirror'
import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths'
import {
getCodexSelectionLaneKey,
getWslSelectionKey,
getSelectedCodexAccountIdForTarget,
normalizeCodexRuntimeSelection,
setSelectedCodexAccountIdForTarget,
type CodexAccountSelectionTarget
} from './runtime-selection'
import { getDefaultWslDistro, getWslHome } from '../wsl'
@@ -85,16 +91,10 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace'
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
import {
getCodexPaneAccount,
hasRecordedLegacyWslCodexPane,
hasRecordedLegacySharedCodexPane,
type CodexPaneHomeRoute
} from '../codex/codex-pane-account-registry'
import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env'
import {
startLegacyWslRuntimeAuthDrain,
type LegacyWslRuntimeAuthDestination
} from './legacy-wsl-runtime-auth-drain'
import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader'
type CodexSystemDefaultSnapshot = {
authJson: string | null
@@ -130,6 +130,7 @@ type CodexRuntimeLogoutMarkerStatus =
| { kind: 'applies' }
| { kind: 'system-default-changed'; systemDefaultAuthJson: string | null }
type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected'
type CodexReadBackMatch =
| {
kind: 'matched'
@@ -194,6 +195,11 @@ export class CodexRuntimeHomeService {
private lastSyncedAccountId: string | null = null
// Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt).
private lastWrittenAuthJson: string | null = null
// Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage.
private readonly lastWrittenWslAuthJsonByDistro = new Map<string, string | null>()
private readonly lastSyncedWslAccountIdByDistro = new Map<string, string | null>()
private readonly wslRuntimeHomePathByDistro = new Map<string, string>()
private skipNextReadBackForAccountId: string | null = null
// Why: a managed host account refreshes auth in its own home. Remember that
// provenance so a later deselect never adopts stale shared bytes.
private lastHostAccountUsedSelfContainedHome = false
@@ -238,11 +244,11 @@ export class CodexRuntimeHomeService {
): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const homePath = this.getWslCodexHomePathForSelection(wslTarget)
this.startLegacyWslAuthDrain(wslTarget)
this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath)
this.startWslSessionBridgeForLaunch(wslTarget, homePath)
return homePath
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath)
const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
return runtimeHomePath
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
if (selfContainedAccount) {
@@ -718,6 +724,26 @@ export class CodexRuntimeHomeService {
syncLegacySharedCodexConfigForRetainedPanes()
}
syncActiveWslSelectionsBeforeRestart(): void {
if (process.platform !== 'win32') {
return
}
const settings = this.store.getSettings()
for (const [selectedDistroKey, accountId] of Object.entries(
normalizeCodexRuntimeSelection(settings).wsl
)) {
if (!accountId) {
continue
}
const account = this.getActiveAccount(settings.codexManagedAccounts, accountId)
if (!account || account.managedHomeRuntime !== 'wsl') {
continue
}
this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
}
}
private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null {
if (process.platform !== 'win32') {
return null
@@ -764,9 +790,10 @@ export class CodexRuntimeHomeService {
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
return {
kind: 'ready',
codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget)
codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
}
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
@@ -806,7 +833,7 @@ export class CodexRuntimeHomeService {
launchEnv?: NodeJS.ProcessEnv
): void {
if (target?.runtime === 'wsl') {
this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target))
this.syncWslRuntimeForCurrentSelection(target)
return
}
@@ -853,6 +880,7 @@ export class CodexRuntimeHomeService {
// distro-local runtime home, so the host mirror only drops its baseline.
this.lastSyncedAccountId = null
this.lastWrittenAuthJson = null
this.skipNextReadBackForAccountId = null
return
}
if (normalizeCodexRuntimeSelection(settings).host) {
@@ -894,13 +922,71 @@ export class CodexRuntimeHomeService {
}
}
// Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline.
// Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens.
clearLastWrittenAuthJson(
accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host
): void {
if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) {
this.lastWrittenAuthJson = null
}
this.skipNextReadBackForAccountId = accountId
}
private readBackRefreshedTokensFromPath(
runtimeAuthPath: string,
options: {
updateLastWrittenAuthJson: boolean
lastWrittenAuthJson?: string | null
setLastWrittenAuthJson?: (contents: string) => void
expectedAccountId?: string
}
): CodexReadBackResult {
try {
if (!existsSync(runtimeAuthPath)) {
return 'unchanged'
}
const lastWrittenAuthJson =
options.lastWrittenAuthJson === undefined
? this.lastWrittenAuthJson
: options.lastWrittenAuthJson
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) {
return 'unchanged'
}
const match = this.findManagedAccountForRuntimeAuth(
runtimeContents,
options.expectedAccountId
)
if (match.kind !== 'matched') {
if (match.kind === 'ambiguous') {
console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back')
}
return 'rejected'
}
// Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage.
if (
lastWrittenAuthJson === null &&
!this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents)
) {
return 'rejected'
}
writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 })
if (options.updateLastWrittenAuthJson) {
if (options.setLastWrittenAuthJson) {
options.setLastWrittenAuthJson(runtimeContents)
} else {
this.lastWrittenAuthJson = runtimeContents
}
}
return 'persisted'
} catch (error) {
// Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle.
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
return 'rejected'
}
}
// Why: which ~/.codex bytes the mirror was seeded from, and whether the system
@@ -965,152 +1051,223 @@ export class CodexRuntimeHomeService {
}
private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null {
return this.getWslCodexHomePathForSelection(target)
}
private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null {
const settings = this.store.getSettings()
const account = this.getActiveAccount(
settings.codexManagedAccounts,
getSelectedCodexAccountIdForTarget(settings, target)
)
if (account) {
const managedHome = parseWslUncPath(account.managedHomePath)
const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim()
// Persisted selections can outlive an account's runtime metadata. Never
// hand a host home (or another distro's UNC home) to a WSL launch.
const accountDistro = account.wslDistro?.trim()
const distroMatches =
(!accountDistro ||
!targetDistro ||
accountDistro.toLowerCase() === targetDistro.toLowerCase()) &&
(!managedHome ||
!targetDistro ||
managedHome.distro.toLowerCase() === targetDistro.toLowerCase())
// Older persisted fixtures may not carry a UNC spelling, but the
// explicit WSL runtime marker still proves the lane; reject unmarked
// host paths while retaining those legacy WSL records.
if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) {
return account.managedHomePath
const distro = target.wslDistro?.trim()
if (distro) {
const settings = this.store.getSettings()
const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target)
if (selectedAccountId === null) {
// Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy.
return this.getWslSystemCodexHomePath(target)
}
const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
if (
cachedRuntimeHomePath &&
this.lastSyncedWslAccountIdByDistro.has(distro) &&
this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId
) {
// Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe.
return cachedRuntimeHomePath
}
}
return this.getWslSystemCodexHomePath(target)
return this.syncWslRuntimeForCurrentSelection(target)
}
private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise<void> {
private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null {
if (process.platform !== 'win32') {
return Promise.resolve()
return null
}
const distro = target.wslDistro?.trim() || getDefaultWslDistro()
const wslTarget = this.resolveWslDefaultTarget(target)
const settings = this.store.getSettings()
const activeAccount = this.getActiveAccount(
settings.codexManagedAccounts,
getSelectedCodexAccountIdForTarget(settings, wslTarget)
)
const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro()
if (!distro) {
return Promise.resolve()
return null
}
const guestHome = getWslHome(distro)
const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null
if (!parsedGuestHome) {
return Promise.resolve()
const runtimeHomePath = this.getWslRuntimeHomePath(distro)
if (!runtimeHomePath) {
return null
}
let legacyPanePresent: boolean
try {
legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target))
} catch (error) {
console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error)
return Promise.resolve()
this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath)
mkdirSync(runtimeHomePath, { recursive: true })
this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro)
const runtimeAuthPath = join(runtimeHomePath, 'auth.json')
const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null
if (previousWslAccountId) {
if (this.skipNextReadBackForAccountId === previousWslAccountId) {
this.skipNextReadBackForAccountId = null
} else {
const previousWslAccount = this.getActiveAccount(
settings.codexManagedAccounts,
previousWslAccountId
)
if (previousWslAccount) {
this.readBackRefreshedTokensFromPath(runtimeAuthPath, {
updateLastWrittenAuthJson: true,
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
setLastWrittenAuthJson: (contents) => {
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
},
expectedAccountId: previousWslAccount.id
})
}
}
}
return startLegacyWslRuntimeAuthDrain({
distro,
guestHomeLinuxPath: parsedGuestHome.linuxPath,
legacyPanePresent,
resolveDestination: (runtimeAuthContents) =>
this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents)
})
const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null
if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) {
const activeAuth = readFileSync(activeAuthPath, 'utf-8')
this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth)
this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id)
return runtimeHomePath
}
if (activeAccount && activeAuthPath) {
console.warn(
'[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default'
)
this.store.updateSettings({
activeCodexManagedAccountId: settings.activeCodexManagedAccountId,
activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget(
normalizeCodexRuntimeSelection(settings),
null,
wslTarget
)
})
}
const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro })
if (systemAuthPath && existsSync(systemAuthPath)) {
const systemAuth = readFileSync(systemAuthPath, 'utf-8')
const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null
const runtimeAuth = existsSync(runtimeAuthPath)
? readFileSync(runtimeAuthPath, 'utf-8')
: null
if (
runtimeAuth !== null &&
runtimeAuth !== systemAuth &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) &&
((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) ||
(mirroredSystemDefaultAuth === null &&
this.runtimeAuthIsFresher(runtimeAuth, systemAuth)))
) {
// Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex.
this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
return runtimeHomePath
}
this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
return runtimeHomePath
}
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenWslAuthJsonByDistro.set(distro, null)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
return runtimeHomePath
}
/** Preserve refreshed auth from retained legacy WSL panes before restart. */
async syncActiveWslSelectionsBeforeRestart(): Promise<void> {
if (process.platform !== 'win32') {
private getWslRuntimeHomePath(distro: string): string | null {
const home = getWslHome(distro)
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
}
private safeReadBackActiveWslAccountBeforeRestart(
account: CodexManagedAccount,
selectedDistroKey: string
): void {
try {
this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
} catch (error) {
console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error)
}
}
private readBackActiveWslAccountBeforeRestart(
account: CodexManagedAccount,
selectedDistroKey: string
): void {
const distro =
selectedDistroKey === getWslSelectionKey(null)
? account.wslDistro?.trim()
: selectedDistroKey.trim() || account.wslDistro?.trim()
if (!distro) {
return
}
const settings = this.store.getSettings()
const drains: Promise<void>[] = []
for (const [selectedDistroKey, accountId] of Object.entries(
normalizeCodexRuntimeSelection(settings).wsl
)) {
if (!accountId) {
continue
}
const account = this.getActiveAccount(settings.codexManagedAccounts, accountId)
if (!account || account.managedHomeRuntime !== 'wsl') {
continue
}
const distro =
selectedDistroKey === getWslSelectionKey(null)
? account.wslDistro?.trim() || null
: selectedDistroKey.trim() || null
if (distro) {
drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro }))
}
const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
if (!runtimeHomePath) {
return
}
await Promise.all(drains)
this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), {
updateLastWrittenAuthJson: true,
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
setLastWrittenAuthJson: (contents) => {
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
},
expectedAccountId: account.id
})
}
private async resolveLegacyWslAuthDestination(
distro: string,
runtimeAuthContents: string
): Promise<LegacyWslRuntimeAuthDestination | null> {
const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => {
const parsedHome = parseWslUncPath(account.managedHomePath)
return parsedHome?.distro.toLowerCase() === distro.toLowerCase()
})
const accountHomes = accounts.flatMap((account) => {
const parsedHome = parseWslUncPath(account.managedHomePath)
return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : []
})
const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null
let reads: WslCodexAuthRead[]
private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
try {
reads = await readWslCodexAuths(distro, [
...accountHomes.map(({ linuxPath }) => linuxPath),
...(parsedSystemHome ? [parsedSystemHome.linuxPath] : [])
])
} catch {
reads = accountHomes.map(() => ({ kind: 'unreadable' }))
if (parsedSystemHome) {
reads.push({ kind: 'unreadable' })
}
}
const authReads = new Map<string, WslCodexAuthRead>(
accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }])
)
const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, {
accounts,
authReads
})
if (match.kind === 'ambiguous') {
return null
}
if (match.kind === 'matched') {
const parsedHome = parseWslUncPath(match.account.managedHomePath)
if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) {
return null
}
return {
authContents: match.managedAuthContents,
linuxHomePath: parsedHome.linuxPath
}
this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
} catch (error) {
console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error)
}
}
if (!systemHome || !parsedSystemHome) {
return null
private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
const runtimeWsl = parseWslUncPath(runtimeHomePath)
if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) {
return
}
const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' }
if (systemAuth.kind !== 'present') {
return null
}
return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents)
? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath }
: null
const activeLinuxPath = runtimeWsl.linuxPath.replace(
/\/codex-runtime-home\/home$/,
'/codex-runtime-home/active/wsl/home'
)
const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}`
const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath)
// Why: login-shell cleanup turns `exit 0` into status 1, so fall through.
execFileSync(
'wsl.exe',
[
'-d',
distro,
'--exec',
'bash',
'-lc',
[
'set -e',
`if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
`elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
'else',
`mkdir -p ${quotePosixShell(activeLinuxParentPath)}`,
`rm -rf -- ${quotePosixShell(nextLinuxPath)}`,
`ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`,
`mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`,
'fi'
].join('\n')
],
// wsl.exe is console-subsystem: without this a GUI-launched Orca flashes
// a conhost and steals foreground for up to the timeout (#10488).
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true }
)
}
private dirnameLinuxPath(value: string): string {
const index = value.lastIndexOf('/')
return index > 0 ? value.slice(0, index) : '/'
}
private joinWslPath(basePath: string, ...segments: string[]): string {
@@ -1129,13 +1286,40 @@ export class CodexRuntimeHomeService {
return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target
}
private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null {
const home = this.getWslSystemCodexHomePath(target)
return home ? this.joinWslPath(home, 'auth.json') : null
}
private seedWslRuntimeHome(
runtimeHomePath: string,
activeAccount: CodexManagedAccount | null,
distro: string
): void {
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
if (existsSync(runtimeConfigPath)) {
return
}
const candidateHomes = [
activeAccount?.managedHomePath,
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
].filter((value): value is string => Boolean(value))
for (const homePath of candidateHomes) {
const configPath = join(homePath, 'config.toml')
if (existsSync(configPath)) {
writeFileAtomically(
runtimeConfigPath,
prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath)
)
return
}
}
}
private findManagedAccountForRuntimeAuth(
runtimeAuthContents: string,
expectedAccountId?: string,
options?: {
accounts: readonly CodexManagedAccount[]
authReads: ReadonlyMap<string, WslCodexAuthRead>
}
expectedAccountId?: string
): CodexReadBackMatch {
const matches: {
account: CodexManagedAccount
@@ -1143,17 +1327,18 @@ export class CodexRuntimeHomeService {
managedAuthContents: string
}[] = []
let unreadableHomeCouldOwnRuntimeAuth = false
for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) {
for (const account of this.store.getSettings().codexManagedAccounts) {
if (expectedAccountId && account.id !== expectedAccountId) {
continue
}
const managedAuthPath = join(account.managedHomePath, 'auth.json')
let managedAuthContents: string
const suppliedRead = options?.authReads.get(account.id)
if (suppliedRead?.kind === 'missing') {
if (!existsSync(managedAuthPath)) {
continue
}
if (suppliedRead?.kind === 'unreadable') {
let managedAuthContents: string
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
// Why: an unreadable home can never be compared, but letting the read
// throw abandons the scan for every other account — dropping a refresh
// the runtime home holds for one of them. Only its record can rule it
@@ -1166,24 +1351,6 @@ export class CodexRuntimeHomeService {
}
continue
}
if (suppliedRead?.kind === 'present') {
managedAuthContents = suppliedRead.contents
} else {
if (!existsSync(managedAuthPath)) {
continue
}
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
if (
!expectedAccountId &&
codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account)
) {
unreadableHomeCouldOwnRuntimeAuth = true
}
continue
}
}
if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) {
matches.push({ account, managedAuthPath, managedAuthContents })
}
@@ -1205,6 +1372,10 @@ export class CodexRuntimeHomeService {
return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents)
}
private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean {
return codexAuthIsFresher(runtimeAuthContents, managedAuthContents)
}
private safeMigrateLegacySharedAuth(): void {
const settings = this.store.getSettings()
try {
@@ -1911,6 +2082,15 @@ export class CodexRuntimeHomeService {
return true
}
private writeRuntimeAuthAtPath(authPath: string, contents: string): void {
if (this.fileContentsEqual(authPath, contents)) {
this.ensureOwnerOnlyMode(authPath)
return
}
mkdirSync(dirname(authPath), { recursive: true })
writeFileAtomically(authPath, contents, { mode: 0o600 })
}
/**
* `true`/`false` only when the bytes were actually read; `null` when the file
* could not be read at all. The old `catch { return false }` reported "these
@@ -2217,3 +2397,14 @@ export class CodexRuntimeHomeService {
rmSync(this.getSystemDefaultSnapshotPath(), { force: true })
}
}
// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2).
export function prepareWslRuntimeSeedConfig(
configContents: string,
sourceHomePath: string
): string {
return prepareSystemConfigForFreshRuntimeMirror(
configContents,
parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath
)
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
import {
@@ -87,16 +87,18 @@ describe('CodexRuntimeHomeService', () => {
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n')
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslManagedHomePath
wslRuntimeHomePath
)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(
'{"account":"wsl"}\n'
)
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
expect(service.prepareForRateLimitFetch()).toEqual({
kind: 'ready',
codexHomePath: getRuntimeCodexHomePath()
})
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
kind: 'ready',
codexHomePath: wslManagedHomePath
codexHomePath: wslRuntimeHomePath
})
} finally {
if (originalPlatform) {
@@ -105,7 +107,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => {
it('clears a selected WSL managed account when auth.json is missing', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -159,11 +161,13 @@ describe('CodexRuntimeHomeService', () => {
)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
managedHomePath
wslRuntimeHomePath
)
expect(store.updateSettings).not.toHaveBeenCalled()
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
expect(store.updateSettings).toHaveBeenCalledWith({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
})
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -171,7 +175,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('launches WSL system default against its existing config without a runtime seed', async () => {
it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -210,18 +214,21 @@ describe('CodexRuntimeHomeService', () => {
)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
systemCodexHomePath
wslRuntimeHomePath
)
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
expect(existsSync(runtimeConfigPath)).toBe(false)
const systemConfigPath = join(systemCodexHomePath, 'config.toml')
const systemConfig = readFileSync(systemConfigPath, 'utf-8')
expect(systemConfig).toContain('model_instructions_file = "instructions.md"')
expect(systemConfig).toContain('[hooks.state.')
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
expect(runtimeConfig).toContain(
`model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'`
)
expect(runtimeConfig).toContain('[projects."/home/alice/repo"]')
expect(runtimeConfig).not.toContain('[hooks.state.')
writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
// Why: WSL runtime configs are seeded once; Codex writes trust into them
// afterwards, so a relaunch must not clobber the seeded file.
writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -229,7 +236,26 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('switches WSL accounts by selecting each account home directly', async () => {
it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => {
const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service')
// Why: real UNC sources cannot back live fs operations in tests, so pin
// the UNC -> Linux-side anchor translation on the extracted seed function.
expect(
prepareWslRuntimeSeedConfig(
'model_instructions_file = "instructions.md"\n',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
)
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
expect(
prepareWslRuntimeSeedConfig(
'model_instructions_file = "instructions.md"\n',
'\\\\wsl$\\Ubuntu\\home\\alice\\.codex'
)
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
})
it('switches WSL accounts by rewriting one stable WSL runtime home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -291,17 +317,16 @@ describe('CodexRuntimeHomeService', () => {
'home'
)
expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath)
expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth)
store.updateSettings({
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } }
})
service.syncForCurrentSelection(target)
expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath)
expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth)
expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -309,7 +334,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('ignores stale retired runtime auth when launching a managed WSL account', async () => {
it('does not use host auth baseline to accept stale WSL runtime auth', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -317,6 +342,7 @@ describe('CodexRuntimeHomeService', () => {
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token')
const wslManagedAuth = createCodexAuthJson(
'wsl@example.com',
'acct-wsl',
@@ -329,6 +355,7 @@ describe('CodexRuntimeHomeService', () => {
'runtime-stale',
1_000
)
const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth)
const wslManagedHomePath = createManagedAuth(
testState.userDataDir,
'wsl-account',
@@ -347,6 +374,17 @@ describe('CodexRuntimeHomeService', () => {
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'host-account',
email: 'host@example.com',
managedHomePath: hostManagedHomePath,
providerAccountId: 'acct-host',
workspaceLabel: null,
workspaceAccountId: 'acct-host',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'wsl-account',
email: 'wsl@example.com',
@@ -357,13 +395,14 @@ describe('CodexRuntimeHomeService', () => {
providerAccountId: 'acct-wsl',
workspaceLabel: null,
workspaceAccountId: 'acct-wsl',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'host-account',
activeCodexManagedAccountIdsByRuntime: {
host: null,
host: 'host-account',
wsl: { Ubuntu: 'wsl-account' }
}
})
@@ -374,10 +413,148 @@ describe('CodexRuntimeHomeService', () => {
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslManagedHomePath
wslRuntimeHomePath
)
expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000)
const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500)
const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000)
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth)
const managedAuthPath = join(managedHomePath, 'auth.json')
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json')
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'wsl@example.com',
managedHomePath,
managedHomeRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
providerAccountId: 'acct-wsl',
workspaceLabel: null,
workspaceAccountId: 'acct-wsl',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'account-1' }
}
})
)
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8')
writeFileSync(managedAuthPath, reauthedAuth, 'utf-8')
service.clearLastWrittenAuthJson('account-1')
service.syncForCurrentSelection(target)
expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('reads active WSL token refreshes back before restart using the selected distro', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000)
const refreshedAuth = createCodexAuthJson(
'wsl@example.com',
'acct-wsl',
'runtime-refreshed',
2_000
)
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
const managedAuthPath = join(managedHomePath, 'auth.json')
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'wsl@example.com',
managedHomePath,
managedHomeRuntime: 'wsl',
wslDistro: null,
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
providerAccountId: 'acct-wsl',
workspaceLabel: null,
workspaceAccountId: 'acct-wsl',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'account-1' }
}
})
)
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json')
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8')
service.syncActiveWslSelectionsBeforeRestart()
expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type * as WslPaths from '../../shared/wsl-paths'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -34,7 +34,62 @@ describe('CodexRuntimeHomeService', () => {
teardownRuntimeHomeTest()
})
it('skips WSL session bridging when system default already uses its direct home', async () => {
it('builds a valid WSL legacy active-home migration shell command', async () => {
const execFileSyncMock = vi.fn()
vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(
createStore(createSettings()) as never
) as unknown as {
migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void
}
service.migrateLegacyWslActiveHomePointer(
'Ubuntu',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
)
expect(execFileSyncMock).toHaveBeenCalledTimes(1)
const firstCall = execFileSyncMock.mock.calls[0]
expect(firstCall).toBeDefined()
const [command, args] = firstCall as [string, string[]]
expect(command).toBe('wsl.exe')
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc'])
expect(args).toHaveLength(6)
const shellCommand = args[5]
expect(shellCommand).toContain(
"if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :"
)
expect(shellCommand).toContain(
"elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :"
)
expect(shellCommand).toContain(
"mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'"
)
expect(shellCommand).toContain(
"ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-"
)
expect(shellCommand).toContain(
"mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-"
)
expect(shellCommand).toContain(
"' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'"
)
expect(shellCommand).not.toContain('[! -L')
expect(shellCommand).not.toContain('mv -Tf--')
expect(shellCommand).not.toContain('$1')
expect(shellCommand).not.toContain('$2')
expect(shellCommand).not.toContain('$3')
expect(shellCommand).not.toContain('exit 0')
} finally {
vi.doUnmock('node:child_process')
}
})
it('starts WSL session bridging after materializing the WSL launch home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
@@ -69,13 +124,17 @@ describe('CodexRuntimeHomeService', () => {
)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslSystemHomePath
wslRuntimeHomePath
)
expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled()
expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe(
'# WSL instructions\n'
)
expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
distro: 'Ubuntu',
systemCodexHomePath: wslSystemHomePath,
managedCodexHomePath: wslRuntimeHomePath
})
const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md')
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n')
expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false)
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
vi.doUnmock('../wsl')
@@ -85,7 +144,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('keeps WSL in-Codex setting changes in the direct system home', async () => {
it('promotes WSL in-Codex setting changes on the next Codex launch', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
@@ -118,20 +177,32 @@ describe('CodexRuntimeHomeService', () => {
'home'
)
// First launch seeds the runtime config and records the per-distro baseline.
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
join(wslHome, '.codex')
wslRuntimeHomePath
)
const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json')
expect(existsSync(baselinePath)).toBe(false)
expect(existsSync(baselinePath)).toBe(true)
// A direct WSL Codex edit wins and is mirrored into Orca's runtime before
// the baseline advances, so later in-Orca changes remain promotable.
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""')
// Codex now persists a /model change inside Orca's reconciled runtime.
writeFileSync(
runtimeConfigPath,
readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'),
'utf-8'
)
writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n')
expect(existsSync(baselinePath)).toBe(false)
// Baseline advances so the promoted value is not re-promoted forever.
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""')
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
vi.doUnmock('../wsl')
@@ -165,13 +236,22 @@ describe('CodexRuntimeHomeService', () => {
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
distro: 'Ubuntu',
systemCodexHomePath: '/home/me/.config/codex',
managedCodexHomePath: join(wslHome, '.codex')
managedCodexHomePath: wslRuntimeHomePath
})
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
@@ -182,7 +262,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('starts WSL session bridging for the selected direct account home', async () => {
it('starts WSL session bridging for the distro used by the materialized runtime home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
@@ -190,6 +270,14 @@ describe('CodexRuntimeHomeService', () => {
startWslCodexSessionBridgeInBackground
}))
const wslHome = join(testState.userDataDir, 'debian-wsl-home')
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => null,
getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null)
@@ -199,10 +287,10 @@ describe('CodexRuntimeHomeService', () => {
return {
...actual,
parseWslUncPath: (candidate: string) =>
candidate.includes('codex-accounts/debian-account/home')
candidate === wslRuntimeHomePath
? {
distro: 'Debian',
linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home'
linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home'
}
: null
}
@@ -240,12 +328,12 @@ describe('CodexRuntimeHomeService', () => {
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe(
managedHomePath
wslRuntimeHomePath
)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
distro: 'Debian',
systemCodexHomePath: join(wslHome, '.codex'),
managedCodexHomePath: managedHomePath
managedCodexHomePath: wslRuntimeHomePath
})
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
@@ -53,6 +53,15 @@ describe('CodexRuntimeHomeService', () => {
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const syncWslRuntime = vi.spyOn(
service as unknown as {
syncWslRuntimeForCurrentSelection: (target: {
runtime: 'wsl'
wslDistro?: string | null
}) => string | null
},
'syncWslRuntimeForCurrentSelection'
)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const expectedHome = join(wslHome, '.codex')
@@ -64,6 +73,7 @@ describe('CodexRuntimeHomeService', () => {
kind: 'ready',
codexHomePath: expectedHome
})
expect(syncWslRuntime).not.toHaveBeenCalled()
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -83,6 +93,15 @@ describe('CodexRuntimeHomeService', () => {
const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token')
const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth)
const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth)
const runtimeAuthPath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home',
'auth.json'
)
const store = createStore(
createSettings({
codexManagedAccounts: [
@@ -128,9 +147,9 @@ describe('CodexRuntimeHomeService', () => {
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({
kind: 'ready',
codexHomePath: ubuntuHomePath
codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
})
expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -209,7 +228,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('keeps WSL system-default token refreshes in its direct home', async () => {
it('reads WSL system-default token refreshes back to WSL system auth', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -238,11 +257,21 @@ describe('CodexRuntimeHomeService', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8')
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8')
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -250,7 +279,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => {
it('preserves WSL system-default token refreshes after app restart', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -290,8 +319,8 @@ describe('CodexRuntimeHomeService', () => {
const service = new CodexRuntimeHomeService(store as never)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth)
expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth)
} finally {
if (originalPlatform) {
@@ -1,70 +0,0 @@
import { runWslProcess } from '../wsl/wsl-runner'
export type WslCodexAuthRead =
| { kind: 'missing' | 'unreadable' }
| { kind: 'present'; contents: string }
export async function readWslCodexAuths(
distro: string,
linuxHomePaths: readonly string[]
): Promise<WslCodexAuthRead[]> {
if (linuxHomePaths.length === 0) {
return []
}
const result = await runWslProcess({
distro,
loginPath: 'none',
script: READ_AUTHS_SCRIPT,
args: linuxHomePaths,
timeoutMs: 5_000,
maxOutputBytes: 2 * 1024 * 1024
})
if (result.code !== 0 || result.timedOut) {
return linuxHomePaths.map(() => ({ kind: 'unreadable' }))
}
const rows = result.stdout.split('\n')
return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? ''))
}
export function decodeWslBase64Payload(encoded: string): string | null {
try {
const decoded = Buffer.from(encoded, 'base64')
const canonical = decoded.toString('base64').replace(/=+$/, '')
return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null
} catch {
return null
}
}
function parseAuthReadRow(row: string): WslCodexAuthRead {
if (row === 'missing' || row === 'unreadable') {
return { kind: row }
}
if (!row.startsWith('present:')) {
return { kind: 'unreadable' }
}
const contents = decodeWslBase64Payload(row.slice('present:'.length))
return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents }
}
const READ_AUTHS_SCRIPT = `
set -eu
for home_path in "$@"; do
auth_path="$home_path/auth.json"
if [ ! -f "$auth_path" ]; then
if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then
printf 'missing\n'
else
printf 'unreadable\n'
fi
continue
fi
if encoded=$(base64 < "$auth_path"); then
printf 'present:'
printf '%s' "$encoded" | tr -d '\n'
printf '\n'
else
printf 'unreadable\n'
fi
done
`
@@ -266,15 +266,6 @@ export function hasRecordedLegacySharedCodexPane(): boolean {
)
}
/** True when a retained WSL pane may still read the retired per-distro runtime home. */
export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean {
return Object.values(readRegistryOrThrow().panes).some(
(record) =>
(record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') &&
(record.homeRoute === undefined || record.homeRoute === 'wsl-home')
)
}
/** True when startup may need to repair hooks for a retained managed host pane. */
export function hasRecordedManagedHostCodexPane(): boolean {
return Object.values(readRegistry().panes).some(
@@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => {
).toEqual({
selectionKey: 'wsl:Ubuntu',
accountId: 'wsl-account',
homeRoute: 'account-home'
homeRoute: 'wsl-home'
})
})
+5 -12
View File
@@ -1,6 +1,5 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
getCodexSelectionLaneKey,
getCodexSelectionTargetForAccount,
@@ -81,6 +80,9 @@ function resolveCodexPaneHomeRoute(args: {
settings: CodexPaneLaunchAccountSettings
target: CodexAccountSelectionTarget
}): CodexPaneHomeRoute {
if (args.target.runtime === 'wsl') {
return 'wsl-home'
}
if (
!args.launchCodexHomePath ||
normalizeRuntimePathForComparison(args.launchCodexHomePath) ===
@@ -91,19 +93,10 @@ function resolveCodexPaneHomeRoute(args: {
const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath)
const accountOwnsHome = args.settings.codexManagedAccounts?.some(
(account) =>
getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) ===
getCodexSelectionLaneKey(args.target) &&
getCodexSelectionTargetForAccount(account).runtime === 'host' &&
normalizeRuntimePathForComparison(account.managedHomePath) === launchHome
)
if (accountOwnsHome) {
return 'account-home'
}
if (args.target.runtime === 'wsl') {
return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex')
? 'real-home'
: 'wsl-home'
}
return 'shared-home'
return accountOwnsHome ? 'account-home' : 'shared-home'
}
/** undefined when no account owns the home; null means the system-default account. */
+2 -1
View File
@@ -1,5 +1,6 @@
import { dirname, join } from 'node:path'
import { resolveCodexCommand } from '../codex-cli/command'
import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure'
import { getSpawnArgsForWindows } from '../win32-utils'
import { getCodexSessionBackfillStateDirPath } from './codex-home-paths'
import { resolveCodexSessionBackfillPaths } from './codex-session-backfill'
@@ -231,7 +232,7 @@ async function healOneThread(
recordHealOutcome(paths, thread, 'missing')
return
}
if (/SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i.test(message)) {
if (isTransientSqliteContention(message)) {
// Why: an active Codex process can briefly own sqlite; leave the id off
// the ledger and abort this pass so a later startup resumes it.
throw error
@@ -8,7 +8,6 @@ import {
forgetCodexPaneAccount,
getCodexPaneAccount,
hasRecordedLegacySharedCodexPane,
hasRecordedLegacyWslCodexPane,
hasRecordedManagedHostCodexPane,
isCodexPaneHomeRouteProvenAwayFromSharedHome,
reconcileCodexPaneAccountsWithLivePtys,
@@ -139,36 +138,6 @@ describe('codex pane account registry', () => {
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
})
it('identifies only legacy runtime-home panes on the requested WSL lane', () => {
recordCodexPaneAccount('pty-legacy', {
selectionKey: 'wsl:Ubuntu',
accountId: 'account-old',
homeRoute: 'wsl-home'
})
recordCodexPaneAccount('pty-direct', {
selectionKey: 'wsl:Ubuntu',
accountId: 'account-new',
homeRoute: 'account-home'
})
recordCodexPaneAccount('pty-other-distro', {
selectionKey: 'wsl:Debian',
accountId: 'account-debian',
homeRoute: 'wsl-home'
})
recordCodexPaneAccount('pty-default', {
selectionKey: 'wsl:__default__',
accountId: null,
homeRoute: 'wsl-home'
})
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
forgetCodexPaneAccount('pty-legacy')
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
forgetCodexPaneAccount('pty-default')
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false)
expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true)
})
it('requests startup inventory only for managed host panes', () => {
recordCodexPaneAccount('pty-real', {
selectionKey: 'host',
+8 -1
View File
@@ -31,6 +31,7 @@ import {
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue,
POSIX_HOOK_STDIN_DRAIN_COMMAND
@@ -818,6 +819,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('codex'),
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
'load_hook_endpoint() {',
' endpoint_path="$1"',
@@ -844,6 +846,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
'post_codex_hook() {',
@@ -875,9 +878,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'if is_wsl_runtime; then',
' windows_curl=$(command -v curl.exe 2>/dev/null || true)',
' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then',
' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true',
' if post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1; then',
' exit 0',
' fi',
' # post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true',
' fi',
'fi',
'spool_hook_event',
'exit 0',
''
].join('\n')
@@ -1,6 +1,7 @@
import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
@@ -36,6 +37,7 @@ export function buildCommandCodeManagedScript(
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('command-code'),
'__orca_read_ancestor_var() {',
' __orca_name="$1"',
' __orca_pid="${PPID:-}"',
@@ -119,6 +121,7 @@ export function buildCommandCodeManagedScript(
' done',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
@@ -135,7 +138,7 @@ export function buildCommandCodeManagedScript(
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
+7 -2
View File
@@ -1,5 +1,8 @@
import { getSharedManagedScriptPath } from '../agent-hooks/installer-utils'
import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines
} from '../agent-hooks/hook-stdin-contract'
export function getManagedScriptFileName(): string {
return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh'
@@ -53,12 +56,14 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'#!/bin/sh',
"printf '{}\\n'",
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('copilot'),
// Why: Copilot consumes stdout for some hooks, so stdout is emitted before
// endpoint refresh, stdin parsing, or the network POST can fail.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
@@ -75,7 +80,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
@@ -194,6 +194,22 @@ function buildDump(options: {
return { dump: builder.build(streams) }
}
/** Offset of a stream's directory entry: `{type, size, rva}`. */
function streamEntry(dump: Buffer, type: number): number {
const directoryRva = dump.readUInt32LE(12)
for (let index = 0; index < dump.readUInt32LE(8); index += 1) {
const entry = directoryRva + index * 12
if (dump.readUInt32LE(entry) === type) {
return entry
}
}
throw new Error(`no stream of type ${type}`)
}
function moduleListRva(dump: Buffer): number {
return dump.readUInt32LE(streamEntry(dump, STREAM_TYPE_MODULE_LIST) + 8)
}
const FATAL_LINE =
'[8104:1234:0815/143022.123456:FATAL:render_frame_impl.cc(4821)] Check failed: !is_detached_.'
@@ -340,6 +356,37 @@ describe('parseMinidumpCrashSignature', () => {
expect(signature?.faultingModuleOffset).toBe('0x1234')
})
it('resolves a faulting module past index 1024 on a real macOS image count', () => {
// A measured macOS renderer carries 1042 loaded images; a cap below that
// dropped the whole module list, so no macOS report could name a module.
const modules = Array.from({ length: 1042 }, (_, index) => ({
base: 0x1_0000_0000n + BigInt(index) * 0x1_0000n,
size: 0x1000,
name: `/Applications/Orca.app/Contents/Frameworks/lib${index}.dylib`
}))
const { dump } = buildDump({
exception: { code: 11, address: 0x1_0000_0000n + 1030n * 0x1_0000n + 0x24n },
modules
})
const signature = parseMinidumpCrashSignature(dump)
expect(signature?.faultingModule).toBe('lib1030.dylib')
expect(signature?.faultingModuleOffset).toBe('0x24')
})
it('still drops the module list when the claimed module count is absurd', () => {
const { dump } = buildDump({
exception: { code: 11, address: 0x7ff7_0000_0010n },
modules: [{ base: 0x7ff7_0000_0000n, size: 0x1000, name: '/opt/orca/orca' }]
})
const corrupt = Buffer.from(dump)
corrupt.writeUInt32LE(0xffff_ffff, moduleListRva(corrupt))
expect(() => parseMinidumpCrashSignature(corrupt)).not.toThrow()
expect(parseMinidumpCrashSignature(corrupt)?.faultingModule).toBeUndefined()
})
it('omits the faulting module when no image range covers the address', () => {
const { dump } = buildDump({
exception: { code: 11, address: 0x10n },
@@ -11,13 +11,16 @@
// rather than throwing: a truncated dump must degrade, not break crash
// reporting.
import { findStream, isMinidump, MAX_MODULES, MinidumpView } from './minidump-stream-reader'
import { findStream, isMinidump, MinidumpView } from './minidump-stream-reader'
import { readCrashpadAnnotations } from './minidump-crashpad-annotations'
const STREAM_TYPE_MODULE_LIST = 4
const STREAM_TYPE_EXCEPTION = 6
const MODULE_RECORD_SIZE = 108
// 8x headroom over a measured 1042-image macOS renderer, whose whole list a
// 1_024 cap dropped; a dump claiming more than this is corrupt.
const MAX_MODULE_LIST_MODULES = 8_192
const MODULE_BASE_OFFSET = 0
const MODULE_SIZE_OFFSET = 8
const MODULE_NAME_RVA_OFFSET = 20
@@ -70,7 +73,7 @@ function readModules(view: MinidumpView): ModuleRecord[] {
return []
}
const count = view.u32(stream.rva)
if (count === null || count > MAX_MODULES) {
if (count === null || count > MAX_MODULE_LIST_MODULES) {
return []
}
const modules: ModuleRecord[] = []
@@ -13,7 +13,7 @@ const DIRECTORY_ENTRY_SIZE = 12
// A dump claiming an absurd stream count is corrupt; cap before iterating.
const MAX_STREAMS = 4_096
export const MAX_ANNOTATION_VALUE_BYTES = 8_192
// Shared cap: both the MINIDUMP_MODULE_LIST and Crashpad's per-module info list.
// Cap for Crashpad's per-module info list; the MINIDUMP_MODULE_LIST is capped separately.
export const MAX_MODULES = 1_024
export type LocationDescriptor = {
+4 -1
View File
@@ -23,6 +23,7 @@ import {
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
@@ -76,11 +77,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('cursor'),
// Why: refresh endpoint coordinates so surviving PTYs keep reporting.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON.
@@ -95,7 +98,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
@@ -90,6 +90,49 @@ describe('TerminalHost agent-session claims', () => {
expect(spawnSubprocess).toHaveBeenCalledOnce()
})
it('cannot adopt a live session that predates provider-session claims', async () => {
const subprocesses: ReturnType<typeof createClaimedSubprocess>[] = []
host = new TerminalHost({
spawnSubprocess: () => {
const handle = createClaimedSubprocess()
subprocesses.push(handle)
return handle
}
})
await host.createOrAttach({
sessionId: 'renderer-owned-live-session',
cols: 80,
rows: 24,
launchAgent: 'codex',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const resumed = await host.createOrAttach({
sessionId: 'replacement-resume-session',
cols: 80,
rows: 24,
launchAgent: 'codex',
streamClient: { onData: vi.fn(), onExit: vi.fn() },
agentSessionEnsure: { claim, surface }
})
expect(resumed.agentSessionEnsure?.disposition).toBe('created')
expect(
host
.listSessions()
.map(({ sessionId }) => sessionId)
.sort()
).toEqual(['renderer-owned-live-session', 'replacement-resume-session'])
expect(subprocesses).toHaveLength(2)
host.write('renderer-owned-live-session', 'original-writer')
host.write('replacement-resume-session', 'replacement-writer')
expect(subprocesses[0]?.write).toHaveBeenCalledWith('original-writer')
expect(subprocesses[1]?.write).toHaveBeenCalledWith('replacement-writer')
for (const handle of subprocesses) {
handle.exit()
}
})
it('rejects a competing claim without replacing the winning stream', async () => {
let releaseSpawn: () => void = () => {}
const spawnGate = new Promise<void>((resolve) => {
+4 -1
View File
@@ -13,6 +13,7 @@ import {
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
@@ -55,12 +56,14 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('devin'),
// Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env).
// Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open).
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: worktreeId embeds a filesystem path, so hand-building JSON in shell is unsafe (quotes/newlines); post as form fields instead.
@@ -75,7 +78,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
+4 -1
View File
@@ -24,6 +24,7 @@ import {
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
@@ -94,10 +95,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('droid'),
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
@@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
+4 -1
View File
@@ -24,6 +24,7 @@ import {
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
@@ -72,11 +73,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early.
'printf "{}\\n"',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('gemini'),
// Why: source refreshes endpoint coords so a PTY surviving an Orca restart keeps reporting. See claude/hook-service.ts.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: worktreeId embeds a path, so post form fields, not hand-built JSON that breaks on quotes/newlines.
@@ -91,7 +94,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
+7 -2
View File
@@ -3,7 +3,10 @@ import {
wrapPosixHookCommand,
wrapWindowsCmdHookCommand
} from '../agent-hooks/installer-utils'
import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines
} from '../agent-hooks/hook-stdin-contract'
import {
buildWindowsGrokHookScript,
GROK_HOME_ENVELOPE_MAX_LENGTH
@@ -33,10 +36,12 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('grok'),
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
'grok_home=',
@@ -54,7 +59,7 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "grokHome=${grok_home}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
+18 -4
View File
@@ -24,7 +24,10 @@ import {
writeManagedScriptRemote,
writeTextFileRemoteAtomic
} from '../agent-hooks/installer-utils-remote'
import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines
} from '../agent-hooks/hook-stdin-contract'
import {
applyManagedKimiHooks,
KIMI_HOOK_EVENTS,
@@ -71,14 +74,25 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
// Why: the windows-local ordering runs this guard before stdin is read and before
// spool_hook_event is defined, so only the payload-first ordering may spool here.
...(windowsLocal ? [] : [' spool_hook_event']),
' exit 0',
'fi'
]
return [
'#!/bin/sh',
...(windowsLocal
? [...endpointRefreshAndGuard, ...buildPosixHookPayloadCapture()]
: [...buildPosixHookPayloadCapture(), ...endpointRefreshAndGuard]),
? [
...endpointRefreshAndGuard,
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('kimi')
]
: [
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('kimi'),
...endpointRefreshAndGuard
]),
// Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
@@ -95,7 +109,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
+3 -1
View File
@@ -1,4 +1,6 @@
/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */
/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync
* with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which
* builds the UNC twin of this path. */
export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [
'.local',
'share',
+190 -22
View File
@@ -582,7 +582,8 @@ import {
WORKTREE_ID_SEPARATOR,
getRepoIdFromWorktreeId,
splitWorktreeId,
splitWorktreeIdForFilesystem
splitWorktreeIdForFilesystem,
worktreeIdComparisonKey
} from '../../shared/worktree/id'
import {
getProjectIdForProviderIdentity,
@@ -704,6 +705,7 @@ import {
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY,
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../shared/protocol-version'
@@ -3316,7 +3318,8 @@ export class OrcaRuntimeService {
// reconnect-scan bounding for sequential soft freezes.
private readonly terminalFocusNavigationCoalescer =
new TerminalFocusNavigationCoalescer<RuntimeTerminalFocus>()
private pendingMobileSessionPtyInventoryRefresh: Promise<Set<string> | null> | null = null
private pendingMobileSessionPtyAggregateInventoryRefresh: Promise<PtyControllerInventory | null> | null =
null
private structuredAgentSessionTabRestorePromise: Promise<void> | null = null
private structuredAgentSessionStartupRestorePromise: Promise<void> | null = null
private leaves = new Map<string, RuntimeLeafRecord>()
@@ -3333,6 +3336,8 @@ export class OrcaRuntimeService {
private syntheticTerminalHandles = new Set<string>()
private detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
private graphSyncCallbacks: (() => void)[] = []
private sessionTabsInventoryPublicationEpoch: number | null = null
private sessionTabsInventoryWaiters = new Set<() => void>()
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
private ptyExitListenersByPtyId = new Map<string, Set<() => void>>()
private ptyController: RuntimePtyController | null = null
@@ -6524,7 +6529,9 @@ export class OrcaRuntimeService {
(process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' ||
capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) &&
(process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' ||
capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)
capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) &&
(process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' ||
capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY)
)
if (hasOffscreen || hasHeadlessCommands) {
capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY)
@@ -7402,7 +7409,19 @@ export class OrcaRuntimeService {
this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId)
}
}
// Why: only the authoritative window grants inventory authority; headless qualifies because it becomes authoritative before its next sync.
const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId
this.markGraphReady(windowId)
if (
isAuthoritativeGraphPublisher &&
(windowId === HEADLESS_RUNTIME_WINDOW_ID || graph.mobileSessionTabs !== undefined)
) {
if (mobileSessionResyncWorktrees.size === 0) {
this.markSessionTabsInventoryPublished()
} else {
this.sessionTabsInventoryPublicationEpoch = null
}
}
if (rendererGeneration !== undefined) {
this.rendererGeneration = rendererGeneration
}
@@ -7514,6 +7533,13 @@ export class OrcaRuntimeService {
async listAllMobileSessionTabs(
clientNavigationId?: string
): Promise<RuntimeMobileSessionTabsResult[]> {
return (await this.collectAllMobileSessionTabs(clientNavigationId)).snapshots
}
private async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{
snapshots: RuntimeMobileSessionTabsResult[]
ptyInventory: PtyControllerInventory | null
}> {
for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
@@ -7521,13 +7547,132 @@ export class OrcaRuntimeService {
})
}
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
await this.refreshMobileSessionPtyRecords()
return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) =>
this.projectMobileSessionTabsForClient(
this.toMobileSessionTabsResult(snapshot),
clientNavigationId
)
)
const ptyInventory = await this.refreshMobileSessionPtyInventory()
this.restoreLivePairedRendererSessionOwnedMobileTerminals(null)
return {
snapshots: [...this.mobileSessionTabsByWorktree.values()].map((snapshot) =>
this.projectMobileSessionTabsForClient(
this.toMobileSessionTabsResult(snapshot),
clientNavigationId
)
),
ptyInventory
}
}
async listAllMobileSessionTabsInventory(
clientNavigationId?: string,
signal?: AbortSignal
): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> {
this.assertSessionTabsInventoryRequestActive(signal)
const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch()
const primed = await this.collectAllMobileSessionTabs(clientNavigationId)
this.assertSessionTabsInventoryRequestActive(signal)
if (
primedPublicationEpoch !== null &&
this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch
) {
return await this.settleSessionTabsInventory(primed, clientNavigationId, signal)
}
while (true) {
const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch()
if (publicationEpoch === null) {
await this.waitForSessionTabsInventoryPublication(signal)
continue
}
const inventory = await this.collectAllMobileSessionTabs(clientNavigationId)
this.assertSessionTabsInventoryRequestActive(signal)
if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) {
return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal)
}
}
}
// Why: a failed census only invalidates the emptiness verdict, never the
// list. An incomplete census usually means a concurrent scan invalidated
// this one mid-relaunch and daemon-backed tabs could not be restored yet, so
// retry the collection once; a still-incomplete retry serves its snapshots
// unlabeled rather than erroring the request.
private async settleSessionTabsInventory(
inventory: {
snapshots: RuntimeMobileSessionTabsResult[]
ptyInventory: PtyControllerInventory | null
},
clientNavigationId?: string,
signal?: AbortSignal
): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> {
if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) {
return { snapshots: inventory.snapshots, authoritative: true }
}
const retried = await this.collectAllMobileSessionTabs(clientNavigationId)
this.assertSessionTabsInventoryRequestActive(signal)
// Why: the retry ran outside the epoch fence, so it never claims authority.
return { snapshots: retried.snapshots }
}
supportsAuthoritativeSessionTabsInventory(): boolean {
return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1'
}
private assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('client_disconnected')
}
}
private isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean {
if (!inventory) {
return false
}
const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds)
return ![...knownHostIds].some((hostId) => {
const parsed = parseExecutionHostId(hostId)
return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId)
})
}
private waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise<void> {
if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
this.sessionTabsInventoryWaiters.delete(onPublished)
signal?.removeEventListener('abort', onAbort)
}
const onPublished = (): void => {
cleanup()
resolve()
}
const onAbort = (): void => {
cleanup()
reject(new Error('client_disconnected'))
}
this.sessionTabsInventoryWaiters.add(onPublished)
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
} else if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) {
onPublished()
}
})
}
private getAuthoritativeSessionTabsInventoryEpoch(): number | null {
return this.graphStatus === 'ready' &&
this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch
? this.rendererGraphEpoch
: null
}
private markSessionTabsInventoryPublished(): void {
if (this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch) {
return
}
this.sessionTabsInventoryPublicationEpoch = this.rendererGraphEpoch
for (const publish of [...this.sessionTabsInventoryWaiters]) {
publish()
}
}
private hydrateHeadlessMobileSessionTabsFromWorkspaceSession(
@@ -9260,19 +9405,29 @@ export class OrcaRuntimeService {
private async refreshMobileSessionPtyRecords(
targetWorktreeId: string | null = null
): Promise<Set<string> | null> {
const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId)
return inventory ? new Set(inventory.livePtyIds) : null
}
private async refreshMobileSessionPtyInventory(
targetWorktreeId: string | null = null
): Promise<PtyControllerInventory | null> {
if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
const pending = this.pendingMobileSessionPtyInventoryRefresh
// Non-floating refreshes all query the aggregate controller inventory;
// coalesce targeted and all-worktree callers so they cannot invalidate
// one another through the shared aggregate generation fence.
const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh
if (pending) {
return pending
}
// Why: reconnect exit bursts share one authoritative daemon inventory
// instead of multiplying a full cross-generation list RPC per stale tab.
const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => {
if (this.pendingMobileSessionPtyInventoryRefresh === refresh) {
this.pendingMobileSessionPtyInventoryRefresh = null
if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) {
this.pendingMobileSessionPtyAggregateInventoryRefresh = null
}
})
this.pendingMobileSessionPtyInventoryRefresh = refresh
this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh
return refresh
}
return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId)
@@ -9280,14 +9435,14 @@ export class OrcaRuntimeService {
private async performMobileSessionPtyRecordsRefresh(
targetWorktreeId: string | null
): Promise<Set<string> | null> {
): Promise<PtyControllerInventory | null> {
if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) {
return null
}
// Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree.
const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID
const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees()
return await this.refreshPtyWorktreeRecordsFromController(
return await this.refreshPtyWorktreeRecordsWithControllerInventory(
resolvedWorktrees,
isFloatingWorkspace ? targetWorktreeId : null
)
@@ -33033,6 +33188,8 @@ export class OrcaRuntimeService {
return false
}
if (fence.recovery === 'renderer') {
const restoresPublishedInventory =
this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - 1
this.graphStatus = 'ready'
this.setTerminalSideEffectConsumerAvailable(true)
for (const leaf of this.leaves.values()) {
@@ -33040,6 +33197,9 @@ export class OrcaRuntimeService {
}
this.reconcilePtyIncarnationHandles()
this.refreshWritableFlags()
if (restoresPublishedInventory) {
this.markSessionTabsInventoryPublished()
}
return true
}
this.graphReloadLifecycle.begin(windowId)
@@ -33152,6 +33312,7 @@ export class OrcaRuntimeService {
this.clearPtyIncarnationHandles()
this.rejectAllWaiters('terminal_handle_stale')
this.refreshWritableFlags()
this.markSessionTabsInventoryPublished()
}
private assertGraphReady(): void {
@@ -33437,6 +33598,7 @@ export class OrcaRuntimeService {
return worktreeId
}
/** Resolves one workspace or throws `selector_not_found` / `selector_ambiguous` — never picks a winner. */
private async resolveWorktreeSelector(selector: string): Promise<ResolvedWorktree> {
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector)
// Why only `id:`: every other selector kind is matched across the whole fleet, and their
@@ -33458,6 +33620,16 @@ export class OrcaRuntimeService {
if (selector.startsWith('id:')) {
const worktreeId = explicitWorktreeId ?? selector.slice(3)
candidates = worktrees.filter((worktree) => worktree.id === worktreeId)
if (candidates.length === 0) {
// Why (#16243): `id:` is the only shape the renderer can send, and a stored id can spell
// its path differently from the scan — the divergence `path:` has always absorbed.
// The bare unprefixed branch below stays byte-exact on purpose: only `id:` reaches a
// renderer caller, so `id:repo::p/` folds here while bare `repo::p/` still misses.
const comparisonKey = worktreeIdComparisonKey(worktreeId)
candidates = comparisonKey
? worktrees.filter((worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey)
: candidates
}
if (candidates.length === 0) {
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const repo = parsed ? this.store?.getRepo(parsed.repoId) : null
@@ -42657,12 +42829,8 @@ function runtimePathsEqual(left: string, right: string): boolean {
* Windows/WSL/SSH ids still match themselves across hosts.
*/
function runtimeWorktreeIdsEqual(left: string, right: string): boolean {
const parsedLeft = splitWorktreeId(left)
const parsedRight = splitWorktreeId(right)
return parsedLeft && parsedRight
? parsedLeft.repoId === parsedRight.repoId &&
runtimePathsEqual(parsedLeft.worktreePath, parsedRight.worktreePath)
: left === right
const leftKey = worktreeIdComparisonKey(left)
return leftKey === null ? left === right : leftKey === worktreeIdComparisonKey(right)
}
function runtimeWorktreeIdentityKey(worktreeId: string): string {
@@ -200,3 +200,88 @@ describe('host-qualified scoped worktree resolution', () => {
expect(getRepos).not.toHaveBeenCalled()
})
})
/**
* #16243: the renderer can only address a workspace by `id:<repoId>::<path>`, and this scoped
* lookup is what a host-qualified removal resolves through. It matched the id byte for byte while a
* `path:` selector has always compared through `normalizeRuntimePathForComparison`, so a stored id
* spelling its path differently from `git worktree list` resolved for the CLI and not for the UI.
*/
describe('scoped worktree id resolution across path spellings (#16243)', () => {
it.each([
['a trailing slash', '/same/worktree', 'shared::/same/worktree/'],
['a doubled separator', '/same/worktree', 'shared::/same//worktree'],
['an NFD name', '/same/café', `shared::${'/same/café'.normalize('NFD')}`]
])('resolves the scanned row when the id carries %s', async (_label, scannedPath, worktreeId) => {
const owner = repo('shared', '/local/repo', { executionHostId: 'local' })
const deps = createDeps([owner])
deps.scanRepo.mockImplementation(async () => ({
ok: true,
worktrees: [gitWorktree(scannedPath)]
}))
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({
id: `shared::${scannedPath}`,
path: scannedPath
})
})
it('still refuses the same path under a different repo id', async () => {
const deps = createDeps([
repo('shared', '/local/repo', { executionHostId: 'local' }),
repo('unrelated', '/unrelated/repo', { executionHostId: 'local' })
])
await expect(
resolveScopedWorktreeIdRow(deps, 'unrelated::/same/worktree/', 'local')
).resolves.toBeNull()
})
it('refuses rather than guessing when two rows spell one path', async () => {
const owner = repo('shared', '/local/repo', { executionHostId: 'local' })
const deps = createDeps([owner])
deps.scanRepo.mockImplementation(async () => ({
ok: true,
worktrees: [gitWorktree('/same/worktree'), gitWorktree('/same//worktree')]
}))
await expect(
resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree/', 'local')
).resolves.toBeNull()
})
it('prefers the exactly matching row over an equivalent spelling', async () => {
const owner = repo('shared', '/local/repo', { executionHostId: 'local' })
const deps = createDeps([owner])
deps.scanRepo.mockImplementation(async () => ({
ok: true,
worktrees: [gitWorktree('/same//worktree'), gitWorktree('/same/worktree')]
}))
await expect(
resolveScopedWorktreeIdRow(deps, 'shared::/same//worktree', 'local')
).resolves.toMatchObject({ id: 'shared::/same//worktree' })
})
// #15598/#15616: the backslash spelling is what a pre-restart Windows registration recorded.
it('resolves a Windows backslash id against the forward-slash spelling git reports', async () => {
const path = 'D:/Agentic/game2/battle-core'
const owner = repo('shared', 'D:/Agentic/game2', { executionHostId: 'runtime:windows' })
const deps = createDeps([owner])
deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] }))
await expect(
resolveScopedWorktreeIdRow(deps, 'shared::D:\\Agentic\\game2\\battle-core', 'runtime:windows')
).resolves.toMatchObject({ id: `shared::${path}`, path })
})
it.each([
['no repo boundary', 'not-an-id'],
['an empty path', 'shared::']
])('keeps exact matching for a malformed id with %s', async (_label, worktreeId) => {
const deps = createDeps([repo('shared', '/local/repo', { executionHostId: 'local' })])
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toBeNull()
expect(deps.scanRepo).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,8 @@
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import {
splitWorktreeId,
splitWorktreeIdForFilesystem,
worktreeIdComparisonKey
} from '../../shared/worktree/id'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import { isFolderRepo } from '../../shared/repo-kind'
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
@@ -195,5 +199,18 @@ export async function resolveScopedWorktreeIdRow(
resolveLocalProjectRuntimesForRepos(store, [repo])
)
const projected = projectResolvedWorktreeLineage(rows, store.getAllWorktreeLineage?.() ?? {})
return projected.find((worktree) => worktree.id === worktreeId) ?? null
const exact = projected.find((worktree) => worktree.id === worktreeId)
if (exact) {
return exact
}
// Why (#16243): the scan can spell this id's path differently — the divergence `path:` absorbs.
// One equivalent row may stand in; two is an ambiguity a scoped lookup must refuse, not guess.
const comparisonKey = worktreeIdComparisonKey(worktreeId)
if (comparisonKey === null) {
return null
}
const equivalent = projected.filter(
(worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey
)
return equivalent.length === 1 ? equivalent[0] : null
}
@@ -0,0 +1,402 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import { OrcaRuntimeService } from '../../orca-runtime'
import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { SESSION_TAB_METHODS } from './session-tabs'
import { listSessionTabsInventory } from './session-tabs-inventory'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('session tabs inventory RPC methods', () => {
it('withholds an old-client list until the host inventory is authoritative', async () => {
let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(
() =>
new Promise<{ snapshots: []; authoritative: true }>((resolve) => {
resolveInventory = resolve
})
)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
const pending = dispatcher.dispatchStreaming(
makeRequest('session.tabs.listAll'),
(message) => messages.push(message),
{ clientKind: 'runtime' }
)
for (let index = 0; index < 20 && !resolveInventory; index += 1) {
await Promise.resolve()
}
expect(messages).toEqual([])
resolveInventory({ snapshots: [], authoritative: true })
await pending
expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [] })
})
// Why: a census failure only invalidates the emptiness verdict — the runtime
// degrades to the same scan unlabeled, so a capable client keeps the list
// (e.g. one configured-but-disconnected SSH host) without a second collect.
it('serves a capable client an unlabeled list when the census fails', async () => {
const legacyListAll = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(async () => ({
snapshots: [
{
worktree: 'wt-local',
publicationEpoch: 'epoch-local',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})),
listAllMobileSessionTabs: legacyListAll
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.listAll'),
(message) => messages.push(message),
{
clientKind: 'runtime',
clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY]
}
)
expect(legacyListAll).not.toHaveBeenCalled()
const result = JSON.parse(messages[0]!).result
expect(result.snapshots).toEqual([expect.objectContaining({ worktree: 'wt-local' })])
expect(result.authoritative).toBeUndefined()
})
it('propagates disconnect errors to legacy clients', async () => {
const legacyListAll = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(async () => {
throw new Error('client_disconnected')
}),
listAllMobileSessionTabs: legacyListAll
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.listAll'),
(message) => messages.push(message),
{ clientKind: 'runtime' }
)
expect(legacyListAll).not.toHaveBeenCalled()
expect(JSON.parse(messages[0]!)).toMatchObject({
ok: false,
error: expect.objectContaining({ code: 'runtime_error' })
})
})
it('labels an authoritative inventory only for a client that negotiated it', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(async () => ({
snapshots: [],
authoritative: true as const
}))
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.listAll'),
(message) => messages.push(message),
{
clientKind: 'runtime',
clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY]
}
)
expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [], authoritative: true })
})
it('serves an old client the degraded scan when terminal liveness cannot be proven', async () => {
const legacyListAll = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(async () => ({
snapshots: [
{
worktree: 'wt-local',
publicationEpoch: 'epoch-local',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})),
listAllMobileSessionTabs: legacyListAll
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.listAll'),
(message) => messages.push(message),
{ clientKind: 'runtime' }
)
expect(legacyListAll).not.toHaveBeenCalled()
expect(JSON.parse(messages[0]!).result).toEqual({
snapshots: [expect.objectContaining({ worktree: 'wt-local' })]
})
})
it('forwards the caller signal so a disconnected request never scans', async () => {
const inventory = vi.fn(async (_navigationId?: string, signal?: AbortSignal) => {
if (signal?.aborted) {
throw new Error('client_disconnected')
}
return { snapshots: [] }
})
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: inventory
} as unknown as OrcaRuntimeService
const controller = new AbortController()
controller.abort()
await expect(listSessionTabsInventory({ runtime, signal: controller.signal })).rejects.toThrow(
'client_disconnected'
)
expect(inventory).toHaveBeenCalledWith(undefined, controller.signal)
})
it('lets the final authoritative inventory subsume prior-epoch updates', async () => {
let resolveInventory!: (value: {
snapshots: RuntimeMobileSessionTabsResult[]
authoritative: true
}) => void
const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = []
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(
() =>
new Promise<{
snapshots: RuntimeMobileSessionTabsResult[]
authoritative: true
}>((resolve) => {
resolveInventory = resolve
})
),
onMobileSessionTabsChanged: vi.fn(
(listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => {
listeners.push(listener)
return vi.fn()
}
),
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
const pending = dispatcher.dispatchStreaming(
makeRequest('session.tabs.subscribeAll'),
(message) => messages.push(message),
{
connectionId: 'conn-authoritative',
clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY]
}
)
await Promise.resolve()
listeners[0]?.({
worktree: 'wt-authoritative',
publicationEpoch: 'epoch-before-reload',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
})
expect(messages).toEqual([])
resolveInventory({
snapshots: [
{
worktree: 'wt-authoritative',
publicationEpoch: 'epoch-after-reload',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
],
authoritative: true
})
await pending
listeners[0]?.({
worktree: 'wt-authoritative',
publicationEpoch: 'epoch-after-reload',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
})
expect(messages.map((message) => JSON.parse(message).result)).toEqual([
{
type: 'snapshots',
snapshots: [
expect.objectContaining({
worktree: 'wt-authoritative',
publicationEpoch: 'epoch-after-reload',
snapshotVersion: 1
})
],
authoritative: true
},
expect.objectContaining({
type: 'updated',
worktree: 'wt-authoritative',
publicationEpoch: 'epoch-after-reload',
snapshotVersion: 2
})
])
})
it('lets authoritative empty inventory win over prior-epoch updates', async () => {
let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void
const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = []
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(
() =>
new Promise<{ snapshots: []; authoritative: true }>((resolve) => {
resolveInventory = resolve
})
),
onMobileSessionTabsChanged: vi.fn(
(listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => {
listeners.push(listener)
return vi.fn()
}
),
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
const snapshot = (snapshotVersion: number): RuntimeMobileSessionTabsResult => ({
worktree: 'wt-buffered',
publicationEpoch: 'epoch-buffered',
snapshotVersion,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
})
const pending = dispatcher.dispatchStreaming(
makeRequest('session.tabs.subscribeAll'),
(message) => messages.push(message),
{
connectionId: 'conn-authoritative-empty',
clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY]
}
)
await Promise.resolve()
listeners[0]?.(snapshot(2))
listeners[0]?.(snapshot(3))
resolveInventory({ snapshots: [], authoritative: true })
await pending
expect(messages.map((message) => JSON.parse(message).result)).toEqual([
{ type: 'snapshots', snapshots: [], authoritative: true }
])
})
it('does no subscription work for an already-cancelled stream', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
supportsAuthoritativeSessionTabsInventory: vi.fn(() => true),
listAllMobileSessionTabsInventory: vi.fn(),
onMobileSessionTabsChanged: vi.fn(),
registerSubscriptionCleanup: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const controller = new AbortController()
const messages: string[] = []
controller.abort()
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.subscribeAll'),
(message) => messages.push(message),
{ signal: controller.signal }
)
expect(runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled()
expect(runtime.listAllMobileSessionTabsInventory).not.toHaveBeenCalled()
expect(messages.map((message) => JSON.parse(message))).toEqual([
expect.objectContaining({
ok: false,
error: expect.objectContaining({ code: 'runtime_error' })
})
])
})
it('aborts and removes a publication waiter when the stream is cleaned up', async () => {
const runtime = new OrcaRuntimeService()
runtime.attachWindow(1)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
const pending = dispatcher.dispatchStreaming(
makeRequest('session.tabs.subscribeAll'),
(message) => messages.push(message),
{
connectionId: 'conn-cancel',
clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY]
}
)
const waiters = (runtime as unknown as { sessionTabsInventoryWaiters: Set<() => void> })
.sessionTabsInventoryWaiters
for (let index = 0; index < 20 && waiters.size === 0; index += 1) {
await Promise.resolve()
}
expect(waiters.size).toBe(1)
runtime.cleanupSubscription('session.tabs:conn-cancel:*:req-1')
await pending
expect(waiters.size).toBe(0)
expect(messages.map((message) => JSON.parse(message))).toEqual([
expect.objectContaining({
ok: false,
error: expect.objectContaining({ code: 'runtime_error' })
})
])
})
})
@@ -0,0 +1,110 @@
import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type { RpcContext } from '../core'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
type SessionTabsInventory = {
snapshots: RuntimeMobileSessionTabsResult[]
authoritative?: true
}
function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean {
return (
context.clientCapabilities?.includes(
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY
) === true
)
}
export function projectSessionTabsForClient(
snapshot: RuntimeMobileSessionTabsResult,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2]
): RuntimeMobileSessionTabsResult {
return projectSessionTabBrowserPlacements(
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities),
clientCapabilities
)
}
function projectInventory(
inventory: SessionTabsInventory,
context: RpcContext
): SessionTabsInventory {
return {
snapshots: inventory.snapshots.map((snapshot) =>
projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities)
),
...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context)
? { authoritative: true as const }
: {})
}
}
export async function listSessionTabsInventory(context: RpcContext): Promise<SessionTabsInventory> {
const { runtime, pairedDeviceId, signal } = context
// Why: a failed census degrades inside the runtime to the same scan without
// the authoritative label, so no client ever pays a second full collection.
const inventory = runtime.supportsAuthoritativeSessionTabsInventory()
? await runtime.listAllMobileSessionTabsInventory(pairedDeviceId, signal)
: { snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) }
return projectInventory(inventory, context)
}
export async function subscribeSessionTabsInventory(
context: RpcContext,
emit: (result: unknown) => void
): Promise<void> {
const { runtime, connectionId, requestId, pairedDeviceId } = context
if (context.signal?.aborted) {
throw new Error('client_disconnected')
}
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
const inventoryController = new AbortController()
const abortInventory = (): void => inventoryController.abort()
context.signal?.addEventListener('abort', abortInventory, { once: true })
let initialized = false
let closed = false
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
if (!initialized) {
// Why: the final authoritative scan subsumes these; replaying a prior epoch afterward resurrects stale host state.
return
}
emit({
type: 'updated',
...projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities)
})
}, pairedDeviceId)
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
inventoryController.abort()
unsubscribe()
if (initialized) {
emit({ type: 'end' })
}
},
connectionId
)
if (closed) {
context.signal?.removeEventListener('abort', abortInventory)
return
}
const inventory = await listSessionTabsInventory({
...context,
signal: inventoryController.signal
})
.catch((error) => {
runtime.cleanupSubscription(subscriptionId)
throw error
})
.finally(() => context.signal?.removeEventListener('abort', abortInventory))
if (closed) {
return
}
emit({ type: 'snapshots', ...inventory })
initialized = true
}
@@ -651,6 +651,7 @@ describe('session tab RPC methods', () => {
tabs: []
}
]),
supportsAuthoritativeSessionTabsInventory: vi.fn(() => false),
onMobileSessionTabsChanged: vi.fn((listener: (snapshot: unknown) => void) => {
listeners.push(listener)
return unsubscribe
@@ -697,6 +698,7 @@ describe('session tab RPC methods', () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listAllMobileSessionTabs: vi.fn(() => []),
supportsAuthoritativeSessionTabsInventory: vi.fn(() => false),
onMobileSessionTabsChanged: vi.fn(() => vi.fn()),
registerSubscriptionCleanup: vi.fn()
} as unknown as OrcaRuntimeService
+11 -76
View File
@@ -7,25 +7,16 @@ import {
WorktreeTabSelector
} from './session-tabs-schemas'
import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
listSessionTabsInventory,
projectSessionTabsForClient,
subscribeSessionTabsInventory
} from './session-tabs-inventory'
import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods'
import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
function projectSessionTabsForClient(
snapshot: RuntimeMobileSessionTabsResult,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2]
) {
return projectSessionTabBrowserPlacements(
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities),
clientCapabilities
)
}
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'session.tabs.list',
@@ -42,13 +33,9 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'session.tabs.listAll',
params: null,
handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
return {
snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) =>
projectSessionTabsForClient(snapshot, clientKind, clientCapabilities)
)
}
handler: async (_params, context) => {
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
return listSessionTabsInventory(context)
}
}),
...SESSION_TAB_MUTATION_METHODS,
@@ -169,61 +156,9 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineStreamingMethod({
name: 'session.tabs.subscribeAll',
params: null,
handler: async (
_params,
{ runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities },
emit
) => {
let unsubscribe = (): void => {}
let closed = false
// Why: initial listAll errors should return one RPC error, not a leaked
// subscription cleanup that later emits a stray end frame.
let initialized = false
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
// Why: include the RPC id so one shared-control stream cannot evict siblings.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribe()
if (initialized) {
emit({ type: 'end' })
}
},
connectionId
)
if (closed) {
return
}
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
const snapshots = await Promise.resolve(
runtime.listAllMobileSessionTabs(pairedDeviceId)
).catch((error) => {
runtime.cleanupSubscription(subscriptionId)
throw error
})
if (closed) {
return
}
emit({
type: 'snapshots',
snapshots: snapshots.map((snapshot) =>
projectSessionTabsForClient(snapshot, clientKind, clientCapabilities)
)
})
initialized = true
if (closed) {
return
}
unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
emit({
type: 'updated',
...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities)
})
}, pairedDeviceId)
handler: async (_params, context, emit) => {
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
return subscribeSessionTabsInventory(context, emit)
}
}),
defineMethod({
@@ -0,0 +1,416 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
type InventoryInternals = {
sessionTabsInventoryWaiters: Set<() => void>
}
function createInventoryRuntime(): OrcaRuntimeService {
const runtime = new OrcaRuntimeService()
runtime.setPtyController({ listProcesses: vi.fn(async () => []) } as never)
return runtime
}
async function waitForInventoryWaiter(runtime: OrcaRuntimeService): Promise<void> {
const internals = runtime as unknown as InventoryInternals
for (let index = 0; index < 20 && internals.sessionTabsInventoryWaiters.size === 0; index += 1) {
await Promise.resolve()
}
expect(internals.sessionTabsInventoryWaiters.size).toBe(1)
}
describe('authoritative session tab inventory publication', () => {
it('primes daemon-backed inventory but withholds headed results until renderer publication', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
const collect = vi.spyOn(
runtime as unknown as { collectAllMobileSessionTabs: () => Promise<unknown> },
'collectAllMobileSessionTabs'
)
let settled = false
const pending = runtime.listAllMobileSessionTabsInventory().then((result) => {
settled = true
return result
})
await waitForInventoryWaiter(runtime)
expect(collect).toHaveBeenCalledTimes(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await Promise.resolve()
expect(settled).toBe(false)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true })
expect(collect).toHaveBeenCalledTimes(2)
})
it('treats an initial headless graph as an authoritative empty publication', async () => {
const runtime = createInventoryRuntime()
const pending = runtime.listAllMobileSessionTabsInventory()
await waitForInventoryWaiter(runtime)
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true })
})
it('returns a non-empty headed publication normally', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
const pending = runtime.listAllMobileSessionTabsInventory()
await waitForInventoryWaiter(runtime)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: 'repo::/worktree',
publicationEpoch: 'renderer-epoch',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
await expect(pending).resolves.toMatchObject({
authoritative: true,
snapshots: [{ worktree: 'repo::/worktree', publicationEpoch: 'renderer-epoch' }]
})
})
it('uses one inventory scan when the current publication is already authoritative', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
const collect = vi.spyOn(
runtime as unknown as { collectAllMobileSessionTabs: () => Promise<unknown> },
'collectAllMobileSessionTabs'
)
await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({
snapshots: [],
authoritative: true
})
expect(collect).toHaveBeenCalledOnce()
})
it('retries publication churn until one inventory observes a stable epoch', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
const inventory = {
snapshots: [],
ptyInventory: {
livePtyIds: new Set<string>(),
allLivePtyIds: new Set<string>(),
terminalIdentityByPtyId: new Map(),
queriedHostIds: new Set(['local'])
}
}
let collections = 0
vi.spyOn(
runtime as unknown as { collectAllMobileSessionTabs: () => Promise<unknown> },
'collectAllMobileSessionTabs'
).mockImplementation(async () => {
collections += 1
if (collections <= 3) {
expect(runtime.markRendererReloading(1)).not.toBeNull()
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
}
return inventory
})
await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({
snapshots: [],
authoritative: true
})
expect(collections).toBe(4)
})
it('coalesces targeted and all-host PTY refreshes behind one aggregate census', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
const emptyInventory = {
livePtyIds: new Set<string>(),
allLivePtyIds: new Set<string>(),
terminalIdentityByPtyId: new Map(),
queriedHostIds: new Set(['local'])
}
let resolveRefresh: ((inventory: typeof emptyInventory) => void) | undefined
const internals = runtime as unknown as {
refreshMobileSessionPtyInventory: (targetWorktreeId?: string | null) => Promise<unknown>
performMobileSessionPtyRecordsRefresh: (targetWorktreeId: string | null) => Promise<unknown>
}
const perform = vi.spyOn(internals, 'performMobileSessionPtyRecordsRefresh').mockImplementation(
() =>
new Promise((resolve) => {
resolveRefresh = resolve
})
)
const allHosts = runtime.listAllMobileSessionTabsInventory()
await Promise.resolve()
const targeted = internals.refreshMobileSessionPtyInventory('repo::/target')
await Promise.resolve()
expect(perform).toHaveBeenCalledOnce()
resolveRefresh?.(emptyInventory)
await targeted
await expect(allHosts).resolves.toEqual({ snapshots: [], authoritative: true })
})
it('retries once and serves an unlabeled scan when the PTY census is unavailable', async () => {
const runtime = new OrcaRuntimeService()
const collect = vi.spyOn(
runtime as unknown as { collectAllMobileSessionTabs: () => Promise<unknown> },
'collectAllMobileSessionTabs'
)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] })
// Why: the retry gives daemon-backed tabs a post-race restore chance.
expect(collect).toHaveBeenCalledTimes(2)
})
it('serves an unlabeled scan when an execution host is omitted from the census', async () => {
const runtime = createInventoryRuntime()
vi.spyOn(
runtime as unknown as {
listKnownExecutionHostIds: () => Set<string>
},
'listKnownExecutionHostIds'
).mockReturnValue(new Set(['local', 'ssh:box-1']))
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] })
})
it('restores a renderer-owned PTY discovered by the authoritative inventory refresh', async () => {
const runtime = createInventoryRuntime()
const ptyId = 'pty-survived-host-relaunch'
const worktreeId = 'repo::/survived'
const tabId = '11111111-1111-4111-8111-111111111111'
const leafId = '22222222-2222-4222-8222-222222222222'
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
;(
runtime as unknown as {
pairedRendererSessionOwnedPtyIds: Set<string>
}
).pairedRendererSessionOwnedPtyIds.add(ptyId)
vi.spyOn(
runtime as unknown as { refreshMobileSessionPtyInventory: () => Promise<unknown> },
'refreshMobileSessionPtyInventory'
).mockImplementation(async () => {
runtime.registerPty(ptyId, worktreeId, null, {
tabId,
leafId,
incarnationId: 'incarnation-survived-host-relaunch'
})
return {
livePtyIds: new Set([ptyId]),
allLivePtyIds: new Set([ptyId]),
terminalIdentityByPtyId: new Map(),
queriedHostIds: new Set(['local'])
}
})
const result = await runtime.listAllMobileSessionTabsInventory()
expect(result).toMatchObject({
authoritative: true,
snapshots: [
{
worktree: worktreeId,
tabs: [
expect.objectContaining({
type: 'terminal',
parentTabId: tabId,
leafId,
ptyId
})
]
}
]
})
})
it('requires the current headed renderer generation to publish', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: []
})
runtime.markRendererReloading(1)
let settled = false
const pending = runtime.listAllMobileSessionTabsInventory().then((result) => {
settled = true
return result
})
await waitForInventoryWaiter(runtime)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await Promise.resolve()
expect(settled).toBe(false)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: 'repo::/restored',
publicationEpoch: 'renderer-reloaded',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
await expect(pending).resolves.toMatchObject({
authoritative: true,
snapshots: [{ worktree: 'repo::/restored', publicationEpoch: 'renderer-reloaded' }]
})
})
it('withholds authority while the renderer owes a requested worktree resync', async () => {
const runtime = createInventoryRuntime()
const worktree = 'repo::/resync'
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
const incomplete = runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [],
unchangedMobileSessionWorktrees: [worktree]
})
expect(incomplete.mobileSessionResyncWorktrees).toEqual([worktree])
const pending = runtime.listAllMobileSessionTabsInventory()
await waitForInventoryWaiter(runtime)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree,
publicationEpoch: 'renderer-resynced',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
await expect(pending).resolves.toMatchObject({
authoritative: true,
snapshots: [{ worktree, publicationEpoch: 'renderer-resynced' }]
})
})
it('restores publication authority when a renderer reload is cancelled', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
const fence = runtime.markRendererReloading(1)
if (!fence) {
throw new Error('expected renderer reload fence')
}
const pending = runtime.listAllMobileSessionTabsInventory()
await waitForInventoryWaiter(runtime)
expect(runtime.markRendererReloadCancelled(1, fence)).toBe(true)
await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true })
})
it('restores authoritative headless publication after a failed desktop promotion', async () => {
const runtime = createInventoryRuntime()
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
runtime.attachWindow(1)
const pending = runtime.listAllMobileSessionTabsInventory()
await waitForInventoryWaiter(runtime)
runtime.markGraphReloadFailed(1, 'renderer-process-gone')
await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true })
})
it('removes a publication waiter when the request is cancelled', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
const controller = new AbortController()
const pending = runtime.listAllMobileSessionTabsInventory(undefined, controller.signal)
await waitForInventoryWaiter(runtime)
controller.abort()
await expect(pending).rejects.toThrow('client_disconnected')
expect((runtime as unknown as InventoryInternals).sessionTabsInventoryWaiters.size).toBe(0)
})
it('does not scan when the inventory request is already cancelled', async () => {
const runtime = createInventoryRuntime()
const collect = vi.spyOn(
runtime as unknown as { collectAllMobileSessionTabs: () => Promise<unknown> },
'collectAllMobileSessionTabs'
)
const controller = new AbortController()
controller.abort()
await expect(
runtime.listAllMobileSessionTabsInventory(undefined, controller.signal)
).rejects.toThrow('client_disconnected')
expect(collect).not.toHaveBeenCalled()
})
it('does not let a secondary renderer graph publish inventory authority', async () => {
const runtime = createInventoryRuntime()
runtime.attachWindow(1)
let settled = false
const pending = runtime.listAllMobileSessionTabsInventory().then((result) => {
settled = true
return result
})
await waitForInventoryWaiter(runtime)
expect(() =>
runtime.syncWindowGraph(2, { tabs: [], leaves: [], mobileSessionTabs: [] })
).toThrow('Runtime graph publisher does not match the authoritative window')
await Promise.resolve()
expect(settled).toBe(false)
expect(
(runtime as unknown as { sessionTabsInventoryPublicationEpoch: number | null })
.sessionTabsInventoryPublicationEpoch
).toBeNull()
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true })
})
it('reports no authoritative support behind the e2e disable override', () => {
vi.stubEnv('ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY', '1')
try {
expect(createInventoryRuntime().supportsAuthoritativeSessionTabsInventory()).toBe(false)
} finally {
vi.unstubAllEnvs()
}
})
})
@@ -0,0 +1,273 @@
/**
* Pins path-SPELLING parity between an `id:` selector and `path:`, deliberately not dedupe parity:
* where two same-host rows spell one directory, `path:` collapses them to the first while `id:`
* refuses as ambiguous, because this resolver also serves delete (#16243).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const electronMocks = vi.hoisted(() => {
const ipcMain = {
on: vi.fn(() => ipcMain),
removeListener: vi.fn(() => ipcMain),
emit: vi.fn(() => true)
}
return {
BrowserWindow: { fromId: vi.fn((): unknown => null) },
webContents: { fromId: vi.fn((): unknown => null) },
ipcMain,
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}
})
vi.mock('electron', () => electronMocks)
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: vi.fn(() => 0),
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable',
requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId)
}))
const listWorktreesStrictMock = vi.hoisted(() => vi.fn())
vi.mock('../git/worktree', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listWorktreesStrict: listWorktreesStrictMock
}))
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { OrcaRuntimeService } from './orca-runtime'
const REPO_ID = 'repo-local'
const REPO_PATH = '/srv/projects/app'
/** The spelling `git worktree list` reports. */
const WORKTREE_PATH = '/srv/projects/workspaces/plugin-host'
const CANONICAL_ID = `${REPO_ID}::${WORKTREE_PATH}`
/** One directory, other spellings a stored id can legitimately carry. */
const ID_SPELLINGS: [label: string, worktreePath: string, repoPath?: string][] = [
['a trailing slash', `${WORKTREE_PATH}/`],
['a doubled separator', '/srv/projects//workspaces/plugin-host'],
['an NFD workspace name', '/srv/projects/workspaces/café-plugin'.normalize('NFD')],
// #15598/#15616: a Windows registration records backslashes; git reports forward slashes.
['a backslash Windows spelling', 'D:\\Agentic\\game2\\battle-core', 'D:/Agentic/game2']
]
/** What a scan reports for a stored id: the same directory, canonically spelled. */
function scannedSpellingOf(storedPath: string): string {
const slashed = /^[A-Za-z]:[\\/]/.test(storedPath) ? storedPath.replace(/\\/g, '/') : storedPath
return slashed.normalize('NFC').replace(/\/+/g, '/').replace(/\/$/, '')
}
/** One registered repo whose worktree meta is writable, so a delete's `forgetLocal` is observable. */
function makeStore(repoPath: string = REPO_PATH) {
const metaById: Record<string, Record<string, unknown>> = {}
const store = {
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
getRepos: () => [
{ id: REPO_ID, path: repoPath, displayName: 'app', badgeColor: 'blue', addedAt: 1 }
],
getAllWorktreeMeta: vi.fn(() => metaById),
getWorktreeMeta: (id: string) => metaById[id],
setWorktreeMeta: (id: string, meta: Record<string, unknown>) => {
metaById[id] = { ...metaById[id], ...meta }
return metaById[id]
},
removeWorktreeMeta: () => {},
getAllWorktreeLineage: () => ({}),
getAllWorkspaceLineage: () => ({}),
removeWorktreeLineage: vi.fn(),
removeWorkspaceLineage: vi.fn(),
getGitHubCache: () => undefined as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
}),
getProjects: () => []
}
return store
}
/** `git worktree list` output: the main checkout plus one workspace at `worktreePath`. */
function scanReports(worktreePath: string, repoPath: string = REPO_PATH): void {
listWorktreesStrictMock.mockResolvedValue([
{ path: repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
{ path: worktreePath, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false }
])
}
type RemovalInternals = {
resolveWorktreeRemovalTarget: (
worktreeSelector: string,
requiredHostId?: string
) => Promise<{ id: string; repoId: string; path: string }>
}
beforeEach(() => {
getSshGitProviderMock.mockReset()
listWorktreesStrictMock.mockReset()
})
describe('worktree id selectors vs. the path spelling git reports (#16243)', () => {
it.each(ID_SPELLINGS)(
'resolves through the fleet path what `path:` resolves when the id carries %s',
async (_label, storedPath, repoPath) => {
scanReports(scannedSpellingOf(storedPath), repoPath)
const runtime = new OrcaRuntimeService(makeStore(repoPath) as never)
// The CLI's shape, and the live data point: `path:` already resolves it.
const byPath = await runtime.showManagedWorktree(`path:${storedPath}`)
// The only shape the renderer can send must resolve the SAME workspace.
await expect(
runtime.showManagedWorktree(`id:${REPO_ID}::${storedPath}`)
).resolves.toMatchObject({ id: byPath.id, path: byPath.path })
}
)
it.each(ID_SPELLINGS)(
'resolves a host-qualified removal target when the id carries %s',
async (_label, storedPath, repoPath) => {
scanReports(scannedSpellingOf(storedPath), repoPath)
const runtime = new OrcaRuntimeService(makeStore(repoPath) as never)
const internals = runtime as unknown as RemovalInternals
// The scoped path the UI's delete takes must agree with the fleet path above.
await expect(
internals.resolveWorktreeRemovalTarget(
`id:${REPO_ID}::${storedPath}`,
LOCAL_EXECUTION_HOST_ID
)
).resolves.toMatchObject({ repoId: REPO_ID, path: scannedSpellingOf(storedPath) })
}
)
it('still refuses an id whose path names a different workspace', async () => {
scanReports(WORKTREE_PATH)
const runtime = new OrcaRuntimeService(makeStore() as never)
await expect(
runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects/workspaces/other-plugin`)
).rejects.toThrow('selector_not_found')
})
// STA-4343: matching across repo ids would delete a workspace the caller never confirmed.
it('still refuses the same path under a different repo id', async () => {
scanReports(WORKTREE_PATH)
const runtime = new OrcaRuntimeService(makeStore() as never)
await expect(runtime.showManagedWorktree(`id:other-repo::${WORKTREE_PATH}/`)).rejects.toThrow(
'selector_not_found'
)
})
it('still refuses a removal qualified to a host that does not own the repo id', async () => {
scanReports(WORKTREE_PATH)
const runtime = new OrcaRuntimeService(makeStore() as never)
const internals = runtime as unknown as RemovalInternals
await expect(
internals.resolveWorktreeRemovalTarget(`id:${CANONICAL_ID}/`, 'runtime:env-b')
).rejects.toThrow('selector_not_found')
})
it('keeps `id:` and `path:` agreeing on a dot segment neither canonicalizes', async () => {
scanReports(WORKTREE_PATH)
const runtime = new OrcaRuntimeService(makeStore() as never)
const dotted = '/srv/projects/./workspaces/plugin-host'
await expect(runtime.showManagedWorktree(`path:${dotted}`)).rejects.toThrow(
'selector_not_found'
)
await expect(runtime.showManagedWorktree(`id:${REPO_ID}::${dotted}`)).rejects.toThrow(
'selector_not_found'
)
})
// #15598/#15616: on Windows one checkout is recorded under both spellings, and git reports the
// forward-slash one. The backslash id itself rides the ID_SPELLINGS rows above; these pin the
// folding limits around it.
describe('Windows drive-letter spellings', () => {
const WINDOWS_REPO = 'D:/Agentic/game2'
const WINDOWS_WORKTREE = 'D:/Agentic/game2/battle-core'
it('folds drive-letter case only for Windows paths, never for a POSIX path', async () => {
scanReports(WINDOWS_WORKTREE, WINDOWS_REPO)
const windowsRuntime = new OrcaRuntimeService(makeStore(WINDOWS_REPO) as never)
// A Windows root is case-insensitive, as `path:` already treats it.
await expect(
windowsRuntime.showManagedWorktree(`id:${REPO_ID}::d:/agentic/game2/battle-core`)
).resolves.toMatchObject({ path: WINDOWS_WORKTREE })
// A POSIX root is not: folding case there would merge distinct directories for a delete.
scanReports(WORKTREE_PATH)
const posixRuntime = new OrcaRuntimeService(makeStore() as never)
await expect(
posixRuntime.showManagedWorktree(`id:${REPO_ID}::/SRV/projects/workspaces/plugin-host`)
).rejects.toThrow('selector_not_found')
})
it('does not fold a backslash inside a POSIX path, where it is a valid filename character', async () => {
scanReports(WORKTREE_PATH)
const runtime = new OrcaRuntimeService(makeStore() as never)
await expect(
runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects\\workspaces\\plugin-host`)
).rejects.toThrow('selector_not_found')
})
})
// The fail-closed guard on a delete-capable resolver: two rows spelling one directory must not
// let a folded id pick one. `path:` collapses same-host duplicates; `id:` deliberately refuses.
it('refuses a folded id when two same-repo rows spell one directory', async () => {
listWorktreesStrictMock.mockResolvedValue([
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
{ path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false },
{
path: '/srv/projects//workspaces/plugin-host',
head: 'ghi',
branch: 'feature-2',
isBare: false,
isMainWorktree: false
}
])
const runtime = new OrcaRuntimeService(makeStore() as never)
// Matches neither row exactly, folds to both.
await expect(runtime.showManagedWorktree(`id:${CANONICAL_ID}/`)).rejects.toThrow(
'selector_ambiguous'
)
})
// Live-proof limit, pinned so nobody "fixes" the trimming: a folder-workspace id only trims a
// trailing slash at end of string, so one placed before the instance suffix stays exact-only.
it('keeps a folder-workspace id exact when a slash precedes the instance suffix', async () => {
const instanceSuffix = '::workspace:123e4567-e89b-12d3-a456-426614174000'
scanReports(WORKTREE_PATH)
const runtime = new OrcaRuntimeService(makeStore() as never)
await expect(
runtime.showManagedWorktree(`id:${REPO_ID}::${WORKTREE_PATH}/${instanceSuffix}`)
).rejects.toThrow('selector_not_found')
})
// #15616 guarantees malformed ids keep exact-match behavior; both sites must honour that too.
it.each([
['no repo boundary', 'not-an-id'],
['an empty path', `${REPO_ID}::`]
])('keeps exact matching for a malformed id with %s', async (_label, malformedId) => {
scanReports(WORKTREE_PATH)
const runtime = new OrcaRuntimeService(makeStore() as never)
const internals = runtime as unknown as RemovalInternals
await expect(runtime.showManagedWorktree(`id:${malformedId}`)).rejects.toThrow(
'selector_not_found'
)
await expect(
internals.resolveWorktreeRemovalTarget(`id:${malformedId}`, LOCAL_EXECUTION_HOST_ID)
).rejects.toThrow('selector_not_found')
})
})
+113
View File
@@ -0,0 +1,113 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import SyncDatabase from './sync-database'
import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite-read-failure'
// Error codes here are the ones a real node:sqlite open produces: a contended
// database reports errcode 5 ("database is locked"), while a read-only WAL open
// with no usable -shm reports errcode 14 ("unable to open database file"). The
// two need opposite responses, so the classifier must never conflate them.
let tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
function contendedDatabase(): { path: string; release: () => void } {
const dir = mkdtempSync(join(tmpdir(), 'orca-sqlite-failure-'))
tempDirs.push(dir)
const path = join(dir, 'contended.db')
const writer = new SyncDatabase(path)
writer.exec('PRAGMA journal_mode=DELETE')
writer.exec('CREATE TABLE session (id TEXT PRIMARY KEY)')
writer.exec('BEGIN EXCLUSIVE')
writer.exec("INSERT INTO session VALUES ('a')")
return {
path,
release: () => {
writer.exec('COMMIT')
writer.close()
}
}
}
describe('isTransientSqliteContention', () => {
it('recognizes a real SQLITE_BUSY thrown by a read-only open', () => {
const contended = contendedDatabase()
let thrown: unknown
try {
new SyncDatabase(contended.path, { readonly: true, timeout: 0 })
.prepare('SELECT id FROM session')
.all()
} catch (error) {
thrown = error
} finally {
contended.release()
}
expect((thrown as { errcode?: number }).errcode).toBe(5)
expect(isTransientSqliteContention(thrown)).toBe(true)
})
it('recognizes a relayed message with no errcode, as the Codex heal pass sees it', () => {
expect(
isTransientSqliteContention('codex app-server thread/read failed: database is locked')
).toBe(true)
expect(isTransientSqliteContention(new Error('SQLITE_LOCKED: table is locked'))).toBe(true)
})
it('reads extended result codes through their primary code', () => {
// SQLITE_BUSY_SNAPSHOT (517) and SQLITE_BUSY_RECOVERY (261) both pack 5.
expect(isTransientSqliteContention({ errcode: 517, message: 'busy snapshot' })).toBe(true)
expect(isTransientSqliteContention({ errcode: 261, message: 'recovery' })).toBe(true)
})
it('does not treat an unreadable or absent database as contention', () => {
expect(isTransientSqliteContention(new Error('file is not a database'))).toBe(false)
expect(
isTransientSqliteContention({ errcode: 14, message: 'unable to open database file' })
).toBe(false)
})
})
describe('classifySqliteReadFailure', () => {
it('classifies contention as retryable regardless of the file evidence', () => {
const error = { errcode: 5, message: 'database is locked' }
expect(classifySqliteReadFailure({ error, databaseFileExists: true })).toBe('contended')
expect(classifySqliteReadFailure({ error, databaseFileExists: false })).toBe('contended')
})
it('classifies SQLITE_CANTOPEN against a present database as an unreachable wal-index', () => {
expect(
classifySqliteReadFailure({
error: { errcode: 14, message: 'unable to open database file' },
databaseFileExists: true
})
).toBe('wal-index-unavailable')
})
it('does not blame the wal-index when the database file itself is gone', () => {
expect(
classifySqliteReadFailure({
error: { errcode: 14, message: 'unable to open database file' },
databaseFileExists: false
})
).toBe('unreadable')
})
it('falls back to unreadable for anything else', () => {
expect(
classifySqliteReadFailure({
error: { errcode: 11, message: 'database disk image is malformed' },
databaseFileExists: true
})
).toBe('unreadable')
})
})
+77
View File
@@ -0,0 +1,77 @@
// Why (#15036): a read-only SQLite open can fail for reasons that demand
// opposite responses, and both arrive as an opaque driver string. Contention is
// transient — retry later, never persist it as a permanent failure. A missing
// wal-index is structural — retrying forever cannot fix it.
/** Primary result codes; extended codes pack the primary code in the low byte. */
const SQLITE_BUSY = 5
const SQLITE_LOCKED = 6
const SQLITE_CANTOPEN = 14
// Shared with the Codex index-heal pass, which only ever sees a relayed message
// string (app-server RPC drops `errcode`), so message matching is not optional.
const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i
function primaryErrcode(error: unknown): number | null {
if (!error || typeof error !== 'object' || !('errcode' in error)) {
return null
}
const errcode = (error as { errcode?: unknown }).errcode
return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* True when a SQLite failure means "someone else holds the database right now".
* @param error - The thrown value from a `node:sqlite` call or a relayed message.
* @returns Whether the caller should retry later rather than record a failure.
*/
export function isTransientSqliteContention(error: unknown): boolean {
const errcode = primaryErrcode(error)
if (errcode === SQLITE_BUSY || errcode === SQLITE_LOCKED) {
return true
}
return CONTENTION_MESSAGE.test(errorText(error))
}
export type SqliteReadFailureKind =
/** Another connection holds the lock; the same read can succeed later. */
| 'contended'
/**
* The database file itself is present, but SQLite still could not open the
* set of files it needs — in practice the `-wal`/`-shm` pair a read-only WAL
* open requires, which shares that cannot host SQLite shared memory (9p
* `\\wsl.localhost`, SMB, SSHFS) refuse. Retrying cannot fix it.
*/
| 'wal-index-unavailable'
/** Anything else: corrupt file, wrong schema, permissions. */
| 'unreadable'
/**
* Classify why a read-only SQLite open or query failed.
*
* Pure: the caller supplies the "does the database file exist" evidence, so
* neither branch needs a filesystem probe of its own. That matters on a WSL 9p
* share, where an extra sync stat is exactly the hang the transcript gate exists
* to prevent.
* @param args.error - The thrown value.
* @param args.databaseFileExists - Whether the main database file was present.
* @returns Which of the three failure modes the error represents.
*/
export function classifySqliteReadFailure(args: {
error: unknown
databaseFileExists: boolean
}): SqliteReadFailureKind {
if (isTransientSqliteContention(args.error)) {
return 'contended'
}
// SQLITE_CANTOPEN against a database that is right there names a companion
// file — the wal-index a read-only WAL open needs — not the database itself.
if (primaryErrcode(args.error) === SQLITE_CANTOPEN && args.databaseFileExists) {
return 'wal-index-unavailable'
}
return 'unreadable'
}
+80
View File
@@ -1,11 +1,13 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import { afterEach, describe, expect, it } from 'vitest'
import SyncDatabase from './sync-database'
const temporaryDirectories: string[] = []
const openDatabases: SyncDatabase.Database[] = []
const lockHolders: Worker[] = []
async function createDatabase(): Promise<SyncDatabase.Database> {
const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-'))
@@ -20,6 +22,7 @@ async function createDatabase(): Promise<SyncDatabase.Database> {
}
afterEach(async () => {
await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate()))
for (const db of openDatabases.splice(0)) {
try {
db.close()
@@ -151,3 +154,80 @@ describe('SyncDatabase statement cache', () => {
)
})
})
// Why (#15036): the OpenCode readers opened read-only with no busy timeout, so a
// contended DB failed in ~1 ms and emptied the whole panel. These pin that the
// wrapper really forwards `timeout` into sqlite3_busy_timeout.
describe('SyncDatabase read-only opens under contention', () => {
// The lock holder must live on another thread: sqlite3_busy_timeout sleeps
// synchronously, so a same-thread timer could never fire to release it.
const WRITER_SOURCE = `
const { parentPort, workerData } = require('node:worker_threads')
const { DatabaseSync } = require('node:sqlite')
const db = new DatabaseSync(workerData.path)
db.exec('PRAGMA journal_mode=DELETE')
db.exec("CREATE TABLE items (id TEXT PRIMARY KEY); INSERT INTO items VALUES ('a')")
db.exec('BEGIN EXCLUSIVE')
db.exec("INSERT INTO items VALUES ('b')")
parentPort.postMessage('locked')
setTimeout(() => {
db.exec('COMMIT')
db.close()
parentPort.postMessage('released')
}, workerData.holdMs)
`
async function contendedDatabase(holdMs: number): Promise<{ path: string }> {
const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-busy-'))
temporaryDirectories.push(directory)
const path = join(directory, 'contended.db')
const worker = new Worker(WRITER_SOURCE, { eval: true, workerData: { path, holdMs } })
lockHolders.push(worker)
await new Promise<void>((resolve, reject) => {
worker.once('message', () => resolve())
worker.once('error', reject)
})
return { path }
}
it('fails immediately with SQLITE_BUSY when no timeout is given', async () => {
const contended = await contendedDatabase(10_000)
const startedAt = Date.now()
let thrown: unknown
try {
new SyncDatabase(contended.path, { readonly: true }).prepare('SELECT id FROM items').all()
} catch (error) {
thrown = error
}
expect((thrown as { errcode?: number }).errcode).toBe(5)
expect((thrown as Error).message).toContain('database is locked')
expect(Date.now() - startedAt).toBeLessThan(200)
})
it('waits for the configured busy timeout before giving up', async () => {
const contended = await contendedDatabase(10_000)
const startedAt = Date.now()
expect(() =>
new SyncDatabase(contended.path, { readonly: true, timeout: 400 })
.prepare('SELECT id FROM items')
.all()
).toThrow(/database is locked/)
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(350)
})
it('succeeds once the writer commits inside the busy timeout', async () => {
const contended = await contendedDatabase(150)
const reader = new SyncDatabase(contended.path, { readonly: true, timeout: 5_000 })
openDatabases.push(reader)
expect(reader.prepare('SELECT id FROM items ORDER BY id').all()).toEqual([
{ id: 'a' },
{ id: 'b' }
])
})
})
@@ -17,6 +17,7 @@
main/agent-hooks/wsl-hook-relay-launch.ts
main/browser/wsl-browser-network-relay-launch.ts
main/claude-accounts/claude-command-process.ts
main/codex-accounts/runtime-home-service.ts
main/codex-accounts/service.ts
main/codex/codex-state-db-backfill-recovery.ts
main/codex/codex-trust-grant-host.ts
+74 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { RelayAgentHookServer } from './agent-hook-server'
@@ -82,6 +82,79 @@ describe('RelayAgentHookServer', () => {
}
})
it('normalizes and forwards raw spooled hooks on startup', async () => {
const spoolDir = join(dir, 'spool')
const spoolFile = join(spoolDir, 'pane-codex.jsonl')
mkdirSync(spoolDir)
writeFileSync(
spoolFile,
`${JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt-1',
env: 'remote',
version: '1',
launchToken: 'generation-token',
hookEventName: 'SubagentStop',
source: 'codex',
payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' },
receivedAt: Date.now()
})}\n`
)
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
expect(forward).toHaveBeenCalledTimes(1)
expect(forward.mock.calls[0][0]).toMatchObject({
source: 'codex',
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt-1',
launchToken: 'generation-token',
hookEventName: 'SubagentStop',
isReplay: true,
env: 'remote',
version: '1',
payload: { state: 'working', agentType: 'codex' }
})
expect(readFileSync(spoolFile)).toHaveLength(0)
} finally {
server.stop()
}
})
it('keeps the relay listening when spool replay forwarding fails', async () => {
const spoolDir = join(dir, 'spool')
const spoolFile = join(spoolDir, 'pane-codex.jsonl')
mkdirSync(spoolDir)
writeFileSync(
spoolFile,
`${JSON.stringify({
paneKey: PANE_KEY,
source: 'codex',
hookEventName: 'SubagentStop',
payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' },
receivedAt: Date.now()
})}\n`
)
const server = new RelayAgentHookServer({
endpointDir: dir,
forward: () => {
throw new Error('receiver unavailable')
}
})
try {
await expect(server.start()).resolves.toBeUndefined()
expect(server.getCoordinates().port).toBeGreaterThan(0)
expect(readFileSync(spoolFile, 'utf8')).not.toBe('')
} finally {
server.stop()
}
})
it('caches before forwarding, schedules retries after forwarding, and responds last', async () => {
const order: string[] = []
let server!: RelayAgentHookServer
+38 -2
View File
@@ -24,10 +24,16 @@ import {
isHookRequestTruncatedError
} from '../shared/agent-hook-transport-interference'
import {
isAgentHookSource,
REMOTE_AGENT_HOOK_ENV,
type AgentHookRelayEnvelope,
type AgentHookSource
} from '../shared/agent-hook-relay'
import {
buildSpoolHookBody,
drainAgentHookSpool,
type SpoolRecord
} from '../shared/agent-hook-spool'
import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates'
import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build'
import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler'
@@ -100,6 +106,19 @@ export class RelayAgentHookServer {
this.token = this.fixedToken ?? randomUUID()
this.endpointFileWritten = false
this.portFallbackApplied = false
try {
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: () => undefined,
ingest: (record) => this.ingestSpoolRecord(record)
})
} catch (err) {
// Why: a downstream relay failure must not prevent the loopback listener from starting;
// the untruncated spool file remains available for retry on the next restart.
process.stderr.write(
`[relay-hook-server] spool replay failed: ${err instanceof Error ? err.message : String(err)}\n`
)
}
try {
await this.listenOn(this.preferredPort)
} catch (err) {
@@ -273,7 +292,8 @@ export class RelayAgentHookServer {
event: AgentHookEventPayload,
source: AgentHookSource,
env?: string,
version?: string
version?: string,
options: { isReplay?: boolean } = {}
): void {
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
this.retryScheduler.clearAssistantMessageRetry(event.paneKey)
@@ -294,6 +314,22 @@ export class RelayAgentHookServer {
}
this.clearPaneState(oldest)
}
this.forward(buildRelayHookEnvelope(event, source, env, version))
this.forward(buildRelayHookEnvelope(event, source, env, version, options))
}
private ingestSpoolRecord(record: SpoolRecord): void {
if (!isAgentHookSource(record.source)) {
return
}
const body = buildSpoolHookBody(record)
const event = normalizeHookPayload(this.state, record.source, body, this.env, {
deferCompactOwnershipToClient: true
})
if (!event) {
return
}
this.applyEvent(event, record.source, hookBodyEnv(body), hookBodyVersion(body), {
isReplay: true
})
}
}
@@ -239,3 +239,26 @@ describe('ImageViewer preview source retry', () => {
expect(findElementsByType(rendered, 'img')).toHaveLength(0)
})
})
describe('ImageViewer pre-load layout box', () => {
beforeEach(() => {
reactHookRuntime.states = []
reactHookRuntime.index = 0
vi.clearAllMocks()
})
// Why: the scroll surface's inner box is w-max/h-max, so percentage maxes resolve
// to none and a 6000x4000 image lays out at natural resolution before onLoad.
it('bounds both scroll surfaces with viewport units before the image loads', async () => {
const images = findElementsByType(await renderExpandedImageViewer(pngBase64(1)), 'img')
expect(images).toHaveLength(2)
for (const image of images) {
const className = String(image.props.className)
expect(className).toContain('max-h-[100vh]')
expect(className).toContain('max-w-[100vw]')
expect(className).not.toContain('max-h-full')
expect(className).not.toContain('max-w-full')
}
})
})
@@ -291,7 +291,9 @@ export default function ImageViewer({
? 'block h-auto max-h-none max-w-full'
: inlineImageLayoutSize
? 'block h-full w-full'
: 'block max-h-full max-w-full'
: // Why: the w-max/h-max scroll box makes percentage maxes resolve to
// none, so only viewport units bound the image before onLoad.
'block max-h-[100vh] max-w-[100vw]'
)}
onLoad={(event) => {
const img = event.currentTarget
@@ -61,7 +61,11 @@ export default function ImageViewerPopup({
alt={filename}
className={cn(
'object-contain',
imageLayoutSize ? 'block h-full w-full' : 'block max-h-full max-w-full'
imageLayoutSize
? 'block h-full w-full'
: // Why: the w-max/h-max scroll box makes percentage maxes resolve to
// none, so only viewport units bound the image before onLoad.
'block max-h-[100vh] max-w-[100vw]'
)}
/>
</div>
@@ -78,6 +78,24 @@ describe('aiVaultScanNoticeIssues', () => {
expect(skippedAiVaultTranscriptCount(partial)).toBe(1)
})
// #15036: a locked opencode.db holds every OpenCode session, so reporting it
// as "1 transcript skipped" both understates the loss and buries the cause in
// a bare driver string.
it('gives an unreadable OpenCode database its own row instead of a skipped count', () => {
const sourceIssue = {
agent: 'opencode' as const,
kind: 'scope' as const,
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db',
message:
'OpenCode is writing to opencode.db right now, so its history was skipped. It is read again on the next refresh.'
}
const empty = result([], [sourceIssue])
expect(skippedAiVaultTranscriptCount(empty)).toBe(0)
expect(skippedAiVaultTranscriptReasons(empty)).toEqual([])
expect(aiVaultScanNoticeIssues(empty)).toEqual([sourceIssue])
})
it('does not repeat the blocking issue as a notice row', () => {
const hostIssue = {
executionHostId: 'ssh:dev-box' as const,
@@ -12,7 +12,9 @@ export function blockingAiVaultScanIssue(
// Host and scope issues carry their own scanner-authored copy, so they get their
// own rows instead of being counted as skipped transcripts — a partial scan
// (one SSH host down, rest fine) must not report a connectivity failure as a
// skipped transcript file.
// skipped transcript file, and an unreadable *source* (a whole locked
// opencode.db holding every OpenCode session) must not read as "1 transcript
// skipped".
export function aiVaultScanNoticeIssues(result: AiVaultListResult | null): AiVaultScanIssue[] {
if (!result) {
return []
@@ -28,9 +28,10 @@ function keyboardEvent(
return event
}
function createHarness(): {
function createHarness(options: { staleActivePane?: boolean } = {}): {
deps: KeyboardHandlersDeps
sendInput: ReturnType<typeof vi.fn>
setActivePane: ReturnType<typeof vi.fn>
startComposition: () => void
terminalInput: HTMLTextAreaElement
dispose: () => void
@@ -40,7 +41,8 @@ function createHarness(): {
const terminalInput = document.createElement('textarea')
terminalInput.className = 'xterm-helper-textarea'
terminalElement.append(terminalInput)
scope.append(terminalElement)
const staleTerminalElement = document.createElement('div')
scope.append(staleTerminalElement, terminalElement)
document.body.append(scope)
const sendInput = vi.fn(() => true)
@@ -49,7 +51,7 @@ function createHarness(): {
sendInput
} as unknown as PtyTransport
const pane = {
id: 1,
id: options.staleActivePane ? 2 : 1,
leafId: '00000000-0000-4000-8000-000000000001',
terminal: {
element: terminalElement,
@@ -57,9 +59,24 @@ function createHarness(): {
getSelection: vi.fn(() => '')
}
}
const stalePane = {
id: 1,
leafId: '00000000-0000-4000-8000-000000000002',
terminal: {
element: staleTerminalElement,
focus: vi.fn(),
getSelection: vi.fn(() => '')
}
}
let activePane = options.staleActivePane ? stalePane : pane
const panes = options.staleActivePane ? [stalePane, pane] : [pane]
const setActivePane = vi.fn((paneId: number) => {
activePane = panes.find((candidate) => candidate.id === paneId) ?? activePane
})
const manager = {
getActivePane: () => pane,
getPanes: () => [pane]
getActivePane: () => activePane,
getPanes: () => panes,
setActivePane
} as unknown as PaneManager
const route = installTerminalImeCompositionRoute({
terminalElement,
@@ -96,6 +113,7 @@ function createHarness(): {
return {
deps,
sendInput,
setActivePane,
terminalInput,
startComposition: () => {
terminalElement.dispatchEvent(
@@ -337,6 +355,29 @@ describe('Windows IME Enter-keyup press-time evidence', () => {
harness.dispose()
})
it('routes a swallowed Enter keydown fallback to the focused pane and repairs stale active state', () => {
const harness = createHarness({ staleActivePane: true })
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
harness.startComposition()
harness.terminalInput.dispatchEvent(
keyboardEvent('keyup', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
timeStamp: 30,
shiftKey: true
})
)
vi.runAllTimers()
expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false })
expect(harness.sendInput).toHaveBeenCalledOnce()
expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r')
hook.unmount()
harness.dispose()
})
it('does not send a second newline from the keyup of a directly-sent Shift+Enter', () => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { createTerminalKeyboardEventHandlers } from './terminal-keyboard-event-handlers'
describe('terminal keyboard pane ownership', () => {
it('submits Enter through the helper textarea pane when active state is stale', () => {
const scope = document.createElement('div')
const firstElement = document.createElement('div')
const focusedElement = document.createElement('div')
const focusedInput = document.createElement('textarea')
focusedInput.className = 'xterm-helper-textarea'
focusedElement.appendChild(focusedInput)
scope.append(firstElement, focusedElement)
document.body.appendChild(scope)
const first = { id: 1, leafId: 'leaf-1', terminal: { element: firstElement } }
const focused = { id: 2, leafId: 'leaf-2', terminal: { element: focusedElement } }
let active = first
const setActivePane = vi.fn((paneId: number) => {
active = paneId === focused.id ? focused : first
})
const sendFocused = vi.fn()
const handlers = createTerminalKeyboardEventHandlers({
isMac: false,
isWindows: false,
shortcutPlatform: 'linux',
keyboardScopeRef: { current: scope },
resolveShortcutEvent: () => ({ type: 'sendInput', data: '\r' }),
createCapturedInputSender: (pane) => (pane.id === focused.id ? sendFocused : vi.fn()),
nativeOnlyShortcutTracker: {
prepareKeyDown: vi.fn(),
armKeyDown: vi.fn()
},
observedEnterKeydownTimeStamps: new Map(),
modifiedEnterChordOwner: {
ownsRedispatchedEnter: () => false,
absorb: () => false,
claim: () => true
},
deferredNewlineSender: {
absorbRedispatchedEnter: () => false,
defer: vi.fn()
},
deferredChordSender: { defer: vi.fn() },
getModifiedEnterChord: () => null,
reconcileHeldImeEnterModifiers: vi.fn(),
optionKittyReleases: { arm: vi.fn(), armNativeDeadKey: vi.fn() },
terminalImeEnterModifierKeydowns: new Set(),
paneKittyKeyboardModesRef: { current: new Map() },
managerRef: {
current: {
getActivePane: () => active,
getPanes: () => [first, focused],
setActivePane
}
},
paneTransportsRef: { current: new Map() },
panePtyBindingsRef: { current: new Map() },
paneCwdRef: { current: new Map() },
tabId: 'tab-1',
worktreeId: 'worktree-1',
fallbackCwd: '',
expandedPaneIdRef: { current: null },
setExpandedPane: vi.fn(),
restoreExpandedLayout: vi.fn(),
refreshPaneSizes: vi.fn(),
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
onSetTitle: vi.fn(),
onClearPaneTitle: vi.fn(),
searchOpenRef: { current: false },
searchStateRef: { current: { query: '', caseSensitive: false, regex: false } },
keybindings: undefined,
terminalShortcutPolicy: 'orca-first',
getKeyboardSplitTelemetrySource: () => 'keyboard'
} as never)
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Enter'
})
Object.defineProperty(event, 'keyCode', { value: 13 })
focusedInput.dispatchEvent(event)
handlers.onKeyDown(event)
expect(setActivePane).toHaveBeenCalledWith(2, { focus: false })
expect(sendFocused).toHaveBeenCalledOnce()
})
})
@@ -18,6 +18,7 @@ import {
import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispatch'
import { getLayoutCharacterForCode } from '@/lib/keyboard-layout/layout-base-character'
import { createTerminalKeyboardReleaseHandlers } from './terminal-keyboard-release-handlers'
import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution'
const MAX_OBSERVED_ENTER_KEYDOWNS_PER_CODE = 8
@@ -111,6 +112,11 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
return
}
// The browser's focused xterm helper is the input owner. A split can retain
// a stale manager activePaneId after focus moved, so repair it before any
// shortcut policy reads pane-scoped host/agent state.
const keyboardPane = synchronizeTerminalKeyboardPane(manager, e.target)
const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null
if (
e.key === 'Enter' &&
@@ -127,7 +133,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
return
}
const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0]
const terminalPaneForImeShortcut = keyboardPane
const hasPendingImeComposition = hasPendingTerminalImeComposition(
terminalPaneForImeShortcut?.terminal.element
)
@@ -139,7 +145,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
}
if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) {
const pane = manager.getActivePane() ?? manager.getPanes()[0]
const pane = keyboardPane
const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection())
if (selectedText) {
e.preventDefault()
@@ -160,7 +166,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
}
e.preventDefault()
e.stopImmediatePropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
const pane = keyboardPane
if (!pane) {
return
}
@@ -210,7 +216,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) {
if (action.type === 'sendInput') {
e.preventDefault()
e.stopImmediatePropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
const pane = keyboardPane
if (!pane) {
return
}
@@ -0,0 +1,64 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import {
resolveTerminalKeyboardPane,
synchronizeTerminalKeyboardPane
} from './terminal-keyboard-pane-resolution'
function pane(id: number): {
id: number
leafId: string
terminal: { element: HTMLElement }
} {
return {
id,
leafId: `leaf-${id}`,
terminal: { element: document.createElement('div') }
}
}
describe('resolveTerminalKeyboardPane', () => {
it('uses the pane containing the event target when manager active state is stale', () => {
const active = pane(1)
const focused = pane(2)
const input = document.createElement('textarea')
focused.terminal.element.appendChild(input)
const manager = {
getActivePane: vi.fn(() => active),
getPanes: vi.fn(() => [active, focused])
}
expect(resolveTerminalKeyboardPane(manager as never, input)).toBe(focused)
expect(manager.getActivePane).not.toHaveBeenCalled()
})
it('falls back to the manager active pane for non-terminal shortcuts', () => {
const active = pane(1)
const manager = {
getActivePane: vi.fn(() => active),
getPanes: vi.fn(() => [active])
}
expect(resolveTerminalKeyboardPane(manager as never, document.body)).toBe(active)
})
it('repairs stale active state without stealing DOM focus', () => {
const active = pane(1)
const focused = pane(2)
const input = document.createElement('textarea')
focused.terminal.element.appendChild(input)
let activePane = active
const setActivePane = vi.fn((id: number) => {
activePane = id === focused.id ? focused : active
})
const manager = {
getActivePane: vi.fn(() => activePane),
getPanes: vi.fn(() => [active, focused]),
setActivePane
}
expect(synchronizeTerminalKeyboardPane(manager as never, input)).toBe(focused)
expect(setActivePane).toHaveBeenCalledWith(focused.id, { focus: false })
expect(activePane).toBe(focused)
})
})
@@ -0,0 +1,28 @@
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
/** Resolve keyboard ownership from the focused xterm surface before using manager state. */
export function resolveTerminalKeyboardPane(
manager: Pick<PaneManager, 'getActivePane' | 'getPanes'>,
target: EventTarget | null
): ManagedPane | null {
if (typeof Node !== 'undefined' && target instanceof Node) {
const focusedPane = manager
.getPanes()
.find((pane) => pane.terminal.element?.contains(target) === true)
if (focusedPane) {
return focusedPane
}
}
return manager.getActivePane() ?? manager.getPanes()[0] ?? null
}
export function synchronizeTerminalKeyboardPane(
manager: Pick<PaneManager, 'getActivePane' | 'getPanes' | 'setActivePane'>,
target: EventTarget | null
): ManagedPane | null {
const pane = resolveTerminalKeyboardPane(manager, target)
if (pane && manager.getActivePane()?.id !== pane.id) {
manager.setActivePane(pane.id, { focus: false })
}
return pane
}
@@ -4,6 +4,7 @@ import { isTerminalImeEnterKeyUp } from './terminal-ime-deferred-newline'
import { isEditableTarget } from './terminal-keyboard-shortcut-matching'
import { hasPendingTerminalImeComposition } from './terminal-ime-composition-route'
import { keyboardEventBelongsToScope } from './terminal-keyboard-scope'
import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution'
type Runtime = ReturnType<typeof createTerminalKeyboardRuntime>
type ReleaseContext = KeyboardHandlersDeps &
@@ -101,7 +102,7 @@ export function createTerminalKeyboardReleaseHandlers(context: ReleaseContextWit
!isEditableTarget(e.target) &&
(!scope || keyboardEventBelongsToScope(e, scope))
) {
const pane = manager.getActivePane() ?? manager.getPanes()[0]
const pane = synchronizeTerminalKeyboardPane(manager, e.target)
if (pane && hasPendingTerminalImeComposition(pane.terminal.element)) {
const action = resolveShortcutEvent({
key: 'Enter',
@@ -64,8 +64,7 @@ describe('direct SSH hydration fanout', () => {
api: {
ui: {},
ssh: {
listTargets: () =>
Promise.resolve(targets.map((id) => ({ id, label: id }))),
listTargets: () => Promise.resolve(targets.map((id) => ({ id, label: id }))),
listRemovedTargetLabels: () => Promise.resolve({}),
// A wedged relay answers nothing until the 30s RPC timeout fires.
getState: ({ targetId }: { targetId: string }) => {
@@ -0,0 +1,197 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES,
pasteDraftWhenAgentReady,
POST_PASTE_SUBMIT_DELAY_MS,
sendAgentDraftPasteContent
} from './agent-paste-draft'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
const testState = vi.hoisted(() => ({
appState: {
settings: {} as Record<string, unknown>,
ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record<string, string[]>,
runtimePaneTitlesByTabId: {},
tabsByWorktree: {} as Record<string, { id: string }[]>,
repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[],
worktreesByRepo: {} as Record<string, { id: string; repoId: string }[]>
},
ptyObserver: null as ((data: string) => void) | null,
unsubscribe: vi.fn(),
subscribeToPtyData: vi.fn(),
replayPreHandlerPtyData: vi.fn(),
isRemoteRuntimePtyId: vi.fn(),
sendRuntimePtyInputVerified: vi.fn(),
inspectRuntimeTerminalProcess: vi.fn(),
subscribeToRuntimeTerminalData: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => testState.appState,
subscribe: () => () => {}
}
}))
vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({
subscribeToPtyData: testState.subscribeToPtyData
}))
vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({
replayPreHandlerPtyData: testState.replayPreHandlerPtyData
}))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId,
sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified,
inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess
}))
vi.mock('@/runtime/runtime-terminal-stream', () => ({
subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData
}))
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything'
const RENDER_QUIET_MS = 1500
const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123'
const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~`
const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0
describe('post-paste submit retry Enter', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout
})
testState.appState.settings = {}
testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] }
testState.appState.tabsByWorktree = {}
testState.appState.repos = []
testState.appState.worktreesByRepo = {}
testState.ptyObserver = null
testState.unsubscribe.mockReset()
testState.subscribeToPtyData.mockReset()
testState.subscribeToPtyData.mockImplementation(
(_ptyId: string, observer: (data: string) => void) => {
testState.ptyObserver = observer
return testState.unsubscribe
}
)
testState.replayPreHandlerPtyData.mockReset()
testState.isRemoteRuntimePtyId.mockReset()
testState.isRemoteRuntimePtyId.mockReturnValue(false)
testState.sendRuntimePtyInputVerified.mockReset()
testState.sendRuntimePtyInputVerified.mockResolvedValue(true)
testState.inspectRuntimeTerminalProcess.mockReset()
testState.subscribeToRuntimeTerminalData.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('sends one retry Enter after the configured gap for agents that can eat the first Enter', async () => {
const promise = startCodexSubmit()
await signalCodexComposerReady()
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS)
expect(enterWrites()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS - 1)
expect(enterWrites()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
await expect(promise).resolves.toBe(true)
expect(enterWrites()).toHaveLength(2)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r')
expect(vi.getTimerCount()).toBe(0)
})
it('sends exactly one Enter for agents without a submit retry delay', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'gemini',
submit: true
})
await flushMicrotasks()
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await vi.advanceTimersByTimeAsync(RENDER_QUIET_MS)
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS)
await expect(promise).resolves.toBe(true)
expect(enterWrites()).toHaveLength(1)
expect(vi.getTimerCount()).toBe(0)
})
it('holds the PTY input transaction across the retry Enter', async () => {
const writes: string[] = []
testState.sendRuntimePtyInputVerified.mockImplementation(
async (_settings: unknown, _ptyId: string, data: string) => {
writes.push(data)
return true
}
)
const promise = startCodexSubmit()
await signalCodexComposerReady()
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS)
// Competing paste on the same PTY: it must not open a frame the retry can land in.
const competing = sendAgentDraftPasteContent(
{},
'pty-1',
'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1)
)
await flushMicrotasks(10)
expect(writes).toEqual([PASTED_ISSUE_URL, '\r'])
await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS)
await expect(promise).resolves.toBe(true)
await expect(competing).resolves.toBe(true)
expect(writes.slice(0, 3)).toEqual([PASTED_ISSUE_URL, '\r', '\r'])
expect(writes.at(3)).toBe('\x1b[200~')
})
it('keeps a successful submit successful when the retry Enter is rejected', async () => {
testState.sendRuntimePtyInputVerified
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('terminal_not_writable'))
const promise = startCodexSubmit()
await signalCodexComposerReady()
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS)
await expect(promise).resolves.toBe(true)
expect(enterWrites()).toHaveLength(2)
})
})
function enterWrites(): unknown[][] {
return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[2] === '\r')
}
function startCodexSubmit(): Promise<boolean> {
return pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex',
submit: true
})
}
async function signalCodexComposerReady(): Promise<void> {
await flushMicrotasks()
testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`)
await flushMicrotasks()
}
async function flushMicrotasks(iterations = 2): Promise<void> {
for (let index = 0; index < iterations; index += 1) {
await Promise.resolve()
}
}
@@ -13,6 +13,7 @@ import {
sendBracketedPasteToRunningAgent,
submitPromptToAgentPty
} from './agent-paste-draft'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
const testState = vi.hoisted(() => ({
appState: {
@@ -70,6 +71,7 @@ const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything
const CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}'
const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123'
const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~`
const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0
describe('pasteDraftWhenAgentReady', () => {
beforeEach(() => {
@@ -169,7 +171,7 @@ describe('pasteDraftWhenAgentReady', () => {
'pty-1',
PASTED_ISSUE_URL
)
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS)
await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r')
expect(testState.unsubscribe).toHaveBeenCalledTimes(1)
+23 -6
View File
@@ -129,7 +129,8 @@ export async function pasteDraftWhenAgentReady(args: {
settings,
ptyId,
content,
submit: submit === true
submit: submit === true,
agent
})
}
@@ -168,7 +169,8 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
settings,
ptyId,
content,
submit: submit === true
submit: submit === true,
agent
})
}
@@ -197,11 +199,13 @@ async function sendBracketedPasteToAgent(args: {
ptyId: string
content: string
submit: boolean
agent?: TuiAgent
}): Promise<boolean> {
const { settings = useAppStore.getState().settings, ptyId, content, submit } = args
const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args
const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined
try {
// Why: paste + Enter must be one transaction, or a concurrent paste on this PTY
// can slip between them and submit a half-written prompt.
// Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent
// paste on this PTY can slip between them and submit a half-written prompt.
return await runTerminalPtyInputTransaction(ptyId, async () => {
const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content)
if (!pasted || !submit) {
@@ -212,7 +216,20 @@ async function sendBracketedPasteToAgent(args: {
// Enter arrive in the same PTY write. Split the submit into the next turn so
// the TUI processes bracketed-paste termination before handling Enter.
await new Promise<void>((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS))
return await sendRuntimePtyInputVerified(settings, ptyId, '\r')
const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r')
if (submitRetryDelayMs !== undefined) {
// Why: agents that render their composer before Enter is live silently eat
// the first Enter; the retry is best-effort and never downgrades `submitted`.
await new Promise<void>((resolve) => window.setTimeout(resolve, submitRetryDelayMs))
try {
await sendRuntimePtyInputVerified(settings, ptyId, '\r')
} catch {
// Why: a rejected retry leaves the first Enter's verdict untouched.
}
}
return submitted
})
} catch {
return false
@@ -19,6 +19,7 @@ type RuntimeCall = (args: {
method: string
params: unknown
timeoutMs: number
expectedEnvironmentPairingRevision?: number
}) => Promise<RuntimeRpcResponse<unknown>>
type ValidTerminalListResult = RuntimeTerminalListResult & {
@@ -52,7 +53,8 @@ function isTerminalListResult(value: unknown): value is ValidTerminalListResult
async function probeHost(
environmentId: string,
call: RuntimeCall
call: RuntimeCall,
expectedEnvironmentPairingRevision?: number
): Promise<HostLiveTerminalProbeVerdict> {
const response = await call({
selector: environmentId,
@@ -66,7 +68,8 @@ async function probeHost(
requireFreshPtyLiveness: true,
includeVisualLayouts: false
},
timeoutMs: 15_000
timeoutMs: 15_000,
expectedEnvironmentPairingRevision
})
if (response.ok === false || !isTerminalListResult(response.result)) {
return 'unverifiable'
@@ -86,14 +89,15 @@ async function probeHost(
export function probeHostLiveTerminals(
environmentId: string,
call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args),
connectionGeneration = 0
connectionGeneration = 0,
expectedEnvironmentPairingRevision?: number
): Promise<HostLiveTerminalProbeVerdict> {
const key = `${environmentId}\0${connectionGeneration}`
const key = `${environmentId}\0${connectionGeneration}\0${expectedEnvironmentPairingRevision ?? 'unknown'}`
const existing = inFlightProbeByEnvironment.get(key)
if (existing) {
return existing
}
const probe = probeHost(environmentId, call)
const probe = probeHost(environmentId, call, expectedEnvironmentPairingRevision)
.catch((): HostLiveTerminalProbeVerdict => 'unverifiable')
.finally(() => {
if (inFlightProbeByEnvironment.get(key) === probe) {
@@ -25,7 +25,14 @@ vi.mock('../store', () => ({
}
}))
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
getRuntimeEnvironmentRevision,
replaceRuntimeEnvironmentRevisions
} from './runtime-environment-revision'
import {
hasHostSessionMirrorHydrated,
parkUntilHostSessionMirrorHydrates,
@@ -35,11 +42,19 @@ import {
clearHostLiveTerminalProbesForTests,
probeHostLiveTerminals
} from './host-live-terminal-probe'
import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync'
import {
applyWebSessionTabsStorePatch,
clearWebSessionTabsTrackingForEnvironment,
type HostSessionMirrorSettle
} from './web-session-tabs-sync'
const WORKTREE = 'repo1::/path/wt1'
type HostCall = { method: string; params: Record<string, unknown> }
type HostCall = {
method: string
params: Record<string, unknown>
expectedEnvironmentPairingRevision?: number
}
/** `null` models a failed probe: `unverifiable`, never `the host has none`. */
function stubPairedHost(
@@ -71,11 +86,31 @@ function stubPairedHost(
}
/** The exact verdict both `listAll` sites build when the host publishes nothing. */
function settleEmptyInventory(environmentId: string): void {
applyWebSessionTabsStorePatch(() => ({}), {
function settleEmptyInventory(
environmentId: string,
options: {
authoritative?: boolean
expectedEnvironmentConnectionGeneration?: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration?: number
} = {}
): void {
createEmptyInventorySettle(environmentId, options)()
}
function createEmptyInventorySettle(
environmentId: string,
options: {
authoritative?: boolean
expectedEnvironmentConnectionGeneration?: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration?: number
} = {}
): HostSessionMirrorSettle {
return applyWebSessionTabsStorePatch(() => ({}), {
frames: [],
fullInventory: { environmentId, publishedSnapshotCount: 0 }
})()
fullInventory: { environmentId, publishedSnapshotCount: 0, ...options }
})
}
async function flushProbe(): Promise<void> {
@@ -87,6 +122,7 @@ describe('empty host inventory settling the session mirror', () => {
resetHostSessionMirrorHydrationForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
clearHostLiveTerminalProbesForTests()
replaceRuntimeEnvironmentRevisions([])
})
afterEach(() => {
@@ -127,6 +163,94 @@ describe('empty host inventory settling the session mirror', () => {
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true)
})
it('settles an authoritative empty inventory despite an unrelated live terminal', async () => {
const environmentId = 'env-authoritative-empty'
const calls = stubPairedHost(['unrelated-terminal'])
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
settleEmptyInventory(environmentId, { authoritative: true })
expect(resumeSweeps).toBe(1)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true)
expect(calls).toEqual([])
})
it('rejects an authoritative receipt after the same id is re-paired', () => {
const environmentId = 'env-authoritative-repaired'
replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }])
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
const settle = createEmptyInventorySettle(environmentId, {
authoritative: true,
expectedEnvironmentPairingRevision: 11
})
replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }])
settle()
expect(resumeSweeps).toBe(0)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
it('rejects an authoritative receipt from the previous connection generation', () => {
const environmentId = 'env-authoritative-reconnected'
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1)
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
const settle = createEmptyInventorySettle(environmentId, {
authoritative: true,
expectedEnvironmentConnectionGeneration: 1
})
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2)
settle()
expect(resumeSweeps).toBe(0)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
it('rejects stale inventory even when recovery completes after reconnect', () => {
const environmentId = 'env-reconnected-before-receipt'
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2)
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
createEmptyInventorySettle(environmentId, {
authoritative: true,
expectedEnvironmentConnectionGeneration: 1
})()
expect(resumeSweeps).toBe(0)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
it('rejects an authoritative receipt after mirror tracking is cleared', () => {
const environmentId = 'env-authoritative-cleared'
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
const settle = createEmptyInventorySettle(environmentId, {
authoritative: true,
expectedTrackingGeneration: 0
})
clearWebSessionTabsTrackingForEnvironment(environmentId)
settle()
expect(resumeSweeps).toBe(0)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
it('leaves waiters parked when the readiness probe fails', async () => {
const environmentId = 'env-probe-failed'
stubPairedHost(null)
@@ -157,7 +281,7 @@ describe('empty host inventory settling the session mirror', () => {
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
it('accepts a legacy zero-terminal result without host scope', async () => {
it('treats a legacy zero-terminal result without host scope as none', async () => {
const result = await probeHostLiveTerminals(
'env-legacy-zero',
vi.fn(async () => ({
@@ -189,6 +313,28 @@ describe('empty host inventory settling the session mirror', () => {
expect(result).toBe('live')
})
// Why: pre-hostScope hosts cannot prove emptiness; parking them forever would
// strand auto-resume on every truly-empty legacy host, so their best-effort
// empty keeps settling as it did before authoritative inventories existed.
it('settles a scope-less legacy empty inventory', async () => {
const environmentId = 'env-legacy-scope-less'
const call = vi.fn(async () => ({
ok: true,
result: { terminals: [], totalCount: 0, truncated: false }
}))
vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } })
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
settleEmptyInventory(environmentId)
await flushProbe()
expect(resumeSweeps).toBe(1)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true)
})
it('rejects a present malformed host scope as unverifiable', async () => {
const result = await probeHostLiveTerminals(
'env-malformed-scope',
@@ -273,4 +419,126 @@ describe('empty host inventory settling the session mirror', () => {
await expect(firstProbe).resolves.toBe('live')
await expect(secondProbe).resolves.toBe('none')
})
it('does not let a probe response cross a same-id pairing revision', async () => {
const environmentId = 'env-repaired'
let resolveProbe!: (response: RuntimeRpcResponse<unknown>) => void
const call = vi.fn(
() =>
new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
resolveProbe = resolve
})
)
vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } })
replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }])
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 })
expect(call).toHaveBeenCalledWith(
expect.objectContaining({ expectedEnvironmentPairingRevision: 11 })
)
replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }])
expect(getRuntimeEnvironmentRevision(environmentId)).toBe(12)
resolveProbe({
id: 'probe-repaired',
ok: true,
result: {
terminals: [],
totalCount: 0,
truncated: false,
hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] }
},
_meta: { runtimeId: 'runtime' }
})
await flushProbe()
expect(resumeSweeps).toBe(0)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
it('does not reuse an in-flight probe across same-id pairing revisions', async () => {
const environmentId = 'env-repaired-inflight'
const responses: ((response: RuntimeRpcResponse<unknown>) => void)[] = []
const call = vi.fn(
() =>
new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
responses.push(resolve)
})
)
vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } })
replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }])
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 })
replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }])
settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 12 })
expect(call).toHaveBeenCalledTimes(2)
responses[0]!({
id: 'probe-old-owner',
ok: true,
result: {
terminals: [],
totalCount: 0,
truncated: false,
hostScope: { hostIds: ['runtime:old'], omittedHostIds: [] }
},
_meta: { runtimeId: 'runtime-old' }
})
responses[1]!({
id: 'probe-new-owner',
ok: true,
result: {
terminals: [{ handle: 'terminal-new-owner' }],
totalCount: 1,
truncated: false,
hostScope: { hostIds: ['runtime:new'], omittedHostIds: [] }
},
_meta: { runtimeId: 'runtime-new' }
})
await flushProbe()
expect(resumeSweeps).toBe(0)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
it('invalidates a pending legacy probe when environment tracking is cleared', async () => {
const environmentId = 'env-cleared'
let resolveProbe!: (response: RuntimeRpcResponse<unknown>) => void
const call = vi.fn(
() =>
new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
resolveProbe = resolve
})
)
vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } })
let resumeSweeps = 0
parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => {
resumeSweeps += 1
})
settleEmptyInventory(environmentId)
clearWebSessionTabsTrackingForEnvironment(environmentId)
resolveProbe({
id: 'probe-cleared',
ok: true,
result: {
terminals: [],
totalCount: 0,
truncated: false,
hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] }
},
_meta: { runtimeId: 'runtime' }
})
await flushProbe()
expect(resumeSweeps).toBe(0)
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false)
})
})
@@ -4,6 +4,10 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-envi
import { makePaneKey } from '../../../shared/stable-pane-id'
import { resetStaleDocumentVisibilityForTesting } from '@/components/terminal-pane/stale-document-visibility'
import { useAppStore, type AppState } from '@/store'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
BG_MIRROR_TAB_ID,
BG_WT,
@@ -189,6 +193,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } =
}
resetWebSessionTabsSnapshotFreshnessForTests()
resetHostSessionMirrorHydrationForTests()
setRuntimeEnvironmentConnectionGenerationForTests(ENV, 1)
seedState()
})
@@ -198,6 +203,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } =
replaceRuntimeEnvironmentRevisions([])
resetWebSessionTabsSnapshotFreshnessForTests()
resetHostSessionMirrorHydrationForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
if (options.fakeTimers) {
resetStaleDocumentVisibilityForTesting()
setDocumentVisibility('visible')
@@ -68,9 +68,11 @@ import {
tabIds
} from './host-session-mirror-frame-ordering-harness'
import {
clearWebSessionTabsTrackingForEnvironment,
useWebSessionTabsSync,
WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS
} from './web-session-tabs-sync'
import { hasHostSessionMirrorHydrated } from './host-session-mirror-hydration'
import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking'
/**
@@ -212,6 +214,22 @@ describe('mirrored-pane resume deferral against real stream frames', () => {
expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID)
expectReplayedResume(paneKey, WT, 'codex-session-scoped-frame')
})
it('does not let a scoped frame from before tracking reset release its pane', async () => {
renderHook(() => useWebSessionTabsSync())
await act(settle)
const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-scoped-old-tracking')
expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0)
clearWebSessionTabsTrackingForEnvironment(ENV)
await publish(findSubscription('session.tabs.subscribe'), {
type: 'snapshot',
...makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID)
})
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false)
})
})
describe('mirror latch verdicts against real stream failures', () => {
@@ -104,11 +104,11 @@ describe('host-session-mirror settle census', () => {
'runtime/host-session-mirror-hydration.ts': { hydrated: 1, worktreeHydrated: 1 },
// Exactly the receipt constructors: the patch receipt (environment-wide
// and per-worktree) and the stale-frame receipt. The environment-wide
// mark has two audited arms, both inside the receipt: an inventory that
// published snapshots settles on the spot, and one that published none
// mark has three audited arms, all inside the receipt: an authoritative
// empty inventory, a non-empty inventory, and an empty inventory that
// settles only after `terminal.list` says the host has no live PTY —
// `0 === 0` is not host evidence (STA-5377).
'runtime/web-session-tabs-sync.ts': { hydrated: 2, worktreeHydrated: 2 }
'runtime/web-session-tabs-sync.ts': { hydrated: 3, worktreeHydrated: 2 }
})
})
@@ -70,6 +70,7 @@ import {
} from './host-session-mirror-hydration'
import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session'
import {
clearWebSessionTabsTrackingForEnvironment,
useWebSessionTabsSync,
WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS
} from './web-session-tabs-sync'
@@ -278,6 +279,36 @@ describe('the eager post-create list answers for its worktree', () => {
expect(useAppStore.getState().tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBeUndefined()
expect(hasHostSessionMirrorHydrated(ENV, FLOATING_TERMINAL_WORKTREE_ID)).toBe(false)
})
it('does not let a pre-reset eager list settle in the new tracking epoch', async () => {
let resolveList!: (response: unknown) => void
runtimeCall.mockImplementation((request: { method: string }) =>
request.method === 'session.tabs.list'
? new Promise((resolve) => {
resolveList = resolve
})
: new Promise(() => {})
)
const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-eager-old-tracking')
expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0)
const refresh = refreshWebRuntimeSessionTabsSnapshot(ENV, WT)
await act(settle)
clearWebSessionTabsTrackingForEnvironment(ENV)
resolveList({
id: 'list',
ok: true as const,
result: makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID),
_meta: { runtimeId: 'runtime-a' }
})
await act(async () => {
await refresh
await settle()
})
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false)
})
})
/**
@@ -24,6 +24,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
applyWebSessionTabsStorePatch: vi.fn(() => () => {}),
resolveHostSessionTabIdForWebSessionTab: vi.fn()
}))
@@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -48,6 +49,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
return () => {}
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -37,6 +38,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -36,6 +37,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -42,6 +43,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
deliverLaunchPromptToAgentTab: vi.fn(),
@@ -46,6 +47,7 @@ vi.mock('./web-session-tabs-sync', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot,
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
@@ -72,6 +72,7 @@ import {
import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch'
import { translate } from '../i18n/i18n'
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import {
@@ -1074,8 +1075,13 @@ export async function refreshWebRuntimeSessionTabsSnapshot(
errorMode?: 'warn' | 'throw'
} = {}
): Promise<void> {
const webSessionTabsSync = await import('./web-session-tabs-sync')
const expectedEnvironmentPairingRevision =
options.expectedEnvironmentPairingRevision ?? getRuntimeEnvironmentRevision(environmentId)
const expectedEnvironmentConnectionGeneration =
getRuntimeEnvironmentConnectionGeneration(environmentId)
const expectedTrackingGeneration =
webSessionTabsSync.getWebSessionTabsTrackingGeneration(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(
environmentId,
expectedEnvironmentPairingRevision
@@ -1124,7 +1130,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot(
applyWebSessionTabsSnapshot,
applyWebSessionTabsStorePatch,
decideWebSessionTabsSnapshot
} = await import('./web-session-tabs-sync')
} = webSessionTabsSync
if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) {
return
}
@@ -1140,7 +1146,18 @@ export async function refreshWebRuntimeSessionTabsSnapshot(
: state
return patch === state ? state : patch
},
{ frames: [{ environmentId, worktreeId: snapshot.worktree, decision }] },
{
frames: [
{
environmentId,
worktreeId: snapshot.worktree,
decision,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
}
]
},
snapshot
)
settleMirror()
@@ -49,6 +49,7 @@ import {
useWebSessionTabsSync,
WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS
} from './web-session-tabs-sync'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT,
WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS
@@ -59,7 +60,7 @@ const ENV_B = 'env-b'
const WORKTREE = 'repo-a::worktree-a'
const REVISION_A = 101
const REVISION_B = 201
const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00011\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00012\u0001${REVISION_B}`
const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00010\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00010\u0001${REVISION_B}`
const initialState = useAppStore.getInitialState()
type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe
@@ -238,6 +239,7 @@ describe('useWebSessionTabsSync window visibility', () => {
useAppStore.setState(initialState, true)
replaceRuntimeEnvironmentRevisions([])
resetWebSessionTabsSnapshotFreshnessForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
resetStaleDocumentVisibilityForTesting()
setDocumentVisibility('visible')
vi.useRealTimers()
@@ -627,7 +629,7 @@ describe('useWebSessionTabsSync window visibility', () => {
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
})
mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue(
MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013')
MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013')
)
hook.rerender()
await act(settle)
@@ -795,7 +797,7 @@ describe('useWebSessionTabsSync window visibility', () => {
expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined()
mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue(
MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013')
MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013')
)
hook.rerender()
await act(settle)
+216 -29
View File
@@ -137,11 +137,16 @@ export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100
type SessionTabsStreamEvent =
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
| {
type: 'snapshots'
snapshots: RuntimeMobileSessionTabsResult[]
authoritative?: boolean
}
| { type: 'end' }
type SessionTabsListAllResult = {
snapshots: RuntimeMobileSessionTabsResult[]
authoritative?: boolean
}
type SnapshotFreshness = {
@@ -188,6 +193,7 @@ const latestSessionTabsRemovalFenceByWorktree = new Map<string, SessionTabsRemov
const sessionTabsRecoveryStateByWorktree = new Map<string, SessionTabsRecoveryState>()
const trackedSessionTabsWorktreeIdsByEnvironment = new Map<string, Set<string>>()
const sessionTabsEnvironmentsByWorktree = new Map<string, Set<string>>()
const sessionTabsTrackingGenerationByEnvironment = new Map<string, number>()
const lastHostTerminalTabCountByWorktree = new Map<string, number>()
const hostSessionTabIdByLocalKey = new Map<string, string>()
const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map<
@@ -823,6 +829,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
return
}
const keyPrefix = `${trimmedEnvironmentId}:`
sessionTabsTrackingGenerationByEnvironment.set(
trimmedEnvironmentId,
(sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1
)
for (const key of latestSessionTabsSnapshotByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
latestSessionTabsSnapshotByWorktree.delete(key)
@@ -874,6 +884,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
clearAllWebRuntimeWakeTerminalRespawn()
}
export function getWebSessionTabsTrackingGeneration(environmentId: string): number {
return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0
}
function hostSessionTabMappingKey(args: {
environmentId: string
worktreeId: string
@@ -4118,6 +4132,9 @@ export type HostSessionMirrorPatchFrame = {
environmentId: string
worktreeId: string
decision: WebSessionTabsSnapshotDecision
expectedEnvironmentConnectionGeneration?: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration?: number
}
/**
@@ -4132,7 +4149,14 @@ export type HostSessionMirrorPatchFrame = {
*/
export type HostSessionMirrorPatchVerdict = {
frames: readonly HostSessionMirrorPatchFrame[]
fullInventory?: { environmentId: string; publishedSnapshotCount: number }
fullInventory?: {
environmentId: string
publishedSnapshotCount: number
authoritative?: boolean
expectedEnvironmentConnectionGeneration?: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration?: number
}
}
/**
@@ -4147,16 +4171,52 @@ export type HostSessionMirrorPatchVerdict = {
* `none` settles; `live` and `unverifiable` leave waiters for the next
* inventory or per-worktree frame.
*/
function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string): void {
const probedGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId)
void probeHostLiveTerminals(environmentId, undefined, probedGeneration).then((verdict) => {
type HostSessionMirrorSettleFence = {
environmentId: string
connectionGeneration: number
pairingRevision?: number
trackingGeneration: number
}
function captureHostSessionMirrorSettleFence(
environmentId: string,
expected: {
connectionGeneration?: number
pairingRevision?: number
trackingGeneration?: number
} = {}
): HostSessionMirrorSettleFence {
return {
environmentId,
connectionGeneration:
expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId),
pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId),
trackingGeneration:
expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId)
}
}
function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean {
return (
getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration &&
getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision &&
getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration
)
}
function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(
fence: HostSessionMirrorSettleFence
): void {
void probeHostLiveTerminals(
fence.environmentId,
undefined,
fence.connectionGeneration,
fence.pairingRevision
).then((verdict) => {
// Why: the probe is a round trip, and a reconnect in between would make its
// answer speak for a connection whose PTYs nobody listed.
if (
verdict === 'none' &&
getRuntimeEnvironmentConnectionGeneration(environmentId) === probedGeneration
) {
markHostSessionMirrorHydrated(environmentId)
if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) {
markHostSessionMirrorHydrated(fence.environmentId)
}
})
}
@@ -4164,19 +4224,51 @@ function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string)
function createHostSessionMirrorSettle(
verdict: HostSessionMirrorPatchVerdict
): HostSessionMirrorSettle {
const fenceByEnvironment = new Map<string, HostSessionMirrorSettleFence>()
for (const frame of verdict.frames) {
fenceByEnvironment.set(
frame.environmentId,
captureHostSessionMirrorSettleFence(frame.environmentId, {
connectionGeneration: frame.expectedEnvironmentConnectionGeneration,
pairingRevision: frame.expectedEnvironmentPairingRevision,
trackingGeneration: frame.expectedTrackingGeneration
})
)
}
if (verdict.fullInventory) {
fenceByEnvironment.set(
verdict.fullInventory.environmentId,
captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, {
connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration,
pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision,
trackingGeneration: verdict.fullInventory.expectedTrackingGeneration
})
)
}
return () => {
const { frames, fullInventory } = verdict
const settles = frames.filter(({ decision }) => decision.settlesHostMirror)
if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) {
const fence = fenceByEnvironment.get(fullInventory.environmentId)
if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) {
return
}
if (fullInventory.publishedSnapshotCount === 0) {
settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fullInventory.environmentId)
if (fullInventory.authoritative) {
markHostSessionMirrorHydrated(fullInventory.environmentId)
} else {
settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence)
}
return
}
markHostSessionMirrorHydrated(fullInventory.environmentId)
return
}
for (const { environmentId, worktreeId } of settles) {
markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId)
const fence = fenceByEnvironment.get(environmentId)
if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) {
markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId)
}
}
}
}
@@ -4190,11 +4282,22 @@ function createHostSessionMirrorSettle(
function hostSessionMirrorSettleForPatchlessFrame(
decision: WebSessionTabsSnapshotDecision,
environmentId: string,
worktreeId: string
worktreeId: string,
expected: {
connectionGeneration?: number
pairingRevision?: number
trackingGeneration?: number
} = {}
): HostSessionMirrorSettle | null {
return decision.settlesHostMirror
? () => markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId)
: null
if (!decision.settlesHostMirror) {
return null
}
const fence = captureHostSessionMirrorSettleFence(environmentId, expected)
return () => {
if (hostSessionMirrorSettleFenceIsCurrent(fence)) {
markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId)
}
}
}
export function applyWebSessionTabsStorePatch(
@@ -4359,7 +4462,9 @@ export function applyWebSessionTabsStorePatch(
function loadInitialWebSessionTabs(
environmentId: string,
expectedEnvironmentConnectionGeneration: number,
expectedEnvironmentPairingRevision: number | undefined,
expectedTrackingGeneration: number,
isCurrent: () => boolean
): void {
// Why: only a conclusion that reached the store may settle the mirror, so
@@ -4436,10 +4541,17 @@ function loadInitialWebSessionTabs(
frames: applicable.map((snapshot, position) => ({
environmentId,
worktreeId: snapshot.worktree,
decision: decisions[position]!
decision: decisions[position]!,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
})),
fullInventory: {
environmentId,
authoritative: result.authoritative === true,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
// Why: a workspace the mirror never writes is not part of the
// inventory the environment-wide verdict has to account for.
publishedSnapshotCount: result.snapshots.filter((snapshot) =>
@@ -4548,9 +4660,11 @@ export function useWebSessionTabsSync(): void {
? runtimeSessionMirrorEnvironmentKey
.split('\u0000')
.map((entry) => {
const [environmentId = '', , , rawRevision = ''] = entry.split('\u0001')
const [environmentId = '', , rawConnectionGeneration = '0', rawRevision = ''] =
entry.split('\u0001')
return {
environmentId,
expectedEnvironmentConnectionGeneration: Number(rawConnectionGeneration),
expectedEnvironmentPairingRevision:
rawRevision === '' ? undefined : Number(rawRevision)
}
@@ -4595,6 +4709,9 @@ export function useWebSessionTabsSync(): void {
inventoryReceived: boolean
latestInventoryReceivedFrame: number
pendingMissingWorktrees: Set<string>
expectedEnvironmentConnectionGeneration: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration: number
}
type VisibilityResumeMissing = {
environmentId: string
@@ -4772,7 +4889,13 @@ export function useWebSessionTabsSync(): void {
frames: decided.map(({ environmentId, snapshot, decision }) => ({
environmentId,
worktreeId: snapshot.worktree,
decision
decision,
expectedEnvironmentConnectionGeneration:
batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision:
batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision,
expectedTrackingGeneration:
batch.environments.get(environmentId)?.expectedTrackingGeneration
}))
},
operations.map(({ snapshot }) => snapshot)
@@ -4942,7 +5065,15 @@ export function useWebSessionTabsSync(): void {
trackedWorktrees,
inventoryReceived: false,
latestInventoryReceivedFrame: 0,
pendingMissingWorktrees: new Set()
pendingMissingWorktrees: new Set(),
expectedEnvironmentConnectionGeneration:
environments.find((environment) => environment.environmentId === environmentId)
?.expectedEnvironmentConnectionGeneration ??
getRuntimeEnvironmentConnectionGeneration(environmentId),
expectedEnvironmentPairingRevision: environments.find(
(environment) => environment.environmentId === environmentId
)?.expectedEnvironmentPairingRevision,
expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId)
})
}
}
@@ -4961,7 +5092,11 @@ export function useWebSessionTabsSync(): void {
}
// Why: the stream's initial snapshot can land after first render, so a one-shot fetch makes initial parity deterministic.
for (const { environmentId, expectedEnvironmentPairingRevision } of environments) {
for (const {
environmentId,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision
} of environments) {
if (
!shouldSyncAllRuntimeSessionTabs({
activeRuntimeEnvironmentId: environmentId,
@@ -4971,6 +5106,7 @@ export function useWebSessionTabsSync(): void {
continue
}
let requestedInitialLoad = false
const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId)
environmentIdBySubscriptionSpec.push(environmentId)
subscriptionSpecs.push({
subscribe: (isCurrent, { visibilityGeneration }) => {
@@ -4978,7 +5114,13 @@ export function useWebSessionTabsSync(): void {
let awaitingVisibilityResumeInventory = isVisibilityRestart
if (!requestedInitialLoad) {
requestedInitialLoad = true
loadInitialWebSessionTabs(environmentId, expectedEnvironmentPairingRevision, isCurrent)
loadInitialWebSessionTabs(
environmentId,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
isCurrent
)
}
return window.api.runtimeEnvironments.subscribe(
{
@@ -5099,10 +5241,17 @@ export function useWebSessionTabsSync(): void {
frames: applicable.map(({ snapshot }, position) => ({
environmentId,
worktreeId: snapshot.worktree,
decision: decisions[position]!
decision: decisions[position]!,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
})),
fullInventory: {
environmentId,
authoritative: event.authoritative === true,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
// Why: a workspace the mirror never writes is not
// part of the inventory this verdict accounts for.
publishedSnapshotCount: event.snapshots.filter((snapshot) =>
@@ -5191,7 +5340,16 @@ export function useWebSessionTabsSync(): void {
settleHydration = applyWebSessionTabsStorePatch(
(state) => applyWebSessionTabsSnapshot(state, recovered, environmentId),
{
frames: [{ environmentId, worktreeId: recovered.worktree, decision }]
frames: [
{
environmentId,
worktreeId: recovered.worktree,
decision,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
}
]
},
recovered,
event.type === 'updated' && !replayed
@@ -5201,7 +5359,12 @@ export function useWebSessionTabsSync(): void {
settleHydration = hostSessionMirrorSettleForPatchlessFrame(
decision,
environmentId,
recovered.worktree
recovered.worktree,
{
connectionGeneration: expectedEnvironmentConnectionGeneration,
pairingRevision: expectedEnvironmentPairingRevision,
trackingGeneration: expectedTrackingGeneration
}
)
}
}
@@ -5279,6 +5442,7 @@ export function useWebSessionTabsSync(): void {
) {
return
}
const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId)
let requestedInitialTerminal = false
let requestedRespawnAfterWake = false
@@ -5288,7 +5452,8 @@ export function useWebSessionTabsSync(): void {
event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' },
response: RuntimeRpcResponse<unknown>,
isCurrent: () => boolean,
receivedFrame: number
receivedFrame: number,
expectedTrackingGeneration: number
): Promise<HostSessionMirrorSettle | null> => {
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
@@ -5335,13 +5500,29 @@ export function useWebSessionTabsSync(): void {
// Why: a rejected frame settles only on the accepted view that outranked it.
let settleMirror: HostSessionMirrorSettle | null = fresh
? null
: hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree)
: hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, {
connectionGeneration: activeWorktreeRuntimeConnectionGeneration,
pairingRevision: expectedEnvironmentPairingRevision,
trackingGeneration: expectedTrackingGeneration
})
try {
if (fresh) {
const replayed = isRuntimeSubscriptionReplayResponse(response)
settleMirror = applyWebSessionTabsStorePatch(
(state) => applyWebSessionTabsSnapshot(state, recovered, environmentId),
{ frames: [{ environmentId, worktreeId: recovered.worktree, decision }] },
{
frames: [
{
environmentId,
worktreeId: recovered.worktree,
decision,
expectedEnvironmentConnectionGeneration:
activeWorktreeRuntimeConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
}
]
},
recovered,
event.type === 'updated' && !replayed
)
@@ -5420,7 +5601,13 @@ export function useWebSessionTabsSync(): void {
event.worktree,
receivedFrame
)
void applyActiveSnapshot(event, response, isCurrent, receivedFrame)
void applyActiveSnapshot(
event,
response,
isCurrent,
receivedFrame,
expectedTrackingGeneration
)
.catch((error) => {
if (isCurrent()) {
console.warn(

Some files were not shown because too many files have changed in this diff Show More