From a27527ae06d92a67c3e8364acfa15ebd524a8ebb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:35:24 -0700 Subject: [PATCH 001/168] fix(orchestration): deliver worker-exit escalations to lightweight Run coordinators (STA-4604) (#15235) --- src/main/runtime/orca-runtime.ts | 87 +++- ...tweight-run-worker-exit-escalation.test.ts | 479 ++++++++++++++++++ 2 files changed, 557 insertions(+), 9 deletions(-) create mode 100644 src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9bf31e6b086..51baa53efd0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -128,6 +128,7 @@ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { OrchestrationDb } from './orchestration/db' +import type { DispatchStatus } from './orchestration/types' import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation' import { classifyWorkerTerminalProcessIncarnation, @@ -16828,28 +16829,96 @@ export class OrcaRuntimeService { } const errorContext = `Agent exited with code ${exitCode}` - this._orchestrationDb.failDispatch(dispatch.id, errorContext, { workerProcessExited: true }) + const settled = this._orchestrationDb.failDispatch(dispatch.id, errorContext, { + workerProcessExited: true + }) - // Why: create an escalation message so the coordinator is notified about - // the unexpected exit on its next check cycle, even if the circuit breaker - // hasn't tripped yet. - const run = this._orchestrationDb.getActiveCoordinatorRun() - if (run) { - this._orchestrationDb.insertMessage({ + // Why: failDispatch above is the authoritative state transition and has already + // committed. Everything below is best-effort mail on top of it — resolving the + // recipient reads the database too — and onPtyExit runs this synchronously per leaf, + // so letting any of it escape would abandon the remaining leaves and the pty record + // pruning that close out this exit. + try { + // Why: create an escalation message so the coordinator is notified about + // the unexpected exit, even if the circuit breaker hasn't tripped yet. + const recipient = this.resolveExitEscalationRecipient(dispatch.run_id) + if (!recipient) { + return + } + const escalation = this._orchestrationDb.insertMessage({ from: handle, - to: run.coordinator_handle, + to: recipient.to, subject: `Agent exited unexpectedly (code ${exitCode})`, + body: this.describeWorkerExit(dispatch, exitCode, handle, settled?.status), type: 'escalation', priority: 'high', + // Why: applyEscalationToDispatch rejects an escalation without an exact Dispatch + // binding, and a coordinator reading this needs to know which Dispatch died. payload: JSON.stringify({ taskId: dispatch.task_id, + dispatchId: dispatch.id, exitCode, handle - }) + }), + ...(recipient.runId ? { runId: recipient.runId } : {}) + }) + // Why: worker death is the one escalation nobody will poll for — the dead pane + // can't nudge the coordinator, so wake its check --wait the way every other + // message producer does. + this.notifyMessageArrived(escalation.to_handle, escalation.type) + } catch (error) { + // Why: log the Run rather than the recipient — resolution itself can be what failed. + console.warn('[orchestration] failed to escalate worker exit', { + dispatchId: dispatch.id, + runId: dispatch.run_id, + error }) } } + // Why: the banner shows the subject and a raw payload, so without prose the coordinator + // has to resolve ids by hand to learn what died and whether the task is still retryable. + private describeWorkerExit( + dispatch: { id: string; task_id: string; run_id: string }, + exitCode: number, + handle: string, + settledStatus: DispatchStatus | undefined + ): string { + const task = this._orchestrationDb?.getTask?.(dispatch.task_id, dispatch.run_id) + const title = + typeof task?.spec === 'string' + ? buildOrchestrationTaskDisplayMetadata({ + spec: task.spec, + taskTitle: task.task_title, + displayName: task.display_name + }).taskTitle + : '' + const named = title ? `"${title}" (${dispatch.task_id})` : dispatch.task_id + const outcome = + settledStatus === 'circuit_broken' + ? ' This task has now failed too many times, so it will not be retried automatically.' + : settledStatus === 'failed' + ? ' The task is ready to be dispatched again.' + : '' + return `Worker ${handle} exited with code ${exitCode} while running task ${named}.${outcome}` + } + + // Why: a lightweight Run keeps its coordinator in runs/run_coordinator_handles and + // never writes the legacy coordinator_runs table, so gating solely on that table + // dropped every worker-death escalation (STA-4604). Address the Run mailbox the + // coordinator's `orchestration check` actually reads, and leave legacy Runs on the + // legacy gate. + private resolveExitEscalationRecipient( + runId: string + ): { to: string; runId?: string } | undefined { + const owningRun = this._orchestrationDb?.getRun?.(runId) + if (owningRun && owningRun.legacy !== 1) { + return { to: `run:${owningRun.id}`, runId: owningRun.id } + } + const legacyRun = this._orchestrationDb?.getActiveCoordinatorRun?.() + return legacyRun ? { to: legacyRun.coordinator_handle } : undefined + } + async listTerminals( worktreeSelector?: string, limit = DEFAULT_TERMINAL_LIST_LIMIT, diff --git a/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts b/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts new file mode 100644 index 00000000000..496bcbc6b3b --- /dev/null +++ b/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import { LEGACY_RUN_ID, OrchestrationDb } from './db' +import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './db/dispatch-context/dispatch-circuit-breaker' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +// STA-4604: failActiveDispatchOnExit fails the dispatch on worker PTY exit but used to +// gate the "Agent exited unexpectedly" escalation on the legacy coordinator_runs table. +// A lightweight Run (runs + run_coordinator_handles) never populates that table, so +// worker death was silent for its coordinator. The escalation now follows the dispatch's +// own Run to the mailbox `orchestration check` actually reads. + +const WORKTREE_ID = 'repo-1::/tmp/sta-4604-worktree' +const WORKER_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const COORDINATOR_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const WORKER_PTY_ID = 'pty-worker' +const COORDINATOR_PTY_ID = 'pty-coordinator' +const WORKER_PANE_KEY = makePaneKey('tab-worker', WORKER_LEAF_ID) +const COORDINATOR_PANE_KEY = makePaneKey('tab-coordinator', COORDINATOR_LEAF_ID) + +function makeStore() { + const session: WorkspaceSessionState = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/sta-4604-worktree', + displayName: 'sta-4604', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +function makeRuntimeWithTwoPanes(): { + runtime: OrcaRuntimeService + workerHandle: string + coordinatorHandle: string +} { + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: vi.fn(() => true), + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: vi.fn(async () => []) + } as never) + const workerHandle = runtime.preAllocateHandleForPty(WORKER_PTY_ID) + const coordinatorHandle = runtime.preAllocateHandleForPty(COORDINATOR_PTY_ID) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: WORKTREE_ID, + title: 'Worker', + activeLeafId: WORKER_LEAF_ID, + layout: null + }, + { + tabId: 'tab-coordinator', + worktreeId: WORKTREE_ID, + title: 'Coordinator', + activeLeafId: COORDINATOR_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: WORKTREE_ID, + leafId: WORKER_LEAF_ID, + paneRuntimeId: 1, + ptyId: WORKER_PTY_ID, + paneTitle: null + }, + { + tabId: 'tab-coordinator', + worktreeId: WORKTREE_ID, + leafId: COORDINATOR_LEAF_ID, + paneRuntimeId: 2, + ptyId: COORDINATOR_PTY_ID, + paneTitle: null + } + ] + }) + return { runtime, workerHandle, coordinatorHandle } +} + +// Pointer delivery runs on a microtask; let it settle before the DB closes. +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +// The single oracle every mode is graded by: after a worker PTY exit, is the dispatch +// failed, and did the escalation reach a mailbox this coordinator actually reads? +async function gradeWorkerExit( + mode: 'lightweight-run' | 'legacy-run' | 'legacy-run-no-coordinator' +): Promise<{ + dispatchStatus: string | undefined + escalationsToCoordinatorHandle: number + escalationsInRunMailbox: number + totalUnreadForCoordinator: number + escalation: Record | null + workerHandle: string + runId: string + taskId: string +}> { + const { runtime, workerHandle, coordinatorHandle } = makeRuntimeWithTwoPanes() + const db = new OrchestrationDb(':memory:') + try { + const lightweight = mode === 'lightweight-run' + const runId = lightweight + ? db.createRun({ + objective: 'sta-4604 lightweight run', + coordinatorHandle, + coordinatorPaneKey: COORDINATOR_PANE_KEY + }).id + : LEGACY_RUN_ID + if (mode === 'legacy-run') { + db.createCoordinatorRun({ spec: 'legacy coordinator loop', coordinatorHandle }) + } + const task = db.createTask({ spec: 'do the work', runId }) + const dispatch = db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + runtime.setOrchestrationDb(db as never) + + runtime.onPtyExit(WORKER_PTY_ID, 137) + await settle() + + const direct = db.getUnreadMessages(coordinatorHandle, ['escalation']) + const runMailbox = db.getUnreadRunMailbox(runId, 100, ['escalation']) + const escalation = runMailbox[0] ?? direct[0] + return { + dispatchStatus: db.getDispatchContextById(dispatch.id)?.status, + escalationsToCoordinatorHandle: direct.length, + escalationsInRunMailbox: runMailbox.length, + // The ticket's literal claim: the coordinator receives no message of any kind. + totalUnreadForCoordinator: + db.getUnreadMessages(coordinatorHandle).length + db.getUnreadRunMailbox(runId, 100).length, + // Counts alone stay green while the escalation's content rots. + escalation: escalation + ? { + from: escalation.from_handle, + to: escalation.to_handle, + runId: escalation.run_id, + type: escalation.type, + priority: escalation.priority, + subject: escalation.subject, + body: escalation.body, + deliveryContract: escalation.delivery_contract, + payload: JSON.parse(escalation.payload ?? 'null') + } + : null, + workerHandle, + runId, + taskId: task.id + } + } finally { + db.close() + } +} + +describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { + it('delivers the escalation to a lightweight Run mailbox', async () => { + const graded = await gradeWorkerExit('lightweight-run') + expect(graded).toMatchObject({ + dispatchStatus: 'failed', + // Addressed run: — the only address getOrCreateRunDelivery / getUnreadRunMailbox read. + escalationsToCoordinatorHandle: 0, + escalationsInRunMailbox: 1, + totalUnreadForCoordinator: 1 + }) + expect(graded.escalation).toEqual({ + from: graded.workerHandle, + to: `run:${graded.runId}`, + runId: graded.runId, + type: 'escalation', + priority: 'high', + subject: 'Agent exited unexpectedly (code 137)', + body: + `Worker ${graded.workerHandle} exited with code 137 while running task ` + + `"do the work" (${graded.taskId}). The task is ready to be dispatched again.`, + deliveryContract: 'current_delivery', + payload: { + taskId: expect.any(String), + dispatchId: expect.any(String), + exitCode: 137, + handle: graded.workerHandle + } + }) + }) + + it('keeps legacy coordinator_runs delivery on the coordinator handle', async () => { + const graded = await gradeWorkerExit('legacy-run') + expect(graded).toMatchObject({ + dispatchStatus: 'failed', + escalationsToCoordinatorHandle: 1, + escalationsInRunMailbox: 0, + totalUnreadForCoordinator: 1 + }) + expect(graded.escalation).toMatchObject({ + to: expect.stringMatching(/^term_/), + runId: LEGACY_RUN_ID, + type: 'escalation', + subject: 'Agent exited unexpectedly (code 137)' + }) + }) + + it('stays silent for a legacy dispatch with no active coordinator', async () => { + await expect(gradeWorkerExit('legacy-run-no-coordinator')).resolves.toMatchObject({ + dispatchStatus: 'failed', + escalationsToCoordinatorHandle: 0, + escalationsInRunMailbox: 0, + totalUnreadForCoordinator: 0, + escalation: null + }) + }) + + it('wakes a coordinator already blocked in check --wait', async () => { + const { runtime, workerHandle, coordinatorHandle } = makeRuntimeWithTwoPanes() + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'sta-4604 blocking coordinator', + coordinatorHandle, + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + const task = db.createTask({ spec: 'do the work', runId: run.id }) + db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + runtime.setOrchestrationDb(db as never) + + const waiting = runtime.waitForMessage(`run:${run.id}`, { + typeFilter: ['escalation'], + timeoutMs: 2_000 + }) + runtime.onPtyExit(WORKER_PTY_ID, 137) + + await expect(waiting).resolves.toBe('notified') + // Why: waitForMessage resolves off the notification alone, so without this the test + // would pass on a notify with no message behind it. + expect(db.getUnreadRunMailbox(run.id, 100, ['escalation'])).toEqual([ + expect.objectContaining({ + run_id: run.id, + to_handle: `run:${run.id}`, + from_handle: workerHandle, + type: 'escalation' + }) + ]) + await settle() + } finally { + db.close() + } + }) + + it('escalates only into the dying worker own Run when several Runs are live', async () => { + const { runtime, workerHandle, coordinatorHandle } = makeRuntimeWithTwoPanes() + const db = new OrchestrationDb(':memory:') + try { + // A second, more recently created Run is the trap: any "latest active run" lookup picks it. + const ownRun = db.createRun({ + objective: 'run that owns the dying worker', + coordinatorHandle, + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + const otherRun = db.createRun({ + objective: 'unrelated newer run', + coordinatorHandle: 'term_other_coordinator', + coordinatorPaneKey: makePaneKey('tab-other', '33333333-3333-4333-8333-333333333333') + }) + const task = db.createTask({ spec: 'owned work', runId: ownRun.id }) + db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + runtime.setOrchestrationDb(db as never) + + runtime.onPtyExit(WORKER_PTY_ID, 137) + await settle() + + expect({ + own: db.getUnreadRunMailbox(ownRun.id, 100, ['escalation']).length, + other: db.getUnreadRunMailbox(otherRun.id, 100, ['escalation']).length + }).toEqual({ own: 1, other: 0 }) + } finally { + db.close() + } + }) + + it('escalates for a supervised worker and settles its worker_dispatches row', async () => { + const { runtime, workerHandle, coordinatorHandle } = makeRuntimeWithTwoPanes() + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'supervised worker run', + coordinatorHandle, + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + const task = db.createTask({ spec: 'supervised work', runId: run.id }) + const started = db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: workerHandle, + paneKey: WORKER_PANE_KEY, + processIncarnation: 'inc-1', + worktreeId: WORKTREE_ID, + effects: [], + setupState: 'skipped' + }) + db.markWorkerDispatchReady(started.dispatch.id) + runtime.setOrchestrationDb(db as never) + + runtime.onPtyExit(WORKER_PTY_ID, 137) + await settle() + + expect({ + dispatch: db.getDispatchContextById(started.dispatch.id)?.status, + workerState: db.getWorkerDispatch(started.dispatch.id)?.state, + workerStage: db.getWorkerDispatch(started.dispatch.id)?.stage, + escalations: db.getUnreadRunMailbox(run.id, 100, ['escalation']).length + }).toEqual({ + dispatch: 'failed', + workerState: 'failed', + workerStage: 'process_exited', + escalations: 1 + }) + } finally { + db.close() + } + }) + + it('still escalates on the failure that breaks the circuit', async () => { + const { runtime, workerHandle, coordinatorHandle } = makeRuntimeWithTwoPanes() + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'circuit breaker run', + coordinatorHandle, + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + const task = db.createTask({ spec: 'repeatedly failing work', runId: run.id }) + // Burn the breaker down to its last life so this exit is the one that trips it. + for (let attempt = 1; attempt < DISPATCH_CIRCUIT_BREAK_FAILURES; attempt += 1) { + const previous = db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + db.failDispatch(previous.id, `attempt ${attempt}`, { workerProcessExited: true }) + } + const dispatch = db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + runtime.setOrchestrationDb(db as never) + + runtime.onPtyExit(WORKER_PTY_ID, 137) + await settle() + + const escalations = db.getUnreadRunMailbox(run.id, 100, ['escalation']) + expect({ + dispatch: db.getDispatchContextById(dispatch.id)?.status, + escalations: escalations.length + }).toEqual({ dispatch: 'circuit_broken', escalations: 1 }) + // Why: a tripped breaker means nobody will retry it, so the body must not + // tell the coordinator the task is ready to go again. + expect(escalations.at(-1)?.body).toBe( + `Worker ${workerHandle} exited with code 137 while running task ` + + `"repeatedly failing work" (${task.id}). This task has now failed too many ` + + `times, so it will not be retried automatically.` + ) + } finally { + db.close() + } + }) + + it('falls back to the legacy gate when the dispatch owning Run row is gone', async () => { + const { runtime, workerHandle, coordinatorHandle } = makeRuntimeWithTwoPanes() + const insertMessage = vi.fn((message: { to: string }) => ({ + ...message, + to_handle: message.to, + type: 'escalation' + })) + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: (handle: string) => + handle === workerHandle + ? { id: 'ctx-orphan', run_id: 'run-that-no-longer-exists', task_id: 'task-orphan' } + : undefined, + failDispatch: vi.fn(), + getRun: () => undefined, + getActiveCoordinatorRun: () => ({ id: 'run-legacy', coordinator_handle: coordinatorHandle }), + insertMessage + } as never) + + runtime.onPtyExit(WORKER_PTY_ID, 137) + + expect(insertMessage).toHaveBeenCalledWith( + expect.objectContaining({ to: coordinatorHandle, type: 'escalation' }) + ) + // An orphaned dispatch has no Run mailbox to address, so it must not invent one. + expect(insertMessage.mock.calls[0]?.[0]).not.toHaveProperty('runId') + }) + + it('still reaches the Run mailbox when the Run has no bound coordinator', async () => { + const { runtime, workerHandle, coordinatorHandle } = makeRuntimeWithTwoPanes() + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'run whose coordinator later unbinds', + coordinatorHandle, + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + const task = db.createTask({ spec: 'work outliving its coordinator', runId: run.id }) + db.createDispatchContext(task.id, workerHandle, WORKER_PANE_KEY) + // Rebinding the pane to a newer Run clears the old Run's coordinator_handle. + db.createRun({ + objective: 'newer run on the same coordinator pane', + coordinatorHandle, + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + expect(db.getRun(run.id)?.coordinator_handle).toBeNull() + runtime.setOrchestrationDb(db as never) + + runtime.onPtyExit(WORKER_PTY_ID, 137) + await settle() + + // Why: with no coordinator handle to address, only the durable run: mailbox + // keeps the escalation reachable for whoever rebinds next. + expect(db.getUnreadRunMailbox(run.id, 100, ['escalation'])).toEqual([ + expect.objectContaining({ to_handle: `run:${run.id}`, run_id: run.id }) + ]) + } finally { + db.close() + } + }) + + // Resolving the recipient reads the DB too, so it needs the same protection as the insert. + it.each([ + [ + 'the recipient lookup', + { + getRun: () => { + throw new Error('database is closed') + } + } + ], + [ + 'the mailbox insert', + { + getRun: () => ({ id: 'run-1', legacy: 0, coordinator_handle: 'term_coord' }), + insertMessage: () => { + throw new Error('Run not found: run-1') + } + } + ] + ])('still finishes PTY exit when %s fails', (_case, dbOverrides) => { + const { runtime, workerHandle } = makeRuntimeWithTwoPanes() + const failDispatch = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: (handle: string) => + handle === workerHandle ? { id: 'ctx-1', run_id: 'run-1', task_id: 'task-1' } : undefined, + failDispatch, + getActiveCoordinatorRun: () => undefined, + insertMessage: () => ({ to_handle: 'run:run-1', type: 'escalation' }), + ...dbOverrides + } as never) + + // Why: onPtyExit walks leaves synchronously — a throwing mailbox would abandon the + // rest of the exit, so the worker death must stay durable and the exit must complete. + expect(() => runtime.onPtyExit(WORKER_PTY_ID, 137)).not.toThrow() + expect(failDispatch).toHaveBeenCalledWith('ctx-1', 'Agent exited with code 137', { + workerProcessExited: true + }) + expect(warn).toHaveBeenCalledWith( + '[orchestration] failed to escalate worker exit', + expect.objectContaining({ dispatchId: 'ctx-1', runId: 'run-1' }) + ) + warn.mockRestore() + }) +}) From fe95698b95e7687857d2421549366b8771c71e36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:37:39 -0700 Subject: [PATCH 002/168] fix(relay): let one owner hold pty.ackData instead of relying on construction order (#15079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PtyHandler registered a no-op pty.ackData handler and SshPtyConsumerSessionAdapter registered the real sourceCredit.acknowledge for the same method. onNotification is a single slot, so only the adapter's survived — and only because relay.ts constructs it second. Reversing those two lines would have made every credit-mode delivery wedge permanently once the 256KB window emptied, with no error and no log. Delete the dead no-op, and make onNotification throw on a duplicate registration the way registerPtyDataPublicationAdmission already does for the admission slot. pty.ackData was the only double-registered method in the tree, so nothing else changes behavior. The existing test asserted only that pty.ackData appeared in the handler map, which stays true when the real handler is shadowed. It now asserts PtyHandler does not own the method. --- .../dispatcher-notification-ownership.test.ts | 18 ++++++++++++++++++ src/relay/dispatcher.ts | 7 +++++++ src/relay/pty-handler-spawn-admission.test.ts | 5 ++++- src/relay/pty-handler.ts | 3 --- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/relay/dispatcher-notification-ownership.test.ts diff --git a/src/relay/dispatcher-notification-ownership.test.ts b/src/relay/dispatcher-notification-ownership.test.ts new file mode 100644 index 00000000000..e94fdcede62 --- /dev/null +++ b/src/relay/dispatcher-notification-ownership.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayDispatcher } from './dispatcher' + +describe('RelayDispatcher notification ownership', () => { + it('refuses a second handler for a method instead of silently shadowing the first', () => { + const dispatcher = new RelayDispatcher(() => {}) + const owner = vi.fn() + + dispatcher.onNotification('pty.ackData', owner) + + // Why: the slot holds one handler, so a second registration used to win purely by + // construction order — that is how a no-op ack handler nearly disabled credit acks. + expect(() => dispatcher.onNotification('pty.ackData', vi.fn())).toThrow(/already registered/) + + // Why: the constructor arms a keepalive interval that would outlive the test. + dispatcher.dispose() + }) +}) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 1898e9fbf4e..00e1836d9d7 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -212,7 +212,14 @@ export class RelayDispatcher { this.requestHandlers.set(method, handler) } + // Why it throws: this is a single slot, so a second registration silently shadows the + // first and which one survives depends only on construction order. `pty.ackData` shipped + // that way — a no-op handler was saved from disabling credit acks purely by the adapter + // being constructed second (STA-4571). Fail loudly instead of encoding that ordering. onNotification(method: string, handler: NotificationHandler): void { + if (this.notificationHandlers.has(method)) { + throw new Error(`Notification handler for ${method} is already registered`) + } this.notificationHandlers.set(method, handler) } diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index c5938d91f57..aae35a2e166 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -79,7 +79,10 @@ describe('PtyHandler', () => { const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) expect(notifMethods).toContain('pty.data') expect(notifMethods).toContain('pty.resize') - expect(notifMethods).toContain('pty.ackData') + // Why not `toContain('pty.ackData')`: PtyHandler must NOT own that method. It used to + // register a no-op here, which survived only because the consumer session adapter was + // constructed later and overwrote it (STA-4571). + expect(notifMethods).not.toContain('pty.ackData') }) it('rejects strict process inspection for a missing relay PTY', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index c7dfb0a533d..3f82038261d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -903,9 +903,6 @@ export class PtyHandler { this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) - this.dispatcher.onNotification('pty.ackData', (_p) => { - /* flow control ack -- not yet enforced */ - }) } private isLikelyInteractiveRedraw(data: string): boolean { From a3da91b10a226c295d0c1aa585d96d5acef4ed1e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:08:04 -0700 Subject: [PATCH 003/168] fix(dev): stop caching unsigned bundles and reclaim stale dev copies (#15247) --- config/scripts/dev-electron-bundle-cache.mjs | 49 +++++++ .../scripts/dev-electron-bundle-cache.test.ts | 137 ++++++++++++++++++ config/scripts/run-electron-vite-dev.mjs | 111 ++++++++++++-- 3 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 config/scripts/dev-electron-bundle-cache.mjs create mode 100644 config/scripts/dev-electron-bundle-cache.test.ts diff --git a/config/scripts/dev-electron-bundle-cache.mjs b/config/scripts/dev-electron-bundle-cache.mjs new file mode 100644 index 00000000000..8732661c874 --- /dev/null +++ b/config/scripts/dev-electron-bundle-cache.mjs @@ -0,0 +1,49 @@ +// Why this module exists: `out/electron-dev` accumulates one ~270MB copy of Electron.app per +// (branch title x Electron version x bundle layout). The runner only ever clears the directory it is +// about to rebuild, so siblings from renamed branches and past upgrades are never reclaimed -- +// measured at 143 directories / 38GB across one developer's worktrees. + +// Measured from BUILD START, not from last activity: a directory's mtime only changes when a +// top-level entry is created, so it freezes once `.app` appears and the 276MB copy, helper +// compiles and signing that follow never refresh it. So this is a hard cliff -- a build still +// running after this long becomes eligible for deletion by a concurrent instance. Full builds +// measured at 130-200s, so the margin is 5-10x. +export const IN_PROGRESS_WINDOW_MS = 15 * 60 * 1000 + +/** + * Which cached bundle directories are safe to reclaim. + * + * Three things are protected, and every one of them is a directory some other process still needs: + * + * - the bundle this run is about to use; + * - a bundle a live dev instance is running from. Deleting it can crash that instance mid-session, + * and developers routinely run several at once; + * - a bundle still being copied. Between `mkdirSync` and the marker write it exists, has no marker, + * and cannot appear in the process table because its instance has not launched yet -- so a + * concurrently starting instance would otherwise delete it mid-copy. + * + * `processTable` is raw `ps` output, searched for each candidate rather than parsed into paths. + * Parsing was tried twice and failed twice in the same dangerous direction -- a regex that missed a + * live process yielded "nothing is running" and deleted its bundle. Searching for a known absolute + * directory is immune to spaces and shell-significant characters in the path, and the trailing + * slash keeps `2` from being mistaken for ``. + */ +export function isDevBundleInUse(dir, processTable) { + // Both spellings: macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the + // process was launched with. A mismatch would read as "not running" and delete a live bundle. + // Checking both can only ever protect more, which is the safe direction. + const alternate = dir.startsWith('/private/') ? dir.slice('/private'.length) : `/private${dir}` + return processTable.includes(`${dir}/`) || processTable.includes(`${alternate}/`) +} + +export function selectStaleDevBundleDirs({ bundles, currentDir, processTable, nowMs }) { + return bundles + .filter(({ dir, hasMarker, mtimeMs }) => { + if (dir === currentDir || isDevBundleInUse(dir, processTable)) { + return false + } + const buildInFlight = !hasMarker && nowMs - mtimeMs < IN_PROGRESS_WINDOW_MS + return !buildInFlight + }) + .map(({ dir }) => dir) +} diff --git a/config/scripts/dev-electron-bundle-cache.test.ts b/config/scripts/dev-electron-bundle-cache.test.ts new file mode 100644 index 00000000000..ca67a9e1946 --- /dev/null +++ b/config/scripts/dev-electron-bundle-cache.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + IN_PROGRESS_WINDOW_MS, + isDevBundleInUse, + selectStaleDevBundleDirs +} from './dev-electron-bundle-cache.mjs' + +const NOW = 1_800_000_000_000 +const ROOT = '/repo/out/electron-dev' +const A = `${ROOT}/aaaaaaaaaaaa` +const B = `${ROOT}/bbbbbbbbbbbb` +const C = `${ROOT}/cccccccccccc` + +/** A finished bundle: marker present, copied long ago. */ +function settled(dir: string) { + return { dir, hasMarker: true, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS * 2 } +} + +/** A `ps -Awwo command=` line for a dev instance running out of `dir`. */ +function psLine(dir: string, appName = 'Orca: some-branch') { + return `${dir}/${appName}.app/Contents/MacOS/Electron --remote-debugging-port=9333` +} + +describe('dev-electron-bundle-cache', () => { + it('reclaims siblings while keeping the current bundle', () => { + expect( + selectStaleDevBundleDirs({ + bundles: [settled(A), settled(B), settled(C)], + currentDir: B, + processTable: '', + nowMs: NOW + }) + ).toEqual([A, C]) + }) + + it('never reclaims a bundle a live process is running from', () => { + // Deleting a bundle out from under a live Electron process can crash it mid-session, and + // developers routinely run several dev instances at once. + expect( + selectStaleDevBundleDirs({ + bundles: [settled(A), settled(B), settled(C)], + currentDir: B, + processTable: psLine(A), + nowMs: NOW + }) + ).toEqual([C]) + }) + + it('protects a live bundle whose path contains spaces', () => { + // Regression: an extraction regex using \S* could not cross a space, so any developer with a + // space in their checkout path got "nothing is live" and had the running bundle deleted. + const spaced = '/Users/me/My Projects/orca/out/electron-dev/aaaaaaaaaaaa' + const other = '/Users/me/My Projects/orca/out/electron-dev/bbbbbbbbbbbb' + expect( + selectStaleDevBundleDirs({ + bundles: [settled(spaced), settled(other)], + currentDir: C, + processTable: psLine(spaced, 'Orca: my branch'), + nowMs: NOW + }) + ).toEqual([other]) + }) + + it('does not treat a sibling as live just because it shares a prefix', () => { + // Boundary: `2` being live must not protect ``. Without the trailing slash in the + // needle, `A` would be found inside `A2`'s path and wrongly spared. + const a2 = `${A}2` + expect( + selectStaleDevBundleDirs({ + bundles: [settled(A), settled(a2)], + currentDir: C, + processTable: psLine(a2), + nowMs: NOW + }) + ).toEqual([A]) + }) + + it('never deletes a bundle that is still being copied', () => { + // Between mkdirSync and the marker write there is a ~270MB copy taking tens of seconds, during + // which the directory exists, has no marker, and cannot appear in `ps` because its instance has + // not launched yet. A concurrently starting instance would otherwise delete it mid-copy. + const inFlight = { dir: A, hasMarker: false, mtimeMs: NOW - 30_000 } + expect( + selectStaleDevBundleDirs({ + bundles: [inFlight, settled(B)], + currentDir: C, + processTable: '', + nowMs: NOW + }) + ).toEqual([B]) + }) + + it('reclaims a marker-less bundle once it is too old to be in flight', () => { + // A build that crashed partway leaves the same signature; only age separates it from the case + // above, otherwise abandoned debris would be protected forever. + const abandoned = { dir: A, hasMarker: false, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS - 1 } + expect( + selectStaleDevBundleDirs({ + bundles: [abandoned], + currentDir: C, + processTable: '', + nowMs: NOW + }) + ).toEqual([A]) + }) + + it('matches a live bundle across the /tmp and /private/tmp spellings', () => { + // macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the process was + // launched with. A mismatch would read as "not running" and delete a live bundle. + const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa' + const plain = '/tmp/wt/out/electron-dev/aaaaaaaaaaaa' + expect(isDevBundleInUse(priv, psLine(plain))).toBe(true) + expect(isDevBundleInUse(plain, psLine(priv))).toBe(true) + expect(isDevBundleInUse(priv, psLine('/private/tmp/wt/out/electron-dev/bbbbbbbbbbbb'))).toBe( + false + ) + }) + + it('still honours the path boundary across spellings', () => { + const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa' + expect(isDevBundleInUse(priv, psLine('/tmp/wt/out/electron-dev/aaaaaaaaaaaa2'))).toBe(false) + }) + + it('reclaims nothing when every directory is current or live', () => { + expect( + selectStaleDevBundleDirs({ + bundles: [settled(A), settled(B)], + currentDir: A, + processTable: psLine(B), + nowMs: NOW + }) + ).toEqual([]) + expect( + selectStaleDevBundleDirs({ bundles: [], currentDir: A, processTable: '', nowMs: NOW }) + ).toEqual([]) + }) +}) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index abd56701237..40bb7454793 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -17,6 +17,7 @@ import net from 'node:net' import { createRequire } from 'node:module' import path from 'node:path' import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs' +import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs' import { DEV_BUNDLE_ID, getDevBundlePlistPatches, @@ -115,6 +116,60 @@ function sanitizeMacAppBundleName(value) { ) } +function getDevBundleProcessTable() { + // Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs, + // which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column + // from being truncated. The raw text is searched directly; see dev-electron-bundle-cache.mjs + // for why it is deliberately not parsed into paths. + try { + return execFileSync('/bin/ps', ['-Awwo', 'command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000 + }) + } catch { + // Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning. + return null + } +} + +function pruneStaleDevBundles(distDir) { + const root = path.dirname(distDir) + let bundles + try { + bundles = readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const dir = path.join(root, entry.name) + return { + dir, + hasMarker: existsSync(path.join(dir, 'orca-dev-electron-app.json')), + mtimeMs: getMtimeMs(dir) + } + }) + } catch { + return + } + if (bundles.length <= 1) { + return + } + const processTable = getDevBundleProcessTable() + if (processTable === null) { + return + } + const stale = selectStaleDevBundleDirs({ + bundles, + currentDir: distDir, + processTable, + nowMs: Date.now() + }) + for (const dir of stale) { + try { + rmSync(dir, { recursive: true, force: true }) + } catch {} + } +} + function prepareMacDevElectronApp() { if (process.platform !== 'darwin') { return @@ -177,15 +232,19 @@ function prepareMacDevElectronApp() { 2 ) const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Electron') + // Split by consequence: without this Chromium blank-crashes, so it gates whether the bundle can + // run at all. The keyboard-layout helper below is optional -- swiftc builds it non-fatally, so on + // a Mac without full Xcode it is simply absent and only a keyboard feature degrades. + const chromiumResourcePath = path.join( + appPath, + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Resources', + 'icudtl.dat' + ) const requiredResourcePaths = [ - path.join( - appPath, - 'Contents', - 'Frameworks', - 'Electron Framework.framework', - 'Resources', - 'icudtl.dat' - ), + chromiumResourcePath, path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout') ] @@ -209,6 +268,30 @@ function prepareMacDevElectronApp() { } if (copiedAppIsUsable()) { + pruneStaleDevBundles(distDir) + process.env.ELECTRON_EXEC_PATH = executablePath + return + } + + // Why this guard: a rebuild replaces this exact directory, and making the marker conditional on a + // successful sign means an unsigned bundle is a permanent cache miss -- so every later run would + // reach this rmSync while another instance is still running from it, deleting its app mid-session. + // Reusing what is there matches what the runner did before the marker became conditional. + const rebuildProcessTable = getDevBundleProcessTable() + if ( + rebuildProcessTable !== null && + isDevBundleInUse(distDir, rebuildProcessTable) && + // Why a runnability check: a bundle missing Chromium's resources blank-crashes, and its + // orphaned crashpad helper still matches the process table -- so without this the runner would + // reuse a broken bundle forever and never rebuild itself out. Deliberately narrower than + // copiedAppIsUsable, which also demands the optional keyboard-layout helper: gating on that + // would strand every Mac without swiftc back on the rmSync path this guard exists to avoid. + existsSync(executablePath) && + existsSync(chromiumResourcePath) + ) { + console.warn( + `[orca-dev] Another dev instance is running from this bundle; reusing it instead of rebuilding. Quit the other instance (or delete ${distDir}) to force a rebuild.` + ) process.env.ELECTRON_EXEC_PATH = executablePath return } @@ -290,14 +373,24 @@ function prepareMacDevElectronApp() { // An ad-hoc re-sign restores delivery, the permission prompt, and the // notification-settings deep link for dev builds. Non-fatal: a signing // failure should not block `pnpm dev`. + let signed = true try { execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath]) } catch (error) { + signed = false console.warn( `[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}` ) } - writeFileSync(markerPath, expectedMarker, 'utf8') + // Why only when signed: the marker is what marks this bundle reusable. Writing it after a failed + // sign cached an unsigned bundle permanently -- the warning above scrolled past once and every + // later launch silently reused it, losing notification delivery and the stable cdhash that keeps + // safeStorage from re-prompting. Leaving it unwritten costs a re-copy per launch until signing + // works, and still does not block `pnpm dev`. + if (signed) { + writeFileSync(markerPath, expectedMarker, 'utf8') + } + pruneStaleDevBundles(distDir) process.env.ELECTRON_EXEC_PATH = executablePath } From bef76953d79d72214788ffdf7c328d2bf7169f6e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:17:42 -0700 Subject: [PATCH 004/168] fix(orchestration): record why a terminal's process is gone (STA-4603, STA-4536) (#15244) --- src/main/daemon/daemon-pty-adapter.test.ts | 9 +- src/main/daemon/daemon-pty-adapter.ts | 5 +- src/main/daemon/daemon-pty-router-events.ts | 3 + src/main/daemon/daemon-server.ts | 7 +- src/main/daemon/daemon-stream-events.ts | 5 +- src/main/daemon/pty-subprocess.ts | 42 ++- src/main/daemon/session-output-plane.ts | 10 +- src/main/daemon/session-subprocess-handle.ts | 4 +- src/main/daemon/session.test.ts | 5 +- src/main/daemon/session.ts | 7 +- .../daemon/terminal-host-create-contract.ts | 3 +- .../pty-daemon-ssh-lease-lifecycle.test.ts | 4 +- .../pty-listener-teardown-and-orphans.test.ts | 4 +- .../ipc/pty-runtime-kill-and-exit.test.ts | 4 +- src/main/ipc/pty.ts | 22 +- .../local-pty-provider-io-events.test.ts | 26 +- .../local-pty-provider-shutdown.test.ts | 5 +- src/main/providers/local-pty-provider.ts | 30 +- src/main/providers/local-pty-utils.test.ts | 3 +- src/main/providers/local-pty-utils.ts | 17 +- src/main/providers/macos-tcc-login-shell.ts | 14 + src/main/providers/pty-provider-contract.ts | 9 +- .../runtime/exit-provenance-audit.test.ts | 305 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 154 +++++++-- .../orchestration/db/contract-constants.ts | 2 +- .../dispatch-context/dispatch-completion.ts | 11 +- .../db/dispatch-context/dispatch-lookup.ts | 7 +- .../db/schema/create-graph-tables-sql.ts | 2 + ...{migrate-v13-v28.ts => migrate-v13-v29.ts} | 8 +- .../orchestration/db/schema/migrate.ts | 4 +- ...ederation-acknowledgment-migration.test.ts | 3 +- ...tweight-run-worker-exit-escalation.test.ts | 22 +- .../mutation-receipt-capacity.test.ts | 3 +- ...hestration-db-retention-pagination.test.ts | 9 +- .../run-coordinator-handle-migration.test.ts | 9 +- src/main/runtime/orchestration/types.ts | 4 + src/shared/runtime-types.ts | 7 + src/shared/terminal-exit-cause.test.ts | 99 ++++++ src/shared/terminal-exit-cause.ts | 103 ++++++ 39 files changed, 897 insertions(+), 93 deletions(-) create mode 100644 src/main/runtime/exit-provenance-audit.test.ts rename src/main/runtime/orchestration/db/schema/{migrate-v13-v28.ts => migrate-v13-v29.ts} (94%) create mode 100644 src/shared/terminal-exit-cause.test.ts create mode 100644 src/shared/terminal-exit-cause.ts diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index f95f35153cc..ef5fa687586 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -698,14 +698,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('onExit', () => { it('routes exit events from daemon', async () => { - const exits: { id: string; code: number }[] = [] + const exits: { id: string; code: number; cause?: unknown }[] = [] adapter.onExit((payload) => exits.push(payload)) const { id } = await adapter.spawn({ cols: 80, rows: 24 }) lastSubprocess._simulateExit(42) await waitFor(() => exits.length > 0) - expect(exits[0]).toEqual({ id, code: 42, incarnationId: expect.any(String) }) + expect(exits[0]).toEqual({ + id, + code: 42, + incarnationId: expect.any(String), + cause: { kind: 'exited', exitCode: 42 } + }) }) }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c0e7e5eef1c..56bad83e39e 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -94,6 +94,7 @@ import { import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' type PendingDaemonSpawnOperation = { exitsBySessionId: Map @@ -261,6 +262,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: string code: number incarnationId?: PtyIncarnationId + cause?: TerminalExitCause }) => void)[] = [] private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] // Why: lets main fan a dead-endpoint signal to every affected pane, not just the written one (STA-2373 sibling-freeze). @@ -2999,7 +3001,8 @@ export class DaemonPtyAdapter implements IPtyProvider { listener({ id: event.sessionId, code: event.payload.code, - ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}) + ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}), + ...(event.payload.cause ? { cause: event.payload.cause } : {}) }) } } diff --git a/src/main/daemon/daemon-pty-router-events.ts b/src/main/daemon/daemon-pty-router-events.ts index 41c1d560286..92b626d36e9 100644 --- a/src/main/daemon/daemon-pty-router-events.ts +++ b/src/main/daemon/daemon-pty-router-events.ts @@ -1,4 +1,5 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export type DaemonPtyRouterDataEvent = { id: string @@ -12,4 +13,6 @@ export type DaemonPtyRouterExitEvent = { id: string code: number incarnationId?: PtyIncarnationId + /** Absent from a daemon predating exit causes; readers fall back to `unknown`. */ + cause?: TerminalExitCause } diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 856b3c75ad5..34cf9134826 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1168,17 +1168,18 @@ export class DaemonServer { seq }) }, - onExit: (code, incarnationId) => { + onExit: (code, incarnationId, cause) => { // Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output. this.log.log('session-exited', { sessionId: routedSessionId, - code + code, + cause: cause?.kind }) this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { type: 'event', event: 'exit', sessionId: routedSessionId, - payload: { code, incarnationId } + payload: { code, incarnationId, ...(cause ? { cause } : {}) } }) this.streamDataBatcher.flush(clientId) recordDaemonStreamBacklogEvent('sessionExit', { diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index 62519016ca9..55e9f053316 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -1,6 +1,7 @@ // ─── Events (Daemon → Client, on stream socket) ──────────────────── import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export type DataEvent = { type: 'event' @@ -20,7 +21,9 @@ export type ExitEvent = { type: 'event' event: 'exit' sessionId: string - payload: { code: number; incarnationId?: PtyIncarnationId } + /** `cause` is additive: a daemon predating it omits the field and readers fall + * back to `unknown` rather than reading meaning into `code` (STA-4536). */ + payload: { code: number; incarnationId?: PtyIncarnationId; cause?: TerminalExitCause } } export type TerminalErrorEvent = { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 937300c1943..a36bc40d3a6 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -15,7 +15,11 @@ import { validateWorkingDirectoryAsync, WorkingDirectoryValidationAbortedError } from '../providers/local-pty-utils' -import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell' +import { + hostReportsChildExitStatus, + wrapShellSpawnForMacosTccAttribution +} from '../providers/macos-tcc-login-shell' +import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' import { readPtsName } from '../pty/node-pty-pts-name' import { @@ -578,7 +582,10 @@ function spawnDaemonPtyWithWindowsFallback(args: { shellPath: string spawnCwd: string startupCommandDeliveredInShellArgs?: boolean + /** False when a wrapper owns the reported status, so no exit code may be read from it. */ + reportsChildExitStatus: boolean } { + let reportsChildExitStatus = true const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) const proc = pty.spawn(wrapped.file, wrapped.args, { @@ -590,15 +597,18 @@ function spawnDaemonPtyWithWindowsFallback(args: { // Why: legacy system ConPTY can corrupt full-width TUI rows in scrollback; bundled ConPTY has the wrap-marker behavior xterm expects. ...(process.platform === 'win32' ? { useConptyDll: true } : {}) }) + reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') return proc } try { + const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) return { - process: spawnAt(args.shellPath, args.shellArgs, args.spawnCwd), + process: process_, shellPath: args.shellPath, - spawnCwd: args.spawnCwd + spawnCwd: args.spawnCwd, + reportsChildExitStatus } } catch (primaryErr) { if (process.platform !== 'win32') { @@ -616,7 +626,8 @@ function spawnDaemonPtyWithWindowsFallback(args: { process, shellPath: attempt.shellPath, spawnCwd: attempt.effectiveCwd, - startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs + startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, + reportsChildExitStatus } } catch { // This fallback shell also failed -- try the next link in the chain. @@ -908,6 +919,7 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise void) | null = null - let onExitCb: ((code: number) => void) | null = null + let onExitCb: ((code: number, cause?: TerminalExitCause) => void) | null = null let pendingPreListenerData: string[] = [] let pendingPreListenerDataChars = 0 let pendingPreListenerExitCode: number | null = null + let pendingPreListenerExitCause: TerminalExitCause | null = null const bufferPreListenerData = (data: string): void => { // Why: Windows shell-arg startup commands can print before Session wires this subprocess in; preserve that spawn-time race window. @@ -976,12 +990,21 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { + proc.onExit(({ exitCode, signal }) => { + // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}, and a + // wrapper spawn reports the wrapper's status — so build the cause here, + // where both facts are still in hand, rather than shipping a bare number. + const cause = resolveProcessExitCause({ + exitCode, + signal, + hostReportsChildExitStatus: reportsChildExitStatus + }) if (onExitCb) { flushPreListenerData() - onExitCb(exitCode) + onExitCb(exitCode, cause) } else { pendingPreListenerExitCode = exitCode + pendingPreListenerExitCause = cause } }) @@ -1341,9 +1364,11 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { @@ -1357,6 +1382,7 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise void - onExit: (code: number, incarnationId: string) => void + onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void } export type SessionOutputPlaneOptions = { @@ -87,9 +88,12 @@ export class SessionOutputPlane { return this.attachedClients.slice() } - broadcastExit(code: number, incarnationId: string): void { + broadcastExit(code: number, incarnationId: string, cause?: TerminalExitCause): void { + // Why the fallback here: a handle that predates exit causes still reports a + // code, and every client deserves the same shape. + const resolved = cause ?? resolveProcessExitCause({ exitCode: code }) for (const client of this.attachedClients) { - client.onExit(code, incarnationId) + client.onExit(code, incarnationId, resolved) } } diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index 1e71dacb4ab..631f7dc1489 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -1,3 +1,5 @@ +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' + export type SubprocessHandle = { pid: number /** Live foreground process name of the PTY (node-pty's `.process`), e.g. @@ -28,7 +30,7 @@ export type SubprocessHandle = { forceKill(): void signal(sig: string): void onData(cb: (data: string) => void): void - onExit(cb: (code: number) => void): void + onExit(cb: (code: number, cause?: TerminalExitCause) => void): void /** Release the native PTY handle via node-pty's destroy(). Idempotent; safe to call after exit. */ dispose(): void } diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 43a8a4e08c9..01c7cf7eb3b 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -736,7 +736,10 @@ describe('Session', () => { expect(onData).toHaveBeenCalledWith('late output') expect(onExit).toHaveBeenCalledTimes(1) - expect(onExit).toHaveBeenCalledWith(23, session.incarnationId) + expect(onExit).toHaveBeenCalledWith(23, session.incarnationId, { + kind: 'exited', + exitCode: 23 + }) expect(session.exitCode).toBe(23) }) }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 84a5bf3034b..be169a0f5aa 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -20,6 +20,7 @@ import type { TerminalSnapshot } from './types' import { createPtySlaveEchoProbe } from '../../shared/pty-slave-line-discipline-echo' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export class Session { readonly sessionId: string @@ -82,7 +83,7 @@ export class Session { }) this.shellReady.startPromptReadinessProbe() this.subprocess.onData((data) => this.handleSubprocessData(data)) - this.subprocess.onExit((code) => this.handleSubprocessExit(code)) + this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } get state(): SessionState { @@ -342,7 +343,7 @@ export class Session { this.shellReady.ingestSubprocessData(data) } - private handleSubprocessExit(code: number): void { + private handleSubprocessExit(code: number, cause?: TerminalExitCause): void { this.termination.markPhysicalExit() if (this._disposed) { return @@ -366,7 +367,7 @@ export class Session { // Not via #teardownSubprocess: it flips `_disposed`, short-circuiting the later Session.dispose() reaper. this.termination.disposeSubprocessHandle() - this.output.broadcastExit(code, this.incarnationId) + this.output.broadcastExit(code, this.incarnationId, cause) // Why: hand off to the owner's reaper (disposes emulator, drops session from host map); else dead sessions accumulate. this.onSessionExit?.(code) diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index dce8d052cef..aaaffaeb8e8 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -8,6 +8,7 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export type CreateOrAttachOptions = { sessionId: string @@ -35,7 +36,7 @@ export type CreateOrAttachOptions = { } streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - onExit: (code: number, incarnationId: PtyIncarnationId) => void + onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void } /** Lets the daemon route output under the adopted owner's canonical id before * attaching its stream callbacks. */ diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index d2e07b8458c..ae4de312c55 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -289,7 +289,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) @@ -342,7 +342,7 @@ describe('registerPtyHandlers', () => { await expect(stopPromise).resolves.toBe(true) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 1064aa59294..de3706fd1f3 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -436,7 +436,9 @@ describe('registerPtyHandlers', () => { await Promise.resolve() expect(killSpy).toHaveBeenCalled() - expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId) + expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId, { + cause: { kind: 'unknown', reason: 'stop_unverified' } + }) const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(false) }) diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index c4dec001542..478212b6c15 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -287,7 +287,7 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: 'local-pty' }) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( [['pty:exit', { id: 'local-pty', code: 0 }]] ) @@ -529,7 +529,7 @@ describe('registerPtyHandlers', () => { seq: 13, rawLength: 'daemon output'.length }) - expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, undefined) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: result.id, code: 0 diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 3a8586e66af..f1bfbbdf611 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2569,14 +2569,14 @@ export function registerPtyHandlers( return env }, onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), - onExit: (id, code, incarnationId) => { + onExit: (id, code, incarnationId, cause) => { if (!isCurrentPtyExit({ id, incarnationId })) { return } clearProviderPtyState(id) ptyOwnership.delete(id) markClaudePtyExited(id) - runtime?.onPtyExit(id, code, incarnationId) + runtime?.onPtyExit(id, code, incarnationId, cause ? { cause } : undefined) }, onData: (id, data, timestamp, sequenceChars, transformed) => runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) @@ -4121,9 +4121,20 @@ export function registerPtyHandlers( clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) markClaudePtyExited(payload.id) - runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId) + runtime?.onPtyExit( + payload.id, + payload.code, + payload.incarnationId, + payload.cause ? { cause: payload.cause } : undefined + ) } - sendPtyExitToRenderer(payload) + // Why not the whole payload: the exit cause is a main-process fact for the + // runtime's records; the renderer's pty:exit contract stays as it was. + sendPtyExitToRenderer({ + id: payload.id, + code: payload.code, + ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) + }) }) } @@ -5591,6 +5602,7 @@ export function registerPtyHandlers( } }, kill: (ptyId) => { + runtime?.markPtyStopRequested?.(ptyId) let connectionId: string | null | undefined = ptyOwnership.get(ptyId) const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null connectionId ??= parsedSshId?.connectionId @@ -5724,6 +5736,7 @@ export function registerPtyHandlers( } }, stopAndWait: async (ptyId, opts) => { + runtime?.markPtyStopRequested?.(ptyId) let connectionId: string | null | undefined = ptyOwnership.get(ptyId) const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null connectionId ??= parsedSshId?.connectionId @@ -7708,6 +7721,7 @@ export function registerPtyHandlers( // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. throw new Error('Invalid PTY provider id') } + runtime?.markPtyStopRequested?.(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null const connectionId = ownedConnectionId ?? parsedSshId?.connectionId diff --git a/src/main/providers/local-pty-provider-io-events.test.ts b/src/main/providers/local-pty-provider-io-events.test.ts index a3f2d6c901e..83cf268a7f3 100644 --- a/src/main/providers/local-pty-provider-io-events.test.ts +++ b/src/main/providers/local-pty-provider-io-events.test.ts @@ -124,7 +124,7 @@ import { describe('LocalPtyProvider', () => { let provider: LocalPtyProvider let mockProc: LocalPtyMockProcess - let exitCb: ((info: { exitCode: number }) => void) | undefined + let exitCb: ((info: { exitCode: number; signal?: number }) => void) | undefined installLocalPtyProviderEnvSandbox() @@ -313,7 +313,29 @@ describe('LocalPtyProvider', () => { // Simulate node-pty exit event exitCb?.({ exitCode: 0 }) - expect(exitHandler).toHaveBeenCalledWith({ id, code: 0, incarnationId }) + expect(exitHandler).toHaveBeenCalledWith({ + id, + code: 0, + incarnationId, + cause: { kind: 'exited', exitCode: 0 } + }) + }) + + it('reports a signalled death as a signal, not as the zero node-pty pairs with it', async () => { + const exitHandler = vi.fn() + provider.onExit(exitHandler) + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + + // node-pty reports an OOM/SIGKILL as {exitCode: 0, signal: 9}; dropping + // the signal is what made a crash read as a clean finish (STA-4536). + exitCb?.({ exitCode: 0, signal: 9 }) + + expect(exitHandler).toHaveBeenCalledWith({ + id, + code: 0, + incarnationId, + cause: { kind: 'signaled', signal: 9 } + }) }) it('allows unsubscribing from events', async () => { diff --git a/src/main/providers/local-pty-provider-shutdown.test.ts b/src/main/providers/local-pty-provider-shutdown.test.ts index 96ec4ec7582..8e2f13bd1cd 100644 --- a/src/main/providers/local-pty-provider-shutdown.test.ts +++ b/src/main/providers/local-pty-provider-shutdown.test.ts @@ -178,7 +178,10 @@ describe('LocalPtyProvider', () => { provider.configure({ onExit }) const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) await provider.shutdown(id, { immediate: true }) - expect(onExit).toHaveBeenCalledWith(id, -1, incarnationId) + expect(onExit).toHaveBeenCalledWith(id, -1, incarnationId, { + kind: 'unknown', + reason: 'stop_unverified' + }) }) it('does not destroy after an intentional Windows shutdown kill', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 763d8fcf223..0ed81da8293 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -93,6 +93,7 @@ import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' +import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -133,6 +134,9 @@ const ptyExitDisposables = new Map void }>() const ptyCleanupCallbacks = new Map void>() const ptyTerminationMode = new Map() const ptyPhysicalExits = new Map() +// Why: a wrapper spawn (macOS TCC login) reports its own status, never the +// shell's, so its exit numbers must not be read as the agent's (STA-4536). +const ptyReportsChildExitStatus = new Map() const ptyForceKillTimers = new Map>() export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 @@ -149,7 +153,12 @@ type DataCallback = (payload: { transformed?: boolean seq?: number }) => void -type ExitCallback = (payload: { id: string; code: number; incarnationId?: string }) => void +type ExitCallback = (payload: { + id: string + code: number + incarnationId?: string + cause?: TerminalExitCause +}) => void const dataListeners = new Set() const exitListeners = new Set() @@ -279,6 +288,7 @@ function clearPtyState(id: string): void { ptyWslDistroById.delete(id) ptyLoadGeneration.delete(id) ptyTerminationMode.delete(id) + ptyReportsChildExitStatus.delete(id) ptyPhysicalExits.delete(id) } @@ -525,7 +535,7 @@ export type LocalPtyProviderOptions = { getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined pwshAvailable?: () => boolean | Promise onSpawned?: (id: string, incarnationId: string) => void - onExit?: (id: string, code: number, incarnationId: string) => void + onExit?: (id: string, code: number, incarnationId: string, cause?: TerminalExitCause) => void onData?: ( id: string, data: string, @@ -945,6 +955,7 @@ export class LocalPtyProvider implements IPtyProvider { ? null : undefined createPtyPhysicalExit(id) + ptyReportsChildExitStatus.set(id, spawnResult.reportsChildExitStatus !== false) ptyProcesses.set(id, proc) ptyInitialCwd.set(id, cwd) if (spawnedWslDistro !== undefined) { @@ -1106,7 +1117,15 @@ export class LocalPtyProvider implements IPtyProvider { disposables.push(onDataDisposable) } - const onExitDisposable = proc.onExit(({ exitCode }) => { + const onExitDisposable = proc.onExit(({ exitCode, signal }) => { + // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the + // cause is built here, where the signal and the spawn's trustworthiness + // are both still in hand. + const cause = resolveProcessExitCause({ + exitCode, + signal, + hostReportsChildExitStatus: ptyReportsChildExitStatus.get(id) + }) const wasTerminationRequested = ptyTerminationMode.has(id) ptyPhysicalExits.get(id)?.markExited() // Why: neutralize proc.kill before destroy — node-pty SIGHUPs on socket 'close', which can race here and signal a reaped/recycled pid. @@ -1125,9 +1144,10 @@ export class LocalPtyProvider implements IPtyProvider { startupIngressByPty.delete(id) // Why: release the master ptmx fd on natural exit, else a clean exit leaks the fd until GC. See docs/fix-pty-fd-leak.md. destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested }) - this.opts.onExit?.(id, exitCode, incarnationId) + ptyReportsChildExitStatus.delete(id) + this.opts.onExit?.(id, exitCode, incarnationId, cause) for (const cb of exitListeners) { - cb({ id, code: exitCode, incarnationId }) + cb({ id, code: exitCode, incarnationId, cause }) } }) if (onExitDisposable) { diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 9ce3857d2d4..032e8572263 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -30,7 +30,8 @@ vi.mock('../wsl', () => ({ })) vi.mock('./macos-tcc-login-shell', () => ({ - wrapShellSpawnForMacosTccAttribution: wrapSpawnMock + wrapShellSpawnForMacosTccAttribution: wrapSpawnMock, + hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login' })) import { diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 33af9ea9c74..5649db65534 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,7 +1,10 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' -import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' +import { + hostReportsChildExitStatus, + wrapShellSpawnForMacosTccAttribution +} from './macos-tcc-login-shell' import { formatLocalPtyEnvironmentDiag } from './working-directory-validation' export { @@ -145,6 +148,9 @@ export type ShellSpawnParams = { export type ShellSpawnResult = { process: pty.IPty shellPath: string + /** False when a wrapper owns the reported status, so no exit code or signal + * from this process describes the shell (STA-4536). */ + reportsChildExitStatus?: boolean /** True when the winning shell's startup command was already embedded in its * argv, so callers must not re-deliver it through stdin. Only set when a * Windows fallback attempt other than the primary was used. */ @@ -234,7 +240,8 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu env, ...windowsConptyDllOptions() }), - shellPath + shellPath, + reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file) } } catch (err) { primaryError = err instanceof Error ? err.message : String(err) @@ -285,7 +292,11 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu console.warn( `[pty] Primary shell "${shellPath}" failed (${primaryError ?? 'unknown error'}), fell back to "${fallback}"` ) - return { process: proc, shellPath: fallback } + return { + process: proc, + shellPath: fallback, + reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file) + } } catch { // Fallback also failed -- try next. } diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index 463ba1fb3b0..18b45319c8e 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -318,6 +318,20 @@ export async function probeMacosLoginSessionAlive( * No-op off macOS, when already wrapped, when disabled via {@link DISABLE_ENV_VAR}, * or when the login(1) PAM preflight rejects this process's user. */ +/** + * Whether a PTY spawned on `file` reports its own child's exit status. + * + * login(1) forks the shell, waits, then exits with its own status — it forwards + * neither the shell's exit code nor its signal. Proved with node-pty: a raw + * `sh -c 'exit 42'` reports `{exitCode: 42}` and a self-SIGKILL reports + * `{signal: 9}`, while the same commands behind this wrapper both report + * `{exitCode: 0, signal: 0}`. Callers must not read a status from a wrapped + * spawn — say `unknown` instead (STA-4536). + */ +export function hostReportsChildExitStatus(file: string): boolean { + return file !== MACOS_LOGIN_PATH +} + export function wrapShellSpawnForMacosTccAttribution( file: string, args: string[], diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 3ebdb6827fb..333d6fcc62c 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -10,6 +10,7 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export type { PtyBackgroundStreamEvent, @@ -217,6 +218,12 @@ export type IPtyProvider = { onData(callback: (payload: PtyDataEvent) => void): () => void onReplay(callback: (payload: { id: string; data: string }) => void): () => void onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + callback: (payload: { + id: string + code: number + incarnationId?: PtyIncarnationId + /** Absent when the provider predates exit causes; readers must not infer one from `code`. */ + cause?: TerminalExitCause + }) => void ): () => void } diff --git a/src/main/runtime/exit-provenance-audit.test.ts b/src/main/runtime/exit-provenance-audit.test.ts new file mode 100644 index 00000000000..ee6b713dd61 --- /dev/null +++ b/src/main/runtime/exit-provenance-audit.test.ts @@ -0,0 +1,305 @@ +// STA-4603 / STA-4536: an operator close, a clean finish and a crash must not +// leave the same record. Each case here was byte-identical before the fix. +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import type { DispatchContextRow } from './orchestration/types' + +const TAB_ID = '11111111-1111-4111-8111-111111111111' +const LEAF_ID = '22222222-2222-4222-8222-222222222222' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const PTY_ID = 'pty-exit-provenance' +const HANDLE = 'term_exit_provenance' +const WORKTREE_ID = 'repo-audit::/tmp/audit' + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createDb(): OrchestrationDb { + const directory = mkdtempSync(join(tmpdir(), 'exit-provenance-')) + directories.push(directory) + return new OrchestrationDb(join(directory, 'orchestration.db')) +} + +function createRuntime(db: OrchestrationDb): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(null) + runtime.setOrchestrationDb(db) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'audit-incarnation' + }) + runtime.registerPreAllocatedHandleForPty(PTY_ID, HANDLE) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Agent', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { tabId: TAB_ID, worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: PTY_ID } + ] + }) + return runtime +} + +function dispatchOnHandle( + db: OrchestrationDb, + spec: string +): DispatchContextRow & { runId: string } { + const run = db.createRun({ + objective: spec, + coordinatorHandle: 'term_coordinator', + coordinatorPaneKey: '99999999-9999-4999-8999-999999999999:88888888-8888-4888-8888-888888888888' + }) + const task = db.createTask({ spec, runId: run.id }) + return { ...db.createDispatchContext(task.id, HANDLE, PANE_KEY), runId: run.id } +} + +/** Where a lightweight Run's coordinator actually reads its mail (STA-4604). */ +function escalations(db: OrchestrationDb, runId: string) { + return db.getUnreadMessages(`run:${runId}`, ['escalation']) +} + +function observe(db: OrchestrationDb, ctxId: string) { + const row = db.getDispatchContextById(ctxId)! + return { + status: row.status, + last_failure: row.last_failure, + termination_reason: row.termination_reason + } +} + +describe('STA-4603/STA-4536 exit provenance', () => { + it('separates a deliberate close from a crash and from a clean finish', () => { + const closedDb = createDb() + const closed = createRuntime(closedDb) + const closedCtx = dispatchOnHandle(closedDb, 'operator close') + closed.markPtyStopRequested(PTY_ID) + closed.onPtyExit(PTY_ID, 0) + + const crashDb = createDb() + const crashed = createRuntime(crashDb) + const crashCtx = dispatchOnHandle(crashDb, 'sigkill crash') + crashed.onPtyExit(PTY_ID, 0, undefined, { cause: { kind: 'signaled', signal: 9 } }) + + const cleanDb = createDb() + const clean = createRuntime(cleanDb) + const cleanCtx = dispatchOnHandle(cleanDb, 'clean finish') + clean.onPtyExit(PTY_ID, 0, undefined, { cause: { kind: 'exited', exitCode: 0 } }) + + expect(observe(closedDb, closedCtx.id)).toEqual({ + status: 'failed', + last_failure: 'Terminal closed by operator request', + termination_reason: 'operator_close' + }) + expect(observe(crashDb, crashCtx.id)).toEqual({ + status: 'failed', + last_failure: 'Agent process killed by signal 9', + termination_reason: 'signaled' + }) + expect(observe(cleanDb, cleanCtx.id)).toEqual({ + status: 'failed', + last_failure: 'Agent process exited with code 0', + termination_reason: 'exited' + }) + }) + + it('says unknown rather than inventing a status the host never reported', () => { + const db = createDb() + const runtime = createRuntime(db) + const ctx = dispatchOnHandle(db, 'macOS login wrapper') + runtime.onPtyExit(PTY_ID, 0, undefined, { + cause: { kind: 'unknown', reason: 'host_status_unavailable' } + }) + expect(observe(db, ctx.id)).toEqual({ + status: 'failed', + last_failure: 'Agent process ended; this host cannot report why', + termination_reason: 'unknown' + }) + }) + + it('does not read a bare code from an older daemon or an SSH relay as a clean finish', () => { + const db = createDb() + const runtime = createRuntime(db) + const ctx = dispatchOnHandle(db, 'exit delivered without a cause') + // No `cause`: the reporter forwards a number and nothing else. Its 0 may be + // a SIGKILL, so the record must not claim the agent finished. + runtime.onPtyExit(PTY_ID, 0) + expect(observe(db, ctx.id)).toEqual({ + status: 'failed', + last_failure: 'Agent process ended; the reporting host did not say why', + termination_reason: 'unknown' + }) + }) + + it('settles the dispatch when the whole tab is closed before the exit lands', () => { + const db = createDb() + const runtime = createRuntime(db) + const ctx = dispatchOnHandle(db, 'whole-tab operator close') + runtime.markPtyStopRequested(PTY_ID) + // The tab teardown drops the leaf from the renderer graph first; the exit + // used to find no leaf and leave the row reading 'dispatched' forever. + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtyExit(PTY_ID, 0) + expect(observe(db, ctx.id)).toEqual({ + status: 'failed', + last_failure: 'Terminal closed by operator request', + termination_reason: 'operator_close' + }) + }) + + it('does not escalate a deliberate close, but still escalates a crash', () => { + const closedDb = createDb() + const closed = createRuntime(closedDb) + const closedCtx = dispatchOnHandle(closedDb, 'no escalation on close') + closed.markPtyStopRequested(PTY_ID) + closed.onPtyExit(PTY_ID, 0) + // Assert the dispatch actually settled first: an empty inbox also happens + // when nothing was settled at all, which would make this vacuous. + expect(observe(closedDb, closedCtx.id).termination_reason).toBe('operator_close') + expect(escalations(closedDb, closedCtx.runId)).toHaveLength(0) + + const crashDb = createDb() + const crashed = createRuntime(crashDb) + const crashCtx = dispatchOnHandle(crashDb, 'escalate on crash') + crashed.onPtyExit(PTY_ID, 0, undefined, { cause: { kind: 'signaled', signal: 9 } }) + expect(escalations(crashDb, crashCtx.runId)).toHaveLength(1) + }) + + it('does not carry an unconsumed stop intent across a same-id respawn', () => { + const db = createDb() + const runtime = createRuntime(db) + // The operator asked for a stop, but the stop never produced an exit — the + // process outlived it. A respawn then reuses the same session id. + runtime.markPtyStopRequested(PTY_ID) + runtime.synchronizePtyOutputSequenceFromProvider(PTY_ID, { value: 0, generation: 'reset' }) + expect(runtime.isPtyStopRequested(PTY_ID)).toBe(false) + + const ctx = dispatchOnHandle(db, 'crash after respawn') + runtime.onPtyExit(PTY_ID, 0, undefined, { cause: { kind: 'signaled', signal: 9 } }) + // The replacement's crash must not inherit the old close's provenance. + expect(observe(db, ctx.id)).toEqual({ + status: 'failed', + last_failure: 'Agent process killed by signal 9', + termination_reason: 'signaled' + }) + }) + + it('does not file an unconfirmed stop as a completed operator close', () => { + const db = createDb() + const runtime = createRuntime(db) + const ctx = dispatchOnHandle(db, 'stop requested but never confirmed') + runtime.markPtyStopRequested(PTY_ID) + // The stop paths pass -1 when they asked a process to die and never saw it. + // The process may still be running against a revoked dispatch. + runtime.onPtyExit(PTY_ID, -1) + expect(observe(db, ctx.id)).toEqual({ + status: 'failed', + last_failure: 'Agent process stop was requested but never confirmed', + termination_reason: 'unknown' + }) + // ...and it must still wake the coordinator, unlike a clean close. + expect(escalations(db, ctx.runId)).toHaveLength(1) + }) + + it('treats a reported completion as authoritative and makes the later exit a no-op', () => { + const db = createDb() + const runtime = createRuntime(db) + const ctx = dispatchOnHandle(db, 'worker_done then exit') + const settlement = db.settleWorkerReport({ + taskId: ctx.task_id, + dispatchId: ctx.id, + outcome: 'succeeded', + result: JSON.stringify({ provenance: 'worker_report', outcome: 'succeeded' }) + }) + expect(settlement.action).toBe('settled') + runtime.onPtyExit(PTY_ID, 0) + expect(observe(db, ctx.id)).toMatchObject({ status: 'completed', last_failure: null }) + }) + + it('marks the intent from the real close path, not just from the helper', async () => { + const db = createDb() + const runtime = new OrcaRuntimeService(null) + runtime.setOrchestrationDb(db) + // Model the shipping controller: `orca terminal close` reaches stopAndWait, + // which verifies the stop and then reports it with a synthetic code 0 -- + // a number indistinguishable from a clean agent finish. + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + stopAndWait: async (ptyId: string) => { + runtime.onPtyExit(ptyId, 0) + return true + } + }) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'audit-incarnation' + }) + runtime.registerPreAllocatedHandleForPty(PTY_ID, HANDLE) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Agent', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { tabId: TAB_ID, worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: PTY_ID } + ] + }) + const ctx = dispatchOnHandle(db, 'close through the real path') + + await runtime.closeTerminal(HANDLE) + + expect(observe(db, ctx.id)).toEqual({ + status: 'failed', + last_failure: 'Terminal closed by operator request', + termination_reason: 'operator_close' + }) + }) + + it('publishes the cause on the terminal record a coordinator polls', async () => { + const db = createDb() + const runtime = createRuntime(db) + dispatchOnHandle(db, 'terminal summary shape') + runtime.markPtyStopRequested(PTY_ID) + runtime.onPtyExit(PTY_ID, 0) + const summary = await runtime.showTerminal(HANDLE) + expect(summary.connected).toBe(false) + expect(summary.exitCause).toEqual({ kind: 'operator_close' }) + }) + + it('leaves no exitCause on a terminal that is still running', async () => { + const db = createDb() + const runtime = createRuntime(db) + dispatchOnHandle(db, 'still running') + const summary = await runtime.showTerminal(HANDLE) + expect(summary.exitCause).toBeUndefined() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 51baa53efd0..2aad1169392 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -390,6 +390,13 @@ import { import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' +import { + describeTerminalExitCause, + isDeliberateTerminalExit, + OPERATOR_CLOSE_EXIT_CAUSE, + resolveUnreportedExitCause, + type TerminalExitCause +} from '../../shared/terminal-exit-cause' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -1419,6 +1426,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { writable: boolean lastOutputAt: number | null lastExitCode: number | null + lastExitCause: TerminalExitCause | null tailBuffer: string[] tailTranscriptBuffer: string[] tailTranscriptChars: number @@ -1471,6 +1479,7 @@ type RuntimePtyWorktreeRecord = { connected: boolean disconnectedAt: number | null lastExitCode: number | null + lastExitCause: TerminalExitCause | null lastAgentStatus: AgentStatus | null /** False until a live OSC frame sets the status; restore seeds never set it. */ lastAgentStatusObservedLive: boolean @@ -3142,6 +3151,10 @@ export class OrcaRuntimeService { private ptyForegroundAgentRefreshes = new Map() private ptyForegroundProcessReads = new Map() private ptyDelayedForegroundSnapshotTitleObservations = new Map() + // Why a set and not a timer: the intent is retired by the exit it explains, or + // by the next lifecycle generation on that id (advancePtyLifecycleGeneration), + // so a stop that never produced an exit cannot outlive its process. + private readonly stopRequestedPtyIds = new Set() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() private readonly orchestrationMailboxOwner = new OrchestrationMailboxOwner({ @@ -6750,6 +6763,7 @@ export class OrcaRuntimeService { writable: this.graphStatus === 'ready' && ptyId !== null, lastOutputAt: tailSource?.lastOutputAt ?? null, lastExitCode: tailSource?.lastExitCode ?? null, + lastExitCause: tailSource?.lastExitCause ?? null, tailBuffer: tailSource?.tailBuffer ?? [], tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [], tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0, @@ -12104,6 +12118,10 @@ export class OrcaRuntimeService { private advancePtyLifecycleGeneration(ptyId: string): void { this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) + // Why: a stop whose exit never arrived would otherwise stay armed across a + // same-id respawn and label the NEXT process's crash an operator close — + // the exact lie this cause model exists to remove. + this.stopRequestedPtyIds.delete(ptyId) this.agentPromptLifecycleByPtyId.delete(ptyId) this.agentPromptPermissionSequenceByPtyId.delete(ptyId) this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now()) @@ -15024,12 +15042,27 @@ export class OrcaRuntimeService { ptyId: string, exitCode: number, exitIncarnationId?: PtyIncarnationId, - options?: { hostExitConfirmed?: boolean } + options?: { hostExitConfirmed?: boolean; cause?: TerminalExitCause } ): void { const pty = this.ptysById.get(ptyId) if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { return } + // Why intent first: a requested stop can still be delivered by the provider's + // own exit event, whose status looks exactly like a natural finish. + // + // Why it yields to stop_unverified: a stop nobody confirmed is not a + // completed close. The process may still be running against a revoked + // dispatch — an incident a coordinator must hear about, not a routine + // teardown to be filed away and left unescalated. + const observedCause = options?.cause ?? resolveUnreportedExitCause(exitCode) + const stopNeverConfirmed = + observedCause.kind === 'unknown' && observedCause.reason === 'stop_unverified' + const exitCause: TerminalExitCause = + this.stopRequestedPtyIds.has(ptyId) && !stopNeverConfirmed + ? OPERATOR_CLOSE_EXIT_CAUSE + : observedCause + this.stopRequestedPtyIds.delete(ptyId) const preservesAbnormalSshSurface = this.isSshOwnedPtyId(ptyId) && pty?.connectionId != null && @@ -15171,6 +15204,7 @@ export class OrcaRuntimeService { this.setPairedRendererSessionOwnership(pty.ptyId, false) pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode + pty.lastExitCause = exitCause if (exitCode >= 0 || options?.hostExitConfirmed === true) { // A real wait status from the owning host is the death certificate; the // synthetic -1 we emit on a failed/unroutable stop is not. @@ -15193,15 +15227,31 @@ export class OrcaRuntimeService { this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) } + const exitedSurfaces: { handle: string; paneKey: string | null }[] = [] for (const leaf of this.getLeavesForPty(ptyId)) { this.detachedPreAllocatedLeaves.delete(ptyId) leaf.connected = false leaf.writable = false leaf.lastExitCode = exitCode + leaf.lastExitCause = exitCause leaf.lastAgentStatusObservedLive = false this.resolveExitWaiters(leaf) - if (!preservesAbnormalSshSurface) { - this.failActiveDispatchOnExit(leaf, exitCode) + const leafHandle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (leafHandle) { + exitedSurfaces.push({ handle: leafHandle, paneKey: `${leaf.tabId}:${leaf.leafId}` }) + } + } + // Why: an explicit whole-tab close drops the leaf from the graph *before* + // this exit lands, so a leaf-only walk found nothing and left the dispatch + // reading 'dispatched' forever against a dead process. The PTY's own handle + // and pane key survive that teardown, so settle from them too (STA-4603). + const ptyHandle = this.handleByPtyId.get(ptyId) + if (ptyHandle && !exitedSurfaces.some((surface) => surface.handle === ptyHandle)) { + exitedSurfaces.push({ handle: ptyHandle, paneKey: pty?.paneKey ?? null }) + } + if (!preservesAbnormalSshSurface) { + for (const surface of exitedSurfaces) { + this.failActiveDispatchOnExit(surface.handle, surface.paneKey, exitCode, exitCause) } } this.pruneDisconnectedPtyRecords() @@ -16813,26 +16863,38 @@ export class OrcaRuntimeService { // dispatch contexts immediately, rather than waiting for the coordinator's // next poll cycle. This catches agent crashes and unexpected exits within // milliseconds. The task is set back to 'pending' so it can be re-dispatched. - private failActiveDispatchOnExit(leaf: RuntimeLeafRecord, exitCode: number): void { + private failActiveDispatchOnExit( + handle: string, + paneKey: string | null, + exitCode: number, + cause: TerminalExitCause + ): void { if (!this._orchestrationDb) { return } - const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - if (!handle) { - return - } - - const dispatch = this._orchestrationDb.getActiveDispatchForTerminal(handle) + // Why the pane key too: a reminted handle no longer matches the row, but the + // pane identity behind it outlives the remint. + const dispatch = this._orchestrationDb.getActiveDispatchForTerminal( + handle, + paneKey ?? undefined + ) if (!dispatch) { return } - const errorContext = `Agent exited with code ${exitCode}` + const errorContext = describeTerminalExitCause(cause) const settled = this._orchestrationDb.failDispatch(dispatch.id, errorContext, { - workerProcessExited: true + workerProcessExited: true, + terminationReason: cause.kind }) + // Why: a deliberate close is not an incident. Escalating it trains + // coordinators to ignore the channel that should wake them for a real one. + if (isDeliberateTerminalExit(cause)) { + return + } + // Why: failDispatch above is the authoritative state transition and has already // committed. Everything below is best-effort mail on top of it — resolving the // recipient reads the database too — and onPtyExit runs this synchronously per leaf, @@ -16848,8 +16910,8 @@ export class OrcaRuntimeService { const escalation = this._orchestrationDb.insertMessage({ from: handle, to: recipient.to, - subject: `Agent exited unexpectedly (code ${exitCode})`, - body: this.describeWorkerExit(dispatch, exitCode, handle, settled?.status), + subject: `Agent exited unexpectedly (${errorContext})`, + body: this.describeWorkerExit(dispatch, cause, handle, settled?.status), type: 'escalation', priority: 'high', // Why: applyEscalationToDispatch rejects an escalation without an exact Dispatch @@ -16857,7 +16919,11 @@ export class OrcaRuntimeService { payload: JSON.stringify({ taskId: dispatch.task_id, dispatchId: dispatch.id, + // Why both: `exitCode` stays for readers that already parse it, but it + // is the raw number the host handed over, not a verdict — `exitCause` + // is what says whether the agent was killed, finished, or was closed. exitCode, + exitCause: cause, handle }), ...(recipient.runId ? { runId: recipient.runId } : {}) @@ -16880,7 +16946,7 @@ export class OrcaRuntimeService { // has to resolve ids by hand to learn what died and whether the task is still retryable. private describeWorkerExit( dispatch: { id: string; task_id: string; run_id: string }, - exitCode: number, + cause: TerminalExitCause, handle: string, settledStatus: DispatchStatus | undefined ): string { @@ -16900,7 +16966,11 @@ export class OrcaRuntimeService { : settledStatus === 'failed' ? ' The task is ready to be dispatched again.' : '' - return `Worker ${handle} exited with code ${exitCode} while running task ${named}.${outcome}` + // Why the cause and not a code: `code 0` reads as success even when the + // worker was killed, which is what sent operators chasing phantom OOMs. + return `Worker ${handle} stopped while running task ${named}. ${describeTerminalExitCause( + cause + )}.${outcome}` } // Why: a lightweight Run keeps its coordinator in runs/run_coordinator_handles and @@ -18014,6 +18084,23 @@ export class OrcaRuntimeService { this.rememberPtyLivenessVerdict(ptyId, { status: 'live', ptyIds: [ptyId] }) } + /** + * Records that Orca asked this PTY to stop — a close, a stop, a teardown. + * + * Why before the kill and not at the exit: a requested stop can still be + * delivered by the provider's own exit event, which carries a process status + * indistinguishable from a natural finish. The intent is the only thing that + * separates "the operator closed it" from "the agent died", so it is recorded + * where it is known rather than reconstructed afterwards (STA-4603). + */ + markPtyStopRequested(ptyId: string): void { + this.stopRequestedPtyIds.add(ptyId) + } + + isPtyStopRequested(ptyId: string): boolean { + return this.stopRequestedPtyIds.has(ptyId) + } + /** Null when nothing has been observed either way, so callers keep their own default. */ getPtyLivenessVerdict(ptyId: string): PtyLivenessVerdict | null { return this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict ?? null @@ -29207,6 +29294,10 @@ export class OrcaRuntimeService { let addressedPtyStopped = false const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS for (const ptyId of ptyIds) { + // Why here: this is the single funnel for an explicit close, and the + // intent must be on record before the stop, since the provider may report + // the exit itself with a status that reads like a natural finish. + this.markPtyStopRequested(ptyId) let stopped = false if (this.ptyController?.stopAndWait) { try { @@ -31947,6 +32038,7 @@ export class OrcaRuntimeService { connected: state.connected ?? true, disconnectedAt: state.connected === false ? Date.now() : null, lastExitCode: null, + lastExitCause: null, lastAgentStatus: null, lastAgentStatusObservedLive: false, lastAgentStatusStartedAtEpochMs: null, @@ -32613,6 +32705,7 @@ export class OrcaRuntimeService { writable: provenAbsent ? false : leaf.writable, lastOutputAt: leaf.lastOutputAt, preview: leaf.preview, + ...(leaf.lastExitCause ? { exitCause: leaf.lastExitCause } : {}), ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId) } } @@ -34571,6 +34664,7 @@ export class OrcaRuntimeService { writable: pty.connected, lastOutputAt: pty.lastOutputAt, preview: pty.preview, + ...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}), ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId) } } @@ -39652,7 +39746,14 @@ function buildTerminalWaitResult( condition: RuntimeTerminalWaitCondition, leaf: RuntimeLeafRecord ): RuntimeTerminalWait { - return buildTerminalWait(handle, condition, getTerminalState(leaf), leaf.lastExitCode) + return buildTerminalWait( + handle, + condition, + getTerminalState(leaf), + leaf.lastExitCode, + undefined, + leaf.lastExitCause + ) } function buildTerminalWaitBlockedResult( @@ -39666,7 +39767,8 @@ function buildTerminalWaitBlockedResult( condition, getTerminalState(leaf), leaf.lastExitCode, - blockedReason + blockedReason, + leaf.lastExitCause ) } @@ -39675,7 +39777,14 @@ function buildPtyTerminalWaitResult( condition: RuntimeTerminalWaitCondition, pty: RuntimePtyWorktreeRecord ): RuntimeTerminalWait { - return buildTerminalWait(handle, condition, getPtyTerminalState(pty), pty.lastExitCode) + return buildTerminalWait( + handle, + condition, + getPtyTerminalState(pty), + pty.lastExitCode, + undefined, + pty.lastExitCause + ) } function buildPtyTerminalWaitBlockedResult( @@ -39689,7 +39798,8 @@ function buildPtyTerminalWaitBlockedResult( condition, getPtyTerminalState(pty), pty.lastExitCode, - blockedReason + blockedReason, + pty.lastExitCause ) } @@ -39698,7 +39808,8 @@ function buildTerminalWait( condition: RuntimeTerminalWaitCondition, status: RuntimeTerminalState, exitCode: number | null, - blockedReason?: RuntimeTerminalWaitBlockedReason + blockedReason?: RuntimeTerminalWaitBlockedReason, + exitCause?: TerminalExitCause | null ): RuntimeTerminalWait { return { handle, @@ -39706,6 +39817,7 @@ function buildTerminalWait( satisfied: blockedReason === undefined, status, exitCode, + ...(exitCause ? { exitCause } : {}), ...(blockedReason ? { blockedReason } : {}) } } diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index e64082cde4f..6d175498bc4 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -7,4 +7,4 @@ export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION // Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity. -export const SCHEMA_VERSION = 28 +export const SCHEMA_VERSION = 29 diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts index 30451458541..d183516d361 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts @@ -77,7 +77,7 @@ export function failDispatch( this: OrchestrationDb, ctxId: string, error: string, - options: { workerProcessExited?: boolean } = {} + options: { workerProcessExited?: boolean; terminationReason?: string } = {} ): DispatchContextRow | undefined { this.db.exec(`SAVEPOINT ${FAIL_DISPATCH_SAVEPOINT}`) try { @@ -86,6 +86,7 @@ export function failDispatch( `UPDATE dispatch_contexts SET status = CASE WHEN failure_count + 1 >= ? THEN 'circuit_broken' ELSE 'failed' END, failure_count = failure_count + 1, last_failure = ?, + termination_reason = COALESCE(?, termination_reason), completed_at = COALESCE(completed_at, datetime('now')), capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) WHERE id = ? AND status IN ('pending', 'dispatched') @@ -95,7 +96,13 @@ export function failDispatch( AND worker.state NOT IN ('failed', 'succeeded', 'stopped', 'abandoned') ))` ) - .run(DISPATCH_CIRCUIT_BREAK_FAILURES, error, ctxId, options.workerProcessExited ? 1 : 0) + .run( + DISPATCH_CIRCUIT_BREAK_FAILURES, + error, + options.terminationReason ?? null, + ctxId, + options.workerProcessExited ? 1 : 0 + ) const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as | DispatchContextRow | undefined diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts index 1fa95aa2882..ae1cd4f45d1 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts @@ -9,9 +9,12 @@ import type { OrchestrationDb } from '../orchestration-db' export function getActiveDispatchForTerminal( this: OrchestrationDb, - handle: string + handle: string, + // Why optional: a handle reminted between dispatch and exit no longer matches + // the row, but the pane identity behind it survives the remint. + assigneePaneKey?: string ): DispatchContextRow | undefined { - return this.findActiveDispatchForAssignee(handle) + return this.findActiveDispatchForAssignee(handle, assigneePaneKey) } /** diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index c20f9deb93c..720b7153cea 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -125,6 +125,8 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), failure_count INTEGER NOT NULL DEFAULT 0, last_failure TEXT, + -- Why the process is gone, when Orca could establish it. See TerminalExitCause. + termination_reason TEXT, dispatched_at TEXT, completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/src/main/runtime/orchestration/db/schema/migrate-v13-v28.ts b/src/main/runtime/orchestration/db/schema/migrate-v13-v29.ts similarity index 94% rename from src/main/runtime/orchestration/db/schema/migrate-v13-v28.ts rename to src/main/runtime/orchestration/db/schema/migrate-v13-v29.ts index abac6fede89..b3129dc7ed6 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v13-v28.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v13-v29.ts @@ -2,7 +2,7 @@ import { migrateMutationReceiptCapacity } from '../../mutation-receipt-capacity' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' -export function applySchemaMigrationsV13ToV28(this: OrchestrationDb, current: number): void { +export function applySchemaMigrationsV13ToV29(this: OrchestrationDb, current: number): void { if (current < 13 && !this.hasColumn('worker_dispatches', 'runtime_epoch')) { this.db.exec('ALTER TABLE worker_dispatches ADD COLUMN runtime_epoch TEXT') } @@ -151,6 +151,12 @@ export function applySchemaMigrationsV13ToV28(this: OrchestrationDb, current: nu ); `) } + // Why a column and not a parsed `last_failure`: an operator close and a crash + // used to write the same sentence, so post-mortem tooling had to reconcile + // against external logs to tell them apart (STA-4603). + if (current < 29 && !this.hasColumn('dispatch_contexts', 'termination_reason')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN termination_reason TEXT') + } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) diff --git a/src/main/runtime/orchestration/db/schema/migrate.ts b/src/main/runtime/orchestration/db/schema/migrate.ts index 4fbd872e5a8..3c33b5c8ce5 100644 --- a/src/main/runtime/orchestration/db/schema/migrate.ts +++ b/src/main/runtime/orchestration/db/schema/migrate.ts @@ -1,7 +1,7 @@ import { resolveOrchestrationMigrationStartVersion } from '../../orchestration-schema-version-skew' import { SCHEMA_VERSION } from '../contract-constants' import type { OrchestrationDb } from '../orchestration-db' -import { applySchemaMigrationsV13ToV28 } from './migrate-v13-v28' +import { applySchemaMigrationsV13ToV29 } from './migrate-v13-v29' import { applySchemaMigrationsV2ToV12 } from './migrate-v2-v12' // Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing). @@ -15,7 +15,7 @@ export function migrate(this: OrchestrationDb): void { this.db.exec('BEGIN IMMEDIATE') try { applySchemaMigrationsV2ToV12.call(this, current) - applySchemaMigrationsV13ToV28.call(this, current) + applySchemaMigrationsV13ToV29.call(this, current) this.db.pragma(`user_version = ${SCHEMA_VERSION}`) this.db.exec('COMMIT') } catch (err) { diff --git a/src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts b/src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts index f216838b261..ede53f0d521 100644 --- a/src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts +++ b/src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' +import { SCHEMA_VERSION } from './db/contract-constants' describe('federation acknowledgment migration', () => { let db: OrchestrationDb | undefined @@ -41,7 +42,7 @@ describe('federation acknowledgment migration', () => { db = new OrchestrationDb(dbPath) const sqlite = (db as unknown as { db: Database.Database }).db - expect(sqlite.pragma('user_version', { simple: true })).toBe(28) + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(db.getFederatedDispatch('ctx_migrated')).toMatchObject({ to_home_imported_sequence: 2, to_home_acknowledged_sequence: 0 diff --git a/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts b/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts index 496bcbc6b3b..43c0cc7a367 100644 --- a/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts +++ b/src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts @@ -185,15 +185,19 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { runId: graded.runId, type: 'escalation', priority: 'high', - subject: 'Agent exited unexpectedly (code 137)', + subject: 'Agent exited unexpectedly (Agent process exited with code 137)', body: - `Worker ${graded.workerHandle} exited with code 137 while running task ` + - `"do the work" (${graded.taskId}). The task is ready to be dispatched again.`, + `Worker ${graded.workerHandle} stopped while running task ` + + `"do the work" (${graded.taskId}). Agent process exited with code 137. ` + + `The task is ready to be dispatched again.`, deliveryContract: 'current_delivery', payload: { taskId: expect.any(String), dispatchId: expect.any(String), exitCode: 137, + // Why: the code alone cannot say whether the agent was killed, finished, + // or was closed on purpose (STA-4603/STA-4536). + exitCause: { kind: 'exited', exitCode: 137 }, handle: graded.workerHandle } }) @@ -211,7 +215,7 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { to: expect.stringMatching(/^term_/), runId: LEGACY_RUN_ID, type: 'escalation', - subject: 'Agent exited unexpectedly (code 137)' + subject: 'Agent exited unexpectedly (Agent process exited with code 137)' }) }) @@ -363,8 +367,9 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { // Why: a tripped breaker means nobody will retry it, so the body must not // tell the coordinator the task is ready to go again. expect(escalations.at(-1)?.body).toBe( - `Worker ${workerHandle} exited with code 137 while running task ` + - `"repeatedly failing work" (${task.id}). This task has now failed too many ` + + `Worker ${workerHandle} stopped while running task ` + + `"repeatedly failing work" (${task.id}). Agent process exited with code 137. ` + + `This task has now failed too many ` + `times, so it will not be retried automatically.` ) } finally { @@ -467,8 +472,9 @@ describe('STA-4604 worker PTY exit escalation reaches the coordinator', () => { // Why: onPtyExit walks leaves synchronously — a throwing mailbox would abandon the // rest of the exit, so the worker death must stay durable and the exit must complete. expect(() => runtime.onPtyExit(WORKER_PTY_ID, 137)).not.toThrow() - expect(failDispatch).toHaveBeenCalledWith('ctx-1', 'Agent exited with code 137', { - workerProcessExited: true + expect(failDispatch).toHaveBeenCalledWith('ctx-1', 'Agent process exited with code 137', { + workerProcessExited: true, + terminationReason: 'exited' }) expect(warn).toHaveBeenCalledWith( '[orchestration] failed to escalate worker exit', diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts index 547117c1ed8..d45cc9f434e 100644 --- a/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' import { MUTATION_RECEIPT_MAX_ROWS } from './mutation-receipt-capacity' +import { SCHEMA_VERSION } from './db/contract-constants' function sqliteFor(db: OrchestrationDb): Database.Database { return (db as unknown as { db: Database.Database }).db @@ -103,7 +104,7 @@ describe('mutation receipt capacity schema', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(28) + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ receipt_count: 20 }) diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 9a3afab1d81..d95524d17fa 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' +import { SCHEMA_VERSION } from './db/contract-constants' const MUTATION_RECEIPT_MAX_ROWS = 10_000 @@ -240,7 +241,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(28) + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: null, @@ -277,7 +278,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(28) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) @@ -309,7 +310,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(28) + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: 'tab_creator:leaf_creator', created_by_process_incarnation: 'pty_creator:incarnation-a', @@ -328,7 +329,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(28) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a') }) }) diff --git a/src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts b/src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts index 674d165ad5c..f15ad3c316a 100644 --- a/src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts +++ b/src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' +import { SCHEMA_VERSION } from './db/contract-constants' describe('Run coordinator handle history migration', () => { let db: OrchestrationDb | undefined @@ -37,7 +38,7 @@ describe('Run coordinator handle history migration', () => { db = new OrchestrationDb(dbPath) const sqlite = (db as unknown as { db: Database.Database }).db - expect(sqlite.pragma('user_version', { simple: true })).toBe(28) + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect( sqlite .prepare('SELECT run_id, terminal_handle FROM run_coordinator_handles WHERE run_id = ?') @@ -79,12 +80,12 @@ describe('Run coordinator handle history migration', () => { const v28Db = new Database(dbPath) v28Db.exec('DROP TABLE run_coordinator_handles') - expect(v28Db.pragma('user_version', { simple: true })).toBe(28) + expect(v28Db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) v28Db.close() db = new OrchestrationDb(dbPath) const sqlite = (db as unknown as { db: Database.Database }).db - expect(sqlite.pragma('user_version', { simple: true })).toBe(28) + expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect( sqlite .prepare('SELECT run_id, terminal_handle FROM run_coordinator_handles WHERE run_id = ?') @@ -111,7 +112,7 @@ describe('Run coordinator handle history migration', () => { oldRuntimeDb .prepare('UPDATE runs SET coordinator_handle = ? WHERE id = ?') .run('term_v27_second', run.id) - expect(oldRuntimeDb.pragma('user_version', { simple: true })).toBe(28) + expect(oldRuntimeDb.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) oldRuntimeDb.close() db = new OrchestrationDb(dbPath) diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index a965891580d..7b73f227a0a 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -1,3 +1,4 @@ +import type { TerminalExitCause } from '../../../shared/terminal-exit-cause' export const MESSAGE_TYPES = [ 'status', 'dispatch', @@ -274,6 +275,9 @@ export type DispatchContextRow = { status: DispatchStatus failure_count: number last_failure: string | null + /** Why the dispatch ended, when Orca could establish it — `operator_close`, + * `signaled`, `exited`, `unknown`. Null on rows written before STA-4603. */ + termination_reason: TerminalExitCause['kind'] | null dispatched_at: string | null completed_at: string | null created_at: string diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index a2fa36ecadb..e3d6b2341ee 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -39,6 +39,7 @@ import type { RemoteServerUpdateSupport } from './remote-server-update' import type { ExecutionHostId } from './execution-host' import type { PtyIncarnationId } from './pty-incarnation' import type { RasterImageDimensions } from './raster-image-dimensions' +import type { TerminalExitCause } from './terminal-exit-cause' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -465,6 +466,10 @@ export type RuntimeTerminalSummary = { writable: boolean lastOutputAt: number | null preview: string + /** Why this terminal's process is gone. Absent while it is still running, and + * absent from a host predating the field — never read an absent value as a + * clean finish (STA-4536). */ + exitCause?: TerminalExitCause /** Where this terminal actually runs. Absent when the host predates the field * or could not name the host — never read an absent value as local. */ executionHostId?: ExecutionHostId @@ -759,6 +764,8 @@ export type RuntimeTerminalWait = { satisfied: boolean status: RuntimeTerminalState exitCode: number | null + /** Why it exited. `exitCode` alone cannot answer that — see {@link TerminalExitCause}. */ + exitCause?: TerminalExitCause blockedReason?: RuntimeTerminalWaitBlockedReason } diff --git a/src/shared/terminal-exit-cause.test.ts b/src/shared/terminal-exit-cause.test.ts new file mode 100644 index 00000000000..d01afabad63 --- /dev/null +++ b/src/shared/terminal-exit-cause.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + describeTerminalExitCause, + isDeliberateTerminalExit, + resolveProcessExitCause, + resolveUnreportedExitCause +} from './terminal-exit-cause' + +describe('resolveProcessExitCause', () => { + it('reports a signalled death as a signal, not as the zero node-pty pairs with it', () => { + expect(resolveProcessExitCause({ exitCode: 0, signal: 9 })).toEqual({ + kind: 'signaled', + signal: 9 + }) + }) + + it('refuses to read a status the host cannot report', () => { + // macOS wraps every PTY in `login -flpq`, which returns 0 for a shell that + // exited 42 and 0 for one that was SIGKILLed. + expect( + resolveProcessExitCause({ exitCode: 0, signal: 0, hostReportsChildExitStatus: false }) + ).toEqual({ kind: 'unknown', reason: 'host_status_unavailable' }) + expect( + resolveProcessExitCause({ exitCode: 42, signal: 9, hostReportsChildExitStatus: false }) + ).toEqual({ kind: 'unknown', reason: 'host_status_unavailable' }) + }) + + it('treats the stop paths’ negative sentinel as absence of evidence', () => { + expect(resolveProcessExitCause({ exitCode: -1 })).toEqual({ + kind: 'unknown', + reason: 'stop_unverified' + }) + }) + + it('keeps a real status when the host vouches for it', () => { + expect(resolveProcessExitCause({ exitCode: 42, signal: 0 })).toEqual({ + kind: 'exited', + exitCode: 42 + }) + expect(resolveProcessExitCause({ exitCode: 0 })).toEqual({ kind: 'exited', exitCode: 0 }) + }) + + it('treats a missing signal as no signal rather than as unknown', () => { + expect(resolveProcessExitCause({ exitCode: 7, signal: null })).toEqual({ + kind: 'exited', + exitCode: 7 + }) + }) +}) + +describe('resolveUnreportedExitCause', () => { + it('refuses to turn a bare zero into a clean finish', () => { + // An older daemon, or the SSH relay, forwards a code and no cause. Its 0 may + // be a clean finish or a SIGKILL; nothing downstream can tell them apart. + expect(resolveUnreportedExitCause(0)).toEqual({ kind: 'unknown', reason: 'cause_unreported' }) + }) + + it('keeps a bare nonzero status, which nothing fabricates', () => { + // node-pty only ever pairs a signal with 0, and a wrapper only ever returns + // 0, so a nonzero code really is the child's wait status. + expect(resolveUnreportedExitCause(137)).toEqual({ kind: 'exited', exitCode: 137 }) + }) + + it('keeps the more specific reason for the stop sentinel', () => { + expect(resolveUnreportedExitCause(-1)).toEqual({ kind: 'unknown', reason: 'stop_unverified' }) + }) +}) + +describe('describeTerminalExitCause', () => { + it('gives every cause a sentence that does not need a number decoded', () => { + expect(describeTerminalExitCause({ kind: 'operator_close' })).toBe( + 'Terminal closed by operator request' + ) + expect(describeTerminalExitCause({ kind: 'signaled', signal: 9 })).toBe( + 'Agent process killed by signal 9' + ) + expect(describeTerminalExitCause({ kind: 'exited', exitCode: 3 })).toBe( + 'Agent process exited with code 3' + ) + expect(describeTerminalExitCause({ kind: 'unknown', reason: 'stop_unverified' })).toBe( + 'Agent process stop was requested but never confirmed' + ) + expect(describeTerminalExitCause({ kind: 'unknown', reason: 'host_status_unavailable' })).toBe( + 'Agent process ended; this host cannot report why' + ) + expect(describeTerminalExitCause({ kind: 'unknown', reason: 'cause_unreported' })).toBe( + 'Agent process ended; the reporting host did not say why' + ) + }) +}) + +describe('isDeliberateTerminalExit', () => { + it('counts only an operator close as deliberate', () => { + expect(isDeliberateTerminalExit({ kind: 'operator_close' })).toBe(true) + expect(isDeliberateTerminalExit({ kind: 'exited', exitCode: 0 })).toBe(false) + expect(isDeliberateTerminalExit({ kind: 'signaled', signal: 9 })).toBe(false) + expect(isDeliberateTerminalExit({ kind: 'unknown', reason: 'stop_unverified' })).toBe(false) + }) +}) diff --git a/src/shared/terminal-exit-cause.ts b/src/shared/terminal-exit-cause.ts new file mode 100644 index 00000000000..c484b9002e8 --- /dev/null +++ b/src/shared/terminal-exit-cause.ts @@ -0,0 +1,103 @@ +/** + * Why a terminal's process is gone. + * + * Orca used to record one number and let every reader guess. That number is not + * evidence: the stop paths synthesize it, node-pty reports 0 for a signalled + * death, and macOS's TCC `login(1)` wrapper returns its own status instead of + * the shell's. A clean finish, an OOM kill and an operator close all arrived as + * "code 0" (STA-4536, STA-4603). + * + * So a cause is only ever built from evidence someone actually holds, and the + * absence of evidence is spelled `unknown` rather than guessed. + */ +export type TerminalExitCause = + /** Teardown was requested through Orca — a close, a stop, a worktree removal. Not a failure. */ + | { kind: 'operator_close' } + /** The host reported a signal. The agent did not choose to stop. */ + | { kind: 'signaled'; signal: number } + /** The process ended on its own and the host vouched for the status. */ + | { kind: 'exited'; exitCode: number } + /** Nothing here is provable. Never narrow this by guessing. */ + | { kind: 'unknown'; reason: TerminalExitUnknownReason } + +export type TerminalExitUnknownReason = + /** A stop was issued and no exit was ever observed, so the process may still be alive. */ + | 'stop_unverified' + /** The host cannot report its child's status at all (see {@link hostReportsChildExitStatus}). */ + | 'host_status_unavailable' + /** + * The exit arrived from a source that reports a code but no cause — an SSH + * relay, or a daemon older than this field. Its `0` may be a clean finish or + * a signal; nothing here can tell, so nothing here claims to. + */ + | 'cause_unreported' + +export const OPERATOR_CLOSE_EXIT_CAUSE: TerminalExitCause = { kind: 'operator_close' } + +/** + * Build a cause from what the host actually observed. + * + * `hostReportsChildExitStatus: false` means the number and signal below describe + * a wrapper process, not the agent — so they are dropped rather than reported. + */ +export function resolveProcessExitCause(observation: { + exitCode: number + signal?: number | null + hostReportsChildExitStatus?: boolean +}): TerminalExitCause { + if (observation.hostReportsChildExitStatus === false) { + return { kind: 'unknown', reason: 'host_status_unavailable' } + } + if (typeof observation.signal === 'number' && observation.signal > 0) { + return { kind: 'signaled', signal: observation.signal } + } + // Why: the stop paths pass a negative code to mean "we asked it to stop and + // never saw it die". That is an absence of evidence, not an exit status. + if (observation.exitCode < 0) { + return { kind: 'unknown', reason: 'stop_unverified' } + } + return { kind: 'exited', exitCode: observation.exitCode } +} + +/** + * The cause for an exit delivered without one — an older daemon, or the SSH + * relay, which forwards a code and nothing else. + * + * Zero is the ambiguous one, and the only one worth refusing: node-pty pairs it + * with every signalled death, and a wrapper spawn returns it for any outcome at + * all. A nonzero status is never fabricated that way, so it is still reported. + */ +export function resolveUnreportedExitCause(exitCode: number): TerminalExitCause { + if (exitCode < 0) { + return { kind: 'unknown', reason: 'stop_unverified' } + } + return exitCode === 0 + ? { kind: 'unknown', reason: 'cause_unreported' } + : { kind: 'exited', exitCode } +} + +/** One line an operator or a coordinating agent can read without decoding a number. */ +export function describeTerminalExitCause(cause: TerminalExitCause): string { + switch (cause.kind) { + case 'operator_close': + return 'Terminal closed by operator request' + case 'signaled': + return `Agent process killed by signal ${cause.signal}` + case 'exited': + return `Agent process exited with code ${cause.exitCode}` + case 'unknown': + switch (cause.reason) { + case 'stop_unverified': + return 'Agent process stop was requested but never confirmed' + case 'host_status_unavailable': + return 'Agent process ended; this host cannot report why' + case 'cause_unreported': + return 'Agent process ended; the reporting host did not say why' + } + } +} + +/** True when a dispatch that ended this way was torn down deliberately rather than lost. */ +export function isDeliberateTerminalExit(cause: TerminalExitCause): boolean { + return cause.kind === 'operator_close' +} From 4b0e01f61335756d4984ced6c3af37157cd9232a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:18:56 -0700 Subject: [PATCH 005/168] fix(github): scope GHES host-auth cache to the executing connection (#14948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(github): scope GHES host-auth cache to the executing connection The gh auth answer cached for a connection-backed repo is probed without the repository cwd, so it describes that connection's runtime — not the local host. Keying only on repoPath+wslDistro let a local repo and an SSH-hosted repo at the same path share one entry, and whichever resolved first decided "is this GHES host authenticated" for both. Include the connection identity in the runtime cache key. Local and WSL keys are unchanged. * fix(github): fence GHES auth cache across SSH reconnects * fix(github): fence origin cache across SSH reconnects * fix(github): fence repository identity cache on reconnect --- src/main/github/github-api-repository.test.ts | 79 +++++++++++- .../github-enterprise-repository.test.ts | 120 +++++++++++++++++- .../github/github-enterprise-repository.ts | 21 ++- 3 files changed, 208 insertions(+), 12 deletions(-) diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts index bf1948588ed..fb6512dab30 100644 --- a/src/main/github/github-api-repository.test.ts +++ b/src/main/github/github-api-repository.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SshGitDispatch from '../providers/ssh-git-dispatch' import type * as GitHubEnterpriseRepository from './github-enterprise-repository' import type * as GhUtils from './gh-utils' -import type * as SshGitDispatch from '../providers/ssh-git-dispatch' const { getEnterpriseGitHubRepoSlugMock, @@ -268,4 +268,81 @@ describe('origin repository cache', () => { await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) }) + + it('does not reuse a cached answer after the SSH provider reconnects', async () => { + const oldRepository = { + owner: 'old-owner', + repo: 'widgets', + host: 'github.acme-corp.com' + } + const newRepository = { + owner: 'new-owner', + repo: 'widgets', + host: 'github.acme-corp.com' + } + getSshGitProviderGenerationMock.mockReturnValue(1) + getEnterpriseGitHubRepoSlugMock + .mockResolvedValueOnce(oldRepository) + .mockResolvedValueOnce(newRepository) + + await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository) + await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository) + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + + getSshGitProviderGenerationMock.mockReturnValue(2) + await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository) + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) + }) + + it('does not reuse a cached negative after the SSH provider reconnects', async () => { + const repository = { + owner: 'acme', + repo: 'widgets', + host: 'github.acme-corp.com' + } + getSshGitProviderGenerationMock.mockReturnValue(1) + getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(repository) + + await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull() + await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull() + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) + + getSshGitProviderGenerationMock.mockReturnValue(2) + await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(repository) + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) + }) + + it('isolates an in-flight probe from an older SSH provider generation', async () => { + const oldRepository = { + owner: 'old-owner', + repo: 'widgets', + host: 'github.acme-corp.com' + } + const newRepository = { + owner: 'new-owner', + repo: 'widgets', + host: 'github.acme-corp.com' + } + let resolveOldProbe: (repository: typeof oldRepository) => void = () => {} + const oldProbeResult = new Promise((resolve) => { + resolveOldProbe = resolve + }) + getEnterpriseGitHubRepoSlugMock + .mockImplementationOnce(() => oldProbeResult) + .mockResolvedValueOnce(newRepository) + getSshGitProviderGenerationMock.mockReturnValue(1) + + const oldProbe = getOriginGitHubApiRepository('/repo', 'ssh-1') + await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)) + + getSshGitProviderGenerationMock.mockReturnValue(2) + const newProbe = getOriginGitHubApiRepository('/repo', 'ssh-1') + await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)) + await expect(newProbe).resolves.toEqual(newRepository) + + resolveOldProbe(oldRepository) + await expect(oldProbe).resolves.toEqual(oldRepository) + await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository) + expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index caee8e0bccf..dac93c146e0 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -27,6 +27,7 @@ import { isGitHubHostAuthenticatedForGlobalCli } from './github-enterprise-repository' import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' function mockOriginRemote(url: string): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -392,15 +393,122 @@ describe('isGitHubHostAuthenticated', () => { } }) - it('shares the native gh auth probe across SSH connections', async () => { - mockHostAuthenticated() + it('does not share a cached answer between SSH connections at the same path', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + }) + .mockResolvedValueOnce({ + stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)', + stderr: '' + }) - await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/a', 'ssh-1') - await isGitHubHostAuthenticated('github.acme-corp.com', '/remote/b', 'ssh-2') - - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo', 'ssh-1')).resolves.toBe( + true + ) + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo', 'ssh-2')).resolves.toBe( + false + ) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('does not reuse a cached answer after the SSH provider reconnects', async () => { + const connectionId = 'ssh-reconnected-auth-cache' + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + }) + .mockResolvedValueOnce({ + stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)', + stderr: '' + }) + + registerSshGitProvider(connectionId, {} as never) + try { + await expect( + isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) + ).resolves.toBe(true) + await expect( + isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) + ).resolves.toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + + registerSshGitProvider(connectionId, {} as never) + await expect( + isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) + ).resolves.toBe(false) + } finally { + unregisterSshGitProvider(connectionId) + } + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not join an in-flight auth probe from an older SSH provider generation', async () => { + const connectionId = 'ssh-reconnected-auth-in-flight' + let finishOldProbe: (() => void) | undefined + ghExecFileAsyncMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOldProbe = () => + resolve({ + stdout: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', + stderr: '' + }) + }) + ) + .mockResolvedValueOnce({ + stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)', + stderr: '' + }) + + registerSshGitProvider(connectionId, {} as never) + try { + const oldProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) + registerSshGitProvider(connectionId, {} as never) + const newProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) + + await expect(newProbe).resolves.toBe(false) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + finishOldProbe?.() + await expect(oldProbe).resolves.toBe(true) + } finally { + unregisterSshGitProvider(connectionId) + } + }) + + // The probe answers differ by runtime: a local repo gets a cwd, a + // connection-backed repo does not. Sharing one entry attributes whichever + // resolved first to both hosts. + it.each([ + ['local resolves first', [null, 'ssh-1'] as const, [true, false]], + ['connection resolves first', ['ssh-1', null] as const, [false, true]] + ])( + 'does not cross-attribute auth between a local and a connection-backed repo at the same path (%s)', + async (_label, connectionIds, expected) => { + ghExecFileAsyncMock.mockImplementation( + async (_args: string[], options: { cwd?: string }) => ({ + stdout: options.cwd + ? 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)' + : 'github.com\n ✓ Logged in to github.com account kelora (keyring)', + stderr: '' + }) + ) + + for (const [index, connectionId] of connectionIds.entries()) { + await expect( + isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) + ).resolves.toBe(expected[index]) + } + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + } + ) + it('does not target an unconfigured remote host with ambient credentials', async () => { mockHostAuthenticated('github.acme-corp.com') diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 0d244039145..d32dfadb3b9 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -20,7 +20,8 @@ import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution' import { parseWslPath } from '../wsl' import { getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE, + getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error' @@ -41,9 +42,19 @@ type HostAuthCacheEntry = { const hostAuthCache = new Map() const hostAuthInFlight = new Map>() -// Why: connection-backed Git operations execute remotely, but gh intentionally -// executes on the native host. Only WSL selects a distinct gh config/runtime. -function runtimeCacheKey(repoPath: string, wslDistro?: string): string { +// Why: gh intentionally executes on the native host even for connection-backed +// repos, but the answer still describes that connection's runtime (it is probed +// without the repository cwd). Keying it per connection stops a local repo and +// an SSH-hosted repo at the same path from adopting each other's auth state. +// Provider generation fences stale answers when reconnect replaces the runtime. +function runtimeCacheKey( + repoPath: string, + connectionId?: string | null, + wslDistro?: string +): string { + if (connectionId) { + return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + } const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro return `local:${resolvedDistro?.toLowerCase() ?? 'host'}` } @@ -134,7 +145,7 @@ async function resolveAuthenticatedGitHubHost( localGitOptions: LocalGitExecOptions = {} ): Promise { const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase() - const cacheKey = `${runtimeCacheKey(repoPath, localGitOptions.wslDistro)}\0${normalizedHost}` + const cacheKey = `${runtimeCacheKey(repoPath, connectionId, localGitOptions.wslDistro)}\0${normalizedHost}` const now = Date.now() pruneHostAuthCache(now) const cached = hostAuthCache.get(cacheKey) From a3edabcd7babc3a488a35be91a30da3dcd1eeaec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:27:25 -0700 Subject: [PATCH 006/168] fix(package): keep cached dev Electron bundles out of app.asar (#15359) `files` is an all-negation list, so electron-builder's default `**/*` packs anything without an explicit `!` entry. out/electron-dev holds `pnpm dev`'s per-branch Electron.app copies (~270MB each), so packaging on a machine that has run dev bundled them all. CI never creates the directory, so releases were never affected. --- config/electron-builder.config.cjs | 3 +++ .../scripts/electron-builder-config.test.mjs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 4fa904ab6d4..577cbd97a93 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -129,6 +129,9 @@ module.exports = { '!out/**/*.test.js', // Why: Vite's manifest is only used to project the paired web client. '!out/renderer/.vite{,/**/*}', + // Why: out/electron-dev caches `pnpm dev`'s per-branch Electron.app copies (~270MB each). + // CI never creates it, but packaging on a machine that has run dev would pack them all. + '!out/electron-dev{,/**/*}', '!electron.vite.config.{js,ts,mjs,cjs}', '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}', '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 33578580dae..75c3898858e 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -75,6 +75,25 @@ describe('electron-builder config', () => { expect(packs('out/main/examples/index.js')).toBe(true) }) + // Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch). + // CI never creates it, so only a local package would have hit this -- silently, as bulk. + it('keeps cached dev Electron bundles out of app.asar', () => { + const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files) + matcher.prependPattern('**/*') + const isPacked = matcher.createFilter() + const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false }) + + for (const devBundlePath of [ + 'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron', + 'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json' + ]) { + expect(packs(devBundlePath)).toBe(false) + } + // The real build outputs sit beside it under out/ and must still ship. + expect(packs('out/main/index.js')).toBe(true) + expect(packs('out/renderer/index.html')).toBe(true) + }) + it('keeps runtime resources available through extraResources', () => { const bundledPluginResources = expect.objectContaining({ from: 'resources/plugins/launch', From a77a2f93f70cb881cb706c50f98ba6f94a5e11ea Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:32:48 -0700 Subject: [PATCH 007/168] fix(remote): search Quick Open paths on the host (#15158) --- .github/workflows/e2e.yml | 5 +- src/main/ipc/filesystem-list-files.test.ts | 21 + src/main/ipc/filesystem-list-files.ts | 52 ++- .../ipc/filesystem-search-file-paths.test.ts | 139 +++++++ src/main/ipc/filesystem-search-file-paths.ts | 231 +++++++++++ src/main/ipc/filesystem.test.ts | 46 +++ src/main/ipc/filesystem.ts | 26 ++ .../providers/filesystem-provider-contract.ts | 8 +- ...h-filesystem-provider-capabilities.test.ts | 36 ++ .../ssh-filesystem-provider-capabilities.ts | 39 ++ .../providers/ssh-filesystem-provider.test.ts | 9 +- src/main/providers/ssh-filesystem-provider.ts | 9 +- .../runtime/orca-runtime-files-search.test.ts | 47 +++ src/main/runtime/orca-runtime-files.ts | 132 ++++++- src/main/runtime/orca-runtime.ts | 2 + .../rpc/methods/files-path-search.test.ts | 85 +++++ src/main/runtime/rpc/methods/files.test.ts | 29 +- src/main/runtime/rpc/methods/files.ts | 47 ++- src/preload/api/filesystem-api.ts | 2 + src/preload/index.ts | 2 + src/relay/fs-handler-install-rg.ts | 5 + .../fs-handler-list-files-ignored.test.ts | 23 ++ src/relay/fs-handler-list-files.ts | 38 +- src/relay/fs-handler-ripgrep-fallback.test.ts | 11 + src/relay/fs-handler.ts | 12 +- src/relay/fs-list-files-fallback-chain.ts | 48 ++- .../QuickOpen.mount-gating.test.tsx | 2 +- src/renderer/src/components/QuickOpen.tsx | 13 +- .../quick-open-file-list.react.test.tsx | 312 ++++++++++++++- .../src/components/quick-open-file-list.ts | 97 ++++- .../src/components/quick-open-search.test.ts | 13 + .../src/components/quick-open-search.ts | 190 +-------- .../TabBarCreateEntry.keyboard.test.tsx | 7 +- .../TabBarCreateEntry.tab-results.test.tsx | 7 +- .../tab-bar/open-tab-search-retention.test.ts | 6 +- src/renderer/src/i18n/locales/en.json | 3 + ...runtime-file-client-search-listing.test.ts | 361 +++++++++++++++++- .../runtime-file-client-test-harness.ts | 2 + .../src/runtime/runtime-file-client.ts | 131 ++++++- .../runtime-legacy-quick-open-inventory.ts | 194 ++++++++++ src/shared/quick-open-install-rg.ts | 8 + src/shared/quick-open-path-search.ts | 217 +++++++++++ .../quick-open-transport-budget.test.ts | 38 ++ src/shared/quick-open-transport-budget.ts | 46 +++ src/shared/runtime-types.ts | 1 + .../paired-quick-open-large-tree-fixture.ts | 101 +++++ .../e2e/paired-quick-open-large-tree.spec.ts | 208 ++++++++++ 47 files changed, 2792 insertions(+), 269 deletions(-) create mode 100644 src/main/ipc/filesystem-search-file-paths.test.ts create mode 100644 src/main/ipc/filesystem-search-file-paths.ts create mode 100644 src/main/providers/ssh-filesystem-provider-capabilities.test.ts create mode 100644 src/main/providers/ssh-filesystem-provider-capabilities.ts create mode 100644 src/renderer/src/runtime/runtime-legacy-quick-open-inventory.ts create mode 100644 src/shared/quick-open-path-search.ts create mode 100644 src/shared/quick-open-transport-budget.test.ts create mode 100644 src/shared/quick-open-transport-budget.ts create mode 100644 tests/e2e/helpers/paired-quick-open-large-tree-fixture.ts create mode 100644 tests/e2e/paired-quick-open-large-tree.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8a4b36c2c23..eba322cd13f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -215,9 +215,11 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools + # Why ripgrep: Quick Open's bounded host-side search requires rg instead of an + # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh + run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches. # Without that cache every E2E job re-downloaded the whole dependency set. @@ -225,7 +227,6 @@ jobs: uses: pnpm/action-setup@v6 with: run_install: false - - name: Setup Node.js uses: actions/setup-node@v6 with: diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 7debd516760..4ad492ec10a 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -335,6 +335,27 @@ describe('filesystem-list-files', () => { } }) + it('kills local rg scans when a paired listing is cancelled', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + controller.signal + ) + await flushMicrotasks() + + controller.abort(cancellation) + + await expect(promise).rejects.toBe(cancellation) + expect(p1.kill).toHaveBeenCalledOnce() + expect(p2.kill).toHaveBeenCalledOnce() + }) + it('filters out .next, .cache, .stably, .vscode, .idea', async () => { const p1 = createMockProcess() const p2 = createMockProcess() diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index c1666234cd0..dee1224b4b1 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -16,6 +16,10 @@ import { } from '../../shared/quick-open-filter' import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' +import { + limitQuickOpenFilesBySerializedBytes, + serializedQuickOpenPathBytes +} from '../../shared/quick-open-transport-budget' import { listFilesWithGit } from './filesystem-list-files-git-fallback' import { absorbPendingRipgrepSpawnError, @@ -23,13 +27,15 @@ import { killSpawnedRipgrepProcess, RipgrepUnavailableError } from '../../shared/ripgrep-process-availability' +import { fileListingCancellationError } from '../../shared/file-listing-cancellation' export async function listQuickOpenFiles( rootPath: string, store: Store, excludePaths?: string[], signal?: AbortSignal, - maxResults?: number + maxResults?: number, + maxSerializedBytes?: number ): Promise { const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -47,13 +53,16 @@ export async function listQuickOpenFiles( const listWithoutRipgrep = async (): Promise => { try { - return await listFilesWithGit( + const files = await listFilesWithGit( authorizedRootPath, excludePathPrefixes, localGitOptions, signal, maxResults ) + return maxSerializedBytes === undefined + ? files + : limitQuickOpenFilesBySerializedBytes(files, maxSerializedBytes) } catch (err) { if (!isQuickOpenReaddirBudgetError(err)) { throw err @@ -69,6 +78,7 @@ export async function listQuickOpenFiles( } const files = new Set() + let serializedBytes = 2 // [] const children: { child: ChildProcess isDone: () => boolean @@ -76,7 +86,7 @@ export async function listQuickOpenFiles( }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. - const { primary, ignoredPass } = buildRgArgsForQuickOpen({ + const rgArgs = buildRgArgsForQuickOpen({ // Why: rg evaluates root-relative exclude globs against cwd only when the // search target is cwd-relative. With an absolute target, `!packages/app` // filters output after traversal but does not prune the nested worktree. @@ -86,6 +96,8 @@ export async function listQuickOpenFiles( // macOS/Linux for idempotence — it's a no-op there. forceSlashSeparator: sep === '\\' }) + const primary = rgArgs.primary + const ignoredPass = rgArgs.ignoredPass const runRg = (args: string[]): Promise => { return new Promise((resolve, reject) => { @@ -114,10 +126,18 @@ export async function listQuickOpenFiles( if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { return false } + if (files.has(relPath)) { + return false + } if (maxResults !== undefined && files.size >= maxResults) { return true } + const nextBytes = serializedQuickOpenPathBytes(relPath) + (files.size === 0 ? 0 : 1) + if (maxSerializedBytes !== undefined && serializedBytes + nextBytes > maxSerializedBytes) { + return true + } files.add(relPath) + serializedBytes += nextBytes return maxResults !== undefined && files.size >= maxResults } @@ -202,6 +222,7 @@ export async function listQuickOpenFiles( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + signal?.removeEventListener('abort', handleAbort) absorbPendingRipgrepSpawnError(child, { errorObserved: processErrorObserved, unavailableExitObserved @@ -212,6 +233,11 @@ export async function listQuickOpenFiles( resolve() } } + const handleAbort = (): void => { + buf = '' + killSpawnedRipgrepProcess(child) + finish(fileListingCancellationError(signal)) + } children.push({ child, isDone: () => done, finish }) @@ -227,6 +253,10 @@ export async function listQuickOpenFiles( killSpawnedRipgrepProcess(child) finish(new Error('rg list timed out')) }, 10000) + signal?.addEventListener('abort', handleAbort, { once: true }) + if (signal?.aborted) { + handleAbort() + } }) } @@ -258,16 +288,19 @@ export async function listQuickOpenFiles( } try { const primaryRun = runRg(primary) - if (maxResults === undefined) { + if (maxResults === undefined && maxSerializedBytes === undefined) { // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn. await (children[0]?.child.pid === undefined ? primaryRun : Promise.all([primaryRun, runRg(ignoredPass)])) } else { - // Why: ignored-file output can be much larger and faster than the primary - // pass; let source files claim the bounded autocomplete budget first. + // Why: ignored-file output can be much larger and faster than the primary pass; let source + // files claim every bounded autocomplete budget first, including the transport byte cap. await primaryRun - if (files.size < maxResults) { + if ( + (maxResults === undefined || files.size < maxResults) && + (maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes) + ) { await runRg(ignoredPass) } } @@ -278,7 +311,10 @@ export async function listQuickOpenFiles( } throw err } - return Array.from(files).slice(0, maxResults) + const result = Array.from(files).slice(0, maxResults) + return maxSerializedBytes === undefined + ? result + : limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes) } function getQuickOpenRgOutputMode( diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts new file mode 100644 index 00000000000..5218758b925 --- /dev/null +++ b/src/main/ipc/filesystem-search-file-paths.test.ts @@ -0,0 +1,139 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' +import { isFileListingCancellation } from '../../shared/file-listing-cancellation' + +const { + checkRgAvailableMock, + getLocalGitOptionsForRegisteredWorktreeMock, + resolveAuthorizedPathMock, + wslAwareSpawnMock +} = vi.hoisted(() => ({ + checkRgAvailableMock: vi.fn(), + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + resolveAuthorizedPathMock: vi.fn(), + wslAwareSpawnMock: vi.fn() +})) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock +})) + +vi.mock('./filesystem-auth', () => ({ + resolveAuthorizedPath: resolveAuthorizedPathMock +})) + +vi.mock('./rg-availability', () => ({ + checkRgAvailable: checkRgAvailableMock +})) + +vi.mock('./local-worktree-runtime-options', () => ({ + getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock +})) + +import { searchQuickOpenFilePaths } from './filesystem-search-file-paths' + +function createMockProcess(): ChildProcess { + const child = new EventEmitter() as ChildProcess + ;(child as unknown as Record).stdout = new EventEmitter() + ;( + (child as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(child as unknown as Record).stderr = new EventEmitter() + ;(child as unknown as Record).kill = vi.fn() + ;(child as unknown as Record).exitCode = null + ;(child as unknown as Record).signalCode = null + Object.defineProperty(child, 'pid', { configurable: true, value: 1 }) + return child +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + +describe('searchQuickOpenFilePaths', () => { + beforeEach(() => { + vi.clearAllMocks() + resolveAuthorizedPathMock.mockImplementation(async (path) => path) + checkRgAvailableMock.mockResolvedValue(true) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + }) + + it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', {} as Store, { + query: 's4354tgt', + limit: 2, + excludePaths: ['/repo/nested'] + }) + await flushMicrotasks() + + expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) + expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') + ;(child.stdout as unknown as EventEmitter).emit( + 'data', + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` + ) + ;(child.stdout as unknown as EventEmitter).emit( + 'data', + 'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n' + ) + child.emit('close', 0, null) + + await expect(promise).resolves.toEqual({ + paths: ['src/sta-4354-target.ts'], + totalCount: 1, + truncated: false + }) + }) + + it('kills the host scan when a superseded query aborts', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const controller = new AbortController() + const promise = searchQuickOpenFilePaths('/repo', {} as Store, { + query: 'target', + limit: 32, + signal: controller.signal + }) + await flushMicrotasks() + + controller.abort() + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(child.kill).toHaveBeenCalledOnce() + }) + + it('requires ripgrep instead of retaining an unbounded fallback inventory', async () => { + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + + await expect( + searchQuickOpenFilePaths('C:\\repo', {} as Store, { query: 'target', limit: 32 }) + ).rejects.toThrow('Quick Open search requires ripgrep') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + + it('does not start a host scan for empty or oversized queries', async () => { + await expect( + searchQuickOpenFilePaths('/repo', {} as Store, { query: ' ', limit: 32 }) + ).resolves.toEqual({ paths: [], totalCount: 0, truncated: false }) + await expect( + searchQuickOpenFilePaths('/repo', {} as Store, { + query: 'é'.repeat(1_025), + limit: 32 + }) + ).resolves.toEqual({ paths: [], totalCount: 0, truncated: false }) + + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts new file mode 100644 index 00000000000..9f14a5f3487 --- /dev/null +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -0,0 +1,231 @@ +import { sep } from 'node:path' +import type { Store } from '../persistence' +import { fileListingCancellationError } from '../../shared/file-listing-cancellation' +import { + buildExcludePathPrefixes, + buildRgArgsForQuickOpen, + normalizeQuickOpenRgLine, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath, + type RgOutputMode +} from '../../shared/quick-open-filter' +import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../../shared/ripgrep-process-availability' +import { wslAwareSpawn } from '../git/runner' +import { parseWslPath, toWindowsWslPath } from '../wsl' +import { checkRgAvailable } from './rg-availability' +import { resolveAuthorizedPath } from './filesystem-auth' +import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' +import { QuickOpenSubprocessPathAccumulator } from '../../shared/quick-open-listing-limits' +import { buildRipgrepRequiredMessage } from '../../shared/quick-open-install-rg' + +export type QuickOpenFilePathSearchResult = { + paths: string[] + totalCount: number + truncated: boolean +} + +export async function searchQuickOpenFilePaths( + rootPath: string, + store: Store, + args: { + query: string + limit: number + excludePaths?: string[] + signal?: AbortSignal + } +): Promise { + if (args.limit <= 0 || !args.query.trim() || isQuickOpenQueryTooLarge(args.query)) { + return { paths: [], totalCount: 0, truncated: false } + } + const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + rootPath, + authorizedRootPath + ) + const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro + + const fallback = async (): Promise => { + throw new Error(await buildRipgrepRequiredMessage()) + } + if ( + wslDistroForOutput && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { + return fallback() + } + + const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, args.excludePaths) + const { ignoredPass } = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes, + forceSlashSeparator: sep === '\\' + }) + const ranker = new QuickOpenPathRanker(args.query, args.limit) + try { + await scanRipgrepPaths({ + args: ignoredPass, + authorizedRootPath, + excludePathPrefixes, + localGitOptions, + ranker, + signal: args.signal, + wslDistroForOutput + }) + } catch (error) { + if (error instanceof RipgrepUnavailableError) { + return fallback() + } + throw error + } + const result = ranker.result() + return { ...result, truncated: result.totalCount > args.limit } +} + +function scanRipgrepPaths(args: { + args: string[] + authorizedRootPath: string + excludePathPrefixes: readonly string[] + localGitOptions: { wslDistro?: string } + ranker: QuickOpenPathRanker + signal?: AbortSignal + wslDistroForOutput?: string +}): Promise { + if (args.signal?.aborted) { + return Promise.reject(fileListingCancellationError(args.signal)) + } + return new Promise((resolve, reject) => { + const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a) + let done = false + let parseablePathCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false + const child = wslAwareSpawn('rg', args.args, { + cwd: args.authorizedRootPath, + ...(args.localGitOptions.wslDistro ? { wslDistro: args.localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) + let timer: ReturnType + + const processLine = (rawLine: string): void => { + const translated = + args.wslDistroForOutput && rawLine.startsWith('/') + ? toWindowsWslPath(rawLine, args.wslDistroForOutput) + : rawLine + const relPath = normalizeQuickOpenRgLine( + translated, + getOutputMode(rawLine, translated, args.authorizedRootPath) + ) + if (relPath === null) { + return + } + parseablePathCount++ + if ( + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, args.excludePathPrefixes) + ) { + args.ranker.consider(relPath) + } + } + const cleanup = (): void => { + clearTimeout(timer) + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + args.signal?.removeEventListener('abort', handleAbort) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + const finish = (error?: Error): void => { + if (done) { + return + } + done = true + cleanup() + if (error) { + reject(error) + } else { + resolve() + } + } + const handleStdoutData = (chunk: string): void => { + pathAccumulator.push(chunk, (path) => { + processLine(path) + return true + }) + } + const handleStderrData = (): void => { + /* drain */ + } + const handleError = (): void => { + processErrorObserved = true + finish( + isRipgrepUnavailableExit(child, null, null) + ? new RipgrepUnavailableError() + : new Error('rg failed to start') + ) + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !args.wslDistroForOutput + }) + ) { + unavailableExitObserved = true + finish(new RipgrepUnavailableError()) + return + } + if (signal) { + finish(new Error(`rg killed by ${signal}`)) + return + } + const trailingPath = pathAccumulator.finish() + if (trailingPath) { + processLine(trailingPath) + } + finish( + code === 0 || code === 1 || (code === 2 && parseablePathCount > 0) + ? undefined + : new Error(`rg exited with code ${code}`) + ) + } + const handleAbort = (): void => { + pathAccumulator.clear() + killSpawnedRipgrepProcess(child) + finish(fileListingCancellationError(args.signal)) + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + args.signal?.addEventListener('abort', handleAbort, { once: true }) + timer = setTimeout(() => { + pathAccumulator.clear() + killSpawnedRipgrepProcess(child) + finish(new Error('rg file-path search timed out')) + }, 10_000) + if (args.signal?.aborted) { + handleAbort() + } + }) +} + +function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode { + return translatedLine !== rawLine || + rawLine.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(rawLine) || + rawLine.startsWith('\\\\') + ? { kind: 'absolute', rootPath } + : { kind: 'cwd-relative' } +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 09706bd269e..fe1568dbf72 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -559,6 +559,52 @@ describe('registerFilesystemHandlers', () => { }) }) + it('fs:listFiles forwards bounded Quick Open search options to SSH', async () => { + const listFilesMock = vi.fn().mockResolvedValue(['src/target.ts']) + getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock }) + + registerFilesystemHandlers(store as never) + + await handlers.get('fs:listFiles')!(null, { + rootPath: '/home/user/repo', + connectionId: 'conn-1', + maxResults: 33, + searchQuery: 'target' + }) + + expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', { + excludePaths: undefined, + maxResults: 33, + searchQuery: 'target' + }) + }) + + it('ranks a bounded legacy SSH listing when the relay lacks Quick Open search', async () => { + const listFilesMock = vi.fn().mockResolvedValue(['src/target.ts', 'src/index.ts']) + const supportsQuickOpenSearchMock = vi.fn().mockResolvedValue(false) + getSshFilesystemProviderMock.mockReturnValue({ + listFiles: listFilesMock, + supportsQuickOpenSearch: supportsQuickOpenSearchMock + }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:listFiles')!(null, { + rootPath: '/home/user/repo', + connectionId: 'conn-1', + maxResults: 2, + searchQuery: 'target' + }) + ).resolves.toEqual(['src/target.ts']) + + expect(supportsQuickOpenSearchMock).toHaveBeenCalled() + expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', { + excludePaths: undefined, + maxResults: 33 + }) + }) + // Why #7721: without a cancel path, every workspace switch left the previous // workspace's full-tree SSH scan running, stacking scans on the relay until // interactive fs.readDir/fs.stat starved past their 30s timeout. diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index c89c9f744fc..0ad3825a7fc 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -139,6 +139,7 @@ import { sanitizeLocalDownloadFilename } from '../local-download-filename' import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { applyGitStatusUpstreamRefWatchRequest, type GitStatusUpstreamRefWatchRequest @@ -148,6 +149,8 @@ import { const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB const BINARY_PROBE_BYTES = 8192 const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 // Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB const PREVIEWABLE_BINARY_MIME_TYPES: Record = { @@ -1115,6 +1118,8 @@ export function registerFilesystemHandlers( connectionId?: string excludePaths?: string[] requestToken?: string + maxResults?: number + searchQuery?: string } ): Promise => { const controller = listFilesCancellations.begin(event, args.requestToken) @@ -1126,8 +1131,29 @@ export function registerFilesystemHandlers( return [] } // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. + if ( + args.searchQuery !== undefined && + provider.supportsQuickOpenSearch && + !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) + ) { + const legacyFiles = await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, + signal: controller?.signal + }) + const ranker = new QuickOpenPathRanker( + args.searchQuery, + args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT + ) + for (const file of legacyFiles) { + ranker.consider(file) + } + return ranker.result().paths + } return await provider.listFiles(args.rootPath, { excludePaths: args.excludePaths, + ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), + ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), signal: controller?.signal }) } diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts index 942cb93f090..4031584b5ff 100644 --- a/src/main/providers/filesystem-provider-contract.ts +++ b/src/main/providers/filesystem-provider-contract.ts @@ -56,8 +56,14 @@ export type IFilesystemProvider = { search(opts: SearchOptions): Promise listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } + options?: { + excludePaths?: string[] + signal?: AbortSignal + maxResults?: number + searchQuery?: string + } ): Promise + supportsQuickOpenSearch?(options?: { signal?: AbortSignal }): Promise scanWorkspaceSpace?( rootPath: string, options?: { signal?: AbortSignal } diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.test.ts b/src/main/providers/ssh-filesystem-provider-capabilities.test.ts new file mode 100644 index 00000000000..426c89f3054 --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-capabilities.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { probeSshQuickOpenSearchCapability } from './ssh-filesystem-provider-capabilities' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' + +describe('SSH Quick Open capability probe', () => { + it('recognizes the query-aware relay', async () => { + const mux = { request: vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) } + await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) + await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledTimes(1) + expect(mux.request).toHaveBeenCalledWith('fs.getCapabilities', undefined, { + signal: undefined, + timeoutMs: 5_000 + }) + }) + + it('lets callers treat a missing capability as legacy', async () => { + const mux = { + request: vi + .fn() + .mockRejectedValue( + Object.assign(new Error('method not found'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + } + await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) + await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) + expect(mux.request).toHaveBeenCalledTimes(1) + }) + + it('does not downgrade transport failures to legacy behavior', async () => { + const mux = { request: vi.fn().mockRejectedValue(new Error('connection closed')) } + await expect(probeSshQuickOpenSearchCapability(mux as never)).rejects.toThrow( + 'connection closed' + ) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.ts b/src/main/providers/ssh-filesystem-provider-capabilities.ts new file mode 100644 index 00000000000..25799bb4e16 --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-capabilities.ts @@ -0,0 +1,39 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter' + +const quickOpenSearchSupport = new WeakMap>() + +export function probeSshQuickOpenSearchCapability( + mux: SshChannelMultiplexer, + signal?: AbortSignal +): Promise { + const cached = quickOpenSearchSupport.get(mux) + const probe = + cached ?? + mux + .request('fs.getCapabilities', undefined, { timeoutMs: 5_000 }) + .then((result) => { + const version = (result as { quickOpenSearchVersion?: unknown } | null) + ?.quickOpenSearchVersion + return version === 1 + }) + .catch((error) => { + if (isMethodNotFoundError(error)) { + return false + } + throw error + }) + if (!cached) { + quickOpenSearchSupport.set(mux, probe) + } + return waitForSshCapabilityProbe(probe, signal).then( + (supported) => supported, + (error) => { + if (!signal?.aborted && quickOpenSearchSupport.get(mux) === probe) { + quickOpenSearchSupport.delete(mux) + } + throw error + } + ) +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index a7061616c4a..65913f0c7e9 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -497,18 +497,19 @@ describe('SshFilesystemProvider', () => { expect(result).toEqual(['src/index.ts', 'package.json']) }) - it('listFiles forwards supported list options', async () => { - mux.request.mockResolvedValue([]) + it('listFiles forwards listing and query options', async () => { await provider.listFiles('/home/user/project', { excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000 + maxResults: 20_000, + searchQuery: 'target' }) expect(mux.request).toHaveBeenCalledWith( 'fs.listFiles', { rootPath: '/home/user/project', excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000 + maxResults: 20_000, + searchQuery: 'target' }, { signal: undefined } ) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 6adda586065..4a3195e65f5 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -27,8 +27,8 @@ import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-type import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { probeSshQuickOpenSearchCapability } from './ssh-filesystem-provider-capabilities' const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 - export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string private mux: SshChannelMultiplexer @@ -304,7 +304,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { async listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } + options?: Parameters[1] ): Promise { const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { @@ -313,6 +313,9 @@ export class SshFilesystemProvider implements IFilesystemProvider { if (options?.maxResults !== undefined) { params.maxResults = options.maxResults } + if (options?.searchQuery !== undefined) { + params.searchQuery = options.searchQuery + } // Why #7721: the signal lets a workspace switch send rpc.cancel so the // relay aborts the full-tree scan instead of stacking abandoned scans // that starve interactive fs.readDir/fs.stat on the shared SSH channel. @@ -321,6 +324,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { })) as string[] } + supportsQuickOpenSearch = (options: { signal?: AbortSignal } = {}): Promise => + probeSshQuickOpenSearchCapability(this.mux, options.signal) async watch( rootPath: string, callback: (events: FsChangeEvent[]) => void, diff --git a/src/main/runtime/orca-runtime-files-search.test.ts b/src/main/runtime/orca-runtime-files-search.test.ts index ff39bd7f5e5..f71aee54c4a 100644 --- a/src/main/runtime/orca-runtime-files-search.test.ts +++ b/src/main/runtime/orca-runtime-files-search.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events' import { checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + getSshFilesystemProviderMock, resolveAuthorizedPathMock, searchWithGitGrepMock, wslAwareSpawnMock @@ -68,6 +69,26 @@ async function flushRuntimeSearchMicrotasks(): Promise { describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() + it('keeps byte-budgeted legacy listings count-bounded across an SSH hop', async () => { + const listFiles = vi.fn().mockResolvedValue(['src/index.ts']) + getSshFilesystemProviderMock.mockReturnValue({ listFiles }) + const { commands } = createRuntimeFileCommands({ + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: 'ssh-1' + })) + }) + + await expect(commands.listRuntimeFiles('id:wt-1', { maxContentBytes: 1024 })).resolves.toEqual([ + 'src/index.ts' + ]) + expect(listFiles).toHaveBeenCalledWith('/repo', { + excludePaths: undefined, + maxResults: 20_001, + signal: undefined + }) + }) + it('settles and detaches runtime rg searches when timeout kill is ignored', async () => { const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { @@ -225,4 +246,30 @@ describe('RuntimeFileCommands', () => { expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') expect(wslAwareSpawnMock).not.toHaveBeenCalled() }) + + it('keeps legacy SSH Quick Open replies within the frame-sized result bound', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: 'ssh-1' + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const listFiles = vi.fn(async () => ['src/target.ts']) + getSshFilesystemProviderMock.mockReturnValue({ + supportsQuickOpenSearch: vi.fn(async () => false), + listFiles + }) + + await expect(commands.searchQuickOpenFilePaths('id:wt-1', 'target', 32)).resolves.toMatchObject( + { + files: [{ relativePath: 'src/target.ts' }], + totalCount: 1, + truncated: false + } + ) + expect(listFiles).toHaveBeenCalledWith('/repo', { + excludePaths: undefined, + maxResults: 32, + signal: undefined + }) + }) }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 1726ee4bdf9..6a4836682ec 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -54,6 +54,9 @@ import { parseWslPath, toWindowsWslPath } from '../wsl' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' import { isENOENT } from '../ipc/filesystem-path-containment' import { listQuickOpenFiles } from '../ipc/filesystem-list-files' +import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths' +import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' +import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' import { searchWithGitGrep } from '../ipc/filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' import { checkRgAvailable } from '../ipc/rg-availability' @@ -99,8 +102,11 @@ import { NodeFileReadTooLargeError, readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' +import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits' const MOBILE_FILE_LIST_LIMIT = 5000 +// Legacy SSH relays cannot enforce a byte budget; 32 max-length paths stay under one 4 MiB frame. +const QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT = 32 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 @@ -615,13 +621,16 @@ export class RuntimeFileCommands { constructor(private readonly host: RuntimeFileCommandHost) {} - async listMobileFiles(worktreeSelector: string): Promise { + async listMobileFiles( + worktreeSelector: string, + options: { signal?: AbortSignal } = {} + ): Promise { const store = this.host.requireStore() const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const { worktree, connectionId } = target const files = connectionId - ? await this.listRemoteMobileFiles(worktree.path, connectionId) - : await listQuickOpenFiles(worktree.path, store) + ? await this.listRemoteMobileFiles(worktree.path, connectionId, undefined, options.signal) + : await listQuickOpenFiles(worktree.path, store, undefined, options.signal) const entries = files .filter((relativePath) => isSafeMobileRelativePath(relativePath)) .sort((a, b) => a.localeCompare(b)) @@ -687,6 +696,46 @@ export class RuntimeFileCommands { } } + async searchQuickOpenFilePaths( + worktreeSelector: string, + query: string, + limit: number, + excludePaths?: string[], + signal?: AbortSignal + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const result = + !query.trim() || isQuickOpenQueryTooLarge(query) + ? { paths: [], totalCount: 0, truncated: false } + : connectionId + ? await this.searchRemoteQuickOpenFilePaths( + worktree.path, + connectionId, + query, + limit, + excludePaths, + signal + ) + : await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), { + query, + limit, + excludePaths, + signal + }) + return { + worktree: worktree.id, + rootPath: worktree.path, + files: result.paths.map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })), + totalCount: result.totalCount, + truncated: result.truncated + } + } + async openMobileFile( worktreeSelector: string, relativePath: string @@ -2043,7 +2092,12 @@ export class RuntimeFileCommands { async listRuntimeFiles( worktreeSelector: string, - options: { excludePaths?: string[] } = {} + options: { + excludePaths?: string[] + maxContentBytes?: number + maxResults?: number + signal?: AbortSignal + } = {} ): Promise { const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null @@ -2051,9 +2105,26 @@ export class RuntimeFileCommands { if (!provider) { return [] } - return provider.listFiles(target.worktree.path, { excludePaths: options.excludePaths }) + const maxResults = + options.maxResults ?? + (options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS) + const files = await provider.listFiles(target.worktree.path, { + excludePaths: options.excludePaths, + maxResults, + signal: options.signal + }) + return options.maxContentBytes === undefined + ? files + : limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes) } - return listQuickOpenFiles(target.worktree.path, this.host.requireStore(), options.excludePaths) + return listQuickOpenFiles( + target.worktree.path, + this.host.requireStore(), + options.excludePaths, + options.signal, + options.maxResults, + options.maxContentBytes + ) } async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { @@ -2264,13 +2335,58 @@ export class RuntimeFileCommands { private async listRemoteMobileFiles( rootPath: string, connectionId: string, - maxResults?: number + maxResults?: number, + signal?: AbortSignal ): Promise { const provider = getSshFilesystemProvider(connectionId) if (!provider) { return [] } - return provider.listFiles(rootPath, { maxResults }) + return provider.listFiles(rootPath, { maxResults, signal }) + } + + private async searchRemoteQuickOpenFilePaths( + rootPath: string, + connectionId: string, + query: string, + limit: number, + excludePaths?: string[], + signal?: AbortSignal + ): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return { paths: [], totalCount: 0, truncated: false } + } + if (!(await provider.supportsQuickOpenSearch?.({ signal }))) { + // Old relays ignore searchQuery. Keep the compatibility request below the + // 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap. + const legacyFiles = await provider.listFiles(rootPath, { + excludePaths, + maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT, + signal + }) + const ranker = new QuickOpenPathRanker(query, limit) + for (const file of legacyFiles) { + ranker.consider(file) + } + const result = ranker.result() + return { + ...result, + truncated: + legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit + } + } + const files = await provider.listFiles(rootPath, { + excludePaths, + maxResults: limit + 1, + searchQuery: query, + signal + }) + return { + paths: files.slice(0, limit), + totalCount: files.length, + truncated: files.length > limit + } } private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2aad1169392..b4fe067c4a8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10075,6 +10075,8 @@ export class OrcaRuntimeService { ) searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) + searchQuickOpenFilePaths: RuntimeFileCommands['searchQuickOpenFilePaths'] = + this.fileCommands.searchQuickOpenFilePaths.bind(this.fileCommands) openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( this.fileCommands ) diff --git a/src/main/runtime/rpc/methods/files-path-search.test.ts b/src/main/runtime/rpc/methods/files-path-search.test.ts index cd47aa8fd7c..38938228b8c 100644 --- a/src/main/runtime/rpc/methods/files-path-search.test.ts +++ b/src/main/runtime/rpc/methods/files-path-search.test.ts @@ -3,6 +3,7 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' import { FILE_METHODS } from './files' +import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' describe('file path search RPC method', () => { it('returns a bounded server-side result for mobile autocomplete', async () => { @@ -32,4 +33,88 @@ describe('file path search RPC method', () => { result: { files: [{ relativePath: 'src/app.ts' }] } }) }) + + it('routes desktop Quick Open searches with exclusions and cancellation', async () => { + const searchQuickOpenFilePaths = vi.fn().mockResolvedValue({ + worktree: 'wt-1', + rootPath: '/repo', + files: [{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }], + totalCount: 1, + truncated: false + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + searchQuickOpenFilePaths + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const controller = new AbortController() + + const response = await dispatcher.dispatch( + { + id: 'req-quick-open', + authToken: 'tok', + method: 'files.searchPaths', + params: { + worktree: 'id:wt-1', + query: 'app', + limit: 8, + excludePaths: ['/repo/nested'], + mode: 'quick-open' + } + }, + { signal: controller.signal } + ) + + expect(searchQuickOpenFilePaths).toHaveBeenCalledWith( + 'id:wt-1', + 'app', + 8, + ['/repo/nested'], + controller.signal + ) + expect(response).toMatchObject({ ok: true, result: { quickOpenSearchVersion: 1 } }) + }) + + it('keeps the complete paired Quick Open reply within its content budget', async () => { + const searchQuickOpenFilePaths = vi.fn().mockResolvedValue({ + worktree: 'wt-1', + rootPath: '/repo', + files: Array.from({ length: 32 }, (_, index) => ({ + relativePath: `${'x'.repeat(170_000)}-${index}.ts`, + basename: `${index}.ts`, + kind: 'text' as const + })), + totalCount: 32, + truncated: false + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + searchQuickOpenFilePaths + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const id = 'req-bounded-quick-open' + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + { + id, + authToken: 'tok', + method: 'files.searchPaths', + params: { worktree: 'id:wt-1', query: 'x', limit: 32, mode: 'quick-open' } + }, + (response) => replies.push(response), + { clientKind: 'runtime' } + ) + + const response = JSON.parse(replies.at(-1)!) as { + ok: boolean + result: { files: unknown[]; truncated: boolean } + } + expect(response.ok).toBe(true) + expect(response.result.files.length).toBeLessThan(32) + expect(response.result.truncated).toBe(true) + expect(Buffer.byteLength(JSON.stringify(response.result), 'utf8')).toBeLessThanOrEqual( + remoteRpcContentBudget(id) + ) + }) }) diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 234bbb664cd..114d4e75df8 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' +import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' import { FILE_METHODS } from './files' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -21,10 +22,13 @@ describe('file RPC methods', () => { }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const controller = new AbortController() - const response = await dispatcher.dispatch(makeRequest('files.list', { worktree: 'id:wt-1' })) + const response = await dispatcher.dispatch(makeRequest('files.list', { worktree: 'id:wt-1' }), { + signal: controller.signal + }) - expect(runtime.listMobileFiles).toHaveBeenCalledWith('id:wt-1') + expect(runtime.listMobileFiles).toHaveBeenCalledWith('id:wt-1', { signal: controller.signal }) expect(response).toMatchObject({ ok: true, result: { worktree: 'wt-1', files: [] } @@ -832,6 +836,27 @@ describe('file RPC methods', () => { expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] }) }) + it('passes the request-scoped transport budget to paired Quick Open', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const id = 'paired-quick-open-request' + const reply = vi.fn() + + await dispatcher.dispatchStreaming( + { ...makeRequest('files.listAll', { worktree: 'id:wt-1' }), id }, + reply, + { clientKind: 'runtime' } + ) + + expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', { + excludePaths: undefined, + maxContentBytes: remoteRpcContentBudget(id) + }) + }) + it('lists markdown documents for a selected worktree', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index f57c9ceb283..ecb416067a9 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -3,14 +3,21 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { runFileWatchStream } from './file-watch-stream-lifecycle' import { FILE_MUTATION_METHODS } from './files-mutation-methods' import { remoteFileContentBudget } from './files-remote-content-budget' +import { + QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS, + QUICK_OPEN_SEARCH_VERSION +} from '../../../../shared/quick-open-path-search' +import { limitQuickOpenSearchReplyBySerializedBytes } from '../../../../shared/quick-open-transport-budget' import { FileOpen, WorktreeSelector } from './files-target-schemas' import { FILE_TERMINAL_ARTIFACT_METHODS } from './files-terminal-artifact-methods' let filesWatchSubscriptionSeq = 0 const FilePathSearch = WorktreeSelector.extend({ - query: z.string().max(256).default(''), - limit: z.number().int().positive().max(32).default(16) + query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''), + limit: z.number().int().positive().max(32).default(16), + excludePaths: z.array(z.string()).optional(), + mode: z.literal('quick-open').optional() }) const ResolveTerminalPath = WorktreeSelector.extend({ @@ -95,13 +102,33 @@ export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.list', params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.listMobileFiles(params.worktree) + handler: async (params, { runtime, signal }) => + signal === undefined + ? runtime.listMobileFiles(params.worktree) + : runtime.listMobileFiles(params.worktree, { signal }) }), defineMethod({ name: 'files.searchPaths', params: FilePathSearch, - handler: async (params, { runtime }) => - runtime.searchMobileFilePaths(params.worktree, params.query, params.limit) + handler: async (params, { runtime, signal, clientKind, requestId }) => { + if (params.mode !== 'quick-open') { + return runtime.searchMobileFilePaths(params.worktree, params.query, params.limit) + } + const result = { + ...(await runtime.searchQuickOpenFilePaths( + params.worktree, + params.query, + params.limit, + params.excludePaths, + signal + )), + quickOpenSearchVersion: QUICK_OPEN_SEARCH_VERSION + } + const maxContentBytes = remoteFileContentBudget(clientKind, requestId) + return maxContentBytes === undefined + ? result + : limitQuickOpenSearchReplyBySerializedBytes(result, maxContentBytes) + } }), defineMethod({ name: 'files.open', @@ -186,8 +213,14 @@ export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.listAll', params: FileListAll, - handler: async (params, { runtime }) => - runtime.listRuntimeFiles(params.worktree, { excludePaths: params.excludePaths }) + handler: async (params, { runtime, clientKind, requestId, signal }) => { + const maxContentBytes = remoteFileContentBudget(clientKind, requestId) + return runtime.listRuntimeFiles(params.worktree, { + excludePaths: params.excludePaths, + ...(signal === undefined ? {} : { signal }), + ...(maxContentBytes === undefined ? {} : { maxContentBytes }) + }) + } }), defineMethod({ name: 'files.listMarkdownDocuments', diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 63dd919b76d..0312bc87fd0 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -117,6 +117,8 @@ export type FilesystemApi = { connectionId?: string excludePaths?: string[] requestToken?: string + maxResults?: number + searchQuery?: string }) => Promise cancelListFiles: (args: { requestToken: string }) => Promise search: (args: SearchOptions & { connectionId?: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index f851ce726b7..0c057883f59 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3385,6 +3385,8 @@ const api = { connectionId?: string excludePaths?: string[] requestToken?: string + maxResults?: number + searchQuery?: string }): Promise => ipcRenderer.invoke('fs:listFiles', args), cancelListFiles: (args: { requestToken: string }): Promise => ipcRenderer.invoke('fs:cancelListFiles', args), diff --git a/src/relay/fs-handler-install-rg.ts b/src/relay/fs-handler-install-rg.ts index 4f4be970aee..5e5eab88aaa 100644 --- a/src/relay/fs-handler-install-rg.ts +++ b/src/relay/fs-handler-install-rg.ts @@ -1,5 +1,6 @@ import { buildInstallRgMessage as buildSharedInstallRgMessage, + buildRipgrepRequiredMessage as buildSharedRipgrepRequiredMessage, detectInstallCommand, detectLinuxInstallCommandFromOsRelease } from '../shared/quick-open-install-rg' @@ -9,3 +10,7 @@ export { detectInstallCommand, detectLinuxInstallCommandFromOsRelease } export function buildInstallRgMessage(cause: unknown): Promise { return buildSharedInstallRgMessage(cause, 'remote') } + +export function buildRipgrepRequiredMessage(): Promise { + return buildSharedRipgrepRequiredMessage('remote') +} diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index b819d3ac85c..3a5368ff96c 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -122,6 +122,29 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) + it('searches the complete stream and retains only ranked fuzzy matches', async () => { + const ignoredProc = createMockProcess() + spawnMock.mockReturnValue(ignoredProc) + const promise = listFilesWithRg('/remote/root', [], { + maxResults: 2, + searchQuery: 'sct' + }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') + ;(ignoredProc.stdout as unknown as EventEmitter).emit( + 'data', + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` + ) + ;(ignoredProc.stdout as unknown as EventEmitter).emit( + 'data', + 'src/components/target.ts\nscripts/check-target.ts\n' + ) + ignoredProc.emit('close', 0, null) + + await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts']) + }) + it.each(['error-first', 'close-first'] as const)( 'tags a %s pre-spawn listing failure without starting the ignored pass', async (order) => { diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 3ec7b99026d..99703af8509 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -28,20 +28,23 @@ import { killSpawnedRipgrepProcess, RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { QuickOpenPathRanker } from '../shared/quick-open-path-search' export const LIST_FILES_TIMEOUT_MS = 25_000 export function listFilesWithRg( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal; maxResults?: number } = {} + options: { signal?: AbortSignal; maxResults?: number; searchQuery?: string } = {} ): Promise { - const { signal, maxResults } = options + const { signal, maxResults, searchQuery } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } return new Promise((resolve, reject) => { const files = new Set() + const ranker = + searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16) let done = false const children: { child: ChildProcess @@ -71,6 +74,10 @@ export function listFilesWithRg( if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { return true } + if (ranker) { + ranker.consider(relPath) + return true + } files.add(relPath) if (maxResults !== undefined && files.size >= maxResults) { finishAtLimit() @@ -266,17 +273,20 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) - const primaryPass = runPass(primary) - const passes = - maxResults === undefined - ? children[0]?.child.pid === undefined - ? primaryPass - : Promise.all([primaryPass, runPass(ignoredPass)]) - : // Why: deterministic primary-first budgeting prevents a large ignored - // tree from starving ordinary source paths on a remote host. - primaryPass.then(() => - files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() - ) + const passes = ranker + ? runPass(ignoredPass) + : (() => { + const primaryPass = runPass(primary) + return maxResults === undefined + ? children[0]?.child.pid === undefined + ? primaryPass + : Promise.all([primaryPass, runPass(ignoredPass)]) + : // Why: deterministic primary-first budgeting prevents a large ignored + // tree from starving ordinary source paths on a remote host. + primaryPass.then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) + })() passes .then(() => { @@ -285,7 +295,7 @@ export function listFilesWithRg( } done = true signal?.removeEventListener('abort', onAbort) - resolve(Array.from(files)) + resolve(ranker ? ranker.result().paths : Array.from(files)) }) .catch((err) => { if (done) { diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts index fee09f509a3..36e49f87403 100644 --- a/src/relay/fs-handler-ripgrep-fallback.test.ts +++ b/src/relay/fs-handler-ripgrep-fallback.test.ts @@ -109,4 +109,15 @@ describe('relay direct ripgrep admission', () => { expect(execFileMock).not.toHaveBeenCalled() expect(listFilesWithGitMock).not.toHaveBeenCalled() }) + + it('requires ripgrep for bounded query ranking instead of retaining a full Git inventory', async () => { + const controller = new AbortController() + listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + + await expect(runListFilesScan('/repo', [], controller.signal, 33, 'target')).rejects.toThrow( + 'Quick Open search requires ripgrep' + ) + expect(execFileMock).not.toHaveBeenCalled() + expect(listFilesWithGitMock).not.toHaveBeenCalled() + }) }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 5ca8b5bbaf5..0e13b4e0e95 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -81,6 +81,9 @@ export class FsHandler { this.dispatcher.onRequest('fs.copy', (p) => copyRelayPath(p)) this.dispatcher.onRequest('fs.realpath', (p) => realpathRelayPath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) + this.dispatcher.onRequest('fs.getCapabilities', async () => ({ + quickOpenSearchVersion: 1 + })) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => @@ -189,6 +192,10 @@ export class FsHandler { params.maxResults > 0 ? Math.min(params.maxResults, 20_001) : undefined + const searchQuery = + typeof params.searchQuery === 'string' && params.searchQuery.trim().length > 0 + ? params.searchQuery + : undefined // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -199,9 +206,10 @@ export class FsHandler { // aborting a stale scan when the workspace changes or the host cancels. return this.listFilesScans.run({ clientId: context?.clientId ?? 0, - key: JSON.stringify([rootPath, excludePathPrefixes, maxResults]), + key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery]), signal: context?.signal, - start: (signal) => runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults) + start: (signal) => + runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery) }) } diff --git a/src/relay/fs-list-files-fallback-chain.ts b/src/relay/fs-list-files-fallback-chain.ts index 5f204f1964a..d34344bafa3 100644 --- a/src/relay/fs-list-files-fallback-chain.ts +++ b/src/relay/fs-list-files-fallback-chain.ts @@ -7,25 +7,34 @@ import { throwIfFileListingCancelled } from '../shared/file-listing-cancellation' import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk' -import { buildInstallRgMessage } from './fs-handler-install-rg' +import { buildInstallRgMessage, buildRipgrepRequiredMessage } from './fs-handler-install-rg' import { buildRelayCommandEnv } from './relay-command-env' import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { QuickOpenPathRanker } from '../shared/quick-open-path-search' export async function runListFilesScan( rootPath: string, excludePathPrefixes: string[], signal: AbortSignal, - maxResults?: number + maxResults?: number, + searchQuery?: string ): Promise { throwIfFileListingCancelled(signal) try { - return await listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + return await listFilesWithRg(rootPath, excludePathPrefixes, { + signal, + maxResults, + searchQuery + }) } catch (error) { throwIfFileListingCancelled(signal) if (!(error instanceof RipgrepUnavailableError)) { throw error } } + if (searchQuery !== undefined) { + throw new Error(await buildRipgrepRequiredMessage()) + } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from // subdirectories of a checkout (e.g. /repo/packages/app added as a folder). @@ -45,7 +54,14 @@ export async function runListFilesScan( // budget errors into install-rg guidance; genuine git failures keep // their own messages. try { - return await listFilesWithGit(rootPath, excludePathPrefixes, { signal, maxResults }) + return rankFallbackFiles( + await listFilesWithGit(rootPath, excludePathPrefixes, { + signal, + maxResults: searchQuery === undefined ? maxResults : undefined + }), + searchQuery, + maxResults + ) } catch (err) { if (isQuickOpenReaddirBudgetError(err)) { throw new Error(await buildInstallRgMessage(err)) @@ -59,7 +75,14 @@ export async function runListFilesScan( // problem, so translate the opaque cap error into actionable guidance // the user can act on directly from the error toast. try { - return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal, maxResults }) + return rankFallbackFiles( + await listFilesWithReaddir(rootPath, excludePathPrefixes, { + signal, + maxResults: searchQuery === undefined ? maxResults : undefined + }), + searchQuery, + maxResults + ) } catch (err) { // Why: a cancelled scan is not an rg-availability problem; wrapping it // in install-rg guidance would surface bogus advice on the client. @@ -69,3 +92,18 @@ export async function runListFilesScan( throw new Error(await buildInstallRgMessage(err)) } } + +function rankFallbackFiles( + files: readonly string[], + query: string | undefined, + limit: number | undefined +): string[] { + if (query === undefined) { + return [...files] + } + const ranker = new QuickOpenPathRanker(query, limit ?? 16) + for (const file of files) { + ranker.consider(file) + } + return ranker.result().paths +} diff --git a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx index fa0c57dfcf8..2da4f0a97e8 100644 --- a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx +++ b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx @@ -28,7 +28,7 @@ vi.mock('@/components/quick-open-file-list', async () => { unsubscribe() } }, []) - return { files: [], loading: false, loadError: null } + return { files: [], loading: false, loadError: null, truncated: false } } } }) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index cb7c8847642..f406fab42ab 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -58,9 +58,10 @@ function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) - const { files, loading, loadError } = useRuntimeFileListForWorktree({ + const { files, loading, loadError, truncated } = useRuntimeFileListForWorktree({ enabled: visible, - worktreeId: activeWorktreeId + worktreeId: activeWorktreeId, + query: deferredQuery }) const worktreePath = activeWorktree?.path ?? null @@ -199,6 +200,14 @@ function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element ) }) )} + {truncated && !loading && !loadError ? ( +
+ {translate( + 'quickOpen.moreMatchesAvailable', + 'More matches may be available. Refine your search to narrow the results.' + )} +
+ ) : null}
diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index fa2a288d7ac..b59f08fae93 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -5,17 +5,21 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Worktree } from '../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { useRuntimeFileListForWorktree, type RuntimeFileListState } from './quick-open-file-list' +import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from './quick-open-search' const listRuntimeFilesMock = vi.hoisted(() => vi.fn()) const cancelRuntimeFileListMock = vi.hoisted(() => vi.fn()) +const searchRuntimeFilePathsMock = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/runtime-file-client', () => ({ listRuntimeFiles: listRuntimeFilesMock, - cancelRuntimeFileList: cancelRuntimeFileListMock + cancelRuntimeFileList: cancelRuntimeFileListMock, + searchRuntimeFilePaths: searchRuntimeFilePathsMock })) const initialAppState = useAppStore.getInitialState() @@ -58,16 +62,50 @@ function makeFolderWorkspace(overrides: Partial = {}): FolderWo } } +function makeRemoteWorktree(): Worktree { + return { + id: 'wt-remote', + repoId: 'repo-remote', + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1', + path: '/srv/remote', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'Remote', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +function seedRemoteWorktree(): void { + useAppStore.setState({ + settings: { ...initialAppState.settings, activeRuntimeEnvironmentId: 'env-1' }, + repos: [], + worktreesByRepo: { 'repo-remote': [makeRemoteWorktree()] } + } as Partial) +} + function HookProbe({ enabled, onState, + query, worktreeId }: { enabled: boolean onState: (state: RuntimeFileListState) => void + query?: string worktreeId: string | null }): null { - onState(useRuntimeFileListForWorktree({ enabled, worktreeId })) + onState(useRuntimeFileListForWorktree({ enabled, worktreeId, query })) return null } @@ -91,6 +129,7 @@ async function waitForListRuntimeFilesCall(): Promise { async function renderProbe(args: { enabled: boolean onState: (state: RuntimeFileListState) => void + query?: string worktreeId: string | null }): Promise { const container = document.createElement('div') @@ -108,6 +147,7 @@ beforeEach(() => { useAppStore.setState(initialAppState, true) listRuntimeFilesMock.mockReset().mockResolvedValue(['packages/app/package.json']) cancelRuntimeFileListMock.mockReset() + searchRuntimeFilePathsMock.mockReset().mockResolvedValue({ files: [], truncated: false }) }) afterEach(async () => { @@ -160,12 +200,53 @@ describe('useRuntimeFileListForWorktree', () => { { rootPath: '/srv/platform', excludePaths: undefined, - requestToken: expect.any(String) + requestToken: expect.any(String), + signal: expect.any(AbortSignal) } ) expect(states.at(-1)?.files).toEqual(['packages/app/package.json']) }) + it('routes paired folder workspace queries to the owning runtime', async () => { + vi.useFakeTimers() + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + useAppStore.setState({ + settings: { ...initialAppState.settings, activeRuntimeEnvironmentId: 'env-1' }, + folderWorkspaces: [ + makeFolderWorkspace({ connectionId: null, executionHostId: 'runtime:env-1' }) + ], + projectGroups: [makeProjectGroup({ connectionId: null, executionHostId: 'runtime:env-1' })], + repos: [], + worktreesByRepo: {} + } as Partial) + searchRuntimeFilePathsMock.mockResolvedValue({ + files: ['notes/remote-folder.md'], + truncated: false + }) + + try { + await renderProbe({ + enabled: true, + onState: () => {}, + query: 'remote-folder', + worktreeId: workspaceKey + }) + await act(async () => vi.advanceTimersByTimeAsync(120)) + + expect(searchRuntimeFilePathsMock).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: workspaceKey, + worktreePath: '/srv/platform' + }), + expect.objectContaining({ query: 'remote-folder', limit: 32 }) + ) + expect(listRuntimeFilesMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('cancels the in-flight scan with the same request token on unmount (#7721)', async () => { const workspaceKey = folderWorkspaceKey('folder-workspace-1') @@ -260,4 +341,229 @@ describe('useRuntimeFileListForWorktree', () => { expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1) expect(cancelRuntimeFileListMock).not.toHaveBeenCalled() }) + + it('searches the owning runtime after the query debounce without listing all paths', async () => { + vi.useFakeTimers() + const states: RuntimeFileListState[] = [] + seedRemoteWorktree() + searchRuntimeFilePathsMock.mockResolvedValue({ + files: ['data/sta-4354-target.ts'], + truncated: true + }) + + try { + await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + query: 'sta-4354-target', + worktreeId: 'wt-remote' + }) + + expect(searchRuntimeFilePathsMock).not.toHaveBeenCalled() + await act(async () => vi.advanceTimersByTimeAsync(120)) + await flushEffects() + + expect(searchRuntimeFilePathsMock).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-remote', + worktreePath: '/srv/remote' + }), + { + query: 'sta-4354-target', + limit: 32, + excludePaths: undefined, + signal: expect.any(AbortSignal) + } + ) + expect(listRuntimeFilesMock).not.toHaveBeenCalled() + expect(states.at(-1)).toMatchObject({ + files: ['data/sta-4354-target.ts'], + loading: false, + truncated: true + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not request a remote inventory for an empty query', async () => { + seedRemoteWorktree() + + const states: RuntimeFileListState[] = [] + await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + query: ' ', + worktreeId: 'wt-remote' + }) + + expect(searchRuntimeFilePathsMock).not.toHaveBeenCalled() + expect(listRuntimeFilesMock).not.toHaveBeenCalled() + expect(states.at(-1)).toMatchObject({ files: [], loading: false, truncated: false }) + }) + + it('does not send oversized remote queries', async () => { + seedRemoteWorktree() + const states: RuntimeFileListState[] = [] + + await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + query: 'x'.repeat(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS + 1), + worktreeId: 'wt-remote' + }) + + expect(searchRuntimeFilePathsMock).not.toHaveBeenCalled() + expect(states.at(-1)).toMatchObject({ files: [], loading: false, truncated: false }) + }) + + it('clears settled remote results when the query is cleared', async () => { + vi.useFakeTimers() + seedRemoteWorktree() + const states: RuntimeFileListState[] = [] + searchRuntimeFilePathsMock.mockResolvedValue({ + files: ['src/target.ts'], + truncated: false + }) + + try { + const root = await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + query: 'target', + worktreeId: 'wt-remote' + }) + await act(async () => vi.advanceTimersByTimeAsync(120)) + await flushEffects() + expect(states.at(-1)?.files).toEqual(['src/target.ts']) + + await act(async () => { + root.render( + createElement(HookProbe, { + enabled: true, + onState: (state: RuntimeFileListState) => states.push(state), + query: '', + worktreeId: 'wt-remote' + }) + ) + }) + await flushEffects() + + expect(searchRuntimeFilePathsMock).toHaveBeenCalledTimes(1) + expect(states.at(-1)).toMatchObject({ files: [], loading: false, truncated: false }) + } finally { + vi.useRealTimers() + } + }) + + it('preserves remote full-list callers that do not opt into query search', async () => { + seedRemoteWorktree() + listRuntimeFilesMock.mockResolvedValue(['src/existing.ts']) + + const states: RuntimeFileListState[] = [] + await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + worktreeId: 'wt-remote' + }) + await waitForListRuntimeFilesCall() + + expect(searchRuntimeFilePathsMock).not.toHaveBeenCalled() + expect(states.at(-1)?.files).toEqual(['src/existing.ts']) + }) + + it('aborts superseded runtime queries and ignores stale replies', async () => { + vi.useFakeTimers() + seedRemoteWorktree() + const states: RuntimeFileListState[] = [] + let resolveFirst!: (value: { files: string[]; truncated: boolean }) => void + let resolveSecond!: (value: { files: string[]; truncated: boolean }) => void + searchRuntimeFilePathsMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }) + ) + + try { + const root = await renderProbe({ + enabled: true, + onState: (state) => states.push(state), + query: 'tar', + worktreeId: 'wt-remote' + }) + await act(async () => vi.advanceTimersByTimeAsync(120)) + const firstSignal = searchRuntimeFilePathsMock.mock.calls[0]?.[1].signal as AbortSignal + + await act(async () => { + root.render( + createElement(HookProbe, { + enabled: true, + onState: (state: RuntimeFileListState) => states.push(state), + query: 'target', + worktreeId: 'wt-remote' + }) + ) + }) + expect(firstSignal.aborted).toBe(true) + await act(async () => vi.advanceTimersByTimeAsync(120)) + + await act(async () => { + resolveSecond({ files: ['src/target.ts'], truncated: false }) + await Promise.resolve() + }) + expect(states.at(-1)?.files).toEqual(['src/target.ts']) + + await act(async () => { + resolveFirst({ files: ['src/stale-target.ts'], truncated: true }) + await Promise.resolve() + }) + expect(states.at(-1)).toMatchObject({ + files: ['src/target.ts'], + truncated: false + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not restart local listings when only the query changes', async () => { + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [], + worktreesByRepo: {} + } as Partial) + + const root = await renderProbe({ + enabled: true, + onState: () => {}, + query: 'one', + worktreeId: workspaceKey + }) + await waitForListRuntimeFilesCall() + + await act(async () => { + root.render( + createElement(HookProbe, { + enabled: true, + onState: () => {}, + query: 'two', + worktreeId: workspaceKey + }) + ) + }) + await flushEffects() + + expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 80a8dbac6d9..04490ed7af6 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -4,7 +4,13 @@ import { useShallow } from 'zustand/react/shallow' import type { Worktree } from '../../../shared/worktree/types' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { createBrowserUuid } from '@/lib/browser-uuid' -import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client' +import { isQuickOpenRemoteQueryTooLarge } from '@/components/quick-open-search' +import { + cancelRuntimeFileList, + listRuntimeFiles, + searchRuntimeFilePaths +} from '@/runtime/runtime-file-client' +import { createRuntimeRpcAbortError } from '@/runtime/abortable-runtime-environment-call' import { useAppStore } from '@/store' import { useWorktreesForRepo } from '@/store/selectors' import type { FileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-types' @@ -18,6 +24,7 @@ export type RuntimeFileListState = { files: string[] loading: boolean loadError: string | null + truncated?: boolean operationOwner?: FileExplorerOperationOwner } @@ -26,6 +33,28 @@ export function cleanRuntimeFileListError(error: unknown): string { return raw.replace(/^Error invoking remote method '[^']+':\s*Error:\s*/, '') } +function debounceRuntimeFilePathSearch( + delayMs: number, + signal: AbortSignal, + search: () => Promise<{ files: string[]; truncated: boolean }> +): Promise<{ files: string[]; truncated: boolean }> { + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + window.clearTimeout(timer) + reject(createRuntimeRpcAbortError()) + } + const timer = window.setTimeout(() => { + signal.removeEventListener('abort', onAbort) + void search().then(resolve, reject) + }, delayMs) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + }) +} + export function isNestedWorktreePath(parentPath: string, childPath: string): boolean { const windowsPath = isWindowsAbsolutePathLike(parentPath) const parent = parentPath.replace(/[\\/]+$/, '').replace(/\\/g, '/') @@ -98,10 +127,12 @@ export function getNestedWorktreeExcludeRequest( export function useRuntimeFileListForWorktree({ enabled, - worktreeId + worktreeId, + query }: { enabled: boolean worktreeId: string | null + query?: string }): RuntimeFileListState { const worktree = useAppStore((state) => // Why: folder workspaces live behind getKnownWorktreeById, not worktreesByRepo. @@ -112,6 +143,7 @@ export function useRuntimeFileListForWorktree({ const [files, setFiles] = useState([]) const [loading, setLoading] = useState(false) const [loadError, setLoadError] = useState(null) + const [truncated, setTruncated] = useState(false) const [listedOperationOwner, setListedOperationOwner] = useState({ kind: 'unresolved' }) @@ -152,15 +184,27 @@ export function useRuntimeFileListForWorktree({ activeTargetStatus === 'connecting' || activeTargetStatus === 'deploying-relay' || activeTargetStatus === 'reconnecting' + const usesRuntimePathSearch = + (runtimeEnvironmentId !== null || connectionId !== undefined) && query !== undefined + const remoteQuery = usesRuntimePathSearch ? query.trim() : '' + const remoteQueryTooLarge = usesRuntimePathSearch && isQuickOpenRemoteQueryTooLarge(remoteQuery) const requestKey = useMemo( () => - `${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`, - [activeTargetStatus, excludeRequest.key, operationOwnerKey, worktreePath] + `${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}${usesRuntimePathSearch ? `\n${remoteQuery}` : ''}`, + [ + activeTargetStatus, + excludeRequest.key, + operationOwnerKey, + remoteQuery, + usesRuntimePathSearch, + worktreePath + ] ) useEffect(() => { if (!enabled) { setLoading(false) + setTruncated(false) setListedOperationOwner({ kind: 'unresolved' }) return } @@ -170,6 +214,7 @@ export function useRuntimeFileListForWorktree({ setListedOperationOwner({ kind: 'unresolved' }) setLoadError(operationRouteAvailable ? null : getFileExplorerOwnerUnresolvedMessage()) setLoading(false) + setTruncated(false) return } @@ -180,10 +225,20 @@ export function useRuntimeFileListForWorktree({ } lastRequestKeyRef.current = requestKey setLoadError(null) + setTruncated(false) + + if (usesRuntimePathSearch && (remoteQuery.length === 0 || remoteQueryTooLarge)) { + setFiles([]) + setLoading(false) + setListedOperationOwner(operationOwnerRef.current) + return + } + setLoading(true) const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined const requestToken = createBrowserUuid() + const requestAbortController = new AbortController() const requestOperationOwner = operationOwnerRef.current const requestContext = { settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, @@ -192,20 +247,35 @@ export function useRuntimeFileListForWorktree({ connectionId } - void listRuntimeFiles(requestContext, { - rootPath: worktreePath, - excludePaths, - requestToken - }) + const request = usesRuntimePathSearch + ? debounceRuntimeFilePathSearch(120, requestAbortController.signal, () => + searchRuntimeFilePaths(requestContext, { + query: remoteQuery, + limit: 32, + excludePaths, + ...(connectionId ? { requestToken } : {}), + signal: requestAbortController.signal + }) + ) + : listRuntimeFiles(requestContext, { + rootPath: worktreePath, + excludePaths, + requestToken, + signal: requestAbortController.signal + }).then((files) => ({ files, truncated: false })) + + void request .then((result) => { if (!cancelled) { - setFiles(result) + setFiles(result.files) + setTruncated(result.truncated) setListedOperationOwner(requestOperationOwner) } }) .catch((error) => { if (!cancelled) { setFiles([]) + setTruncated(false) setLoadError(cleanRuntimeFileListError(error)) } }) @@ -217,6 +287,7 @@ export function useRuntimeFileListForWorktree({ return () => { cancelled = true + requestAbortController.abort() // Why #7721: switching workspaces (or closing the palette) must abort // the previous full-tree scan host- and relay-side. Over SSH, abandoned // scans otherwise stack up and starve fs.readDir/fs.stat past their @@ -233,13 +304,17 @@ export function useRuntimeFileListForWorktree({ runtimeEnvironmentId, target.canList, worktreeId, - worktreePath + worktreePath, + remoteQuery, + remoteQueryTooLarge, + usesRuntimePathSearch ]) return { files, loading: loading || connectionPending, loadError, + truncated, operationOwner: listedOperationOwner } } diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index be15b7ee45c..6473242d6d0 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + QuickOpenPathRanker, QUICK_OPEN_QUERY_MAX_BYTES, QUICK_OPEN_RESULT_LIMIT, isQuickOpenQueryTooLarge, @@ -9,6 +10,18 @@ import { } from './quick-open-search' describe('quick-open-search', () => { + it('finds and retains a target after 100k non-matches without retaining the inventory', () => { + const ranker = new QuickOpenPathRanker('sta-4354-tail-target', 32) + for (let index = 0; index < 100_100; index++) { + ranker.consider(`data/chunk-${String(index).padStart(6, '0')}/payload.bin`) + } + ranker.consider('src/sta-4354-tail-target.ts') + + expect(ranker.result()).toEqual({ + paths: ['src/sta-4354-tail-target.ts'], + totalCount: 1 + }) + }) it('orders numbered paths naturally for empty queries and fuzzy-score ties', () => { const files = prepareQuickOpenFiles([ 'songs/100 - b.txt', diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index 690c59aae00..3ddd1ce9ce3 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -1,189 +1 @@ -import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' -import { compareFileNames } from '../../../shared/file-name-sort' - -export const QUICK_OPEN_RESULT_LIMIT = 50 -export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 - -export type QuickOpenIndexedFile = { - path: string - lowerPath: string - lowerFilename: string - inputIndex: number -} - -export type QuickOpenSearchResult = { - path: string - score: number -} - -export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] { - return files.map((path, inputIndex) => { - const searchPath = path.replace(/\\/g, '/') - const lastSlash = searchPath.lastIndexOf('/') - return { - path, - lowerPath: searchPath.toLowerCase(), - lowerFilename: searchPath.slice(lastSlash + 1).toLowerCase(), - inputIndex - } - }) -} - -const preparedQuickOpenFiles = new WeakMap() - -// Why: keystroke-driven callers re-classify on every character, but the index -// only changes when the file list array itself is replaced. -export function getPreparedQuickOpenFiles( - files: readonly string[] -): readonly QuickOpenIndexedFile[] { - const cached = preparedQuickOpenFiles.get(files) - if (cached) { - return cached - } - const prepared = prepareQuickOpenFiles(files) - preparedQuickOpenFiles.set(files, prepared) - return prepared -} - -export function isQuickOpenQueryTooLarge( - query: string, - maxBytes = QUICK_OPEN_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function rankQuickOpenFiles( - query: string, - files: readonly QuickOpenIndexedFile[], - limit = QUICK_OPEN_RESULT_LIMIT -): QuickOpenSearchResult[] { - if (limit <= 0) { - return [] - } - if (isQuickOpenQueryTooLarge(query)) { - return [] - } - - // Why: Quick Open presents slash-normalized paths even on Windows; users - // still naturally type backslashes in path queries. - const normalizedQuery = query.trim().replace(/\\/g, '/').toLowerCase() - if (!normalizedQuery) { - const results: QuickOpenRankedResult[] = [] - for (const file of files) { - retainTopResult(results, { path: file.path, score: 0, inputIndex: file.inputIndex }, limit) - } - return finalizeResults(results) - } - - const results: QuickOpenRankedResult[] = [] - for (const file of files) { - const score = fuzzyMatchIndexedFile(normalizedQuery, file) - if (score === -1) { - continue - } - - retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit) - } - - return finalizeResults(results) -} - -function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number { - let qi = 0 - let score = 0 - let lastMatchIdx = -1 - - for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { - if (file.lowerPath[ti] === query[qi]) { - // Preserve the existing Quick Open score semantics while avoiding - // repeated lowercase work for each candidate on every keystroke. - const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 - score += gap - if ( - ti > 0 && - (file.lowerPath[ti - 1] === '/' || - file.lowerPath[ti - 1] === '.' || - file.lowerPath[ti - 1] === '-') - ) { - score -= 5 - } - lastMatchIdx = ti - qi++ - } - } - - if (qi < query.length) { - return -1 - } - - if (file.lowerFilename.includes(query)) { - score -= 100 - } - - return score -} - -type QuickOpenRankedResult = QuickOpenSearchResult & { - inputIndex: number -} - -function retainTopResult( - heap: QuickOpenRankedResult[], - candidate: QuickOpenRankedResult, - limit: number -): void { - if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) { - return - } - - if (heap.length < limit) { - heap.push(candidate) - siftResultUp(heap, heap.length - 1) - return - } - - heap[0] = candidate - siftResultDown(heap) -} - -function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void { - let index = startIndex - while (index > 0) { - const parentIndex = Math.floor((index - 1) / 2) - if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) { - return - } - ;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]] - index = parentIndex - } -} - -function siftResultDown(heap: QuickOpenRankedResult[]): void { - let index = 0 - while (true) { - const leftIndex = index * 2 + 1 - if (leftIndex >= heap.length) { - return - } - const rightIndex = leftIndex + 1 - const worseChildIndex = - rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0 - ? rightIndex - : leftIndex - if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) { - return - } - ;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]] - index = worseChildIndex - } -} - -function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] { - return results - .sort(compareRankedResult) - .map(({ path, score }): QuickOpenSearchResult => ({ path, score })) -} - -function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number { - return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex -} +export * from '../../../shared/quick-open-path-search' diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index 0480bbcac87..2f02c7901d9 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -24,7 +24,12 @@ vi.mock('./tab-create-entry-action', () => ({ isTabEntryAbsolutePathLike: () => false })) vi.mock('../quick-open-file-list', () => ({ - useRuntimeFileListForWorktree: () => ({ files: [], loading: false, loadError: null }) + useRuntimeFileListForWorktree: () => ({ + files: [], + loading: false, + loadError: null, + truncated: false + }) })) vi.mock('@/lib/agent-catalog', () => ({ getAgentCatalog: () => [], diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx index be843eb876c..d73156d533a 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx @@ -21,7 +21,12 @@ vi.mock('./tab-create-entry-action', () => ({ isTabEntryAbsolutePathLike: () => false })) vi.mock('../quick-open-file-list', () => ({ - useRuntimeFileListForWorktree: () => ({ files: [], loading: false, loadError: null }) + useRuntimeFileListForWorktree: () => ({ + files: [], + loading: false, + loadError: null, + truncated: false + }) })) vi.mock('@/lib/agent-catalog', () => ({ getAgentCatalog: () => [], diff --git a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts index 4f164845152..399a23b1942 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts @@ -139,14 +139,14 @@ function makeBrowserPage({ worktree, repoName: 'octo/rocket', worktreeSortIndex: 0, - isCurrentPage: false, - isCurrentWorktree: true, document: buildSearchableBrowserPageDocument({ page, workspace, worktree, repoName: 'octo/rocket' - }) + }), + isCurrentPage: false, + isCurrentWorktree: true } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cf5c4ce277b..554c9a629e8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16431,5 +16431,8 @@ "settingsInstruction": "Change the model, dictation mode, or shortcut anytime in", "settingsLink": "Settings → Voice" } + }, + "quickOpen": { + "moreMatchesAvailable": "More matches may be available. Refine your search to narrow the results." } } diff --git a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts index 482a389cc1b..9126316665a 100644 --- a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { cancelRuntimeFileList, listRuntimeFiles, listRuntimeMarkdownDocuments, runtimePathExists, + searchRuntimeFilePaths, searchRuntimeFiles, statRuntimePath } from './runtime-file-client' @@ -14,8 +15,10 @@ import { fsListFiles, fsCancelListFiles, runtimeEnvironmentCall, + runtimeEnvironmentSubscribe, installRuntimeFileClientEnvironment } from './runtime-file-client-test-harness' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' installRuntimeFileClientEnvironment() @@ -121,6 +124,362 @@ describe('runtime file client', () => { }) }) + it('routes bounded quick-open queries through the owning runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + rootPath: '/remote/repo', + files: [ + { relativePath: 'data/target.ts', basename: 'target.ts', kind: 'text' }, + { relativePath: 'src/target.ts', basename: 'target.ts', kind: 'text' } + ], + totalCount: 40, + truncated: true, + quickOpenSearchVersion: 1 + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { query: 'target', limit: 32, excludePaths: ['/remote/repo/nested'] } + ) + ).resolves.toEqual({ + files: ['data/target.ts', 'src/target.ts'], + truncated: true + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.searchPaths', + params: { + worktree: 'id:wt-1', + query: 'target', + limit: 32, + excludePaths: ['/remote/repo/nested'], + mode: 'quick-open' + }, + timeoutMs: 15_000 + }) + expect(fsListFiles).not.toHaveBeenCalled() + }) + + it('routes bounded quick-open queries through an SSH-owned workspace', async () => { + fsListFiles.mockResolvedValue(['src/target.ts', 'lib/target.ts', 'extra/target.ts']) + + await expect( + searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-1' + }, + { + query: 'target', + limit: 2, + excludePaths: ['/remote/repo/nested'], + requestToken: 'quick-open-ssh-1' + } + ) + ).resolves.toEqual({ + files: ['src/target.ts', 'lib/target.ts'], + truncated: true + }) + + expect(fsListFiles).toHaveBeenCalledWith({ + rootPath: '/remote/repo', + connectionId: 'ssh-1', + excludePaths: ['/remote/repo/nested'], + requestToken: 'quick-open-ssh-1', + maxResults: 3, + searchQuery: 'target' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('falls back to one cached legacy inventory and ranks evolving queries locally', async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1 }]) + runtimeEnvironmentCall.mockImplementation(({ method }) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'rpc-search-legacy', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: files.searchPaths' }, + _meta: { runtimeId: 'legacy-runtime' } + }) + } + return Promise.resolve({ + id: 'rpc-list-legacy', + ok: true, + result: { + worktree: 'wt-1', + rootPath: '/remote/repo', + files: [ + { relativePath: 'src/target.ts', basename: 'target.ts', kind: 'text' }, + { relativePath: 'src/other.ts', basename: 'other.ts', kind: 'text' }, + { relativePath: 'nested/target.ts', basename: 'target.ts', kind: 'text' } + ], + totalCount: 7_000, + truncated: true + }, + _meta: { runtimeId: 'legacy-runtime' } + }) + }) + + await expect( + searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { + query: 'target', + limit: 32, + excludePaths: ['/remote/repo/nested'] + } + ) + ).resolves.toEqual({ files: ['src/target.ts'], truncated: true }) + + await expect( + searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { query: 'other', limit: 32 } + ) + ).resolves.toEqual({ files: ['src/other.ts'], truncated: true }) + + expect(runtimeEnvironmentCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'files.searchPaths', + 'files.list' + ]) + + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 2 }]) + await searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { query: 'target', limit: 32 } + ) + expect(runtimeEnvironmentCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'files.searchPaths', + 'files.list', + 'files.searchPaths', + 'files.list' + ]) + expect( + runtimeEnvironmentCall.mock.calls + .map(([request]) => request) + .filter((request) => request.method === 'files.list') + .map((request) => request.expectedEnvironmentPairingRevision) + ).toEqual([1, 2]) + }) + + it('keeps update guidance when a paired host lacks both search and legacy listing', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-legacy', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'legacy-runtime' } + }) + + await expect( + searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-legacy' }, + worktreeId: 'wt-legacy', + worktreePath: '/remote/repo' + }, + { query: 'target', limit: 32 } + ) + ).rejects.toThrow( + 'Quick Open search requires a newer paired Orca host. Update the remote host and reconnect.' + ) + }) + + it('re-applies exclusions for legacy runtimes that ignore quick-open fields', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'rpc-legacy-search', + ok: true, + result: { + worktree: 'wt-1', + rootPath: '/remote/repo', + files: Array.from({ length: 32 }, (_, index) => ({ + relativePath: `nested/target-${index}.ts`, + basename: `target-${index}.ts`, + kind: 'text' + })), + totalCount: 33, + truncated: true + }, + _meta: { runtimeId: 'legacy-runtime' } + }) + } + return Promise.resolve({ + id: 'rpc-legacy-list', + ok: true, + result: { + worktree: 'wt-1', + rootPath: '/remote/repo', + files: [ + { relativePath: 'src/target.ts', basename: 'target.ts', kind: 'text' }, + { relativePath: 'nested/target.ts', basename: 'target.ts', kind: 'text' } + ], + totalCount: 2, + truncated: false + }, + _meta: { runtimeId: 'legacy-runtime' } + }) + }) + + await expect( + searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { query: 'target', limit: 32, excludePaths: ['/remote/repo/nested'] } + ) + ).resolves.toEqual({ files: ['src/target.ts'], truncated: false }) + + expect(runtimeEnvironmentCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'files.searchPaths', + 'files.list' + ]) + }) + + it('cancels a legacy paired inventory scan when its only search is superseded', async () => { + const controller = new AbortController() + const unsubscribeSearch = vi.fn() + const unsubscribeList = vi.fn() + runtimeEnvironmentSubscribe.mockImplementation((request, callbacks) => { + if (request.method === 'files.searchPaths') { + callbacks.onResponse({ + id: 'rpc-legacy-search', + ok: true, + result: { + worktree: 'wt-1', + rootPath: '/remote/repo', + files: [], + totalCount: 0, + truncated: false + }, + _meta: { runtimeId: 'legacy-runtime' } + }) + return Promise.resolve({ unsubscribe: unsubscribeSearch }) + } + expect(request.method).toBe('files.list') + return Promise.resolve({ unsubscribe: unsubscribeList }) + }) + + const pending = searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { + query: 'target', + limit: 32, + excludePaths: ['/remote/repo/nested'], + signal: controller.signal + } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2)) + + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(unsubscribeList).toHaveBeenCalledTimes(1) + + const retryController = new AbortController() + const retry = searchRuntimeFilePaths( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + { + query: 'target', + limit: 32, + excludePaths: ['/remote/repo/nested'], + signal: retryController.signal + } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(4)) + retryController.abort() + await expect(retry).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('does not reuse a legacy inventory after a folder workspace root changes', async () => { + runtimeEnvironmentCall.mockImplementation(({ method, params }) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'rpc-search-legacy', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: files.searchPaths' }, + _meta: { runtimeId: 'legacy-runtime' } + }) + } + const rootPath = + runtimeEnvironmentCall.mock.calls.filter(([request]) => request.method === 'files.list') + .length === 1 + ? '/old/root' + : '/new/root' + return Promise.resolve({ + id: `rpc-list-${rootPath}`, + ok: true, + result: { + worktree: String((params as { worktree: string }).worktree), + rootPath, + files: [ + { + relativePath: rootPath === '/old/root' ? 'old-target.ts' : 'new-target.ts', + basename: rootPath === '/old/root' ? 'old-target.ts' : 'new-target.ts', + kind: 'text' + } + ], + totalCount: 1, + truncated: false + }, + _meta: { runtimeId: 'legacy-runtime' } + }) + }) + + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'folder-1' + } + await expect( + searchRuntimeFilePaths({ ...context, worktreePath: '/old/root' }, { query: 'target' }) + ).resolves.toEqual({ files: ['old-target.ts'], truncated: false }) + await expect( + searchRuntimeFilePaths({ ...context, worktreePath: '/new/root' }, { query: 'target' }) + ).resolves.toEqual({ files: ['new-target.ts'], truncated: false }) + + expect(runtimeEnvironmentCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'files.searchPaths', + 'files.list', + 'files.searchPaths', + 'files.list' + ]) + }) + it('passes the cancellation token through the IPC file listing path (#7721)', async () => { fsListFiles.mockResolvedValue(['src/index.ts']) diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index 2d4608aa9c9..a2f75dd8751 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -2,6 +2,7 @@ import { beforeEach, vi } from 'vitest' import type { Mock } from 'vitest' import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { clearLegacyQuickOpenInventoryCacheForTests } from './runtime-legacy-quick-open-inventory' import { FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, @@ -62,6 +63,7 @@ export function installRuntimeFileClientEnvironment(): void { beforeEach(() => { delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ clearRuntimeCompatibilityCacheForTests() + clearLegacyQuickOpenInventoryCacheForTests() replaceRuntimeEnvironmentRevisions([]) fsReadFile.mockReset() fsWriteFile.mockReset() diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index e6df50d1f5b..29d1e26ad96 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -12,6 +12,7 @@ import type { RuntimeFilePreviewResult, RuntimeFileReadChunkResult, RuntimeFileReadResult, + RuntimeFileListResult, RuntimeStatus } from '../../../shared/runtime-types' import { @@ -31,11 +32,19 @@ import { createEmptyRuntimeFileSearchResult, getRuntimeFileSearchRejectedField } from './runtime-file-search-bounds' +import { + buildExcludePathPrefixes, + shouldExcludeQuickOpenRelPath +} from '../../../shared/quick-open-filter' import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' import { captureRuntimeEnvironmentRequestRevision, getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { + hasCachedLegacyQuickOpenInventory, + searchLegacyQuickOpenInventory +} from './runtime-legacy-quick-open-inventory' export type RuntimeReadableFileContent = { content: string @@ -66,6 +75,9 @@ export type RuntimeFileOperationArgs = { expectedExternalSshTargetId?: string } +const QUICK_OPEN_REMOTE_UPDATE_REQUIRED_MESSAGE = + 'Quick Open search requires a newer paired Orca host. Update the remote host and reconnect.' + function assertExternalSshReadOwnership( settings: Pick | null | undefined, connectionId: string | undefined, @@ -951,7 +963,12 @@ export async function searchRuntimeFiles( export async function listRuntimeFiles( context: RuntimeFileOperationArgs, - args: { rootPath: string; excludePaths?: string[]; requestToken?: string } + args: { + rootPath: string + excludePaths?: string[] + requestToken?: string + signal?: AbortSignal + } ): Promise { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId) { @@ -969,10 +986,120 @@ export async function listRuntimeFiles( worktree: toRuntimeWorktreeSelector(context.worktreeId), excludePaths: args.excludePaths }, - { timeoutMs: 15_000 } + { timeoutMs: 15_000, ...(args.signal === undefined ? {} : { signal: args.signal }) } ) } +export async function searchRuntimeFilePaths( + context: RuntimeFileOperationArgs, + args: { + query: string + limit?: number + excludePaths?: string[] + requestToken?: string + signal?: AbortSignal + } +): Promise<{ files: string[]; truncated: boolean }> { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'environment') { + if (!context.connectionId || !context.worktreePath) { + return { files: [], truncated: false } + } + const limit = args.limit ?? 32 + const files = await window.api.fs.listFiles({ + rootPath: context.worktreePath, + connectionId: context.connectionId, + excludePaths: args.excludePaths, + requestToken: args.requestToken, + maxResults: limit + 1, + searchQuery: args.query + }) + return { files: files.slice(0, limit), truncated: files.length > limit } + } + if (!context.worktreeId) { + return { files: [], truncated: false } + } + const worktreeSelector = toRuntimeWorktreeSelector(context.worktreeId) + const limit = args.limit ?? 32 + if (hasCachedLegacyQuickOpenInventory(target, worktreeSelector, context.worktreePath)) { + return searchLegacyQuickOpenInventory({ + target, + worktreeSelector, + query: args.query, + limit, + worktreePath: context.worktreePath, + excludePaths: args.excludePaths, + signal: args.signal + }) + } + let result: RuntimeFileListResult + try { + result = await callRuntimeRpc( + target, + 'files.searchPaths', + { + worktree: worktreeSelector, + query: args.query, + limit, + excludePaths: args.excludePaths, + mode: 'quick-open' + }, + { timeoutMs: 15_000, ...(args.signal === undefined ? {} : { signal: args.signal }) } + ) + } catch (error) { + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + try { + return await searchLegacyQuickOpenInventory({ + target, + worktreeSelector, + query: args.query, + limit, + worktreePath: context.worktreePath, + excludePaths: args.excludePaths, + signal: args.signal + }) + } catch (legacyError) { + if (legacyError instanceof RuntimeRpcCallError && legacyError.code === 'method_not_found') { + throw new Error(QUICK_OPEN_REMOTE_UPDATE_REQUIRED_MESSAGE) + } + throw legacyError + } + } + throw error + } + if ( + args.excludePaths?.length && + !(typeof result.quickOpenSearchVersion === 'number' && result.quickOpenSearchVersion >= 1) + ) { + try { + return await searchLegacyQuickOpenInventory({ + target, + worktreeSelector, + query: args.query, + limit, + worktreePath: context.worktreePath, + excludePaths: args.excludePaths, + signal: args.signal + }) + } catch (legacyError) { + if (legacyError instanceof RuntimeRpcCallError && legacyError.code === 'method_not_found') { + throw new Error(QUICK_OPEN_REMOTE_UPDATE_REQUIRED_MESSAGE) + } + throw legacyError + } + } + const excludePrefixes = buildExcludePathPrefixes( + context.worktreePath ?? result.rootPath, + args.excludePaths + ) + return { + files: result.files + .map((entry) => entry.relativePath) + .filter((relativePath) => !shouldExcludeQuickOpenRelPath(relativePath, excludePrefixes)), + truncated: result.truncated + } +} + /** * Best-effort abort of an in-flight listRuntimeFiles call (#7721). Switching * workspaces must stop the previous workspace's full-tree scan — over SSH an diff --git a/src/renderer/src/runtime/runtime-legacy-quick-open-inventory.ts b/src/renderer/src/runtime/runtime-legacy-quick-open-inventory.ts new file mode 100644 index 00000000000..397830356e2 --- /dev/null +++ b/src/renderer/src/runtime/runtime-legacy-quick-open-inventory.ts @@ -0,0 +1,194 @@ +import type { RuntimeFileListResult } from '../../../shared/runtime-types' +import { + buildExcludePathPrefixes, + shouldExcludeQuickOpenRelPath +} from '../../../shared/quick-open-filter' +import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' +import { callRuntimeRpc, type RuntimeClientTarget } from './runtime-rpc-client' +import { createRuntimeRpcAbortError } from './abortable-runtime-environment-call' +import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' + +const CACHE_LIMIT = 8 +const CACHE_TTL_MS = 30_000 + +type EnvironmentTarget = Extract +type CacheEntry = { + expiresAt: number + load: Promise + controller: AbortController + activeConsumers: number + settled: boolean +} + +const inventoryCache = new Map() + +function cacheKey( + target: EnvironmentTarget, + worktreeSelector: string, + worktreePath: string | null | undefined +): string { + return JSON.stringify([ + target.environmentId, + getRuntimeEnvironmentRevision(target.environmentId) ?? 'unknown', + worktreeSelector, + worktreePath ?? null + ]) +} + +export function clearLegacyQuickOpenInventoryCacheForTests(): void { + inventoryCache.clear() +} + +export function hasCachedLegacyQuickOpenInventory( + target: EnvironmentTarget, + worktreeSelector: string, + worktreePath: string | null | undefined +): boolean { + const entry = inventoryCache.get(cacheKey(target, worktreeSelector, worktreePath)) + return entry !== undefined && entry.expiresAt > Date.now() +} + +async function loadLegacyQuickOpenInventory( + target: EnvironmentTarget, + worktreeSelector: string, + worktreePath: string | null | undefined, + signal?: AbortSignal +): Promise { + const key = cacheKey(target, worktreeSelector, worktreePath) + const now = Date.now() + const expectedEnvironmentPairingRevision = getRuntimeEnvironmentRevision(target.environmentId) + const cached = inventoryCache.get(key) + if (cached && cached.expiresAt > now) { + inventoryCache.delete(key) + inventoryCache.set(key, cached) + return awaitLegacyInventoryLoad(cached, signal) + } + inventoryCache.delete(key) + + let entry: CacheEntry + const controller = new AbortController() + if (signal?.aborted) { + controller.abort() + } + // Share one inventory request; abort it only after every caller detaches. + const load = callRuntimeRpc( + target, + 'files.list', + { worktree: worktreeSelector }, + { + timeoutMs: 15_000, + ...(signal === undefined ? {} : { signal: controller.signal }), + expectedEnvironmentPairingRevision + } + ) + .then((result) => { + entry.settled = true + entry.expiresAt = Date.now() + CACHE_TTL_MS + return result + }) + .catch((error) => { + entry.settled = true + if (inventoryCache.get(key) === entry) { + inventoryCache.delete(key) + } + throw error + }) + entry = { + expiresAt: now + CACHE_TTL_MS, + load, + controller, + activeConsumers: 0, + settled: false + } + inventoryCache.set(key, entry) + while (inventoryCache.size > CACHE_LIMIT) { + const oldest = inventoryCache.keys().next().value as string | undefined + if (!oldest) { + break + } + inventoryCache.delete(oldest) + } + return awaitLegacyInventoryLoad(entry, signal) +} + +async function awaitLegacyInventoryLoad( + entry: CacheEntry, + signal?: AbortSignal +): Promise { + if (signal?.aborted) { + throw createRuntimeRpcAbortError() + } + entry.activeConsumers += 1 + let released = false + const release = (): void => { + if (released) { + return + } + released = true + entry.activeConsumers -= 1 + if (entry.activeConsumers === 0 && !entry.settled) { + entry.expiresAt = 0 + for (const [key, cached] of inventoryCache) { + if (cached === entry) { + inventoryCache.delete(key) + break + } + } + entry.controller.abort() + } + } + if (!signal) { + return entry.load.finally(release) + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + release() + reject(createRuntimeRpcAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + entry.load.then( + (result) => { + signal.removeEventListener('abort', onAbort) + release() + resolve(result) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + release() + reject(error) + } + ) + }) +} + +export async function searchLegacyQuickOpenInventory(args: { + target: EnvironmentTarget + worktreeSelector: string + query: string + limit: number + worktreePath: string | null | undefined + excludePaths: string[] | undefined + signal?: AbortSignal +}): Promise<{ files: string[]; truncated: boolean }> { + const result = await loadLegacyQuickOpenInventory( + args.target, + args.worktreeSelector, + args.worktreePath, + args.signal + ) + const excludePrefixes = buildExcludePathPrefixes( + args.worktreePath ?? result.rootPath, + args.excludePaths + ) + const ranker = new QuickOpenPathRanker(args.query, args.limit) + for (const entry of result.files) { + if (!shouldExcludeQuickOpenRelPath(entry.relativePath, excludePrefixes)) { + ranker.consider(entry.relativePath) + } + } + const matches = ranker.result() + return { + files: matches.paths, + truncated: result.truncated || matches.totalCount > args.limit + } +} diff --git a/src/shared/quick-open-install-rg.ts b/src/shared/quick-open-install-rg.ts index 7815d7d6e34..9bd37114823 100644 --- a/src/shared/quick-open-install-rg.ts +++ b/src/shared/quick-open-install-rg.ts @@ -87,3 +87,11 @@ export async function buildInstallRgMessage( `Install ripgrep ${location} to enable fast, gitignore-aware listing: ${cmd}` ) } + +export async function buildRipgrepRequiredMessage( + host: 'local' | 'remote' = 'local' +): Promise { + const cmd = await detectInstallCommand() + const location = host === 'local' ? 'on the host running Quick Open' : 'on the remote' + return `Quick Open search requires ripgrep ${location} to stay resource-bounded. Install it with: ${cmd}` +} diff --git a/src/shared/quick-open-path-search.ts b/src/shared/quick-open-path-search.ts new file mode 100644 index 00000000000..5680abf8286 --- /dev/null +++ b/src/shared/quick-open-path-search.ts @@ -0,0 +1,217 @@ +import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { compareFileNames } from './file-name-sort' + +export const QUICK_OPEN_RESULT_LIMIT = 50 +export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 +export const QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS = 256 +export const QUICK_OPEN_SEARCH_VERSION = 1 + +export type QuickOpenIndexedFile = { + path: string + lowerPath: string + lowerFilename: string + inputIndex: number +} + +export type QuickOpenSearchResult = { + path: string + score: number +} + +export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] { + return files.map((path, inputIndex) => prepareQuickOpenFile(path, inputIndex)) +} + +const preparedQuickOpenFiles = new WeakMap() + +export function getPreparedQuickOpenFiles( + files: readonly string[] +): readonly QuickOpenIndexedFile[] { + const cached = preparedQuickOpenFiles.get(files) + if (cached) { + return cached + } + const prepared = prepareQuickOpenFiles(files) + preparedQuickOpenFiles.set(files, prepared) + return prepared +} + +export function isQuickOpenQueryTooLarge( + query: string, + maxBytes = QUICK_OPEN_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(query, maxBytes) +} + +export function isQuickOpenRemoteQueryTooLarge(query: string): boolean { + return query.length > QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS || isQuickOpenQueryTooLarge(query) +} + +export function rankQuickOpenFiles( + query: string, + files: readonly QuickOpenIndexedFile[], + limit = QUICK_OPEN_RESULT_LIMIT +): QuickOpenSearchResult[] { + if (limit <= 0 || isQuickOpenQueryTooLarge(query)) { + return [] + } + + const normalizedQuery = normalizeQuickOpenQuery(query) + const results: QuickOpenRankedResult[] = [] + for (const file of files) { + const score = normalizedQuery ? fuzzyMatchIndexedFile(normalizedQuery, file) : 0 + if (score !== -1) { + retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit) + } + } + return finalizeResults(results) +} + +export class QuickOpenPathRanker { + private readonly normalizedQuery: string | null + private readonly retained: QuickOpenRankedResult[] = [] + private inputIndex = 0 + private matchCount = 0 + + constructor( + query: string, + private readonly limit: number + ) { + this.normalizedQuery = + limit <= 0 || isQuickOpenQueryTooLarge(query) ? null : normalizeQuickOpenQuery(query) + } + + consider(path: string): void { + const file = prepareQuickOpenFile(path, this.inputIndex++) + if (this.normalizedQuery === null) { + return + } + const score = this.normalizedQuery ? fuzzyMatchIndexedFile(this.normalizedQuery, file) : 0 + if (score === -1) { + return + } + this.matchCount++ + retainTopResult( + this.retained, + { path: file.path, score, inputIndex: file.inputIndex }, + this.limit + ) + } + + result(): { paths: string[]; totalCount: number } { + return { + paths: finalizeResults(this.retained).map((result) => result.path), + totalCount: this.matchCount + } + } +} + +function normalizeQuickOpenQuery(query: string): string { + return query.trim().replace(/\\/g, '/').toLowerCase() +} + +function prepareQuickOpenFile(path: string, inputIndex: number): QuickOpenIndexedFile { + const searchPath = path.replace(/\\/g, '/') + const lastSlash = searchPath.lastIndexOf('/') + return { + path, + lowerPath: searchPath.toLowerCase(), + lowerFilename: searchPath.slice(lastSlash + 1).toLowerCase(), + inputIndex + } +} + +function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number { + let qi = 0 + let score = 0 + let lastMatchIdx = -1 + + for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { + if (file.lowerPath[ti] !== query[qi]) { + continue + } + const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 + score += gap + if ( + ti > 0 && + (file.lowerPath[ti - 1] === '/' || + file.lowerPath[ti - 1] === '.' || + file.lowerPath[ti - 1] === '-') + ) { + score -= 5 + } + lastMatchIdx = ti + qi++ + } + + if (qi < query.length) { + return -1 + } + if (file.lowerFilename.includes(query)) { + score -= 100 + } + return score +} + +type QuickOpenRankedResult = QuickOpenSearchResult & { + inputIndex: number +} + +function retainTopResult( + heap: QuickOpenRankedResult[], + candidate: QuickOpenRankedResult, + limit: number +): void { + if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) { + return + } + if (heap.length < limit) { + heap.push(candidate) + siftResultUp(heap, heap.length - 1) + return + } + heap[0] = candidate + siftResultDown(heap) +} + +function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void { + let index = startIndex + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2) + if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) { + return + } + ;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]] + index = parentIndex + } +} + +function siftResultDown(heap: QuickOpenRankedResult[]): void { + let index = 0 + while (true) { + const leftIndex = index * 2 + 1 + if (leftIndex >= heap.length) { + return + } + const rightIndex = leftIndex + 1 + const worseChildIndex = + rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0 + ? rightIndex + : leftIndex + if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) { + return + } + ;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]] + index = worseChildIndex + } +} + +function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] { + return results + .sort(compareRankedResult) + .map(({ path, score }): QuickOpenSearchResult => ({ path, score })) +} + +function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number { + return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex +} diff --git a/src/shared/quick-open-transport-budget.test.ts b/src/shared/quick-open-transport-budget.test.ts new file mode 100644 index 00000000000..1a2eff1d845 --- /dev/null +++ b/src/shared/quick-open-transport-budget.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + limitQuickOpenFilesBySerializedBytes, + limitQuickOpenSearchReplyBySerializedBytes, + serializedQuickOpenPathBytes +} from './quick-open-transport-budget' + +describe('Quick Open transport byte budget', () => { + it('matches JSON.stringify byte accounting, including escaped paths', () => { + const path = 'src/line\nquote".ts' + expect(serializedQuickOpenPathBytes(path)).toBe(Buffer.byteLength(JSON.stringify(path), 'utf8')) + }) + + it('returns the largest prefix that fits as a JSON array', () => { + const files = ['src/a.ts', 'data/ignored\nfile.bin', 'src/b.ts'] + const budget = Buffer.byteLength(JSON.stringify(files.slice(0, 2)), 'utf8') + expect(limitQuickOpenFilesBySerializedBytes(files, budget)).toEqual(files.slice(0, 2)) + }) + + it('drops the lowest-ranked reply entries until the complete result fits', () => { + const result = { + worktree: 'wt-1', + rootPath: '/repo', + files: [ + { relativePath: 'src/b.ts', basename: 'b.ts', kind: 'text' as const }, + { relativePath: 'src/a.ts', basename: 'a.ts', kind: 'text' as const } + ], + totalCount: 2, + truncated: false + } + const budget = Buffer.byteLength( + JSON.stringify({ ...result, files: result.files.slice(0, 1) }), + 'utf8' + ) + expect(limitQuickOpenSearchReplyBySerializedBytes(result, budget).files).toHaveLength(1) + expect(limitQuickOpenSearchReplyBySerializedBytes(result, budget).truncated).toBe(true) + }) +}) diff --git a/src/shared/quick-open-transport-budget.ts b/src/shared/quick-open-transport-budget.ts new file mode 100644 index 00000000000..569a871fa6d --- /dev/null +++ b/src/shared/quick-open-transport-budget.ts @@ -0,0 +1,46 @@ +/** Byte accounting for the JSON array returned by files.listAll. */ +export function serializedQuickOpenPathBytes(path: string): number { + return Buffer.byteLength(JSON.stringify(path), 'utf8') +} + +type QuickOpenSearchReply = { + files: readonly unknown[] + totalCount: number + truncated: boolean +} + +/** Keep a ranked Quick Open RPC reply below the request-scoped content ceiling. */ +export function limitQuickOpenSearchReplyBySerializedBytes( + result: T, + maxBytes: number +): T { + if (Buffer.byteLength(JSON.stringify(result), 'utf8') <= maxBytes) { + return result + } + + for (let length = result.files.length - 1; length >= 0; length -= 1) { + const bounded = { ...result, files: result.files.slice(0, length), truncated: true } as T + if (Buffer.byteLength(JSON.stringify(bounded), 'utf8') <= maxBytes) { + return bounded + } + } + + throw new Error('Quick Open search result exceeds the remote transport budget') +} + +export function limitQuickOpenFilesBySerializedBytes( + files: readonly string[], + maxBytes: number +): string[] { + const bounded: string[] = [] + let serializedBytes = 2 // [] + for (const path of files) { + const nextBytes = serializedQuickOpenPathBytes(path) + (bounded.length === 0 ? 0 : 1) + if (serializedBytes + nextBytes > maxBytes) { + break + } + bounded.push(path) + serializedBytes += nextBytes + } + return bounded +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index e3d6b2341ee..8e009201af2 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -381,6 +381,7 @@ export type RuntimeFileListResult = { files: RuntimeFileListEntry[] totalCount: number truncated: boolean + quickOpenSearchVersion?: number } export type RuntimeFileOpenResult = { diff --git a/tests/e2e/helpers/paired-quick-open-large-tree-fixture.ts b/tests/e2e/helpers/paired-quick-open-large-tree-fixture.ts new file mode 100644 index 00000000000..5455eaa0e3b --- /dev/null +++ b/tests/e2e/helpers/paired-quick-open-large-tree-fixture.ts @@ -0,0 +1,101 @@ +import { execFileSync } from 'node:child_process' +import { + closeSync, + mkdirSync, + mkdtempSync, + openSync, + renameSync, + rmSync, + truncateSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export const LARGE_TREE_FILE_COUNT = 88_763 +export const LARGE_TREE_IGNORED_FILE_COUNT = 77_792 +export const LARGE_TREE_NOMINAL_IGNORED_BYTES = 22 * 1024 * 1024 * 1024 + +const FILES_PER_DIRECTORY = 256 +const LONG_STEM = 'remote-quick-open-transport-budget-path-'.repeat(2) + +function createFiles(root: string, directoryName: string, count: number, extension: string): void { + for (let start = 0; start < count; start += FILES_PER_DIRECTORY) { + const directory = path.join(root, directoryName, `chunk-${String(start).padStart(6, '0')}`) + mkdirSync(directory, { recursive: true }) + const end = Math.min(start + FILES_PER_DIRECTORY, count) + for (let index = start; index < end; index++) { + closeSync( + openSync( + path.join(directory, `${LONG_STEM}${String(index).padStart(6, '0')}.${extension}`), + 'w' + ) + ) + } + } +} + +export type PairedQuickOpenLargeTreeFixture = { + dispose: () => void + gitIgnoredTargetPath: string + orcaIgnoredTargetPath: string + root: string +} + +export function createPairedQuickOpenLargeTreeFixture(): PairedQuickOpenLargeTreeFixture { + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-quick-open-large-tree-')) + try { + execFileSync('git', ['init'], { cwd: root, stdio: 'pipe' }) + writeFileSync(path.join(root, '.gitignore'), 'data/\n') + writeFileSync(path.join(root, '.orcaignore'), 'orca-ignored/\n') + + const visibleFileCount = LARGE_TREE_FILE_COUNT - LARGE_TREE_IGNORED_FILE_COUNT - 3 + createFiles(root, 'src', visibleFileCount, 'ts') + createFiles(root, 'data', LARGE_TREE_IGNORED_FILE_COUNT, 'bin') + const lastIgnoredIndex = LARGE_TREE_IGNORED_FILE_COUNT - 1 + const lastIgnoredChunk = + Math.floor(lastIgnoredIndex / FILES_PER_DIRECTORY) * FILES_PER_DIRECTORY + const ignoredDirectory = `chunk-${String(lastIgnoredChunk).padStart(6, '0')}` + const gitIgnoredTargetPath = `data/${ignoredDirectory}/sta-4354-gitignored-target.bin` + renameSync( + path.join( + root, + 'data', + ignoredDirectory, + `${LONG_STEM}${String(lastIgnoredIndex).padStart(6, '0')}.bin` + ), + path.join(root, ...gitIgnoredTargetPath.split('/')) + ) + const orcaIgnoredTargetPath = 'orca-ignored/sta-4354-orcaignore-target.ts' + mkdirSync(path.join(root, 'orca-ignored')) + closeSync(openSync(path.join(root, ...orcaIgnoredTargetPath.split('/')), 'w')) + truncateSync( + path.join(root, 'data', 'chunk-000000', `${LONG_STEM}000000.bin`), + LARGE_TREE_NOMINAL_IGNORED_BYTES + ) + + execFileSync('git', ['add', '.gitignore', '.orcaignore'], { cwd: root, stdio: 'pipe' }) + execFileSync( + 'git', + [ + '-c', + 'user.name=Orca E2E', + '-c', + 'user.email=orca-e2e@example.invalid', + 'commit', + '-m', + 'seed large-tree fixture' + ], + { cwd: root, stdio: 'pipe' } + ) + return { + root, + gitIgnoredTargetPath, + orcaIgnoredTargetPath, + dispose: () => rmSync(root, { recursive: true, force: true }) + } + } catch (error) { + rmSync(root, { recursive: true, force: true }) + throw error + } +} diff --git a/tests/e2e/paired-quick-open-large-tree.spec.ts b/tests/e2e/paired-quick-open-large-tree.spec.ts new file mode 100644 index 00000000000..36184e990b3 --- /dev/null +++ b/tests/e2e/paired-quick-open-large-tree.spec.ts @@ -0,0 +1,208 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { createPairedQuickOpenLargeTreeFixture } from './helpers/paired-quick-open-large-tree-fixture' +import type { PairedQuickOpenLargeTreeFixture } from './helpers/paired-quick-open-large-tree-fixture' +import { waitForSessionReady } from './helpers/store' + +async function activateWorktree(page: Page, repoPath: string, timeout = 60_000): Promise { + await expect + .poll( + () => + page.evaluate( + (targetPath) => + window.__store + ?.getState() + .allWorktrees() + .find((worktree) => { + const normalize = (value: string): string => + value.startsWith('/private/var/') ? value.slice('/private'.length) : value + return normalize(worktree.path) === normalize(targetPath) + })?.id ?? null, + repoPath + ), + { timeout, message: 'paired client never received the large-tree worktree' } + ) + .not.toBeNull() + return page.evaluate((targetPath) => { + const state = window.__store?.getState() + const worktree = state?.allWorktrees().find((entry) => { + const normalize = (value: string): string => + value.startsWith('/private/var/') ? value.slice('/private'.length) : value + return normalize(entry.path) === normalize(targetPath) + }) + if (!state || !worktree) { + throw new Error('large-tree worktree is unavailable') + } + state.setActiveRepo(worktree.repoId) + state.setActiveWorktree(worktree.id) + return worktree.id + }, repoPath) +} + +async function openQuickOpen(page: Page): Promise { + await page.evaluate(() => window.__store?.getState().openModal('quick-open')) + await expect(page.getByRole('dialog', { name: 'Go to file' })).toBeVisible() +} + +async function expectQuickOpenAndRuntimeHealthy( + client: PairedElectronClient, + worktreeId: string, + fixture: PairedQuickOpenLargeTreeFixture +): Promise { + const dialog = client.page.getByRole('dialog', { name: 'Go to file' }) + const input = dialog.getByPlaceholder('Go to file...') + for (const targetPath of [fixture.gitIgnoredTargetPath, fixture.orcaIgnoredTargetPath]) { + const filename = targetPath.split('/').at(-1)! + const stat = await client.page.evaluate( + async ({ environmentId, worktreeId, targetPath }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'files.stat', + params: { worktree: `id:${worktreeId}`, relativePath: targetPath } + }) + if (!response.ok) { + throw new Error(`files.stat oracle failed: ${JSON.stringify(response)}`) + } + return response.result + }, + { environmentId: client.environmentId, worktreeId, targetPath } + ) + expect(stat.isDirectory).toBe(false) + expect(stat.size).toBeGreaterThanOrEqual(0) + await input.fill(filename.slice(0, 8)) + await input.fill(filename.slice(0, 18)) + await input.fill(filename) + await expect(dialog.getByText('Loading files...')).toHaveCount(0, { timeout: 60_000 }) + await expect(dialog.getByRole('option').filter({ hasText: filename })).toHaveCount(1) + await expect(dialog).not.toContainText('Outbound reply buffer overflow') + await expect(dialog).not.toContainText('Remote Orca runtime closed the connection') + } + + const response = await client.page.evaluate( + async ({ environmentId }) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'worktree.list', + params: { limit: 10_000 } + }), + { environmentId: client.environmentId } + ) + expect(response.ok).toBe(true) + if (response.ok) { + expect(response.result.worktrees.some((worktree) => worktree.id === worktreeId)).toBe(true) + } + + const queryOracle = async (targetPath: string) => + client.page.evaluate( + async ({ environmentId, worktreeId, targetPath }) => { + const query = targetPath.split('/').at(-1)! + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'files.searchPaths', + params: { worktree: `id:${worktreeId}`, query, limit: 32, mode: 'quick-open' } + }) + if (!response.ok) { + throw new Error(`files.searchPaths oracle failed: ${JSON.stringify(response)}`) + } + const oracle = { + files: response.result.files.map((file) => file.relativePath), + totalCount: response.result.totalCount, + truncated: response.result.truncated + } + const encodedOracle = new TextEncoder().encode(JSON.stringify(oracle)) + const digest = await crypto.subtle.digest('SHA-256', encodedOracle) + return { + ...oracle, + oracleByteLength: encodedOracle.byteLength, + wireByteLength: new TextEncoder().encode(JSON.stringify(response.result)).byteLength, + sha256: Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + } + }, + { environmentId: client.environmentId, worktreeId, targetPath } + ) + for (const targetPath of [fixture.gitIgnoredTargetPath, fixture.orcaIgnoredTargetPath]) { + const queryResult = await queryOracle(targetPath) + const repeatedResult = await queryOracle(targetPath) + console.log( + `[STA-4354] query=${targetPath} oracle=${JSON.stringify(queryResult)} repeated=${JSON.stringify(repeatedResult)}` + ) + expect(queryResult).toEqual(repeatedResult) + expect(queryResult.files).toEqual([targetPath]) + expect(queryResult.totalCount).toBe(1) + expect(queryResult.truncated).toBe(false) + expect(queryResult.wireByteLength).toBeLessThan(4_096) + expect(queryResult).toMatchObject( + targetPath === fixture.gitIgnoredTargetPath + ? { + oracleByteLength: 95, + sha256: 'a6259174bf63bccb04ec461aed9b316f8ef78fe0474a6a101d4f11a383414125' + } + : { + oracleByteLength: 89, + sha256: '9e7399e32001d443105e0f612f0eb2eee88fba50902dc75c926c547538e93eb5' + } + ) + } +} + +test('finds paths beyond the old prefix on a headed paired runtime @headful', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(240_000) + const fixture = createPairedQuickOpenLargeTreeFixture() + let client: PairedElectronClient | null = null + try { + await waitForSessionReady(orcaPage) + await orcaPage.evaluate(async (repoPath) => { + const store = window.__store + if (!store || !(await store.getState().addRepoPath(repoPath))) { + throw new Error('headed host could not add the large-tree repo') + } + }, fixture.root) + client = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(orcaPage), + testInfo, + 'STA-4354 headed host' + ) + const worktreeId = await activateWorktree(client.page, fixture.root) + await openQuickOpen(client.page) + await expectQuickOpenAndRuntimeHealthy(client, worktreeId, fixture) + } finally { + await client?.dispose() + fixture.dispose() + } +}) + +test('finds paths beyond the old prefix on a headless paired runtime', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires fixture destructuring. +{}, testInfo) => { + test.setTimeout(240_000) + const fixture = createPairedQuickOpenLargeTreeFixture() + const host = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: fixture.root, + kind: 'git' + }) + await host.client.call('repo.update', { + repo: `id:${added.result.repo.id}`, + updates: { externalWorktreeVisibility: 'show' } + }) + client = await launchPairedElectronClient(host.offer, testInfo, 'STA-4354 headless host') + const worktreeId = await activateWorktree(client.page, fixture.root, 180_000) + await openQuickOpen(client.page) + await expectQuickOpenAndRuntimeHealthy(client, worktreeId, fixture) + } finally { + await client?.dispose() + await host.dispose() + fixture.dispose() + } +}) From 12550fcc283431fcfa4aead8cb58db9a2cb291ed Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:50:33 -0700 Subject: [PATCH 008/168] refactor(test): move shell wrapper fixtures where they read as test data (#15363) --- .gitattributes | 4 ++ .../shell-wrapper-snapshots/README.md | 37 +++++++++++++++++++ .../daemon-bash-rcfile.txt | 0 .../daemon-fish-init.txt | 0 .../daemon-zsh-zlogin.txt | 0 .../daemon-zsh-zprofile.txt | 0 .../daemon-zsh-zshenv.txt | 0 .../daemon-zsh-zshrc.txt | 0 .../local-bash-rcfile.txt | 0 .../local-fish-init.txt | 0 .../local-zsh-zlogin.txt | 0 .../local-zsh-zprofile.txt | 0 .../local-zsh-zshenv.txt | 0 .../local-zsh-zshrc.txt | 0 .../relay-bash-rcfile.txt | 0 .../relay-zsh-zlogin.txt | 0 .../relay-zsh-zprofile.txt | 0 .../relay-zsh-zshenv.txt | 0 .../relay-zsh-zshrc.txt | 0 ...ll-wrapper-generated-file-snapshot.test.ts | 5 ++- 20 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/main/__tests__/shell-wrapper-snapshots/README.md rename src/main/{ => __tests__}/shell-wrapper-snapshots/daemon-bash-rcfile.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/daemon-fish-init.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/daemon-zsh-zlogin.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/daemon-zsh-zprofile.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/daemon-zsh-zshenv.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/daemon-zsh-zshrc.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/local-bash-rcfile.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/local-fish-init.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/local-zsh-zlogin.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/local-zsh-zprofile.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/local-zsh-zshenv.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/local-zsh-zshrc.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/relay-bash-rcfile.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/relay-zsh-zlogin.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/relay-zsh-zprofile.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/relay-zsh-zshenv.txt (100%) rename src/main/{ => __tests__}/shell-wrapper-snapshots/relay-zsh-zshrc.txt (100%) diff --git a/.gitattributes b/.gitattributes index cbd3a039dc0..c58a3c5aa36 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,7 @@ # source patch under xterm-src/ instead. The sibling patches stay diffable. /config/patches/@xterm__xterm@*.patch -diff /config/patches/xterm-src/*.patch text eol=lf +# Generated wrapper fixtures: collapse them in the PR diff so they stop drowning +# the reviewable change, and pin LF because they are compared byte-for-byte. +# Not -diff: the shell diff is the review surface when a wrapper does change. +/src/main/__tests__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf diff --git a/src/main/__tests__/shell-wrapper-snapshots/README.md b/src/main/__tests__/shell-wrapper-snapshots/README.md new file mode 100644 index 00000000000..b3c5e66d20d --- /dev/null +++ b/src/main/__tests__/shell-wrapper-snapshots/README.md @@ -0,0 +1,37 @@ +# Shell wrapper snapshots + +Generated test fixtures. **Do not edit by hand.** + +Each `.txt` here is the byte-exact content of one shell startup file Orca writes +into a pane's wrapper `ZDOTDIR`, captured per transport: + +| | | +| :--- | :--- | +| `local-*` | local PTY | +| `daemon-*` | daemon / SSH host | +| `relay-*` | relay overlay | + +Owned by [`../../shell-wrapper-generated-file-snapshot.test.ts`](../../shell-wrapper-generated-file-snapshot.test.ts), +which drives the real wrapper entry points, reads the files back off disk and +compares them here. The temp wrapper root is normalized to `` — +the only path-dependent bytes in the output. + +## Why these exist + +The three zsh generators were once copy-pasted and drifted, so a fix landed in +one transport and silently missed the other two. These pin all three at once, so +drift shows up as a reviewable shell diff instead of hiding in a TypeScript +template literal. + +## Regenerating + +Run the test and review the resulting diff: + +```sh +npx vitest run --config config/vitest.config.ts src/main/shell-wrapper-generated-file-snapshot.test.ts +``` + +⚠️ `toMatchFileSnapshot` **rewrites these files on mismatch when run locally** — +only CI fails. So a passing local run proves nothing on its own: always +`git diff` this directory afterwards, and treat any change as a real behavior +change to what users' shells execute until you have explained it. diff --git a/src/main/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__tests__/shell-wrapper-snapshots/daemon-bash-rcfile.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/daemon-bash-rcfile.txt rename to src/main/__tests__/shell-wrapper-snapshots/daemon-bash-rcfile.txt diff --git a/src/main/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__tests__/shell-wrapper-snapshots/daemon-fish-init.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/daemon-fish-init.txt rename to src/main/__tests__/shell-wrapper-snapshots/daemon-fish-init.txt diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/daemon-zsh-zlogin.txt rename to src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/daemon-zsh-zprofile.txt rename to src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/daemon-zsh-zshenv.txt rename to src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt diff --git a/src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/daemon-zsh-zshrc.txt rename to src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt diff --git a/src/main/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__tests__/shell-wrapper-snapshots/local-bash-rcfile.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/local-bash-rcfile.txt rename to src/main/__tests__/shell-wrapper-snapshots/local-bash-rcfile.txt diff --git a/src/main/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__tests__/shell-wrapper-snapshots/local-fish-init.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/local-fish-init.txt rename to src/main/__tests__/shell-wrapper-snapshots/local-fish-init.txt diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zlogin.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/local-zsh-zlogin.txt rename to src/main/__tests__/shell-wrapper-snapshots/local-zsh-zlogin.txt diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zprofile.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/local-zsh-zprofile.txt rename to src/main/__tests__/shell-wrapper-snapshots/local-zsh-zprofile.txt diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshenv.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/local-zsh-zshenv.txt rename to src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshenv.txt diff --git a/src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshrc.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/local-zsh-zshrc.txt rename to src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshrc.txt diff --git a/src/main/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__tests__/shell-wrapper-snapshots/relay-bash-rcfile.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/relay-bash-rcfile.txt rename to src/main/__tests__/shell-wrapper-snapshots/relay-bash-rcfile.txt diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zlogin.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/relay-zsh-zlogin.txt rename to src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zlogin.txt diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zprofile.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/relay-zsh-zprofile.txt rename to src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zprofile.txt diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshenv.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/relay-zsh-zshenv.txt rename to src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshenv.txt diff --git a/src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshrc.txt similarity index 100% rename from src/main/shell-wrapper-snapshots/relay-zsh-zshrc.txt rename to src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshrc.txt diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index a5c0548f43a..bcf1d7e7483 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -4,6 +4,9 @@ * * Why: the zsh generators were unified behind one builder; these fixtures were * captured from the pre-unification code so any drift shows up as a diff. + * + * Fixtures live in ./__tests__/shell-wrapper-snapshots/ — see the README there + * before accepting a rewrite; a local run updates them silently. */ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -33,7 +36,7 @@ const WRAPPER_FILES = [ ['bash-rcfile', join('bash', 'rcfile')] ] as const -const SNAPSHOT_DIR = join(__dirname, 'shell-wrapper-snapshots') +const SNAPSHOT_DIR = join(__dirname, '__tests__', 'shell-wrapper-snapshots') // Why: the wrapper root is a temp dir per run, and the baked ZDOTDIR literal is // the only path-dependent byte in the output; pin it to a stable placeholder. From f71ef7ee03042b28cdbc5352351ebedda6d0e6f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:08:25 -0700 Subject: [PATCH 009/168] fix(sidebar): clear the delete state when a removal is refused (#15187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): clear the delete state when a removal is refused Callers mark rows deleting up front for immediate sidebar feedback, but a refusal in beginHostQualifiedRemoval returns before removeWorktree's try/catch — the only other place that clears the flag. The failure toast auto-dismisses after 10s, so the workspace was left on a 'Deleting…' spinner indefinitely with no explanation still on screen. Also removes workspace-cleanup-removal-host-guard.ts: #14731 refused a colliding cleanup removal, #15013 replaced refusing with routing to the confirmed host, and nothing imports the module any more — not even its own tests. * test(sidebar): pin the two states that actually reach the removal refusal The existing case used an empty store, which proved the clear but not that a user can land there. Cover a stale folder-workspace id and a hostless row under an ambiguous legacy runtime — both resolve to no route from populated state. --------- Co-authored-by: QA --- .../workspace-cleanup-removal-host-guard.ts | 43 ------- .../host-qualified-removal-refusal.test.ts | 110 ++++++++++++++++++ .../host-qualified-worktree-removal.ts | 7 ++ 3 files changed, 117 insertions(+), 43 deletions(-) delete mode 100644 src/renderer/src/store/slices/workspace-cleanup-removal-host-guard.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-host-guard.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-host-guard.ts deleted file mode 100644 index 30d55d66d6b..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-host-guard.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * STA-4343 fail-closed guard: cleanup must never issue a destructive removal - * against a host other than the one whose row the user confirmed. - * - * A cleanup row's `worktreeId` is `repoId::path`, which two execution hosts can - * both own, while selection, confirmation and preflight all key on `worktreeId` - * alone and removal routing prefers the ACTIVE workspace's host. This is the - * minimal safety property: refuse whenever the confirmed owner is unknown, is - * not the only owner the refreshed scan reports, or is not where the removal - * would actually land. Routing a colliding row to its right host is #14606. - */ -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { resolveWorkspaceCleanupRemovalHostId } from '../../../../shared/workspace-cleanup-host-identity' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' - -/** True only when the removal is provably about to land on the confirmed row's own host. */ -export function isWorkspaceCleanupRemovalHostCertain(args: { - /** The row the user confirmed; internal callers without one fall back to the scanned row. */ - confirmedCandidate: WorkspaceCleanupCandidate | undefined - scannedCandidate: WorkspaceCleanupCandidate - /** Every owner the refreshed scan reported for this `worktreeId`, null where a row carries none. */ - scannedHostIds: readonly (ExecutionHostId | null)[] - /** - * The host `removeWorktree` would route the destructive IPC to. Null is not a - * refusal here: `removeWorktree` already fails an unroutable removal closed - * with WORKTREE_REMOVAL_AMBIGUOUS_ERROR before it touches any transport. - */ - routeHostId: ExecutionHostId | null -}): boolean { - const confirmedHostId = resolveWorkspaceCleanupRemovalHostId( - args.confirmedCandidate ?? args.scannedCandidate - ) - if (!confirmedHostId) { - return false - } - // Why: one id owned by two hosts cannot say which row was confirmed, and a - // confirmed owner the refreshed scan no longer reports cannot be verified either. - const scannedHostIds = new Set(args.scannedHostIds) - if (scannedHostIds.size !== 1 || !scannedHostIds.has(confirmedHostId)) { - return false - } - return args.routeHostId === null || args.routeHostId === confirmedHostId -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts new file mode 100644 index 00000000000..ae2d3c31871 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { beginHostQualifiedRemoval } from './host-qualified-worktree-removal' +import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' + +const WORKTREE_ID = 'repo1::/shared/workspace/path' + +/** + * Callers mark rows deleting up front for immediate sidebar feedback + * (worktree-delete-execution.ts). These refusals return BEFORE removeWorktree's + * try/catch, which is the only other place that clears the flag — so without an + * explicit clear the workspace keeps its "Deleting…" spinner indefinitely, long + * after the 10s failure toast has gone. + */ +// Note: with a confirmed host the route resolves from the host id alone, so this refusal is +// reached on the UNQUALIFIED path — a caller that names no host and has no resolvable owner. +describe('beginHostQualifiedRemoval refusals clear the delete state', () => { + function makeGet(clearWorktreeDeleteState: ReturnType) { + // Minimal store surface: no worktrees, so no route can resolve for MISSING_HOST. + return () => + ({ + clearWorktreeDeleteState, + allWorktrees: () => [], + worktreesByRepo: {}, + repos: [], + detectedWorktreesByRepo: {}, + settings: {}, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + workspaceCleanupScan: null + }) as never + } + + it('clears when no route resolves and no host was confirmed', () => { + const clearWorktreeDeleteState = vi.fn() + const start = beginHostQualifiedRemoval( + makeGet(clearWorktreeDeleteState), + WORKTREE_ID, + null, + false + ) + + expect(start.ok).toBe(false) + expect(clearWorktreeDeleteState).toHaveBeenCalledWith(WORKTREE_ID) + }) + + // The store above is deliberately empty, which proves the clear but not that a real user can + // land here. These two cases route through populated state a user actually has, and still + // resolve to no route — the states that leave the row spinning in the product. + function makeRoutedGet( + clearWorktreeDeleteState: ReturnType, + overrides: Record + ) { + return () => + ({ + clearWorktreeDeleteState, + allWorktrees: () => [], + worktreesByRepo: {}, + repos: [], + detectedWorktreesByRepo: {}, + settings: {}, + runtimeEnvironments: [], + folderWorkspaces: [], + projectGroups: [], + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + workspaceCleanupScan: null, + ...overrides + }) as never + } + + // A hostless row (folder-workspace meta never sets hostId, and runtime rows omit the field + // when the repo is unresolved) plus more than one saved runtime environment trips the legacy + // single-runtime gate in resolveWorktreeOperationRouteResult, which returns `missing`. + it('clears when a known worktree has no host and the legacy runtime is ambiguous', () => { + const clearWorktreeDeleteState = vi.fn() + const start = beginHostQualifiedRemoval( + makeRoutedGet(clearWorktreeDeleteState, { + repos: [{ id: 'repo1', connectionId: null, executionHostId: undefined }], + worktreesByRepo: { repo1: [{ id: WORKTREE_ID, repoId: 'repo1' }] }, + settings: { activeRuntimeEnvironmentId: 'env-a' }, + runtimeEnvironments: [{ id: 'env-a' }, { id: 'env-b' }] + }), + WORKTREE_ID, + null, + false + ) + + expect(start.ok).toBe(false) + expect(clearWorktreeDeleteState).toHaveBeenCalledWith(WORKTREE_ID) + }) + + // Folder workspaces fail closed on a stale id by design, so a row whose folder record is gone + // refuses instead of routing. + it('clears when a folder workspace id no longer has an owner', () => { + const clearWorktreeDeleteState = vi.fn() + const folderId = folderWorkspaceKey('fw-removed') + const start = beginHostQualifiedRemoval( + makeRoutedGet(clearWorktreeDeleteState, { + repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }], + folderWorkspaces: [] + }), + folderId, + null, + false + ) + + expect(start.ok).toBe(false) + expect(clearWorktreeDeleteState).toHaveBeenCalledWith(folderId) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts index 86bc2d62f80..7f7eb8d4158 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts @@ -78,6 +78,12 @@ export function beginHostQualifiedRemoval( resolveHostQualifiedRemovalRoute(get, worktreeId, requiredExecutionHostId) const removalRoute = resolveRemovalRoute() if (!removalRoute && (!forgetLocalOnly || !requiredExecutionHostId)) { + // Why: callers mark rows deleting up front for immediate sidebar feedback + // (worktree-delete-execution.ts), and a refusal returns before the try/catch that + // would otherwise clear it. The failure toast auto-dismisses after 10s, so without + // this the workspace sits on a "Deleting…" spinner forever with no explanation left + // on screen. + get().clearWorktreeDeleteState(worktreeId) return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR } } // Fail closed rather than delete on a host the caller never confirmed. @@ -86,6 +92,7 @@ export function beginHostQualifiedRemoval( removalRoute && removalRoute.executionHostId !== requiredExecutionHostId ) { + get().clearWorktreeDeleteState(worktreeId) return { ok: false, error: WORKTREE_REMOVAL_HOST_CHANGED_ERROR } } const sameIdSurvivingHostId = resolveSameIdSurvivingHostId( From 487e43d61965f00c9874ba220e562e968730944a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:09:12 -0700 Subject: [PATCH 010/168] refactor(test): use __fixtures__ for wrapper snapshots and teach the LoC bot (#15365) --- .gitattributes | 2 +- .github/scripts/pr-test-loc-table.mjs | 5 ++++- config/scripts/pr-test-loc-summary.test.mjs | 11 +++++++++++ .../shell-wrapper-snapshots/README.md | 0 .../shell-wrapper-snapshots/daemon-bash-rcfile.txt | 0 .../shell-wrapper-snapshots/daemon-fish-init.txt | 0 .../shell-wrapper-snapshots/daemon-zsh-zlogin.txt | 0 .../shell-wrapper-snapshots/daemon-zsh-zprofile.txt | 0 .../shell-wrapper-snapshots/daemon-zsh-zshenv.txt | 0 .../shell-wrapper-snapshots/daemon-zsh-zshrc.txt | 0 .../shell-wrapper-snapshots/local-bash-rcfile.txt | 0 .../shell-wrapper-snapshots/local-fish-init.txt | 0 .../shell-wrapper-snapshots/local-zsh-zlogin.txt | 0 .../shell-wrapper-snapshots/local-zsh-zprofile.txt | 0 .../shell-wrapper-snapshots/local-zsh-zshenv.txt | 0 .../shell-wrapper-snapshots/local-zsh-zshrc.txt | 0 .../shell-wrapper-snapshots/relay-bash-rcfile.txt | 0 .../shell-wrapper-snapshots/relay-zsh-zlogin.txt | 0 .../shell-wrapper-snapshots/relay-zsh-zprofile.txt | 0 .../shell-wrapper-snapshots/relay-zsh-zshenv.txt | 0 .../shell-wrapper-snapshots/relay-zsh-zshrc.txt | 0 .../shell-wrapper-generated-file-snapshot.test.ts | 4 ++-- 22 files changed, 18 insertions(+), 4 deletions(-) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/README.md (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/daemon-bash-rcfile.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/daemon-fish-init.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/daemon-zsh-zlogin.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/daemon-zsh-zprofile.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/daemon-zsh-zshenv.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/daemon-zsh-zshrc.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/local-bash-rcfile.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/local-fish-init.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/local-zsh-zlogin.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/local-zsh-zprofile.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/local-zsh-zshenv.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/local-zsh-zshrc.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/relay-bash-rcfile.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/relay-zsh-zlogin.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/relay-zsh-zprofile.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/relay-zsh-zshenv.txt (100%) rename src/main/{__tests__ => __fixtures__}/shell-wrapper-snapshots/relay-zsh-zshrc.txt (100%) diff --git a/.gitattributes b/.gitattributes index c58a3c5aa36..f4676d210bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,4 +21,4 @@ # Generated wrapper fixtures: collapse them in the PR diff so they stop drowning # the reviewable change, and pin LF because they are compared byte-for-byte. # Not -diff: the shell diff is the review surface when a wrapper does change. -/src/main/__tests__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf +/src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf diff --git a/.github/scripts/pr-test-loc-table.mjs b/.github/scripts/pr-test-loc-table.mjs index 839098475ab..c5d233c4497 100644 --- a/.github/scripts/pr-test-loc-table.mjs +++ b/.github/scripts/pr-test-loc-table.mjs @@ -3,7 +3,10 @@ export const LOC_BLOCK_END = '' export const LOC_HANDS_OFF_COMMENT = '' -const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i +// Why the __-wrapped names: they are unambiguous test-data markers, so a dir +// cannot be one by accident. Bare `fixtures/` is deliberately absent — it reads +// as a plausible prod module name, and this classifier bills every PR. +const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e|tests)(?:\/|$)/i const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i export function isTestPath(path) { diff --git a/config/scripts/pr-test-loc-summary.test.mjs b/config/scripts/pr-test-loc-summary.test.mjs index b13ab1b4763..044b645a29c 100644 --- a/config/scripts/pr-test-loc-summary.test.mjs +++ b/config/scripts/pr-test-loc-summary.test.mjs @@ -50,6 +50,17 @@ describe('PR test LoC summary', () => { expect(isTestPath('src/main/foo.ts')).toBe(false) }) + it('classifies __fixtures__ and __snapshots__ data of any extension', () => { + expect(isTestPath('src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt')).toBe( + true + ) + expect(isTestPath('src/shared/__fixtures__/trace.json')).toBe(true) + expect(isTestPath('src/main/runtime/orchestration/__snapshots__/run.test.ts.snap')).toBe(true) + // Why: only the __-wrapped names count; a bare `fixtures` dir stays prod. + expect(isTestPath('src/main/daemon/fixtures/ratatui-tui.py')).toBe(false) + expect(isTestPath('src/main/my__fixtures__helper.ts')).toBe(false) + }) + it('sums GitHub pull-file additions and deletions', () => { const totals = sumChangedFiles([ { filename: 'src/app.ts', additions: 4, deletions: 1 }, diff --git a/src/main/__tests__/shell-wrapper-snapshots/README.md b/src/main/__fixtures__/shell-wrapper-snapshots/README.md similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/README.md rename to src/main/__fixtures__/shell-wrapper-snapshots/README.md diff --git a/src/main/__tests__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/daemon-bash-rcfile.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/daemon-fish-init.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/local-bash-rcfile.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/local-fish-init.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/local-zsh-zlogin.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/local-zsh-zprofile.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshenv.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/local-zsh-zshrc.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/relay-bash-rcfile.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zlogin.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zprofile.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshenv.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt diff --git a/src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt similarity index 100% rename from src/main/__tests__/shell-wrapper-snapshots/relay-zsh-zshrc.txt rename to src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index bcf1d7e7483..bf34df55e86 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -5,7 +5,7 @@ * Why: the zsh generators were unified behind one builder; these fixtures were * captured from the pre-unification code so any drift shows up as a diff. * - * Fixtures live in ./__tests__/shell-wrapper-snapshots/ — see the README there + * Fixtures live in ./__fixtures__/shell-wrapper-snapshots/ — see the README there * before accepting a rewrite; a local run updates them silently. */ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' @@ -36,7 +36,7 @@ const WRAPPER_FILES = [ ['bash-rcfile', join('bash', 'rcfile')] ] as const -const SNAPSHOT_DIR = join(__dirname, '__tests__', 'shell-wrapper-snapshots') +const SNAPSHOT_DIR = join(__dirname, '__fixtures__', 'shell-wrapper-snapshots') // Why: the wrapper root is a temp dir per run, and the baked ZDOTDIR literal is // the only path-dependent byte in the output; pin it to a stable placeholder. From 0b80a773a4b70a9214336b4ce80bb92e385a40ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:10:32 -0700 Subject: [PATCH 011/168] fix(codex): stop overwriting and deleting Codex files that were merely unreadable (STA-4737) (#15287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): stop overwriting and deleting Codex files that were merely unreadable (STA-4737) Three modules shared by the host and WSL Codex lanes decided a file was absent from a read that had only failed, and then wrote over it or removed it. - `codex-config-mirror`: `existsSync` on the RUNTIME config.toml returned false for a locked file exactly as for an absent one, so the mirror took the "seed a fresh runtime config" branch and replaced the user's config wholesale. - `config-settings-promotion`: an unreadable ~/.codex/config.toml counted as having no promoted settings, and the write path then rebuilt the user's canonical Codex config from Orca's runtime copy. - `codex-home-paths`: both delete branches in `linkSystemCodexResource` remove Orca's mirrored copy because the system resource "is not there". `existsSync` and `systemResourceIsRegularFile`'s `catch { return false }` both reported that for a source nobody could read, so one denied read on ~/.codex/AGENTS.md removed the managed copy on the next launch. `src/shared/definitive-filesystem-absence.ts` now owns the one errno allowlist — ENOENT and ENOTDIR, with every other code including unrecognised ones treated as indeterminate — and `host-codex-managed-home-ownership.ts` drops its private copy rather than letting the two drift. `codex-path-observation.ts` builds the three-valued observation on top of it. The resource sync's two `existsSync`/`statSync` probes collapse into one resolved stat, which answers reachability and regular-file-ness together and closes the window between them. `config-settings-promotion.ts` crossed its max-lines budget, so the write-target resolution moves to its own module rather than taking a lint exemption. Deliberately not here: the hook-service trust writes that run after a refused mirror, and the promotion write target's own classification, which is unreachable because it always resolves to the same file the read above already refused. Both are noted in comments rather than half-built. * fix(codex): preserve resource copies on indeterminate reads --- config/tsconfig.cli.json | 2 + .../host-codex-managed-home-ownership.ts | 6 +- src/main/codex/codex-config-mirror.ts | 55 ++- src/main/codex/codex-home-paths.ts | 72 ++-- src/main/codex/codex-path-observation.ts | 43 +++ .../config-settings-promotion-write-target.ts | 46 +++ src/main/codex/config-settings-promotion.ts | 89 ++--- .../sta-4737-unreadable-is-not-absent.test.ts | 330 ++++++++++++++++++ src/shared/definitive-filesystem-absence.ts | 17 + 9 files changed, 555 insertions(+), 105 deletions(-) create mode 100644 src/main/codex/codex-path-observation.ts create mode 100644 src/main/codex/config-settings-promotion-write-target.ts create mode 100644 src/main/codex/sta-4737-unreadable-is-not-absent.test.ts create mode 100644 src/shared/definitive-filesystem-absence.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 855d1e988b0..42b3d0772d0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -40,6 +40,7 @@ "../src/main/codex/codex-config-settings-upsert.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-managed-home-resource-copy-marker.ts", + "../src/main/codex/codex-path-observation.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", @@ -55,6 +56,7 @@ "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", "../src/main/codex/config-settings-promotion.ts", + "../src/main/codex/config-settings-promotion-write-target.ts", "../src/main/codex/config-sync-stall.ts", "../src/main/codex/config-toml-deprecated-hook-flag.ts", "../src/main/codex/config-toml-key-path.ts", diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.ts index 8dfebe8df1d..1820325247c 100644 --- a/src/main/codex-accounts/host-codex-managed-home-ownership.ts +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.ts @@ -1,5 +1,6 @@ import { lstatSync, readFileSync, realpathSync, statSync } from 'node:fs' import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' type HostCodexManagedHomeOwnershipOptions = { candidatePath: string @@ -40,11 +41,6 @@ export class ManagedCodexHomeTemporarilyUnavailableError extends Error { } } -function isDefinitiveAbsence(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | null)?.code - return code === 'ENOENT' || code === 'ENOTDIR' -} - function pathsEqual(left: string, right: string): boolean { const resolvedLeft = resolve(left) const resolvedRight = resolve(right) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 10ede4d9cca..dcb51c814da 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,6 +1,5 @@ -import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' -import { readAgentStateFileSync } from '../agent-state-file-reader' +import { observeAgentStateFile } from './codex-path-observation' import { recoverInterruptedGuardedFileOperation, writeFileAtomically, @@ -67,6 +66,17 @@ export function syncSystemConfigIntoManagedCodexHome( reportCodexConfigSyncOutcome(homes.runtimeHomePath, getCodexConfigSyncStatus(homes), error) return } + if (mirrorResult.status === 'refused-indeterminate') { + // Why: no mirror ran, so this must behave exactly like the throwing path + // above — surface the reason and advance nothing. Advancing the baseline + // here would record an unmirrored runtime change as promoted and strand it. + reportCodexConfigSyncOutcome( + homes.runtimeHomePath, + getCodexConfigSyncStatus(homes), + mirrorResult.error + ) + return + } // Why: report from the same pass that decided, so the surfaced status can // never disagree with what the mirror actually did. reportCodexConfigSyncOutcome(homes.runtimeHomePath, getCodexConfigSyncStatus(homes)) @@ -106,18 +116,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome( const systemConfigPath = join(homes.systemHomePath, 'config.toml') const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml') recoverInterruptedGuardedFileOperation(runtimeConfigPath) - const rawSystemConfig = existsSync(systemConfigPath) - ? readAgentStateFileSync(systemConfigPath) - : '' + const systemConfigObservation = observeAgentStateFile(systemConfigPath) + if (systemConfigObservation.kind === 'indeterminate') { + throw systemConfigObservation.error + } + const rawSystemConfig = + systemConfigObservation.kind === 'present' ? systemConfigObservation.value : '' // Why: a missing cloud-synced source is not proof the user cleared config. if (rawSystemConfig.trim() === '') { return } const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) - const runtimeConfigBeforeMirror = existsSync(runtimeConfigPath) - ? readAgentStateFileSync(runtimeConfigPath) - : null + const runtimeConfigObservation = observeAgentStateFile(runtimeConfigPath) + if (runtimeConfigObservation.kind === 'indeterminate') { + throw runtimeConfigObservation.error + } + const runtimeConfigBeforeMirror = + runtimeConfigObservation.kind === 'present' ? runtimeConfigObservation.value : null const nextRuntimeConfig = runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( @@ -135,6 +151,7 @@ export function syncSystemConfigIntoLegacySharedCodexHome( type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } + | { status: 'refused-indeterminate'; error: unknown } | { status: 'mirrored'; preservedConflictKeys: ReadonlySet } function syncSystemConfigIntoManagedCodexHomeUnsafe( @@ -143,9 +160,21 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( ): CodexConfigMirrorResult { const systemConfigPath = join(systemHomePath, 'config.toml') const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - const systemConfigExists = existsSync(systemConfigPath) - const runtimeConfigExists = existsSync(runtimeConfigPath) - const rawSystemConfig = systemConfigExists ? readAgentStateFileSync(systemConfigPath) : '' + // Why: `existsSync` answered `false` for a locked file exactly as for an absent + // one, so a held handle on the RUNTIME config read as "no runtime config yet" + // and the fresh-mirror branch below overwrote it wholesale. Neither side may + // be acted on unless it was actually observed. + const systemConfigObservation = observeAgentStateFile(systemConfigPath) + if (systemConfigObservation.kind === 'indeterminate') { + return { status: 'refused-indeterminate', error: systemConfigObservation.error } + } + const runtimeConfigObservation = observeAgentStateFile(runtimeConfigPath) + if (runtimeConfigObservation.kind === 'indeterminate') { + return { status: 'refused-indeterminate', error: runtimeConfigObservation.error } + } + const runtimeConfigExists = runtimeConfigObservation.kind === 'present' + const rawSystemConfig = + systemConfigObservation.kind === 'present' ? systemConfigObservation.value : '' // Why: a missing or blank source is not an authoritative empty config. Merging // it would erase every ordinary setting from an existing managed runtime, and // a 0-byte file is what a half-written or unhydrated cloud-synced home shows. @@ -165,7 +194,9 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( } const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) - const runtimeConfig = readAgentStateFileSync(runtimeConfigPath) + // Why: reuse the bytes already observed above rather than re-reading. A second + // read could succeed where the first failed and re-open the gap this closes. + const runtimeConfig = runtimeConfigObservation.value const preserved = preserveRuntimeConflictValues( mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig), promotionPlan.runtimeValuesToPreserve diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 5402a316d0e..b265245f92d 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -1,6 +1,5 @@ import { cpSync, - existsSync, lstatSync, mkdirSync, readFileSync, @@ -18,6 +17,7 @@ import { markCopiedResource, targetIsOwnedFallbackCopy } from './codex-managed-home-resource-copy-marker' +import { observe, observeResolvedPathEntry } from './codex-path-observation' const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md' @@ -103,11 +103,20 @@ function linkSystemCodexResource( ): void { const sourcePath = join(systemHomePath, entryName) const targetPath = join(managedHomePath, entryName) - if (!existsSync(sourcePath)) { + // Why: both branches below DELETE Orca's mirrored copy because the system + // resource "is not there". `existsSync` and the old `catch { return false }` + // both reported that for a source we merely could not read, so one denied + // read on ~/.codex/AGENTS.md removed the managed copy on the next launch. + // One resolved stat now answers reachability and regular-file-ness together. + const sourceObservation = observeResolvedPathEntry(sourcePath) + if (sourceObservation.kind === 'indeterminate') { + return + } + if (sourceObservation.kind === 'absent') { removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) return } - if (entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && !systemResourceIsRegularFile(sourcePath)) { + if (entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && !sourceObservation.value.isFile()) { removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) console.warn('[codex-home] Ignoring non-file system Codex resource:', entryName) return @@ -119,23 +128,28 @@ function linkSystemCodexResource( return } } - const shouldRefreshFallbackCopy = targetIsOwnedFallbackCopy( - targetPath, - managedHomePath, - entryName, - sourcePath - ) - if (pathEntryExists(targetPath) && !shouldRefreshFallbackCopy) { + // Why: an unreadable target is not a missing target; do not let the fallback + // copier remove it merely because existsSync/lstatSync collapsed the error. + const targetObservation = observe(() => lstatSync(targetPath)) + if (targetObservation.kind === 'indeterminate') { + return + } + const shouldRefreshFallbackCopy = + targetObservation.kind === 'present' && + targetIsOwnedFallbackCopy(targetPath, managedHomePath, entryName, sourcePath) + if (targetObservation.kind === 'present' && !shouldRefreshFallbackCopy) { return } if (shouldRefreshFallbackCopy) { // Why: WSL launch preparation runs before every Codex start. Avoid // rewriting an unchanged file across the UNC boundary on every launch. - if ( - entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && - copiedFileContentsMatch(sourcePath, targetPath) - ) { - return + if (entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY) { + const contentsMatch = copiedFileContentsMatch(sourcePath, targetPath) + if (contentsMatch === 'match' || contentsMatch === 'indeterminate') { + // Why: a failed comparison is not permission to remove the only + // readable copy; leave it in place for the next launch. + return + } } rmSync(targetPath, { recursive: true, force: true }) } @@ -205,33 +219,19 @@ function copySystemCodexResourceAsOwnedFallback( } } -function systemResourceIsRegularFile(sourcePath: string): boolean { - try { - return statSync(sourcePath).isFile() - } catch { - return false - } -} - -function pathEntryExists(entryPath: string): boolean { - try { - lstatSync(entryPath) - return true - } catch { - return false - } -} - -function copiedFileContentsMatch(sourcePath: string, targetPath: string): boolean { +function copiedFileContentsMatch( + sourcePath: string, + targetPath: string +): 'match' | 'different' | 'indeterminate' { try { // Why: reading a FIFO or device synchronously can block Codex launch. // Follow source symlinks, but only compare two regular files. if (!statSync(sourcePath).isFile() || !lstatSync(targetPath).isFile()) { - return false + return 'different' } - return readFileSync(sourcePath).equals(readFileSync(targetPath)) + return readFileSync(sourcePath).equals(readFileSync(targetPath)) ? 'match' : 'different' } catch { - return false + return 'indeterminate' } } diff --git a/src/main/codex/codex-path-observation.ts b/src/main/codex/codex-path-observation.ts new file mode 100644 index 00000000000..5613dd9c336 --- /dev/null +++ b/src/main/codex/codex-path-observation.ts @@ -0,0 +1,43 @@ +import { statSync, type Stats } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { readAgentStateFileSync } from '../agent-state-file-reader' + +/** + * A filesystem read either answered, definitively answered "not there", or + * failed to answer at all. + * + * `existsSync` collapses the last two into `false`, and a `catch` that returns a + * default collapses them into the default. That is how a held lock comes to + * authorise an overwrite or a delete: the caller reads "this file is not there" + * from evidence that says only "I could not look". + */ +export type CodexPathObservation = + | { kind: 'present'; value: T } + | { kind: 'absent' } + | { kind: 'indeterminate'; error: unknown } + +/** + * Classify any read. Only `ENOENT`/`ENOTDIR` are absence — every other errno, + * including unrecognised ones, is indeterminate. Mapping an unknown errno to a + * verdict is the category error this module exists to prevent. + */ +export function observe(read: () => T): CodexPathObservation { + try { + return { kind: 'present', value: read() } + } catch (error) { + return isDefinitiveAbsence(error) ? { kind: 'absent' } : { kind: 'indeterminate', error } + } +} + +/** Why: one call replaces the `existsSync` + read pair, closing its TOCTOU window too. */ +export function observeAgentStateFile(filePath: string): CodexPathObservation { + return observe(() => readAgentStateFileSync(filePath)) +} + +/** + * `stat`, so a symlink resolves to its target — the same reachability question + * `existsSync` answers, but with the failure kept distinct from the absence. + */ +export function observeResolvedPathEntry(entryPath: string): CodexPathObservation { + return observe(() => statSync(entryPath)) +} diff --git a/src/main/codex/config-settings-promotion-write-target.ts b/src/main/codex/config-settings-promotion-write-target.ts new file mode 100644 index 00000000000..8759aaa65a0 --- /dev/null +++ b/src/main/codex/config-settings-promotion-write-target.ts @@ -0,0 +1,46 @@ +import { lstatSync, readlinkSync, realpathSync, statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +/** + * Where a promotion write actually lands. `~/.codex/config.toml` is commonly a + * dotfile-manager symlink, so the write has to follow it — including when the + * link is dangling, which `realpathSync` cannot resolve. + */ +export function resolvePromotionWriteTarget(systemTomlPath: string): { + path: string + mode: number +} { + try { + const realPath = realpathSync(systemTomlPath) + return { path: realPath, mode: statSync(realPath).mode & 0o777 } + } catch { + // Continue below: realpath also fails for a valid dangling dotfile link. + } + try { + if (lstatSync(systemTomlPath).isSymbolicLink()) { + const targetPath = resolveDanglingSymlinkTarget(systemTomlPath) + return { path: targetPath, mode: 0o600 } + } + } catch { + // Missing non-link targets are created owner-only at the requested path. + } + return { path: systemTomlPath, mode: 0o600 } +} + +function resolveDanglingSymlinkTarget(linkPath: string): string { + let currentPath = linkPath + const visited = new Set() + while (!visited.has(currentPath)) { + visited.add(currentPath) + try { + if (!lstatSync(currentPath).isSymbolicLink()) { + return currentPath + } + currentPath = resolve(dirname(currentPath), readlinkSync(currentPath)) + } catch { + return currentPath + } + } + // Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead. + throw new Error(`Codex config symlink cycle at ${linkPath}`) +} diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 76785369fb5..cd0b703960a 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -1,14 +1,7 @@ -import { - existsSync, - lstatSync, - mkdirSync, - readlinkSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' -import { readAgentStateFileSync } from '../agent-state-file-reader' +import { observeAgentStateFile } from './codex-path-observation' +import { resolvePromotionWriteTarget } from './config-settings-promotion-write-target' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' @@ -112,10 +105,17 @@ function matchPromotedStructuredKey( // the [tui] body, and [profiles.*]/other tables are still ignored. function readPromotedSettingValues(configPath: string): Map { const result = new Map() - if (!existsSync(configPath)) { + // Why: an unreadable config held no settings only in the sense that we could + // not read them. Returning an empty map says the user cleared every promoted + // value, and the write below then acts on that. + const observation = observeAgentStateFile(configPath) + if (observation.kind === 'absent') { return result } - const lines = readAgentStateFileSync(configPath).split('\n') + if (observation.kind === 'indeterminate') { + throw observation.error + } + const lines = observation.value.split('\n') let state = createTomlLineScanState() let inPreamble = true let tuiTableSeen = false @@ -224,9 +224,16 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) { return emptyPromotionPlan() } - if (!existsSync(runtimeTomlPath)) { + const runtimeTomlObservation = observeAgentStateFile(runtimeTomlPath) + if (runtimeTomlObservation.kind === 'absent') { return emptyPromotionPlan() } + if (runtimeTomlObservation.kind === 'indeterminate') { + // Why: the caller turns a throw into the existing "stall and retry" null. An + // empty plan here would instead let the mirror proceed against a runtime + // config nobody read. + throw runtimeTomlObservation.error + } // Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one. const baseline = readCodexSettingsBaseline(runtimeHomePath) if (!baseline) { @@ -253,14 +260,27 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( const writeTarget = resolvePromotionWriteTarget(systemTomlPath) // Why: a dangling symlink may target an unmade dir tree; create its real parent so the atomic temp write has a home. mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 }) - const targetExists = existsSync(writeTarget.path) + // Why: this is the user's real ~/.codex/config.toml, and `existsSync` reading + // `false` for a locked file sent it down the reconstruct branch below, which + // replaces the canonical config with settings derived from Orca's runtime + // copy. One read replaces the old existsSync + read pair and its TOCTOU gap. + // The indeterminate arm is a backstop rather than the live guard: an + // unreadable system config already refused in readPromotedSettingValues, + // because `writeTarget.path` always resolves to the same file as + // `systemTomlPath` (its realpath, its dangling-link target, or itself). + const writeTargetObservation = observeAgentStateFile(writeTarget.path) + if (writeTargetObservation.kind === 'indeterminate') { + throw writeTargetObservation.error + } + const targetExists = writeTargetObservation.kind === 'present' // Why: seeding a brand-new ~/.codex/config.toml from the promoted keys alone // would leave a skeleton the next mirror treats as authoritative, deleting // every other runtime setting (mcp_servers, features). With no system config // the runtime IS the user's config, so carry its ordinary settings across. - const systemContent = targetExists - ? readAgentStateFileSync(writeTarget.path) - : extractOrdinaryCodexSettings(readAgentStateFileSync(runtimeTomlPath)) + const systemContent = + writeTargetObservation.kind === 'present' + ? writeTargetObservation.value + : extractOrdinaryCodexSettings(runtimeTomlObservation.value) const nextContent = upsertPromotedSettingsInContent(systemContent, updates) if (nextContent === systemContent) { return { conflicts, runtimeValuesToPreserve } @@ -329,38 +349,3 @@ function emptyPromotionPlan(): CodexSettingsPromotionPlan { } // Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config. -function resolvePromotionWriteTarget(systemTomlPath: string): { path: string; mode: number } { - try { - const realPath = realpathSync(systemTomlPath) - return { path: realPath, mode: statSync(realPath).mode & 0o777 } - } catch { - // Continue below: realpath also fails for a valid dangling dotfile link. - } - try { - if (lstatSync(systemTomlPath).isSymbolicLink()) { - const targetPath = resolveDanglingSymlinkTarget(systemTomlPath) - return { path: targetPath, mode: 0o600 } - } - } catch { - // Missing non-link targets are created owner-only at the requested path. - } - return { path: systemTomlPath, mode: 0o600 } -} - -function resolveDanglingSymlinkTarget(linkPath: string): string { - let currentPath = linkPath - const visited = new Set() - while (!visited.has(currentPath)) { - visited.add(currentPath) - try { - if (!lstatSync(currentPath).isSymbolicLink()) { - return currentPath - } - currentPath = resolve(dirname(currentPath), readlinkSync(currentPath)) - } catch { - return currentPath - } - } - // Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead. - throw new Error(`Codex config symlink cycle at ${linkPath}`) -} diff --git a/src/main/codex/sta-4737-unreadable-is-not-absent.test.ts b/src/main/codex/sta-4737-unreadable-is-not-absent.test.ts new file mode 100644 index 00000000000..23549990a3c --- /dev/null +++ b/src/main/codex/sta-4737-unreadable-is-not-absent.test.ts @@ -0,0 +1,330 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as CodexSettingsPromotion from './config-settings-promotion' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// STA-4737: three shared Codex modules decided a file was absent from a read +// that had merely failed, and then overwrote or deleted it. These are the paths +// where the collapse costs the user data, not just a retry. + +/** + * One denial, modelled coherently across every call that can observe it. A path + * under an ACL denial throws from `readFileSync`/`statSync` AND reports `false` + * from `existsSync`. Faulting one call but not another lets a test pass against + * code that still consults the one left healthy. + */ +const denials = vi.hoisted(() => { + const state = { + paths: new Set(), + readOnlyPaths: new Set(), + deny(path: string): void { + state.paths.add(path) + }, + reset(): void { + state.paths.clear() + state.readOnlyPaths.clear() + }, + denyReads(path: string): void { + state.readOnlyPaths.add(path) + }, + has(target: unknown): boolean { + return typeof target === 'string' && state.paths.has(target) + }, + fail(target: string, syscall: string): never { + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, ${syscall} '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const guard = (fn: unknown, syscall: string): unknown => { + const original = fn as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => { + if ( + denials.has(args[0]) || + ((syscall === 'read' || syscall === 'copy') && + typeof args[0] === 'string' && + denials.readOnlyPaths.has(args[0])) + ) { + denials.fail(args[0] as string, syscall) + } + return original(...args) + } + return Object.assign(wrapped, original) + } + const patched: Record = { + ...actual, + readFileSync: guard(actual.readFileSync, 'read'), + cpSync: guard(actual.cpSync, 'copy'), + statSync: guard(actual.statSync, 'stat'), + lstatSync: guard(actual.lstatSync, 'lstat'), + openSync: guard(actual.openSync, 'open'), + existsSync: Object.assign( + (...args: unknown[]): boolean => + denials.has(args[0]) ? false : actual.existsSync(args[0] as string), + actual.existsSync + ) + } + return { ...patched, default: patched } +}) + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) + +const realFs = await vi.importActual('node:fs') + +const { syncSystemConfigIntoLegacySharedCodexHome, syncSystemConfigIntoManagedCodexHome } = + await import('./codex-config-mirror') +const { promoteCodexRuntimeSettingsToSystem, snapshotCodexRuntimeSettingsBaseline } = + await import('./config-settings-promotion') +const { syncCodexGlobalInstructionsIntoManagedHome } = await import('./codex-home-paths') +const { markCopiedResource } = await import('./codex-managed-home-resource-copy-marker') + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +const systemHome = (): string => join(fakeHomeDir, '.codex') +const systemConfigPath = (): string => join(systemHome(), 'config.toml') +const runtimeHome = (): string => join(userDataDir, 'codex-runtime-home', 'home') +const runtimeConfigPath = (): string => join(runtimeHome(), 'config.toml') + +beforeEach(() => { + denials.reset() + fakeHomeDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4737-home-')) + userDataDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4737-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + realFs.mkdirSync(systemHome(), { recursive: true }) + realFs.mkdirSync(runtimeHome(), { recursive: true }) +}) + +afterEach(() => { + denials.reset() + realFs.rmSync(fakeHomeDir, { recursive: true, force: true }) + realFs.rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('STA-4737 the config mirror must not overwrite a runtime config it could not read', () => { + const SYSTEM_CONFIG = 'model = "system-model"\n' + const RUNTIME_CONFIG = 'model = "user-picked"\n\n[mcp_servers.local]\ncommand = "run-me"\n' + + // Why: with no baseline, promotion returns an empty plan without reading the + // runtime config at all, so the mirror is the only thing standing between a + // denied read and an overwrite. This is the ordinary state for a runtime home + // seeded outside the mirror. + it('leaves a denied runtime config exactly as it found it', () => { + realFs.writeFileSync(systemConfigPath(), SYSTEM_CONFIG, 'utf-8') + realFs.writeFileSync(runtimeConfigPath(), RUNTIME_CONFIG, 'utf-8') + denials.deny(runtimeConfigPath()) + + syncSystemConfigIntoManagedCodexHome() + + // Before the fix the mirror read "no runtime config yet" and replaced this + // whole file with a fresh copy of the system one. + expect(realFs.readFileSync(runtimeConfigPath(), 'utf-8')).toBe(RUNTIME_CONFIG) + }) + + // Why: promotion runs first and refuses on the same denied file, so it alone + // covers the case above. This drives the mirror's OWN guard, which is what + // stands between a write and a file that became unreadable in the window + // between promotion reading it and the mirror rewriting it — the exact shape + // of a scanner taking the file mid-pass. + it('refuses its own write when the runtime config is denied after promotion succeeded', async () => { + realFs.writeFileSync(systemConfigPath(), SYSTEM_CONFIG, 'utf-8') + realFs.writeFileSync(runtimeConfigPath(), RUNTIME_CONFIG, 'utf-8') + + vi.resetModules() + vi.doMock('./config-settings-promotion', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Promotion already read the file successfully and had nothing to do. + promoteCodexRuntimeSettingsToSystem: () => ({ + conflicts: new Map(), + runtimeValuesToPreserve: new Map() + }) + } + }) + try { + const mirror = await import('./codex-config-mirror') + denials.deny(runtimeConfigPath()) + mirror.syncSystemConfigIntoManagedCodexHome() + expect(realFs.readFileSync(runtimeConfigPath(), 'utf-8')).toBe(RUNTIME_CONFIG) + } finally { + vi.doUnmock('./config-settings-promotion') + vi.resetModules() + } + }) + + it('still seeds a runtime config that is genuinely absent', () => { + realFs.writeFileSync(systemConfigPath(), SYSTEM_CONFIG, 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + // Why: absence is a real answer, and seeding it is the mirror's job. A fix + // that refused here would break first launch for everyone. + expect(realFs.readFileSync(runtimeConfigPath(), 'utf-8')).toContain('model = "system-model"') + }) + + it('refuses the retained-pane mirror when its runtime config is denied', () => { + realFs.writeFileSync(systemConfigPath(), SYSTEM_CONFIG, 'utf-8') + realFs.writeFileSync(runtimeConfigPath(), RUNTIME_CONFIG, 'utf-8') + denials.deny(runtimeConfigPath()) + + expect(() => + syncSystemConfigIntoLegacySharedCodexHome({ + runtimeHomePath: runtimeHome(), + systemHomePath: systemHome() + }) + ).toThrow('EPERM') + expect(realFs.readFileSync(runtimeConfigPath(), 'utf-8')).toBe(RUNTIME_CONFIG) + }) +}) + +describe('STA-4737 promotion must not rebuild a system config it could not read', () => { + function seedPromotableRuntimeChange(): void { + realFs.writeFileSync(systemConfigPath(), 'model = "old-model"\n', 'utf-8') + // The baseline records what Orca last mirrored; the later edit then reads as + // an in-Codex change that must be promoted back to ~/.codex. + realFs.writeFileSync(runtimeConfigPath(), 'model = "old-model"\n', 'utf-8') + snapshotCodexRuntimeSettingsBaseline(runtimeHome()) + realFs.writeFileSync(runtimeConfigPath(), 'model = "new-model"\n', 'utf-8') + } + + it('leaves a denied system config exactly as it found it', () => { + seedPromotableRuntimeChange() + const before = realFs.readFileSync(systemConfigPath(), 'utf-8') + denials.deny(systemConfigPath()) + + // Why: the caller turns a refusal into its existing "stall and retry" null. + expect( + promoteCodexRuntimeSettingsToSystem({ + runtimeHomePath: runtimeHome(), + systemHomePath: systemHome() + }) + ).toBeNull() + + // Before the fix, the unreadable config counted as absent and was replaced + // by a reconstruction built from Orca's runtime copy. + expect(realFs.readFileSync(systemConfigPath(), 'utf-8')).toBe(before) + }) + + it('still promotes into a readable system config', () => { + seedPromotableRuntimeChange() + + const plan = promoteCodexRuntimeSettingsToSystem({ + runtimeHomePath: runtimeHome(), + systemHomePath: systemHome() + }) + + // Why: the anchor. Without it, "returns null and writes nothing" would pass + // against a promotion that had simply stopped working. + expect(plan).not.toBeNull() + expect(realFs.readFileSync(systemConfigPath(), 'utf-8')).toContain('model = "new-model"') + }) +}) + +describe('STA-4737 the resource sync must not delete a mirror whose source it could not read', () => { + const AGENTS_ENTRY = 'AGENTS.md' + const MIRRORED = '# instructions Orca copied for the distro\n' + + function seedOwnedMirrorCopy(): { sourcePath: string; targetPath: string } { + const sourcePath = join(systemHome(), AGENTS_ENTRY) + const targetPath = join(runtimeHome(), AGENTS_ENTRY) + realFs.writeFileSync(sourcePath, MIRRORED, 'utf-8') + realFs.writeFileSync(targetPath, MIRRORED, 'utf-8') + // Orca owns this copy, which is what entitles the sync to remove it. + markCopiedResource(runtimeHome(), AGENTS_ENTRY, sourcePath) + return { sourcePath, targetPath } + } + + it('keeps the mirrored copy when the source is denied', () => { + const { sourcePath, targetPath } = seedOwnedMirrorCopy() + denials.deny(sourcePath) + + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath: systemHome(), + managedHomePath: runtimeHome() + }) + + // Before the fix, `existsSync` reported the denied source as gone and the + // managed copy was deleted on the next launch. + expect(realFs.existsSync(targetPath)).toBe(true) + expect(realFs.readFileSync(targetPath, 'utf-8')).toBe(MIRRORED) + }) + + it('keeps the mirrored copy when the source becomes unreadable after stat', () => { + const { sourcePath, targetPath } = seedOwnedMirrorCopy() + // A Windows sharing violation can leave metadata readable while content + // reads fail; stat success must not authorize deleting the fallback copy. + denials.denyReads(sourcePath) + + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath: systemHome(), + managedHomePath: runtimeHome() + }) + + expect(realFs.readFileSync(targetPath, 'utf-8')).toBe(MIRRORED) + }) + + it('keeps an unreadable owned target instead of refreshing it', () => { + const { sourcePath, targetPath } = seedOwnedMirrorCopy() + realFs.writeFileSync(sourcePath, '# updated system instructions\n', 'utf-8') + denials.deny(targetPath) + + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath: systemHome(), + managedHomePath: runtimeHome() + }) + + expect(realFs.readFileSync(targetPath, 'utf-8')).toBe(MIRRORED) + }) + + it('still removes the mirrored copy when the source is genuinely gone', () => { + const { sourcePath, targetPath } = seedOwnedMirrorCopy() + realFs.rmSync(sourcePath) + + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath: systemHome(), + managedHomePath: runtimeHome() + }) + + // Why: removing Orca's own copy of a resource the user deleted is the + // point of this path. Refusing here would strand stale instructions. + expect(realFs.existsSync(targetPath)).toBe(false) + }) +}) diff --git a/src/shared/definitive-filesystem-absence.ts b/src/shared/definitive-filesystem-absence.ts new file mode 100644 index 00000000000..8140d0588e3 --- /dev/null +++ b/src/shared/definitive-filesystem-absence.ts @@ -0,0 +1,17 @@ +/** + * The single errno allowlist for "this path is definitively not there". + * + * `existsSync` returns `false` for `ENOENT` and for `EPERM`, `EACCES`, `EBUSY`, + * `EIO`, `UNKNOWN` and every unrecognised code alike, and a `catch` returning a + * default does the same. Callers that act on absence — deleting a mirror, + * overwriting a config, clearing a credential — must be able to tell the two + * apart, and they must all agree on where the line is, so this lives in one + * place rather than being re-derived per lane. + * + * An unknown code is never absence. Mapping the unknown to a verdict is the + * category error this predicate exists to prevent. + */ +export function isDefinitiveAbsence(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} From 4cc7e7859a4a5f566a05fec0f2f66fb5ba2f1f22 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:24 -0700 Subject: [PATCH 012/168] fix(cli): route --host runtime: to that server instead of answering locally (#15364) * fix(cli): route --host runtime: to that server instead of answering locally `--host` was only ever a local filter over whatever runtime the CLI happened to connect to, so `--host runtime:` silently answered for (and mutated) the local machine. A real environment id and a made-up one were indistinguishable: both returned ok:true with an empty list and the local runtimeId in _meta, and `project setup-clone --host runtime:` cloned into the caller's own machine. Resolve the flag before the client is built: unparseable host ids and runtime ids that no paired environment owns are rejected, and a known runtime id selects that environment as the connection (conflicting with --pairing-code or a different --environment is an error). Once routed, a host filter also accepts the runtime's own `local`-stamped rows, since both spellings name the machine we are now talking to. * fix(cli): close --host routing gaps found in review - Conflict-check an ambient ORCA_ENVIRONMENT, not just the --environment flag. `ORCA_ENVIRONMENT=staging orca ... --host runtime:` silently routed to prod while the flag spelling errored. An ambient pairing code still loses to the explicit flag, because it cannot be resolved to an id to compare. - Attach the known environment ids to the unknown-id error as `error.data`, so a --json consumer can retry without parsing prose, and say outright that runtime: matches ids only and never environment names. - Fix four command examples that documented `--host runtime:gpu`. `gpu` is an environment name, so every one of them would now be rejected; use an id. - Cover the routed connection on `worktree create` and `automations create` (the mutating paths), the `--environment X --host local` filter-only case, and assert error.code/error.data rather than only substrings. * test(cli): pin execution-host-flag to the deferred error-class import index.ts now loads execution-host-flag.ts on every invocation, making it the sixth module on the --help path. It imports RuntimeClientError from ./runtime/types today, but nothing enforced that; switching it to the barrel would silently drag zod/ws/tweetnacl back onto --help, which is exactly what this guard exists to prevent. Verified the assertion fails when the import is flipped to the barrel. --- src/cli/execution-host-flag.test.ts | 189 +++++++++++++++++++ src/cli/execution-host-flag.ts | 102 ++++++++++ src/cli/handlers/project.ts | 20 +- src/cli/index-automation-target.test.ts | 4 +- src/cli/index-project-setup.test.ts | 108 ++++++++++- src/cli/index-test-harness.ts | 25 +++ src/cli/index-worktree-create-target.test.ts | 4 +- src/cli/index.ts | 33 +++- src/cli/runtime-client-deferral.test.ts | 30 +-- src/cli/specs/automations.ts | 1 + src/cli/specs/core.ts | 3 +- src/cli/specs/project.ts | 19 +- src/cli/worktree-project-target.ts | 40 ++-- 13 files changed, 527 insertions(+), 51 deletions(-) create mode 100644 src/cli/execution-host-flag.test.ts create mode 100644 src/cli/execution-host-flag.ts diff --git a/src/cli/execution-host-flag.test.ts b/src/cli/execution-host-flag.test.ts new file mode 100644 index 00000000000..a51b69f5d38 --- /dev/null +++ b/src/cli/execution-host-flag.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from 'vitest' + +const { listEnvironmentsMock, resolveEnvironmentMock, getDefaultUserDataPathMock } = vi.hoisted( + () => ({ + listEnvironmentsMock: vi.fn(), + resolveEnvironmentMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data') + }) +) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: vi.fn(), + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: resolveEnvironmentMock +})) + +vi.mock('./runtime-client', () => ({ + getDefaultUserDataPath: getDefaultUserDataPathMock +})) + +import { + hostFilterMatchesHostId, + parseHostFlag, + resolveHostFlagEnvironmentId +} from './execution-host-flag' +import { parseExecutionHostId } from '../shared/execution-host' + +const NO_SELECTION = { pairingCode: null, environmentSelector: null } + +function flags(entries: Record): Map { + return new Map(Object.entries(entries)) +} + +function environment(id: string, name = id) { + return { id, name } +} + +describe('parseHostFlag', () => { + it('returns undefined when --host is absent', () => { + expect(parseHostFlag(flags({}))).toBeUndefined() + }) + + it('rejects a --host flag with no value', () => { + expect(() => parseHostFlag(flags({ host: true }))).toThrow('Missing value for --host') + }) + + it.each(['runtime:', 'ssh:', 'nonsense'])('rejects the unparseable host id %s', (value) => { + expect(() => parseHostFlag(flags({ host: value }))).toThrow(`Invalid --host value: ${value}`) + }) + + it('parses the three supported host kinds', () => { + expect(parseHostFlag(flags({ host: 'local' }))?.kind).toBe('local') + expect(parseHostFlag(flags({ host: 'ssh:box-1' }))?.kind).toBe('ssh') + expect(parseHostFlag(flags({ host: 'runtime:env-1' }))).toEqual({ + kind: 'runtime', + id: 'runtime:env-1', + environmentId: 'env-1' + }) + }) +}) + +describe('resolveHostFlagEnvironmentId', () => { + it('keeps the current connection for local and ssh hosts', async () => { + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'local' }), NO_SELECTION) + ).resolves.toBe(null) + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'ssh:box-1' }), NO_SELECTION) + ).resolves.toBe(null) + expect(listEnvironmentsMock).not.toHaveBeenCalled() + }) + + it('routes a paired runtime host to that environment', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), NO_SELECTION) + ).resolves.toBe('env-1') + }) + + it('rejects a runtime host id that no paired environment owns', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-missing' }), NO_SELECTION) + ).rejects.toThrow('no paired environment has id env-missing') + }) + + it('matches by environment id only, never by environment name', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION) + ).rejects.toThrow('no paired environment has id gpu') + }) + + it('decodes percent-encoded environment ids', async () => { + listEnvironmentsMock.mockReturnValue([environment('env one')]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env%20one' }), NO_SELECTION) + ).resolves.toBe('env one') + }) + + it('refuses a runtime host alongside --pairing-code', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1')]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + pairingCode: 'orca://pair?x', + environmentSelector: null + }) + ).rejects.toThrow('not both') + }) + + it('refuses a runtime host that disagrees with --environment', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1')]) + resolveEnvironmentMock.mockReturnValue(environment('env-2')) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + pairingCode: null, + environmentSelector: { value: 'other', label: '--environment' } + }) + ).rejects.toThrow('name different Orca servers') + }) + + it('names the ambient variable when ORCA_ENVIRONMENT is the conflicting selector', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1')]) + resolveEnvironmentMock.mockReturnValue(environment('env-2')) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + pairingCode: null, + environmentSelector: { value: 'staging', label: 'ORCA_ENVIRONMENT' } + }) + ).rejects.toThrow('ORCA_ENVIRONMENT staging name different Orca servers') + }) + + it('hands an agent the known environment ids to retry with', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION) + ).rejects.toMatchObject({ + code: 'invalid_argument', + data: { knownEnvironments: [{ id: 'env-1', name: 'gpu' }] } + }) + }) + + it('accepts --environment naming the same server as the runtime host', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')]) + resolveEnvironmentMock.mockReturnValue(environment('env-1', 'gpu')) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + pairingCode: null, + environmentSelector: { value: 'gpu', label: '--environment' } + }) + ).resolves.toBe('env-1') + }) +}) + +describe('hostFilterMatchesHostId', () => { + const runtimeHost = parseExecutionHostId('runtime:env-1')! + const localHost = parseExecutionHostId('local')! + const sshHost = parseExecutionHostId('ssh:box-1')! + + it('matches the identical host id', () => { + expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-1')).toBe(true) + expect(hostFilterMatchesHostId(localHost, 'local')).toBe(true) + expect(hostFilterMatchesHostId(sshHost, 'ssh:box-1')).toBe(true) + }) + + it('treats the routed runtime own local rows as that runtime', () => { + expect(hostFilterMatchesHostId(runtimeHost, 'local')).toBe(true) + }) + + it('does not widen local or ssh filters', () => { + expect(hostFilterMatchesHostId(localHost, 'runtime:env-1')).toBe(false) + expect(hostFilterMatchesHostId(sshHost, 'local')).toBe(false) + }) + + it('does not match a different runtime host', () => { + expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-2')).toBe(false) + expect(hostFilterMatchesHostId(runtimeHost, null)).toBe(false) + }) +}) diff --git a/src/cli/execution-host-flag.ts b/src/cli/execution-host-flag.ts new file mode 100644 index 00000000000..36dc080e329 --- /dev/null +++ b/src/cli/execution-host-flag.ts @@ -0,0 +1,102 @@ +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + type ParsedExecutionHost +} from '../shared/execution-host' +import { RuntimeClientError } from './runtime/types' + +export type HostFlagRoutingSelection = { + pairingCode: string | null + // Why: an ambient ORCA_ENVIRONMENT counts as a selection too, so carry the label to + // name the real source in the conflict message. + environmentSelector: { value: string; label: string } | null +} + +export function parseHostFlag( + flags: Map +): ParsedExecutionHost | undefined { + if (!flags.has('host')) { + return undefined + } + const raw = flags.get('host') + if (typeof raw !== 'string' || raw.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing value for --host') + } + const parsed = parseExecutionHostId(raw) + if (!parsed) { + throw new RuntimeClientError( + 'invalid_argument', + `Invalid --host value: ${raw}. Expected local, ssh:, or runtime:.` + ) + } + return parsed +} + +// Why: `runtime:` ids are minted by this machine's pairing store, so they +// only name a machine relative to it. Filtering or mutating the locally connected runtime +// with one answers for the wrong host, and an id that matches nothing is indistinguishable +// from a real host with no rows — so resolve the id here and send the command to it. +export async function resolveHostFlagEnvironmentId( + flags: Map, + selection: HostFlagRoutingSelection +): Promise { + const host = parseHostFlag(flags) + if (host?.kind !== 'runtime') { + return null + } + const [{ listEnvironments, resolveEnvironment }, { getDefaultUserDataPath }] = await Promise.all([ + import('./runtime/environments.js'), + import('./runtime-client.js') + ]) + const userDataPath = getDefaultUserDataPath() + const known = listEnvironments(userDataPath) + const environment = known.find((candidate) => candidate.id === host.environmentId) + if (!environment) { + // Why: `runtime:` is a host id that also appears in stored rows, so it resolves by id + // only — unlike --environment, which also accepts a name. Say so, and hand back the ids an + // agent can retry with instead of making it scrape the sentence. + throw new RuntimeClientError( + 'invalid_argument', + `Unknown Orca server in --host ${host.id}: no paired environment has id ${host.environmentId}. --host runtime: matches environment ids only, never names. Run \`orca environment list\` to see paired servers, or pass --host local.`, + { + knownEnvironments: known.map((candidate) => ({ id: candidate.id, name: candidate.name })), + nextSteps: [ + 'Run `orca environment list` to see paired Orca servers.', + 'Use the environment id, not its name: --host runtime:.', + 'Use --host local to target this machine.' + ] + } + ) + } + if (selection.pairingCode) { + throw new RuntimeClientError( + 'invalid_argument', + `--host ${host.id} already selects a paired Orca server; use either --host runtime: or --pairing-code, not both.` + ) + } + if (selection.environmentSelector) { + const selected = resolveEnvironment(userDataPath, selection.environmentSelector.value) + if (selected.id !== environment.id) { + throw new RuntimeClientError( + 'invalid_argument', + `--host ${host.id} and ${selection.environmentSelector.label} ${selection.environmentSelector.value} name different Orca servers.` + ) + } + } + return environment.id +} + +// Why: a runtime stamps its own setups `local` when they were made on the box and +// `runtime:` when a paired client made them. Once --host routed the command to that +// runtime both spellings mean the same machine, so a host filter has to accept both. +export function hostFilterMatchesHostId( + filter: ParsedExecutionHost, + candidateHostId: string | null | undefined +): boolean { + const candidate = normalizeExecutionHostId(candidateHostId) + if (candidate === filter.id) { + return true + } + return filter.kind === 'runtime' && candidate === LOCAL_EXECUTION_HOST_ID +} diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index c3868001f4c..6ee8266b97c 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -10,6 +10,7 @@ import type { ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult } from '../../shared/project-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { RepoKind } from '../../shared/repo-types' import type { CommandHandler } from '../dispatch' import { @@ -21,10 +22,19 @@ import { formatProjectList, printResult } from '../format' +import { hostFilterMatchesHostId, parseHostFlag } from '../execution-host-flag' import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' import { resolveRepoPathArgument } from '../repo-path-arguments' import { RuntimeClientError } from '../runtime-client' +function getRequiredHostId(flags: Map): ExecutionHostId { + const host = parseHostFlag(flags) + if (!host) { + throw new RuntimeClientError('invalid_argument', 'Missing required --host') + } + return host.id +} + function getOptionalRepoKind(flags: Map): RepoKind | undefined { const kind = getOptionalStringFlag(flags, 'kind') if (kind === undefined) { @@ -43,12 +53,12 @@ export const PROJECT_HANDLERS: Record = { }, 'project setups': async ({ flags, client, json }) => { const projectFilter = getOptionalStringFlag(flags, 'project') - const hostFilter = getOptionalStringFlag(flags, 'host') + const hostFilter = parseHostFlag(flags) const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') const setups = result.result.setups.filter( (setup) => (projectFilter === undefined || setup.projectId === projectFilter) && - (hostFilter === undefined || setup.hostId === hostFilter) + (hostFilter === undefined || hostFilterMatchesHostId(hostFilter, setup.hostId)) ) printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList) }, @@ -56,7 +66,7 @@ export const PROJECT_HANDLERS: Record = { const rawPath = getRequiredStringFlag(flags, 'path') const args: ProjectHostSetupExistingFolderArgs = { projectId: getRequiredStringFlag(flags, 'project'), - hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'], + hostId: getRequiredHostId(flags), path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'), kind: getOptionalRepoKind(flags), displayName: getOptionalStringFlag(flags, 'display-name') @@ -71,7 +81,7 @@ export const PROJECT_HANDLERS: Record = { const rawDestination = getRequiredStringFlag(flags, 'destination') const args: ProjectHostSetupCloneArgs = { projectId: getRequiredStringFlag(flags, 'project'), - hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCloneArgs['hostId'], + hostId: getRequiredHostId(flags), url: getRequiredStringFlag(flags, 'url'), destination: resolveRepoPathArgument( rawDestination, @@ -91,7 +101,7 @@ export const PROJECT_HANDLERS: Record = { const path = getOptionalStringFlag(flags, 'path') const args: ProjectHostSetupCreateArgs = { projectId: getRequiredStringFlag(flags, 'project'), - hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCreateArgs['hostId'], + hostId: getRequiredHostId(flags), setupId: getOptionalStringFlag(flags, 'setup-id'), path: path === undefined diff --git a/src/cli/index-automation-target.test.ts b/src/cli/index-automation-target.test.ts index e2e541207ed..ca9e398cadd 100644 --- a/src/cli/index-automation-target.test.ts +++ b/src/cli/index-automation-target.test.ts @@ -42,7 +42,7 @@ vi.mock('child_process', async () => { import { main } from './index' import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' -import { useWorktreeAwarenessEnvironment } from './index-test-harness' +import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' describe('orca cli worktree awareness', () => { useWorktreeAwarenessEnvironment({ @@ -123,6 +123,7 @@ describe('orca cli worktree awareness', () => { }) it('resolves project and host flags for automation create', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') queueFixtures( callMock, okFixture('req_project_setups', { @@ -180,6 +181,7 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu') expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') expect(callMock).toHaveBeenNthCalledWith( 2, diff --git a/src/cli/index-project-setup.test.ts b/src/cli/index-project-setup.test.ts index d4483f6376a..7e9b5a72f56 100644 --- a/src/cli/index-project-setup.test.ts +++ b/src/cli/index-project-setup.test.ts @@ -43,7 +43,7 @@ vi.mock('child_process', async () => { import { main } from './index' import { okFixture, queueFixtures } from './test-fixtures' -import { useWorktreeAwarenessEnvironment } from './index-test-harness' +import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' describe('orca cli worktree awareness', () => { useWorktreeAwarenessEnvironment({ @@ -103,7 +103,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('project.list') }) - it('filters project host setups locally after fetching setup compatibility state', async () => { + it('routes a runtime host filter to that paired server and keeps its own local rows', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') queueFixtures( callMock, okFixture('req_project_setups', { @@ -142,9 +143,106 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu') expect(callMock).toHaveBeenCalledWith('projectHostSetup.list') expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote') - expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local') + expect(logSpy.mock.calls[0]?.[0]).toContain('setup-local') + }) + + it('keeps --host local a filter on the selected environment rather than a second selector', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'prod') + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + id: 'setup-on-box', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-on-box', + path: '/srv/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }, + { + id: 'setup-by-client', + projectId: 'github:stablyai/orca', + hostId: 'runtime:prod', + repoId: 'repo-by-client', + path: '/srv/orca-2', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['project', 'setups', '--environment', 'prod', '--host', 'local'], '/tmp/repo') + + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'prod') + expect(logSpy.mock.calls[0]?.[0]).toContain('setup-on-box') + expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-by-client') + }) + + it('rejects a runtime host id that no paired server owns instead of answering empty', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['project', 'setups', '--host', 'runtime:not-a-real-env', '--json'], '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + const printed = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n') + expect(printed).toContain('no paired environment has id not-a-real-env') + // An agent reads the code and the retry candidates, not the prose. + expect(JSON.parse(printed).error.code).toBe('invalid_argument') + expect(JSON.parse(printed).error.data.knownEnvironments).toEqual([]) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects a malformed --host value before contacting any runtime', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['project', 'setups', '--host', 'runtime:', '--json'], '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Invalid --host value: runtime:' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('refuses a runtime host id alongside an unrelated --pairing-code connection', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['project', 'setups', '--host', 'runtime:gpu', '--pairing-code', 'remote-runtime', '--json'], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'use either --host runtime: or --pairing-code, not both' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode }) it('sets up an existing project folder with a path resolved against the local cli cwd', async () => { @@ -213,6 +311,7 @@ describe('orca cli worktree awareness', () => { }) it('rejects remote project setup relative paths instead of resolving against client cwd', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const priorExitCode = process.exitCode @@ -227,8 +326,6 @@ describe('orca cli worktree awareness', () => { 'runtime:gpu', '--path', './orca', - '--pairing-code', - 'remote-runtime', '--json' ], '/tmp/repo' @@ -377,6 +474,7 @@ describe('orca cli worktree awareness', () => { }) it('creates independent project host setup metadata through the project-first runtime API', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') queueFixtures( callMock, okFixture('req_project_setup_create', { diff --git a/src/cli/index-test-harness.ts b/src/cli/index-test-harness.ts index 2a4576f8612..bb8c2d61366 100644 --- a/src/cli/index-test-harness.ts +++ b/src/cli/index-test-harness.ts @@ -83,6 +83,31 @@ export async function createChildProcessModuleMock(spawnMock: Mock) { } } +/** Registers a paired environment so `--host runtime:` routes there instead of being rejected. */ +export function pairRuntimeEnvironment(listEnvironmentsMock: Mock, id: string, name = id): void { + listEnvironmentsMock.mockReturnValue([ + { + id, + name, + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [ + { + id: `ws-${id}`, + kind: 'websocket', + label: 'WebSocket', + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'pk' + } + ], + preferredEndpointId: `ws-${id}` + } + ]) +} + /** Installs the env-var save/restore + mock-reset hooks shared by the CLI worktree-awareness suites. */ export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): void { const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE diff --git a/src/cli/index-worktree-create-target.test.ts b/src/cli/index-worktree-create-target.test.ts index 4e83f811f80..92ed80fec7e 100644 --- a/src/cli/index-worktree-create-target.test.ts +++ b/src/cli/index-worktree-create-target.test.ts @@ -42,7 +42,7 @@ vi.mock('child_process', async () => { import { main } from './index' import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' -import { useWorktreeAwarenessEnvironment } from './index-test-harness' +import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' describe('orca cli worktree awareness', () => { useWorktreeAwarenessEnvironment({ @@ -86,6 +86,7 @@ describe('orca cli worktree awareness', () => { }) it('resolves project and host flags to the matching repo for worktree.create', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') queueFixtures( callMock, okFixture('req_project_setups', { @@ -140,6 +141,7 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu') expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-gpu', diff --git a/src/cli/index.ts b/src/cli/index.ts index 4d42adf30d2..c6585e79247 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,7 @@ import { validateCommandAndFlags } from './args' import { dispatch } from './dispatch' +import { resolveHostFlagEnvironmentId } from './execution-host-flag' import { reportCliError } from './format' import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' @@ -89,10 +90,34 @@ export async function main( const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') + // Why: --host runtime: names a paired server, not a filter over this + // runtime's rows, so it has to pick the connection before the client exists. + // An ambient ORCA_ENVIRONMENT is checked for disagreement too — silently + // retargeting a mutation to another server is the bug this flag already had. + // An ambient pairing code cannot be resolved to an id to compare, so the + // explicit flag simply wins there. + const hostEnvironmentId = ignoreRemoteSelection + ? null + : await resolveHostFlagEnvironmentId(parsed.flags, { + pairingCode: typeof pairingCode === 'string' ? pairingCode : null, + environmentSelector: + typeof environmentSelector === 'string' + ? { value: environmentSelector, label: '--environment' } + : process.env.ORCA_ENVIRONMENT + ? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' } + : null + }) // Why: pass `null` (not `undefined`) when remote selection is suppressed // so the RuntimeClient default parameter does not re-activate the // ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands // that must run locally (environment / serve). + const suppressed = ignoreRemoteSelection ? null : undefined + // An explicit --host runtime: outranks an ambient pairing code or environment. + const remotePairingCode = + hostEnvironmentId !== null ? null : typeof pairingCode === 'string' ? pairingCode : suppressed + const remoteEnvironment = + hostEnvironmentId ?? + (typeof environmentSelector === 'string' ? environmentSelector : suppressed) let client: RuntimeClient | undefined await dispatch(parsed.commandPath, { flags: parsed.flags, @@ -101,12 +126,8 @@ export async function main( client ??= new RuntimeClientClass( undefined, undefined, - typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined, - typeof environmentSelector === 'string' - ? environmentSelector - : ignoreRemoteSelection - ? null - : undefined + remotePairingCode, + remoteEnvironment ) return client }, diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index 396bc6656ec..d5a1f421213 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -48,22 +48,26 @@ describe('RuntimeClient module-graph deferral', () => { process.exitCode = 0 }) - // Why: the whole point of the change. These five modules load on EVERY + // Why: the whole point of the change. These six modules load on EVERY // invocation, so a value-import of the barrel from any of them drags the // RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path. - it.each(['args.ts', 'flags.ts', 'dispatch.ts', 'format.ts', 'selectors.ts'])( - '%s imports error classes from ./runtime/types, not the barrel', - (file) => { - const source = readFileSync(join(CLI_DIR, file), 'utf8') - const valueImports = source - .split('\n') - .filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'")) - for (const line of valueImports) { - expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /) - } - expect(source).toContain("} from './runtime/types'") + it.each([ + 'args.ts', + 'flags.ts', + 'dispatch.ts', + 'format.ts', + 'selectors.ts', + 'execution-host-flag.ts' + ])('%s imports error classes from ./runtime/types, not the barrel', (file) => { + const source = readFileSync(join(CLI_DIR, file), 'utf8') + const valueImports = source + .split('\n') + .filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'")) + for (const line of valueImports) { + expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /) } - ) + expect(source).toContain("} from './runtime/types'") + }) it('index.ts has no eager value-import of the runtime client', () => { const source = readFileSync(join(CLI_DIR, 'index.ts'), 'utf8') diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts index 84d462a0f26..aea17b795ec 100644 --- a/src/cli/specs/automations.ts +++ b/src/cli/specs/automations.ts @@ -56,6 +56,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ 'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.', 'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.', 'Use --project with --host, or --project-host-setup, to run on a specific project host setup.', + '--host runtime: targets that paired Orca server; use the id from `orca environment list`, not the environment name.', 'Use --source-context with a JSON TaskSourceContext when task/provider data should come from a specific host/account; pass null on edit to clear it.', 'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.', 'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.', diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index a2aa81804f5..714115680f6 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -111,6 +111,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.', 'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.', 'Use --project with --host to create on a ready project host setup without spelling the backing repo id.', + '--host runtime: creates on that paired Orca server; use the id from `orca environment list`, not the environment name.', 'For related work, use the inferred parent or pass --parent-worktree active, folder:, or worktree: to make the relationship explicit. Worktree ids are the full :: values returned by `orca worktree list --json`.', 'Use --no-parent when the new worktree should be independent of the current context.', '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.', @@ -124,7 +125,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ examples: [ 'orca worktree create --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id: --name related-task --json', - 'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json', + 'orca worktree create --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --name benchmark --json', 'orca worktree create --repo id: --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json', 'orca worktree create --repo id: --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id: --name folder-child --parent-worktree folder: --json', diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index caeabda3de7..56b33827c4b 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -14,11 +14,17 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ summary: 'List project host setups', usage: 'orca project setups [--project ] [--host ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'], - notes: ['A setup means a project is available on a host at a concrete filesystem path.'], + notes: [ + 'A setup means a project is available on a host at a concrete filesystem path.', + '--host runtime: runs the command on that paired Orca server instead of filtering this runtime; unknown environment ids are rejected rather than answered with an empty list.', + 'Run `orca environment list` to see the environment ids that runtime: accepts. It matches ids only, never environment names.', + "A routed --host runtime: also lists that server's own local-stamped setups, because both spellings name the machine the command reached." + ], examples: [ 'orca project setups', 'orca project setups --project github:stablyai/orca', - 'orca project setups --host local' + 'orca project setups --host local', + 'orca project setups --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3' ] }, { @@ -29,11 +35,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'], notes: [ 'For remote runtimes, --path must be an absolute path on the remote server.', + '--host runtime: targets that paired Orca server; use the id from `orca environment list`, not the environment name.', 'SSH targets are set up through the desktop UI because the desktop client owns SSH connections.' ], examples: [ 'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca', - 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json' + 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --path /home/me/orca --kind git --json' ] }, { @@ -44,11 +51,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'url', 'destination', 'display-name'], notes: [ 'For remote runtimes, --destination must be an absolute parent directory on the remote server.', + '--host runtime: targets that paired Orca server; use the id from `orca environment list`, not the environment name.', 'SSH targets are cloned through the desktop UI because the desktop client owns SSH connections.' ], examples: [ 'orca project setup-clone --project github:stablyai/orca --host local --url https://github.com/stablyai/orca.git --destination ~/src', - 'orca project setup-clone --project github:stablyai/orca --host runtime:gpu --url https://github.com/stablyai/orca.git --destination /srv --json' + 'orca project setup-clone --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --url https://github.com/stablyai/orca.git --destination /srv --json' ] }, { @@ -71,10 +79,11 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ ], notes: [ 'Creates setup metadata without registering a repo compatibility record.', + '--host runtime: targets that paired Orca server; use the id from `orca environment list`, not the environment name.', 'Use setup-existing-folder when Orca should import and manage an actual checkout path now.' ], examples: [ - 'orca project setup-create --project github:stablyai/orca --host runtime:gpu --state setting-up --method provisioned --json' + 'orca project setup-create --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --state setting-up --method provisioned --json' ] }, { diff --git a/src/cli/worktree-project-target.ts b/src/cli/worktree-project-target.ts index 6f4541f3783..766b0bf710f 100644 --- a/src/cli/worktree-project-target.ts +++ b/src/cli/worktree-project-target.ts @@ -1,4 +1,6 @@ +import { normalizeExecutionHostId, type ParsedExecutionHost } from '../shared/execution-host' import type { ProjectHostSetup } from '../shared/project-types' +import { hostFilterMatchesHostId, parseHostFlag } from './execution-host-flag' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' @@ -48,34 +50,44 @@ export async function resolveProjectCreateRepoSelector( return (await resolveProjectCreateTarget(flags, client))?.repoSelector } +// Why: the routed runtime can hold both an exact `runtime:` row and a `local` row for the +// same project; the exact stamp is the one the caller named, so it wins the selection. +function findReadySetupOnHost( + setups: readonly ProjectHostSetup[], + projectId: string | undefined, + host: ParsedExecutionHost | undefined +): ProjectHostSetup | undefined { + const candidates = setups.filter((candidate) => candidate.projectId === projectId) + if (!host) { + return candidates[0] + } + return ( + candidates.find((candidate) => normalizeExecutionHostId(candidate.hostId) === host.id) ?? + candidates.find((candidate) => hostFilterMatchesHostId(host, candidate.hostId)) + ) +} + export async function resolveProjectCreateTarget( flags: Map, client: RuntimeClient ): Promise { const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup') const projectId = getPresentStringFlag(flags, 'project') - const hostId = getPresentStringFlag(flags, 'host') - if (!projectHostSetupId && !projectId && !hostId) { + const host = parseHostFlag(flags) + if (!projectHostSetupId && !projectId && !host) { return undefined } const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') - const setup = result.result.setups.find((candidate) => { - if (candidate.setupState !== 'ready') { - return false - } - if (projectHostSetupId) { - return candidate.id === projectHostSetupId - } - return ( - candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId) - ) - }) + const ready = result.result.setups.filter((candidate) => candidate.setupState === 'ready') + const setup = projectHostSetupId + ? ready.find((candidate) => candidate.id === projectHostSetupId) + : findReadySetupOnHost(ready, projectId, host) if (!setup) { throw new RuntimeClientError( 'invalid_argument', projectHostSetupId ? `Project host setup is not ready or was not found: ${projectHostSetupId}` - : `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}` + : `Project is not set up on the selected host: ${projectId}${host ? ` on ${host.id}` : ''}` ) } return { From ddaaa4628cce2a0a992a49a34922cd2547dada9e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:14:44 -0700 Subject: [PATCH 013/168] fix(codex): keep two host-lane records that a failed read used to destroy (STA-4735) (#15289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `snapshotCodexRuntimeHookTrustProvenance` rebuilt `.orca-hook-trust-provenance.json` from the current `config.toml` on every install and refresh, including when the existing record could not be read. That record is the only thing separating a trust entry Orca wrote from one the user approved inside Codex, so rewriting it after a failed read stamps the approval as Orca-written — and `promoteCodexRuntimeHookApprovalsToSystem`, which runs earlier in the same pass and had already bailed on the same unreadable file, then skips it on every later pass too. One denied read, permanent loss. Only the unreadable case is preserved. A malformed or absent record is still rebuilt, because resetting those IS the intent; conflating the two would wedge a user on a corrupt file forever. `fileContentsEqual` returned `false` from a bare `catch`, so "I could not read this" reached `writeRuntimeAuth` as "these differ" and sent it to the unconditional write below — replacing a refresh token Codex may have rotated a moment earlier with Orca's stale copy. It now reports the difference only when the bytes were actually compared, and the caller refuses instead. `fileContentsMatchExpected`'s `!existsSync` has the same collapse but is left alone deliberately: the write it guards is `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the real file and refuses on its own, so classifying there would add a guard no test can drive. Noted in a comment. The three `writeRuntimeAuthAtPath` call sites have the same overwrite shape but are all on the WSL lane, which STA-4606 restructures; refusing there without its lane bookkeeping would set a baseline for a write that never happened. --- .../codex-accounts/runtime-home-service.ts | 34 +++- ...a-4735-unreadable-host-lane-writes.test.ts | 160 ++++++++++++++++++ src/main/codex/hook-trust-promotion.ts | 33 +++- ...35-hook-trust-provenance-overwrite.test.ts | 151 +++++++++++++++++ 4 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts create mode 100644 src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index d14ac6cffb8..242e9d68da9 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -16,6 +16,7 @@ import { symlinkSync, unlinkSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { execFileSync } from 'node:child_process' import { dirname, @@ -2011,7 +2012,15 @@ export class CodexRuntimeHomeService { } const provenance: CodexSharedRuntimeAuthProvenance = owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner - const runtimeAuthAlreadyMatches = this.fileContentsEqual(runtimeAuthPath, contents) + const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) + if (runtimeAuthComparison === null) { + // Why: an unreadable runtime auth.json may hold a token Codex rotated a + // moment ago. Treating "could not read" as "differs" sent execution to the + // unconditional write below, consuming that rotation and logging the user + // out for good. Refuse; the next sync retries. + return false + } + const runtimeAuthAlreadyMatches = runtimeAuthComparison if ( runtimeAuthAlreadyMatches && this.sharedRuntimeAuthProvenanceMatches( @@ -2059,16 +2068,31 @@ export class CodexRuntimeHomeService { writeFileAtomically(authPath, contents, { mode: 0o600 }) } - private fileContentsEqual(targetPath: string, contents: string): boolean { + /** + * `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 + * differ" for a file nobody could open, and every caller reads that as + * permission to write. + */ + private compareFileContents(targetPath: string, contents: string): boolean | null { try { - return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents - } catch { - return false + return readFileSync(targetPath, 'utf-8') === contents + } catch (error) { + return isDefinitiveAbsence(error) ? false : null } } + private fileContentsEqual(targetPath: string, contents: string): boolean { + return this.compareFileContents(targetPath, contents) === true + } + private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean { if (expectedContents === null) { + // Why: `!existsSync` does report `true` for a locked file, but this branch + // is not where that matters — the write it guards is + // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the + // real file and refuses on its own. Classifying here would be a guard no + // test can drive. return !existsSync(targetPath) } return this.fileContentsEqual(targetPath, expectedContents) diff --git a/src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts b/src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts new file mode 100644 index 00000000000..aaa0fb42312 --- /dev/null +++ b/src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import { join } from 'node:path' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getRuntimeCodexAuthPath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +// STA-4735: two host-lane reads still answered "absent" from a read that had +// only failed, and both answers authorised a write over live data. + +const denials = vi.hoisted(() => { + const state = { + paths: new Set(), + reads: new Map(), + deny(path: string): void { + state.paths.add(path) + }, + release(path: string): void { + state.paths.delete(path) + }, + readsFor(path: string): number { + return state.reads.get(path) ?? 0 + }, + reset(): void { + state.paths.clear() + state.reads.clear() + }, + check(target: unknown, syscall: string): void { + if (typeof target !== 'string' || !state.paths.has(target)) { + return + } + state.reads.set(target, (state.reads.get(target) ?? 0) + 1) + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, ${syscall} '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const guard = (fn: unknown, syscall: string): unknown => { + const original = fn as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => { + denials.check(args[0], syscall) + return original(...args) + } + return Object.assign(wrapped, original) + } + const patched: Record = { + ...actual, + readFileSync: guard(actual.readFileSync, 'read'), + lstatSync: guard(actual.lstatSync, 'lstat'), + statSync: guard(actual.statSync, 'stat'), + existsSync: Object.assign( + (...args: unknown[]): boolean => + typeof args[0] === 'string' && denials.paths.has(args[0]) + ? false + : actual.existsSync(args[0] as string), + actual.existsSync + ) + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir } })) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +const realFs = await vi.importActual('node:fs') + +type RuntimeAuthWriter = { + writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean +} + +describe('STA-4735 an unreadable runtime auth.json must not be written over', () => { + beforeEach(() => { + denials.reset() + setupRuntimeHomeTest() + }) + + afterEach(() => { + denials.reset() + teardownRuntimeHomeTest() + }) + + async function createService(): Promise { + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1') + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + return new CodexRuntimeHomeService(store as never) as unknown as RuntimeAuthWriter + } + + it('refuses the write instead of overwriting a token it could not read', async () => { + const service = await createService() + const runtimeAuthPath = getRuntimeCodexAuthPath() + const ROTATED = createCodexAuthJson('user@example.com', 'acct-1', 'refresh-rotated-by-codex') + const STALE = createCodexAuthJson('user@example.com', 'acct-1', 'refresh-stale-orca-copy') + realFs.mkdirSync(join(runtimeAuthPath, '..'), { recursive: true }) + realFs.writeFileSync(runtimeAuthPath, ROTATED, 'utf-8') + + denials.deny(runtimeAuthPath) + const wrote = service.writeRuntimeAuth(STALE, { owner: 'managed', accountId: 'account-1' }) + + // The fault really was consumed by the code under test. + expect(denials.readsFor(runtimeAuthPath)).toBeGreaterThan(0) + // THE FIX. Before it, "could not read" counted as "differs" and the write + // below replaced a freshly rotated refresh token with Orca's stale copy. + expect(wrote).toBe(false) + expect(realFs.readFileSync(runtimeAuthPath, 'utf-8')).toBe(ROTATED) + }) + + it('still writes once the file is readable again', async () => { + const service = await createService() + const runtimeAuthPath = getRuntimeCodexAuthPath() + const NEXT = createCodexAuthJson('user@example.com', 'acct-1', 'refresh-2') + realFs.mkdirSync(join(runtimeAuthPath, '..'), { recursive: true }) + realFs.writeFileSync(runtimeAuthPath, 'stale\n', 'utf-8') + + // Why: the anchor. A fix that simply stopped writing would pass the test + // above and break every credential sync. + const wrote = service.writeRuntimeAuth(NEXT, { owner: 'managed', accountId: 'account-1' }) + + expect(wrote).toBe(true) + expect(realFs.readFileSync(runtimeAuthPath, 'utf-8')).toBe(NEXT) + }) +}) diff --git a/src/main/codex/hook-trust-promotion.ts b/src/main/codex/hook-trust-promotion.ts index 16b19ceeae6..9f6b5a867e9 100644 --- a/src/main/codex/hook-trust-promotion.ts +++ b/src/main/codex/hook-trust-promotion.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { createManagedCommandMatcher, readHooksJson, @@ -43,15 +44,23 @@ function getProvenancePath(runtimeHomePath: string): string { return join(runtimeHomePath, '.orca-hook-trust-provenance.json') } +/** + * `null` means "no usable provenance": genuinely absent, or present but + * malformed, where rebuilding it IS the intent. It deliberately does NOT cover + * a file that could not be read — see `provenanceIsUnreadable`. + */ function readHookTrustProvenance( runtimeHomePath: string ): Map | null { const provenancePath = getProvenancePath(runtimeHomePath) - if (!existsSync(provenancePath)) { + let rawProvenance: string + try { + rawProvenance = readFileSync(provenancePath, 'utf-8') + } catch { return null } try { - const parsed: unknown = JSON.parse(readFileSync(provenancePath, 'utf-8')) + const parsed: unknown = JSON.parse(rawProvenance) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null } @@ -79,9 +88,29 @@ function readHookTrustProvenance( * install/refresh, so the next launch can tell "entry Orca wrote" apart from * "entry Codex wrote after a user approval". Call after all trust writes. */ +/** + * Why: this record is the only thing that tells a later launch which + * `config.toml` trust entries Orca wrote apart from which the user approved + * inside Codex. Overwriting it from the current config state after a failed + * read stamps the user's approval as Orca-written, and promotion then skips it + * forever — a permanent loss from one unreadable file. Keep the old record and + * let the next pass, which can read it, do the comparison. + */ +function provenanceIsUnreadable(provenancePath: string): boolean { + try { + readFileSync(provenancePath, 'utf-8') + return false + } catch (error) { + return !isDefinitiveAbsence(error) + } +} + export function snapshotCodexRuntimeHookTrustProvenance( runtimeHomePath: string = getOrcaManagedCodexHomePath() ): void { + if (provenanceIsUnreadable(getProvenancePath(runtimeHomePath))) { + return + } try { const runtimeHooksPath = join(runtimeHomePath, 'hooks.json') const canonicalRuntimeHooksPath = getCodexExplicitHomeHookSourcePath(runtimeHooksPath) diff --git a/src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts b/src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts new file mode 100644 index 00000000000..99997e1e961 --- /dev/null +++ b/src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// STA-4735: the hook-trust provenance file is the only record of which +// config.toml trust entries Orca wrote versus which the user approved inside +// Codex. An unreadable one was rebuilt from the current config on the same +// pass, stamping the user's approval as Orca-written — after which promotion +// skips it forever. + +const denials = vi.hoisted(() => { + const state = { + paths: new Set(), + deny(path: string): void { + state.paths.add(path) + }, + reset(): void { + state.paths.clear() + }, + check(target: unknown, syscall: string): void { + if (typeof target !== 'string' || !state.paths.has(target)) { + return + } + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, ${syscall} '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const guard = (fn: unknown, syscall: string): unknown => { + const original = fn as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => { + denials.check(args[0], syscall) + return original(...args) + } + return Object.assign(wrapped, original) + } + const patched: Record = { + ...actual, + readFileSync: guard(actual.readFileSync, 'read'), + existsSync: Object.assign( + (...args: unknown[]): boolean => + typeof args[0] === 'string' && denials.paths.has(args[0]) + ? false + : actual.existsSync(args[0] as string), + actual.existsSync + ) + } + return { ...patched, default: patched } +}) + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) + +const realFs = await vi.importActual('node:fs') +const { snapshotCodexRuntimeHookTrustProvenance } = await import('./hook-trust-promotion') + +const PROVENANCE_ENTRY = 'orca-hooks:stop:0:0' +let fakeHomeDir: string +let userDataDir: string +let runtimeHomePath: string + +const provenancePath = (): string => join(runtimeHomePath, '.orca-hook-trust-provenance.json') + +function seedRecordedProvenance(): string { + const contents = `${JSON.stringify( + { version: 1, entries: { [PROVENANCE_ENTRY]: { trustedHash: 'sha256:orca', enabled: true } } }, + null, + 2 + )}\n` + realFs.writeFileSync(provenancePath(), contents, 'utf-8') + return contents +} + +beforeEach(() => { + denials.reset() + fakeHomeDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4735-home-')) + userDataDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4735-data-')) + runtimeHomePath = join(userDataDir, 'codex-runtime-home', 'home') + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + realFs.mkdirSync(runtimeHomePath, { recursive: true }) + realFs.mkdirSync(join(fakeHomeDir, '.codex'), { recursive: true }) + realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "m"\n', 'utf-8') +}) + +afterEach(() => { + denials.reset() + realFs.rmSync(fakeHomeDir, { recursive: true, force: true }) + realFs.rmSync(userDataDir, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +describe('STA-4735 snapshotCodexRuntimeHookTrustProvenance', () => { + it('leaves a provenance record it could not read exactly as it found it', () => { + const recorded = seedRecordedProvenance() + denials.deny(provenancePath()) + + snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) + + // Before the fix this rewrote the file from the current config.toml, which + // holds no record of what Orca wrote — so a user approval made since the + // last pass was permanently reclassified as Orca's own write. + expect(realFs.readFileSync(provenancePath(), 'utf-8')).toBe(recorded) + }) + + it('still records a provenance file that does not exist yet', () => { + // Why: writing the first snapshot is this function's whole job. A fix that + // refused whenever the read failed would never seed one. + snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) + + expect(realFs.existsSync(provenancePath())).toBe(true) + expect(JSON.parse(realFs.readFileSync(provenancePath(), 'utf-8'))).toMatchObject({ version: 1 }) + }) + + it('still replaces a provenance file that is present but malformed', () => { + // Why: a corrupt record carries no information, and resetting it IS the + // intent. Only the unreadable case may be preserved — conflating the two + // would wedge a user on a broken file forever. + realFs.writeFileSync(provenancePath(), '{ not json', 'utf-8') + + snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) + + expect(JSON.parse(realFs.readFileSync(provenancePath(), 'utf-8'))).toMatchObject({ version: 1 }) + }) +}) From 2b2529c32adbcdd402e79b8bab00de5aed323544 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:14:50 -0700 Subject: [PATCH 014/168] fix(codex): stop a locked auth.json from deleting a just-authenticated account (STA-4734) (#15279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): stop a locked auth.json from deleting a just-authenticated account (STA-4734) A successful Codex login could be reported as a failure, and the rollback then deleted the managed home holding the credentials it had just written. Three reads on the add path answered "no credentials" from a read that had merely failed: - `service.ts` decided the login verdict with `existsSync(authJsonPath)`, which returns false for EPERM/EBUSY as readily as for ENOENT. The post-auth tree kill only arms after the watcher has already observed new credential bytes, so an unreadable file there is a lock, not a failed login. - `loadOAuthCredentials` read auth.json bare, so a denial surfaced as a generic error indistinguishable from a real one. - `importCodexAuthFromHome` turned an unreadable source file into "No Codex credentials found ... run `codex login` first" — advice to redo a login that had already succeeded. Each now classifies: only a definitive ENOENT/ENOTDIR means absent, everything else is `ManagedCodexHomeTemporarilyUnavailableError`. Classifying alone is inert, because the add path's rollback deletes the home for any error. `removeManagedHomeUnlessUnproven` holds the rollback back on an unproven failure — a kept home is a recoverable leak, a deleted one is permanent data loss. The mutation check for this is in the test: with the typed error but no rollback guard, the home is still destroyed. `isDefinitiveAbsence` is exported so the second credential lane reuses the host predicate instead of keeping its own copy of the errno allowlist. * refactor(codex): move the definitive-absence predicate to src/shared Why: STA-4737 needs the same predicate from src/main/codex, which the CLI tsconfig project does not admit imports from codex-accounts into. Both branches now carry byte-identical copies so they merge cleanly in either order. --- src/main/codex-accounts/service.ts | 56 ++- ...ollback-deletes-authenticated-home.test.ts | 368 ++++++++++++++++++ 2 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index fe0215b3258..a69db117808 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -60,6 +60,7 @@ import { assertOwnedHostCodexManagedHomePath, ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -762,7 +763,7 @@ export class CodexAccountService { await this.runCodexLogin(managedHomePath) return await this.persistCapturedCodexAccount(accountId, managedHome) } catch (error) { - this.safeRemoveManagedHome(managedHomePath, accountId) + this.removeManagedHomeUnlessUnproven(error, managedHomePath, accountId) throw error } } @@ -781,7 +782,7 @@ export class CodexAccountService { this.importCodexAuthFromHome(sourceHome, managedHomePath, accountId) return await this.persistCapturedCodexAccount(accountId, managedHome) } catch (error) { - this.safeRemoveManagedHome(managedHomePath, accountId) + this.removeManagedHomeUnlessUnproven(error, managedHomePath, accountId) throw error } } @@ -799,13 +800,22 @@ export class CodexAccountService { throw new Error('A Codex home directory path is required.') } const authPath = join(resolve(trimmed), 'auth.json') - if (!existsSync(authPath)) { + let sourceAuthContents: string + try { + sourceAuthContents = readFileSync(authPath, 'utf-8') + } catch (error) { + // Why: "no credentials here, run codex login" is only true for a definitive + // absence. A locked source file used to produce that advice, which sends the + // user to re-run a login they had already completed. + if (!isDefinitiveAbsence(error)) { + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) + } throw new Error( `No Codex credentials found in ${resolve(trimmed)}. Run \`codex login\` into this directory first.` ) } const trustedHome = this.assertManagedHomePath(managedHomePath, accountId) - writeFileAtomically(join(trustedHome, 'auth.json'), readFileSync(authPath, 'utf-8'), { + writeFileAtomically(join(trustedHome, 'auth.json'), sourceAuthContents, { mode: 0o600 }) } @@ -1624,6 +1634,22 @@ export class CodexAccountService { } } + /** + * Rollback deletes the managed home, so it may only run on a *proven* failure. + * An unreadable credential file means the login may well have succeeded, and a + * kept home is a recoverable leak where a deleted one is permanent data loss. + */ + private removeManagedHomeUnlessUnproven( + error: unknown, + managedHomePath: string, + accountId: string + ): void { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + return + } + this.safeRemoveManagedHome(managedHomePath, accountId) + } + private safeRemoveManagedHome(candidatePath: string, expectedAccountId: string): void { let managedHomePath: string try { @@ -1794,7 +1820,14 @@ export class CodexAccountService { // Why: the post-auth tree kill is a success path — auth.json already // exists and codex only failed to exit on its own, so the forced // non-zero exit must not surface as a login failure. - if (code === 0 || (loginTreeKilledAfterAuth && existsSync(authJsonPath))) { + // Why: the kill only arms after the watcher observed new credential + // bytes, so an unreadable auth.json here is a lock, not a failed login. + // Only a definitive absence may revoke that verdict — reading a lock as + // failure sends the caller's rollback at a home that just authenticated. + if ( + code === 0 || + (loginTreeKilledAfterAuth && readLoginAuthSnapshot(authJsonPath) !== null) + ) { resolvePromise() return } @@ -1875,7 +1908,18 @@ export class CodexAccountService { this.assertManagedHomePath(managedHomePath, expectedAccountId), 'auth.json' ) - const authFileContents = readFileSync(authFilePath, 'utf-8') + let authFileContents: string + try { + authFileContents = readFileSync(authFilePath, 'utf-8') + } catch (error) { + // Why: an unreadable auth.json is not a missing credential. Surfacing it as + // a generic failure is what lets the add path's rollback delete a home + // holding freshly authenticated bytes. + if (isDefinitiveAbsence(error)) { + throw error + } + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) + } let parsed: Record try { parsed = JSON.parse(authFileContents) as Record diff --git a/src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts b/src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts new file mode 100644 index 00000000000..8e4c90d1b68 --- /dev/null +++ b/src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type * as NodeFs from 'node:fs' +import type * as HostCodexManagedHomeOwnership from './host-codex-managed-home-ownership' +import { PassThrough } from 'node:stream' +import { join } from 'node:path' +import { + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' + +// STA-4734: the login verdict and the identity read both decided "no credentials" +// from a read that had merely failed, and doAddAccount's rollback then deleted the +// managed home that had just been authenticated successfully. + +/** + * One denial, modelled coherently: a path under an ACL denial fails `readFileSync` + * AND reports `false` from `existsSync`. Faulting only one of them lets a test pass + * against code that still consults the other. + */ +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const authFaults = vi.hoisted(() => { + const state = { + denied: new Set(), + deny(path: string): void { + state.denied.add(path) + }, + reset(): void { + state.denied.clear() + }, + isDenied(target: unknown): boolean { + return typeof target === 'string' && state.denied.has(target) + }, + throwDenial(target: string, syscall: string): never { + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, ${syscall} '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const originalRead = actual.readFileSync as (...args: unknown[]) => unknown + const originalExists = actual.existsSync as (...args: unknown[]) => boolean + const patched: Record = { + ...actual, + readFileSync: Object.assign((...args: unknown[]): unknown => { + if (authFaults.isDenied(args[0])) { + authFaults.throwDenial(args[0] as string, 'read') + } + return originalRead(...args) + }, originalRead), + existsSync: Object.assign((...args: unknown[]): boolean => { + // Why: existsSync swallows every errno, so a denied path reads as absent — + // this is the exact collapse the fix removes, and modelling it is what lets + // the pre-fix behaviour be observed. + return authFaults.isDenied(args[0]) ? false : originalExists(...args) + }, originalExists) + } + return { ...patched, default: patched } +}) + +const { + existsSync: realExistsSync, + readdirSync: realReaddirSync, + readFileSync: realReadFileSync, + writeFileSync: realWriteFileSync +} = await vi.importActual('node:fs') + +/** + * Why: `vi.resetModules()` gives each test a fresh module graph, so a top-level + * import of the error class is a DIFFERENT constructor than the one the service + * under test throws — `toBeInstanceOf` would fail against a correct fix. + */ +async function importTemporarilyUnavailableError(): Promise< + typeof HostCodexManagedHomeOwnership.ManagedCodexHomeTemporarilyUnavailableError +> { + return (await import('./host-codex-managed-home-ownership')) + .ManagedCodexHomeTemporarilyUnavailableError +} + +function makeLoginChild(): EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void +} { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + return child +} + +function authJsonFor(email: string): string { + const payload = Buffer.from(JSON.stringify({ email })).toString('base64url') + return JSON.stringify({ tokens: { id_token: `header.${payload}.signature` } }) +} + +/** Every managed home the add path created, whether or not it survived. */ +function listManagedHomes(): string[] { + const root = join(testState.userDataDir, 'codex-accounts') + if (!realExistsSync(root)) { + return [] + } + return realReaddirSync(root).map((accountId) => join(root, accountId, 'home')) +} + +describe('STA-4734 a locked auth.json must not delete a just-authenticated home', () => { + registerCodexAccountsTestHomes() + + it('keeps the managed home and its credentials when the identity read is denied', async () => { + vi.resetModules() + authFaults.reset() + + let deniedAuthPath = '' + const spawnMock = vi.fn( + (_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + const child = makeLoginChild() + const loginHome = options.env.CODEX_HOME! + // The login genuinely succeeds: real credential bytes land on disk. + deniedAuthPath = join(loginHome, 'auth.json') + realWriteFileSync(deniedAuthPath, authJsonFor('user@example.com'), 'utf-8') + // Only now does the scanner take the file. + authFaults.deny(deniedAuthPath) + queueMicrotask(() => child.emit('close', 0)) + return child + } + ) + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + try { + const store = createStore(createSettings()) + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + await expect(service.addAccount()).rejects.toBeInstanceOf( + await importTemporarilyUnavailableError() + ) + + // THE FIX: the rollback is refused, so the credentials survive the failure. + // Before it, safeRemoveManagedHome deleted this tree. + expect(deniedAuthPath).not.toBe('') + const survivors = listManagedHomes().filter((home) => realExistsSync(home)) + expect(survivors).toHaveLength(1) + expect(realExistsSync(deniedAuthPath)).toBe(true) + expect(realReadFileSync(deniedAuthPath, 'utf-8')).toBe(authJsonFor('user@example.com')) + } finally { + authFaults.reset() + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + } + }) + + it('still deletes the managed home when the login genuinely fails', async () => { + vi.resetModules() + authFaults.reset() + + // Why: the fix must not turn every add failure into a kept home. A real + // failure — no credentials written, non-zero exit — still rolls back. + const spawnMock = vi.fn(() => { + const child = makeLoginChild() + queueMicrotask(() => child.emit('close', 1)) + return child + }) + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + await expect(service.addAccount()).rejects.toThrow(/Codex login exited with code 1/) + expect(listManagedHomes().filter((home) => realExistsSync(home))).toHaveLength(0) + } finally { + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + } + }) + + it('treats a denied auth.json after a Windows tree kill as the success it is', async () => { + vi.resetModules() + authFaults.reset() + vi.useFakeTimers() + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + + const child = makeLoginChild() as ReturnType & { + pid: number + exitCode: number | null + signalCode: string | null + } + child.pid = 5150 + child.exitCode = null + child.signalCode = null + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: vi.fn(() => child) })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + const loginPromise = ( + service as unknown as { runCodexLogin(managedHomePath: string): Promise } + ).runCodexLogin(testState.fakeHomeDir) + + const authPath = join(testState.fakeHomeDir, 'auth.json') + realWriteFileSync(authPath, authJsonFor('user@example.com'), 'utf-8') + // The watcher observes the new bytes and arms the post-auth kill. + await vi.advanceTimersByTimeAsync(6_000) + + // The scanner takes the file only after the kill is armed, so the close + // handler's re-confirmation is the read that fails. + authFaults.deny(authPath) + child.emit('close', 1) + + // THE FIX: the kill only arms on observed credential bytes, so a denial here + // cannot revoke that. Before it, existsSync returned false and this rejected. + await expect(loginPromise).resolves.toBeUndefined() + } finally { + authFaults.reset() + Object.defineProperty(process, 'platform', originalPlatform) + vi.useRealTimers() + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + } + }) + + it('still fails a Windows tree kill whose auth.json is definitively gone', async () => { + vi.resetModules() + authFaults.reset() + vi.useFakeTimers() + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + + const child = makeLoginChild() as ReturnType & { + pid: number + exitCode: number | null + signalCode: string | null + } + child.pid = 5151 + child.exitCode = null + child.signalCode = null + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: vi.fn(() => child) })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + const loginPromise = ( + service as unknown as { runCodexLogin(managedHomePath: string): Promise } + ).runCodexLogin(testState.fakeHomeDir) + + // Why: ENOENT is a real answer. Without this anchor the fix could resolve + // every non-zero exit and the suite would not notice. + await vi.advanceTimersByTimeAsync(6_000) + child.emit('close', 1) + + await expect(loginPromise).rejects.toThrow(/Codex login exited with code 1/) + } finally { + Object.defineProperty(process, 'platform', originalPlatform) + vi.useRealTimers() + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + } + }) + + it('does not advise "run codex login" for a source home it merely could not read', async () => { + vi.resetModules() + authFaults.reset() + + const sourceHome = join(testState.fakeHomeDir, '.codex') + const sourceAuthPath = join(sourceHome, 'auth.json') + realWriteFileSync(sourceAuthPath, authJsonFor('imported@example.com'), 'utf-8') + authFaults.deny(sourceAuthPath) + + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: vi.fn() })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + // Before the fix this reported "No Codex credentials found … Run `codex + // login` into this directory first" — advice to redo a login that had + // already succeeded. + const rejection = service.addAccountFromHome(sourceHome) + await expect(rejection).rejects.toBeInstanceOf(await importTemporarilyUnavailableError()) + await expect(rejection).rejects.not.toThrow(/Run `codex login`/) + } finally { + authFaults.reset() + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + } + }) + + it('still reports a genuinely empty source home as having no credentials', async () => { + vi.resetModules() + authFaults.reset() + + const sourceHome = join(testState.fakeHomeDir, 'empty-codex-home') + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: vi.fn() })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + await expect(service.addAccountFromHome(sourceHome)).rejects.toThrow( + /No Codex credentials found/ + ) + } finally { + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + } + }) +}) From 79be5b7fde1a78bf5aca52999167b55d2d72ffdf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:19:20 -0700 Subject: [PATCH 015/168] feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) (#15261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) A lane parked on an approval, trust, or permission prompt looked exactly like a lane that was thinking or inside a long tool call. On origin/main, driving a real cursor-agent through Orca: surface running `sleep 60` awaiting approval worktree ps agents[].state working working terminal show / list no such field no such field terminal wait --for tui-idle satisfied: true satisfied: true worker-show no agent state no agent state The runtime already fuses hook state, OSC title, and matched prompt text into a `permission` verdict inside getTerminalAgentStatus — it was reachable only from the renderer, and it was blind to cursor-agent approvals. Two gaps, one boundary. Exposure: getTerminalInteractiveWait publishes that same fusion, minus the async foreground probe, as `agentWait` on `terminal show` and on `worker-show`'s observation. It carries the evidence that proved the wait (hook, prompt-text, or title) so a coordinator can weigh it. Null means no proof; a missing field means the host predates it — absence is never read as "not waiting". Detection: cursor-agent's hook set has no approval event and beforeShellExecution fires identically for auto-allowed commands, so its rendered menu is the only authority. Matched on the key-bound choices rather than the prose, requiring two, and self-clearing when the follow-up input line returns. Its live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits. Falls out of routing it through the shared verdict: `dispatch --inject` into a cursor pane on an approval now refuses with agent_prompt_blocked instead of typing the preamble into the dialog. Fixtures are captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca; the same case matrix was replayed live against a built runtime. terminal list stays untouched: its rows would each need a full tail scan, and STA-4694 owns the one-call-per-run aggregate. * fix(orchestration): only call a Cursor approval live while it owns the screen Independent review found the approval detector trusted one dismissal string, so any later output that did not contain cursor's follow-up line left the menu reading as a live wait. Reproduced: a tail of the real menu followed by two lines of ordinary output returned agent-approval-prompt, which fails tui-idle and refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for: a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the retained tail. That tolerates a status footer or a partial line mid-redraw without admitting scrollback, and it drops the vendor prose. Being bottom-of-screen is also the dating this reason needed, so it no longer requires waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so before this, an Orca restart made exactly the lane both issues are about go quiet for good. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Also from review: - worker-show and federationShow reuse the verdict showTerminal already computed rather than rescanning the tail, so the two can no longer disagree. - The worker-show test now drives a real runtime, real PTY tail, and the real detector; it previously mocked getTerminalInteractiveWait, so it would have passed with detection permanently returning null. - The guard claim is now asserted against the guard: a blocked pane rejects both assertTerminalAgentSendable and sendTerminalAgentPrompt, and a working pane still passes. - Added a non-local (connectionId) pane case, since the verdict is derived from retained tail and title state on every host. * fix(agent-status): stop a hook wait from outliving its agent A third reviewer caught that the hook branch proved agent ownership from the pane title alone, while the shared verdict it claimed to reuse also probes the foreground process. A shell that takes a pane back usually sets something like `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for AGENT_STATUS_STALE_AFTER_MS — so a dead agent could be reported as waiting on a human for half an hour. Hook evidence now goes through getTerminalAgentStatus, which is the only thing that can answer whether an agent still owns this PTY. The two prompt branches skip it: a matched prompt is on the pane's screen now, so it proves itself. That makes the probe cost fall exactly where correctness needs it, and getTerminalInteractiveWait async, which only showTerminal had to absorb. Also trims the comments the same reviewer flagged as longer than the repo's rule. * test(agent-status): pin that a dead pane stops reporting a human wait A fourth reviewer noted the approval menu sits at the bottom of a dead pane's tail forever, and that no test covered process exit with no trailing output. The snapshot already refuses an exited pane, and worker-show gates agentWait on proven identity — this pins both so neither can drift into reporting a worker that needs intervention as one that needs an answer. * fix(orchestration): never report an unchecked worker as not waiting Automated review caught that the three worker paths which return before the wait is ever evaluated — unattached, missing, and identity_changed — then had their undefined coerced to null by the emitters. A worker whose process was replaced was reported as `agentWait: null`, which reads as "Orca looked and nobody is waiting" when Orca never looked. That is the false negative this field exists to remove. The field is now emitted only when it was evaluated, so a present null is a claim about the pane and an absent one means nobody looked — because the host predates the field, or the worker's identity could not be verified. The CLI and the worker-show note say that rather than blaming an old host. Covered on the context-only path, where the regression test fails against the previous behavior; the supervised and federated emitters take the identical one-line change. Also trims the two test-file headers to one statement of purpose. * fix(agent-status): tighten the Cursor menu match and stop guessing on unknowns Fourth review round, three findings, each reproduced before acting. Matching each choice marker with an independent lastIndexOf let text outside the menu carry the anchor. An agent narrating "next time I'll suggest Run Everything" after the menu was answered pulled the match down to the bottom of the screen and revived it. The match is now confined to the last lines of the tail, and a choice is a line that ends in the key that picks it — prose writes the same words but not the same shape. The one line of slack under the dialog went with it. It was a guess; every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. A redraw caught mid-flight now reads as no wait until the next poll, which is the safe way to be wrong. The hook branch awaited a foreground probe that reaches a PTY controller which may be a remote host, so a wedged probe stalled every caller of showTerminal — a path that never probed before. It is bounded now, and a timeout leaves the wait unevaluated rather than claiming there is none. Which is the same distinction the previous commit only fixed one level up: getTerminalInteractiveWait itself turned an unreadable pane into `null`, so showTerminal published "looked, nobody waiting" for a pane it could not read. It returns undefined there, showTerminal omits the key, and worker-show's text output prints unknown rather than rendering it the same as none. * fix(agent-status): bound the wedged probe's cost and stop matching prose keys Fifth review round. No correctness defects in the shipped behaviour this time; two robustness holes and the documentation of the contract. The bounded probe abandoned the wait but not the request, so a coordinator watching a wedged remote host added one live probe on every poll. It is single-flighted per PTY now, the way the leaf-absence probe already is. The trailing-key rule that separates a menu row from the agent narrating a choice was written as a character class, and any lowercase run up to twelve characters satisfied it — "…suggest Run Everything (as before)" passed. Spelled out as key names instead, which also lets the glyph forms of those keys through. The contract wording said an absent agentWait meant an old host or an unverifiable identity. It also covers an unreadable pane and a probe that did not answer, and a reader diagnosing an old peer from that would be wrong. Corrected on the type, the worker-show note, and in docs/reference/remote-wire-compatibility.md, which had no entry for a field whose absent and null states mean different things. Also strengthens the worker-show agreement test, which compared the terminal and observation payloads without asserting either held the expected wait, so it passed when both were absent. --- docs/reference/remote-wire-compatibility.md | 21 + ...orchestration-worker-show-wait-cli.test.ts | 74 +++ src/cli/handlers/orchestration.ts | 20 +- src/cli/specs/orchestration-worker-specs.ts | 3 +- src/cli/terminal-format.ts | 13 + .../cursor-agent-approval-prompt.txt | 24 + .../cursor-agent-idle-after-approval.txt | 40 ++ .../cursor-agent-long-tool-call.txt | 41 ++ src/main/runtime/orca-runtime.ts | 204 +++++++- .../orchestration-federation-control.ts | 13 +- .../methods/orchestration-worker-control.ts | 6 +- ...hestration-worker-interactive-wait.test.ts | 168 ++++++ .../orchestration-worker-observation.ts | 26 +- ...rminal-interactive-wait-visibility.test.ts | 490 ++++++++++++++++++ src/shared/runtime-types.ts | 22 + 15 files changed, 1142 insertions(+), 23 deletions(-) create mode 100644 src/cli/handlers/orchestration-worker-show-wait-cli.test.ts create mode 100644 src/main/runtime/__fixtures__/cursor-agent-approval-prompt.txt create mode 100644 src/main/runtime/__fixtures__/cursor-agent-idle-after-approval.txt create mode 100644 src/main/runtime/__fixtures__/cursor-agent-long-tool-call.txt create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts create mode 100644 src/main/runtime/terminal-interactive-wait-visibility.test.ts diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 4cfe9545d2e..87ed2586948 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -100,6 +100,27 @@ sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, the relay transport. A change on those paths still needs its own reasoning against the three rules above. +## Worked example: `agentWait` on terminal and worker reads + +`terminal.show`, `orchestration.workerShow` and `orchestration.federationShow` carry an +optional `agentWait` naming a pane parked on a prompt only a human can answer. It is Rule 1 — +a new optional field — but it has a second state that Rule 1 alone does not describe, and +getting that wrong turns a skew into a false "nothing is blocked". + +- **present object** — this pane is waiting, with the evidence that proved it. +- **present `null`** — the host evaluated this pane and nothing proves a wait. +- **absent** — the host never evaluated it: it predates the field, the worker identity was + unverifiable, the pane was unreadable, or the agent probe did not answer in time. + +A new client against an old host sees the field absent, which is why absence must read as +*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a +convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable +from a healthy idle worker, which is the exact failure the field exists to remove. + +An old client against a new host ignores the key, as Rule 1 allows. New members added to +`RuntimeTerminalWaitBlockedReason` are also Rule 1: no consumer switches exhaustively on it, +and both the CLI and worker-start interpolate it as an opaque string. + ## Known debt: JSON-RPC errors drop Node's string code An error raised on an SSH host crosses the relay as JSON-RPC, and diff --git a/src/cli/handlers/orchestration-worker-show-wait-cli.test.ts b/src/cli/handlers/orchestration-worker-show-wait-cli.test.ts new file mode 100644 index 00000000000..e42fea9850e --- /dev/null +++ b/src/cli/handlers/orchestration-worker-show-wait-cli.test.ts @@ -0,0 +1,74 @@ +// `worker-show` text output must keep "nobody is waiting" apart from "nobody looked". +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { printResult } from '../format' + +function renderedLine(result: unknown): string { + const [value, , render] = vi.mocked(printResult).mock.calls[0] as [ + unknown, + boolean, + (value: unknown) => string + ] + expect(value).toBe(result) + return render(value) +} + +async function showWorker(observation: unknown): Promise { + const result = { + dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' }, + worker: { state: 'ready', stage: 'dispatch_input', agent_terminal_handle: 'term_1' }, + ...(observation === undefined ? {} : { observation }) + } + callMock.mockResolvedValue(result) + await ORCHESTRATION_HANDLERS['orchestration worker-show']({ + flags: new Map([['dispatch', 'ctx_1']]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + return renderedLine(result) +} + +describe('orchestration worker-show interactive wait output', () => { + beforeEach(() => { + callMock.mockReset() + vi.mocked(printResult).mockReset() + }) + + it('names the prompt when a worker is waiting on a human', async () => { + const line = await showWorker({ + status: 'live', + exactWorker: true, + agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt', since: 1 } + }) + + expect(line).toContain('Waiting on a human: agent-approval-prompt (via prompt-text)') + }) + + it('says none when the pane was evaluated and nothing was waiting', async () => { + const line = await showWorker({ status: 'live', exactWorker: true, agentWait: null }) + + expect(line).toContain('Interactive wait: none') + }) + + it('says unknown when the field is absent, not none', async () => { + // An older host, or a worker whose identity could not be verified. Printing the same + // thing as an evaluated `null` is the false negative this field exists to remove. + const line = await showWorker({ status: 'identity_changed', exactWorker: false }) + + expect(line).toContain('Interactive wait: unknown (not evaluated)') + expect(line).not.toContain('Interactive wait: none') + }) + + it('says unknown when the host sent no observation at all', async () => { + const line = await showWorker(undefined) + + expect(line).toContain('Interactive wait: unknown (not evaluated)') + }) +}) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index e529fa62d11..43fffc54b8c 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -939,15 +939,23 @@ export const ORCHESTRATION_HANDLERS: Record = { const result = await client.call<{ dispatch: { id: string; task_id: string; status: string } worker: { state: string; stage: string; agent_terminal_handle: string | null } + observation?: { agentWait?: { source: string; reason?: string } | null } }>('orchestration.workerShow', { dispatch: getRequiredStringFlag(flags, 'dispatch') }) - printResult( - result, - json, - (value) => - `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` - ) + printResult(result, json, (value) => { + const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` + // Why a whole line and not a suffix: this is the one state where the lane is healthy + // and still needs a person, so it must not read as another status token. An absent + // field is unknown, which must not print the same as an evaluated "none". + if (value.observation === undefined || !('agentWait' in value.observation)) { + return `${base}\nInteractive wait: unknown (not evaluated)` + } + const wait = value.observation.agentWait + return wait + ? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})` + : `${base}\nInteractive wait: none` + }) }, 'orchestration worker-read': async ({ flags, client, json }) => { diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 22430c14456..31377eb4384 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -44,7 +44,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration worker-show --dispatch [--json]', allowedFlags: [...GLOBAL_FLAGS, 'dispatch'], notes: [ - 'A Dispatch created by orchestration dispatch is shown as unsupervised and reports the exact adopted terminal when its identity is still provable.' + 'A Dispatch created by orchestration dispatch is shown as unsupervised and reports the exact adopted terminal when its identity is still provable.', + 'observation.agentWait names a worker parked on a prompt only a human can answer, with the evidence that proved it (hook, prompt-text, or title). Null means Orca looked and found no wait. An absent field means it never looked — an older host, an unverifiable worker identity, an unreadable pane, or an agent probe that did not answer in time — and never means the worker is not waiting. A waiting worker is healthy, not failed.' ] }, { diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 717f1d33701..97da0e890c6 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -113,10 +113,23 @@ export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): s `ptyId: ${terminal.ptyId ?? 'none'}`, `connected: ${terminal.connected}`, `writable: ${terminal.writable}`, + // Why listed above the preview: the preview is where a reader would otherwise have to + // spot the prompt by eye, which is the work this line exists to remove. + `agentWait: ${formatAgentWait(terminal.agentWait)}`, `preview: ${terminal.preview || ''}` ].join('\n') } +function formatAgentWait(agentWait: RuntimeTerminalShow['agentWait']): string { + if (agentWait === undefined) { + return 'unknown (not evaluated)' + } + if (!agentWait) { + return 'none' + } + return `${agentWait.reason ?? 'interactive prompt'} (via ${agentWait.source})` +} + export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string { const terminal = result.terminal const oldestCursor = diff --git a/src/main/runtime/__fixtures__/cursor-agent-approval-prompt.txt b/src/main/runtime/__fixtures__/cursor-agent-approval-prompt.txt new file mode 100644 index 00000000000..fd85a77c44f --- /dev/null +++ b/src/main/runtime/__fixtures__/cursor-agent-approval-prompt.txt @@ -0,0 +1,24 @@ +p/sta4513/live-cursor-trust && cursor-agent + + + Cursor Agent + v2026.08.11-e8db854 + Tip: Use /mcp to connect Cursor to your tools and data sources. + + + Run the shell command: git status --porcelain + + + I'll run git status --porcelain in the workspace now. + + $ git status --porcelain Waiting for approval... + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + $ git status --porcelain in . + + Run this command? + Not in allowlist: git status + → Run (once) (y) + Add Shell(git status) to allowlist? (tab) + Run Everything (shift+tab) + Skip & tell the agent what to do instead (esc or n) diff --git a/src/main/runtime/__fixtures__/cursor-agent-idle-after-approval.txt b/src/main/runtime/__fixtures__/cursor-agent-idle-after-approval.txt new file mode 100644 index 00000000000..4e4e9192e4d --- /dev/null +++ b/src/main/runtime/__fixtures__/cursor-agent-idle-after-approval.txt @@ -0,0 +1,40 @@ +p/sta4513/live-cursor-trust && cursor-agent + + + Cursor Agent + v2026.08.11-e8db854 + Tip: Use /mcp to connect Cursor to your tools and data sources. + + + Run the shell command: git status --porcelain + + + I'll run git status --porcelain in the workspace now. + + $ git status --porcelain exit 128 • 9.6s + fatal: not a git repository (or any of the parent directories): .git + + git status --porcelain failed: + + fatal: not a git repository (or any of the parent directories): .git + + This workspace (/private/tmp/sta4513/live-cursor-trust) is not a git repo. + + + Run the shell command: sleep 60 + + + I'll run sleep 60 and wait until it finishes. + + $ sleep 60 1m 14s + + sleep 60 finished successfully (exit code 0) after about 60 seconds. + + + + + → Add a follow-up + + + Auto · 6.3% + /private/tmp/sta4513/live-cursor-trust \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cursor-agent-long-tool-call.txt b/src/main/runtime/__fixtures__/cursor-agent-long-tool-call.txt new file mode 100644 index 00000000000..cfaed659a8f --- /dev/null +++ b/src/main/runtime/__fixtures__/cursor-agent-long-tool-call.txt @@ -0,0 +1,41 @@ +p/sta4513/live-cursor-trust && cursor-agent + + + Cursor Agent + v2026.08.11-e8db854 + Tip: Use /mcp to connect Cursor to your tools and data sources. + + + Run the shell command: git status --porcelain + + + I'll run git status --porcelain in the workspace now. + + $ git status --porcelain exit 128 • 9.6s + fatal: not a git repository (or any of the parent directories): .git + + git status --porcelain failed: + + fatal: not a git repository (or any of the parent directories): .git + + This workspace (/private/tmp/sta4513/live-cursor-trust) is not a git repo. + + + Run the shell command: sleep 60 + + + I'll run sleep 60 and wait until it finishes. + + $ sleep 60 29s + ctrl+b twice to send to background + + ⠘⠆ Running 53 tokens + Tip: Use /debug to instrument and debug complex problems. + + + → Add a follow-up ctrl+c to stop + + + 1 task + Auto · 6.2% + /private/tmp/sta4513/live-cursor-trust \ No newline at end of file diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b4fe067c4a8..1cb745ebcba 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -453,6 +453,7 @@ import { type RuntimeWorktreeStatus, type RuntimeSpeechModelSummary, type RuntimeSpeechSetupState, + type RuntimeTerminalInteractiveWait, type RuntimeTerminalShow, type RuntimeTerminalSummary, type RuntimeTerminalVisualGroupNode, @@ -18302,7 +18303,8 @@ export class OrcaRuntimeService { leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId, paneRuntimeId: -1, ptyId: pty.pty.ptyId, - rendererGraphEpoch: this.rendererGraphEpoch + rendererGraphEpoch: this.rendererGraphEpoch, + ...expandTerminalInteractiveWait(await this.getTerminalInteractiveWait(handle)) } } const graphEpoch = this.captureReadyGraphEpoch() @@ -18322,7 +18324,8 @@ export class OrcaRuntimeService { preview, paneRuntimeId: leaf.paneRuntimeId, ptyId: leaf.ptyId, - rendererGraphEpoch: this.rendererGraphEpoch + rendererGraphEpoch: this.rendererGraphEpoch, + ...expandTerminalInteractiveWait(await this.getTerminalInteractiveWait(handle)) } } @@ -18670,17 +18673,37 @@ export class OrcaRuntimeService { explicitStatus: { status: AgentStatus; updatedAt: number } | null, lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined ): boolean { + return ( + this.resolveAuthoritativeTerminalWaitPermission(terminal, explicitStatus, lifecycle) !== null + ) + } + + /** The matched prompt when the tail authoritatively proves a human wait, else null. */ + private resolveAuthoritativeTerminalWaitPermission( + terminal: TerminalAgentStatusSnapshot, + explicitStatus: { status: AgentStatus; updatedAt: number } | null, + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + ): RuntimeTerminalWaitBlockedReason | null { const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) if (!blockedByWaitText) { - return false + return null } const liveTitleClearsBlockedText = terminal.titleStatusIsLive && terminal.titleStatus !== null && terminal.titleStatus !== 'permission' && - !isOpenCodeNativeTitle(terminal.title) + !isOpenCodeNativeTitle(terminal.title) && + // Why exempt: cursor-agent keeps spinning in its title while an approval waits, so a + // "working" title there is not evidence of progress. + blockedByWaitText !== 'agent-approval-prompt' if (liveTitleClearsBlockedText && lifecycle?.status !== terminal.titleStatus) { - return false + return null + } + // Why no timestamp: the menu is only matched while it still owns the bottom of the screen, + // which is the dating. Requiring one would lose a dialog restored from history, across the + // app restart an unattended run has to survive. + if (blockedByWaitText === 'agent-approval-prompt') { + return blockedByWaitText } const newestPermissionAt = Math.max( explicitStatus?.status === 'permission' ? explicitStatus.updatedAt : -1, @@ -18692,7 +18715,86 @@ export class OrcaRuntimeService { lifecycle?.status && lifecycle.status !== 'permission' ? lifecycle.updatedAt : -1 ) // Equal wall-clock observations fail closed because their raw intra-chunk order is unknown. - return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt + return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt ? blockedByWaitText : null + } + + /** Why: "blocked on a human" is the one worker state a coordinator cannot infer — silence + * covers it, a long tool call, and a wedged process alike. `null` means this pane was + * evaluated and nothing proves a wait; `undefined` means it could not be evaluated, which + * is never the same as "not waiting". */ + async getTerminalInteractiveWait( + handle: string + ): Promise { + let ptyId: string + let terminal: TerminalAgentStatusSnapshot + try { + ptyId = this.getTerminalAgentStatusPtyId(handle) + terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + } catch { + return undefined + } + const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) + const promptReason = this.resolveAuthoritativeTerminalWaitPermission( + terminal, + explicitStatus, + this.agentPromptLifecycleByPtyId.get(ptyId) + ) + if (promptReason) { + // A matched prompt is self-proving: it is on this pane's screen now. + return { + source: 'prompt-text', + reason: promptReason, + ...(terminal.waitBlockedAt !== null ? { since: terminal.waitBlockedAt } : {}) + } + } + if (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) { + return { source: 'title' } + } + if (explicitStatus?.status !== 'permission') { + return null + } + // Why the extra round trip for hook evidence only: a hook row outlives its agent by up to + // AGENT_STATUS_STALE_AFTER_MS, and only the shared verdict proves an agent still owns this + // PTY — a shell that took the pane back rarely leaves a title we can recognize as one. + // Bounded because it reaches a PTY controller that can be a remote host: a wedged probe + // must leave the wait unevaluated, never stall every caller of showTerminal. + const status = await withTimeout( + this.probeAgentStatusOncePerPty(handle, ptyId), + TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS, + undefined + ) + if (!status) { + return undefined + } + return status.isRunningAgent && status.status === 'permission' + ? { source: 'hook', since: explicitStatus.updatedAt } + : null + } + + // Why single-flight: the timeout above abandons the wait, not the request. A wedged remote + // host would otherwise accrue one live probe per poll for as long as a coordinator watches. + private readonly interactiveWaitProbesByPtyId = new Map< + string, + Promise + >() + + private probeAgentStatusOncePerPty( + handle: string, + ptyId: string + ): Promise { + const inFlight = this.interactiveWaitProbesByPtyId.get(ptyId) + if (inFlight) { + return inFlight + } + const probe = this.getTerminalAgentStatus(handle) + .catch(() => undefined) + .finally(() => { + if (this.interactiveWaitProbesByPtyId.get(ptyId) === probe) { + this.interactiveWaitProbesByPtyId.delete(ptyId) + } + }) + this.interactiveWaitProbesByPtyId.set(ptyId, probe) + return probe } private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { @@ -39652,7 +39754,91 @@ function isTerminalWaitWhitespace(value: string, index: number): boolean { } const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always/i + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i + +// Why text at all: cursor-agent's hook set has no approval event and beforeShellExecution +// fires for auto-allowed commands too, so the menu is the only authority. Match the key-bound +// choices rather than the prose above them. +const CURSOR_APPROVAL_CHOICE_MARKERS = [ + 'run (once)', + 'to allowlist?', + 'run everything', + 'skip & tell the agent' +] +// Why bounded to the last lines: an answered menu stays in scrollback, and a stale hit fails +// tui-idle and refuses prompt injection. Only a dialog that still owns the bottom of the +// screen is live, and confining the whole match to that window also keeps prose above or +// below — an agent narrating "I'll pick Run Everything" — from anchoring it. +const CURSOR_APPROVAL_TAIL_LINES = 8 + +function findCursorApprovalPromptIndex(normalized: string): number | null { + const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) + const tail = normalized.slice(windowStart) + if (!tail.includes('run this command?')) { + return null + } + const lines = tail.split('\n') + while (lines.length > 0 && lines.at(-1)?.trim() === '') { + lines.pop() + } + let matchedLines = 0 + let lastChoiceLine = -1 + for (let index = 0; index < lines.length; index += 1) { + if (!isCursorApprovalChoiceLine(lines[index])) { + continue + } + matchedLines += 1 + lastChoiceLine = index + } + if (matchedLines < 2) { + return null + } + // Why no slack: every capture of a live dialog ends on its last choice, and one line of + // tolerance is enough for the agent's own narration of a choice to revive an answered menu. + // A redraw caught mid-flight reads as no wait until the next poll, which is the safe way + // to be wrong. + return lastChoiceLine === lines.length - 1 + ? windowStart + tail.lastIndexOf('run this command?') + : null +} + +// Why the trailing key and not the wording alone: an agent narrating "next time I'll suggest +// Run Everything" writes the same words as the menu. A selectable row ends in the key that +// picks it, and prose does not. Spelled as key names rather than a character class, because +// any lowercase run would readmit "…suggest Run Everything (as before)". +const CURSOR_APPROVAL_CHOICE_KEY_RE = + /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ + +function isCursorApprovalChoiceLine(line: string): boolean { + return ( + CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && + CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) + ) +} + +/** Offset of the first character of the last `count` newline-separated lines. */ +function startOfLastLines(value: string, count: number): number { + let cursor = value.length + for (let seen = 0; seen < count; seen += 1) { + const previous = value.lastIndexOf('\n', cursor - 1) + if (previous === -1) { + return 0 + } + cursor = previous + } + return cursor + 1 +} + +/** Why a spread and not `agentWait: value`: an absent key is the only way to say the pane was + * never evaluated, which a reader must not confuse with an evaluated "no wait". */ +function expandTerminalInteractiveWait( + agentWait: RuntimeTerminalInteractiveWait | null | undefined +): { agentWait?: RuntimeTerminalInteractiveWait | null } { + return agentWait === undefined ? {} : { agentWait } +} + +/** A wedged PTY controller must not stall every reader of this pane. */ +const TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS = 2_000 function findTerminalWaitBlockedSignal( normalized: string @@ -39722,6 +39908,10 @@ function findTerminalWaitBlockedSignal( candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) } } + const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized) + if (cursorApprovalIndex !== null) { + candidates.push({ reason: 'agent-approval-prompt', index: cursorApprovalIndex }) + } const permissionPromptIndex = Math.max( normalized.lastIndexOf('permission required'), normalized.lastIndexOf('requires permission') diff --git a/src/main/runtime/rpc/methods/orchestration-federation-control.ts b/src/main/runtime/rpc/methods/orchestration-federation-control.ts index eb8a29bce74..806f7b8e06a 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-control.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-control.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../shared/orchestration-worker-output' +import type { RuntimeTerminalInteractiveWait } from '../../../../shared/runtime-types' import type { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationError } from '../../orchestration/orchestration-error' import type { RemoteDispatchAttachmentRow } from '../../orchestration/types' @@ -40,7 +41,8 @@ export const ORCHESTRATION_FEDERATION_CONTROL_METHODS: RpcMethod[] = [ observation: { status: observation.status, exactWorker: observation.exact, - ...(observation.reason ? { reason: observation.reason } : {}) + ...(observation.reason ? { reason: observation.reason } : {}), + ...(observation.agentWait !== undefined ? { agentWait: observation.agentWait } : {}) } } } @@ -216,6 +218,8 @@ async function inspectRemoteAttachment( status: 'unattached' | 'missing' | 'identity_changed' | 'live' | 'exited' | 'unverifiable' /** Set with `unverifiable`; names what we lost contact with. */ reason?: string + /** Set only on a proven-exact attachment parked on a prompt that needs a human. */ + agentWait?: RuntimeTerminalInteractiveWait | null }> { const db = runtime.getOrchestrationDb() const attachment = db.getRemoteDispatchAttachment(dispatchId) @@ -237,14 +241,17 @@ async function inspectRemoteAttachment( // Why: the same rule as the local worker observation — the inventory only // iterates registered providers, so a dropped relay clears `connected` for // every remote PTY at once. Lost contact is not a death certificate. + // Why reused: showTerminal above already scanned this pane's tail for the same verdict. + const agentWait = terminal.agentWait const verdict = runtime.getTerminalLivenessVerdict?.(attachment.terminal_handle) ?? null if (verdict?.status === 'unverifiable') { - return { terminal, exact, status: 'unverifiable', reason: verdict.reason } + return { terminal, exact, status: 'unverifiable', reason: verdict.reason, agentWait } } return { terminal, exact, - status: verdict?.status !== 'live' && terminal.connected === false ? 'exited' : 'live' + status: verdict?.status !== 'live' && terminal.connected === false ? 'exited' : 'live', + agentWait } } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-control.ts b/src/main/runtime/rpc/methods/orchestration-worker-control.ts index 3e305a90a70..e21b899319b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-control.ts @@ -141,7 +141,11 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ status: observation.status, exactWorker: observation.exact, // Why: a bare `unverifiable` is not actionable without naming what we lost. - ...(observation.reason ? { reason: observation.reason } : {}) + ...(observation.reason ? { reason: observation.reason } : {}), + // Why conditional: a present null must mean "looked, nothing waiting". An + // unattached, missing or identity-changed worker was never looked at, and saying + // null there is the false negative this field exists to remove. + ...(observation.agentWait !== undefined ? { agentWait: observation.agentWait } : {}) }, terminalResource: resource ? exposeWorkerTerminalResource(resource) : null } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts new file mode 100644 index 00000000000..2392e3e4490 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-interactive-wait.test.ts @@ -0,0 +1,168 @@ +// `worker-show` must distinguish a worker parked on a human prompt (STA-3714, STA-4513). +// Deliberately unmocked below the RPC so detector, plumbing, and RPC shape are all covered. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { ORCHESTRATION_METHODS } from './orchestration' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const LEAF_ID = '22222222-2222-4222-8222-222222222222' +const TAB_ID = 'tab-worker' +const WORKTREE_ID = 'wt-worker' +const PTY_ID = 'pty-worker' + +// Captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca. +function fixture(name: string): string { + return readFileSync(join(__dirname, '../../__fixtures__', `${name}.txt`), 'utf8') +} + +function workerShowMethod() { + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.workerShow' + ) + if (!method) { + throw new Error('Missing method orchestration.workerShow') + } + return method +} + +describe('worker-show interactive wait (STA-3714, STA-4513)', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + async function showWorkerPaneServing(paneOutput: string, opts?: { breakIdentity?: boolean }) { + const runtime = new OrcaRuntimeService(null) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: WORKTREE_ID, + path: '/repo/app', + connectionId: null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'inc-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + leafId: LEAF_ID, + title: 'worker' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'worker', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + // cursor-agent's spinner title, identical whether it runs or waits. + paneTitle: '⠇ Cursor Agent' + } + ] + }) + runtime.onPtyData(PTY_ID, paneOutput, Date.now()) + + db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const run = db.createRun({ + objective: 'supervise lanes', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'run the suite', runId: run.id }) + const paneKey = runtime.getTerminalPaneKey(terminal.handle) + const incarnation = runtime.getTerminalProcessIncarnation(terminal.handle) + if (!paneKey || !incarnation) { + throw new Error('Runtime did not expose the worker pane identity.') + } + const dispatch = db.createDispatchContext( + task.id, + terminal.handle, + paneKey, + 'launch-hash', + // A dispatch recorded against a process that has since been replaced. + opts?.breakIdentity === true ? `${incarnation}:replaced` : incarnation + ) + db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey, + processIncarnation: opts?.breakIdentity === true ? `${incarnation}:replaced` : incarnation + }) + + const method = workerShowMethod() + return method.handler(method.params?.parse({ dispatch: dispatch.id }), { runtime }) + } + + it('names the pending prompt on the observation a coordinator polls', async () => { + const result = await showWorkerPaneServing(fixture('cursor-agent-approval-prompt')) + + expect(result).toMatchObject({ + observation: { + exactWorker: true, + agentWait: { + source: 'prompt-text', + reason: 'agent-approval-prompt', + since: expect.any(Number) + } + } + }) + }) + + it('reports an explicit null for the same lane inside a long tool call', async () => { + // Why an explicit null and not an omitted key: a coordinator has to tell "not waiting" + // from "this host is too old to know", and only absence may mean the latter. + const result = await showWorkerPaneServing(fixture('cursor-agent-long-tool-call')) + + expect(result).toMatchObject({ observation: { exactWorker: true, agentWait: null } }) + }) + + it('omits the field entirely for a worker it could not verify', async () => { + // Why not null: null is a claim that Orca looked. A replaced process is never looked at, + // and reporting "no wait" there is the false negative this field exists to remove. + const result = (await showWorkerPaneServing(fixture('cursor-agent-approval-prompt'), { + breakIdentity: true + })) as { observation: Record } + + expect(result.observation.exactWorker).toBe(false) + expect('agentWait' in result.observation).toBe(false) + }) + + it('agrees with the terminal payload it is derived from', async () => { + const result = (await showWorkerPaneServing(fixture('cursor-agent-approval-prompt'))) as { + terminal: { agentWait: unknown } | null + observation: { agentWait: unknown } + } + + // Why the explicit shape first: comparing the two fields alone passes when both are absent. + expect(result.observation.agentWait).toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt' + }) + expect(result.observation.agentWait).toEqual(result.terminal?.agentWait) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-observation.ts b/src/main/runtime/rpc/methods/orchestration-worker-observation.ts index ae1f8fd142e..b4e947893fc 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-observation.ts @@ -1,3 +1,4 @@ +import type { RuntimeTerminalInteractiveWait } from '../../../../shared/runtime-types' import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -17,6 +18,8 @@ export async function inspectWorkerTerminal( status: 'unattached' | 'missing' | 'identity_changed' | 'live' | 'exited' | 'unverifiable' /** Set with `unverifiable`; names what we lost contact with. */ reason?: string + /** Set only on a proven-exact worker parked on a prompt that needs a human. */ + agentWait?: RuntimeTerminalInteractiveWait | null }> { const worker = db.getWorkerDispatch(dispatchId) const terminalHandle = @@ -39,17 +42,23 @@ export async function inspectWorkerTerminal( // Why: the aggregate inventory only iterates registered providers, so a dropped // relay clears `connected` for every remote PTY at once. Lost contact is not a // death certificate, and the verdict is the only field that can tell them apart. + // Why reused rather than re-derived: showTerminal already scanned this pane's retained + // tail for the same verdict, and a second scan could also disagree with the one it published. + // Exact-gated by the early return above: a replaced process's prompt would attribute another + // lane's blocker to this worker. + const agentWait = terminal.agentWait const verdict = runtime.getTerminalLivenessVerdict?.(terminalHandle) ?? null if (verdict?.status === 'unverifiable') { - return { terminal, exact, status: 'unverifiable', reason: verdict.reason } + return { terminal, exact, status: 'unverifiable', reason: verdict.reason, agentWait } } if (verdict?.status === 'live') { - return { terminal, exact, status: 'live' } + return { terminal, exact, status: 'live', agentWait } } return { terminal, exact, - status: terminal.connected === false ? 'exited' : 'live' + status: terminal.connected === false ? 'exited' : 'live', + agentWait } } @@ -84,7 +93,8 @@ export async function showContextOnlyWorker( observation: { status: observation.status, exactWorker: observation.exact, - ...(observation.reason ? { reason: observation.reason } : {}) + ...(observation.reason ? { reason: observation.reason } : {}), + ...(observation.agentWait !== undefined ? { agentWait: observation.agentWait } : {}) }, terminalResource: null } @@ -129,7 +139,13 @@ export async function callFederatedWorkerShow( residualResources: unknown[] } terminal: unknown - observation: { status: string; exactWorker: boolean; reason?: string } + observation: { + status: string + exactWorker: boolean + reason?: string + /** Absent from servers that predate the field; absence is unknown, not "not waiting". */ + agentWait?: RuntimeTerminalInteractiveWait | null + } }> { return (await runtime.callOrchestrationWorkerServer( federated.environment_id, diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts new file mode 100644 index 00000000000..987bdbb15b7 --- /dev/null +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -0,0 +1,490 @@ +// A worker parked on an interactive prompt must be distinguishable from one that is thinking +// or inside a long tool call (STA-4513, STA-3714). +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'wt-1' +const PTY_ID = 'pty-1' + +// Captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca. +function fixture(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') +} +const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt') +const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call') +const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval') + +// Claude Code 2.1.234's own trust screen, which the runtime already matched by shape. +const CLAUDE_TRUST = [ + 'Accessing workspace:\n', + '/private/tmp/repo\n', + 'Quick safety check: Is this a project you created or one you trust?\n', + '❯ 1. Yes, I trust this folder\n', + ' 2. No, exit\n' +].join('') + +function agentStatusOsc(state: string): string { + return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` +} + +async function createPane(options: { + paneTitle: string + foregroundProcess: string | null + data: string + /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ + connectionId?: string + /** Simulates a PTY controller whose foreground probe never settles. */ + foregroundProbeHangs?: boolean + onForegroundProbe?: () => void +}): Promise<{ runtime: OrcaRuntimeService; handle: string }> { + const runtime = new OrcaRuntimeService(null) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: WORKTREE_ID, + path: '/repo/app', + connectionId: options.connectionId ?? null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'inc-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: (): Promise => { + options.onForegroundProbe?.() + return options.foregroundProbeHangs === true + ? new Promise(() => {}) + : Promise.resolve(options.foregroundProcess) + } + }) + const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + leafId: LEAF_ID, + title: 'Terminal' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: options.paneTitle + } + ] + }) + // Why the guard: a restore seed is only applied to a never-written record, so the restore + // cases must not write an empty chunk first. + if (options.data.length > 0) { + runtime.onPtyData(PTY_ID, options.data, Date.now()) + } + return { runtime, handle: terminal.handle } +} + +// cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads +// that as `working`; the title is identical whether it is running a command or waiting. +const CURSOR_TITLE = '⠇ Cursor Agent' + +describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { + describe('cursor-agent approval menu, the case no hook reports', () => { + it('names the pending approval on the pane a coordinator inspects', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' } + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt', + since: expect.any(Number) + }) + }) + + it('refuses a guarded send and a dispatch preamble while the approval is up', async () => { + // Why this matters beyond reporting: the coordinator's preamble was typed straight into + // the approval dialog instead of being refused (the STA-2631 shape). Both refusal paths + // are asserted here, not just the `permission` verdict they read. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).rejects.toThrow('terminal_guard_permission') + await expect(runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble')).rejects.toThrow( + 'agent_prompt_blocked' + ) + }) + + it('lets a dispatch preamble through once the same lane is working', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).resolves.toBeUndefined() + }) + + it('refuses to call the lane idle while the approval is unanswered', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' }) + }) + + it('reports no wait for the same agent inside a long tool call', async () => { + // The control: identical vendor, identical spinner title, a real `sleep 60` running. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null }) + }) + + it('reports no wait once the agent is idle again', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_IDLE + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('treats an answered menu still in scrollback as scrollback', async () => { + // Why: the menu is not erased on every terminal, so a live dialog is one that still owns + // the bottom of the screen rather than one that merely appears somewhere in the tail. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('treats an answered menu followed by any later output as scrollback', async () => { + // Why not keyed on cursor's own follow-up line: output after the menu can be anything, + // and a stale hit here fails tui-idle and refuses prompt injection on a healthy lane. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() + + runtime.onPtyData( + PTY_ID, + '\nCommand completed successfully.\nContinuing automatically.\n', + Date.now() + ) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('is not anchored by the agent narrating a choice after the menu', async () => { + // Why: matching each marker independently let prose below the answered menu carry the + // anchor down to the bottom of the screen and revive it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('is not anchored by the agent narrating a choice before the menu', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('requires the dialog to be the last thing on the screen', async () => { + // Not a tolerance question: one line of slack is exactly enough for the agent's own + // narration to revive an answered menu, and every capture of a live dialog ends on it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('reports the same wait for a pane whose PTY is not local', async () => { + // The verdict is derived from retained tail and title state, so an SSH or WSL pane + // reaches it the same way a local one does. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL, + connectionId: 'ssh:build-host' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt' + }) + }) + + it('ignores a partial menu that names no decision keys', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: 'The agent asked: Run this command? I said yes and it worked.\n' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + }) + + describe('prompts the runtime already matched but never surfaced', () => { + it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => { + // A pane on its trust screen still wears Orca's tab title; the agent has set none. + const { runtime, handle } = await createPane({ + paneTitle: 'sta4513-claude', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'codex-trust-workspace' } + }) + }) + + it('still lets a live working title clear a stale startup prompt', async () => { + // Pins the shared authority getTerminalAgentStatus and the agent-prompt send guard + // already use: for the startup modals, a live non-permission title is the staleness + // proof, because their text survives in scrollback with no self-dismissal marker. + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + }) + + describe('hook-reported waits (STA-3714)', () => { + it('surfaces an agent-reported blocked state with hook provenance', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'hook', + since: expect.any(Number) + }) + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'hook' } + }) + }) + + it('reports no wait for a hook-reported working turn', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('working') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('drops a retained permission row once the agent stops owning the pane', async () => { + // Why not the title alone: a shell that takes the pane back usually sets something like + // `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for + // AGENT_STATUS_STALE_AFTER_MS — half an hour of reporting a dead agent as waiting. + const { runtime, handle } = await createPane({ + paneTitle: 'jinwoo@host: ~/repo', + foregroundProcess: 'zsh', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('drops a retained permission row once a shell owns the pane', async () => { + const { runtime, handle } = await createPane({ + paneTitle: 'zsh', + foregroundProcess: 'zsh', + data: agentStatusOsc('blocked') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + }) + + it('still reports a live dialog restored from terminal history', async () => { + // Why this case exists: a lane parked on a prompt emits no bytes, so an Orca restart is + // exactly when it would go quiet forever. A restored tail carries no waitBlockedAt, and + // the approval menu does not need one — being at the bottom of the restored screen is + // itself the proof that this is where the pane stopped. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: '' + }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CURSOR_APPROVAL, lastTitle: CURSOR_TITLE }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt' + }) + }) + + it('does not resurrect a startup modal from a restored tail', async () => { + // The startup prompts keep the timestamp rule: their text lingers in scrollback with no + // marker for whether it was answered, so restored bytes alone must not mint a wait. + const { runtime, handle } = await createPane({ + paneTitle: 'sta4513-claude', + foregroundProcess: 'claude', + data: '' + }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CLAUDE_TRUST }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('stops reporting a wait once the pane is no longer running', async () => { + // Why: the menu stays at the bottom of a dead pane's tail forever. A worker whose process + // is gone needs intervention, not an answer, so it must not read as blocked on a human. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() + + runtime.onPtyExit(PTY_ID, 0) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() + }) + + it('does not accrue a probe per poll while the foreground probe wedges', async () => { + // Why: the timeout abandons the wait, not the request. Without single-flighting, a + // coordinator watching a wedged remote host adds one live probe on every poll. + let probes = 0 + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting'), + onForegroundProbe: () => { + probes += 1 + }, + foregroundProbeHangs: true + }) + + await Promise.all([ + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle) + ]) + + expect(probes).toBe(1) + }, 20_000) + + it('accepts a menu whose keys are rendered as glyphs', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: [ + 'Run this command?\n', + ' → Run (once) (\u21b5)\n', + ' Run Everything (\u21e7\u21b9)\n' + ].join('') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + reason: 'agent-approval-prompt' + }) + }) + + it('still rejects prose that merely ends in parentheses', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('leaves the wait unevaluated when the foreground probe wedges', async () => { + // Why bounded: this probe reaches a PTY controller that can be a remote host. A wedged + // one must leave the wait unknown, not stall every caller of showTerminal. + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting'), + foregroundProbeHangs: true + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() + const show = (await runtime.showTerminal(handle)) as Record + expect('agentWait' in show).toBe(false) + }, 15_000) + + it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { + const { runtime } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.getTerminalInteractiveWait('term_does_not_exist')).resolves.toBeUndefined() + }) +}) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 8e009201af2..5d03b9523c0 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -609,10 +609,29 @@ export type RuntimeWorktreeTerminalSleepResult = { } ) +/** Evidence class that proved a pane is parked on a prompt only a human can answer. + * Never inferred from silence: `hook` is an agent-reported blocked/waiting state, + * `prompt-text` is a matched prompt in the retained tail, `title` is a live OSC title. */ +export type RuntimeTerminalInteractiveWaitSource = 'hook' | 'prompt-text' | 'title' + +/** Present only while the wait is live. A `null` field means the pane was evaluated and nothing + * proves a wait; an absent field means it was never evaluated — an older host, an unverifiable + * worker identity, an unreadable pane, or an agent probe that did not answer in time. Absence + * is never "not waiting". */ +export type RuntimeTerminalInteractiveWait = { + source: RuntimeTerminalInteractiveWaitSource + /** Named prompt when the tail identified one. Absent for hook and title evidence. */ + reason?: RuntimeTerminalWaitBlockedReason + /** ms epoch the wait was first observed, when the evidence carries a timestamp. */ + since?: number +} + export type RuntimeTerminalShow = RuntimeTerminalSummary & { paneRuntimeId: number ptyId: string | null rendererGraphEpoch: number + /** Null when nothing proves an interactive wait. Absent on hosts that predate the field. */ + agentWait?: RuntimeTerminalInteractiveWait | null } export type RuntimeTerminalState = 'running' | 'exited' | 'unknown' @@ -751,6 +770,8 @@ export type RuntimeTerminalClose = { } export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle' +/** The `codex-` prefixes are historical: these startup prompts are matched by shape, not by + * vendor, so they also fire for Claude and Cursor. Renaming them would break paired hosts. */ export type RuntimeTerminalWaitBlockedReason = | 'codex-update-prompt' | 'codex-trust-workspace' @@ -758,6 +779,7 @@ export type RuntimeTerminalWaitBlockedReason = | 'codex-model-migration-prompt' | 'codex-hooks-review-prompt' | 'codex-interactive-prompt' + | 'agent-approval-prompt' export type RuntimeTerminalWait = { handle: string From 1be6a035b9ec192e10adb0f88f2531a402d68f2f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:24:26 -0700 Subject: [PATCH 016/168] fix(shell): carry a WSL pane's shell across relay revive and stop forking to probe zsh emulation (STA-4682) (#15361) * fix(shell): carry a WSL pane's shell across relay revive and stop forking to probe zsh emulation (STA-4682) Two residuals left behind by #15236 and #15258. Relay revive called resolveDefaultShell() and ignored the entry's shell override, so a restarted relay handed the user a PowerShell pane where a WSL one had been -- and with it the host default shell's history rather than the worktree-scoped one #15236 injects on spawn. The override and the requested distro are now serialized (optional fields; state from an older relay still revives the default shell) and re-resolved through the same allowlist a fresh spawn uses, so revive re-launches the same shell with the same guest-visible HISTFILE and the same WSLENV carrier. A bad override degrades that one pane instead of failing the whole batch. The wrapper's three `$(emulate)` probes each fork a zsh carrying everything the user's config has loaded, and since wrapping widened to every zsh pane every pane pays for them. Each probe now sits behind a fork-free option test that is true whenever `emulate sh`/`emulate ksh` has run; the exact probe still runs when that test passes, so no answer changes. Measured on zsh 5.9 / macOS: 9.97 ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped after -- about half the wrapper's cost. * fix(shell): harden the revive override path and silence the option probe on an exotic zsh Readiness-review follow-ups on this branch. - `[[ -o ]]` prints "no such option" to stderr and returns false rather than aborting, so a zsh lacking one of the three Bourne option names would put that text in the user's pane. All three predate every supported zsh, so `2>/dev/null` is belt and braces -- but the belt costs nothing. - Bound the WSL distro name revive replays into `wsl.exe -d `. It is the one field this branch newly routes from untrusted serialized state into argv, and reviveEntry's stated job is to re-apply fresh-spawn bounds. - Skip a pane whose overridden shell can no longer spawn instead of letting the throw escape the revive loop and cost every later entry its state. Skipping, not falling back to the host default shell: substituting a different shell is the defect the override exists to fix, and the args and history env are built for the shell that is gone. The worktree-removal fence throws before this, outside reviveEntry, so it stays a hard failure. --- .../daemon-zsh-zprofile.txt | 2 +- .../daemon-zsh-zshenv.txt | 2 +- .../daemon-zsh-zshrc.txt | 2 +- .../local-zsh-zprofile.txt | 2 +- .../local-zsh-zshenv.txt | 2 +- .../local-zsh-zshrc.txt | 2 +- .../relay-zsh-zprofile.txt | 2 +- .../relay-zsh-zshenv.txt | 2 +- .../relay-zsh-zshrc.txt | 2 +- src/main/daemon/shell-ready.test.ts | 7 +- ...pty-shell-ready-wrapper-generation.test.ts | 7 +- src/main/shell-templates.ts | 31 +++- .../zsh-scoped-histfile.live-shell.test.ts | 69 ++++++- src/main/zsh-startup-wrapper-builder.ts | 8 +- src/relay/pty-handler-revive.test.ts | 168 ++++++++++++++++++ src/relay/pty-handler.ts | 115 ++++++++++-- 16 files changed, 388 insertions(+), 35 deletions(-) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt index 36c75e3d29f..7b275404b38 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt @@ -18,7 +18,7 @@ if [[ -f "$_orca_home/.zprofile" ]]; then unset _orca_wrapper_zdotdir fi -if [[ -f "$_orca_home/.zprofile" ]]; then +if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then case "$(emulate 2>/dev/null)" in sh|ksh) export ZDOTDIR="$_orca_home" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 17d11cf24c2..fdd43798f72 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -82,7 +82,7 @@ __orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir -if [[ -n "${_orca_zshenv_path:-}" ]]; then +if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then case "$(emulate 2>/dev/null)" in sh|ksh) export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt index 6f45dca80a2..8a4e38c3aa3 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt @@ -18,6 +18,6 @@ if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ] unset _orca_wrapper_zdotdir fi -if [[ ! -o login || "$(emulate 2>/dev/null)" != zsh ]]; then +if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt index 96105bd173b..ee1befa1429 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt @@ -18,7 +18,7 @@ if [[ -f "$_orca_home/.zprofile" ]]; then unset _orca_wrapper_zdotdir fi -if [[ -f "$_orca_home/.zprofile" ]]; then +if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then case "$(emulate 2>/dev/null)" in sh|ksh) export ZDOTDIR="$_orca_home" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 90700b19403..33d4d04d2a6 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -82,7 +82,7 @@ __orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir -if [[ -n "${_orca_zshenv_path:-}" ]]; then +if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then case "$(emulate 2>/dev/null)" in sh|ksh) export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt index ef60afbf3f5..4776da936c3 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt @@ -18,6 +18,6 @@ if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ] unset _orca_wrapper_zdotdir fi -if [[ ! -o login || "$(emulate 2>/dev/null)" != zsh ]]; then +if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt index 9a0936db85d..79540246999 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt @@ -18,7 +18,7 @@ if [[ -f "$_orca_home/.zprofile" ]]; then unset _orca_wrapper_zdotdir fi -if [[ -f "$_orca_home/.zprofile" ]]; then +if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then case "$(emulate 2>/dev/null)" in sh|ksh) export ZDOTDIR="$_orca_home" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index df7b7883c69..750978b1cc9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -29,7 +29,7 @@ export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" __orca_resolve_user_config_dir "${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" -if [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]]; then +if [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then case "$(emulate 2>/dev/null)" in sh|ksh) export ZDOTDIR="$ORCA_USER_ZDOTDIR" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt index 1fc1794596e..9c84f4df2ec 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt @@ -18,6 +18,6 @@ if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then unset _orca_wrapper_zdotdir fi -if [[ ! -o login || "$(emulate 2>/dev/null)" != zsh ]]; then +if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue fi diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 2fa0eeacdd7..3bcbb6aa085 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -508,7 +508,12 @@ describePosix('daemon shell-ready launch config', () => { expectFinalZdotdirRestoreContext(zshenv) // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin // rather than the wrapper's, so the epilogue has to run from here instead. - expect(zshrc).toContain('if [[ ! -o login || "$(emulate 2>/dev/null)" != zsh ]]; then') + // Why the option test in front of it: the probe forks, and all-off proves + // zsh emulation without one. + expect(zshrc).toContain( + 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + + '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + ) expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index 72790537751..9be82c7b769 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -256,7 +256,12 @@ describePosix('local PTY shell-ready launch config', () => { expectFinalZdotdirRestoreContext(zshenv) // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin // rather than the wrapper's, so the epilogue has to run from here instead. - expect(zshrc).toContain('if [[ ! -o login || "$(emulate 2>/dev/null)" != zsh ]]; then') + // Why the option test in front of it: the probe forks, and all-off proves + // zsh emulation without one. + expect(zshrc).toContain( + 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + + '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + ) expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') expect(zlogin).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') }) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 7bd5ae5abd5..6bcfde6bfb7 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -93,6 +93,33 @@ __orca_resolve_inherited_config_dir() { // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. export { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from './bash-prompt-command-composition' +/** + * Fork-free precondition for the `$(emulate)` probe: options that both + * `emulate sh` and `emulate ksh` turn on, so all-off proves zsh emulation. + * + * Why: the probe is a command substitution, which forks a zsh carrying every + * function, alias and completion the user's config has loaded by that point — + * the most expensive line in the wrapper, and one every zsh pane pays now that + * wrapping widened to all of them. Measured on zsh 5.9 / macOS, 150 login + * startups per arm, with a user .zshenv + .zprofile + .zshrc present: 9.97 + * ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped once these three + * probes are skipped — about half of what wrapping costs. + * + * Why a hint in front of the real probe and not a replacement for it: these + * options say nothing about `emulation`, which is what zsh's `sourcehome()` + * branches on, so a config that sets one by hand must still get the exact + * answer. OR, not AND, so the only way past it is to enter emulation and then + * unset all three; every real `emulate sh`/`emulate ksh` sets them. A false + * positive costs exactly the fork this saves. + * + * Why `2>/dev/null`: `[[ -o ]]` prints `no such option` to stderr and + * returns false rather than aborting, so on a zsh too old for one of these + * names the only symptom would be that text in the user's pane. All three + * predate every zsh Orca supports, so this is belt and braces, not a fallback. + */ +export const ZSH_BOURNE_EMULATION_OPTION_HINT = + '[[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null' + /** * Hands the pane back to the user unwrapped when zsh has entered sh/ksh * emulation, and stops reading the rest of the current wrapper file. @@ -118,12 +145,14 @@ export { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from './bash-prompt-command-com * anything that had already entered emulation (a system /etc/zshenv or * /etc/zprofile) would have hidden this very file too. The only thing that can * have entered emulation by this line is the user file this file just sourced. + * + * Why ZSH_BOURNE_EMULATION_OPTION_HINT gates it further: see that constant. */ export function getZshEmulationDegradeBlock(options: { userZdotdirExpression: string sourcedUserFileTest: string }): string { - return `if [[ ${options.sourcedUserFileTest} ]]; then + return `if [[ ${options.sourcedUserFileTest} ]] && ${ZSH_BOURNE_EMULATION_OPTION_HINT}; then case "$(emulate 2>/dev/null)" in sh|ksh) export ZDOTDIR=${options.userZdotdirExpression} diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 17ff8cae7ef..741f892d734 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -17,7 +17,7 @@ * Orca would not wrap cannot pass here by construction. */ import { execFileSync, spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -278,11 +278,11 @@ describe('worktree-scoped HISTFILE survives zsh startup', () => { }) }) -describe('the wrapper survives a hostile user zsh config', () => { - const PROBE = - 'echo "RESULT=$HISTFILE"; print -r -- "ZDOTDIR=[$ZDOTDIR]"; ' + - 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-unset}]"' +const PROBE = + 'echo "RESULT=$HISTFILE"; print -r -- "ZDOTDIR=[$ZDOTDIR]"; ' + + 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-unset}]"' +describe('the wrapper survives a hostile user zsh config', () => { // Why both forms: `REPLY` is zsh's shared scratch global, and the two ways a // user config constrains it fail differently. `typeset -r` makes the // wrapper's first assignment fatal and prints into the pane; `typeset -i` @@ -343,3 +343,62 @@ describe('the wrapper survives a hostile user zsh config', () => { }) }) }) + +/** + * Records every argument-less `emulate` — i.e. exactly the wrapper's emulation + * probe, and not the epilogue's `emulate -L zsh` — into a file the test reads. + * + * Why a shadowing function and not a timing assertion: the cost being removed + * is a fork, and a fork is countable where milliseconds are flaky. Placed in + * the user .zshenv so it is defined before all three probe sites. + */ +function probeCounterConfig(logPath: string): string { + return [ + 'emulate() {', + ` (( $# == 0 )) && print -r -- probe >> ${JSON.stringify(logPath)}`, + ' builtin emulate "$@"', + '}' + ].join('\n') +} + +const probeCount = (logPath: string): number => + existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n').filter(Boolean).length : 0 + +describe('the emulation probe forks only when it can change the answer', () => { + itWithZsh('never forks for a pane whose config stays in zsh emulation', () => { + withTempHome((home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const log = join(home, 'probe.log') + const { launch, env } = launchPlainHistoryPane(home, scoped) + // All three probe sites are gated on having sourced the matching user + // file, so every one of them has to exist for this to mean anything. + writeFileSync(join(home, '.zshenv'), probeCounterConfig(log)) + writeFileSync(join(home, '.zprofile'), '') + writeFileSync(join(home, '.zshrc'), '') + + const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) + + expect(probeCount(log)).toBe(0) + // The pane still has to work: the saving is a fork, not a feature. + expect(histfileOf(output)).toBe(scoped) + expect(output).toContain(`ZDOTDIR=[${home}]`) + }) + }) + + // Why kept exact rather than replaced by the option test: those options say + // nothing about `emulation`, which is what zsh's sourcehome() branches on. + itWithZsh('still forks to get the exact answer once a Bourne option is set', () => { + withTempHome((home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const log = join(home, 'probe.log') + const { launch, env } = launchPlainHistoryPane(home, scoped) + writeFileSync(join(home, '.zshenv'), `${probeCounterConfig(log)}\nsetopt shwordsplit\n`) + + const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) + + expect(probeCount(log)).toBeGreaterThan(0) + // shwordsplit alone is not emulation, so the pane stays wrapped. + expect(histfileOf(output)).toBe(scoped) + }) + }) +}) diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index eccedcfb6ac..a337b186a4d 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -28,6 +28,7 @@ import { getZshShellReadyMarkerRegistrationBlock, getZshStartupFileSourceBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, + ZSH_BOURNE_EMULATION_OPTION_HINT, ZSH_FEATURE_CHANNEL_BLOCK, ZSH_HISTFILE_RESTORE_BLOCK, ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, @@ -216,8 +217,13 @@ const EPILOGUE_CALL = '(( ${+functions[__orca_shell_epilogue]} )) && __orca_shel * beats not running it at all. An older zsh whose `emulate` has no query form * prints nothing, fails the comparison, and runs it here too; the epilogue's * once-flag makes the later .zlogin call a no-op. + * + * Of the wrapper's three emulation probes this is the costliest — it forks a + * zsh that has just finished loading the user's whole .zshrc — so + * ZSH_BOURNE_EMULATION_OPTION_HINT skips it for any shell not in Bourne + * emulation. Braces because `A || B && C` groups as `(A || B) && C` in a shell. */ -const ZSHRC_EPILOGUE_INVOCATION = `if [[ ! -o login || "$(emulate 2>/dev/null)" != zsh ]]; then +const ZSHRC_EPILOGUE_INVOCATION = `if [[ ! -o login ]] || { ${ZSH_BOURNE_EMULATION_OPTION_HINT} && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then ${EPILOGUE_CALL} fi` diff --git a/src/relay/pty-handler-revive.test.ts b/src/relay/pty-handler-revive.test.ts index 7955abfeac1..9350067da3b 100644 --- a/src/relay/pty-handler-revive.test.ts +++ b/src/relay/pty-handler-revive.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { existsSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { hashWorktreeId } from '../main/terminal-history-id' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -480,4 +484,168 @@ describe('PtyHandler', () => { }) ).rejects.toThrow('PTY "pty-1" not found') }) + + describe('a Windows relay reviving a WSL pane', () => { + const worktreeId = 'r::/remote/wsl-worktree' + const historyFile = join( + homedir(), + '.orca-remote', + 'terminal-history', + `${hashWorktreeId(worktreeId)}-bash_history` + ) + let previousPlatform: PropertyDescriptor | undefined + + beforeEach(() => { + previousPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + if (previousPlatform) { + Object.defineProperty(process, 'platform', previousPlatform) + } + rmSync(historyFile, { force: true }) + }) + + /** Spawn a WSL pane, serialize it, then revive it into a fresh handler. */ + async function reviveWslPane(): Promise { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + worktreeId, + historyIsolationEnabled: true + }) + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + + await handler.dispose({ waitForPhysicalExit: false }) + mockPtySpawn.mockClear() + rmSync(historyFile, { force: true }) + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + } + + // The #15236 gap: revive called resolveDefaultShell() and ignored the + // entry's override, so a restarted relay silently handed the user a + // PowerShell pane where a WSL one had been. + it('relaunches wsl.exe in the pane\u2019s own distro instead of the host default shell', async () => { + await reviveWslPane() + + const [shell, args] = mockPtySpawn.mock.calls[0] as [string, string[]] + expect(shell).toBe('wsl.exe') + expect(args).toEqual(['-d', 'Ubuntu']) + }) + + it('scopes HISTFILE past the wsl.exe wrapper and carries it over WSLENV', async () => { + await reviveWslPane() + + const spawnEnv = mockPtySpawn.mock.calls[0][2]?.env as Record + expect(spawnEnv.HISTFILE?.endsWith(`${hashWorktreeId(worktreeId)}-bash_history`)).toBe(true) + expect(spawnEnv.WSLENV?.split(':')).toContain('HISTFILE') + // Deletion is unchanged because the file still lives on the relay host. + expect(existsSync(historyFile)).toBe(true) + }) + + // Why: a revived pane is serialized again on the next restart, so dropping + // the override there is the same defect one reconnect later. + it('keeps the override across a second serialize/revive round trip', async () => { + await reviveWslPane() + + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + + expect(JSON.parse(state)[0]).toMatchObject({ + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + }) + + // Why: this field is replayed from state the relay hands a client and takes + // back unvalidated, and it lands in argv. + it('drops an over-long distro name rather than passing it to wsl.exe', async () => { + const state = JSON.stringify([ + { + id: 'pty-11', + pid: process.pid, + cols: 80, + rows: 24, + cwd: 'C:\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'U'.repeat(257) + } + ]) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const [shell, args] = mockPtySpawn.mock.calls[0] as [string, string[]] + expect(shell).toBe('wsl.exe') + expect(args).toEqual([]) + }) + + // Why: the override's whole point is that the pane keeps its own shell, so a + // shell that no longer exists must cost that pane and nothing else. + it('skips a pane whose overridden shell can no longer spawn, keeping the batch', async () => { + const state = JSON.stringify([ + { + id: 'pty-12', + pid: process.pid, + cols: 80, + rows: 24, + cwd: 'C:\\repo', + shellOverride: 'wsl.exe' + }, + { id: 'pty-13', pid: process.pid, cols: 80, rows: 24, cwd: 'C:\\repo' } + ]) + mockPtySpawn.mockImplementationOnce(() => { + throw new Error('spawn wsl.exe ENOENT') + }) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + // The second entry still revived, and no other shell stood in for the first. + expect(mockPtySpawn).toHaveBeenCalledTimes(2) + const live = (await dispatcher.callRequest('pty.serialize', { + ids: ['pty-12', 'pty-13'] + })) as string + expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-13']) + }) + + it('degrades one entry with an unsupported override without failing the batch', async () => { + const state = JSON.stringify([ + { + id: 'pty-9', + pid: process.pid, + cols: 80, + rows: 24, + cwd: 'C:\\repo', + shellOverride: 'nc.exe' + }, + { id: 'pty-10', pid: process.pid, cols: 80, rows: 24, cwd: 'C:\\repo' } + ]) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + expect(mockPtySpawn).toHaveBeenCalledTimes(2) + expect(mockPtySpawn.mock.calls.map(([shell]) => shell)).not.toContain('nc.exe') + }) + }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 3f82038261d..52401a65476 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -164,6 +164,10 @@ type ManagedPty = { terminalHandle?: string explicitTerm?: string shellPath?: string + /** The raw client-requested shell override, kept so revive can re-resolve it on this host. */ + shellOverride?: string + /** Requested WSL distro; only meaningful when the override launched wsl.exe. */ + wslDistro?: string shellCwd?: string shellPathEnv?: string envToDelete: string[] @@ -313,6 +317,31 @@ function resolvePtyShellOverride(shellOverride: string): string { return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride } +/** + * Longest WSL distro name revive will carry into `wsl.exe -d `. + * + * Why bounded here and not at spawn: spawn's value is a live RPC parameter, + * while this one is replayed from state the relay hands a client and takes back + * unvalidated. It reaches an argv, not a shell, so an over-long name costs a + * failed spawn rather than anything worse -- but revive's whole job here is to + * re-apply fresh-spawn bounds, and an unbounded value had no business in it. + * Real distro names are registry keys, far below this. + */ +const MAX_REVIVED_WSL_DISTRO_LENGTH = 256 + +/** + * Same bounds as a fresh spawn, but one unusable entry may not fail the whole + * revive batch — an override this host no longer allows degrades that single + * pane to the default shell instead of throwing away every other pane's state. + */ +function resolveRevivedShellOverride(shellOverride: string): string { + try { + return resolvePtyShellOverride(shellOverride) + } catch { + return '' + } +} + type PtyProcessSummary = { id: string incarnationId: string @@ -340,6 +369,13 @@ type SerializedPtyEntry = { gitCredentialPromptGuarded?: boolean /** Optional for state serialized by relays predating scoped history. */ historyIsolationEnabled?: boolean + /** + * The shell override this pane was spawned with, re-resolved (and re-bounded) + * on revive. Optional: state from a relay predating it revives the host + * default shell, which is what those relays did anyway. + */ + shellOverride?: string + terminalWindowsWslDistro?: string agentSessionOwners?: AgentSessionOwnerBinding[] } @@ -1666,6 +1702,10 @@ export class PtyHandler { gitCredentialPromptGuarded, ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, + // Why the resolved one gates it: on a POSIX relay an override is rejected + // outright, and storing one revive would only reject again is noise. + ...(resolvedShellOverride ? { shellOverride } : {}), + ...(terminalWindowsWslDistro ? { wslDistro: terminalWindowsWslDistro } : {}), shellCwd: cwd, shellPathEnv: spawnEnv.PATH, ownerBackend: resolvePtyOwnerBackend({ @@ -2103,6 +2143,10 @@ export class PtyHandler { envToDelete: managed.envToDelete, gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), + // Why serialized: revive re-spawns the shell, and without these a WSL + // pane came back as the host default shell in another distro's history. + ...(managed.shellOverride ? { shellOverride: managed.shellOverride } : {}), + ...(managed.wslDistro ? { terminalWindowsWslDistro: managed.wslDistro } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } @@ -2165,7 +2209,14 @@ export class PtyHandler { } // Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds. const envToDelete = sanitizeEnvToDelete(entry.envToDelete) - const shell = resolveDefaultShell() + const shellOverride = typeof entry.shellOverride === 'string' ? entry.shellOverride.trim() : '' + const resolvedShellOverride = resolveRevivedShellOverride(shellOverride) + const shell = resolvedShellOverride || resolveDefaultShell() + const terminalWindowsWslDistro = + typeof entry.terminalWindowsWslDistro === 'string' && + entry.terminalWindowsWslDistro.length <= MAX_REVIVED_WSL_DISTRO_LENGTH + ? entry.terminalWindowsWslDistro + : null const historyIsolationEnabled = entry.historyIsolationEnabled === true const spawnEnv = this.buildSpawnEnv( revivedEnv, @@ -2179,29 +2230,53 @@ export class PtyHandler { ) { injectRelayFishHistoryEnv(spawnEnv, entry.worktreeId) } - // No WSL branch on purpose: revive re-spawns the host default shell rather - // than the entry's stored override, so this env matches the shell it launches. + // Mirrors spawn: the entry's override is what gets re-launched, so a WSL + // pane needs the same guest-visible HISTFILE and the same WSLENV carrier. + const wslShell = isRelayWslShell(shell) if (historyIsolationEnabled && entry.worktreeId) { - injectRelayHistoryEnv(spawnEnv, entry.worktreeId, shell) + const historyRoot = injectRelayHistoryEnv(spawnEnv, entry.worktreeId, shell, { + wsl: wslShell + }) + if (wslShell && historyRoot) { + addWslEnvKeys(spawnEnv, ['HISTFILE']) + } } // Why: revive lacks the original launch command, so reuse the fresh-spawn guard decision (legacy defaults to unguarded). const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true if (gitCredentialPromptGuarded) { Object.assign(spawnEnv, gitCredentialPromptGuardEnv(spawnEnv, process.platform)) } - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) - const term = ptyMod.spawn(shell, shellLaunch.args, { - name: spawnEnv.TERM ?? 'xterm-256color', - cols: entry.cols, - rows: entry.rows, - cwd: entry.cwd, - // Why: no provider-delivered command is waiting for a ready marker. - env: { - ...spawnEnv, - [SHELL_STARTUP_FEATURE_ENV]: '', - ...shellLaunch.env - } + const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { + terminalWindowsWslDistro }) + let term: IPty + try { + term = ptyMod.spawn(shell, shellLaunch.args, { + name: spawnEnv.TERM ?? 'xterm-256color', + cols: entry.cols, + rows: entry.rows, + cwd: entry.cwd, + // Why: no provider-delivered command is waiting for a ready marker. + env: { + ...spawnEnv, + [SHELL_STARTUP_FEATURE_ENV]: '', + ...shellLaunch.env + } + }) + } catch (error) { + // Why skip rather than retry the host default shell: the stored override + // names a shell that existed at serialize time and may not now (an + // uninstalled WSL takes wsl.exe with it), and substituting a different + // shell is the defect this override exists to fix -- its args and its + // history env are built for the shell that is gone. Dropping one pane is + // the honest outcome; letting the throw escape the revive loop would cost + // every later entry its state too. The worktree-removal fence throws + // before this, outside reviveEntry, so it stays a hard failure. + if (!resolvedShellOverride) { + throw error + } + return + } this.wireAndStore({ id: entry.id, incarnationId: randomUUID(), @@ -2219,9 +2294,15 @@ export class PtyHandler { envToDelete, gitCredentialPromptGuarded, ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), + shellPath: shell, + // Why re-stored: a revived pane can be serialized again, and losing the + // override on the second round trip is the same bug one restart later. + ...(resolvedShellOverride ? { shellOverride } : {}), + ...(terminalWindowsWslDistro ? { wslDistro: terminalWindowsWslDistro } : {}), ownerBackend: resolvePtyOwnerBackend({ platform: process.platform, - shellPath: shell + shellPath: shell, + wslDistro: terminalWindowsWslDistro }), ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) From e5a1744e27a16181d620d0605c8c86e54708d7c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:10:36 -0700 Subject: [PATCH 017/168] fix(agent-status): stop an idle title retiring a pane pending a human answer (#15351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Claude pane parked on a permission prompt published `done` to mobile and paired clients — the state that retires the card — while the user was still being asked. The title layer cannot express permission for Claude or OpenCode: a title reads `permission` only from a vendor glyph or a synthesized ` - action required` label, and SYNTHETIC_AGENT_TITLE_PROFILES has no entry for Claude (OpenCode sets synthesizeTerminalTitle: false). So `titleConfirmsState` is unreachable for them and the hook's only protection was a timestamp comparison. That comparison loses because the two title clocks advance on different events: lastAgentStatusRichInvalidatedAtEpochMs moves only when the title's status class changes, while lastOscTitleEpochMs moves on every write. Measured against a real claude 2.1.234 (two runs, identical structure): the title settles working->idle ~12ms after PreToolUse, PermissionRequest fires ~39ms later, then one more same-class repaint lands ~123ms after the hook. That last repaint pushes title evidence past a hook that is still current. The title then goes silent for the whole prompt (31s and 85s in the two runs), so the wrong verdict is frozen for as long as the user takes to answer. Treat a fresh `waiting`/`blocked` under an idle title as non-renewable. Scoped to idle on purpose: idle is the absence of activity evidence, whereas a `working` title or a null/shell/identity-only one contradicts the hook and must still retire the row and its stale question (#11761). Bounded by the stale window so an agent killed while parked still decays. Both state names matter: Claude's PermissionRequest normalizes to `waiting`, not `blocked`, so a guard written against `blocked` alone passes its own tests and misses every Claude permission prompt. --- ...le-agent-status-permission-renewal.test.ts | 110 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 19 +++ 2 files changed, 129 insertions(+) create mode 100644 src/main/runtime/mobile-agent-status-permission-renewal.test.ts diff --git a/src/main/runtime/mobile-agent-status-permission-renewal.test.ts b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts new file mode 100644 index 00000000000..9b7f0ef457d --- /dev/null +++ b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../shared/agent-status-types' + +// Timings below are the measured deltas from a real `claude` 2.1.234 parked on a +// Write permission prompt (epoch ms, one capture of two with identical structure): +// ...900370 PreToolUse Write +// ...900382 title "◐ Claude Code" → "✳ Claude Code" settle, working→idle +// ...900421 PermissionRequest Write the hook Orca reports as `waiting` +// ...900544 title → "✳ probe2.txt Write tool file" same idle class, last write +// then 31s of silence with the prompt still up. +// The trailing same-class repaint is the whole defect: it advances lastOscTitleEpochMs +// past a hook that is still current, while lastAgentStatusRichInvalidatedAtEpochMs (which +// moves only on a class change) stays at the settle. +const SETTLE_AFTER_HOOK_MS = -39 +const TRAILING_TITLE_AFTER_HOOK_MS = 123 + +// Private by design — the projection has no public entry point, and testing it through a +// full session.tabs publication would bury the one decision this suite is about. +type TitleRenewal = ( + status: AgentStatusEntry | null, + pty: unknown, + options?: { preserveQuestionUnderShellTitle?: boolean } +) => AgentStatusEntry | null + +function renewFromPtyTitle(): TitleRenewal { + const runtime = new OrcaRuntimeService() + const renew = (runtime as unknown as { renewMobileAgentStatusFromPtyTitle: TitleRenewal }) + .renewMobileAgentStatusFromPtyTitle + return (status, pty, options) => renew.call(runtime, status, pty, options ?? {}) +} + +function claudeStatus(state: AgentStatusEntry['state'], updatedAt: number): AgentStatusEntry { + return { + state, + prompt: 'Use the Write tool to create probe2.txt', + updatedAt, + stateStartedAt: updatedAt, + agentType: 'claude', + paneKey: 'tab-1:leaf-1', + terminalTitle: '✳ probe2.txt Write tool file', + stateHistory: [] + } +} + +/** The PTY as it stands once Claude has raised the prompt and gone quiet. */ +function parkedOnPromptPty(hookAt: number): unknown { + return { + ptyId: 'pty-1', + lastAgentStatus: 'idle', + lastOscTitle: '✳ probe2.txt Write tool file', + lastOscTitleEpochMs: hookAt + TRAILING_TITLE_AFTER_HOOK_MS, + lastAgentStatusRichInvalidatedAtEpochMs: hookAt + SETTLE_AFTER_HOOK_MS, + lastAgentStatusStartedAtEpochMs: hookAt + SETTLE_AFTER_HOOK_MS, + title: '✳ probe2.txt Write tool file', + worktreeId: 'wt-1' + } +} + +describe('mobile/paired projection for a pane pending a human answer', () => { + // Why both names: Claude's PermissionRequest normalizes to `waiting`, not `blocked` + // (agent-hook-listener normalizeClaudeEvent), so a guard written against `blocked` + // alone passes its own tests and still misses every Claude permission prompt. + it.each(['waiting', 'blocked'] as const)( + 'keeps a fresh `%s` instead of publishing a title-derived done', + (state) => { + const hookAt = Date.now() + const out = renewFromPtyTitle()(claudeStatus(state, hookAt), parkedOnPromptPty(hookAt), { + preserveQuestionUnderShellTitle: true + }) + expect(out?.state).toBe(state) + } + ) + + // Why: the guard is bounded by freshness, or an agent killed while parked on a prompt + // would hold the card open forever instead of decaying like any other abandoned row. + it('still lets the title retire a `waiting` older than the stale boundary', () => { + const hookAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + const out = renewFromPtyTitle()(claudeStatus('waiting', hookAt), parkedOnPromptPty(hookAt), { + preserveQuestionUnderShellTitle: true + }) + expect(out?.state).toBe('done') + }) + + // Why: proves the guard is scoped to pending-answer states rather than switching off + // title renewal wholesale — an idle title must still retire a stale spinner (#1437). + it('still lets an idle title retire a fresh `working`', () => { + const hookAt = Date.now() + const out = renewFromPtyTitle()(claudeStatus('working', hookAt), parkedOnPromptPty(hookAt), { + preserveQuestionUnderShellTitle: true + }) + expect(out?.state).toBe('done') + }) + + // Why: an idle title is the ABSENCE of activity evidence, so it cannot outrank the hook. + // A `working` title is positive evidence the agent resumed, which does — otherwise a + // finished turn's question card would linger into the next working interval (#11761). + it('still lets a working title retire a fresh `waiting`', () => { + const hookAt = Date.now() + const workingTitlePty = { + ...(parkedOnPromptPty(hookAt) as Record), + lastAgentStatus: 'working', + lastOscTitle: '⠋ Claude' + } + const out = renewFromPtyTitle()(claudeStatus('waiting', hookAt), workingTitlePty, { + preserveQuestionUnderShellTitle: true + }) + expect(out?.state).toBe('working') + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1cb745ebcba..e9b940cdf61 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -33673,6 +33673,25 @@ export class OrcaRuntimeService { if (!status || !pty) { return status } + // Why: pending a human answer is hook-only evidence an idle title cannot renew. A title + // reads `permission` only from a vendor glyph or a synthesized ` - action required` + // label, and SYNTHETIC_AGENT_TITLE_PROFILES has no Claude entry (OpenCode opts out), so + // `titleConfirmsState` below is unreachable for them. Timestamps can't arbitrate either: + // lastAgentStatusRichInvalidatedAtEpochMs moves only on a status-CLASS change while + // lastOscTitleEpochMs moves on EVERY write, so Claude's one same-class repaint ~123ms + // after PermissionRequest pushed title evidence past a still-current hook and published + // `done` — retiring the card while the user was still being asked. + // Only under an `idle` title: idle is the ABSENCE of activity evidence, but a `working` + // title (agent resumed) or a null/shell/identity-only one (agent released the pane) + // contradicts the hook and must still retire the row and its stale question (#11761). + // Both names: Claude's PermissionRequest normalizes to `waiting`, not `blocked`. + if ( + (status.state === 'waiting' || status.state === 'blocked') && + pty.lastAgentStatus === 'idle' && + Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + ) { + return status + } if ( options.preserveQuestionUnderShellTitle && status.interactivePrompt != null && From ccb2305c8d13682bc72914d939a6305034d54c8f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:17:33 -0700 Subject: [PATCH 018/168] refactor(runtime): name every mobile-session close outcome so the tombstone decision is explicit (STA-4718) (#15346) --- .../mobile-session-tab-close-outcome.ts | 58 +++++++++++++++ src/main/runtime/orca-runtime.test.ts | 63 ++++++++++++++++ src/main/runtime/orca-runtime.ts | 72 ++++++++----------- 3 files changed, 151 insertions(+), 42 deletions(-) create mode 100644 src/main/runtime/mobile-session-tab-close-outcome.ts diff --git a/src/main/runtime/mobile-session-tab-close-outcome.ts b/src/main/runtime/mobile-session-tab-close-outcome.ts new file mode 100644 index 00000000000..214016b2c7b --- /dev/null +++ b/src/main/runtime/mobile-session-tab-close-outcome.ts @@ -0,0 +1,58 @@ +import type { RuntimeMobileSessionTabCloseResult } from '../../shared/runtime-types' +import type { ClientSessionTabSelectionStore } from './client-session-tab-selection' + +// Why: a selection tombstone (ClientSessionTabSelectionStore.forgetTabs) is not a +// hint — it filters the tab out of every snapshot published to that client, and +// only activating the tab clears it. So it is a claim that the host itself +// authoritatively retired the tab while its own published snapshot still lists +// it. Claiming that for a close the host merely asked someone else to perform +// would hide a still-live tab from a paired client with no recovery path. +// Hence the three outcome classes below, one constructor each. + +// Why: declared, never assigned — the brand exists only in the type system, so +// no new field reaches mixed-version clients over the RPC wire. +declare const mobileSessionTabCloseOutcomeBrand: unique symbol + +// Why: the required brand makes a bare `{ closed: true }` unassignable, forcing +// every close return site to name which outcome class it is. +export type MobileSessionTabCloseOutcome = RuntimeMobileSessionTabCloseResult & { + readonly [mobileSessionTabCloseOutcomeBrand]: never +} + +type MobileSessionTabCloseRefusalReason = NonNullable< + RuntimeMobileSessionTabCloseResult['refusalReason'] +> + +// Why: the host tore the tab down (or verified its teardown) itself, so it may +// tombstone the client's selection. The tombstone is written here, not by the +// caller, so this outcome's name and its effect cannot drift apart. +export function committedMobileSessionTabClose( + selections: Pick, + worktreeId: string, + closedTabIds: readonly string[] +): MobileSessionTabCloseOutcome { + selections.forgetTabs(worktreeId, closedTabIds) + return { closed: true } as MobileSessionTabCloseOutcome +} + +// Why: the close was handed to the renderer over a fire-and-forget notifier that +// carries no acknowledgement. The renderer may legitimately decline it — an +// unresolvable worktree route, or a pinned-tab confirm the user cancels — so the +// host does not know the tab retired and must not tombstone it. +export function delegatedMobileSessionTabClose(): MobileSessionTabCloseOutcome { + return { closed: true } as MobileSessionTabCloseOutcome +} + +// Why: `snapshotRepublished` tells the client to restore its pruned mirror, so it +// is passed explicitly per site — some refusals deliberately skip the republish. +export function refusedMobileSessionTabClose( + refusalReason: MobileSessionTabCloseRefusalReason, + options: { snapshotRepublished?: boolean } = {} +): MobileSessionTabCloseOutcome { + return { + closed: true, + refused: true, + refusalReason, + ...(options.snapshotRepublished ? { snapshotRepublished: true as const } : {}) + } as MobileSessionTabCloseOutcome +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 49f1fbac345..dab6531c425 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -32539,6 +32539,69 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() }) + it('returns a delegated close outcome with no selection tombstone when the renderer owns the outcome', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const kill = vi.fn(() => true) + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(runtimeStore as never) + const forgetTabs = vi.spyOn(runtime['clientSessionTabSelections'], 'forgetTabs') + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.setNotifier({ closeTerminal } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Pending Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:pending', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `host-tab::${HEADLESS_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `host-tab::${HEADLESS_LEAF_ID}`, + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Pending Terminal', + isActive: true + } + ] + } + ] + }) + + const result = await runtime.closeMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_LEAF_ID}` + ) + + // Why: a delegated close is handed to the renderer over a fire-and-forget + // notifier that may decline it, so tombstoning would hide a live tab from + // paired clients forever. Only a host-committed close may tombstone. + expect(result).toEqual({ closed: true }) + expect(closeTerminal).toHaveBeenCalledWith('host-tab') + expect(forgetTabs).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + }) + it('tears down a runtime pending shell the renderer never adopted on close', async () => { const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e9b940cdf61..7405a020c6d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -467,7 +467,6 @@ import { type RuntimeMarkdownSaveTabResult, type RuntimeMobileSessionCreateTerminalResult, type RuntimeMobileSessionClientTab, - type RuntimeMobileSessionTabCloseResult, type RuntimeMobileSessionMarkdownTab, type RuntimeMobileSessionTabMove, type RuntimeMobileSessionTabMoveResult, @@ -695,6 +694,12 @@ import { deriveClientSessionTabSelection, projectClientSessionTabSelection } from './client-session-tab-selection' +import { + committedMobileSessionTabClose, + delegatedMobileSessionTabClose, + refusedMobileSessionTabClose, + type MobileSessionTabCloseOutcome +} from './mobile-session-tab-close-outcome' import type { PtyProviderBufferSnapshot, IFilesystemProvider, @@ -8995,7 +9000,7 @@ export class OrcaRuntimeService { async refuseUnattributedMobileSessionTabClose( worktreeSelector: string, tabId: string - ): Promise { + ): Promise { const snapshot = await this.listMobileSessionTabs(worktreeSelector) const tabExists = snapshot.tabs.some( (candidate) => @@ -9009,12 +9014,9 @@ export class OrcaRuntimeService { // Why: a legacy client may already have hidden its mirror; a new snapshot // restores it without granting an unattributed request destructive authority. this.republishMobileSessionTabsSnapshot(snapshot.worktree) - return { - closed: true, - refused: true, - refusalReason: 'missing-intent', + return refusedMobileSessionTabClose('missing-intent', { snapshotRepublished: true - } + }) } async closeMobileSessionTab( @@ -9027,7 +9029,7 @@ export class OrcaRuntimeService { clientNavigationId?: string localPtyTeardownOwnedExternally?: boolean } = {} - ): Promise { + ): Promise { const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -9042,24 +9044,18 @@ export class OrcaRuntimeService { if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) { // Why: keep-on-unknown must also restore the mirror the caller already pruned. this.republishMobileSessionTabsSnapshot(worktreeId) - return { - closed: true, - refused: true, - refusalReason: 'unknown-liveness', - ...(snapshot ? { snapshotRepublished: true as const } : {}) - } + return refusedMobileSessionTabClose('unknown-liveness', { + snapshotRepublished: Boolean(snapshot) + }) } if ( options.expectedPublicationEpoch !== undefined && snapshot?.publicationEpoch !== options.expectedPublicationEpoch ) { this.republishMobileSessionTabsSnapshot(worktreeId) - return { - closed: true, - refused: true, - refusalReason: 'stale-publication', - ...(snapshot ? { snapshotRepublished: true as const } : {}) - } + return refusedMobileSessionTabClose('stale-publication', { + snapshotRepublished: Boolean(snapshot) + }) } const tab = snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? @@ -9084,19 +9080,18 @@ export class OrcaRuntimeService { ) if (!terminalIncarnationMatches) { this.republishMobileSessionTabsSnapshot(worktreeId) - return { - closed: true, - refused: true, - refusalReason: 'stale-terminal', + return refusedMobileSessionTabClose('stale-terminal', { snapshotRepublished: true - } + }) } } let closedSelectionTabIds = [tab.id] - const finishCommittedClose = (): RuntimeMobileSessionTabCloseResult => { - this.clientSessionTabSelections.forgetTabs(worktreeId, closedSelectionTabIds) - return { closed: true } - } + const finishCommittedClose = (): MobileSessionTabCloseOutcome => + committedMobileSessionTabClose( + this.clientSessionTabSelections, + worktreeId, + closedSelectionTabIds + ) if (tab.type === 'terminal') { const parentLeafCount = snapshot.tabs.filter( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId @@ -9148,21 +9143,14 @@ export class OrcaRuntimeService { } // Why: both markers are skew-safe; clients must restore a mirror only // when the host actually republished it, not for a dead leaf. - return { - closed: true, - refused: true, - refusalReason: 'live-host-pty', - ...(!addressedDeadLeaf ? { snapshotRepublished: true as const } : {}) - } + return refusedMobileSessionTabClose('live-host-pty', { + snapshotRepublished: !addressedDeadLeaf + }) } if (!closingWholeParent || this.tabs.has(tab.parentTabId)) { // Why: only the renderer may retire its own tab or split leaf; a // remote lifecycle echo must never cross that boundary into a kill. - return { - closed: true, - refused: true, - refusalReason: 'retirement-owner' - } + return refusedMobileSessionTabClose('retirement-owner') } } // Why: a runtime-owned headless tab is absent from renderer state, so the @@ -9241,14 +9229,14 @@ export class OrcaRuntimeService { return finishCommittedClose() } this.notifier.closeTerminal(tab.parentTabId) - return { closed: true } + return delegatedMobileSessionTabClose() } // Why: paired web tab bars represent a split terminal with one local // parent tab id. Closing that parent should close the desktop tab, not // just whichever leaf happened to be first in the session snapshot. this.notifier.closeTerminal(tab.parentTabId) this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) - return { closed: true } + return delegatedMobileSessionTabClose() } else if (tab.type === 'browser' && this.offscreenBrowserBackend) { // Why: headless browser tabs are offscreen WebContents with no renderer to // route closeSessionTab to. Close the page directly and drop it from the From 3c676ed13d2677b99ef6aa0cd97b4ade2f935c8d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:26:48 -0700 Subject: [PATCH 019/168] fix(ssh): validate hashed known_hosts fields through one strict base64 decoder (STA-4717) (#15345) --- src/main/ssh/ssh-known-hosts.test.ts | 46 ++++++++++++++++++++++++++++ src/main/ssh/ssh-known-hosts.ts | 42 ++++++++++++++++--------- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/main/ssh/ssh-known-hosts.test.ts b/src/main/ssh/ssh-known-hosts.test.ts index 0a204be6aa0..d8f8d0283df 100644 --- a/src/main/ssh/ssh-known-hosts.test.ts +++ b/src/main/ssh/ssh-known-hosts.test.ts @@ -349,6 +349,52 @@ describe('lines ssh itself refuses to parse', () => { const hash = Buffer.alloc(20, 2).toString('base64') expect(parseKnownHostsLine(`|1|${salt}|${hash} ssh-ed25519 ${ED}`)).toBeDefined() }) + + const [, , REAL_SALT = '', REAL_HASH = ''] = HASHED_EXAMPLE_COM.split('|') + const hashedLine = (salt: string, hash: string): string => `|1|${salt}|${hash} ssh-ed25519 ${ED}` + + // Non-obvious: every mutation below still decodes to exactly 20 bytes, so the length check above + // passes and the entry would be trusted — Buffer.from just skips the junk. Verified live against + // OpenSSH 10.2p1 with `ssh-keygen -vvv -F` on a real `ssh-keygen -H` file: the salt cases print + // "extract_salt: salt decode error" / "bad host hash", and the hash cases find nothing silently + // because ssh regenerates the canonical `|1|salt|hash` string and byte-compares it. + it.each([ + ['salt with invalid characters spliced into the middle', REAL_SALT.replace(/^(.{6})/, '$1@@')], + ['salt with trailing junk', `${REAL_SALT}!!!`], + // Buffer.from accepts the base64url alphabet and yields the same 20 bytes; ssh does not. + ['salt in the base64url alphabet', REAL_SALT.replace(/\//g, '_').replace(/\+/g, '-')], + // Right length and it looks like base64, but the final character's leftover bits are set — the + // case a "does it look like base64" check misses and b64_pton rejects as a subliminal channel. + ['salt whose final character carries non-zero padding bits', `${REAL_SALT.slice(0, -2)}9=`], + ['salt with its base64 padding stripped', REAL_SALT.replace(/=+$/, '')] + ])('drops a hashed entry with a %s, as ssh does', (_label, salt) => { + expect(parseKnownHostsLine(hashedLine(salt, REAL_HASH))).toBeUndefined() + }) + + it.each([ + ['invalid characters spliced into the middle', REAL_HASH.replace(/^(.{6})/, '$1@@')], + ['trailing junk', `${REAL_HASH}!!!`] + ])('drops a hashed entry whose hash has %s, as ssh does', (_label, hash) => { + expect(parseKnownHostsLine(hashedLine(REAL_SALT, hash))).toBeUndefined() + }) + + // Guards the cases above from passing by rejecting everything. + it('still accepts the unmutated ssh-keygen -H vector', () => { + expect(parseKnownHostsLine(hashedLine(REAL_SALT, REAL_HASH))).toBeDefined() + }) + + // Extra padding is a b64_pton error too, so the key field is held to the same exact-encoding rule. + it('drops a key field with extra base64 padding', () => { + expect(parseKnownHostsLine(`example.com ssh-ed25519 ${ED}==`)).toBeUndefined() + }) + + // The parse result IS the trust decision: a junk-salt line previously produced a full `match`. + it('reports unknown for a junk-salt hashed line instead of matching it', () => { + expect(verdict(hashedLine(REAL_SALT.replace(/^(.{6})/, '$1@@'), REAL_HASH), { key: ED })).toBe( + 'unknown' + ) + expect(verdict(hashedLine(REAL_SALT, REAL_HASH), { key: ED })).toBe('match') + }) }) describe('agreement with a live OpenSSH client', () => { diff --git a/src/main/ssh/ssh-known-hosts.ts b/src/main/ssh/ssh-known-hosts.ts index 6f0765ae1a4..6545d615023 100644 --- a/src/main/ssh/ssh-known-hosts.ts +++ b/src/main/ssh/ssh-known-hosts.ts @@ -86,18 +86,29 @@ export function formatHostKeyFingerprint(sha256Base64: string): string { return `SHA256:${sha256Base64.replace(/=+$/, '')}` } -function decodeKey(raw: string): Buffer | undefined { - // Buffer.from never throws on bad base64 — it silently SKIPS invalid characters, so `!!!` - // and a blob with `@@` spliced into it both decode to the same correct key. ssh rejects those - // lines outright ("parse error in hostkeys file"), so accepting them grants trust from a line the - // user's own ssh ignores; `AAAA` is worse still, decoding to different bytes that still - // parse, which reads as a CHANGED key. Re-encoding and comparing is what makes us as strict. +/** + * The ONE way this file turns a base64 field into bytes. Every base64 field on a known_hosts line — + * key blob, hash salt, host hash — must come through here, so a field added later cannot quietly + * skip the rule. + * + * Why re-encode and compare: Buffer.from never throws on bad base64, it silently SKIPS invalid + * characters, so `!!!` and a field with `@@` spliced into it both decode to the same correct + * bytes. ssh rejects those lines outright ("parse error in hostkeys file", "salt decode error"), so + * accepting them grants trust from a line the user's own ssh ignores; `AAAA` is worse still, + * decoding to different bytes that still parse, which reads as a CHANGED key. + * + * The comparison is EXACT, padding included, which is what OpenSSH's b64_pton does: it rejects a + * missing `=`, a stray one, the base64url alphabet, and a final character whose leftover bits are + * non-zero. Verified live against OpenSSH 10.2p1 — each of those mutations on a real `ssh-keygen -H` + * salt makes ssh refuse to find the host at all. + */ +function decodeCanonicalBase64(raw: string): Buffer | undefined { const decoded = Buffer.from(raw, 'base64') + // Empty would re-encode to '' and pass the comparison; `|1||hash` must not survive as an entry. if (decoded.length === 0) { return undefined } - const canonical = decoded.toString('base64').replace(/=+$/, '') - return canonical === raw.replace(/=+$/, '') ? decoded : undefined + return decoded.toString('base64') === raw ? decoded : undefined } function parseHashedPatterns(field: string): KnownHostsEntry['hashed'] | undefined { @@ -106,12 +117,13 @@ function parseHashedPatterns(field: string): KnownHostsEntry['hashed'] | undefin if (parts.length !== 4 || parts[0] !== '' || parts[1] !== '1') { return undefined } - const salt = Buffer.from(parts[2] ?? '', 'base64') - const hash = Buffer.from(parts[3] ?? '', 'base64') - // ssh requires BOTH to be exactly one SHA1 digest — extract_salt rejects anything else with - // "expected salt len 20, got N". Accepting a shorter salt would let us match a line ssh treats as - // a parse error, so the entry would be invisible to the user's own ssh but trusted by us. - if (salt.length !== SHA1_DIGEST_BYTES || hash.length !== SHA1_DIGEST_BYTES) { + const salt = decodeCanonicalBase64(parts[2] ?? '') + const hash = decodeCanonicalBase64(parts[3] ?? '') + // Length is orthogonal to canonicality, so both checks are needed: ssh requires BOTH fields to be + // exactly one SHA1 digest — extract_salt rejects anything else with "expected salt len 20, got N". + // Accepting a shorter salt would let us match a line ssh treats as a parse error, so the entry + // would be invisible to the user's own ssh but trusted by us. + if (!salt || !hash || salt.length !== SHA1_DIGEST_BYTES || hash.length !== SHA1_DIGEST_BYTES) { return undefined } return { salt, hash } @@ -148,7 +160,7 @@ export function parseKnownHostsLine(line: string): KnownHostsEntry | undefined { return undefined } - const key = decodeKey(keyBase64) + const key = decodeCanonicalBase64(keyBase64) if (!key || readHostKeyType(key) !== keyType || !isWellFormedHostKeyBlob(key)) { return undefined } From ef788c80c780dbdcffb37379cb3b2b19b322c755 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:26:49 -0700 Subject: [PATCH 020/168] test(terminal): assert wide-char buffer content across repaints (#15280) * test(terminal): assert wide-char buffer content across repaints The first reproduction for the Korean duplication asserted on the byte stream a pty emits, and it passes on real Windows on both ConPTY backends. The reporter's evidence says that is the wrong surface: their copied text pastes doubled, so the corruption is in the buffer, and in one run the command echo is doubled while that same command's output is clean - text that was correct when it left the shell and went wrong while being placed on screen. A redraw landing on the wrong cells emits perfectly legitimate bytes, so no stream assertion can see it. These assert buffer content instead, against a cell-level model of a grid where one glyph spans two cells. The model shares no code with the emulator, but its rules were chosen to match observed behaviour, so it is a regression detector rather than a first-principles oracle - the header says so rather than overclaiming. The largest case is a cursor positioned onto the second cell of a two-cell glyph, then erase-to-end-of-line, then rewrite, swept across every row and column at widths 8 to 44. That is the only place a wide character can be half addressed. Also covered: a whole row re-emitted over wide characters, which is the shape a console redraw takes; snapshot round-trip of a half-addressed buffer; and reflow across 148 width pairs, which is the resize the reporter uses as a workaround. Nothing reproduces. The emulator blanks the orphaned half correctly at every width and column, and reflow is lossless. The two pty specs run in the existing Windows packaging job rather than a new lane. A dedicated runner cost roughly four minutes, almost all of it checkout and a native rebuild, to run thirteen seconds of tests, and Windows minutes bill at double - the packaging job already installs the same dependencies and already runs a Windows test step. No production code changes. An earlier revision added an environment variable to select the system ConPTY, and it is dropped: its only effect would have been to let a user disable the fix for an earlier duplication bug, it logged nothing so a support bundle could not confirm it took effect, and the tests drive the backend directly without it. Refs #15192 * test(terminal): share the wide-glyph predicate instead of copying its regex The duplication detector carried its own copy of the range the grid model already exports, so the two could drift and only one would be updated. --- .github/workflows/pr.yml | 2 + .../__fixtures__/terminal-wide-cell-grid.ts | 173 +++++++++++++++ ...eadless-emulator-wide-char-repaint.test.ts | 165 +++++++++++++++ ...adless-emulator-wide-char-snapshot.test.ts | 57 +++++ ...-repaint-wide-char-buffer.node-pty.test.ts | 198 ++++++++++++++++++ ...pty-wide-char-duplication.node-pty.test.ts | 147 +++++++++++++ 6 files changed, 742 insertions(+) create mode 100644 src/main/daemon/__fixtures__/terminal-wide-cell-grid.ts create mode 100644 src/main/daemon/headless-emulator-wide-char-repaint.test.ts create mode 100644 src/main/daemon/headless-emulator-wide-char-snapshot.test.ts create mode 100644 src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts create mode 100644 src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4170819f57f..011b08695c6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -539,6 +539,8 @@ jobs: - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts + src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts + src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts src/main/orca-profiles/profile-index-store.test.ts src/main/runtime/repo-worktree-admin-fingerprint.test.ts diff --git a/src/main/daemon/__fixtures__/terminal-wide-cell-grid.ts b/src/main/daemon/__fixtures__/terminal-wide-cell-grid.ts new file mode 100644 index 00000000000..4fac37d471c --- /dev/null +++ b/src/main/daemon/__fixtures__/terminal-wide-cell-grid.ts @@ -0,0 +1,173 @@ +/** + * A cell-level model of a terminal grid in which one glyph can occupy two cells, + * plus readers for the same view out of a real xterm buffer. + * + * Why a model rather than hand-written expectations (#15192): the cases that + * matter are repaints whose cursor lands on a wide glyph's TRAILING cell, and + * the correct outcome there — blank the orphaned leading half — is too fiddly to + * spell out for every width and column by hand. + * + * What agreement does and does not prove: the model shares no code with xterm - its + * own width oracle, cursor and erase semantics - so agreement is not a code-reuse + * tautology. But its rules were chosen to match observed behaviour, so it is a + * regression detector that locks in today's semantics, not a first-principles oracle. + * If both were wrong in the same way it would not notice. + */ +import type { Terminal } from '@xterm/headless' + +/** Code points that occupy two console cells: Hangul, CJK ideographs, kana, fullwidth forms. */ +const WIDE = /[ᄀ-ᅟ⺀-鿿가-힣豈-﫿︰-﹏!-⦆¢-₩]/ + +export function isWideGlyph(ch: string): boolean { + return WIDE.test(ch) +} + +/** `null` marks a wide glyph's trailing cell, which renders as nothing. */ +type Cell = string | null + +export class WideCellGrid { + private readonly rows: Cell[][] = [] + private row = 0 + private col = 0 + + constructor(private readonly cols: number) {} + + private at(row: number): Cell[] { + while (this.rows.length <= row) { + this.rows.push(Array.from({ length: this.cols }).fill(' ')) + } + return this.rows[row]! + } + + /** Clears whichever half-pair covers `col`, so no orphaned half is ever left behind. */ + private clearPair(row: number, col: number): void { + if (col < 0 || col >= this.cols) { + return + } + const cells = this.at(row) + if (cells[col] === null) { + cells[col] = ' ' + if (col > 0) { + cells[col - 1] = ' ' + } + return + } + if (col + 1 < this.cols && cells[col + 1] === null) { + cells[col] = ' ' + cells[col + 1] = ' ' + } + } + + moveTo(row: number, col: number): void { + this.row = row + this.col = col + } + + carriageReturn(): void { + this.col = 0 + } + + lineFeed(): void { + this.row += 1 + } + + eraseToLineEnd(): void { + this.clearPair(this.row, this.col) + const cells = this.at(this.row) + for (let col = this.col; col < this.cols; col += 1) { + cells[col] = ' ' + } + } + + eraseLine(): void { + this.rows[this.row] = Array.from({ length: this.cols }).fill(' ') + } + + text(value: string): void { + for (const ch of value) { + if (ch === '\r') { + this.carriageReturn() + continue + } + if (ch === '\n') { + this.lineFeed() + continue + } + const width = isWideGlyph(ch) ? 2 : 1 + // A wide glyph that does not fit blanks the rest of the row and wraps whole. + if (this.col + width > this.cols) { + const cells = this.at(this.row) + for (let col = this.col; col < this.cols; col += 1) { + this.clearPair(this.row, col) + cells[col] = ' ' + } + this.row += 1 + this.col = 0 + } + this.clearPair(this.row, this.col) + if (width === 2) { + this.clearPair(this.row, this.col + 1) + } + const cells = this.at(this.row) + cells[this.col] = ch + if (width === 2) { + cells[this.col + 1] = null + } + this.col += width + } + } + + render(rowCount: number): string[] { + const out: string[] = [] + for (let row = 0; row < rowCount; row += 1) { + const cells = this.rows[row] + out.push( + cells + ? cells + .map((cell) => cell ?? '') + .join('') + .replace(/\s+$/, '') + : '' + ) + } + return out + } +} + +/** Physical rows, right-trimmed. A wide glyph's trailing cell contributes nothing, as in the model. */ +export function readGridRows(terminal: Terminal, rowCount = terminal.rows): string[] { + const buffer = terminal.buffer.active + const out: string[] = [] + for (let row = 0; row < rowCount; row += 1) { + out.push((buffer.getLine(row)?.translateToString(false) ?? '').replace(/\s+$/, '')) + } + return out +} + +/** + * Rows joined across xterm's wrap continuations, with whitespace removed. + * + * Why whitespace-free: when a wide glyph cannot fit, xterm blanks the last cell + * and wraps the glyph whole. That blank is indistinguishable in the buffer from + * a space the program wrote, so a joined line gains one space per seam at some + * widths and not others. Dropping spaces removes an ambiguity the buffer really + * does not carry, and keeps what this is for: a doubled or missing glyph still + * shows. (Same normalization as headless-emulator-wide-char-snapshot.test.ts.) + */ +export function readWrappedLineGlyphs(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const lines: string[] = [] + for (let row = 0; row < buffer.length; row += 1) { + const line = buffer.getLine(row) + if (!line) { + continue + } + const text = line.translateToString(false) + if (line.isWrapped && lines.length > 0) { + lines[lines.length - 1] += text + } else { + lines.push(text) + } + } + return lines.map((line) => line.replace(/\s+/g, '')) +} diff --git a/src/main/daemon/headless-emulator-wide-char-repaint.test.ts b/src/main/daemon/headless-emulator-wide-char-repaint.test.ts new file mode 100644 index 00000000000..e183c8735f3 --- /dev/null +++ b/src/main/daemon/headless-emulator-wide-char-repaint.test.ts @@ -0,0 +1,165 @@ +/** + * #15192, second hypothesis: the reported doubling is a REPAINT landing at the + * wrong cells, not corrupted source bytes. + * + * The reporter's decisive run had the command echo doubled while that same + * command's output was clean, so the shell never received doubled bytes; and the + * doubled text pasted doubled into Notepad, so it was really in the buffer. A + * stream-level assertion (windows-conpty-wide-char-duplication.node-pty.test.ts) + * cannot see that: every byte of a bad repaint is legitimate — cursor + * positioning plus text — and only the cells it lands on are wrong. + * + * So these assert BUFFER CONTENT after repaint-shaped sequences. The sharpest + * case is a rewrite whose cursor lands on the second cell of a two-cell glyph: + * the only place a wide character can be half-addressed, and where both + * duplication and loss would come from. Every fixture carries a Latin control so + * a failure proves script-selectivity rather than general corruption. + * + * Runs everywhere: no ConPTY needed, so this also guards macOS and Linux. + */ +import { describe, expect, it } from 'vitest' +import type { Terminal } from '@xterm/headless' +import { HeadlessEmulator } from './headless-emulator' +import { WideCellGrid, readGridRows } from './__fixtures__/terminal-wide-cell-grid' + +const KO = '안녕하세요 오르카 테스트입니다.' +const KO2 = '결론부터 말씀드리면 시각적 피로도' +const LA = 'roadmap/complete-overhaul-backlog-history.md' +// Single-row fixtures: `\r` returns to the start of the last PHYSICAL row, so a +// whole-row repaint is only well defined for content that fits on one. +const KO_ROW = '안녕하세요 오르카' +const KO2_ROW = '테스트입니다.' +const LA_ROW = 'complete-overhaul.md' +const ROWS = 14 + +type Painted = { rows: string[]; emulator: HeadlessEmulator } + +function paint(cols: number, sequence: string): Painted { + const emulator = new HeadlessEmulator({ cols, rows: ROWS }) + emulator.writeSync(sequence) + const terminal = (emulator as unknown as { terminal: Terminal }).terminal + return { rows: readGridRows(terminal, ROWS), emulator } +} + +function rowsOf(cols: number, sequence: string): string[] { + const painted = paint(cols, sequence) + painted.emulator.dispose() + return painted.rows +} + +describe('wide-character wrap fidelity', () => { + it('places every glyph where an independent cell model does, at every width', () => { + const mismatches: string[] = [] + for (let cols = 6; cols <= 60; cols += 1) { + for (const text of [KO, `${LA} ${KO}`, `${KO} ${LA}`, `${KO}${KO2}`]) { + const model = new WideCellGrid(cols) + model.text(text) + const actual = rowsOf(cols, text) + if (actual.join('|') !== model.render(ROWS).join('|')) { + mismatches.push( + `cols=${cols} ${JSON.stringify(text.slice(0, 12))}: ${JSON.stringify(actual)}` + ) + } + } + } + expect(mismatches).toEqual([]) + }) +}) + +describe('repaint over wide characters (#15192)', () => { + it('lands a rewrite correctly on every column, including a glyph trailing cell', () => { + const mismatches: string[] = [] + for (let cols = 8; cols <= 44; cols += 1) { + for (let row = 0; row < 3; row += 1) { + for (let col = 0; col < Math.min(cols, 20); col += 1) { + // CUP + EL + rewrite is the shape a program uses to redraw one row. + const sequence = `${KO}\x1b[${row + 1};${col + 1}H\x1b[0K${KO2}` + const model = new WideCellGrid(cols) + model.text(KO) + model.moveTo(row, col) + model.eraseToLineEnd() + model.text(KO2) + const actual = rowsOf(cols, sequence) + if (actual.join('|') !== model.render(ROWS).join('|')) { + mismatches.push(`cols=${cols} row=${row} col=${col}: ${JSON.stringify(actual)}`) + } + } + } + } + expect(mismatches).toEqual([]) + }) + + it('leaves a full-row repaint indistinguishable from writing the row once', () => { + // ConPTY redraws by re-emitting whole rows; a row re-emitted over wide + // characters must not differ from the same row drawn from scratch. + const mismatches: string[] = [] + for (let cols = 22; cols <= 60; cols += 1) { + for (const [prior, next] of [ + [KO_ROW, KO2_ROW], + [KO2_ROW, KO_ROW], + [LA_ROW, KO_ROW], + [KO_ROW, LA_ROW], + [KO_ROW, KO_ROW] + ] as const) { + for (const repaint of ['\r\x1b[2K', '\r\x1b[0K', '\x1b[1;1H\x1b[0K']) { + const painted = rowsOf(cols, `${prior}${repaint}${next}`) + const direct = rowsOf(cols, next) + if (painted.join('|') !== direct.join('|')) { + mismatches.push( + `cols=${cols} ${JSON.stringify(repaint)}: painted=${JSON.stringify(painted)} direct=${JSON.stringify(direct)}` + ) + } + } + } + } + expect(mismatches).toEqual([]) + }) + + it('keeps a repainted buffer intact through the snapshot the renderer replays', () => { + // The main-side snapshot is where a half-addressed cell could be re-serialized + // as a whole glyph, doubling it for every client that restores the pane. + const mismatches: string[] = [] + for (let cols = 10; cols <= 44; cols += 1) { + for (const col of [1, 2, 5, 6, 9, 10]) { + const painted = paint(cols, `${LA}\r\n${KO}\x1b[${col}G\x1b[0K${KO2}\r\n`) + const snapshot = painted.emulator.getSnapshot({ scrollbackRows: 400 }) + painted.emulator.dispose() + const replay = paint(cols, `${snapshot.scrollbackAnsi ?? ''}${snapshot.snapshotAnsi}`) + replay.emulator.dispose() + if (replay.rows.join('|') !== painted.rows.join('|')) { + mismatches.push( + `cols=${cols} col=${col}: live=${JSON.stringify(painted.rows.filter(Boolean))} replay=${JSON.stringify(replay.rows.filter(Boolean))}` + ) + } + } + } + expect(mismatches).toEqual([]) + }) + + it('reflows to exactly what the target width would have drawn from scratch', () => { + // The reporter's workaround was resizing the window, so the reflow path is + // load-bearing: after it the buffer must hold what that width always shows. + const mismatches: string[] = [] + const written = `${LA}\r\n${KO}\r\n${KO2}\r\n${LA}\r\n` + for (let cols = 12; cols <= 48; cols += 1) { + for (const target of [cols - 5, cols - 1, cols + 1, cols + 7]) { + if (target < 8) { + continue + } + const emulator = new HeadlessEmulator({ cols, rows: ROWS }) + emulator.writeSync(written) + emulator.resize(target, ROWS) + const terminal = (emulator as unknown as { terminal: Terminal }).terminal + const reflowed = readGridRows(terminal, ROWS) + emulator.dispose() + const direct = rowsOf(target, written) + if (reflowed.join('|') !== direct.join('|')) { + mismatches.push( + `${cols}->${target}: reflowed=${JSON.stringify(reflowed.filter(Boolean))} direct=${JSON.stringify(direct.filter(Boolean))}` + ) + } + } + } + expect(mismatches).toEqual([]) + }) +}) diff --git a/src/main/daemon/headless-emulator-wide-char-snapshot.test.ts b/src/main/daemon/headless-emulator-wide-char-snapshot.test.ts new file mode 100644 index 00000000000..c12b680bec5 --- /dev/null +++ b/src/main/daemon/headless-emulator-wide-char-snapshot.test.ts @@ -0,0 +1,57 @@ +/** + * #15192 coverage gap: the model-snapshot fuzzers exercise wide text only through + * a single `你好世界` word, so a leading/trailing-cell fault in the main-side + * snapshot path could survive them. This sweeps Hangul across every width where + * a syllable can straddle the wrap boundary and pins that the restore the + * renderer replays holds the same text the model does. + */ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { HeadlessEmulator } from './headless-emulator' +import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' + +const KO = + '안녕하세요 오르카 테스트입니다. 결론부터 말씀드리면 시각적 피로도 절제된 럭셔리 다크 테마 가독성 행간(1.75) 적용 roadmap/complete-overhaul-backlog-history.md' + +function textOf(t: Terminal): string { + const b = t.buffer.active + const out: string[] = [] + for (let y = 0; y < b.length; y++) { + out.push(b.getLine(y)?.translateToString(true) ?? '') + } + return out.join('').replace(/\s+/g, '') +} + +function replay( + snapshot: { scrollbackAnsi?: string; snapshotAnsi: string }, + cols: number, + rows: number +): string { + const t = new Terminal({ cols, rows, scrollback: 5000, allowProposedApi: true }) + t.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(t as never) + const core = (t as unknown as { _core: { writeSync(d: string): void } })._core + core.writeSync(`${snapshot.scrollbackAnsi ?? ''}${snapshot.snapshotAnsi}`) + return textOf(t) +} + +describe('headless emulator wide-character snapshot fidelity', () => { + it('does not duplicate Hangul across widths', () => { + const bad: string[] = [] + for (let cols = 12; cols <= 80; cols++) { + const emu = new HeadlessEmulator({ cols, rows: 14 }) + emu.write(`${KO}\r\n${KO}\r\n`) + const snap = emu.getSnapshot({ scrollbackRows: 200 }) + const src = textOf((emu as unknown as { terminal: Terminal }).terminal) + const rt = replay(snap, cols, 14) + // Why: an empty read would satisfy src === rt and assert nothing. + expect(src.length).toBeGreaterThan(0) + if (src !== rt) { + bad.push(`cols=${cols}\n src: ${src}\n rt : ${rt}`) + } + emu.dispose() + } + expect(bad).toEqual([]) + }) +}) diff --git a/src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts b/src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts new file mode 100644 index 00000000000..525f337b82f --- /dev/null +++ b/src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts @@ -0,0 +1,198 @@ +/** + * Real-ConPTY reproduction for #15192, asserted on BUFFER CONTENT rather than on + * the byte stream. + * + * Its sibling windows-conpty-wide-char-duplication.node-pty.test.ts asserts that + * no two identical wide characters arrive adjacent in the bytes node-pty emits. + * That passes on both backends, and it cannot fail for the fault the reporter + * actually described: in their decisive run the command echo was doubled while + * that same command's output was clean, so the shell never received doubled + * bytes. A repaint landing at the wrong cells is made entirely of legitimate + * bytes -- cursor positioning plus text -- and only the cells it lands on are + * wrong. The doubled text also pasted doubled into Notepad, so the corruption + * was in the buffer. + * + * So the ConPTY output is replayed into Orca's own main-side emulator, resizing + * it before the pty exactly as Session.resize does (session.ts), and the + * resulting lines are compared to the fixtures. + * + * Everything that does not need ConPTY runs on every platform: on macOS and + * Linux node-pty spawns a real pty, so the same assertions guard those and prove + * the replay harness is not silently vacuous on the Windows lane. Only the + * backend A/B (bundled OpenConsole vs the system ConPTY) is win32-only. + * + * The width sweep behind these fixtures lives in + * src/main/daemon/headless-emulator-wide-char-repaint.test.ts. + */ +import { describe, expect, it } from 'vitest' +import type { Terminal } from '@xterm/headless' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { isWideGlyph, readWrappedLineGlyphs } from '../daemon/__fixtures__/terminal-wide-cell-grid' + +const itOnWindows = process.platform === 'win32' ? it : it.skip + +const KOREAN_LINE = '안녕하세요 오르카 테스트입니다. 결론부터 말씀드리면 시각적 피로도' +const LATIN_LINE = 'roadmap/complete-overhaul-backlog-history.md (1.75) R-08)' +const LINE_REPEATS = 8 +const COLS = 40 +const ROWS = 12 + +const glyphsOf = (line: string): string => line.replace(/\s+/g, '') +const KOREAN_GLYPHS = glyphsOf(KOREAN_LINE) +const LATIN_GLYPHS = glyphsOf(LATIN_LINE) + +type Event = { data: string } | { resizeTo: number } + +type RunOptions = { + useConptyDll: boolean + /** Milliseconds after spawn at which to resize, paired with the target width. */ + resizes?: { atMs: number; cols: number }[] + /** How long the child stays alive after its last write, so a resize can trigger a repaint. */ + holdMs: number +} + +/** Why node and not a shell: cmd.exe's output codepage follows the machine's ANSI codepage, so the fixture bytes would not be trustworthy. */ +function childScript(holdMs: number): string { + return [ + `const ko=${JSON.stringify(KOREAN_LINE)};`, + `const la=${JSON.stringify(LATIN_LINE)};`, + `let i=0;`, + `const t=setInterval(()=>{process.stdout.write(ko+"\\r\\n"+la+"\\r\\n");`, + `if(++i>=${LINE_REPEATS}){clearInterval(t);setTimeout(()=>process.exit(0),${holdMs});}},25);` + ].join('') +} + +async function recordConpty(options: RunOptions): Promise { + const nodePty = await import('node-pty') + const proc = nodePty.spawn(process.execPath, ['-e', childScript(options.holdMs)], { + name: 'xterm-256color', + cols: COLS, + rows: ROWS, + cwd: process.cwd(), + env: process.env as Record, + ...(options.useConptyDll ? { useConptyDll: true } : {}) + }) + + // One ordered log: a resize recorded here replays at the same point in the + // stream it happened at, which is the only way the replay can be faithful. + const events: Event[] = [] + proc.onData((chunk) => { + events.push({ data: chunk }) + }) + const timers = (options.resizes ?? []).map((resize) => + setTimeout(() => { + events.push({ resizeTo: resize.cols }) + try { + proc.resize(resize.cols, ROWS) + } catch { + /* the child may already have exited */ + } + }, resize.atMs) + ) + + await new Promise((resolve) => { + proc.onExit(() => resolve()) + }) + for (const timer of timers) { + clearTimeout(timer) + } + return events +} + +function replayIntoEmulator(events: Event[]): string[] { + const emulator = new HeadlessEmulator({ cols: COLS, rows: ROWS }) + for (const event of events) { + if ('resizeTo' in event) { + // Session.resize sizes the emulator before the pty; keep that order. + emulator.resize(event.resizeTo, ROWS) + } else { + emulator.writeSync(event.data) + } + } + const terminal = (emulator as unknown as { terminal: Terminal }).terminal + const lines = readWrappedLineGlyphs(terminal).filter((line) => line.length > 0) + emulator.dispose() + return lines +} + +/** Adjacent identical wide glyphs. Neither fixture contains any, so a hit is duplication. */ +function doubledWideRuns(text: string): string[] { + const hits: string[] = [] + for (let index = 1; index < text.length; index += 1) { + const glyph = text[index]! + if (glyph === text[index - 1] && isWideGlyph(glyph)) { + hits.push(text.slice(Math.max(0, index - 12), index + 12)) + } + } + return hits +} + +function expectFixtureLinesOnly(lines: string[]): void { + // Guard against a vacuous pass: the fixture must actually have reached us. + expect(lines.length).toBeGreaterThan(0) + expect(lines.filter((line) => line === KOREAN_GLYPHS)).toHaveLength(LINE_REPEATS) + // Control: Latin survives whatever happened above, so a failure is script-selective. + expect(lines.filter((line) => line === LATIN_GLYPHS)).toHaveLength(LINE_REPEATS) + expect(lines.filter((line) => line !== KOREAN_GLYPHS && line !== LATIN_GLYPHS)).toEqual([]) +} + +describe('doubled-wide-glyph detector', () => { + it('flags the text the reporter pasted into Notepad and clears the correct text', () => { + expect(doubledWideRuns('시시각각적적 피피로로도도').length).toBeGreaterThan(0) + expect(doubledWideRuns(KOREAN_LINE)).toEqual([]) + // Latin repeats (`ll`, `oo`) must not register, or the ConPTY cases would fail for the wrong reason. + expect(doubledWideRuns(LATIN_LINE)).toEqual([]) + }) +}) + +describe('pty repaint fidelity in the terminal buffer (#15192)', () => { + it('leaves the buffer holding exactly the lines that were printed', async () => { + expectFixtureLinesOnly( + replayIntoEmulator(await recordConpty({ useConptyDll: true, holdMs: 50 })) + ) + }, 60_000) + + itOnWindows( + 'holds the same lines on the system ConPTY (A/B for the bundled build)', + async () => { + expectFixtureLinesOnly( + replayIntoEmulator(await recordConpty({ useConptyDll: false, holdMs: 50 })) + ) + }, + 60_000 + ) + + it('survives the resize the reporter used as a workaround, after output settles', async () => { + // Resizing once the child is quiet isolates the repaint: nothing is being + // written, so any line that changes was moved by ConPTY's redraw alone. + const events = await recordConpty({ + useConptyDll: true, + holdMs: 900, + resizes: [ + { atMs: 450, cols: 31 }, + { atMs: 650, cols: 47 } + ] + }) + expectFixtureLinesOnly(replayIntoEmulator(events)) + }, 60_000) + + it('does not double a wide glyph when a resize lands mid-line', async () => { + // Deliberately weaker than the cases above: a resize that interrupts a line + // lets ConPTY's reflow and xterm's disagree about where the tail belongs, + // which would fail an exact comparison without proving duplication. What + // must never happen either way is a glyph appearing twice. + const events = await recordConpty({ + useConptyDll: true, + holdMs: 200, + resizes: [ + { atMs: 60, cols: 33 }, + { atMs: 110, cols: 45 }, + { atMs: 160, cols: 29 } + ] + }) + const lines = replayIntoEmulator(events) + expect(lines.length).toBeGreaterThan(0) + expect(lines.flatMap(doubledWideRuns)).toEqual([]) + expect(lines).toContain(LATIN_GLYPHS) + }, 60_000) +}) diff --git a/src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts b/src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts new file mode 100644 index 00000000000..5f449bbfe49 --- /dev/null +++ b/src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts @@ -0,0 +1,147 @@ +/** + * Real-ConPTY reproduction for #15192: wide characters arrive doubled in the + * terminal buffer on Windows (`안녕하세요` -> `안안녕녕하하세세요요`), while + * Latin is never affected. + * + * Why at this layer and not in the renderer: every renderer-side duplication + * path (delivery replay, snapshot restore, output-queue chunking) is + * script-blind and would double ASCII too. The one component in the stack that + * represents a character as TWO cells is the Windows console text buffer, where + * a DBCS glyph occupies a leading and a trailing cell that both carry the same + * wchar. Emitting the trailing cell as text produces exactly the reported + * signature. So the assertion is on the bytes node-pty hands us, BEFORE xterm. + * + * Orca pins the ConPTY implementation with `useConptyDll: true` + * (local-pty-utils.ts, pty-subprocess.ts, windows-conpty-warmup.ts), so the + * bundled OpenConsole build is a variable we control and the system one is the + * A/B. Both are exercised here. + * + * Runs only on win32; skipped elsewhere (macOS/Linux have no ConPTY). + */ +import { describe, expect, it } from 'vitest' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { isWideGlyph } from '../daemon/__fixtures__/terminal-wide-cell-grid' + +const itOnWindows = process.platform === 'win32' ? it : it.skip + +const KOREAN_LINE = '안녕하세요 오르카 테스트입니다. 결론부터 말씀드리면 시각적 피로도' +const LATIN_LINE = 'roadmap/complete-overhaul-backlog-history.md (1.75) R-08)' +const LINE_REPEATS = 12 + +/** Adjacent identical wide characters. The fixtures contain none, so any hit is duplication. */ +function doubledWideRuns(text: string): string[] { + const hits: string[] = [] + for (let i = 1; i < text.length; i++) { + const ch = text[i]! + if (ch === text[i - 1] && isWideGlyph(ch)) { + hits.push(`${text.slice(Math.max(0, i - 12), i + 12)}`) + } + } + return hits +} + +async function runThroughConpty(opts: { useConptyDll: boolean }): Promise { + const nodePty = await import('node-pty') + // Why node and not `echo`: cmd.exe's output codepage depends on the machine's + // ANSI codepage, which would make the fixture bytes untrustworthy. Node always + // writes UTF-8 here. + const script = [ + `const ko=${JSON.stringify(KOREAN_LINE)};`, + `const la=${JSON.stringify(LATIN_LINE)};`, + `let i=0;`, + `const t=setInterval(()=>{process.stdout.write(ko+"\\r\\n"+la+"\\r\\n");`, + `if(++i>=${LINE_REPEATS}){clearInterval(t);process.exit(0);}},20);` + ].join('') + + const proc = nodePty.spawn(process.execPath, ['-e', script], { + name: 'xterm-256color', + // Why narrow: the reporter's doubling shows on wrapped rows, and a wide glyph + // straddling the wrap boundary is where the leading/trailing cell pair matters. + cols: 40, + rows: 10, + cwd: process.cwd(), + env: process.env as Record, + ...(opts.useConptyDll ? { useConptyDll: true } : {}) + }) + + let out = '' + proc.onData((chunk) => { + out += chunk + }) + // Why a resize mid-stream: ConPTY repaints its viewport on a size change, and + // the report is intermittent in a way that tracks repaints (the reporter's own + // workaround is resizing the window). + const resizes = [38, 44, 36, 46] + const timers = resizes.map((cols, index) => + setTimeout( + () => { + try { + proc.resize(cols, 10) + } catch { + /* the child may already have exited */ + } + }, + 60 + index * 50 + ) + ) + + try { + await new Promise((resolve) => { + proc.onExit(() => resolve()) + }) + } finally { + // Why: a child that never exits would otherwise outlive the run and leak into the rest of the job. + for (const timer of timers) { + clearTimeout(timer) + } + try { + proc.kill() + } catch { + /* already gone */ + } + } + return out +} + +// Why a self-test: the ConPTY cases only run on Windows, so without this the +// detector could rot into a vacuous pass on every other platform's CI. +describe('doubled-wide-character detector', () => { + it('flags the text the reporter pasted into Notepad and clears the correct text', () => { + const reported = '시시각각적적 피피로로도도 | 빨빨강강/파파랑랑/초초록록 원원색색 배배지지' + const correct = '시각적 피로도 | 빨강/파랑/초록 원색 배지' + expect(doubledWideRuns(reported).length).toBeGreaterThan(0) + expect(doubledWideRuns(correct)).toEqual([]) + // Latin repeats (`ll`, `oo`) must not register, or the ConPTY cases would fail for the wrong reason. + expect(doubledWideRuns('complete-overhaul-backlog-history.md (1.75)')).toEqual([]) + }) + + it('survives the shared ANSI stripper the ConPTY cases run output through', () => { + expect(stripAnsiEscapeSequences(`\x1b[2K${KOREAN_LINE}\x1b[0m\x1b]0;title\x07`)).toBe( + KOREAN_LINE + ) + }) +}) + +describe('Windows ConPTY wide-character fidelity (#15192)', () => { + itOnWindows( + 'does not double wide characters with the bundled ConPTY (useConptyDll)', + async () => { + const text = stripAnsiEscapeSequences(await runThroughConpty({ useConptyDll: true })) + // Guard against a vacuous pass: the fixture must actually have reached us. + expect(text).toContain(KOREAN_LINE.slice(0, 5)) + expect(doubledWideRuns(text)).toEqual([]) + // Control: the Latin line survives intact, so a failure above is script-selective. + expect(text).toContain(LATIN_LINE) + }, + 60_000 + ) + + itOnWindows( + 'does not double wide characters with the system ConPTY (A/B for the bundled build)', + async () => { + const text = stripAnsiEscapeSequences(await runThroughConpty({ useConptyDll: false })) + expect(doubledWideRuns(text)).toEqual([]) + }, + 60_000 + ) +}) From 9312c5cd9b8976a9c41b0c76ae653f48fe6853be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:32:13 -0700 Subject: [PATCH 021/168] fix(browser): serialize cookie imports per partition (#15095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): serialize cookie imports per partition (STA-4601) Two concurrent cookie imports on one partition can erase a session. Nothing serialises imports per partition — neither the renderer IPC handler (browser-session-profile-ipc.ts) nor the runtime RPC method (orca-runtime-browser.ts) — and the clear lock covered only the clear itself, so it was released before the writes and before any rollback. Reachable interleaving: import A replaces cookies for its imported domains, releases the lock, and later fails a write; import B clears and writes; A's rollback then removes cookies B already wrote and reported as imported. Path B has the same shape: A clears, B clears, then A writes its cookies on top of B's jar. Fix: one lock spans the whole live-jar transaction on both paths — the clear/replace, the writes, and the rollback. withCookieClearLock becomes acquireCookieMutationLock/withCookieMutationLock so path A can hold it across a try/finally rather than a single callback. Rebased onto #15030 (STA-4300), which rewrote this file: imports now write through CDP identities, so path A's writes moved from an inline cookies.set() loop into writeImportedCookies(), path B's into the same helper, and — the part that matters for a lock — importValidatedCookies no longer holds the Electron Session at all. It receives a CookieImportTarget that hides the Session behind openWriteStore(), and openWriteStore() builds a FRESH adapter per call, so keying the lock on anything reachable from it would serialise nothing. The target therefore carries mutationLockOwner, set to session.fromPartition's instance, which is the same object the native path locks on. That is what keeps both paths on one key per partition. Deliberately bounded: this covers the LIVE JAR only. Staging and cold-start replay keep their existing semantics, so two other pre-existing hazards in that subsystem are untouched and still open — the crash window between the clear and the result, and a permanently-armed replay when replay keeps failing. Both need the pending-image operations to become provable, which is a different change. The lock is keyed per owner, so imports into different partitions still run concurrently. Mutation-proved against the rebased tree, every mutation structure-preserving (brace/paren balance pinned) and every one re-run rather than carried over: dropping path A's acquire reddens the file-import detector; keying path A's acquire on a fresh object instead of mutationLockOwner reddens it too — the lock-present-but-miskeyed shape this rebase risks; replacing path B's lock with a passthrough reddens the native detector; doing both reddens both, which rules out incidental serialisation; and neutering the cold-init probe's lock reddens the new probe detector. That last detector is new: neutering the probe lock previously left all 779 browser tests green, so the probe's protection was unproven. The concurrency suite's store stub also gained writeCookieIdentity and getStoragePath. Without them the STA-4300 code throws a TypeError that writeImportedCookies catches as a write rejection, so the imports would have taken the failure path while the ordering assertions still passed. Each real import test now asserts writeCookieIdentity was CALLED, and that cookies.set never was. src/main/browser: 72 files, 780 tests, green; typecheck, oxlint, type-aware code quality, oxfmt and the max-lines ratchet all clean. * fix(browser): serialize native cookie staging * test(browser): pin native flush serialization * test(browser): pin staged cookie replay ordering * test(browser): remove vacuous staged replay detector * test(browser): detect stale native staging images * test(browser): guard cookie import ordering events * test(browser): cover staging lock boundary --- .../browser/browser-cookie-import-clear.ts | 34 +- .../browser-cookie-import-concurrency.test.ts | 509 ++++++++ src/main/browser/browser-cookie-import.ts | 1082 +++++++++-------- 3 files changed, 1093 insertions(+), 532 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-concurrency.test.ts diff --git a/src/main/browser/browser-cookie-import-clear.ts b/src/main/browser/browser-cookie-import-clear.ts index a01e3aa1d10..9f364d097d0 100644 --- a/src/main/browser/browser-cookie-import-clear.ts +++ b/src/main/browser/browser-cookie-import-clear.ts @@ -51,7 +51,7 @@ export type CookieClearSession = { restoreClearIdentities: CookieClearStore['restoreClearIdentities'] } -const clearLocks = new WeakMap>() +const mutationLocks = new WeakMap>() function cookieClearKey(url: string, name: string): string { return JSON.stringify([url, name]) @@ -74,17 +74,30 @@ export function identitiesFromClearCookies( })) } -export async function withCookieClearLock(owner: object, run: () => Promise): Promise { - const previous = clearLocks.get(owner) ?? Promise.resolve() +/** + * Serialises every live-jar mutation for one owner. + * + * Why (STA-4601): an import's clear, its writes, and its rollback are one transaction. Holding the + * lock for the clear alone lets a second import interleave between them, so a stale rollback can + * remove cookies the newer import already reported as written. Callers that need the lock across a + * try/finally take it directly; callers with a single callback use the wrapper below. + */ +export async function acquireCookieMutationLock(owner: object): Promise<() => void> { + const previous = mutationLocks.get(owner) ?? Promise.resolve() let release!: () => void const current = new Promise((resolve) => { release = resolve }) - clearLocks.set( + mutationLocks.set( owner, previous.then(() => current) ) await previous + return release +} + +export async function withCookieMutationLock(owner: object, run: () => Promise): Promise { + const release = await acquireCookieMutationLock(owner) try { return await run() } finally { @@ -165,11 +178,22 @@ async function restoreClearedCookies( ) } +/** + * Clears the transplantable cookies from a jar. + * + * Why (STA-4601): this takes the mutation lock on the object it is PASSED, which serialises direct + * callers that hand it a real Session — pinned by "serializes concurrent clears on the same + * session" in the atomicity suite. It does NOT serialise the importer, because both import paths + * build a fresh adapter object per call, so the key is new every time and this lock is a no-op for + * them. That is deliberate and safe: the importer holds the real per-partition lock, keyed on the + * Electron Session, across its whole clear-and-write transaction — a scope this function cannot + * see. Do not remove the importer's outer lock on the assumption that this one covers it. + */ export async function removeTransplantableCookies( targetSession: CookieClearSession, preserveFamilies: ReadonlySet = new Set() ): Promise { - return withCookieClearLock(targetSession, async () => { + return withCookieMutationLock(targetSession, async () => { const store = targetSession.cookies const initialCookies = await store.get({}) if (initialCookies.length === 0) { diff --git a/src/main/browser/browser-cookie-import-concurrency.test.ts b/src/main/browser/browser-cookie-import-concurrency.test.ts new file mode 100644 index 00000000000..0da6242cf8c --- /dev/null +++ b/src/main/browser/browser-cookie-import-concurrency.test.ts @@ -0,0 +1,509 @@ +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + appGetPathMock, + sessionFromPartitionMock, + snapshotClearIdentitiesMock, + writeCookieIdentityMock, + copyFileWithWindowsRetryMock, + setPendingCookieImportMock, + clearPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + snapshotClearIdentitiesMock: vi.fn(), + writeCookieIdentityMock: vi.fn(), + copyFileWithWindowsRetryMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + dialog: { showOpenDialog: vi.fn() }, + session: { fromPartition: sessionFromPartitionMock } +})) +// Why (STA-4300): both import paths now write through writeCookieIdentity, not cookies.set. A stub +// without that method throws a TypeError that writeImportedCookies CATCHES as a write rejection, so +// the import would silently take the failure path and the ordering assertions would go vacuous. +// Every test below therefore also asserts writeCookieIdentity was actually CALLED. +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { + get: (filter: object) => Promise + remove: (url: string, name: string) => Promise + } + }) => ({ + get: (filter: object) => targetSession.cookies.get(filter), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: snapshotClearIdentitiesMock, + restoreClearIdentities: async () => undefined, + writeCookieIdentity: writeCookieIdentityMock, + dispose: () => undefined + }) +})) +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('../codex-accounts/fs-utils', () => ({ + copyFileWithWindowsRetry: copyFileWithWindowsRetryMock +})) + +import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { acquireCookieMutationLock, withCookieMutationLock } from './browser-cookie-import-clear' + +/** + * STA-4601: two imports on one partition must not interleave on the live jar. + * + * Nothing serialises imports per partition — neither the renderer IPC handler nor the runtime RPC + * method — so before this change the lock covered the clear alone and was released before the + * writes and the rollback. That let import A's rollback remove cookies import B had already + * written and reported as imported. + * + * These tests drive the lock primitive directly with deterministic interleavings rather than + * racing two real imports, so a failure names the ordering rule that broke instead of flaking. + */ +describe('cookie mutation lock', () => { + it('serialises two transactions on the same owner', async () => { + const owner = {} + const order: string[] = [] + + const first = withCookieMutationLock(owner, async () => { + order.push('A:clear') + await Promise.resolve() + order.push('A:write') + }) + // Why: started before A resolves, so an unserialised implementation interleaves here. + const second = withCookieMutationLock(owner, async () => { + order.push('B:clear') + await Promise.resolve() + order.push('B:write') + }) + + await Promise.all([first, second]) + + expect(order).toEqual(['A:clear', 'A:write', 'B:clear', 'B:write']) + }) + + it('does not serialise across different owners', async () => { + // Why: the lock is per partition. Two different profiles must still import concurrently. + const order: string[] = [] + const a = withCookieMutationLock({}, async () => { + order.push('a:start') + await Promise.resolve() + order.push('a:end') + }) + const b = withCookieMutationLock({}, async () => { + order.push('b:start') + await Promise.resolve() + order.push('b:end') + }) + + await Promise.all([a, b]) + + expect(order).toContain('a:end') + expect(order).toContain('b:end') + // Interleaved rather than strictly sequential. + expect(order).not.toEqual(['a:start', 'a:end', 'b:start', 'b:end']) + }) + + it('releases the lock when the transaction throws, so the next import is not wedged', async () => { + const owner = {} + + await expect( + withCookieMutationLock(owner, async () => { + throw new Error('clear rejected') + }) + ).rejects.toThrow('clear rejected') + + // Why: a failed import must not leave the partition permanently locked. + await expect(withCookieMutationLock(owner, async () => 'second ran')).resolves.toBe( + 'second ran' + ) + }) + + it('holds across an explicit acquire/release so a rollback stays inside the transaction', async () => { + // Why: path A takes the lock directly rather than through a callback, because its rollback runs + // in a finally block. A stale rollback outside the lock is the STA-4601 defect. + const owner = {} + const order: string[] = [] + + const release = await acquireCookieMutationLock(owner) + const queued = withCookieMutationLock(owner, async () => { + order.push('B:clear') + }) + + order.push('A:replace') + await Promise.resolve() + order.push('A:rollback') + release() + + await queued + + expect(order).toEqual(['A:replace', 'A:rollback', 'B:clear']) + }) + + it('leaves the lock usable if a holder releases twice', async () => { + const owner = {} + const release = await acquireCookieMutationLock(owner) + release() + release() + + await expect(withCookieMutationLock(owner, async () => 'ok')).resolves.toBe('ok') + }) +}) + +/** + * The primitive tests above prove the lock works. They would ALL still pass if the importer stopped + * calling it, because they never run an import — so on their own they are not a detector for the + * bug this change fixes. These tests drive two real concurrent imports into one partition and + * assert the second cannot begin until the first has finished writing. + */ +describe('two concurrent imports into one partition', () => { + let tmpDir: string + let events: string[] + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let releaseFirstWrite: () => void + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-concurrency-')) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + events = [] + copyFileWithWindowsRetryMock + .mockReset() + .mockImplementation((source: string, destination: string) => { + copyFileSync(source, destination) + }) + // Why: the clear asserts its snapshot covers the removal plan, so a stub returning [] fails + // the import before it ever reaches a write — and the ordering assertion would pass vacuously. + snapshotClearIdentitiesMock + .mockReset() + .mockImplementation(async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie: entry, url }) => ({ url, ...entry })) + ) + + cookiesGetMock = vi.fn().mockResolvedValue([ + { domain: '.a.example', name: 'old-a', value: 'v', path: '/', secure: true }, + { domain: '.b.example', name: 'old-b', value: 'v', path: '/', secure: true } + ]) + cookiesRemoveMock = vi.fn(async (_url: string, name: string) => { + events.push(`remove:${name}`) + }) + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve + }) + writeCookieIdentityMock.mockReset().mockImplementation(async (identity: { name: string }) => { + events.push(`set:${identity.name}`) + // Why: hold the FIRST import inside its write so a second import has a real opportunity to + // interleave. Without the lock it takes that opportunity; with it, it waits. + if (identity.name === 'new-a') { + await firstWrite + } + }) + // Why (STA-4300): the importer must never reach cookies.set — it drops partitionKey. Kept as a + // guard so a regression back onto it shows up here instead of silently passing. + cookiesSetMock = vi.fn() + + // Why: Electron returns the SAME Session instance for a given partition string, which is what + // makes a per-session lock a per-partition lock. Returning a fresh object per call here would + // make this test pass against an unlocked implementation. + const stableSession = { + cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock } + } + sessionFromPartitionMock.mockReset().mockReturnValue(stableSession) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeSource(name: string, domain: string): string { + const filePath = join(tmpDir, `${name}.json`) + writeFileSync( + filePath, + JSON.stringify([{ domain, name, value: 'imported', path: '/', secure: true }]) + ) + return filePath + } + + it('does not let the second import touch the jar until the first has finished writing', async () => { + const fileA = writeSource('new-a', '.a.example') + const fileB = writeSource('new-b', '.b.example') + + const first = importCookiesFromFile(fileA, 'persist:concurrency-test') + // Let the first import reach its write and block there. + await new Promise((resolve) => setTimeout(resolve, 5)) + const second = importCookiesFromFile(fileB, 'persist:concurrency-test') + await new Promise((resolve) => setTimeout(resolve, 20)) + + // At this instant the first import is still inside its write. Nothing from the second may have + // touched the jar yet — this is the assertion that fails if the call sites drop the lock. + expect(events).not.toContain('remove:old-b') + expect(events).not.toContain('set:new-b') + + releaseFirstWrite() + const [resultA, resultB] = await Promise.all([first, second]) + + // Why: if either import failed, the ordering assertions below would pass vacuously. + expect(resultA.ok).toBe(true) + expect(resultB.ok).toBe(true) + // Why: a store stub missing writeCookieIdentity throws a TypeError that writeImportedCookies + // swallows as a rejection, so BOTH imports would report zero writes while the ordering + // assertions still held. Pin that the write path was actually entered, once per import. + expect(writeCookieIdentityMock).toHaveBeenCalledTimes(2) + expect(cookiesSetMock).not.toHaveBeenCalled() + + // And the full ordering is serial, not interleaved. + expect(events).toContain('set:new-a') + expect(events).toContain('remove:old-b') + expect(events.indexOf('set:new-a')).toBeLessThan(events.indexOf('remove:old-b')) + }) +}) + +/** + * Path B (native Chromium) has its own lock acquisition, and the file-import test above cannot + * reach it. Without this, deleting path B's withCookieMutationLock leaves every test green — which + * a review round confirmed by mutation before this was added. + */ +describe('two concurrent NATIVE imports into one partition', () => { + let tmpDir: string + let events: string[] + let cookiesSetMock: ReturnType + let releaseFirstWrite: () => void + let platformSpy: { mockRestore: () => void } + let stagingCopyCount: number + + function chromeBrowser(cookiesPath: string) { + return { + family: 'chrome' as const, + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } + } + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-native-concurrency-')) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + events = [] + stagingCopyCount = 0 + copyFileWithWindowsRetryMock + .mockReset() + .mockImplementation((source: string, destination: string) => { + if (destination.includes('cookie-import-staging')) { + stagingCopyCount += 1 + events.push(`copy:${stagingCopyCount === 1 ? 'first' : 'second'}`) + } + copyFileSync(source, destination) + }) + // Why: an EMPTY jar makes removeTransplantableCookies return before it clears, so the clear + // would never run and a clearData assertion would pass vacuously. Seed it. + snapshotClearIdentitiesMock + .mockReset() + .mockImplementation(async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie: entry, url }) => ({ url, ...entry })) + ) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve + }) + writeCookieIdentityMock.mockReset().mockImplementation(async (identity: { name: string }) => { + events.push(`set:${identity.name}`) + if (identity.name === 'first') { + await firstWrite + } + }) + // Why (STA-4300): same guard as the file path — the native writes go through CDP identities. + cookiesSetMock = vi.fn() + const stableSession = { + cookies: { + get: vi + .fn() + .mockResolvedValue([ + { domain: '.stale.example', name: 'stale', value: 'v', path: '/', secure: true } + ]), + remove: vi.fn().mockResolvedValue(undefined), + set: cookiesSetMock, + flushStore: vi.fn(async () => { + events.push('flushStore') + }) + }, + clearData: vi.fn(async () => { + events.push('clearData') + }), + // Why (STA-4300): the importer asks the Session where its storage lives instead of rebuilding + // the path from the partition string, so the stub has to answer. + getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'native-conc'), + setUserAgent: vi.fn() + } + sessionFromPartitionMock.mockReset().mockReturnValue(stableSession) + }) + + afterEach(() => { + platformSpy.mockRestore() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('does not let a second native import clear while the first is still writing', async () => { + const sourceA = join(tmpDir, 'ChromeA', 'Default', 'Network', 'Cookies') + const sourceB = join(tmpDir, 'ChromeB', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceA, [ + { domain: '.a.example', name: 'first', value: 'v' } + ]).close() + createChromiumCookieTestDatabase(sourceB, [ + { domain: '.b.example', name: 'second', value: 'v' } + ]).close() + // Why: a live target DB so the cold-init probe does not run and change the event ordering. + createChromiumCookieTestDatabase( + join(tmpDir, 'userData', 'Partitions', 'native-conc', 'Network', 'Cookies'), + [] + ).close() + + // Start both calls before either reaches its write. This makes the detector cover the full + // lock boundary: B's staging copy must be blocked too, not merely B's live clear/write. + const first = importCookiesFromBrowser(chromeBrowser(sourceA), 'persist:native-conc') + const second = importCookiesFromBrowser(chromeBrowser(sourceB), 'persist:native-conc') + // Wait until A is parked in its write so the assertions below have a deterministic hold point. + for (let i = 0; i < 100 && !events.includes('set:first'); i++) { + await new Promise((resolve) => setTimeout(resolve, 1)) + } + expect(events).toContain('set:first') + await new Promise((resolve) => setTimeout(resolve, 10)) + + // The first import is parked inside its write. The second must not stage a stale image while + // that transaction is active — this assertion fails if the staging copy sits outside the lock. + expect(events).not.toContain('copy:second') + // Nor may it flush, clear, or write the live jar while that transaction is active. + expect(events.filter((e) => e === 'flushStore')).toHaveLength(1) + expect(events.filter((e) => e === 'clearData')).toHaveLength(1) + expect(events).not.toContain('set:second') + + releaseFirstWrite() + const [resultA, resultB] = await Promise.all([first, second]) + + // Why: a failed import would make the ordering assertions pass vacuously. + expect(resultA.ok).toBe(true) + expect(resultB.ok).toBe(true) + // Why: same swallowed-TypeError trap as the file path — assert the write path was ATTEMPTED, + // once per import, not just that the end state looks right. + expect(writeCookieIdentityMock).toHaveBeenCalledTimes(2) + expect(cookiesSetMock).not.toHaveBeenCalled() + expect(events.indexOf('set:first')).toBeLessThan(events.lastIndexOf('clearData')) + expect(events.indexOf('copy:second')).toBeGreaterThan(events.indexOf('set:first')) + }) +}) + +/** + * The cold-init probe writes and then DELETES https://localhost/__init on the live jar, and an + * import can legitimately write that exact coordinate (normalizeCookieImportDomain accepts + * `localhost` and deriveUrl produces that URL). Unlocked, the probe's remove() would erase a cookie + * a concurrent import had just reported as imported. + * + * Nothing else in the suite reaches this call site — the other native test pre-creates the target + * DB so the probe never runs — so without this, dropping the probe's lock leaves every test green. + */ +describe('cold-init probe on a partition that has never stored a cookie', () => { + let tmpDir: string + let events: string[] + let cookiesSetMock: ReturnType + let platformSpy: { mockRestore: () => void } + let stableSession: object + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-probe-')) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + events = [] + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + snapshotClearIdentitiesMock + .mockReset() + .mockImplementation(async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie: entry, url }) => ({ url, ...entry })) + ) + writeCookieIdentityMock.mockReset().mockResolvedValue(undefined) + + const partitionDir = join(tmpDir, 'userData', 'Partitions', 'probe-conc') + cookiesSetMock = vi.fn(async (cookie: { name: string }) => { + events.push(`set:${cookie.name}`) + }) + stableSession = { + cookies: { + get: vi.fn().mockResolvedValue([]), + remove: vi.fn(async (_url: string, name: string) => { + events.push(`remove:${name}`) + }), + set: cookiesSetMock, + // Why: the real probe's flushStore is what creates the file. It must NOT create it on the + // earlier pre-staging flush, or the DB already exists when the probe is chosen and the + // probe never runs — which is exactly how this detector went vacuous the first time. + flushStore: vi.fn(async () => { + if (events.includes('set:__init')) { + createChromiumCookieTestDatabase(join(partitionDir, 'Network', 'Cookies'), []).close() + } + }) + }, + clearData: vi.fn(async () => { + events.push('clearData') + }), + getStoragePath: () => partitionDir, + setUserAgent: vi.fn() + } + sessionFromPartitionMock.mockReset().mockReturnValue(stableSession) + }) + + afterEach(() => { + platformSpy.mockRestore() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('cannot write or delete __init while another transaction holds the partition', async () => { + const source = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(source, [ + { domain: '.probe.example', name: 'probe', value: 'v' } + ]).close() + + // Why: stand in for an import already mid-transaction on this partition. + const release = await acquireCookieMutationLock(stableSession) + + const running = importCookiesFromBrowser( + { + family: 'chrome' as const, + label: 'Google Chrome', + cookiesPath: source, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + }, + 'persist:probe-conc' + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + + // This is the assertion that fails if the probe stops taking the lock. + expect(events).not.toContain('set:__init') + expect(events).not.toContain('remove:__init') + + release() + await running + + // Why: if the import had bailed before the probe, the assertions above would pass vacuously. + // Pin that the probe actually ran once released. + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: '__init', url: 'https://localhost' }) + ) + expect(events.indexOf('set:__init')).toBeGreaterThanOrEqual(0) + expect(events.indexOf('remove:__init')).toBeGreaterThan(events.indexOf('set:__init')) + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 36a6e4a9312..c39a831a6bb 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -85,8 +85,9 @@ import { type ReplacedImportedDomainCookies } from './browser-cookie-import-policy' import { + acquireCookieMutationLock, removeTransplantableCookies, - withCookieClearLock, + withCookieMutationLock, type CookieClearStore, type CookieImportWriteStore } from './browser-cookie-import-clear' @@ -583,6 +584,11 @@ type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { di type CookieImportTarget = { partition: string + // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the + // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME + // instance for one partition string, so carrying that instance here is what keeps this path's + // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. + mutationLockOwner: object openWriteStore: () => CookieImportSessionStore } @@ -594,6 +600,7 @@ function cookieImportTarget(targetPartition: string): CookieImportTarget { const targetSession = session.fromPartition(targetPartition) return { partition: targetPartition, + mutationLockOwner: targetSession, openWriteStore: () => openCookieClearStore(targetSession) } } @@ -664,6 +671,12 @@ async function importValidatedCookies( const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null if (cookieClearStore) { + // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. + // Releasing after the replace lets a second import interleave, so this run's rollback could + // remove cookies the newer import already wrote and reported as imported. Taken AFTER the + // store is opened on purpose — openWriteStore only builds the adapter, it attaches no + // debugger, so holding it while queued cannot deadlock against the holder. + const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) let replaced: ReplacedImportedDomainCookies | null = null try { if (mode === 'replace-imported-domains') { @@ -724,7 +737,11 @@ async function importValidatedCookies( } } } finally { - cookieClearStore.dispose() + try { + cookieClearStore.dispose() + } finally { + releaseMutationLock() + } } } @@ -1572,452 +1589,463 @@ export async function importCookiesFromBrowser( // Why: CookieMonster overwrites the live DB on flush, so stage a populated copy and swap it in at next cold start. const targetSession = session.fromPartition(targetPartition) - await targetSession.cookies.flushStore() + // Why (STA-4601): native imports mutate the live jar and their staged image before the old + // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, + // live replacement, pending-image bookkeeping, and cleanup so an older image cannot race a newer + // import on the same partition. + return withCookieMutationLock(targetSession, async () => { + await targetSession.cookies.flushStore() - // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from - // the caller's partition string. String surgery on a caller-supplied name is what let a value like - // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement - // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. - const partitionDir = targetSession.getStoragePath() - if (!partitionDir) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - const partitionName = targetPartition.replace('persist:', '') - let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - - // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. - if (!liveCookiesPath) { - try { - await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) - await targetSession.cookies.remove('https://localhost', '__init') - await targetSession.cookies.flushStore() - } catch { - // ignore — the set/remove may fail but flushStore should still create the file + // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from + // the caller's partition string. String surgery on a caller-supplied name is what let a value like + // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement + // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. + const partitionDir = targetSession.getStoragePath() + if (!partitionDir) { + return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } } - liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - } + const partitionName = targetPartition.replace('persist:', '') + let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - if (!liveCookiesPath) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - - const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') - const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') - const stagingCookiesPath = join( - stagingDir, - `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` - ) - // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory - // import rejects, so losing it must degrade that fallback rather than abort the import. - let stagingAvailable = false - try { - mkdirSync(stagingDir, { recursive: true }) - copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) - stagingAvailable = true - } catch (err) { - const fsErr = err as NodeJS.ErrnoException - diag( - ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` - ) - // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - } - - let sourceSnapshot: ChromiumCookieSnapshot - try { - // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. - sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) - } catch (err) { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - diag(` Chromium snapshot failed: ${String(err)}`) - return { - ok: false, - reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` - } - } - - let sourceDb: InstanceType | null = null - let stagingDb: InstanceType | null = null - const closeStagingDb = (): void => { - try { - stagingDb?.close() - } catch { - /* best-effort */ - } - stagingDb = null - } - const discardStagingFile = (): void => { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - } - - try { - // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. - sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { - readOnly: true, - readBigInts: true - }) - let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null - let colList: string | null = null - let placeholders: string | null = null - if (stagingAvailable) { - // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same - // transient AV handle can make opening it throw — degrade instead of killing the import. + // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. + // Why (STA-4601): this probe MUTATES the live jar, so it runs under the same per-partition lock as + // the import itself. An earlier revision left it outside on the argument that no import writes + // https://localhost/__init — that was wrong. normalizeCookieImportDomain accepts `localhost`, + // cookie names are unrestricted, and deriveUrl produces exactly this URL, so an import CAN write + // that coordinate. Unlocked, this probe's remove() would delete a cookie a concurrent import had + // just written and reported as imported. The cost is negligible: the probe only runs for a + // partition that has never stored a cookie, so it is at most a one-time wait per profile. + if (!liveCookiesPath) { try { - stagingDb = new DatabaseSync(stagingCookiesPath) - targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols: string[] = targetColumnInfo.map((r) => r.name) - colList = targetCols.join(', ') - placeholders = targetCols.map(() => '?').join(', ') - // Why: the staged DB replaces the whole live DB at cold start, so it is a clear step - // like any other — keep the live non-transplantable rows in it rather than replaying - // a wipe the in-memory path was not allowed to perform. - stagingDb.exec(`DELETE FROM cookies WHERE NOT (${NON_TRANSPLANTABLE_HOST_KEY_SQL})`) - } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) + await targetSession.cookies.remove('https://localhost', '__init') + await targetSession.cookies.flushStore() + } catch { + // ignore — the set/remove may fail but flushStore should still create the file + } + liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + } + + if (!liveCookiesPath) { + return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + + const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') + const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') + const stagingCookiesPath = join( + stagingDir, + `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` + ) + // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory + // import rejects, so losing it must degrade that fallback rather than abort the import. + let stagingAvailable = false + try { + mkdirSync(stagingDir, { recursive: true }) + copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) + stagingAvailable = true + } catch (err) { + const fsErr = err as NodeJS.ErrnoException + diag( + ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` + ) + // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } + + let sourceSnapshot: ChromiumCookieSnapshot + try { + // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. + sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) + } catch (err) { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + diag(` Chromium snapshot failed: ${String(err)}`) + return { + ok: false, + reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` + } + } + + let sourceDb: InstanceType | null = null + let stagingDb: InstanceType | null = null + const closeStagingDb = (): void => { + try { + stagingDb?.close() + } catch { + /* best-effort */ + } + stagingDb = null + } + const discardStagingFile = (): void => { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } + + try { + // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { + readOnly: true, + readBigInts: true + }) + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same + // transient AV handle can make opening it throw — degrade instead of killing the import. + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols: string[] = targetColumnInfo.map((r) => r.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + // Why: the staged DB replaces the whole live DB at cold start, so it is a clear step + // like any other — keep the live non-transplantable rows in it rather than replaying + // a wipe the in-memory path was not allowed to perform. + stagingDb.exec(`DELETE FROM cookies WHERE NOT (${NON_TRANSPLANTABLE_HOST_KEY_SQL})`) + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. + discardStagingFile() + } + } + + // Why (STA-4300): the partition columns drift across Chromium versions, so read the source + // schema rather than assuming a row's missing column means "unpartitioned". + const sourceColumns = new Set( + (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( + (column) => column.name + ) + ) + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null + + diag(` source has ${sourceRows.length} cookies`) + + if (sourceRows.length === 0) { + closeStagingDb() + discardStagingFile() + return { ok: false, reason: `No cookies found in ${browser.label}.` } + } + + // Why (STA-4300): partition fidelity is a property of the source row, even when its value + // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. + const partitionCandidates = sourceRows.flatMap((sourceRow) => { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) + ? [] + : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] + }) + const nativePlan = planImportWrites(partitionCandidates) + const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) + const partitionBySourceRow = new Map( + partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) + ) + + // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a + // family we cannot protect is the P0. Refuse before the jar is touched. + if (nativePlan.hasUnrepresentableSkip) { + closeStagingDb() + discardStagingFile() + return { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + + const needsSourceKey = sourceRows.some((sourceRow) => { + const encRaw = sourceRow.encrypted_value + if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { + return false + } + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + closeStagingDb() + // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. + discardStagingFile() + return { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } + + let imported = 0 + let skipped = 0 + let decryptFailed = 0 + let appBoundFailed = 0 + let keyringUnavailableFailed = 0 + let integritySkipped = 0 + let nonTransplantableSkipped = 0 + const partitionSkipped = nativePlan.skips.length + let memoryLoaded = 0 + let memoryFailed = 0 + const domainSet = new Set() + + type DecryptedCookie = Omit & { + decryptedValue: Buffer + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead + } + + const decryptedCookies: DecryptedCookie[] = [] + // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. + // A plan record holding only the derived fields compiles fine and then cannot stage. + const scanned: { entry: DecryptedCookie; sourceRow: Record }[] = [] + const sourceDomainValidity = new Map() + + // Why: staging only backs the cold-restart replay, so any failure writing it disables that + // fallback instead of aborting an import whose in-memory half still works. + let insertStmt: ReturnType['prepare']> | null = null + const disableStaging = (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) stagingAvailable = false - targetColumnInfo = null - colList = null - placeholders = null + insertStmt = null closeStagingDb() - // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. discardStagingFile() } - } - // Why (STA-4300): the partition columns drift across Chromium versions, so read the source - // schema rather than assuming a row's missing column means "unpartitioned". - const sourceColumns = new Set( - (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( - (column) => column.name - ) - ) - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null - - diag(` source has ${sourceRows.length} cookies`) - - if (sourceRows.length === 0) { - closeStagingDb() - discardStagingFile() - return { ok: false, reason: `No cookies found in ${browser.label}.` } - } - - // Why (STA-4300): partition fidelity is a property of the source row, even when its value - // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. - const partitionCandidates = sourceRows.flatMap((sourceRow) => { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) - ? [] - : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] - }) - const nativePlan = planImportWrites(partitionCandidates) - const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) - const partitionBySourceRow = new Map( - partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) - ) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a - // family we cannot protect is the P0. Refuse before the jar is touched. - if (nativePlan.hasUnrepresentableSkip) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - const needsSourceKey = sourceRows.some((sourceRow) => { - const encRaw = sourceRow.encrypted_value - if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { - return false - } - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) - }) - const sourceKey = needsSourceKey - ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - : null - if (needsSourceKey && !sourceKey) { - closeStagingDb() - // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - discardStagingFile() - return { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` - } - } - - let imported = 0 - let skipped = 0 - let decryptFailed = 0 - let appBoundFailed = 0 - let keyringUnavailableFailed = 0 - let integritySkipped = 0 - let nonTransplantableSkipped = 0 - const partitionSkipped = nativePlan.skips.length - let memoryLoaded = 0 - let memoryFailed = 0 - const domainSet = new Set() - - type DecryptedCookie = Omit & { - decryptedValue: Buffer - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead - } - - const decryptedCookies: DecryptedCookie[] = [] - // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. - // A plan record holding only the derived fields compiles fine and then cannot stage. - const scanned: { entry: DecryptedCookie; sourceRow: Record }[] = [] - const sourceDomainValidity = new Map() - - // Why: staging only backs the cold-restart replay, so any failure writing it disables that - // fallback instead of aborting an import whose in-memory half still works. - let insertStmt: ReturnType['prepare']> | null = null - const disableStaging = (reason: string): void => { - diag(` staging disabled, restart fallback unavailable: ${reason}`) - stagingAvailable = false - insertStmt = null - closeStagingDb() - discardStagingFile() - } - - if (stagingDb && colList && placeholders) { - try { - insertStmt = stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` - ) - stagingDb.exec('BEGIN TRANSACTION') - } catch (err) { - disableStaging(String(err)) - } - } else if (stagingAvailable) { - disableStaging('staged database exposed no cookies columns') - } - - // Why (§4.3b): a staged image is a whole-DB replacement on next start, so it cannot represent - // "preserve this family". When anything is preserved, this import gets no cold-start fallback. - if (nativePlan.skippedFamilies.size > 0) { - disableStaging( - `${nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` - ) - } - - for (const sourceRow of sourceRows) { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - - if (isGoogleSourceBoundCookie(name, domain)) { - integritySkipped++ - continue - } - - // Why: transplanting these replaces a working sign-in with a session the site rejects. - if (isNonTransplantableCookieDomain(domain)) { - nonTransplantableSkipped++ - continue - } - - const encRaw = sourceRow.encrypted_value - // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. - const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null - const plainRaw = sourceRow.value - - let decryptedValue: Buffer - if (encBuf && encBuf.length > 0) { - const version = cookieEncryptionVersion(encBuf) - const appBoundIneligible = version === 'v20' - const keyringIneligible = - version === 'v11' && - sourceKey?.mode === 'aes-128-cbc' && - sourceKey.keyringUnavailable === true - const raw = - sourceKey && !appBoundIneligible && !keyringIneligible - ? decryptCookieValueRaw(encBuf, sourceKey) - : null - if (!raw) { - // Why: once decrypt returns null every failure looks identical, so attribute the cause - // here while the version prefix is still in hand. Without this an undecryptable profile - // is indistinguishable from an empty one and reports success. - decryptFailed++ - if (appBoundIneligible) { - appBoundFailed++ - } else if (keyringIneligible) { - keyringUnavailableFailed++ - } - skipped++ - continue - } - decryptedValue = raw - } else if (plainRaw instanceof Uint8Array) { - decryptedValue = Buffer.from(plainRaw) - } else if (typeof plainRaw === 'string') { - decryptedValue = Buffer.from(plainRaw, 'latin1') - } else { - decryptedValue = Buffer.alloc(0) - } - - let validDomain = sourceDomainValidity.get(domain) - if (validDomain === undefined) { - validDomain = normalizeCookieImportDomain(domain) !== null - sourceDomainValidity.set(domain, validDomain) - } - if (!validDomain) { - skipped++ - continue - } - - // Decryption failures are already counted above. Every other row suppressed by the - // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. - if (!plannedSourceRows.has(sourceRow)) { - skipped++ - continue - } - - const path = sourceRow.path as string - const secure = sourceRow.is_secure === 1n - const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) - const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) - const partition = partitionBySourceRow.get(sourceRow)! - // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. - const value = decryptedValue.toString('latin1') - - // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not - // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and - // THEN applied the unreadable guard, so an unreadable row discovered late could not retract - // a sibling already emitted, and the jar-wide clear then removed more than was written back. - scanned.push({ - entry: { - decryptedValue, - value, - domain, - name, - path, - secure, - httpOnly, - sameSite, - expirationDate: expiresUtc > 0 ? expiresUtc : undefined, - partition - }, - sourceRow - }) - } - - // EMIT: everything downstream derives from the plan, so there is no second place a row can - // leak in. - for (const { entry, sourceRow } of scanned) { - decryptedCookies.push(entry) - const cleanDomain = entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain - domainSet.add(cleanDomain) - if (insertStmt && targetColumnInfo) { + if (stagingDb && colList && placeholders) { try { - const params = buildChromiumCookieInsertParams( - targetColumnInfo, - sourceRow, - entry.decryptedValue + insertStmt = stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` ) - insertStmt.run(...params) + stagingDb.exec('BEGIN TRANSACTION') } catch (err) { disableStaging(String(err)) } + } else if (stagingAvailable) { + disableStaging('staged database exposed no cookies columns') } - // Why: counts importable cookies, not staged rows — the summary must stay truthful when - // the optional staging DB is unavailable. - imported++ - } - diag( - ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` - ) - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - const undecryptableWarning = buildUndecryptableWarning({ - decryptFailed, - appBoundFailed, - keyringUnavailableFailed - }) - - // Why: an older remote client ignores the new counter and would present this loss as success. - // Placed before the early return and before any jar mutation, so a client that cannot render - // the skip fails the import outright rather than reporting a partial import as complete. - if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + // Why (§4.3b): a staged image is a whole-DB replacement on next start, so it cannot represent + // "preserve this family". When anything is preserved, this import gets no cold-start fallback. + if (nativePlan.skippedFamilies.size > 0) { + disableStaging( + `${nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` + ) } - } - if (decryptedCookies.length === 0) { - const zeroPathWarning = undecryptableWarning - closeStagingDb() - discardStagingFile() - return { - ok: true, - profileId: '', - summary: { - totalCookies: sourceRows.length, - importedCookies: 0, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so - // totalCookies === importedCookies + skippedCookies keeps holding on this path too. - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [], - // Why: a profile whose rows cannot be decrypted returns here, and without this it is - // reported as a successful empty import. - ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) + for (const sourceRow of sourceRows) { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + + if (isGoogleSourceBoundCookie(name, domain)) { + integritySkipped++ + continue + } + + // Why: transplanting these replaces a working sign-in with a session the site rejects. + if (isNonTransplantableCookieDomain(domain)) { + nonTransplantableSkipped++ + continue + } + + const encRaw = sourceRow.encrypted_value + // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. + const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null + const plainRaw = sourceRow.value + + let decryptedValue: Buffer + if (encBuf && encBuf.length > 0) { + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null + if (!raw) { + // Why: once decrypt returns null every failure looks identical, so attribute the cause + // here while the version prefix is still in hand. Without this an undecryptable profile + // is indistinguishable from an empty one and reports success. + decryptFailed++ + if (appBoundIneligible) { + appBoundFailed++ + } else if (keyringIneligible) { + keyringUnavailableFailed++ + } + skipped++ + continue + } + decryptedValue = raw + } else if (plainRaw instanceof Uint8Array) { + decryptedValue = Buffer.from(plainRaw) + } else if (typeof plainRaw === 'string') { + decryptedValue = Buffer.from(plainRaw, 'latin1') + } else { + decryptedValue = Buffer.alloc(0) + } + + let validDomain = sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + skipped++ + continue + } + + // Decryption failures are already counted above. Every other row suppressed by the + // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. + if (!plannedSourceRows.has(sourceRow)) { + skipped++ + continue + } + + const path = sourceRow.path as string + const secure = sourceRow.is_secure === 1n + const httpOnly = sourceRow.is_httponly === 1n + const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) + const partition = partitionBySourceRow.get(sourceRow)! + // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. + const value = decryptedValue.toString('latin1') + + // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not + // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and + // THEN applied the unreadable guard, so an unreadable row discovered late could not retract + // a sibling already emitted, and the jar-wide clear then removed more than was written back. + scanned.push({ + entry: { + decryptedValue, + value, + domain, + name, + path, + secure, + httpOnly, + sameSite, + expirationDate: expiresUtc > 0 ? expiresUtc : undefined, + partition + }, + sourceRow + }) + } + + // EMIT: everything downstream derives from the plan, so there is no second place a row can + // leak in. + for (const { entry, sourceRow } of scanned) { + decryptedCookies.push(entry) + const cleanDomain = entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain + domainSet.add(cleanDomain) + if (insertStmt && targetColumnInfo) { + try { + const params = buildChromiumCookieInsertParams( + targetColumnInfo, + sourceRow, + entry.decryptedValue + ) + insertStmt.run(...params) + } catch (err) { + disableStaging(String(err)) + } + } + // Why: counts importable cookies, not staged rows — the summary must stay truthful when + // the optional staging DB is unavailable. + imported++ + } + diag( + ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` + ) + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + + const undecryptableWarning = buildUndecryptableWarning({ + decryptFailed, + appBoundFailed, + keyringUnavailableFailed + }) + + // Why: an older remote client ignores the new counter and would present this loss as success. + // Placed before the early return and before any jar mutation, so a client that cannot render + // the skip fails the import outright rather than reporting a partial import as complete. + if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) { + closeStagingDb() + discardStagingFile() + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' } } - } - if (stagingDb) { - try { - stagingDb.exec('COMMIT') + if (decryptedCookies.length === 0) { + const zeroPathWarning = undecryptableWarning closeStagingDb() - diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) - } catch (err) { - disableStaging(String(err)) + discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: sourceRows.length, + importedCookies: 0, + skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so + // totalCookies === importedCookies + skippedCookies keeps holding on this path too. + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), + domains: [], + // Why: a profile whose rows cannot be decrypted returns here, and without this it is + // reported as a successful empty import. + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) + } + } } - } else { - diag(` staging skipped: ${imported} cookies will load in-memory only`) - } - // Why: clear stale cookies first; mixing them with the imported set makes sites reject the - // session. Non-transplantable families are exempt — nothing was imported for them, and their - // live session is the only one that works. - // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak - // the same CDP identities — cookies.set() cannot express the partition either one reads. - const cookieClearStore = openCookieClearStore(targetSession) - try { - // Why: this lock covers only the live jar; staging and cold-start replay keep their existing - // semantics while clear and writes can no longer interleave with another import. - await withCookieClearLock(targetSession, () => - removeTransplantableCookies( + if (stagingDb) { + try { + stagingDb.exec('COMMIT') + closeStagingDb() + diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) + } catch (err) { + disableStaging(String(err)) + } + } else { + diag(` staging skipped: ${imported} cookies will load in-memory only`) + } + + // Why: clear stale cookies first; mixing them with the imported set makes sites reject the + // session. Non-transplantable families are exempt — nothing was imported for them, and their + // live session is the only one that works. + // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak + // the same CDP identities — cookies.set() cannot express the partition either one reads. + const cookieClearStore = openCookieClearStore(targetSession) + try { + // Why (STA-4601): the outer lock spans the clear and the writes that repopulate the jar, so a + // second import cannot clear between them and write on top of a newer import's jar. + await removeTransplantableCookies( { cookies: cookieClearStore, clearData: (options) => targetSession.clearData(options), @@ -2030,114 +2058,114 @@ export async function importCookiesFromBrowser( // snapshot taken from it, so they are never submitted to any mutation. nativePlan.skippedFamilies ) - ) - diag( - ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` - ) + diag( + ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` + ) - const writable: SourceCookieToWrite[] = [] - for (const cookie of decryptedCookies) { - const url = deriveUrl(cookie.domain, cookie.secure) - if (!url) { - memoryFailed++ - continue + const writable: SourceCookieToWrite[] = [] + for (const cookie of decryptedCookies) { + const url = deriveUrl(cookie.domain, cookie.secure) + if (!url) { + memoryFailed++ + continue + } + writable.push({ ...cookie, url }) } - writable.push({ ...cookie, url }) + // Why: a rejected cookie here falls back to the staged cold-start replay rather than + // unwinding the import, so one failure must not stop the rest from loading. + const phase = await writeImportedCookies(cookieClearStore, writable, { + stopOnFailure: false, + log: diag + }) + memoryLoaded = phase.importedCount + memoryFailed += phase.writeRejected + } finally { + cookieClearStore.dispose() } - // Why: a rejected cookie here falls back to the staged cold-start replay rather than - // unwinding the import, so one failure must not stop the rest from loading. - const phase = await writeImportedCookies(cookieClearStore, writable, { - stopOnFailure: false, - log: diag - }) - memoryLoaded = phase.importedCount - memoryFailed += phase.writeRejected - } finally { - cookieClearStore.dispose() - } - diag( - ` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable` - ) - - let warning: BrowserCookieImportSummary['warning'] - if (memoryFailed > 0 && stagingAvailable) { - // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. - browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) - diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) - } else if (memoryFailed > 0) { - // Why: never register a path that was never written — cold start would replay a missing - // or partial DB over the live partition. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) - // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. - warning = { - code: 'restart-fallback-unavailable', - loadedCookies: memoryLoaded, - failedCookies: memoryFailed - } - } else { - // Why: this import already rewrote the live session, so an older staged DB must not replay over it. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` all cookies loaded in-memory — no restart needed`) - } - - // Why: the session keeps the UA the registry set at startup (clean or native). - // Imports must not impersonate the source browser — the synthesized UA read a - // fork's marketing version as a Chromium version (STA-3514), and Google binds - // sessions to the re-import, not the UA (#12884), so it bought nothing. - // Google-bound integrity cookies are already excluded by - // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. - - // Why: a partial import still drops every undecryptable row, so silence here would report it - // as an unqualified success. The restart-fallback warning describes a lossier outcome and - // keeps precedence. - if (!warning && undecryptableWarning) { - warning = undecryptableWarning - } - - const summary: BrowserCookieImportSummary = { - totalCookies: sourceRows.length, - importedCookies: imported, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...domainSet].sort(), - ...(warning ? { warning } : {}) - } - - return { ok: true, profileId: '', summary } - } catch (err) { - try { - sourceDb?.close() - } catch { - /* may already be closed */ - } - try { - stagingDb?.close() - } catch { - /* may already be closed */ - } - // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* may not exist yet */ - } - diag(` SQLite import failed: ${String(err)}`) - return { - ok: false, - reason: reasonWithDiagLog( - `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` + diag( + ` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable` ) - } - } finally { - try { - sourceSnapshot.cleanup() + + let warning: BrowserCookieImportSummary['warning'] + if (memoryFailed > 0 && stagingAvailable) { + // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. + browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) + diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) + } else if (memoryFailed > 0) { + // Why: never register a path that was never written — cold start would replay a missing + // or partial DB over the live partition. + browserSessionRegistry.clearPendingCookieImport(targetPartition) + discardStagingFile() + diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) + // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. + warning = { + code: 'restart-fallback-unavailable', + loadedCookies: memoryLoaded, + failedCookies: memoryFailed + } + } else { + // Why: this import already rewrote the live session, so an older staged DB must not replay over it. + browserSessionRegistry.clearPendingCookieImport(targetPartition) + discardStagingFile() + diag(` all cookies loaded in-memory — no restart needed`) + } + + // Why: the session keeps the UA the registry set at startup (clean or native). + // Imports must not impersonate the source browser — the synthesized UA read a + // fork's marketing version as a Chromium version (STA-3514), and Google binds + // sessions to the re-import, not the UA (#12884), so it bought nothing. + // Google-bound integrity cookies are already excluded by + // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. + + // Why: a partial import still drops every undecryptable row, so silence here would report it + // as an unqualified success. The restart-fallback warning describes a lossier outcome and + // keeps precedence. + if (!warning && undecryptableWarning) { + warning = undecryptableWarning + } + + const summary: BrowserCookieImportSummary = { + totalCookies: sourceRows.length, + importedCookies: imported, + skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), + domains: [...domainSet].sort(), + ...(warning ? { warning } : {}) + } + + return { ok: true, profileId: '', summary } } catch (err) { - diag(` Chromium snapshot cleanup failed: ${String(err)}`) + try { + sourceDb?.close() + } catch { + /* may already be closed */ + } + try { + stagingDb?.close() + } catch { + /* may already be closed */ + } + // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. + try { + unlinkSync(stagingCookiesPath) + } catch { + /* may not exist yet */ + } + diag(` SQLite import failed: ${String(err)}`) + return { + ok: false, + reason: reasonWithDiagLog( + `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` + ) + } + } finally { + try { + sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${String(err)}`) + } } - } + }) } From 3ac4171d14fd536a8a81e9ad903b0965debe50cc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:32:36 -0700 Subject: [PATCH 022/168] fix(cmd-j): host-qualify worktree resolution and disambiguate list render keys (STA-4343) (#15371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cmd-j): host-qualify worktree identity to fix STA-4343 - Same repo::path across hosts now render as two distinct rows, each resolving to its own worktree on activation — closes wrong-host activation bug. - Disambiguate repeated persisted entry ids with render-key suffixes so React never leaves ghost rows mounted; prevents frozen display glitches when a session holds duplicate tab records. - Collapse query whitespace runs, treat emoji/symbols as content, deduplicate query tokens, and improve CJK compound matching in palette search. - Track filter-field cursor by option id rather than position so re-ranking after a toggle doesn't snap to the wrong row. - Dedup tabs by id at hydration so corrupt sessions don't render duplicates. * test(palette-search): add rankMode and current-state fields to palette o Adapt test fixtures and function calls to include new rankMode parameter and isCurrentPage/isCurrentWorktree tracking fields required by the palette search algorithm fix. * fix(palette-search): add host-qualified repo and worktree resolution Handle repo and worktree collisions in multi-host environments by querying host-qualified maps and comparing execution host IDs when determining current worktree status. Ensures the palette correctly displays repo identity and marks active status for worktrees across multiple hosts. * fix(cmd-j): namespace-safe duplicates and script-aware token matching - Carve out `palette-dup:` namespace for duplicate keys to prevent collision with real persisted ids, which previously could match generated keys like `id#dup1` - Gate reverse-containment token scoring to unsegmented scripts (Han, Hiragana, Katakana, Hangul), preventing false matches like "database" matching keyword "base" in Latin text - Host-qualify worktree identity in empty-query ordering to allow switching between same-id worktrees on different hosts, which were previously treated as duplicates (STA-4343) * Shorten comments in palette-list-entry-render-keys Replace verbose comments with concise one-liners per style guide. --- .../components/WorktreeJumpPalette.test.tsx | 59 ++-- .../src/components/WorktreeJumpPalette.tsx | 186 +++++++++--- .../cmd-j/PaletteFilterFieldOptions.tsx | 35 ++- .../palette-duplicate-key-ghost-rows.test.tsx | 68 +++++ .../cmd-j/palette-filter-option-list.test.ts | 23 ++ .../palette-list-entry-render-keys.test.ts | 47 +++ .../cmd-j/palette-list-entry-render-keys.ts | 24 ++ .../cmd-j/palette-project-results.ts | 7 +- .../cmd-j/palette-query-tokens.test.ts | 39 +++ .../components/cmd-j/palette-query-tokens.ts | 42 ++- .../components/cmd-j/palette-results.test.ts | 17 ++ .../src/components/cmd-j/palette-results.ts | 46 ++- .../sidebar/workspace-kanban-search.test.ts | 17 +- .../src/lib/browser-palette-page-entries.ts | 22 +- .../src/lib/browser-palette-search.test.ts | 20 ++ .../src/lib/browser-palette-search.ts | 4 + .../lib/order-empty-query-worktrees.test.ts | 14 + .../src/lib/order-empty-query-worktrees.ts | 17 +- .../src/lib/palette-match/match-document.ts | 8 +- .../src/lib/palette-match/palette-document.ts | 6 + .../palette-match/palette-match-core.test.ts | 81 ++++++ .../src/lib/palette-match/palette-query.ts | 11 +- .../src/lib/palette-repo-resolution.test.ts | 23 ++ .../src/lib/palette-repo-resolution.ts | 31 ++ .../src/lib/simulator-palette-search.ts | 40 ++- .../workspace-tab-palette-entry-builder.ts | 275 ++++++++++++++++++ .../src/lib/workspace-tab-palette-results.ts | 4 + .../lib/workspace-tab-palette-search.test.ts | 46 +++ .../src/lib/workspace-tab-palette-search.ts | 255 +--------------- .../worktree-palette-comment-snippet.test.ts | 70 +++++ .../lib/worktree-palette-comment-snippet.ts | 11 + .../worktree-palette-create-action.test.ts | 16 + .../src/lib/worktree-palette-create-action.ts | 10 +- .../src/lib/worktree-palette-document.ts | 24 +- .../worktree-palette-multi-keyword.test.ts | 20 +- .../src/lib/worktree-palette-search.ts | 8 +- .../worktree-palette-task-url-match.test.ts | 23 ++ .../lib/worktree-palette-task-url-match.ts | 1 + .../src/store/slices/tab-group-state.ts | 17 ++ .../src/store/slices/tabs-hydration.test.ts | 42 +++ .../src/store/slices/tabs-hydration.ts | 5 +- 41 files changed, 1346 insertions(+), 368 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx create mode 100644 src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-list-entry-render-keys.ts create mode 100644 src/renderer/src/lib/palette-repo-resolution.test.ts create mode 100644 src/renderer/src/lib/palette-repo-resolution.ts create mode 100644 src/renderer/src/lib/workspace-tab-palette-entry-builder.ts create mode 100644 src/renderer/src/lib/worktree-palette-comment-snippet.test.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index a986541ed76..f42cdbb8d9c 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -181,7 +181,7 @@ async function renderPalette(overrides: Partial): Promise { } function getWorktreeRows(): string[] { - return [...testContainer.querySelectorAll('[data-command-item^="worktree:"]')].map( + return [...testContainer.querySelectorAll('[data-command-item*="worktree:"]')].map( (node) => node.textContent ?? '' ) } @@ -361,14 +361,10 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) - // KNOWN GAP (STA-4343): two same-id host rows both render, on the SAME bare command value, - // so React logs "Encountered two children with the same key" — a state it documents as - // unsupported and free to duplicate or omit children. #15170 rebuilt palette search around - // a `documents` map keyed by BARE worktree id, so host-qualifying the item id here alone - // would not fix matching; the id needs qualifying upstream with it. What DOES hold, and is - // asserted here, is that activating a row routes to THAT row's host — the routing - // guarantee this PR is about. - it('routes activation to the row host even though a collision renders one row', async () => { + // STA-4343 closed: two workspaces sharing `repoId::path` across hosts are two distinct + // rows. The documents map and worktreeMap are keyed by host identity, so each row resolves + // to its OWN worktree, and render keys keep the two apart for React and cmdk. + it('routes activation to each row own host when two same-id rows collide', async () => { const local = makeWorktree('shared', 'Local workspace', { hostId: 'local' }) const ssh = makeWorktree('shared', 'SSH workspace', { hostId: 'ssh:box' }) const state = { @@ -378,24 +374,41 @@ describe('WorktreeJumpPalette', () => { await renderPalette(state) - // The gap: both rows render, sharing one bare command value (duplicate React key). + // Both rows render; the second carries a disambiguated command value so the two never + // share a React key. const rows = testContainer.querySelectorAll( - '[data-command-item="worktree:shared"]' + '[data-command-item$="worktree:shared"]' ) expect(rows).toHaveLength(2) - const row = rows[0]! + expect([...rows].map((candidate) => candidate.getAttribute('data-command-item'))).toEqual([ + 'worktree:shared', + 'palette-dup:1:worktree:shared' + ]) - // Activation always NAMES a host — it never falls through to a hostless call, which is - // the routing guarantee this PR adds. - await act(async () => fireEvent.click(row)) - // Activation always NAMES a host — it never falls through to a hostless call, which is the - // routing guarantee this PR adds. - // - // DEFECT, pinned deliberately so a fix has to update this test: clicking the FIRST row - // activates the SECOND row's host. Duplicate React keys let the click resolve to the wrong - // child. Reproduces on main (bare `worktree:${id}` item id + id-keyed worktreeMap), so it - // is pre-existing rather than a regression here — but it is a real wrong-host open and - // should be fixed with the upstream document/id qualification. + // The first row names ITS OWN host — the wrong-host open is gone. + await act(async () => fireEvent.click(rows[0]!)) + expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { + executionHostId: 'local' + }) + }) + + // Why a separate render: activating closes the palette, so the sibling row is detached + // before a second click in the same test could reach it. + it('routes the second same-id row to the other host', async () => { + const local = makeWorktree('shared', 'Local workspace', { hostId: 'local' }) + const ssh = makeWorktree('shared', 'SSH workspace', { hostId: 'ssh:box' }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [local, ssh] }, + showSleepingWorkspaces: true + }) + + const rows = testContainer.querySelectorAll( + '[data-command-item$="worktree:shared"]' + ) + expect(rows).toHaveLength(2) + + await act(async () => fireEvent.click(rows[1]!)) expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { executionHostId: 'ssh:box' }) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 84f1400edf9..a3333c3d2a2 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -100,6 +100,10 @@ import { type SearchableBrowserPage } from '@/lib/browser-palette-search' import { buildSearchableBrowserPages } from '@/lib/browser-palette-page-entries' +import { + isPaletteCurrentWorktree, + resolvePaletteRepoForWorktree +} from '@/lib/palette-repo-resolution' import { activateBrowserPagePaletteResult } from '@/lib/browser-page-palette-activation' import { activateSimulatorTabPaletteResult } from '@/lib/simulator-tab-palette-activation' import { @@ -135,6 +139,18 @@ import { import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' +import { + composeWorktreeHostIdentity, + getWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import { findRepoForHost, getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import type { Repo } from '../../../shared/repo-types' +import { + getSettingsFocusedExecutionHostId, + isRuntimeOwnedSshTargetId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { buildPaletteListEntryRenderKeys } from '@/components/cmd-j/palette-list-entry-render-keys' import PaletteFilterMenu from '@/components/cmd-j/PaletteFilterMenu' import PaletteFilterChips from '@/components/cmd-j/PaletteFilterChips' import { buildPaletteFilterModel } from '@/components/cmd-j/palette-filter-options' @@ -182,7 +198,6 @@ import { import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-focus-restore-target' import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' -import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions' import { PaletteCreateWorktreeRow } from '@/components/cmd-j/PaletteCreateWorktreeRow' import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' @@ -205,10 +220,6 @@ import { } from '@/lib/worktree-palette-task-url-match' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' -import { - getSettingsFocusedExecutionHostId, - isRuntimeOwnedSshTargetId -} from '../../../shared/execution-host' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' @@ -710,6 +721,7 @@ function WorktreeJumpPaletteContent({ const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) const activeView = useAppStore((s) => s.activeView) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId) const activeTabType = useAppStore((s) => s.activeTabType) const activeTabId = useAppStore((s) => s.activeTabId) const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) @@ -827,6 +839,18 @@ function WorktreeJumpPaletteContent({ () => new Map(repos.map((repo) => [getRepoHostIdentity(repo), repo])), [repos] ) + // Why not repoMap.get(worktree.repoId): one repo id can be registered on two hosts, so the + // bare map is last-wins and one side of the collision renders the other host's repo — wrong + // name, wrong badge, and a false SSH chip on a purely local workspace. Falls back to the + // bare lookup so a worktree with no host, or no repo on its host, keeps its badge. + const resolveRepoForWorktree = useCallback( + (worktree: Pick): Repo | undefined => + (worktree.hostId + ? findRepoForHost(repos, worktree.repoId, { hostId: worktree.hostId, settings }) + : null) ?? resolvePaletteRepoForWorktree(worktree, repoMap, repoByHostIdentity), + [repoByHostIdentity, repoMap, repos, settings] + ) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) // Why: reuse the sidebar host-scope registry so host badge labels stay in sync. const hostOptions = useMemo( @@ -992,9 +1016,15 @@ function WorktreeJumpPaletteContent({ orderEmptyQueryWorktrees({ visibleWorktrees: emptyQueryVisibleWorktrees, activeWorktreeId, + activeWorkspaceExecutionHostId, lastVisitedAtByWorktreeId }), - [emptyQueryVisibleWorktrees, activeWorktreeId, lastVisitedAtByWorktreeId] + [ + emptyQueryVisibleWorktrees, + activeWorktreeId, + activeWorkspaceExecutionHostId, + lastVisitedAtByWorktreeId + ] ) const searchScopeWorktrees = useMemo(() => { @@ -1048,16 +1078,40 @@ function WorktreeJumpPaletteContent({ ) // Why: browser search includes archived worktrees, so this map must cover all worktrees, not just non-archived. + // Why keyed on the host identity (STA-4343): `repoId::path` repeats across hosts, so a bare + // id lets the last host inserted win and both rows then resolve to the same workspace. const worktreeMap = useMemo(() => { const map = new Map() for (const worktree of browserSortedWorktrees) { - map.set(worktree.id, worktree) + map.set(getWorktreeHostIdentity(worktree), worktree) } return map }, [browserSortedWorktrees]) + // Why a bare-id fallback: rows built before a host was stamped carry none, and dropping + // them would be a worse regression than resolving them the old ambiguous way. + const worktreeByBareId = useMemo(() => { + const map = new Map() + for (const worktree of browserSortedWorktrees) { + if (!map.has(worktree.id)) { + map.set(worktree.id, worktree) + } + } + return map + }, [browserSortedWorktrees]) + + const resolveWorktree = useCallback( + (worktreeId: string, hostId: ExecutionHostId | undefined): Worktree | undefined => + worktreeMap.get(composeWorktreeHostIdentity(hostId, worktreeId)) ?? + worktreeByBareId.get(worktreeId), + [worktreeByBareId, worktreeMap] + ) + const worktreeOrder = useMemo( - () => new Map(browserSortedWorktrees.map((worktree, index) => [worktree.id, index])), + () => + new Map( + browserSortedWorktrees.map((worktree, index) => [getWorktreeHostIdentity(worktree), index]) + ), [browserSortedWorktrees] ) @@ -1077,13 +1131,17 @@ function WorktreeJumpPaletteContent({ const hostLabelByWorktreeId = useMemo(() => { const labels = new Map() for (const worktree of allWorktrees) { - const badge = getPaletteHostBadge(repoMap.get(worktree.repoId), hostOptions, hostFilterActive) + const badge = getPaletteHostBadge( + resolveRepoForWorktree(worktree), + hostOptions, + hostFilterActive + ) if (badge) { - labels.set(worktree.id, badge.label) + labels.set(getWorktreeHostIdentity(worktree), badge.label) } } return labels - }, [allWorktrees, hostFilterActive, hostOptions, repoMap]) + }, [allWorktrees, hostFilterActive, hostOptions, resolveRepoForWorktree]) // Why keyed on the unsorted list: normalized documents depend only on text // inputs, so re-sorting for recency re-ranks without re-normalizing anything. @@ -1094,6 +1152,7 @@ function WorktreeJumpPaletteContent({ allWorktrees.filter((worktree) => !worktree.isArchived), { repoMap, + repoMapByHostIdentity: repoByHostIdentity, prCache, issueCache, workspacePortsByWorktreeId: getWorkspacePortsByWorktreeId(workspacePortScan), @@ -1103,6 +1162,7 @@ function WorktreeJumpPaletteContent({ ), [ allWorktrees, + repoByHostIdentity, repoMap, prCache, issueCache, @@ -1119,9 +1179,17 @@ function WorktreeJumpPaletteContent({ query: paletteSearchQuery, documents: worktreeDocuments, repoMap, + repoMapByHostIdentity: repoByHostIdentity, checksReviewByWorktree }), - [sortedWorktrees, paletteSearchQuery, worktreeDocuments, repoMap, checksReviewByWorktree] + [ + sortedWorktrees, + paletteSearchQuery, + worktreeDocuments, + repoByHostIdentity, + repoMap, + checksReviewByWorktree + ] ) const browserPageEntries = useMemo(() => { @@ -1131,11 +1199,13 @@ function WorktreeJumpPaletteContent({ return buildSearchableBrowserPages({ worktrees: browserSortedWorktrees, repoMap, + repoMapByHostIdentity: repoByHostIdentity, worktreeOrder, browserTabsByWorktree, browserPagesByWorkspace, activeBrowserTabId, activeWorktreeId, + activeWorkspaceExecutionHostId, activeTabType }) }, [ @@ -1143,9 +1213,11 @@ function WorktreeJumpPaletteContent({ activeBrowserTabId, activeTabType, activeWorktreeId, + activeWorkspaceExecutionHostId, browserPagesByWorkspace, browserTabsByWorktree, browserSortedWorktrees, + repoByHostIdentity, repoMap, worktreeOrder ]) @@ -1162,11 +1234,13 @@ function WorktreeJumpPaletteContent({ return buildSearchableSimulatorTabs({ worktrees: browserSortedWorktrees, repoMap, + repoMapByHostIdentity: repoByHostIdentity, worktreeOrder, unifiedTabsByWorktree, activeGroupIdByWorktree, groupsByWorktree, activeWorktreeId, + activeWorkspaceExecutionHostId, activeTabType }) }, [ @@ -1174,9 +1248,11 @@ function WorktreeJumpPaletteContent({ activeGroupIdByWorktree, activeTabType, activeWorktreeId, + activeWorkspaceExecutionHostId, browserSortedWorktrees, groupsByWorktree, repoMap, + repoByHostIdentity, unifiedTabsByWorktree, worktreeOrder ]) @@ -1193,6 +1269,7 @@ function WorktreeJumpPaletteContent({ return buildSearchableWorkspaceTabs({ worktrees: browserSortedWorktrees, repoMap, + repoMapByHostIdentity: repoByHostIdentity, worktreeOrder, unifiedTabsByWorktree, tabsByWorktree, @@ -1203,6 +1280,7 @@ function WorktreeJumpPaletteContent({ activeGroupIdByWorktree, groupsByWorktree, activeWorktreeId, + activeWorkspaceExecutionHostId, activeTabType, activeTabId, activeTabIdByWorktree, @@ -1223,10 +1301,12 @@ function WorktreeJumpPaletteContent({ activeTabType, activeTabTypeByWorktree, activeWorktreeId, + activeWorkspaceExecutionHostId, agentStatusByPaneKey, browserSortedWorktrees, groupsByWorktree, openFiles, + repoByHostIdentity, repoMap, retainedAgentsByPaneKey, settings?.tabAutoGenerateTitle, @@ -1246,11 +1326,14 @@ function WorktreeJumpPaletteContent({ const worktreeItems = useMemo(() => { const items = worktreeMatches .map((match) => { - const worktree = worktreeMap.get(match.worktreeId) + const worktree = resolveWorktree(match.worktreeId, match.worktreeHostId) if (!worktree) { return null } return { + // Why the bare id stays: buildPaletteListEntryRenderKeys already disambiguates a + // repeated id for React and cmdk, and the row now resolves through its own host + // above — so the visible command value needs no host suffix. id: `worktree:${worktree.id}`, type: 'worktree' as const, match, @@ -1270,7 +1353,7 @@ function WorktreeJumpPaletteContent({ { rank: b.match.rank, order: orderById.get(b.id) ?? 0, id: b.id } ) ) - }, [hasQuery, worktreeMap, worktreeMatches]) + }, [hasQuery, resolveWorktree, worktreeMatches]) const browserItems = useMemo( () => @@ -1349,7 +1432,7 @@ function WorktreeJumpPaletteContent({ const openTabRecentRows = useMemo(() => { const entries: OpenTabRecentRow[] = [] for (const item of openTabItems) { - const worktree = worktreeMap.get(item.result.worktreeId) + const worktree = resolveWorktree(item.result.worktreeId, item.result.executionHostId) if (!worktree) { continue } @@ -1369,7 +1452,7 @@ function WorktreeJumpPaletteContent({ }) } return entries - }, [openTabItems, terminalTabsById, worktreeMap]) + }, [openTabItems, resolveWorktree, terminalTabsById]) const recentTabRowById = useMemo( () => new Map(openTabRecentRows.map(({ row }) => [row.id, row])), @@ -2202,6 +2285,13 @@ function WorktreeJumpPaletteContent({ worktreeItems.length ]) + // Why not entry.id directly: a duplicated persisted id would repeat a React key, + // and the reconciler then leaves the extra row mounted as a frozen ghost. + const listEntryRenderKeys = useMemo( + () => buildPaletteListEntryRenderKeys(listEntries.map((entry) => entry.id)), + [listEntries] + ) + // Why derive from listEntries: multi-primary interleave must stay identical for // empty-state counts and keyboard selection — dual-path builders drifted before. const selectableItems = useMemo( @@ -2215,9 +2305,11 @@ function WorktreeJumpPaletteContent({ [listEntries] ) + // Why the render keys, not entry ids: cmdk selects by the rendered `value`, so the + // allow-list has to name the same de-duplicated keys the rows carry. const selectionItemIds = useMemo( - () => getWorktreePaletteSelectionItemIds(listEntries), - [listEntries] + () => getWorktreePaletteSelectionItemIds(listEntries, listEntryRenderKeys), + [listEntries, listEntryRenderKeys] ) // Why passive, and why after the snapshot effect: it must record the head cmdk *had* while the @@ -3073,11 +3165,12 @@ function WorktreeJumpPaletteContent({ ) : ( <> - {listEntries.map((entry) => { + {listEntries.map((entry, entryIndex) => { + const renderKey = listEntryRenderKeys[entryIndex] ?? entry.id if (entry.type === 'section-header') { return (
{entry.label} @@ -3089,7 +3182,7 @@ function WorktreeJumpPaletteContent({ // Why: plain div (not CommandItem) so cmdk can't select it; arrow keys skip it via selectableItems. return (
{entry.label} @@ -3103,7 +3196,7 @@ function WorktreeJumpPaletteContent({ linearIssueUrlIntent !== null && currentLinearIssuePreview?.loading !== false return ( handleSelectItem(entry)} data-current={isCurrentWorktree ? 'true' : undefined} className={cn(JUMP_PALETTE_ITEM_CLASSNAME, 'py-2.5')} @@ -3262,8 +3359,8 @@ function WorktreeJumpPaletteContent({ : translate('auto.components.WorktreeJumpPalette.repoGroupBadge', 'Repo group') return ( handleSelectItem(entry)} className={cn(JUMP_PALETTE_ITEM_CLASSNAME, 'py-2.5')} > @@ -3306,8 +3403,8 @@ function WorktreeJumpPaletteContent({ : translate('auto.components.WorktreeJumpPalette.actionBadge', 'Action') return ( handleSelectItem(entry)} className={cn(JUMP_PALETTE_ITEM_CLASSNAME, 'py-2.5')} > @@ -3333,9 +3430,12 @@ function WorktreeJumpPaletteContent({ if (entry.type === 'workspace-tab') { const result = entry.result - const workspaceTabWorktree = worktreeMap.get(result.worktreeId) + const workspaceTabWorktree = resolveWorktree( + result.worktreeId, + result.executionHostId + ) const workspaceTabRepo = workspaceTabWorktree - ? repoMap.get(workspaceTabWorktree.repoId) + ? resolveRepoForWorktree(workspaceTabWorktree) : undefined const workspaceTabRepoName = workspaceTabRepo?.displayName ?? result.repoName const workspaceTabHostBadge = getPaletteHostBadge( @@ -3363,8 +3463,8 @@ function WorktreeJumpPaletteContent({ return ( handleSelectItem(entry)} className={cn(JUMP_PALETTE_ITEM_CLASSNAME, 'py-2.5')} > @@ -3433,9 +3533,9 @@ function WorktreeJumpPaletteContent({ if (entry.type === 'simulator-tab') { const result = entry.result - const simulatorWorktree = worktreeMap.get(result.worktreeId) + const simulatorWorktree = resolveWorktree(result.worktreeId, result.executionHostId) const simulatorRepo = simulatorWorktree - ? repoMap.get(simulatorWorktree.repoId) + ? resolveRepoForWorktree(simulatorWorktree) : undefined const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName const simulatorHostBadge = getPaletteHostBadge( @@ -3446,8 +3546,8 @@ function WorktreeJumpPaletteContent({ return ( handleSelectItem(entry)} className={cn(JUMP_PALETTE_ITEM_CLASSNAME, 'py-2.5')} > @@ -3515,8 +3615,10 @@ function WorktreeJumpPaletteContent({ } const result = entry.result - const browserWorktree = worktreeMap.get(result.worktreeId) - const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined + const browserWorktree = resolveWorktree(result.worktreeId, result.executionHostId) + const browserRepo = browserWorktree + ? resolveRepoForWorktree(browserWorktree) + : undefined const browserRepoName = browserRepo?.displayName ?? result.repoName const browserHostBadge = getPaletteHostBadge( browserRepo, @@ -3526,8 +3628,8 @@ function WorktreeJumpPaletteContent({ return ( handleSelectItem(entry)} className={cn(JUMP_PALETTE_ITEM_CLASSNAME, 'py-2.5')} > diff --git a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx index 9b098cd40eb..cfea2c37f67 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx @@ -96,19 +96,32 @@ export function PaletteFilterFieldOptions({ // listener has to re-attach to the node that replaces it. const [scrollEl, setScrollEl] = useState(null) const inputRef = useRef(null) - // Why the field/query are stored alongside the index instead of reset in an + // Why the field/query are stored alongside the cursor instead of reset in an // effect: a stale row would stay active for one paint. Why not key off the // ranked list identity: a toggle re-ranks, so that would yank the cursor back // to the top mid multi-select. - const [highlight, setHighlight] = useState(() => ({ + const [highlight, setHighlight] = useState<{ + field: PaletteFilterField + query: string + id: string | null + }>(() => ({ field: group.field, query: normalizedQuery, - index: 0 + id: null })) - const storedIndex = - highlight.field === group.field && highlight.query === normalizedQuery ? highlight.index : 0 - // The stored index can outlive the rows it pointed at when the list shrinks. - const activeIndex = Math.min(storedIndex, Math.max(0, ranked.ordered.length - 1)) + const storedId = + highlight.field === group.field && highlight.query === normalizedQuery ? highlight.id : null + // Why track the option id rather than its position: toggling re-ranks the list and pins + // the toggled option to the front, so a stored index would land on a different option and + // the next Enter would check one the user never aimed at. findIndex also covers the option + // disappearing from a narrowed list. + const activeIndex = + storedId === null + ? 0 + : Math.max( + 0, + ranked.ordered.findIndex((option) => option.id === storedId) + ) useEffect(() => { inputRef.current?.focus() @@ -146,9 +159,13 @@ export function PaletteFilterFieldOptions({ } const next = Math.max(0, Math.min(ranked.ordered.length - 1, activeIndex + delta)) virtualizer.scrollToIndex(next, { align: 'auto' }) - setHighlight({ field: group.field, query: normalizedQuery, index: next }) + setHighlight({ + field: group.field, + query: normalizedQuery, + id: ranked.ordered[next]?.id ?? null + }) }, - [activeIndex, group.field, normalizedQuery, ranked.ordered.length, virtualizer] + [activeIndex, group.field, normalizedQuery, ranked.ordered, virtualizer] ) const handleListKeyDown = useCallback( diff --git a/src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx b/src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx new file mode 100644 index 00000000000..384accda173 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { buildPaletteListEntryRenderKeys } from './palette-list-entry-render-keys' + +/** + * Regression for the Cmd+J ghost rows: a windows-low-spec session persisted two + * tab records under one id, so two rows shared a React key. When the query + * narrowed and the rows stopped matching, React had one fiber for the two keys + * and never unmounted the extra — it stayed above the section headers, frozen at + * the highlight ranges of the keystroke that last matched it. + */ +type Row = { id: string; label: string } + +function PaletteList({ rows, uniqueKeys }: { rows: Row[]; uniqueKeys: boolean }) { + const renderKeys = uniqueKeys + ? buildPaletteListEntryRenderKeys(rows.map((row) => row.id)) + : rows.map((row) => row.id) + return ( +
+ {rows.map((row, index) => ( + {row.label} + ))} +
+ ) +} + +// The duplicated pair, as persisted for the lungfish worktree. +const DUPLICATE_TAB_ID = 'workspace-tab:editor:lungfish:FINAL-REPORT.md' + +function rowsForQuery(query: string): Row[] { + // "l" matches the worktree name; "li" and beyond match nothing in that workspace. + const remoteRows = + query === 'l' + ? [ + { id: DUPLICATE_TAB_ID, label: `[lungfish ${query}-hit]` }, + { id: DUPLICATE_TAB_ID, label: `[lungfish ${query}-hit]` } + ] + : [] + return [...remoteRows, { id: '__header_open_tabs__', label: '(OPEN TABS)' }] +} + +async function typeQuery(uniqueKeys: boolean): Promise { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + for (const query of ['l', 'li', 'lin', 'line', 'linea', 'linear']) { + await act(async () => { + root.render() + }) + } + const rendered = host.textContent ?? '' + root.unmount() + host.remove() + return rendered +} + +describe('duplicate persisted tab ids in the Cmd+J list', () => { + it('strands a ghost row when the render key repeats', async () => { + // Why assert the bug: it is the reason the disambiguated key exists. + expect(await typeQuery(false)).toBe('[lungfish l-hit](OPEN TABS)') + }) + + it('leaves nothing behind once render keys are disambiguated', async () => { + expect(await typeQuery(true)).toBe('(OPEN TABS)') + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts b/src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts index bb5e2a1e8a7..a08d4f88fd0 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts @@ -29,6 +29,29 @@ describe('rankPaletteFilterOptions', () => { option('d', 'Delta Host', 2, 'ssh') ] + it('moves a toggled option to the front, so a cursor must track ids not positions', () => { + // Why pinned here: PaletteFilterFieldOptions derives its highlight from the option id + // precisely because this re-rank invalidates any stored index the moment a user toggles. + const before = rankPaletteFilterOptions({ + options, + query: '', + selectedIds: new Set(), + rankMode: 'registry' + }) + expect(before.ordered.map((entry) => entry.id)).toEqual(['a', 'b', 'c', 'd']) + + // Toggling the option the user had arrowed onto pins it to the front; an index of 1 + // would now point at a different option than it did a render earlier. + const after = rankPaletteFilterOptions({ + options, + query: '', + selectedIds: new Set(['c']), + rankMode: 'registry' + }) + expect(after.ordered[0]?.id).toBe('c') + expect(before.ordered[1]?.id).not.toBe(after.ordered[1]?.id) + }) + it('pins selected matches first and ranks the rest by popularity', () => { const ranked = rankPaletteFilterOptions({ options, diff --git a/src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts b/src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts new file mode 100644 index 00000000000..41d3908ead2 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { buildPaletteListEntryRenderKeys } from './palette-list-entry-render-keys' + +describe('buildPaletteListEntryRenderKeys', () => { + it('leaves unique ids untouched', () => { + const ids = ['__header_open_tabs__', 'workspace-tab:a', 'worktree:b'] + expect(buildPaletteListEntryRenderKeys(ids)).toEqual(ids) + }) + + it('disambiguates a repeated persisted id', () => { + expect( + buildPaletteListEntryRenderKeys([ + 'workspace-tab:editor:lungfish', + 'workspace-tab:editor:lungfish', + 'workspace-tab:editor:lungfish' + ]) + ).toEqual([ + 'workspace-tab:editor:lungfish', + 'palette-dup:1:workspace-tab:editor:lungfish', + 'palette-dup:2:workspace-tab:editor:lungfish' + ]) + }) + + it('never emits a key twice', () => { + const keys = buildPaletteListEntryRenderKeys(['a', 'a', 'b', 'a', 'b', 'palette-dup:1:a']) + expect(new Set(keys).size).toBe(keys.length) + }) + + it('keeps keys stable as later entries drop away', () => { + const full = buildPaletteListEntryRenderKeys(['header', 'tab:x', 'tab:x', 'tab:y']) + const narrowed = buildPaletteListEntryRenderKeys(['header', 'tab:x', 'tab:x']) + expect(narrowed).toEqual(full.slice(0, 3)) + }) + + // Why: a generated key that lands in the persisted-id namespace is worse than the + // duplicate it fixes — narrowing the duplicate away hands its fiber, and the row + // state frozen in it, to the distinct entry that happens to own that id. + it('never mints a key a sibling entry already owns as its persisted id', () => { + const full = buildPaletteListEntryRenderKeys(['a', 'a', 'palette-dup:1:a']) + const narrowed = buildPaletteListEntryRenderKeys(['a', 'palette-dup:1:a']) + expect(new Set(full).size).toBe(full.length) + // The distinct entry keeps its own key across the narrow; the duplicate's key is not reused. + expect(full[2]).not.toBe(full[1]) + expect(narrowed[1]).toBe(full[2]) + expect(narrowed).not.toContain(full[1]) + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-list-entry-render-keys.ts b/src/renderer/src/components/cmd-j/palette-list-entry-render-keys.ts new file mode 100644 index 00000000000..5664792ab23 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-list-entry-render-keys.ts @@ -0,0 +1,24 @@ +// Disambiguates repeated entry IDs so duplicate React keys do not strand frozen ghost rows. + +// Why: a reserved prefix with escaping avoids collisions with real IDs; printable for cmdk values. +const DUPLICATE_KEY_NAMESPACE = 'palette-dup:' + +// Why prepend rather than strip: prepending is injective, and the result can never +// look generated because a generated key always has `\d+:` after the prefix. +function escapeReservedNamespace(entryId: string): string { + return entryId.startsWith(DUPLICATE_KEY_NAMESPACE) + ? `${DUPLICATE_KEY_NAMESPACE}${entryId}` + : entryId +} + +export function buildPaletteListEntryRenderKeys(entryIds: readonly string[]): string[] { + const occurrences = new Map() + return entryIds.map((entryId) => { + const occurrence = occurrences.get(entryId) ?? 0 + occurrences.set(entryId, occurrence + 1) + const escaped = escapeReservedNamespace(entryId) + // Why keep the first key bare: stable ids must survive re-renders untouched. + // Later occurrences encode their index, so (id, occurrence) maps one-to-one to a key. + return occurrence === 0 ? escaped : `${DUPLICATE_KEY_NAMESPACE}${occurrence}:${escaped}` + }) +} diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index 52ec2f90454..c403607e48f 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -2,6 +2,7 @@ import { isCmdJPaletteQueryTooLarge } from './palette-results' import { cmdJPaletteTokenScore, isCmdJPaletteQueryOverTokenLimit, + uniqueCmdJPaletteQueryTokens, normalizeCmdJPaletteQuery, uniqueNormalizedCmdJPaletteKeywords } from './palette-query-tokens' @@ -150,6 +151,7 @@ export function hasCmdJProjectSearchCandidates({ function projectRankingForCandidate( query: string, + queryTokens: readonly string[], candidate: CmdJProjectSearchResult ): RankedProjectResult | null { const title = normalizeCmdJPaletteQuery(candidate.title) @@ -166,7 +168,7 @@ function projectRankingForCandidate( if (candidate.keywords.some((keyword) => keyword.startsWith(query))) { return { result: candidate, rule: 4, score: 0 } } - const score = cmdJPaletteTokenScore(query, [candidate.title, ...candidate.keywords]) + const score = cmdJPaletteTokenScore(queryTokens, [candidate.title, ...candidate.keywords]) return score > 0 ? { result: candidate, rule: 5, score } : null } @@ -209,6 +211,7 @@ export function searchCmdJProjectResults({ if (normalizedQuery.length < 2 || isCmdJPaletteQueryOverTokenLimit(normalizedQuery)) { return [] } + const queryTokens = uniqueCmdJPaletteQueryTokens(normalizedQuery) return buildCmdJProjectSearchCandidates({ projectGroups, repos, @@ -216,7 +219,7 @@ export function searchCmdJProjectResults({ projectHostSetups, renderableRepoIds }) - .map((candidate) => projectRankingForCandidate(normalizedQuery, candidate)) + .map((candidate) => projectRankingForCandidate(normalizedQuery, queryTokens, candidate)) .filter((entry): entry is RankedProjectResult => entry !== null) .sort(compareProjectRanked) .map((entry) => ({ ...entry.result, qualityClass: projectRuleQualityClass(entry.rule) })) diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts index 11574a057d8..b029b315426 100644 --- a/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + cmdJPaletteTokenScore, isCmdJPaletteQueryOverTokenLimit, + uniqueCmdJPaletteQueryTokens, normalizeCmdJPaletteQuery, tokenizeCmdJPaletteQuery } from './palette-query-tokens' @@ -31,6 +33,43 @@ describe('Cmd+J palette query normalization', () => { }) }) +describe('Cmd+J palette token scoring', () => { + it('matches a CJK compound query against its keyword parts', () => { + // Why: CJK is written without spaces, so the natural query for the terminal settings + // pane is one token that CONTAINS the keyword rather than being contained by it. + expect(cmdJPaletteTokenScore(['终端设置'], ['终端', '设置'])).toBeGreaterThan(0) + expect(cmdJPaletteTokenScore(['终端'], ['终端', '设置'])).toBeGreaterThan(0) + }) + + it('does not let a one-character keyword match every long token', () => { + expect(cmdJPaletteTokenScore(['terminal'], ['a'])).toBe(0) + }) + + it('does not score a Latin keyword merely contained in the query token', () => { + // Why: `database` does not mean the keyword `base`. Reverse containment exists for + // scripts that carry no spaces, so it must not fire on space-delimited text. + expect(cmdJPaletteTokenScore(['database'], ['base'])).toBe(0) + expect(cmdJPaletteTokenScore(['worktree'], ['tree'])).toBe(0) + }) + + it('scores a repeated query token once', () => { + const once = cmdJPaletteTokenScore(uniqueCmdJPaletteQueryTokens('terminal'), ['terminal']) + const twice = cmdJPaletteTokenScore(uniqueCmdJPaletteQueryTokens('terminal terminal'), [ + 'terminal' + ]) + expect(twice).toBe(once) + }) + + it('rejects a query whose punctuation split blows past the ceiling', () => { + // Whitespace-split counting saw one token; the scoring tokenizer saw hundreds. + const punctuationHeavy = Array.from( + { length: PALETTE_QUERY_MAX_TOKENS + 1 }, + (_, i) => `t${i}` + ).join('.') + expect(isCmdJPaletteQueryOverTokenLimit(punctuationHeavy)).toBe(true) + }) +}) + describe('Cmd+J palette query token ceiling', () => { const tokens = Array.from({ length: PALETTE_QUERY_MAX_TOKENS }, (_, index) => `token${index}`) diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.ts index ba133746570..96dcd769b28 100644 --- a/src/renderer/src/components/cmd-j/palette-query-tokens.ts +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.ts @@ -45,7 +45,18 @@ export function isCmdJPaletteQueryOverTokenLimit(normalizedQuery: string): boole return true } } - return false + // Why also bound the scoring split: whitespace-split counting lets 2KB of punctuation + // through as a single token, and the scorer's tokenizer then expands it into hundreds — + // paid once per candidate, per keystroke, on the render thread. + return uniqueCmdJPaletteQueryTokens(normalizedQuery).length > PALETTE_QUERY_MAX_TOKENS +} + +/** + * Why dedupe: a repeated query token would otherwise contribute its score twice, so + * `terminal terminal ssh` outranks an otherwise-tied candidate on the strength of the repeat. + */ +export function uniqueCmdJPaletteQueryTokens(query: string): string[] { + return [...new Set(tokenizeCmdJPaletteQuery(query))] } export function uniqueNormalizedCmdJPaletteKeywords(values: readonly string[]): string[] { @@ -60,6 +71,16 @@ export function tokenizeCmdJPaletteQuery(value: string): string[] { .filter(Boolean) } +// Why gate the reverse-containment tier on script: a space-delimited language already +// tokenizes its own compounds, so a contained Latin keyword is a coincidence (`database` +// vs `base`), while Han/Kana/Hangul text arrives as one run that must be matched inside. +const UNSEGMENTED_SCRIPT_TOKEN_RE = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u + +function isUnsegmentedScriptToken(token: string): boolean { + return UNSEGMENTED_SCRIPT_TOKEN_RE.test(token) +} + // Why: navigation filler carries no intent ("open ssh settings" means "ssh settings"), so an // unmatched filler word must not count against a candidate's coverage. const CMD_J_QUERY_FILLER_TOKENS = new Set([ @@ -89,7 +110,12 @@ const CMD_J_QUERY_FILLER_TOKENS = new Set([ 'with' ]) -export function cmdJPaletteTokenScore(query: string, values: readonly string[]): number { +// Why the caller passes tokens: this runs once per candidate, and re-folding plus +// re-splitting the same query for each one dominated the ranking pass. +export function cmdJPaletteTokenScore( + queryTokens: readonly string[], + values: readonly string[] +): number { const candidateTokens = values.flatMap(tokenizeCmdJPaletteQuery) if (candidateTokens.length === 0) { return 0 @@ -98,7 +124,7 @@ export function cmdJPaletteTokenScore(query: string, values: readonly string[]): let score = 0 let meaningful = 0 let covered = 0 - for (const queryToken of tokenizeCmdJPaletteQuery(query)) { + for (const queryToken of queryTokens) { let best = 0 for (const candidateToken of candidateTokens) { if (candidateToken === queryToken) { @@ -107,6 +133,16 @@ export function cmdJPaletteTokenScore(query: string, values: readonly string[]): best = Math.max(best, 2) } else if (candidateToken.includes(queryToken)) { best = Math.max(best, 1) + } else if ( + candidateToken.length >= 2 && + queryToken.includes(candidateToken) && + isUnsegmentedScriptToken(candidateToken) + ) { + // Why the reverse direction: CJK is written without spaces, so the natural query for + // the terminal settings pane is the single token `终端设置` — one token that CONTAINS + // the keyword rather than being contained by it. Weakest tier, gated on the script + // that needs it: in Latin text the query `database` does not mean the keyword `base`. + best = Math.max(best, 1) } } score += best diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 63cd6a8933d..4c87444e39c 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -172,6 +172,23 @@ function top(query: string): string | undefined { return rankMiddle(query)[0]?.id } +describe('action keyword folding', () => { + it('folds verbKeywords so a raw-cased command still reaches exact-intent', () => { + // Why: the query is folded before ranking, so a plugin command titled `Format Document` + // could never satisfy rules 1/3/4 and sank below any prefix-matching workspace. + const [folded] = buildCmdJActionResults([ + { + id: 'quick-action:format', + kind: 'action', + title: 'Format Document', + description: 'Format the open file', + verbKeywords: ['Format Document', 'FORMAT DOC'] + } as (typeof actions)[number] + ]) + expect(folded.verbKeywords).toEqual(['format document', 'format doc']) + }) +}) + const overTokenLimitQuery = Array.from( { length: PALETTE_QUERY_MAX_TOKENS + 1 }, (_, index) => `token${index}` diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts index b6576b4d78c..16f7041158f 100644 --- a/src/renderer/src/components/cmd-j/palette-results.ts +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import type { SettingsNavIcon, SettingsNavSection } from '@/lib/settings-navigation-types' import type { CmdJQuickAction } from './quick-actions' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' @@ -5,6 +6,7 @@ import { cmdJPaletteTokenScore, isCmdJPaletteQueryOverTokenLimit, normalizeCmdJPaletteQuery, + uniqueCmdJPaletteQueryTokens, uniqueNormalizedCmdJPaletteKeywords } from './palette-query-tokens' import { @@ -92,24 +94,41 @@ export function isCmdJPaletteQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } +// Why not a module constant: translate() must run at call time, and the localized word is +// the same one the palette stamps on these rows — a zh user searching `设置` types the badge. +function settingsWord(): string { + return translate('auto.components.WorktreeJumpPalette.settingsBadge', 'Settings').toLowerCase() +} + function keywordParts(section: SettingsNavSection): string[] { const baseId = section.id.startsWith('repo-') ? 'repo' : section.id const idWords = baseId.replace(/-/g, ' ') const paneLevelEntries = section.searchEntries.filter((entry) => !entry.targetSectionId) + const localized = settingsWord() + // Why keep the English forms alongside the localized ones: section titles stay English in + // some catalogs, and a user on a localized build still types `terminal settings` freely. + const suffixed = [`${section.title} settings`, `${idWords} settings`] + if (localized !== 'settings') { + suffixed.push(`${section.title} ${localized}`, `${idWords} ${localized}`, localized) + } return [ section.id, baseId, idWords, section.title, - `${section.title} settings`, - `${idWords} settings`, + ...suffixed, ...(SETTINGS_ALIASES[baseId] ?? []), ...paneLevelEntries.map((entry) => entry.title) ] } function targetEntryKeywordParts(entryTitle: string): string[] { - return [entryTitle, `${entryTitle} settings`] + const localized = settingsWord() + const parts = [entryTitle, `${entryTitle} settings`] + if (localized !== 'settings') { + parts.push(`${entryTitle} ${localized}`) + } + return parts } export function buildCmdJSettingsResults( @@ -148,7 +167,14 @@ export function buildCmdJSettingsResults( } export function buildCmdJActionResults(actions: readonly CmdJQuickAction[]): CmdJActionResult[] { - return actions.map((action, order) => ({ ...action, order })) + // Why fold here, like the settings path above: the query is folded before ranking, so + // a raw `Format Document` keyword can never satisfy the exact-intent rules and the + // command sinks below any workspace that merely prefix-matches. + return actions.map((action, order) => ({ + ...action, + order, + verbKeywords: uniqueNormalizedCmdJPaletteKeywords(action.verbKeywords) + })) } function startsOrIsStartedBy(query: string, keyword: string): boolean { @@ -157,6 +183,7 @@ function startsOrIsStartedBy(query: string, keyword: string): boolean { function rankingForCandidate( query: string, + queryTokens: readonly string[], candidate: CmdJMiddleResult, actionVerbKeywords: readonly string[], settingsConfigKeywords: readonly string[] @@ -171,7 +198,7 @@ function rankingForCandidate( candidate.kind === 'settings' ? [candidate.title, ...candidate.configKeywords] : [candidate.title, ...candidate.verbKeywords] - const score = cmdJPaletteTokenScore(query, values) + const score = cmdJPaletteTokenScore(queryTokens, values) if (score === 0) { return null } @@ -245,6 +272,7 @@ export function rankCmdJMiddleResults({ if (normalizedQuery.length < 2 || isCmdJPaletteQueryOverTokenLimit(normalizedQuery)) { return [] } + const queryTokens = uniqueCmdJPaletteQueryTokens(normalizedQuery) const settings = settingsResults const actions = actionResults const actionVerbKeywords = actions.flatMap((action) => action.verbKeywords) @@ -252,7 +280,13 @@ export function rankCmdJMiddleResults({ return [...settings, ...actions] .map((candidate) => - rankingForCandidate(normalizedQuery, candidate, actionVerbKeywords, settingsConfigKeywords) + rankingForCandidate( + normalizedQuery, + queryTokens, + candidate, + actionVerbKeywords, + settingsConfigKeywords + ) ) .filter((entry): entry is RankedResult => entry !== null) .sort(compareRanked) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts index bc99c85cb3a..4c9e0fba178 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts @@ -116,13 +116,9 @@ describe('matchWorkspaceBoardWorktrees', () => { expect(match(worktrees, 'x'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES + 1))).toBeNull() }) - // KNOWN GAP (STA-4343): the board cannot yet tell two same-id host rows apart, and the - // cause is upstream of this module — searchWorktrees looks up evidence in a - // `documents` map keyed by BARE worktree id (worktree-palette-search.ts), so the second - // row's document overwrites the first and only one row can ever match. The matched set - // below is host-qualified and ready for the fix; closing the gap means host-qualifying - // that document map, which belongs with the palette work, not this PR. - it('cannot yet separate two same-id host rows (documents map is id-keyed upstream)', () => { + // STA-4343 closed: the documents map is keyed by host identity, so two workspaces sharing + // `repoId::path` across hosts each keep their own searchable document. + it('separates two same-id host rows', () => { const local = worktree({ id: 'shared', branch: 'refs/heads/local-only' }) const remote = worktree({ id: local.id, @@ -130,10 +126,9 @@ describe('matchWorkspaceBoardWorktrees', () => { branch: 'refs/heads/remote-only' }) - // Documents collapse on the shared id, so the local row's branch is not searchable. - expect(match([local, remote], 'local-only')).toEqual(new Set()) - // What DOES hold today: the matched set is keyed by host identity, not bare id, so a - // match never leaks across hosts once the upstream map is fixed. + // Each row matches on its OWN branch, and neither match leaks to the other host. + expect(match([local, remote], 'local-only')).toEqual(identities(local)) + expect(match([local, remote], 'remote-only')).toEqual(identities(remote)) expect(match([local], 'local-only')).toEqual(identities(local)) }) }) diff --git a/src/renderer/src/lib/browser-palette-page-entries.ts b/src/renderer/src/lib/browser-palette-page-entries.ts index 9d781821f7a..d252769e02c 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.ts @@ -1,5 +1,8 @@ +import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' import type { Worktree } from '../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' import { buildSearchableBrowserPageDocument, type SearchableBrowserPage @@ -10,28 +13,36 @@ type BrowserPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulato export type BuildSearchableBrowserPagesOptions = { worktrees: readonly Worktree[] repoMap: ReadonlyMap + repoMapByHostIdentity?: ReadonlyMap worktreeOrder: ReadonlyMap browserTabsByWorktree: Record browserPagesByWorkspace: Record activeBrowserTabId: string | null activeWorktreeId: string | null + activeWorkspaceExecutionHostId?: ExecutionHostId | null activeTabType: BrowserPaletteActiveTabType } export function buildSearchableBrowserPages({ worktrees, repoMap, + repoMapByHostIdentity, worktreeOrder, browserTabsByWorktree, browserPagesByWorkspace, activeBrowserTabId, activeWorktreeId, + activeWorkspaceExecutionHostId, activeTabType }: BuildSearchableBrowserPagesOptions): SearchableBrowserPage[] { const entries: SearchableBrowserPage[] = [] for (const worktree of worktrees) { - const repoName = repoMap.get(worktree.repoId)?.displayName ?? '' - const worktreeSortIndex = worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER + const repoName = + resolvePaletteRepoForWorktree(worktree, repoMap, repoMapByHostIdentity)?.displayName ?? '' + const worktreeSortIndex = + worktreeOrder.get(getWorktreeHostIdentity(worktree)) ?? + worktreeOrder.get(worktree.id) ?? + Number.MAX_SAFE_INTEGER for (const workspace of browserTabsByWorktree[worktree.id] ?? []) { for (const page of browserPagesByWorkspace[workspace.id] ?? []) { entries.push({ @@ -41,10 +52,15 @@ export function buildSearchableBrowserPages({ repoName, worktreeSortIndex, isCurrentPage: + isPaletteCurrentWorktree(worktree, activeWorktreeId, activeWorkspaceExecutionHostId) && activeTabType === 'browser' && workspace.id === activeBrowserTabId && workspace.activePageId === page.id, - isCurrentWorktree: activeWorktreeId === worktree.id, + isCurrentWorktree: isPaletteCurrentWorktree( + worktree, + activeWorktreeId, + activeWorkspaceExecutionHostId + ), document: buildSearchableBrowserPageDocument({ page, workspace, worktree, repoName }) }) } diff --git a/src/renderer/src/lib/browser-palette-search.test.ts b/src/renderer/src/lib/browser-palette-search.test.ts index 618729e9e15..224f6dbcd4b 100644 --- a/src/renderer/src/lib/browser-palette-search.test.ts +++ b/src/renderer/src/lib/browser-palette-search.test.ts @@ -82,6 +82,26 @@ describe('browser-palette-search', () => { ) }) + it('stamps the row execution host so activation never resolves by id alone', () => { + // Why: worktree ids repeat across hosts, so a host-blind activation opened the other + // host's workspace behind a row labelled with this one's name and branch. + const [result] = searchBrowserPages( + [ + makeEntry({ + page: makePage({ id: 'page-1', title: 'Docs' }), + workspace: makeWorkspace({ id: 'ws-1' }), + worktree: makeWorktree({ id: 'shared', hostId: 'ssh:box' }), + repoName: 'repo/one', + worktreeSortIndex: 0, + isCurrentPage: false, + isCurrentWorktree: false + }) + ], + '' + ) + expect(result.executionHostId).toBe('ssh:box') + }) + it('keeps empty-query ordering deterministic and context-first', () => { const results = searchBrowserPages( [ diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index e0ff9df2d02..ec1adeda66e 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -13,6 +13,7 @@ import { resolveWorktreeBranchLabel, resolveWorktreeDisplayName } from './worktree-default-display-name' +import type { ExecutionHostId } from '../../../shared/execution-host' import type { MatchRange } from './palette-match/normalized-text' import type { PaletteDocument, PaletteDocumentRank } from './palette-match/palette-document' import type { PaletteResultQualityClass } from './palette-match/match-quality' @@ -32,6 +33,8 @@ export type SearchableBrowserPage = { } export type BrowserPaletteSearchResult = { + /** Worktree ids collide across hosts; activation must not resolve by id alone. */ + executionHostId?: ExecutionHostId pageId: string workspaceId: string worktreeId: string @@ -138,6 +141,7 @@ function positionScore(entry: SearchableBrowserPage): number { function baseResult(entry: SearchableBrowserPage): BrowserPaletteSearchResult { const formattedUrl = formatBrowserPaletteUrl(entry.page.url) return { + ...(entry.worktree.hostId ? { executionHostId: entry.worktree.hostId } : {}), pageId: entry.page.id, workspaceId: entry.workspace.id, worktreeId: entry.worktree.id, diff --git a/src/renderer/src/lib/order-empty-query-worktrees.test.ts b/src/renderer/src/lib/order-empty-query-worktrees.test.ts index f60ddb04a89..8f55bc69652 100644 --- a/src/renderer/src/lib/order-empty-query-worktrees.test.ts +++ b/src/renderer/src/lib/order-empty-query-worktrees.test.ts @@ -100,6 +100,20 @@ describe('orderEmptyQueryWorktrees', () => { expect(result.visibleWorktreesForState.map((w) => w.id)).toEqual(['cur', 'other']) }) + // Why: `repoId::path` repeats across hosts, so a bare-id filter treated the SSH twin as + // current too and dropped it from the rows — the host it lived on became unreachable. + it('keeps the same-id worktree on the other host switchable', () => { + const local = wt({ id: 'shared', displayName: 'local', hostId: 'local' }) + const ssh = wt({ id: 'shared', displayName: 'ssh', hostId: 'ssh:box' }) + const result = orderEmptyQueryWorktrees({ + visibleWorktrees: [local, ssh], + activeWorktreeId: 'shared', + activeWorkspaceExecutionHostId: 'local', + lastVisitedAtByWorktreeId: {} + }) + expect(result.switchableWorktreesForRows.map((w) => w.displayName)).toEqual(['ssh']) + }) + it('returns empty rows but non-empty state when only the current worktree is visible', () => { const cur = wt({ id: 'cur', displayName: 'cur' }) const result = orderEmptyQueryWorktrees({ diff --git a/src/renderer/src/lib/order-empty-query-worktrees.ts b/src/renderer/src/lib/order-empty-query-worktrees.ts index 8362bc03527..08f31534145 100644 --- a/src/renderer/src/lib/order-empty-query-worktrees.ts +++ b/src/renderer/src/lib/order-empty-query-worktrees.ts @@ -1,9 +1,13 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' import type { Worktree } from '../../../shared/worktree/types' +import { isPaletteCurrentWorktree } from './palette-repo-resolution' import { compareWorktreeDisplayName } from './worktree-display-name-order' export type OrderEmptyQueryInputs = { visibleWorktrees: readonly Worktree[] activeWorktreeId: string | null + /** Host of the active workspace. Omit to fall back to bare-id matching (STA-4343). */ + activeWorkspaceExecutionHostId?: ExecutionHostId | null lastVisitedAtByWorktreeId: Record } @@ -29,8 +33,17 @@ export type OrderEmptyQueryResult = { * visibleWorktreesForState so empty-state logic isn't affected. */ export function orderEmptyQueryWorktrees(inputs: OrderEmptyQueryInputs): OrderEmptyQueryResult { - const { visibleWorktrees, activeWorktreeId, lastVisitedAtByWorktreeId } = inputs - const switchable = visibleWorktrees.filter((w) => w.id !== activeWorktreeId) + const { + visibleWorktrees, + activeWorktreeId, + activeWorkspaceExecutionHostId, + lastVisitedAtByWorktreeId + } = inputs + // Why the host too (STA-4343): `repoId::path` repeats across hosts, so filtering on the + // bare id drops BOTH same-id rows as "current" and the other host becomes unreachable. + const switchable = visibleWorktrees.filter( + (w) => !isPaletteCurrentWorktree(w, activeWorktreeId, activeWorkspaceExecutionHostId) + ) // Why: a visited worktree must always outrank a never-visited one, // even when the never-visited worktree has a newer lastActivityAt. // Mixing the two signals into a single numeric score would let diff --git a/src/renderer/src/lib/palette-match/match-document.ts b/src/renderer/src/lib/palette-match/match-document.ts index 9ba8d69dc73..b5f7647ef28 100644 --- a/src/renderer/src/lib/palette-match/match-document.ts +++ b/src/renderer/src/lib/palette-match/match-document.ts @@ -202,7 +202,13 @@ function buildSupportingEvidence( continue } for (const range of assignment.ranges) { - ranges.push({ start: range.start + offset, end: range.end + offset }) + // Why clamp: a range is only meaningful against the unit text the row renders, and + // an out-of-range end would highlight past the end of that string. + const start = Math.min(range.start + offset, unit.text.length) + const end = Math.min(range.end + offset, unit.text.length) + if (start < end) { + ranges.push({ start, end }) + } } } if (!ranges.length) { diff --git a/src/renderer/src/lib/palette-match/palette-document.ts b/src/renderer/src/lib/palette-match/palette-document.ts index 3b4b16b8b29..0e92875d493 100644 --- a/src/renderer/src/lib/palette-match/palette-document.ts +++ b/src/renderer/src/lib/palette-match/palette-document.ts @@ -64,6 +64,12 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume if (!fields.length) { continue } + // Why first-wins, matching indexPaletteFields: it keeps the first entry's fields, so + // overwriting the unit here would pair one record's rendered text with another's + // match offsets — highlights landing outside the string the row actually shows. + if (evidenceUnits.has(entry.unit.id)) { + continue + } evidenceUnits.set(entry.unit.id, entry.unit) for (const field of fields) { evidenceSources.push(field) diff --git a/src/renderer/src/lib/palette-match/palette-match-core.test.ts b/src/renderer/src/lib/palette-match/palette-match-core.test.ts index f57f9dc1297..6fe7878513f 100644 --- a/src/renderer/src/lib/palette-match/palette-match-core.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-core.test.ts @@ -42,6 +42,25 @@ describe('palette query preparation', () => { expect(ready('scan scan daily').tokens.map((token) => token.text)).toEqual(['scan', 'daily']) }) + it('collapses whitespace runs so the whole-query tier still matches', () => { + // Why: field text is always single-spaced, so an uncollapsed run could never satisfy + // the whole-query equality tier and the exactly-named row silently lost its rank. + expect(ready('scan daily').normalized).toBe('scan daily') + expect(run(labelOnly('scan daily'), 'scan daily')?.rank.wholeQuery).toBe( + run(labelOnly('scan daily'), 'scan daily')?.rank.wholeQuery + ) + }) + + it('treats emoji and symbols as content, not punctuation', () => { + // Why: the palette input expands `:rocket:` into 🚀, so dropping symbol tokens made the + // palette unable to match a query it produced itself. + expect(ready('🚀 rocket').tokens[0]?.isPunctuationOnly).toBe(false) + expect(run(labelOnly('🚀 rocket ship'), '🚀 rocket ship')).not.toBeNull() + expect(run(labelOnly('a → b'), '→')).not.toBeNull() + // A genuinely punctuation-only token stays rejected. + expect(ready('--').tokens[0]?.isPunctuationOnly).toBe(true) + }) + it('parses repo/branch per token', () => { expect(ready('orca/main').tokens[0].repoBranch).toEqual({ repo: 'orca', branch: 'main' }) expect(ready('feature').tokens[0].repoBranch).toBeNull() @@ -234,6 +253,68 @@ describe('identifier fields', () => { }) }) +describe('duplicate evidence unit ids', () => { + // Two listeners on one port with different process names: the scanner keys ports on + // host:port:pid, so a parent and a forked child both survive. + const duplicateUnits: PaletteDocumentInput = { + id: 'doc', + visibleFields: [{ id: 'name', profile: 'structured-label', text: 'checkout' }], + evidence: [ + { + unit: { + id: 'port:3000', + kind: 'port', + text: '3000 · next-server', + accessibilityLabel: 'Port' + }, + fields: [ + { + id: 'port:3000#name', + profile: 'structured-label', + text: 'next-server', + evidenceId: 'port:3000', + renderOffset: 7 + } + ] + }, + { + unit: { id: 'port:3000', kind: 'port', text: '3000 · node', accessibilityLabel: 'Port' }, + fields: [ + { + id: 'port:3000#name', + profile: 'structured-label', + text: 'node', + evidenceId: 'port:3000', + renderOffset: 7 + } + ] + } + ] + } + + it('keeps the first unit so its text matches the indexed fields', () => { + // Why first-wins: indexPaletteFields keeps the first entry's fields, so overwriting the + // unit paired one record's rendered text with another's offsets. + const match = run(duplicateUnits, 'next-server') + const evidence = match?.supportingEvidence[0] + expect(evidence?.text).toBe('3000 · next-server') + expect(evidence?.text.slice(evidence.ranges[0].start, evidence.ranges[0].end)).toBe( + 'next-server' + ) + }) + + it('never emits a range past the end of the rendered unit text', () => { + for (const query of ['next-server', 'node', '3000']) { + for (const evidence of run(duplicateUnits, query)?.supportingEvidence ?? []) { + for (const range of evidence.ranges) { + expect(range.end).toBeLessThanOrEqual(evidence.text.length) + expect(range.start).toBeLessThan(range.end) + } + } + } + }) +}) + describe('evidence limits', () => { const twoUnits: PaletteDocumentInput = { id: 'doc', diff --git a/src/renderer/src/lib/palette-match/palette-query.ts b/src/renderer/src/lib/palette-match/palette-query.ts index bdd704f5912..f0149bce59b 100644 --- a/src/renderer/src/lib/palette-match/palette-query.ts +++ b/src/renderer/src/lib/palette-match/palette-query.ts @@ -4,7 +4,10 @@ import { normalizePaletteText } from './normalized-text' export const PALETTE_QUERY_MAX_TOKENS = 16 const DIGIT = /\p{N}/u -const ALPHANUMERIC = /[\p{L}\p{N}]/u +// Why symbols count as content: an emoji is \p{So} and an arrow is \p{Sm}, so a +// letters-and-digits test would classify `🚀` as punctuation and drop the token — +// and the palette input expands `:rocket:` into exactly that. +const ALPHANUMERIC = /[\p{L}\p{N}\p{S}\p{Extended_Pictographic}]/u const LETTERS_ONLY = /^\p{L}+$/u const LATIN_OR_DIGIT = /^[\p{Script=Latin}\p{Nd}]$/u const IDENTIFIER_PUNCTUATION = /[./#!_-]/ @@ -79,7 +82,11 @@ export function preparePaletteQuery(query: string): PreparedPaletteQuery { if (isWorktreePaletteQueryTooLarge(query)) { return { state: 'invalid', reason: 'too-large' } } - const normalized = normalizePaletteText(query).normalized.trim() + // Why collapse runs: field text is always single-spaced, so an uncollapsed double + // space can never satisfy the whole-query equality/prefix tier and the exact-name + // match silently loses its rank. Safe here — this string feeds only scoreWholeQuery + // and carries no offset mapping back into the source text. + const normalized = normalizePaletteText(query).normalized.replace(/ +/g, ' ').trim() if (!normalized) { return { state: 'empty' } } diff --git a/src/renderer/src/lib/palette-repo-resolution.test.ts b/src/renderer/src/lib/palette-repo-resolution.test.ts new file mode 100644 index 00000000000..8d60061cbfe --- /dev/null +++ b/src/renderer/src/lib/palette-repo-resolution.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' + +describe('palette repo and current-worktree resolution', () => { + const local = { id: 'repo-1', displayName: 'Local repo' } + const remote = { id: 'repo-1', displayName: 'Remote repo' } + const worktree = { id: 'repo-1::/checkout', repoId: 'repo-1', hostId: 'ssh:box' as const } + + it('uses the host-qualified repo for a colliding worktree', () => { + expect( + resolvePaletteRepoForWorktree( + worktree, + new Map([['repo-1', local]]), + new Map([['ssh:box\u0000repo-1', remote]]) + ) + ).toBe(remote) + }) + + it('does not mark a same-id worktree on another host current', () => { + expect(isPaletteCurrentWorktree(worktree, worktree.id, 'local')).toBe(false) + expect(isPaletteCurrentWorktree(worktree, worktree.id, 'ssh:box')).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/palette-repo-resolution.ts b/src/renderer/src/lib/palette-repo-resolution.ts new file mode 100644 index 00000000000..807b4f7f8d1 --- /dev/null +++ b/src/renderer/src/lib/palette-repo-resolution.ts @@ -0,0 +1,31 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { getRepoHostIdentityForParts } from '../../../shared/repo-host-identity' +import type { Worktree } from '../../../shared/worktree/types' + +/** Resolve the repo that owns a worktree, preserving host collisions. */ +export function resolvePaletteRepoForWorktree( + worktree: Pick, + repoMap: ReadonlyMap, + repoMapByHostIdentity?: ReadonlyMap +): T | undefined { + return ( + repoMapByHostIdentity?.get( + getRepoHostIdentityForParts(worktree.repoId, worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) + ) ?? repoMap.get(worktree.repoId) + ) +} + +export function isPaletteCurrentWorktree( + worktree: Pick, + activeWorktreeId: string | null, + activeWorkspaceExecutionHostId?: ExecutionHostId | null +): boolean { + if (activeWorkspaceExecutionHostId === undefined) { + return activeWorktreeId === worktree.id + } + return ( + activeWorktreeId === worktree.id && + (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === + (activeWorkspaceExecutionHostId ?? LOCAL_EXECUTION_HOST_ID) + ) +} diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index e53b379e59f..f5d1faecde1 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -1,6 +1,8 @@ +import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab, TabGroup } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' +import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { @@ -78,11 +80,13 @@ export function isSimulatorPaletteQueryTooLarge( export type BuildSearchableSimulatorTabsOptions = { worktrees: readonly Worktree[] repoMap: ReadonlyMap + repoMapByHostIdentity?: ReadonlyMap worktreeOrder: ReadonlyMap unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record groupsByWorktree: Record activeWorktreeId: string | null + activeWorkspaceExecutionHostId?: ExecutionHostId | null activeTabType: SimulatorPaletteActiveTabType } @@ -151,17 +155,31 @@ function baseResult(entry: SearchableSimulatorTab): SimulatorPaletteSearchResult function getActiveUnifiedTabId({ worktreeId, + worktreeHostId, activeWorktreeId, + activeWorkspaceExecutionHostId, activeTabType, activeGroupIdByWorktree, groupsByWorktree }: Pick< BuildSearchableSimulatorTabsOptions, - 'activeGroupIdByWorktree' | 'activeTabType' | 'activeWorktreeId' | 'groupsByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabType' + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' + | 'groupsByWorktree' > & { worktreeId: string + worktreeHostId?: Worktree['hostId'] }): string | null { - if (activeWorktreeId !== worktreeId || activeTabType !== 'simulator') { + if ( + !isPaletteCurrentWorktree( + { id: worktreeId, hostId: worktreeHostId }, + activeWorktreeId, + activeWorkspaceExecutionHostId + ) || + activeTabType !== 'simulator' + ) { return null } const activeGroupId = activeGroupIdByWorktree[worktreeId] @@ -174,20 +192,28 @@ function getActiveUnifiedTabId({ export function buildSearchableSimulatorTabs({ worktrees, repoMap, + repoMapByHostIdentity, worktreeOrder, unifiedTabsByWorktree, activeGroupIdByWorktree, groupsByWorktree, activeWorktreeId, + activeWorkspaceExecutionHostId, activeTabType }: BuildSearchableSimulatorTabsOptions): SearchableSimulatorTab[] { const entries: SearchableSimulatorTab[] = [] for (const worktree of worktrees) { - const repoName = repoMap.get(worktree.repoId)?.displayName ?? '' - const worktreeSortIndex = worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER + const repoName = + resolvePaletteRepoForWorktree(worktree, repoMap, repoMapByHostIdentity)?.displayName ?? '' + const worktreeSortIndex = + worktreeOrder.get(getWorktreeHostIdentity(worktree)) ?? + worktreeOrder.get(worktree.id) ?? + Number.MAX_SAFE_INTEGER const activeUnifiedTabId = getActiveUnifiedTabId({ worktreeId: worktree.id, + worktreeHostId: worktree.hostId, activeWorktreeId, + activeWorkspaceExecutionHostId, activeTabType, activeGroupIdByWorktree, groupsByWorktree @@ -205,7 +231,11 @@ export function buildSearchableSimulatorTabs({ // Why: simulator tabs are unified tabs; terminal activeTabId does not // identify the visible emulator tab after split-group activation. isCurrentTab: activeUnifiedTabId === tab.id, - isCurrentWorktree: activeWorktreeId === worktree.id, + isCurrentWorktree: isPaletteCurrentWorktree( + worktree, + activeWorktreeId, + activeWorkspaceExecutionHostId + ), document: buildPaletteTabDocument({ id: tab.id, title: simulatorPaletteTabTitle(tab), diff --git a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts new file mode 100644 index 00000000000..2b70345904d --- /dev/null +++ b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts @@ -0,0 +1,275 @@ +import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { + resolveTerminalTabTitle, + resolveUnifiedTabLabel +} from '../../../shared/tab-title-resolution' +import type { Tab, TabContentType } from '../../../shared/tab-types' +import type { Worktree } from '../../../shared/worktree/types' +import { getEditorDisplayLabel } from '@/components/editor/editor-labels' +import { buildPaletteTabDocument } from './palette-match/tab-document' +import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' +import { resolveOpenTabOccupantAgent } from './open-tab-occupant-agent' +import { + resolveWorktreeBranchLabel, + resolveWorktreeDisplayName +} from './worktree-default-display-name' +import { + buildAgentMetadataTabIndex, + collectAgentMetadataFromIndex +} from './workspace-tab-agent-metadata' +import type { + BuildSearchableWorkspaceTabsOptions, + SearchableWorkspaceTab, + WorkspaceTabContentType +} from './workspace-tab-palette-search' + +function getActiveUnifiedTabId({ + worktreeId, + worktreeHostId, + activeWorktreeId, + activeWorkspaceExecutionHostId, + activeTabType, + activeGroupIdByWorktree, + groupsByWorktree +}: Pick< + BuildSearchableWorkspaceTabsOptions, + | 'activeGroupIdByWorktree' + | 'activeTabType' + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' + | 'groupsByWorktree' +> & { worktreeId: string; worktreeHostId?: Worktree['hostId'] }): string | null { + if ( + !isPaletteCurrentWorktree( + { id: worktreeId, hostId: worktreeHostId }, + activeWorktreeId, + activeWorkspaceExecutionHostId + ) + ) { + return null + } + const activeGroupId = activeGroupIdByWorktree[worktreeId] + const activeGroup = activeGroupId + ? (groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) + : undefined + const activeUnifiedTabId = activeGroup?.activeTabId ?? null + return activeTabType === 'terminal' || activeTabType === 'editor' ? activeUnifiedTabId : null +} + +function isCurrentWorkspaceTab({ + tab, + worktreeHostId, + activeWorktreeId, + activeWorkspaceExecutionHostId, + activeTabType, + activeTabId, + activeTabIdByWorktree, + activeFileId, + activeFileIdByWorktree, + activeTabTypeByWorktree, + activeUnifiedTabId +}: Pick< + BuildSearchableWorkspaceTabsOptions, + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' +> & { + tab: Tab & { contentType: WorkspaceTabContentType } + worktreeHostId?: Worktree['hostId'] + activeUnifiedTabId: string | null +}): boolean { + if ( + !isPaletteCurrentWorktree( + { id: tab.worktreeId, hostId: worktreeHostId }, + activeWorktreeId, + activeWorkspaceExecutionHostId + ) + ) { + return false + } + const visibleType = tab.contentType === 'terminal' ? 'terminal' : 'editor' + const storedType = activeTabTypeByWorktree[tab.worktreeId] ?? activeTabType + if (storedType !== visibleType || activeUnifiedTabId !== tab.id) { + return false + } + return visibleType === 'terminal' + ? (activeTabIdByWorktree[tab.worktreeId] ?? activeTabId) === tab.entityId + : (activeFileIdByWorktree[tab.worktreeId] ?? activeFileId) === tab.entityId +} + +function isWorkspaceTabContentType( + contentType: TabContentType +): contentType is WorkspaceTabContentType { + return ['terminal', 'editor', 'diff', 'conflict-review', 'check-details'].includes(contentType) +} + +export function buildSearchableWorkspaceTabEntries({ + worktrees, + repoMap, + repoMapByHostIdentity, + worktreeOrder, + unifiedTabsByWorktree, + tabsByWorktree, + openFiles, + agentStatusByPaneKey, + retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey, + activeGroupIdByWorktree, + groupsByWorktree, + activeWorktreeId, + activeWorkspaceExecutionHostId, + activeTabType, + activeTabId, + activeTabIdByWorktree, + activeFileId, + activeFileIdByWorktree, + activeTabTypeByWorktree, + generatedTitlesEnabled, + terminalLayoutsByTabId, + paneForegroundAgentByPaneKey +}: BuildSearchableWorkspaceTabsOptions): SearchableWorkspaceTab[] { + const entries: SearchableWorkspaceTab[] = [] + const seenTabIds = new Set() + const openFilesById = new Map(openFiles.map((file) => [file.id, file])) + const agentIndex = buildAgentMetadataTabIndex({ + agentStatusByPaneKey, + retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey + }) + + for (const worktree of worktrees) { + const repoName = + resolvePaletteRepoForWorktree(worktree, repoMap, repoMapByHostIdentity)?.displayName ?? '' + const worktreeName = resolveWorktreeDisplayName(worktree) + const branch = resolveWorktreeBranchLabel(worktree) + const worktreeSortIndex = + worktreeOrder.get(getWorktreeHostIdentity(worktree)) ?? + worktreeOrder.get(worktree.id) ?? + Number.MAX_SAFE_INTEGER + const activeUnifiedTabId = getActiveUnifiedTabId({ + worktreeId: worktree.id, + worktreeHostId: worktree.hostId, + activeWorktreeId, + activeWorkspaceExecutionHostId, + activeTabType, + activeGroupIdByWorktree, + groupsByWorktree + }) + const groups = groupsByWorktree[worktree.id] ?? [] + const groupOrder = new Map(groups.map((group, index) => [group.id, index])) + const tabOrder = new Map() + for (const group of groups) { + group.tabOrder.forEach((tabId, index) => tabOrder.set(tabId, index)) + } + const terminalTabs = new Map((tabsByWorktree[worktree.id] ?? []).map((tab) => [tab.id, tab])) + + for (const rawTab of unifiedTabsByWorktree[worktree.id] ?? []) { + if (!isWorkspaceTabContentType(rawTab.contentType) || seenTabIds.has(rawTab.id)) { + continue + } + const tab = rawTab as Tab & { contentType: WorkspaceTabContentType } + const baseEntry = { + tab, + worktree, + repoName, + worktreeSortIndex, + groupSortIndex: groupOrder.get(tab.groupId) ?? Number.MAX_SAFE_INTEGER, + tabSortIndex: tabOrder.get(tab.id) ?? tab.sortOrder, + isCurrentTab: isCurrentWorkspaceTab({ + tab, + worktreeHostId: worktree.hostId, + activeWorktreeId, + activeWorkspaceExecutionHostId, + activeTabType, + activeTabId, + activeTabIdByWorktree, + activeFileId, + activeFileIdByWorktree, + activeTabTypeByWorktree, + activeUnifiedTabId + }), + isCurrentWorktree: isPaletteCurrentWorktree( + worktree, + activeWorktreeId, + activeWorkspaceExecutionHostId + ) + } + if (tab.contentType === 'terminal') { + const terminalTab = terminalTabs.get(tab.entityId) + const terminalTitle = terminalTab + ? resolveTerminalTabTitle(terminalTab, generatedTitlesEnabled, 'Terminal') + : 'Terminal' + const title = resolveUnifiedTabLabel( + { + ...tab, + customLabel: tab.customLabel ?? terminalTab?.customTitle ?? null, + quickCommandLabel: tab.quickCommandLabel ?? terminalTab?.quickCommandLabel, + generatedLabel: tab.generatedLabel ?? terminalTab?.generatedTitle + }, + generatedTitlesEnabled, + terminalTitle + ) + seenTabIds.add(tab.id) + entries.push({ + ...baseEntry, + title, + secondaryText: '', + titleSearchText: title, + secondarySearchTexts: [], + typeSearchAliases: ['terminal tab', 'terminal'], + document: buildPaletteTabDocument({ + id: tab.id, + title, + secondaryTexts: [], + worktreeName, + branch, + repoName, + typeAliases: ['terminal tab', 'terminal'] + }), + agentMetadata: collectAgentMetadataFromIndex(agentIndex, tab.entityId, worktree.id), + occupantAgent: resolveOpenTabOccupantAgent({ + tabId: tab.entityId, + title, + defaultTitle: terminalTab?.defaultTitle, + launchAgent: terminalTab?.launchAgent, + layout: terminalLayoutsByTabId?.[tab.entityId], + agentStatusByPaneKey, + retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey, + paneForegroundAgentByPaneKey + }) + }) + continue + } + const file = openFilesById.get(tab.entityId) + if (!file || file.worktreeId !== worktree.id) { + continue + } + const title = getEditorDisplayLabel(file) + seenTabIds.add(tab.id) + entries.push({ + ...baseEntry, + title, + secondaryText: file.relativePath, + titleSearchText: title, + secondarySearchTexts: [file.relativePath, file.filePath], + document: buildPaletteTabDocument({ + id: tab.id, + title, + secondaryTexts: [file.relativePath, file.filePath], + worktreeName, + branch, + repoName + }), + agentMetadata: [], + occupantAgent: null + }) + } + } + return entries +} diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index f3302745a4a..26ad64b16c5 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -10,6 +10,7 @@ import { resolveWorktreeDisplayName } from './worktree-default-display-name' import { matchWorkspaceTabAgentSnippet } from './workspace-tab-agent-snippet-match' +import type { ExecutionHostId } from '../../../shared/execution-host' import type { MatchRange } from './palette-match/normalized-text' import type { PaletteDocumentRank } from './palette-match/palette-document' import type { PaletteResultQualityClass } from './palette-match/match-quality' @@ -22,6 +23,8 @@ import type { const NO_RANGES: readonly MatchRange[] = [] export type WorkspaceTabPaletteSearchResult = { + /** Worktree ids collide across hosts; activation must not resolve by id alone. */ + executionHostId?: ExecutionHostId tabId: string entityId: string worktreeId: string @@ -81,6 +84,7 @@ function positionScore(entry: SearchableWorkspaceTab): number { function baseResult(entry: SearchableWorkspaceTab): WorkspaceTabPaletteSearchResult { return { + ...(entry.worktree.hostId ? { executionHostId: entry.worktree.hostId } : {}), tabId: entry.tab.id, entityId: entry.tab.entityId, worktreeId: entry.worktree.id, diff --git a/src/renderer/src/lib/workspace-tab-palette-search.test.ts b/src/renderer/src/lib/workspace-tab-palette-search.test.ts index 7570213dd6a..d31c1128fe0 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts @@ -134,6 +134,52 @@ function buildEntries(overrides: Partial { + it('stamps the row execution host so activation never resolves by id alone', () => { + // Why: worktree ids repeat across hosts, so a host-blind activation opened the other + // host's workspace behind a row labelled with this one's name and branch. + const worktree = makeWorktree({ hostId: 'ssh:box' }) + const entries = buildEntries({ worktrees: [worktree] }) + const [result] = searchWorkspaceTabs(entries, '') + expect(result.executionHostId).toBe('ssh:box') + }) + + it('keeps the resolvable twin when the first record under an id has no open file', () => { + // Why: dropping the id on sight would lose the row entirely — the leading + // record dies at the open-file lookup and the survivor never gets its turn. + const orphaned = makeUnifiedTab({ + id: 'unified-editor-dup', + contentType: 'editor', + entityId: 'missing-file' + }) + const resolvable = makeUnifiedTab({ + id: 'unified-editor-dup', + contentType: 'editor', + entityId: SRC_APP_PATH + }) + const entries = buildEntries({ + unifiedTabsByWorktree: { 'wt-1': [orphaned, resolvable] }, + openFiles: [makeOpenFile()] + }) + + expect(entries.map((entry) => entry.tab.id)).toEqual(['unified-editor-dup']) + expect(entries[0]?.secondaryText).toBe(SRC_APP_RELATIVE_PATH) + }) + it('emits one entry per tab id when a session persisted the same id twice', () => { + // Why: the palette keys rows by tab id, and duplicated persisted records used + // to render the row twice under one React key, stranding a ghost row. + const duplicate = makeUnifiedTab({ id: 'unified-terminal-dup' }) + const entries = buildEntries({ + unifiedTabsByWorktree: { + 'wt-1': [makeUnifiedTab(), duplicate, { ...duplicate }] + } + }) + + const tabIds = entries.map((entry) => entry.tab.id) + expect(tabIds).toEqual(['unified-terminal-1', 'unified-terminal-dup']) + + const results = searchWorkspaceTabs(entries, 'unified') + expect(results.map((result) => result.tabId)).toEqual(tabIds) + }) it('uses the same title precedence as the tab strip and honors generated-title disabling', () => { const enabledEntries = buildEntries({ tabsByWorktree: { diff --git a/src/renderer/src/lib/workspace-tab-palette-search.ts b/src/renderer/src/lib/workspace-tab-palette-search.ts index b35e1154b23..2fba19df1e9 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.ts @@ -1,27 +1,13 @@ -import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import type { OpenFile } from '@/store/slices/editor' -import { buildPaletteTabDocument } from './palette-match/tab-document' import type { PaletteDocument } from './palette-match/palette-document' -import { - resolveWorktreeBranchLabel, - resolveWorktreeDisplayName -} from './worktree-default-display-name' -import { - resolveTerminalTabTitle, - resolveUnifiedTabLabel -} from '../../../shared/tab-title-resolution' -import type { Tab, TabContentType, TabGroup } from '../../../shared/tab-types' +import type { Tab, TabGroup } from '../../../shared/tab-types' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { Worktree } from '../../../shared/worktree/types' -import { resolveOpenTabOccupantAgent } from './open-tab-occupant-agent' -import { - buildAgentMetadataTabIndex, - collectAgentMetadataFromIndex, - type AgentMetadata, - type WorkspaceTabAgentMetadataState -} from './workspace-tab-agent-metadata' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { AgentMetadata, WorkspaceTabAgentMetadataState } from './workspace-tab-agent-metadata' +import { buildSearchableWorkspaceTabEntries } from './workspace-tab-palette-entry-builder' export { searchWorkspaceTabs, type WorkspaceTabPaletteSearchResult @@ -68,6 +54,7 @@ type WorkspaceTabPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'sim export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState & { worktrees: readonly Worktree[] repoMap: ReadonlyMap + repoMapByHostIdentity?: ReadonlyMap worktreeOrder: ReadonlyMap unifiedTabsByWorktree: Record tabsByWorktree: Record @@ -75,6 +62,7 @@ export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState activeGroupIdByWorktree: Record groupsByWorktree: Record activeWorktreeId: string | null + activeWorkspaceExecutionHostId?: ExecutionHostId | null activeTabType: WorkspaceTabPaletteActiveTabType activeTabId: string | null activeTabIdByWorktree: Record @@ -86,233 +74,4 @@ export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState paneForegroundAgentByPaneKey?: Record } -function getActiveUnifiedTabId({ - worktreeId, - activeWorktreeId, - activeTabType, - activeGroupIdByWorktree, - groupsByWorktree -}: Pick< - BuildSearchableWorkspaceTabsOptions, - 'activeGroupIdByWorktree' | 'activeTabType' | 'activeWorktreeId' | 'groupsByWorktree' -> & { - worktreeId: string -}): string | null { - if (activeWorktreeId !== worktreeId) { - return null - } - const activeGroupId = activeGroupIdByWorktree[worktreeId] - const activeGroup = activeGroupId - ? (groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) - : undefined - const activeUnifiedTabId = activeGroup?.activeTabId ?? null - return activeTabType === 'terminal' || activeTabType === 'editor' ? activeUnifiedTabId : null -} - -function isCurrentWorkspaceTab({ - tab, - activeWorktreeId, - activeTabType, - activeTabId, - activeTabIdByWorktree, - activeFileId, - activeFileIdByWorktree, - activeTabTypeByWorktree, - activeUnifiedTabId -}: Pick< - BuildSearchableWorkspaceTabsOptions, - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' - | 'activeWorktreeId' -> & { - tab: Tab & { contentType: WorkspaceTabContentType } - activeUnifiedTabId: string | null -}): boolean { - if (tab.worktreeId !== activeWorktreeId) { - return false - } - const visibleType = tab.contentType === 'terminal' ? 'terminal' : 'editor' - const storedType = activeTabTypeByWorktree[tab.worktreeId] ?? activeTabType - if (storedType !== visibleType || activeUnifiedTabId !== tab.id) { - return false - } - if (visibleType === 'terminal') { - return (activeTabIdByWorktree[tab.worktreeId] ?? activeTabId) === tab.entityId - } - return (activeFileIdByWorktree[tab.worktreeId] ?? activeFileId) === tab.entityId -} - -function isWorkspaceTabContentType( - contentType: TabContentType -): contentType is WorkspaceTabContentType { - return ( - contentType === 'terminal' || - contentType === 'editor' || - contentType === 'diff' || - contentType === 'conflict-review' || - contentType === 'check-details' - ) -} - -export function buildSearchableWorkspaceTabs({ - worktrees, - repoMap, - worktreeOrder, - unifiedTabsByWorktree, - tabsByWorktree, - openFiles, - agentStatusByPaneKey, - retainedAgentsByPaneKey, - sleepingAgentSessionsByPaneKey, - activeGroupIdByWorktree, - groupsByWorktree, - activeWorktreeId, - activeTabType, - activeTabId, - activeTabIdByWorktree, - activeFileId, - activeFileIdByWorktree, - activeTabTypeByWorktree, - generatedTitlesEnabled, - terminalLayoutsByTabId, - paneForegroundAgentByPaneKey -}: BuildSearchableWorkspaceTabsOptions): SearchableWorkspaceTab[] { - const entries: SearchableWorkspaceTab[] = [] - const openFilesById = new Map(openFiles.map((file) => [file.id, file])) - const agentIndex = buildAgentMetadataTabIndex({ - agentStatusByPaneKey, - retainedAgentsByPaneKey, - sleepingAgentSessionsByPaneKey - }) - - for (const worktree of worktrees) { - const repoName = repoMap.get(worktree.repoId)?.displayName ?? '' - const worktreeName = resolveWorktreeDisplayName(worktree) - const branch = resolveWorktreeBranchLabel(worktree) - const worktreeSortIndex = worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER - const activeUnifiedTabId = getActiveUnifiedTabId({ - worktreeId: worktree.id, - activeWorktreeId, - activeTabType, - activeGroupIdByWorktree, - groupsByWorktree - }) - const groups = groupsByWorktree[worktree.id] ?? [] - const groupOrder = new Map(groups.map((group, index) => [group.id, index])) - const tabOrder = new Map() - for (const group of groups) { - group.tabOrder.forEach((tabId, index) => tabOrder.set(tabId, index)) - } - const terminalTabs = new Map((tabsByWorktree[worktree.id] ?? []).map((tab) => [tab.id, tab])) - - for (const rawTab of unifiedTabsByWorktree[worktree.id] ?? []) { - if (!isWorkspaceTabContentType(rawTab.contentType)) { - continue - } - const tab = rawTab as Tab & { contentType: WorkspaceTabContentType } - const isCurrentTab = isCurrentWorkspaceTab({ - tab, - activeWorktreeId, - activeTabType, - activeTabId, - activeTabIdByWorktree, - activeFileId, - activeFileIdByWorktree, - activeTabTypeByWorktree, - activeUnifiedTabId - }) - const baseEntry = { - tab, - worktree, - repoName, - worktreeSortIndex, - groupSortIndex: groupOrder.get(tab.groupId) ?? Number.MAX_SAFE_INTEGER, - tabSortIndex: tabOrder.get(tab.id) ?? tab.sortOrder, - isCurrentTab, - isCurrentWorktree: activeWorktreeId === worktree.id - } - - if (tab.contentType === 'terminal') { - const terminalTab = terminalTabs.get(tab.entityId) - // Match the tab strip: useTabGroupWorkspaceModel resolves the visible - // title from the unified tab, not tabsByWorktree. Those two can desync - // (live OSC on the label, stale "Terminal N" on the terminal record). - const terminalTitle = terminalTab - ? resolveTerminalTabTitle(terminalTab, generatedTitlesEnabled, 'Terminal') - : 'Terminal' - const title = resolveUnifiedTabLabel( - { - ...tab, - customLabel: tab.customLabel ?? terminalTab?.customTitle ?? null, - quickCommandLabel: tab.quickCommandLabel ?? terminalTab?.quickCommandLabel, - generatedLabel: tab.generatedLabel ?? terminalTab?.generatedTitle - }, - generatedTitlesEnabled, - terminalTitle - ) - entries.push({ - ...baseEntry, - title, - // Why: type is already clear from the status/content icon; a fixed - // "Terminal tab" label only crowds the row (and used to leave a bare "· ·"). - secondaryText: '', - titleSearchText: title, - secondarySearchTexts: [], - typeSearchAliases: TERMINAL_TYPE_SEARCH_ALIASES, - document: buildPaletteTabDocument({ - id: tab.id, - title, - secondaryTexts: [], - worktreeName, - branch, - repoName, - typeAliases: TERMINAL_TYPE_SEARCH_ALIASES - }), - agentMetadata: collectAgentMetadataFromIndex(agentIndex, tab.entityId, worktree.id), - occupantAgent: resolveOpenTabOccupantAgent({ - tabId: tab.entityId, - // The unified label, not terminalTab.title: the record can lag it (see above). - title, - defaultTitle: terminalTab?.defaultTitle, - launchAgent: terminalTab?.launchAgent, - layout: terminalLayoutsByTabId?.[tab.entityId], - agentStatusByPaneKey, - retainedAgentsByPaneKey, - sleepingAgentSessionsByPaneKey, - paneForegroundAgentByPaneKey - }) - }) - continue - } - - const file = openFilesById.get(tab.entityId) - if (!file || file.worktreeId !== worktree.id) { - continue - } - const title = getEditorDisplayLabel(file) - entries.push({ - ...baseEntry, - title, - secondaryText: file.relativePath, - titleSearchText: title, - secondarySearchTexts: [file.relativePath, file.filePath], - document: buildPaletteTabDocument({ - id: tab.id, - title, - secondaryTexts: [file.relativePath, file.filePath], - worktreeName, - branch, - repoName - }), - agentMetadata: [], - occupantAgent: null - }) - } - } - - return entries -} +export const buildSearchableWorkspaceTabs = buildSearchableWorkspaceTabEntries diff --git a/src/renderer/src/lib/worktree-palette-comment-snippet.test.ts b/src/renderer/src/lib/worktree-palette-comment-snippet.test.ts new file mode 100644 index 00000000000..f9580db8d5e --- /dev/null +++ b/src/renderer/src/lib/worktree-palette-comment-snippet.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { extractWorktreePaletteCommentSnippet } from './worktree-palette-comment-snippet' + +const EMOJI = '\u{1F680}' + +// Why not `isWellFormed` alone: it only proves no half-pair survived, which an +// implementation that DELETES the emoji satisfies just as well. The retained span must +// be a contiguous slice of the source, so nothing may be dropped out of its middle. +function expectContiguousSlice(text: string, comment: string): void { + const core = text.replace(/^\u2026/, '').replace(/\u2026$/, '') + expect(comment).toContain(core) +} + +describe('extractWorktreePaletteCommentSnippet', () => { + it('highlights the match inside the returned text', () => { + const comment = 'the reconnect work is blocked on infra' + const matchStart = comment.indexOf('infra') + const { text, matchRange } = extractWorktreePaletteCommentSnippet( + comment, + matchStart, + matchStart + 'infra'.length + ) + expect(text.slice(matchRange.start, matchRange.end)).toBe('infra') + }) + + // CJK carries no whitespace, so both boundary loops always exhaust their 10 iterations and + // stop wherever the raw code-unit arithmetic put them — which can be mid-surrogate-pair. + // Sweeping the emoji past the cut while the match stays put covers both halves of the pair. + it('never splits a surrogate pair at the leading edge', () => { + let keptEmoji = 0 + for (let offset = 0; offset < 40; offset += 1) { + const comment = `${'词'.repeat(offset)}${EMOJI}${'词'.repeat(80 - offset)}目标${'词'.repeat(20)}` + const matchStart = comment.indexOf('目标') + const { text, matchRange } = extractWorktreePaletteCommentSnippet( + comment, + matchStart, + matchStart + '目标'.length + ) + expect(text.isWellFormed()).toBe(true) + expect(text.slice(matchRange.start, matchRange.end)).toBe('目标') + expectContiguousSlice(text, comment) + if (text.includes(EMOJI)) { + keptEmoji += 1 + } + } + // Why: the boundary iterations are the point of the loop — if the snap dropped the + // emoji on every one of them, the assertions above would still all pass. + expect(keptEmoji).toBeGreaterThan(0) + }) + + it('never splits a surrogate pair at the trailing edge', () => { + let keptEmoji = 0 + for (let offset = 0; offset < 70; offset += 1) { + const comment = `${'词'.repeat(20)}目标${'词'.repeat(offset)}${EMOJI}${'词'.repeat(90 - offset)}` + const matchStart = comment.indexOf('目标') + const { text, matchRange } = extractWorktreePaletteCommentSnippet( + comment, + matchStart, + matchStart + '目标'.length + ) + expect(text.isWellFormed()).toBe(true) + expect(text.slice(matchRange.start, matchRange.end)).toBe('目标') + expectContiguousSlice(text, comment) + if (text.includes(EMOJI)) { + keptEmoji += 1 + } + } + expect(keptEmoji).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/lib/worktree-palette-comment-snippet.ts b/src/renderer/src/lib/worktree-palette-comment-snippet.ts index 79d6de273db..ec8a0002384 100644 --- a/src/renderer/src/lib/worktree-palette-comment-snippet.ts +++ b/src/renderer/src/lib/worktree-palette-comment-snippet.ts @@ -21,6 +21,17 @@ export function extractWorktreePaletteCommentSnippet( snippetEnd++ } + // Why: the offsets above count UTF-16 code units and the boundary loops test one unit at + // a time, so either edge can land between the halves of a surrogate pair \u2014 CJK text backs + // up the full 10 iterations without ever finding whitespace. Snap outward to keep the pair + // whole; matchRange derives from snippetStart, so widening by one keeps it correct. + if (snippetStart > 0 && (comment.charCodeAt(snippetStart) & 0xfc00) === 0xdc00) { + snippetStart -= 1 + } + if (snippetEnd < comment.length && (comment.charCodeAt(snippetEnd - 1) & 0xfc00) === 0xd800) { + snippetEnd += 1 + } + const prefix = snippetStart > 0 ? '\u2026' : '' const suffix = snippetEnd < comment.length ? '\u2026' : '' return { diff --git a/src/renderer/src/lib/worktree-palette-create-action.test.ts b/src/renderer/src/lib/worktree-palette-create-action.test.ts index 60f23867e46..b93d3b5a408 100644 --- a/src/renderer/src/lib/worktree-palette-create-action.test.ts +++ b/src/renderer/src/lib/worktree-palette-create-action.test.ts @@ -204,6 +204,22 @@ describe('worktree-palette-create-action', () => { ]) }) + it('names the rendered render key so a duplicate row is reachable by keyboard', () => { + // Why: rows render under de-duplicated keys, and cmdk selects by that rendered value. + // Naming the bare id here left the duplicate absent from the allow-list, so arrowing + // onto it failed the `includes` check and snapped the highlight back to the top. + expect( + getWorktreePaletteSelectionItemIds( + [ + { id: '__header_worktrees__', type: 'section-header' }, + { id: 'worktree:shared', type: 'worktree' }, + { id: 'worktree:shared', type: 'worktree' } + ], + ['__header_worktrees__', 'worktree:shared', 'worktree:shared#dup1'] + ) + ).toEqual(['worktree:shared', 'worktree:shared#dup1']) + }) + it('falls back deterministically when the selected row disappears', () => { expect( getNextWorktreePaletteSelection({ diff --git a/src/renderer/src/lib/worktree-palette-create-action.ts b/src/renderer/src/lib/worktree-palette-create-action.ts index 6b6afbc0256..07539764082 100644 --- a/src/renderer/src/lib/worktree-palette-create-action.ts +++ b/src/renderer/src/lib/worktree-palette-create-action.ts @@ -82,10 +82,16 @@ export function isSelectableWorktreePaletteEntry( export function getWorktreePaletteSelectionItemIds< T extends WorktreePaletteSelectionCandidateEntry ->(entries: readonly T[]): string[] { +>(entries: readonly T[], renderKeys: readonly string[] = []): string[] { // Why: keyboard focus should mirror rendered order, including synthetic // action rows, while skipping headers and explanatory hint rows. - return entries.filter(isSelectableWorktreePaletteEntry).map((entry) => entry.id) + // Why renderKeys wins: rows render under de-duplicated keys, so naming the bare + // id here would leave a duplicate row absent from the list the `includes` check + // above consults — arrowing onto it would snap the highlight back to the top. + return entries + .map((entry, index) => ({ entry, id: renderKeys[index] ?? entry.id })) + .filter(({ entry }) => isSelectableWorktreePaletteEntry(entry)) + .map(({ id }) => id) } export function getNextWorktreePaletteSelection({ diff --git a/src/renderer/src/lib/worktree-palette-document.ts b/src/renderer/src/lib/worktree-palette-document.ts index 5267bc94ea0..e9a82dbb5aa 100644 --- a/src/renderer/src/lib/worktree-palette-document.ts +++ b/src/renderer/src/lib/worktree-palette-document.ts @@ -1,3 +1,4 @@ +import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' import { buildPaletteDocument, type PaletteDocument } from './palette-match/palette-document' import type { PaletteComposedEvidence } from './palette-match/evidence-composer' @@ -17,6 +18,7 @@ import { import type { HostedReviewInfo } from '../../../shared/hosted-review' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' +import { resolvePaletteRepoForWorktree } from './palette-repo-resolution' export const WORKTREE_PALETTE_NAME_FIELD_ID = 'name' export const WORKTREE_PALETTE_BRANCH_FIELD_ID = 'branch' @@ -34,6 +36,7 @@ export type WorktreePaletteEvidencePolicy = 'palette' | 'board' export type WorktreePaletteDocumentSources = { repoMap: ReadonlyMap + repoMapByHostIdentity?: ReadonlyMap prCache?: Record | null issueCache?: Record | null workspacePortsByWorktreeId?: ReadonlyMap< @@ -122,7 +125,11 @@ export function buildWorktreePaletteDocument( worktree: Worktree, sources: WorktreePaletteDocumentSources ): PaletteDocument { - const repo = sources.repoMap.get(worktree.repoId) + const repo = resolvePaletteRepoForWorktree( + worktree, + sources.repoMap, + sources.repoMapByHostIdentity + ) return buildPaletteDocument({ id: worktree.id, visibleFields: [ @@ -146,7 +153,12 @@ export function buildWorktreePaletteDocument( profile: 'structured-label', // Why conditional: the host chip only renders for active remote hosts, and // an unrendered match would be unexplainable on the row. - text: sources.hostLabelByWorktreeId?.get(worktree.id) ?? '' + // Why both keys: the palette keys this map by host identity so two same-id + // workspaces keep distinct chips, but a bare-id map is still a valid input. + text: + sources.hostLabelByWorktreeId?.get(getWorktreeHostIdentity(worktree)) ?? + sources.hostLabelByWorktreeId?.get(worktree.id) ?? + '' } ], compositePairs: [ @@ -166,7 +178,13 @@ export function buildWorktreePaletteDocuments( ): Map { const documents = new Map() for (const worktree of worktrees) { - documents.set(worktree.id, buildWorktreePaletteDocument(worktree, sources)) + // Why the host identity (STA-4343): `repoId::path` repeats across hosts, so keying on + // the bare id lets the second host overwrite the first and one workspace becomes + // unsearchable by its own name. + documents.set( + getWorktreeHostIdentity(worktree), + buildWorktreePaletteDocument(worktree, sources) + ) } return documents } diff --git a/src/renderer/src/lib/worktree-palette-multi-keyword.test.ts b/src/renderer/src/lib/worktree-palette-multi-keyword.test.ts index 0a0d93ac373..8e72fef0a0d 100644 --- a/src/renderer/src/lib/worktree-palette-multi-keyword.test.ts +++ b/src/renderer/src/lib/worktree-palette-multi-keyword.test.ts @@ -8,6 +8,13 @@ import { import { searchWorktrees, type PaletteSearchResult } from './worktree-palette-search' import { buildWorktreePaletteDocuments } from './worktree-palette-document' import { PALETTE_QUERY_MAX_TOKENS, preparePaletteQuery } from './palette-match/palette-query' +import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' + +function documentKey(worktreeId: string): string { + return getWorktreeHostIdentity( + CMD_J_FIXTURE_WORKTREES.find((worktree) => worktree.id === worktreeId)! + ) +} function search(query: string, extra: Record = {}): PaletteSearchResult[] { return searchWorktrees(CMD_J_FIXTURE_WORKTREES, query, CMD_J_FIXTURE_REPO_MAP, { @@ -162,15 +169,22 @@ describe('document invalidation inputs', () => { repoMap: CMD_J_FIXTURE_REPO_MAP, workspacePortsByWorktreeId: CMD_J_FIXTURE_PORTS }) - expect(before.get('wt-main-orca')?.evidenceUnits.has('port:3000')).toBe(false) - expect(after.get('wt-main-orca')?.evidenceUnits.has('port:3000')).toBe(true) + // Documents are keyed by host identity so two same-id workspaces on different hosts + // keep separate entries. + const key = documentKey('wt-main-orca') + expect(before.get(key)?.evidenceUnits.has('port:3000')).toBe(false) + expect(after.get(key)?.evidenceUnits.has('port:3000')).toBe(true) }) it('keeps recency and current-tab state out of normalized field data', () => { const documents = buildWorktreePaletteDocuments(CMD_J_FIXTURE_WORKTREES, { repoMap: CMD_J_FIXTURE_REPO_MAP }) - const fieldIds = [...(documents.get('wt-docs')?.fields ?? [])].map((field) => field.id) + const fieldIds = [...(documents.get(documentKey('wt-docs'))?.fields ?? [])].map( + (field) => field.id + ) + // Guard the guard: a missing document would make the assertions below vacuous. + expect(fieldIds.length).toBeGreaterThan(0) expect(fieldIds).not.toContain('recency') expect(fieldIds).not.toContain('isCurrent') }) diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts index 96968ae4d51..8cb637bd77a 100644 --- a/src/renderer/src/lib/worktree-palette-search.ts +++ b/src/renderer/src/lib/worktree-palette-search.ts @@ -1,3 +1,4 @@ +import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import { matchPaletteDocument } from './palette-match/match-document' import { preparePaletteQuery } from './palette-match/palette-query' import type { MatchRange } from './palette-match/normalized-text' @@ -24,6 +25,7 @@ import { import type { HostedReviewInfo } from '../../../shared/hosted-review' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' +import { resolvePaletteRepoForWorktree } from './palette-repo-resolution' import { matchWorktreePaletteTaskUrl, parseCmdJTaskSourceUrl @@ -156,6 +158,7 @@ export type WorktreePaletteSearchArgs = { query: string documents: ReadonlyMap repoMap: ReadonlyMap + repoMapByHostIdentity?: ReadonlyMap checksReviewByWorktree?: ReadonlyMap } @@ -178,7 +181,7 @@ export function searchWorktreeDocuments(args: WorktreePaletteSearchArgs): Palett const match = matchWorktreePaletteTaskUrl({ worktree, intent: taskSourceUrl, - repo: args.repoMap.get(worktree.repoId), + repo: resolvePaletteRepoForWorktree(worktree, args.repoMap, args.repoMapByHostIdentity), review: args.checksReviewByWorktree?.get(worktree) }) if (match) { @@ -187,7 +190,7 @@ export function searchWorktreeDocuments(args: WorktreePaletteSearchArgs): Palett continue } - const document = args.documents.get(worktree.id) + const document = args.documents.get(getWorktreeHostIdentity(worktree)) if (!document) { continue } @@ -216,6 +219,7 @@ export function searchWorktrees( query, documents: buildWorktreePaletteDocuments(worktrees, documentSources), repoMap, + repoMapByHostIdentity: sources.repoMapByHostIdentity, checksReviewByWorktree: sources.checksReviewByWorktree }) } diff --git a/src/renderer/src/lib/worktree-palette-task-url-match.test.ts b/src/renderer/src/lib/worktree-palette-task-url-match.test.ts index 3dba3578c5f..0f1f1c9bd97 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-match.test.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-match.test.ts @@ -696,6 +696,29 @@ describe('matchWorktreePaletteTaskUrl', () => { ).toBeNull() }) + it('carries the worktree host so the board can key the Jira match by host identity', () => { + // Why: the other three providers spread worktreeHostId and Jira did not, so a Jira URL + // recorded an unqualified identity and the board filtered every lane to empty. + const intent = parseCmdJTaskSourceUrl('https://acme.atlassian.net/browse/PROJ-123') + + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ + hostId: 'ssh:box', + linkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'Ticket', + jiraIdentifier: 'PROJ-123', + url: 'https://acme.atlassian.net/browse/PROJ-123' + } + }), + intent: intent! + }) + ).toMatchObject({ worktreeHostId: 'ssh:box' }) + }) + // Same host, different site path: Jira Server installs are commonly path-scoped. it('rejects a Jira issue URL from a different site path on the same host', () => { const intent = parseCmdJTaskSourceUrl('https://jira.acme.test/one/browse/PROJ-123') diff --git a/src/renderer/src/lib/worktree-palette-task-url-match.ts b/src/renderer/src/lib/worktree-palette-task-url-match.ts index 151ae62782c..543c6a48ee0 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-match.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-match.ts @@ -316,6 +316,7 @@ export function matchWorktreePaletteTaskUrl(args: { } return buildWorktreePaletteTaskUrlResult({ worktreeId: worktree.id, + ...(worktree.hostId ? { worktreeHostId: worktree.hostId } : {}), labelKind: 'issue', text: intent.parsed.issueKey }) diff --git a/src/renderer/src/store/slices/tab-group-state.ts b/src/renderer/src/store/slices/tab-group-state.ts index 838433651c7..1b3466e9fc7 100644 --- a/src/renderer/src/store/slices/tab-group-state.ts +++ b/src/renderer/src/store/slices/tab-group-state.ts @@ -184,6 +184,23 @@ export function selectHydratedActiveGroupId( return candidates[0]?.id } +/** + * Persisted sessions can hold two tab records under one id (editor owner + * migration re-stamps a tab id that another record already carries). Downstream + * consumers key React lists by tab id, and a repeated key leaves the extra row + * mounted forever, so the duplicate has to die at hydration. + */ +export function dedupeTabsById(tabs: T[]): T[] { + const seen = new Set() + return tabs.filter((tab) => { + if (seen.has(tab.id)) { + return false + } + seen.add(tab.id) + return true + }) +} + export function dedupeTabOrder(tabIds: string[]): string[] { const seen = new Set() const deduped: string[] = [] diff --git a/src/renderer/src/store/slices/tabs-hydration.test.ts b/src/renderer/src/store/slices/tabs-hydration.test.ts index bab64605273..da7fcc35ee4 100644 --- a/src/renderer/src/store/slices/tabs-hydration.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration.test.ts @@ -347,4 +347,46 @@ describe('buildHydratedTabState – legacy format', () => { const result = buildHydratedTabState(session, new Set(['w1', 'w2'])) expect(Object.keys(result.unifiedTabsByWorktree)).toHaveLength(0) }) + it('collapses tab records that a corrupt session persisted under one id', () => { + // Why: editor owner migration re-stamped a tab id a sibling record already + // held. Two rows under one id repeat a React key and strand a ghost row. + const duplicateId = 'editor:wt%3A%3Alungfish:env-a:FINAL-REPORT.md' + const editorTab = (id: string, sortOrder: number) => ({ + id, + entityId: 'editor:wt%3A%3Alungfish:env-b:FINAL-REPORT.md', + groupId: 'g1', + worktreeId: 'w1', + contentType: 'editor' as const, + label: 'FINAL-REPORT.md', + customLabel: null, + color: null, + sortOrder, + createdAt: 1 + }) + const session: WorkspaceSessionState = { + ...makeBaseSession(), + unifiedTabs: { + w1: [editorTab('t-unique', 0), editorTab(duplicateId, 1), editorTab(duplicateId, 2)] + }, + tabGroups: { + w1: [ + { + id: 'g1', + worktreeId: 'w1', + activeTabId: 't-unique', + tabOrder: ['t-unique', duplicateId, duplicateId] + } + ] + } + } + + const result = buildHydratedTabState(session, new Set(['w1'])) + const hydratedIds = result.unifiedTabsByWorktree.w1.map((tab) => tab.id) + expect(hydratedIds).toEqual(['t-unique', duplicateId]) + // Why sortOrder: the two duplicate records differ only there, so an id-only + // assertion passes just as well for an implementation that keeps the LAST one. + expect(result.unifiedTabsByWorktree.w1.map((tab) => tab.sortOrder)).toEqual([0, 1]) + expect(new Set(hydratedIds).size).toBe(hydratedIds.length) + expect(result.groupsByWorktree.w1[0].tabOrder).toEqual(['t-unique', duplicateId]) + }) }) diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index d3a8dd97c01..753286e1142 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -5,6 +5,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { adoptGrouplessTabs, layoutSpanningGroups } from './tab-group-reference-repair' import { dedupeTabOrder, + dedupeTabsById, getPersistedEditFileIdsByWorktree, isTransientEditorContentType, sanitizeRecentTabIds, @@ -75,7 +76,7 @@ function hydrateUnifiedFormat( .filter((tab) => tab.aiVaultTitle) .map((tab) => [tab.id, tab.aiVaultTitle!]) ) - tabsByWorktree[worktreeId] = [...tabs] + const hydratedTabs = [...tabs] .map((tab) => ({ ...tab, entityId: tab.entityId ?? tab.id @@ -111,6 +112,8 @@ function hydrateUnifiedFormat( return persistedEditFileIds.has(tab.entityId) }) .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + // Why after the sort: the surviving record is the one the strip renders first. + tabsByWorktree[worktreeId] = dedupeTabsById(hydratedTabs) } for (const [worktreeId, groups] of Object.entries(session.tabGroups!)) { From 1b5cc00870e58bd761b90e1d9c6c780328c4ba86 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:59:17 -0700 Subject: [PATCH 023/168] fix(shell): content-address shell wrapper trees so builds stop clobbering each other (#15285) Every writer sharing a userData dir -- main's local PTY path, the daemon fork, and the daemons of other builds that outlive the app that spawned them -- wrote one fixed `shell-ready/` tree. Last writer won, and the guard only re-checked that the files were present, never that they were this build's. A daemon whose spawn env no longer agreed with the wrapper on disk kept launching shells it could not read: the ready marker never fired and every startup command waited out the full 15s timeout, silently, with restarting the app powerless to fix it because the daemon outlives the app. Measured 15.17s vs 1.10s once the tree matched. The five live daemons on the machine this was found on spanned app versions 1.4.181-1.4.185, all from the same installed app across auto-updates, so this reaches ordinary installs. Name each tree after a hash of its contents, at `/shell-wrappers//shell-ready/`. Different bytes are a different directory, so "present" means "written by this build" again. The hash sits above the `shell-ready` leaf because ZDOTDIR self-reference guards match that exact suffix. Nothing collects old trees: ~48KB each, a couple of MB a year against a userData dir in the tens of GB, not worth an `rm -rf` on the spawn path. Also publishes the resolved root to WSL over WSLENV `/p`, since the in-guest script cannot derive a hash, and reports readiness failures to the daemon's NDJSON log rather than a console the detached daemon discards. Generated wrapper content is byte-identical; snapshots unchanged. --- .../daemon-bash-wrapper-osc133-fixture.ts | 51 +++++ src/main/daemon/daemon-server.ts | 3 + .../daemon-shell-ready-wrapper-fileset.ts | 24 ++ src/main/daemon/session-options.ts | 3 + .../daemon/session-shell-ready-barrier.ts | 36 ++- src/main/daemon/session.test.ts | 41 ++++ src/main/daemon/session.ts | 1 + .../daemon/shell-ready-bash-wrapper.test.ts | 196 ++++++++++++++++ src/main/daemon/shell-ready.test.ts | 213 ++---------------- src/main/daemon/shell-ready.ts | 90 ++++---- src/main/daemon/terminal-host-options.ts | 4 + .../terminal-host-readiness-reporting.test.ts | 75 ++++++ .../daemon/terminal-host-session-create.ts | 2 + src/main/daemon/terminal-host.ts | 5 + src/main/ipc/pty-hidden-at-spawn-mark.test.ts | 5 +- .../pty-listener-teardown-and-orphans.test.ts | 5 +- .../pty-login-shell-startup-commands.test.ts | 7 +- src/main/ipc/pty-wsl-cwd-validation.test.ts | 5 +- .../shell-script-test.ts | 7 +- .../local-pty-shell-ready-bash-rcfile.ts | 2 +- .../providers/local-pty-shell-ready-marker.ts | 8 + .../local-pty-shell-ready-wrapper-fileset.ts | 50 ++++ ...pty-shell-ready-wrapper-generation.test.ts | 74 ++++-- ...ocal-pty-shell-ready-wrapper-generation.ts | 66 ++---- .../local-pty-shell-ready-wrapper-root.ts | 50 ++-- ...-shell-ready-zsh-zdotdir-discovery.test.ts | 16 +- ...ll-ready-zsh-zdotdir-normalization.test.ts | 4 +- src/main/providers/windows-shell-args.test.ts | 10 +- src/main/pty/wsl-orca-env.test.ts | 29 ++- src/main/pty/wsl-orca-env.ts | 9 + .../shell-wrapper-content-address.test.ts | 74 ++++++ src/main/shell-wrapper-content-address.ts | 63 ++++++ ...ll-wrapper-generated-file-snapshot.test.ts | 9 +- src/shared/wsl-login-shell-command.test.ts | 38 +++- src/shared/wsl-login-shell-command.ts | 8 +- 35 files changed, 935 insertions(+), 348 deletions(-) create mode 100644 src/main/daemon/daemon-bash-wrapper-osc133-fixture.ts create mode 100644 src/main/daemon/daemon-shell-ready-wrapper-fileset.ts create mode 100644 src/main/daemon/shell-ready-bash-wrapper.test.ts create mode 100644 src/main/daemon/terminal-host-readiness-reporting.test.ts create mode 100644 src/main/providers/local-pty-shell-ready-marker.ts create mode 100644 src/main/providers/local-pty-shell-ready-wrapper-fileset.ts create mode 100644 src/main/shell-wrapper-content-address.test.ts create mode 100644 src/main/shell-wrapper-content-address.ts diff --git a/src/main/daemon/daemon-bash-wrapper-osc133-fixture.ts b/src/main/daemon/daemon-bash-wrapper-osc133-fixture.ts new file mode 100644 index 00000000000..e8c57584c68 --- /dev/null +++ b/src/main/daemon/daemon-bash-wrapper-osc133-fixture.ts @@ -0,0 +1,51 @@ +/** + * Shared harness for exercising the daemon's generated bash rcfile against a real + * interactive bash, and asserting the OSC 133 command lifecycle it must emit. + * + * Why its own module: both the launch-config suite and the bash-wrapper suite + * need it, and duplicating a live-shell harness invites the two copies to drift. + */ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' +import { writeFileSync } from 'node:fs' +import { expect } from 'vitest' + +export function runInteractiveBashRcfile(rcfileContent: string, tempDir: string): string { + const rcfile = join(tempDir, 'bash-osc133-rcfile') + writeFileSync(rcfile, rcfileContent) + + const result = spawnSync( + 'bash', + ['-lc', 'bash --noprofile --rcfile "$1" -i 2>&1', 'bash', rcfile], + { + input: 'true\nfalse\nexit 0\n', + encoding: 'utf8', + env: { + ...process.env, + HOME: tempDir, + ORCA_SHELL_FEATURES: 'ready', + TERM: process.env.TERM || 'xterm' + }, + timeout: 5000 + } + ) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + return result.stdout +} + +export function expectBashOsc133Lifecycle(output: string): void { + const oscA = '\x1b]133;A\x07' + const oscC = '\x1b]133;C\x07' + const oscD = '\x1b]133;D;' + const firstPromptMarker = output.indexOf(oscA) + + expect(firstPromptMarker).toBeGreaterThanOrEqual(0) + expect(output.slice(0, firstPromptMarker)).not.toContain(oscC) + expect(output.slice(0, firstPromptMarker)).not.toContain(oscD) + expect(output).toContain(`${oscD}0\x07${oscA}`) + expect(output).toContain(`${oscD}1\x07${oscA}`) + expect(output.split(oscC)).toHaveLength(4) + expect(output.split(oscD)).toHaveLength(3) +} diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 34cf9134826..b276b3cb0ba 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -240,6 +240,9 @@ export class DaemonServer { this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {}) this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess, + // Why a closure: this.log is assigned after this constructor call, and the + // callback only fires once a session is live. + reportReadinessEvent: (event, details) => this.log.log(event, details), // Why host-level and not the attach callback: a session whose client transport already dropped // has no attachment left to fire exit bookkeeping, and the daemon must still notice it can idle. onSessionReaped: (sessionId) => { diff --git a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts new file mode 100644 index 00000000000..2e6b3e19613 --- /dev/null +++ b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts @@ -0,0 +1,24 @@ +/** + * The wrapper files the daemon launches shells with, built against a + * caller-supplied root so the tree can be content-addressed (see + * shell-wrapper-content-address.ts). + */ +import { join } from 'node:path' +import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' +import type { ShellWrapperFile } from '../shell-wrapper-file-writer' +import { buildZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder' +import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' +import { getDaemonZshWrapperSpec } from './daemon-zsh-shell-ready-wrapper-spec' + +export function buildDaemonShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { + const zshDir = join(root, 'zsh') + const zsh = buildZshStartupWrapperFiles(getDaemonZshWrapperSpec(zshDir)) + return [ + [join(zshDir, '.zshenv'), zsh.zshenv], + [join(zshDir, '.zprofile'), zsh.zprofile], + [join(zshDir, '.zshrc'), zsh.zshrc], + [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], + [join(root, 'bash', 'rcfile'), getDaemonBashShellReadyRcfileContent()] + ] +} diff --git a/src/main/daemon/session-options.ts b/src/main/daemon/session-options.ts index 412c47fdd1d..1e2d38814d4 100644 --- a/src/main/daemon/session-options.ts +++ b/src/main/daemon/session-options.ts @@ -12,6 +12,9 @@ export type SessionOptions = { subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number + /** Reports a readiness outcome worth diagnosing to the daemon's file log. + * Why not console: the detached daemon runs with stdio 'ignore'. */ + reportReadinessEvent?: (event: string, details: Record) => void historySeedChunks?: readonly string[] scrollback?: number wslDistro?: string diff --git a/src/main/daemon/session-shell-ready-barrier.ts b/src/main/daemon/session-shell-ready-barrier.ts index ccc79050852..e7562e23224 100644 --- a/src/main/daemon/session-shell-ready-barrier.ts +++ b/src/main/daemon/session-shell-ready-barrier.ts @@ -15,6 +15,7 @@ import { } from '../shell-prompt-readiness-probe' import type { HeadlessEmulator } from './headless-emulator' import type { SubprocessHandle } from './session-subprocess-handle' +import { basename } from 'node:path' import type { ShellReadyState } from './types' const SHELL_READY_TIMEOUT_MS = 15_000 @@ -30,6 +31,10 @@ export type SessionShellReadyBarrierDeps = { installDeviceAttributesFilter(): void releaseDeviceAttributesFilter(): void acceptStartupIngress(data: string): void + /** Reports a readiness outcome worth diagnosing. Why injected rather than + * console: the daemon runs detached with stdio 'ignore', so console output + * goes nowhere -- the only durable sink is the daemon's NDJSON file log. */ + reportReadinessEvent?(event: string, details: Record): void } /** The startup gate every byte of PTY output passes through: it strips the shell-ready marker, holds @@ -183,11 +188,37 @@ export class SessionShellReadyBarrier { this.postReadyFlushGate.arm(postMarkerBytesObserved) } + /** Why the shell basename and not its path: the path can carry a home dir, and + * the basename is all a diagnosis needs. The session id is already logged by + * the daemon's session lifecycle events, so it is only correlation here. */ + private reportReadiness(event: string, details: Record): void { + const shellPath = this.deps.subprocess.shellPath + try { + this.deps.reportReadinessEvent?.(event, { + sessionId: this.deps.sessionId, + shell: shellPath ? basename(shellPath) : 'unknown', + ...details + }) + } catch { + // Why swallow: this runs before the state transition that releases held + // PTY bytes and flushes queued stdin, and the ready timer is already + // cleared. A throwing sink must never strand the barrier in `pending` + // with nothing left to wake it -- diagnostics cannot break the terminal. + } + } + private onShellReadyTimeout(): void { this.readyTimer = null if (this._state !== 'pending') { return } + // Why report: this path costs every startup command the full timeout, and it + // used to fail silently -- a wrapper that never emits the marker looks + // identical to a slow shell. Name the shell so the next report can be + // diagnosed from the log alone. + this.reportReadiness('shell-ready-timeout', { + timeoutMs: this.deps.shellReadyTimeoutMs ?? SHELL_READY_TIMEOUT_MS + }) this._state = 'timed_out' this.disposePromptReadinessProbe() this.releaseDeviceAttributes() @@ -199,9 +230,8 @@ export class SessionShellReadyBarrier { if (this._state !== 'pending') { return } - console.warn( - `[Session] ${this.deps.sessionId}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.` - ) + // Same sink as the timeout above: console goes nowhere in the detached daemon. + this.reportReadiness('shell-ready-wrapper-replaced', {}) this.releaseHeldBytes() this.transitionToReady(true) this.releaseDeviceAttributes() diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 01c7cf7eb3b..6686959a07f 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -114,9 +114,11 @@ describe('Session', () => { } ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl' wslDistro?: string + reportReadinessEvent?: (event: string, details: Record) => void }): Session { session = new Session({ sessionId: 'test-session', + ...(opts?.reportReadinessEvent ? { reportReadinessEvent: opts.reportReadinessEvent } : {}), cols: opts?.cols ?? 80, rows: opts?.rows ?? 24, ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), @@ -564,6 +566,45 @@ describe('Session', () => { expect(subprocess.written).toEqual(['waiting input']) }) + // Why this matters: the detached daemon runs with stdio 'ignore', so a + // console.warn here reaches nobody. This path costs every startup command + // the full timeout, and diagnosing it from a silent log is what made the + // original report expensive -- so it has to reach the daemon's file log. + it('reports the timeout to the daemon log rather than the void', () => { + const events: { event: string; details: Record }[] = [] + createSession({ + shellReadySupported: true, + reportReadinessEvent: (event, details) => events.push({ event, details }) + }) + + vi.advanceTimersByTime(15_000) + + expect(events).toHaveLength(1) + expect(events[0]?.event).toBe('shell-ready-timeout') + expect(events[0]?.details).toMatchObject({ sessionId: 'test-session', timeoutMs: 15_000 }) + // Why a basename: the shell path can carry a home dir, and the basename is + // all a diagnosis needs. + expect(String(events[0]?.details.shell)).not.toContain('/') + }) + + // Why: the report runs before the transition that releases held PTY bytes and + // flushes queued stdin, and the ready timer is already cleared by then. A + // throwing sink must not leave the barrier stuck in `pending` forever. + it('still releases the barrier when the diagnostic sink throws', () => { + createSession({ + shellReadySupported: true, + reportReadinessEvent: () => { + throw new Error('log sink unavailable') + } + }) + session.write('waiting input') + + vi.advanceTimersByTime(15_000) + + expect(session.shellState).toBe('timed_out' satisfies ShellReadyState) + expect(subprocess.written).toEqual(['waiting input']) + }) + it('honors a shorter shell-ready timeout for Codex startup sessions', () => { createSession({ shellReadySupported: true, shellReadyTimeoutMs: 300 }) session.write('codex\n') diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index be169a0f5aa..a6e5d4a3210 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -67,6 +67,7 @@ export class Session { subprocess: this.subprocess, responderParser: this.output.responderParser, shellReadySupported: opts.shellReadySupported, + ...(opts.reportReadinessEvent ? { reportReadinessEvent: opts.reportReadinessEvent } : {}), shellReadyTimeoutMs: opts.shellReadyTimeoutMs, installDeviceAttributesFilter: () => this.output.installDeviceAttributesFilter(), releaseDeviceAttributesFilter: () => this.output.releaseDeviceAttributesFilter(), diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts new file mode 100644 index 00000000000..0fbdce87029 --- /dev/null +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' +import { + OVERLAY_ONLY_FEATURES, + STARTUP_COMMAND_FEATURES +} from '../shell-startup-launch-intent-fixtures' +import { + expectBashOsc133Lifecycle, + runInteractiveBashRcfile +} from './daemon-bash-wrapper-osc133-fixture' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from './shell-ready' + +async function importFreshShellReady() { + vi.resetModules() + const module = await import('./shell-ready') + return { + ...module, + getShellReadyLaunchConfig: (shell: string) => + module.getShellLaunchConfig(shell, STARTUP_COMMAND_FEATURES), + getMarkerlessShellLaunchConfig: (shell: string) => + module.getShellLaunchConfig(shell, OVERLAY_ONLY_FEATURES) + } +} + +async function importFreshDaemonBashRcfile(): Promise { + vi.resetModules() + return import('./daemon-bash-shell-ready-rcfile') +} + +const describePosix = process.platform === 'win32' ? describe.skip : describe +const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 +const itWithBash = hasBash ? it : it.skip + +// Split out of shell-ready.test.ts, which sits at the max-lines cap for tests. +describePosix('daemon shell-ready bash wrapper', () => { + let userDataPath: string + let previousUserDataPath: string | undefined + + beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'daemon-shell-ready-bash-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + + afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. + it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => { + const { getShellReadyLaunchConfig } = await importFreshShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + getShellReadyLaunchConfig('/bin/bash') + + // Why .zshenv: the zsh markers live in the epilogue, behind `markers`. + const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') + const bashRc = readFileSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'), 'utf8') + + expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') + expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') + expect(bashRc).toContain('printf "\\033]133;C\\007"') + expect(bashRc).toContain('__orca_prepend_prompt_command "__orca_osc133_precmd"') + expect(bashRc).toContain('__orca_append_prompt_command "__orca_osc133_epilogue"') + // DEBUG is armed after PROMPT_COMMAND setup so rcfile commands aren't seen as foreground; lastIndexOf skips the epilogue's re-arm. + expect(bashRc.lastIndexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan( + bashRc.indexOf('__orca_append_prompt_command "__orca_osc133_epilogue"') + ) + expect(zshrc).toContain('printf "\\033]133;D;%s\\007"') + expect(zshrc).toContain('printf "\\033]133;C\\007"') + }) + + itWithBash( + 'runs the daemon bash wrapper without fake C/D markers before the first prompt', + async () => { + const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() + + const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) + + expectBashOsc133Lifecycle(output) + } + ) + + itWithBash( + 'preserves prompt hooks and existing DEBUG traps without fake command markers', + async () => { + const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() + writeFileSync( + join(userDataPath, '.bash_profile'), + [ + 'PROMPT_COMMAND=\'AFTER_FIRST_PROMPT=1; printf "PROMPT_HOOK\\n"\'', + 'trap \'if [[ -n "${AFTER_FIRST_PROMPT:-}" ]]; then\n printf "USER_DEBUG_AFTER\\n"\nfi\' DEBUG' + ].join('\n') + ) + + const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) + + expect(output).toContain('PROMPT_HOOK') + expect(output).toContain('USER_DEBUG_AFTER') + expectBashOsc133Lifecycle(output) + } + ) + + itWithBash( + 'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt', + async () => { + const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() + // Minimal bash-preexec imitation: re-arms its own DEBUG trap from PROMPT_COMMAND at first prompt, silencing Orca's trap. + writeFileSync( + join(userDataPath, '.bash_profile'), + [ + 'preexec_functions=()', + '__bp_preexec_invoke_exec() {', + ' [[ -n "${__bp_interactive_mode:-}" ]] || return', + ' __bp_interactive_mode=""', + ' local f', + ' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done', + '}', + "__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }", + 'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"' + ].join('\n') + ) + + const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) + + expectBashOsc133Lifecycle(output) + } + ) + + itWithBash( + 'dispatches a non-empty preexec_functions against the real command, not Orca hooks', + async () => { + const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() + // Why: the epilogue chains bash-preexec's re-armed DEBUG trap, so a real preexec callback must fire against the user's command. + writeFileSync( + join(userDataPath, '.bash_profile'), + [ + 'preexec_functions=(__user_preexec)', + '__user_preexec() { printf \'USER_PREEXEC:%s\\n\' "$1"; }', + '__bp_inside=0', + '__bp_last_hist=""', + '__bp_preexec_invoke_exec() {', + ' (( __bp_inside > 0 )) && return', + ' [[ -n "${__bp_interactive_mode:-}" ]] || return', + ' local __bp_inside=1', + ' local this_command', + ' this_command="$(builtin history 1)"', + ' this_command="${this_command#"${this_command%%[![:space:]]*}"}"', + ' this_command="${this_command#* }"', + ' this_command="${this_command#"${this_command%%[![:space:]]*}"}"', + ' [[ -n "$this_command" && "$this_command" != "$__bp_last_hist" ]] || return', + ' __bp_last_hist="$this_command"', + ' __bp_interactive_mode=""', + ' local f', + ' for f in "${preexec_functions[@]}"; do "$f" "$this_command"; done', + '}', + "__bp_arm() { set -o functrace; __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }", + 'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"' + ].join('\n') + ) + + const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) + + expectBashOsc133Lifecycle(output) + expect(output).toContain('USER_PREEXEC:true') + expect(output).toContain('USER_PREEXEC:false') + expect(output).not.toContain('USER_PREEXEC:__orca_osc133') + expect(output).not.toContain('USER_PREEXEC:__bp_') + } + ) + + itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => { + const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() + writeFileSync( + join(userDataPath, '.bash_profile'), + 'PROMPT_COMMAND=(\'printf "PROMPT_ARRAY_A\\n"\' \'printf "PROMPT_ARRAY_B\\n"; \')\n' + ) + + const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) + + expect(output.split('PROMPT_ARRAY_A')).toHaveLength(4) + expect(output.split('PROMPT_ARRAY_B')).toHaveLength(4) + expectBashOsc133Lifecycle(output) + }) +}) diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 3bcbb6aa085..b680cb06954 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -3,7 +3,6 @@ import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' import { OVERLAY_ONLY_FEATURES, STARTUP_COMMAND_FEATURES @@ -17,6 +16,8 @@ import { scanShellStartupOutput } from '../shell-startup-output-scanner' import { HeadlessEmulator } from './headless-emulator' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from './shell-ready' async function importFreshShellReady() { vi.resetModules() @@ -30,14 +31,7 @@ async function importFreshShellReady() { } } -async function importFreshDaemonBashRcfile(): Promise { - vi.resetModules() - return import('./daemon-bash-shell-ready-rcfile') -} - const describePosix = process.platform === 'win32' ? describe.skip : describe -const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 -const itWithBash = hasBash ? it : it.skip const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 const itWithZsh = hasZsh ? it : it.skip const FISH = resolveFishBinary() @@ -138,46 +132,6 @@ async function runInteractiveZshRc(args: { return output } -function runInteractiveBashRcfile(rcfileContent: string, tempDir: string): string { - const rcfile = join(tempDir, 'bash-osc133-rcfile') - writeFileSync(rcfile, rcfileContent) - - const result = spawnSync( - 'bash', - ['-lc', 'bash --noprofile --rcfile "$1" -i 2>&1', 'bash', rcfile], - { - input: 'true\nfalse\nexit 0\n', - encoding: 'utf8', - env: { - ...process.env, - HOME: tempDir, - ORCA_SHELL_FEATURES: 'ready', - TERM: process.env.TERM || 'xterm' - }, - timeout: 5000 - } - ) - - expect(result.error).toBeUndefined() - expect(result.status).toBe(0) - return result.stdout -} - -function expectBashOsc133Lifecycle(output: string): void { - const oscA = '\x1b]133;A\x07' - const oscC = '\x1b]133;C\x07' - const oscD = '\x1b]133;D;' - const firstPromptMarker = output.indexOf(oscA) - - expect(firstPromptMarker).toBeGreaterThanOrEqual(0) - expect(output.slice(0, firstPromptMarker)).not.toContain(oscC) - expect(output.slice(0, firstPromptMarker)).not.toContain(oscD) - expect(output).toContain(`${oscD}0\x07${oscA}`) - expect(output).toContain(`${oscD}1\x07${oscA}`) - expect(output.split(oscC)).toHaveLength(4) - expect(output.split(oscD)).toHaveLength(3) -} - function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { expect(content).toContain('export ZDOTDIR="$_orca_home"') expect(content).toContain(`source "$_orca_home/${fileName}"`) @@ -226,7 +180,7 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/bash') - const rcfile = join(userDataPath, 'shell-ready', 'bash', 'rcfile') + const rcfile = join(getShellReadyWrapperRoot(), 'bash', 'rcfile') expect(config.args).toEqual(['--rcfile', rcfile]) expect(existsSync(rcfile)).toBe(true) @@ -234,7 +188,7 @@ describePosix('daemon shell-ready launch config', () => { it('rewrites wrappers when a long-lived daemon finds a missing rcfile', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() - const rcfile = join(userDataPath, 'shell-ready', 'bash', 'rcfile') + const rcfile = join(getShellReadyWrapperRoot(), 'bash', 'rcfile') getShellReadyLaunchConfig('/bin/bash') rmSync(rcfile) @@ -250,8 +204,8 @@ describePosix('daemon shell-ready launch config', () => { const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.args).toEqual(['-l']) - expect(config.env.ZDOTDIR).toBe(join(userDataPath, 'shell-ready', 'zsh')) - expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) + expect(config.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'))).toBe(true) }) it('extends the startup barrier to fish so launch commands queue until the prompt', async () => { @@ -492,10 +446,10 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') + const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') + const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') + const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') @@ -524,7 +478,7 @@ describePosix('daemon shell-ready launch config', () => { // Why .zshenv: the widget registration lives in the epilogue, which .zlogin // (login) and .zshrc (non-login) both call exactly once. - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') @@ -649,9 +603,9 @@ describePosix('daemon shell-ready launch config', () => { // Why one file: every restore now lives in the single epilogue in .zshenv, // which .zshrc and .zlogin each invoke on their own startup path. - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') const zlogin = zshrc - const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') + const bashRc = readFileSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'), 'utf8') const restoreLine = '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' const mimoRestoreLine = @@ -689,143 +643,6 @@ describePosix('daemon shell-ready launch config', () => { } }) - // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. - it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => { - const { getShellReadyLaunchConfig } = await importFreshShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - getShellReadyLaunchConfig('/bin/bash') - - // Why .zshenv: the zsh markers live in the epilogue, behind `markers`. - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') - const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') - - expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') - expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(bashRc).toContain('printf "\\033]133;C\\007"') - expect(bashRc).toContain('__orca_prepend_prompt_command "__orca_osc133_precmd"') - expect(bashRc).toContain('__orca_append_prompt_command "__orca_osc133_epilogue"') - // DEBUG is armed after PROMPT_COMMAND setup so rcfile commands aren't seen as foreground; lastIndexOf skips the epilogue's re-arm. - expect(bashRc.lastIndexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan( - bashRc.indexOf('__orca_append_prompt_command "__orca_osc133_epilogue"') - ) - expect(zshrc).toContain('printf "\\033]133;D;%s\\007"') - expect(zshrc).toContain('printf "\\033]133;C\\007"') - }) - - itWithBash( - 'runs the daemon bash wrapper without fake C/D markers before the first prompt', - async () => { - const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() - - const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) - - expectBashOsc133Lifecycle(output) - } - ) - - itWithBash( - 'preserves prompt hooks and existing DEBUG traps without fake command markers', - async () => { - const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() - writeFileSync( - join(userDataPath, '.bash_profile'), - [ - 'PROMPT_COMMAND=\'AFTER_FIRST_PROMPT=1; printf "PROMPT_HOOK\\n"\'', - 'trap \'if [[ -n "${AFTER_FIRST_PROMPT:-}" ]]; then\n printf "USER_DEBUG_AFTER\\n"\nfi\' DEBUG' - ].join('\n') - ) - - const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) - - expect(output).toContain('PROMPT_HOOK') - expect(output).toContain('USER_DEBUG_AFTER') - expectBashOsc133Lifecycle(output) - } - ) - - itWithBash( - 'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt', - async () => { - const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() - // Minimal bash-preexec imitation: re-arms its own DEBUG trap from PROMPT_COMMAND at first prompt, silencing Orca's trap. - writeFileSync( - join(userDataPath, '.bash_profile'), - [ - 'preexec_functions=()', - '__bp_preexec_invoke_exec() {', - ' [[ -n "${__bp_interactive_mode:-}" ]] || return', - ' __bp_interactive_mode=""', - ' local f', - ' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done', - '}', - "__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }", - 'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"' - ].join('\n') - ) - - const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) - - expectBashOsc133Lifecycle(output) - } - ) - - itWithBash( - 'dispatches a non-empty preexec_functions against the real command, not Orca hooks', - async () => { - const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() - // Why: the epilogue chains bash-preexec's re-armed DEBUG trap, so a real preexec callback must fire against the user's command. - writeFileSync( - join(userDataPath, '.bash_profile'), - [ - 'preexec_functions=(__user_preexec)', - '__user_preexec() { printf \'USER_PREEXEC:%s\\n\' "$1"; }', - '__bp_inside=0', - '__bp_last_hist=""', - '__bp_preexec_invoke_exec() {', - ' (( __bp_inside > 0 )) && return', - ' [[ -n "${__bp_interactive_mode:-}" ]] || return', - ' local __bp_inside=1', - ' local this_command', - ' this_command="$(builtin history 1)"', - ' this_command="${this_command#"${this_command%%[![:space:]]*}"}"', - ' this_command="${this_command#* }"', - ' this_command="${this_command#"${this_command%%[![:space:]]*}"}"', - ' [[ -n "$this_command" && "$this_command" != "$__bp_last_hist" ]] || return', - ' __bp_last_hist="$this_command"', - ' __bp_interactive_mode=""', - ' local f', - ' for f in "${preexec_functions[@]}"; do "$f" "$this_command"; done', - '}', - "__bp_arm() { set -o functrace; __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }", - 'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"' - ].join('\n') - ) - - const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) - - expectBashOsc133Lifecycle(output) - expect(output).toContain('USER_PREEXEC:true') - expect(output).toContain('USER_PREEXEC:false') - expect(output).not.toContain('USER_PREEXEC:__orca_osc133') - expect(output).not.toContain('USER_PREEXEC:__bp_') - } - ) - - itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => { - const { getDaemonBashShellReadyRcfileContent } = await importFreshDaemonBashRcfile() - writeFileSync( - join(userDataPath, '.bash_profile'), - 'PROMPT_COMMAND=(\'printf "PROMPT_ARRAY_A\\n"\' \'printf "PROMPT_ARRAY_B\\n"; \')\n' - ) - - const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) - - expect(output.split('PROMPT_ARRAY_A')).toHaveLength(4) - expect(output.split('PROMPT_ARRAY_B')).toHaveLength(4) - expectBashOsc133Lifecycle(output) - }) - it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => { // Why: only the wrapper self-loop should be rejected; a real user ZDOTDIR must round-trip so their configs load. const previousZdotdir = process.env.ZDOTDIR @@ -917,7 +734,7 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('unset ZDOTDIR') expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ZSHENV') @@ -933,7 +750,7 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') // Save spawn-env value before sourcing user .zshenv expect(zshenv).toContain('_orca_user_zdotdir="$_orca_resolved_config_dir"') diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index a04965e27f5..277817b9c61 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -7,46 +7,54 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' -import { - getFishShellReadyInitCommand, - ZSH_WRAPPER_DIR_MARKER_CONTENT, - ZSH_WRAPPER_DIR_MARKER_FILE -} from '../shell-templates' +import { getFishShellReadyInitCommand } from '../shell-templates' import { encodeShellStartupFeatures, SHELL_STARTUP_FEATURE_ENV, type ShellStartupFeature } from '../shell-startup-features' +import { resolveShellWrapperRoot } from '../shell-wrapper-content-address' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' +import { buildDaemonShellReadyWrapperFiles } from './daemon-shell-ready-wrapper-fileset' import { resolveInheritedZdotdir, resolveInheritedZshenvSourceDir } from '../zsh-wrapper-dir-ownership' -import { buildZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' -import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' -import { getDaemonZshWrapperSpec } from './daemon-zsh-shell-ready-wrapper-spec' const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH' -let didEnsureShellReadyWrappers = false - -function getShellReadyWrapperRoot(): string { +function getShellReadyWrapperBaseDir(): string { const userDataPath = process.env[ORCA_USER_DATA_PATH_ENV] - // Why: older/test launchers may not seed ORCA_USER_DATA_PATH. Keep a - // fallback so daemon startup does not fail before the parent can be fixed. - return join(userDataPath || tmpdir(), userDataPath ? 'shell-ready' : 'orca-shell-ready') + // Why a base dir of its own rather than the legacy `shell-ready/`: daemons of + // older builds still write that path unconditionally, so leaving it to them + // keeps this build's content-addressed trees out of their reach. + // Why the tmpdir fallback: older/test launchers may not seed + // ORCA_USER_DATA_PATH, and daemon startup must not fail before the parent can + // be fixed. It is dev/test-only -- daemon-init always passes the real path -- + // which matters because the presence check is size-only, so a complete tree + // pre-planted under a shared /tmp would be trusted rather than overwritten. + return join(userDataPath || tmpdir(), userDataPath ? 'shell-wrappers' : 'orca-shell-wrappers') +} + +// Why memoized and keyed on the base dir: the digest is stable for a given base +// dir, every shell launch asks for it, and the key self-invalidates if +// ORCA_USER_DATA_PATH is ever re-pointed mid-process. +let cachedShellReadyWrapperRoot: { baseDir: string; root: string } | null = null + +export function getShellReadyWrapperRoot(): string { + const baseDir = getShellReadyWrapperBaseDir() + if (cachedShellReadyWrapperRoot?.baseDir !== baseDir) { + cachedShellReadyWrapperRoot = { + baseDir, + root: resolveShellWrapperRoot(baseDir, buildDaemonShellReadyWrapperFiles) + } + } + return cachedShellReadyWrapperRoot.root } function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] { - return [ - join(root, 'zsh', '.zshenv'), - join(root, 'zsh', '.zprofile'), - join(root, 'zsh', '.zshrc'), - join(root, 'zsh', '.zlogin'), - join(root, 'zsh', ZSH_WRAPPER_DIR_MARKER_FILE), - join(root, 'bash', 'rcfile') - ] + return buildDaemonShellReadyWrapperFiles(root).map(([path]) => path) } // Why non-empty and not just present: a partial write leaves a zero-byte @@ -66,31 +74,23 @@ function ensureShellReadyWrappers(): boolean { if (process.platform === 'win32') { return false } - if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) { - return true - } - didEnsureShellReadyWrappers = true - const root = getShellReadyWrapperRoot() - const zshDir = join(root, 'zsh') - const zsh = buildZshStartupWrapperFiles(getDaemonZshWrapperSpec(zshDir)) - - const written = writeShellWrapperFiles( - [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], - [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], - [join(root, 'bash', 'rcfile'), getDaemonBashShellReadyRcfileContent()] - ], - '[daemon/shell-ready]' - ) - if (!written || !shellReadyWrappersExist()) { - // Why reset: the next launch retries instead of trusting a half-written tree. - didEnsureShellReadyWrappers = false - return false + // Why existence alone decides, with no per-process flag: the root is keyed by + // a hash of the exact bytes we would write, so a tree that is present and + // non-empty is a tree this build wrote. Rewriting it would replace a live file + // on the terminal-spawn path for no gain. + if (!shellReadyWrappersExist()) { + const written = writeShellWrapperFiles( + buildDaemonShellReadyWrapperFiles(root), + '[daemon/shell-ready]' + ) + if (!written || !shellReadyWrappersExist()) { + // Why no flag to reset: the next launch re-checks the files themselves, so + // a half-written tree is retried without any extra bookkeeping. + return false + } } + return true } diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index e8a61e5ed4c..9bb5ff48c0f 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -23,6 +23,10 @@ export type TerminalHostOptions = { }) => SubprocessHandle | Promise // Why: login-session death detection (#7936) needs subprocess exits even when no client is attached. onSessionReaped?: (sessionId: string) => void + /** Reports a shell-readiness outcome worth diagnosing. Why threaded rather + * than console: the detached daemon runs with stdio 'ignore', so the only + * durable sink is its NDJSON file log. */ + reportReadinessEvent?: (event: string, details: Record) => void // Why: graceful shutdown checkpoints must finish in-process before teardown. onFinalCheckpoint?: ( sessionId: string, diff --git a/src/main/daemon/terminal-host-readiness-reporting.test.ts b/src/main/daemon/terminal-host-readiness-reporting.test.ts new file mode 100644 index 00000000000..00cebd99a1a --- /dev/null +++ b/src/main/daemon/terminal-host-readiness-reporting.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost } from './terminal-host' + +/** + * Why this test exists: the shell-ready barrier used to report a timeout with + * console.warn, and the daemon is spawned detached with stdio 'ignore', so that + * output reached nobody. The diagnostic looked present in code and was absent in + * production. Asserting it at the barrier alone would not have caught that -- + * the callback has to survive every hop out to the daemon's file log. + */ +function createSubprocess(): SubprocessHandle & { exit: () => void } { + let onExit: ((code: number) => void) | null = null + return { + pid: 4242, + shellPath: '/opt/homebrew/bin/zsh', + getForegroundProcess: () => 'zsh', + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + forceKill: vi.fn(), + signal: vi.fn(), + onData: () => {}, + onExit: (listener) => { + onExit = listener + }, + dispose: vi.fn(), + exit: () => onExit?.(0) + } as SubprocessHandle & { exit: () => void } +} + +describe('TerminalHost readiness reporting', () => { + let host: TerminalHost + let subprocess: ReturnType | undefined + const events: { event: string; details: Record }[] = [] + const spawnSubprocess = vi.fn(() => { + subprocess = createSubprocess() + return subprocess + }) + + beforeEach(() => { + vi.useFakeTimers() + events.length = 0 + spawnSubprocess.mockClear() + host = new TerminalHost({ + spawnSubprocess, + reportReadinessEvent: (event, details) => events.push({ event, details }) + }) + }) + + afterEach(async () => { + subprocess?.exit() + await host.dispose() + vi.useRealTimers() + }) + + it('delivers a shell-ready timeout out to the daemon log sink', async () => { + await host.createOrAttach({ + sessionId: 'session-readiness', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() }, + shellReadySupported: true + }) + + vi.advanceTimersByTime(15_000) + + expect(events.map((entry) => entry.event)).toContain('shell-ready-timeout') + const timeout = events.find((entry) => entry.event === 'shell-ready-timeout') + expect(timeout?.details).toMatchObject({ sessionId: 'session-readiness', timeoutMs: 15_000 }) + // Why a basename: the shell path can carry a home dir, and the daemon log is + // documented to hold terse fields only. + expect(timeout?.details.shell).toBe('zsh') + }) +}) diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index e8e7a8f2c82..1cb2a75f232 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -22,6 +22,7 @@ type TerminalHostSessionCreateDependencies = { onDeadSessionRemoved: (sessionId: string) => void onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void onSessionExit: (sessionId: string, generation: string | undefined) => void + reportReadinessEvent?: (event: string, details: Record) => void } export async function createOrAttachTerminalSession( @@ -121,6 +122,7 @@ async function spawnAndPublishSession( ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), wslDistro, onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration), + ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), ...(opts.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } : {}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 014e1f7c181..c8a57715b91 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -51,6 +51,7 @@ export class TerminalHost { private killedTombstones: TerminalHostTombstones private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] private onSessionReaped: TerminalHostOptions['onSessionReaped'] + private reportReadinessEvent: TerminalHostOptions['reportReadinessEvent'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] private maxTombstones: number private creationFenced = false @@ -61,6 +62,7 @@ export class TerminalHost { constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess this.onSessionReaped = opts.onSessionReaped + this.reportReadinessEvent = opts.reportReadinessEvent this.onFinalCheckpoint = opts.onFinalCheckpoint this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES this.killedTombstones = new TerminalHostTombstones(this.maxTombstones) @@ -110,6 +112,9 @@ export class TerminalHost { onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), onSessionCreated: (sessionId, generation, isAlive) => this.agentSessionGenerations.remember(sessionId, generation, isAlive), + ...(this.reportReadinessEvent + ? { reportReadinessEvent: this.reportReadinessEvent } + : {}), onSessionExit: (sessionId, generation) => { this.agentSessionOwners.release(sessionId, generation) this.agentSessionGenerations.forget(sessionId, generation) diff --git a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts index e7ff2532c63..240793eda1d 100644 --- a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts @@ -5,6 +5,9 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { isHiddenRendererPty } from './pty-hidden-delivery-gate' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { registerPtyHandlers } from './pty' +import { join } from 'node:path' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -414,7 +417,7 @@ describe('registerPtyHandlers', () => { ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config', // No `ready`: the fallback shell carries an overlay, not a startup command. ORCA_SHELL_FEATURES: 'overlay,history,markers', - ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh' + ZDOTDIR: join(getShellReadyWrapperRoot(), 'zsh') }) }) ) diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index de3706fd1f3..46b15d86bcb 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -9,6 +9,9 @@ import { posixOnlyIt, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import * as livePtyGate from '../claude-accounts/live-pty-gate' import { registerPtyHandlers, setLocalPtyProvider, getLocalPtyProvider } from './pty' +import { join } from 'node:path' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -87,7 +90,7 @@ describe('registerPtyHandlers', () => { SHELL: '/bin/zsh', ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config', ORCA_SHELL_FEATURES: 'overlay,history,markers', - ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh' + ZDOTDIR: join(getShellReadyWrapperRoot(), 'zsh') }) }) ) diff --git a/src/main/ipc/pty-login-shell-startup-commands.test.ts b/src/main/ipc/pty-login-shell-startup-commands.test.ts index 16d8ff4e093..3192d2e2cf3 100644 --- a/src/main/ipc/pty-login-shell-startup-commands.test.ts +++ b/src/main/ipc/pty-login-shell-startup-commands.test.ts @@ -9,6 +9,9 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { userInfo } from 'node:os' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' import { registerPtyHandlers } from './pty' +import { join } from 'node:path' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -120,7 +123,7 @@ describe('registerPtyHandlers', () => { expect(args).toEqual(['-l']) expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') - expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh') + expect(options.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) expect(options.env.ORCA_SHELL_FEATURES).not.toContain('ready') } finally { Object.defineProperty(process, 'platform', { @@ -161,7 +164,7 @@ describe('registerPtyHandlers', () => { expect(options.env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent') expect(options.env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(options.env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') - expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh') + expect(options.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) expect(options.env.ORCA_SHELL_FEATURES).not.toContain('ready') } finally { Object.defineProperty(process, 'platform', { diff --git a/src/main/ipc/pty-wsl-cwd-validation.test.ts b/src/main/ipc/pty-wsl-cwd-validation.test.ts index 9eaa54ebedc..93cd869bee0 100644 --- a/src/main/ipc/pty-wsl-cwd-validation.test.ts +++ b/src/main/ipc/pty-wsl-cwd-validation.test.ts @@ -8,6 +8,9 @@ import { import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { _setWslCachesForTests } from '../wsl' import { registerPtyHandlers } from './pty' +import { join } from 'node:path' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -351,7 +354,7 @@ describe('registerPtyHandlers', () => { }) expect(shell).toBe('/bin/zsh') expect(args).toEqual(['-l']) - expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh') + expect(options.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) expect(options.env.ORCA_ORIG_ZDOTDIR).toBe(process.env.HOME) } finally { Object.defineProperty(process, 'platform', { diff --git a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts index 016103e76c1..5f1eb6a9cf3 100644 --- a/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts +++ b/src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts @@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process' import { getShellLaunchConfig } from '../../local-pty-shell-ready' import { selectShellStartupFeatures } from '../../../shell-startup-features' import { escapeRegex } from '../../../../shared/string-utils' +import { getShellReadyWrapperRoot } from '../../local-pty-shell-ready-wrapper-root' const RUN_MARKER = /^[ \t]*#[ \t]*Run:.*$/m @@ -144,7 +145,11 @@ function normalizeOutput( return output } - const wrapperDir = join(ctx.userDataPath, 'shell-ready', ctx.shellName) + // Why resolved rather than rebuilt: the wrapper tree is content-addressed, so + // rebuilding it from userDataPath yields a directory that never appears in the + // output and the placeholder silently stops substituting -- which would bake a + // machine-specific, hash-bearing path into the next inline snapshot. + const wrapperDir = join(getShellReadyWrapperRoot(), ctx.shellName) const paths: { path: string; placeholder: string }[] = [ { path: wrapperDir, placeholder: '' }, diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 77c21d39a5f..4259e73e59c 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -8,7 +8,7 @@ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-co import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' -import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-wrapper-root' +import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' export function getBashShellReadyRcfileContent(): string { return `# Orca bash shell-ready wrapper diff --git a/src/main/providers/local-pty-shell-ready-marker.ts b/src/main/providers/local-pty-shell-ready-marker.ts new file mode 100644 index 00000000000..37c440d6274 --- /dev/null +++ b/src/main/providers/local-pty-shell-ready-marker.ts @@ -0,0 +1,8 @@ +/** + * The marker the local shell-ready wrappers emit once startup files finish. + * + * Why its own module: the wrapper file-set builder needs it, and so do the + * rcfile templates that builder pulls in. Keeping it as a leaf lets the wrapper + * root import the builder (to hash it) without a cycle. + */ +export const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts new file mode 100644 index 00000000000..1851a42e1a8 --- /dev/null +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -0,0 +1,50 @@ +/** + * The wrapper files main's local PTY path launches shells with, built against a + * caller-supplied root so the tree can be content-addressed (see + * shell-wrapper-content-address.ts). + */ +import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' +import type { ShellWrapperFile } from '../shell-wrapper-file-writer' +import { + buildZshStartupWrapperFiles, + type ZshStartupWrapperSpec +} from '../zsh-startup-wrapper-builder' +import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' +import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' + +export function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { + return { + headerLabel: 'Orca zsh shell-ready wrapper', + zshDir, + zshenvStrategy: 'discover-user-zdotdir', + readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, + osc133CommandMarkers: true, + skipUserZshrcWhenHomeIsWrapperDir: true, + overlayRestoreComment: + "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", + restores: { + agentTeamsPath: true, + remoteCliBinDir: false, + codexHome: true, + codexLaunchPreflight: true + } + } +} + +// Why `/` concatenation rather than path.join: these paths are baked into +// .zshenv as a shell literal and handed to bash as `--rcfile`, and the launch +// config builds the matching values the same way (local-pty-shell-ready.ts). +// path.join would emit backslashes on Windows, where a shell literal reads them +// as escapes -- and would desync the written path from the launched one. +export function buildLocalShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { + const zshDir = `${root}/zsh` + const zsh = buildZshStartupWrapperFiles(getLocalZshWrapperSpec(zshDir)) + return [ + [`${zshDir}/.zshenv`, zsh.zshenv], + [`${zshDir}/.zprofile`, zsh.zprofile], + [`${zshDir}/.zshrc`, zsh.zshrc], + [`${zshDir}/.zlogin`, zsh.zlogin], + [`${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`, ZSH_WRAPPER_DIR_MARKER_CONTENT], + [`${root}/bash/rcfile`, getBashShellReadyRcfileContent()] + ] +} diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index 9be82c7b769..96e8c901f29 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { describePosix, importFreshLocalPtyShellReady, @@ -13,9 +13,57 @@ import { import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' import { getZshShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-generation' import { makeUserZdotdir } from '../zsh-user-config-dir-fixture' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' restoreUserDataPathAfterEach() +describe('ensureShellReadyWrappersAt', () => { + // Why: rewriting a byte-identical tree replaces a live file on the terminal + // spawn path for no gain -- and on Windows that is precisely the collision an + // indexer or antivirus turns into a failed write. The tree is + // content-addressed, so its presence already proves this build wrote it. + it('does not rewrite a tree that is already present', async () => { + const userData = mkdtempSync(join(tmpdir(), 'orca-warm-tree-')) + try { + setTestUserDataPath(userData) + const generation = await import('./local-pty-shell-ready-wrapper-generation') + const { getShellReadyWrapperRoot: resolveRoot } = + await import('./local-pty-shell-ready-wrapper-root') + expect(generation.ensureShellReadyWrappersAt()).toBe(true) + const rcfile = join(resolveRoot(), 'bash', 'rcfile') + const firstWrite = statSync(rcfile).mtimeMs + + generation.ensureShellReadyWrappersAt() + vi.resetModules() + const fresh = await import('./local-pty-shell-ready-wrapper-generation') + fresh.ensureShellReadyWrappersAt() + + expect(statSync(rcfile).mtimeMs).toBe(firstWrite) + } finally { + rmSync(userData, { recursive: true, force: true }) + } + }) + + it('regenerates a tree whose files went missing', async () => { + const userData = mkdtempSync(join(tmpdir(), 'orca-missing-tree-')) + try { + setTestUserDataPath(userData) + const generation = await import('./local-pty-shell-ready-wrapper-generation') + const { getShellReadyWrapperRoot: resolveRoot } = + await import('./local-pty-shell-ready-wrapper-root') + generation.ensureShellReadyWrappersAt() + const rcfile = join(resolveRoot(), 'bash', 'rcfile') + rmSync(rcfile) + + expect(generation.ensureShellReadyWrappersAt()).toBe(true) + expect(existsSync(rcfile)).toBe(true) + } finally { + rmSync(userData, { recursive: true, force: true }) + } + }) +}) + describe('shell-ready wrapper root resolution', () => { // Why: daemon-entry fork is plain Node (no electron), so the wrapper root resolves from ORCA_USER_DATA_PATH, not app.getPath. it('resolves the wrapper root from ORCA_USER_DATA_PATH', async () => { @@ -24,7 +72,7 @@ describe('shell-ready wrapper root resolution', () => { setTestUserDataPath(root) const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ZDOTDIR).toBe(`${root}/shell-ready/zsh`) + expect(config.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) } finally { rmSync(root, { recursive: true, force: true }) } @@ -240,10 +288,10 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') + const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') + const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') + const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') @@ -273,7 +321,7 @@ describePosix('local PTY shell-ready launch config', () => { // Why .zshenv: the widget registration lives in the epilogue, which .zlogin // (login) and .zshrc (non-login) both call exactly once. - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') @@ -281,7 +329,7 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshenv).not.toContain('add-zle-hook-widget line-init') // Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source. expect(zshenv).toContain('== "user:__orca_prompt_mark"') - expect(readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')).toContain( + expect(readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8')).toContain( '__orca_shell_epilogue' ) }) @@ -293,7 +341,7 @@ describePosix('local PTY shell-ready launch config', () => { // Why one file: every restore now lives in the single epilogue in .zshenv, // which .zshrc and .zlogin each invoke on their own startup path. - const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') const zlogin = zshrc const bashRc = getBashShellReadyRcfileContent() const restoreLine = @@ -520,7 +568,7 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('unset ZDOTDIR') expect(zshenv).toContain( @@ -540,7 +588,7 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') // Save spawn-env value before sourcing user .zshenv expect(zshenv.indexOf('_orca_user_zdotdir="$_orca_resolved_config_dir"')).toBeLessThan( @@ -557,7 +605,7 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') // Why: derive wrapper dir from %x, not env $ZDOTDIR — zsh corrupts non-ASCII usernames in its 0x84-0x9D token range. expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"') @@ -572,7 +620,7 @@ describePosix('local PTY shell-ready launch config', () => { 'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' + ' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' + 'else\n' + - ` export ZDOTDIR='${join(userDataPath, 'shell-ready', 'zsh')}'\n` + + ` export ZDOTDIR='${join(getShellReadyWrapperRoot(), 'zsh')}'\n` + 'fi' ) // Capture must happen before the wrapper unsets ZDOTDIR to source user files. diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts index 12f613893ed..8d39bcc028a 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts @@ -6,69 +6,37 @@ */ import { buildZshStartupWrapperFiles, - type ZshStartupWrapperFiles, - type ZshStartupWrapperSpec + type ZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' -import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' -import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' +import { + buildLocalShellReadyWrapperFiles, + getLocalZshWrapperSpec +} from './local-pty-shell-ready-wrapper-fileset' import { getShellReadyWrapperRoot, - shellReadyWrappersExist, - SHELL_READY_MARKER_ESCAPED + shellReadyWrappersExist } from './local-pty-shell-ready-wrapper-root' -let didEnsureShellReadyWrappers = false - -function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { - return { - headerLabel: 'Orca zsh shell-ready wrapper', - zshDir, - zshenvStrategy: 'discover-user-zdotdir', - readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, - osc133CommandMarkers: true, - skipUserZshrcWhenHomeIsWrapperDir: true, - overlayRestoreComment: - "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", - restores: { - agentTeamsPath: true, - remoteCliBinDir: false, - codexHome: true, - codexLaunchPreflight: true - } - } -} - export function getZshShellReadyWrapperFiles(): ZshStartupWrapperFiles { return buildZshStartupWrapperFiles(getLocalZshWrapperSpec(`${getShellReadyWrapperRoot()}/zsh`)) } /** True when every wrapper file is present and non-empty afterwards. */ export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): boolean { - if (didEnsureShellReadyWrappers && shellReadyWrappersExist(root)) { - return true + // Why existence alone decides, with no per-process flag: the root is keyed by + // a hash of the exact bytes we would write, so a tree that is present and + // non-empty is a tree this build wrote. Rewriting it would replace a live file + // on the terminal-spawn path for no gain. + if (!shellReadyWrappersExist(root)) { + const written = writeShellWrapperFiles(buildLocalShellReadyWrapperFiles(root), '[shell-ready]') + if (!written || !shellReadyWrappersExist(root)) { + // Why no flag to reset: the next launch re-checks the files themselves, so + // a half-written tree is retried without any extra bookkeeping. + return false + } } - didEnsureShellReadyWrappers = true - const zshDir = `${root}/zsh` - const zsh = buildZshStartupWrapperFiles(getLocalZshWrapperSpec(zshDir)) - - const written = writeShellWrapperFiles( - [ - [`${zshDir}/.zshenv`, zsh.zshenv], - [`${zshDir}/.zprofile`, zsh.zprofile], - [`${zshDir}/.zshrc`, zsh.zshrc], - [`${zshDir}/.zlogin`, zsh.zlogin], - [`${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`, ZSH_WRAPPER_DIR_MARKER_CONTENT], - [`${root}/bash/rcfile`, getBashShellReadyRcfileContent()] - ], - '[shell-ready]' - ) - if (!written || !shellReadyWrappersExist(root)) { - // Why reset: the next launch retries instead of trusting a half-written tree. - didEnsureShellReadyWrappers = false - return false - } return true } diff --git a/src/main/providers/local-pty-shell-ready-wrapper-root.ts b/src/main/providers/local-pty-shell-ready-wrapper-root.ts index e0163e1e8b2..bc8799f596e 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-root.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-root.ts @@ -3,26 +3,50 @@ * wrappers emit — the shared contract between wrapper generation and shell launch. */ import { tmpdir } from 'node:os' +import { join } from 'node:path' import { statSync } from 'node:fs' -import { ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' +import { resolveShellWrapperRoot } from '../shell-wrapper-content-address' +import { buildLocalShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-fileset' -export const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' +export { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' + +// Module-private, matching the daemon's twin: nothing outside needs the base. +function getShellReadyWrapperBaseDir(): string { + // Why: bundled into the daemon fork (no electron), so read ORCA_USER_DATA_PATH rather than electron's userData; main and the fork both set it to the same path. + // Why a truthiness test rather than `??`: a set-but-empty ORCA_USER_DATA_PATH + // would leave a relative base dir, so wrapper trees would be written under + // whatever the process cwd happens to be. + const userDataPath = process.env.ORCA_USER_DATA_PATH + // Why not the legacy `shell-ready/`: daemons of older builds still write that + // path unconditionally, so this build's trees live out of their reach. Why the + // fallback is namespaced: os.tmpdir() is a shared world-writable /tmp on + // Linux, where a generic name is one any local user can pre-create and own. + // Note the presence check is size-only, so a complete tree pre-planted under + // that fallback would be trusted rather than overwritten. Production never + // reaches it -- ORCA_USER_DATA_PATH is seeded before anything spawns and the + // daemon fork inherits it -- so this stays a documented trust boundary rather + // than an ownership check on the spawn path. + return userDataPath ? join(userDataPath, 'shell-wrappers') : join(tmpdir(), 'orca-shell-wrappers') +} + +// Why memoized: the digest is stable for a given base dir and every shell launch +// asks for it. Why keyed on the base dir rather than a bare flag: it +// self-invalidates if ORCA_USER_DATA_PATH is ever re-pointed mid-process. +let cachedShellReadyWrapperRoot: { baseDir: string; root: string } | null = null export function getShellReadyWrapperRoot(): string { - // Why: bundled into the daemon fork (no electron), so read ORCA_USER_DATA_PATH rather than electron's userData; main and the fork both set it to the same path. - const userDataPath = process.env.ORCA_USER_DATA_PATH ?? tmpdir() - return `${userDataPath}/shell-ready` + const baseDir = getShellReadyWrapperBaseDir() + if (cachedShellReadyWrapperRoot?.baseDir !== baseDir) { + cachedShellReadyWrapperRoot = { + baseDir, + root: resolveShellWrapperRoot(baseDir, buildLocalShellReadyWrapperFiles) + } + } + return cachedShellReadyWrapperRoot.root } export function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] { - return [ - `${root}/zsh/.zshenv`, - `${root}/zsh/.zprofile`, - `${root}/zsh/.zshrc`, - `${root}/zsh/.zlogin`, - `${root}/zsh/${ZSH_WRAPPER_DIR_MARKER_FILE}`, - `${root}/bash/rcfile` - ] + return buildLocalShellReadyWrapperFiles(root).map(([path]) => path) } /** diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts index c6b74b353e9..40ceb8333d7 100644 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts +++ b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts @@ -10,6 +10,8 @@ import { restoreUserDataPathAfterEach, setTestUserDataPath } from './local-pty-shell-ready-test-harness' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' restoreUserDataPathAfterEach() @@ -99,7 +101,12 @@ path=(/custom/bin $path) delete cleanEnv.ZDOTDIR delete cleanEnv.ORCA_ORIG_ZDOTDIR delete cleanEnv.USER_ZSHRC_LOADED - cleanEnv.ZDOTDIR = join(movedUserData, 'shell-ready', 'zsh') + // Why the prefix swap: the tree is content-addressed under the user data + // dir, so the relocated ZDOTDIR has to follow the resolved root. + cleanEnv.ZDOTDIR = join( + getShellReadyWrapperRoot().replace(userDataPath, movedUserData), + 'zsh' + ) // Cover both the WSL login shell (`exec zsh -l`) and the non-login local-pane flow so both restore paths stay pinned. for (const args of [['-i'], ['-l', '-i']] as const) { @@ -146,7 +153,12 @@ path=(/custom/bin $path) delete cleanEnv.ZDOTDIR delete cleanEnv.ORCA_ORIG_ZDOTDIR delete cleanEnv.USER_ZSHRC_LOADED - cleanEnv.ZDOTDIR = join(nonAsciiUserData, 'shell-ready', 'zsh') + // Why the prefix swap: the tree is content-addressed under the user data + // dir, so the relocated ZDOTDIR has to follow the resolved root. + cleanEnv.ZDOTDIR = join( + getShellReadyWrapperRoot().replace(userDataPath, nonAsciiUserData), + 'zsh' + ) for (const args of [['-i'], ['-l', '-i']] as const) { const result = spawnSync( diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts index 360e2c361d8..32b162ae7a6 100644 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts +++ b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts @@ -3,6 +3,8 @@ import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join, dirname } from 'node:path' import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' import { describeIfZsh, describePosix, @@ -243,7 +245,7 @@ describePosix('live zsh subprocess tests', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') - const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') // Verify wrapper checks the resolved source root is non-empty before sourcing expect(zshenv).toContain('if [[ -n "${_orca_zshenv_source_dir:-}"') diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 631c5a5ed38..0ae94230829 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { buildWslInteractiveLoginShellCommand } from '../../shared/wsl-login-shell-command' import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' +// Why resolved rather than hardcoded: the wrapper tree is content-addressed. +import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe' const CMD_CODEX_LAUNCH_PREFLIGHT = @@ -378,14 +380,14 @@ describe('resolveWindowsShellLaunchArgs', () => { ) expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) - expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) - expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) + expect(existsSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'))).toBe(true) + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'))).toBe(true) // Why: typed OMP keeps its existing shell integration, while typed Prime // commands must reach the user's binary without Orca rewriting argv. - const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') + const bashRcfile = readFileSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'), 'utf8') // Why .zshenv: the omp wrapper is part of the epilogue defined there. - const zshEnv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + const zshEnv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') for (const wrapperFile of [bashRcfile, zshEnv]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 916d11dfd67..a0e3e5edd3f 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { isAbsolute } from 'node:path' +import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV @@ -15,7 +17,23 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u') + expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') + }) + + // Why this is published at all: the wrapper tree is content-addressed, so the + // in-guest login script cannot rebuild its path from ORCA_USER_DATA_PATH -- it + // cannot derive the hash segment. Without this the guest finds no wrapper and + // every WSL pane launches unwrapped: no ready marker, so every startup command + // waits out the full readiness timeout. + it('publishes the resolved wrapper root path-translated for the guest', () => { + const env: Record = {} + + addOrcaWslInteropEnv(env) + + expect(env.ORCA_SHELL_READY_ROOT).toBe(getShellReadyWrapperRoot()) + expect(isAbsolute(env.ORCA_SHELL_READY_ROOT as string)).toBe(true) + // /p, not /u: the guest reads a Windows path through /mnt/c. + expect(env.WSLENV?.split(':')).toContain('ORCA_SHELL_READY_ROOT/p') }) it('imports setup-gated startup env into WSL without path translation', () => { @@ -27,6 +45,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) expect(env.WSLENV?.split(':')).toEqual([ + 'ORCA_SHELL_READY_ROOT/p', `${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`, `${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u` ]) @@ -39,7 +58,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p') + expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p:ORCA_SHELL_READY_ROOT/p') }) it('marks OMP status and hook env for Windows to WSL import', () => { @@ -178,7 +197,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_WORKSPACE_NAME/u') + expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WORKSPACE_NAME/u') }) it('does not register setup vars that are absent from the env', () => { @@ -186,7 +205,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u') + expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') }) it('marks the WSL hook relay version for import on relay spawn envs', () => { @@ -194,7 +213,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' } addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_WSL_HOOK_RELAY_VERSION/u') + expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WSL_HOOK_RELAY_VERSION/u') }) it('crosses a guest-side OpenCode config overlay untranslated (/u)', () => { diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 40f3b957249..d567b171f5b 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -8,6 +8,7 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../shared/setup-agent-sequencing' +import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' const WSLENV_ENTRY_SEPARATOR = ':' @@ -61,6 +62,11 @@ export function addWorktreeSetupWslInteropEnv(env: Record): void { + // Why set here: every WSL spawn path funnels through this helper, and the + // in-guest login script needs the resolved wrapper root. Windows/WSL wrappers + // are always the local file set -- windows-shell-args.ts is shared by the + // in-process provider and the daemon spawner, so both resolve the same tree. + env.ORCA_SHELL_READY_ROOT = getShellReadyWrapperRoot() // Why: the endpoint is a Windows path (/p-translated so the guest reads it // via /mnt/c) until the WSL hook relay reports the guest home — then it is // already a guest-side POSIX path and must cross untranslated. @@ -76,6 +82,9 @@ export function addOrcaWslInteropEnv(env: Record): void { const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', 'ORCA_USER_DATA_PATH/p', + // Why /p: the guest reads the content-addressed wrapper tree through /mnt/c, + // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. + 'ORCA_SHELL_READY_ROOT/p', 'ORCA_CLI_COMMAND/u', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', diff --git a/src/main/shell-wrapper-content-address.test.ts b/src/main/shell-wrapper-content-address.test.ts new file mode 100644 index 00000000000..ac8f1b9dee7 --- /dev/null +++ b/src/main/shell-wrapper-content-address.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { + resolveShellWrapperRoot, + type ShellWrapperFileBuilder +} from './shell-wrapper-content-address' +import { writeShellWrapperFiles } from './shell-wrapper-file-writer' + +function builderFor(marker: string): ShellWrapperFileBuilder { + // Why the root is embedded: mirrors .zshenv, the one wrapper file that names + // its own tree. The digest must stay stable despite it. + return (root) => [ + [join(root, 'zsh', '.zshrc'), `zshrc ${marker} root=${root}`], + [join(root, 'bash', 'rcfile'), `rcfile ${marker}`] + ] +} + +function makeBase(): string { + return mkdtempSync(join(tmpdir(), 'orca-wrapper-address-')) +} + +describe('resolveShellWrapperRoot', () => { + it('gives builds with different wrapper contents different trees', () => { + const base = makeBase() + expect(resolveShellWrapperRoot(base, builderFor('ORCA_SHELL_READY_MARKER'))).not.toEqual( + resolveShellWrapperRoot(base, builderFor('ORCA_SHELL_FEATURES')) + ) + }) + + it('is stable for identical contents', () => { + const base = makeBase() + expect(resolveShellWrapperRoot(base, builderFor('a'))).toEqual( + resolveShellWrapperRoot(base, builderFor('a')) + ) + }) + + // Why: `*/shell-ready/zsh` globs baked into the wrapper scripts, plus TS + // guards, detect a self-referential ZDOTDIR by this exact suffix. Losing it + // makes a wrapper source itself until zsh hits its recursion limit. + it('keeps the zsh dir ending in /shell-ready/zsh', () => { + const root = resolveShellWrapperRoot(makeBase(), builderFor('a')) + // Compare in POSIX form: the guards match a POSIX suffix, and the value they + // see is built with POSIX concatenation regardless of the host separator. + expect(join(root, 'zsh').split(sep).join('/').endsWith('/shell-ready/zsh')).toBe(true) + }) + + it('lets two contracts coexist instead of clobbering each other', () => { + const base = makeBase() + const oldBuild = builderFor('old') + const newBuild = builderFor('new') + const oldRoot = resolveShellWrapperRoot(base, oldBuild) + const newRoot = resolveShellWrapperRoot(base, newBuild) + + writeShellWrapperFiles(oldBuild(oldRoot), '[test]') + writeShellWrapperFiles(newBuild(newRoot), '[test]') + + // The regression: the second writer used to overwrite the first writer's + // file in place, leaving the first build launching shells it cannot read. + expect(readFileSync(join(oldRoot, 'bash', 'rcfile'), 'utf8')).toBe('rcfile old') + expect(readFileSync(join(newRoot, 'bash', 'rcfile'), 'utf8')).toBe('rcfile new') + }) + + // Why: older builds still write the unversioned `/shell-ready/` tree + // and long-lived daemons launch shells from it. Addressing by content has to + // land somewhere that cannot collide with it. + it('never resolves onto the legacy unversioned tree', () => { + const userData = makeBase() + const root = resolveShellWrapperRoot(join(userData, 'shell-wrappers'), builderFor('a')) + expect(root.startsWith(join(userData, 'shell-wrappers') + sep)).toBe(true) + expect(root).not.toEqual(join(userData, 'shell-ready')) + }) +}) diff --git a/src/main/shell-wrapper-content-address.ts b/src/main/shell-wrapper-content-address.ts new file mode 100644 index 00000000000..126cee5f997 --- /dev/null +++ b/src/main/shell-wrapper-content-address.ts @@ -0,0 +1,63 @@ +/** + * Decides where a generated shell wrapper tree lives, by hashing its contents. + * + * Why: every writer that shares a userData dir -- main's local PTY path, the + * daemon fork, and the daemons of other builds that outlive the app that + * spawned them -- wrote one fixed `shell-ready/` tree. Last writer won, and the + * guard only re-checked that the files were present, never that they were this + * build's. A daemon whose spawn env no longer agreed with the wrapper on disk + * then kept launching shells it could not read: the ready marker never fired + * and every startup command waited out the full readiness timeout, silently. + * + * Naming the directory after a hash of its own contents makes that impossible: + * different bytes are a different directory, so "present" means "written by + * this build" again. Writing stays in shell-wrapper-file-writer.ts. + * + * Why nothing here collects old trees: a tree is ~48KB and a new one appears + * only when the wrapper templates themselves change, so an install accrues a + * couple of megabytes a year against a userData directory that runs to tens of + * gigabytes. Reclaiming that is not worth putting an `rm -rf` on the + * terminal-spawn path. + */ +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import type { ShellWrapperFile } from './shell-wrapper-file-writer' + +/** Builds the wrapper set for a tree rooted at `root`, as the writer takes it. */ +export type ShellWrapperFileBuilder = (root: string) => readonly ShellWrapperFile[] + +// Why a placeholder root: .zshenv bakes in the tree path, so hashing against the +// real root would make the digest depend on the very path it is choosing. A +// fixed stand-in keeps it stable across machines and user data dirs while still +// covering every difference that matters. +// +// Note this string is itself part of the digest, because it appears in the baked +// content. Renaming it re-keys every tree -- which costs one extra ~48KB +// directory per install and nothing else, so it is not worth normalizing out. +// +// The digest therefore covers the probe build, not the literal bytes on disk. +// That holds only while the sole path-dependent thing in a wrapper is the root +// itself. A future template that varies on something else about its location +// would need that input folded in here, or two genuinely different trees would +// collide on one directory. +const HASH_PROBE_ROOT = '/__orca_shell_wrapper_root__' +const ROOT_HASH_LENGTH = 16 +// Why the hash sits ABOVE this leaf rather than below it: ZDOTDIR +// self-reference guards -- in TS and as `*/shell-ready/zsh` globs baked into +// the wrapper scripts -- match on that exact suffix. Without it a wrapper +// sources itself and zsh dies with "job table full or recursion limit +// exceeded". `//shell-ready/zsh` leaves every guard intact and +// keeps older builds able to recognize a newer build's dir. +const WRAPPER_ROOT_LEAF = 'shell-ready' + +export function resolveShellWrapperRoot(baseDir: string, build: ShellWrapperFileBuilder): string { + const digest = createHash('sha256') + for (const [path, content] of build(HASH_PROBE_ROOT)) { + // Relative, so the digest does not vary with the base dir it is naming. + digest.update(path.slice(HASH_PROBE_ROOT.length)) + digest.update('\0') + digest.update(content) + digest.update('\0') + } + return join(baseDir, digest.digest('hex').slice(0, ROOT_HASH_LENGTH), WRAPPER_ROOT_LEAF) +} diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index bf34df55e86..6e6d10ab0ff 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -13,7 +13,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ensureShellReadyWrappersAt } from './providers/local-pty-shell-ready-wrapper-generation' -import { getShellLaunchConfig as getDaemonShellLaunchConfig } from './daemon/shell-ready' +import { + getShellLaunchConfig as getDaemonShellLaunchConfig, + getShellReadyWrapperRoot as getDaemonShellReadyWrapperRoot +} from './daemon/shell-ready' import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' import { getShellLaunchConfig as getLocalShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' @@ -128,7 +131,7 @@ describePosix('generated shell wrapper files', () => { it('daemon wrappers', async () => { process.env.ORCA_USER_DATA_PATH = root getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES) - await expectWrapperFiles('daemon', join(root, 'shell-ready')) + await expectWrapperFiles('daemon', getDaemonShellReadyWrapperRoot()) }) it('relay overlay wrappers', async () => { @@ -149,7 +152,7 @@ describePosix('generated shell wrapper files', () => { process.env.ORCA_USER_DATA_PATH = root getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES) }, - (): string => join(root, 'shell-ready') + (): string => getDaemonShellReadyWrapperRoot() ], ['relay', (): void => void ensureOverlayRestoreWrappers(root), (): string => root] ])('%s wrappers write no shell global outside Orca’s namespace', (_transport, generate, dir) => { diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 4b3ea6fbdf7..76217c297bc 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -156,9 +156,7 @@ describe('wsl login shell command helpers', () => { // Why the captured form: an interactive login shell also prints the distro's // rc/motd to stdout (stock Ubuntu ships a sudo hint), so a raw login-shell // read cannot be compared byte-for-byte on a real distro. - const captured = buildWslCapturedLoginShellCommand( - 'orca_value=ok; printf "<%s>" "$orca_value"' - ) + const captured = buildWslCapturedLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"') expect( captured.readStdout( @@ -309,3 +307,35 @@ describe('wsl login shell command helpers', () => { expectValidShSyntax(command) }) }) + +describe('in-guest wrapper root resolution', () => { + // Why this test exists: the wrapper tree is content-addressed, so its path + // carries a hash the guest cannot derive. A previous revision of this script + // rebuilt the root as `${ORCA_USER_DATA_PATH}/shell-ready`, which stopped + // matching -- every WSL pane then fell through to an unwrapped `exec $shell -l` + // and silently lost the ready marker, OSC 133, and the launch preflight. + it('prefers the host-published root over the legacy user-data guess', () => { + const script = buildWslInteractiveLoginShellCommand() + expect(script).toContain('if [ -n "${ORCA_SHELL_READY_ROOT:-}" ]; then') + expect(script).toContain('_orca_shell_ready_root="${ORCA_SHELL_READY_ROOT%/}"') + // The legacy branch must remain reachable only as a fallback, so an older + // host that exports just ORCA_USER_DATA_PATH still wraps its shells. + expect(script).toContain('elif [ -n "${ORCA_USER_DATA_PATH:-}" ]; then') + }) + + it('resolves the published root ahead of the legacy path under a real shell', () => { + const script = buildWslInteractiveLoginShellCommand() + // Run only the root-resolution prologue, then report what it picked. + const prologue = script.split('_orca_wsl_shell_name=')[0] as string + const probe = [ + 'ORCA_SHELL_READY_ROOT=/mnt/c/ud/shell-wrappers/deadbeefdeadbeef/shell-ready', + 'ORCA_USER_DATA_PATH=/mnt/c/ud', + 'export ORCA_SHELL_READY_ROOT ORCA_USER_DATA_PATH', + prologue, + 'printf "%s" "$_orca_shell_ready_root"' + ].join('\n') + const result = spawnSync('sh', ['-c', probe], { encoding: 'utf8' }) + expect(result.status).toBe(0) + expect(result.stdout).toBe('/mnt/c/ud/shell-wrappers/deadbeefdeadbeef/shell-ready') + }) +}) diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index 7643f94b664..fd0f0b505c9 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -114,7 +114,13 @@ export function buildWslInteractiveLoginShellCommand(): string { ' _orca_wsl_shell=/bin/sh', 'fi', '_orca_shell_ready_root=""', - 'if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then', + // Why the explicit root first: the wrapper tree is content-addressed, so its + // path carries a hash the guest cannot derive. The host publishes the + // resolved root and WSLENV /p-translates it. The ORCA_USER_DATA_PATH branch + // stays as the fallback for an older host that exports only that. + 'if [ -n "${ORCA_SHELL_READY_ROOT:-}" ]; then', + ' _orca_shell_ready_root="${ORCA_SHELL_READY_ROOT%/}"', + 'elif [ -n "${ORCA_USER_DATA_PATH:-}" ]; then', ' _orca_shell_ready_root="${ORCA_USER_DATA_PATH%/}/shell-ready"', 'fi', '_orca_wsl_shell_name=$(basename "$_orca_wsl_shell" | tr "[:upper:]" "[:lower:]")', From 4ec6bbf5884e5bd915b4f49c4602b5e5a88b61ae Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 18 Aug 2026 16:57:35 -0700 Subject: [PATCH 024/168] Kill hung WSL transcript filesystem operations via child process with route quarantine (#15381) * fix(native-chat): kill hung WSL operations via child process Stalled UNC file operations hold libuv permits even after the gate timeout expires, blocking Chat tab recovery. Two stalled operations fill both permits and freeze all WSL access until restart. Fork file I/O for UNC paths into a separate child process. On deadline expiry, kill the process to force the hung syscall to exit. This frees the permit for the affected tab's next read. Temporarily quarantine the stalled route to avoid retry storms. * chore: drop internal review artifact from the repo root * fix(native-chat): harden the WSL transcript fs sidecar Review follow-ups on the sidecar isolation change: - Only the deadline may abort running gate work. The sole waiter's same-duration timeout fired first, killed healthy children on caller abandonment, and settled the task before the deadline could quarantine a stalled route - leaving the back-off dead for every dedupe:false op. - Resolve the fork entry from out/main/chunks too: the resolver compiles into a shared chunk, and the scanner service child has no process.resourcesPath, so packaged WSL vault scans threw entry-not-found (masked as an empty tree). - Allowlist the fork env instead of spreading process.env; ambient NODE_OPTIONS would halt or --require code into every child. - Wrap transport faults (spawn failure, child death) in WslTranscriptFsError('unavailable') so discovery reports them as scan issues instead of misreading them as missing paths or empty trees. - Gate the vitest in-process fallback on the vitest worker global so a leaked VITEST=true cannot revert production to in-process UNC syscalls. - Reap idle sidecar processes after 60s instead of holding them for the app session. - Split 'open' into its own protocol union member so the reusable-call Exclude actually strips it from the pooled-process API. - Guard kill('SIGKILL') against the teardown race where an exiting child emits an unlistened 'error', and dispatch reads by handle kind before path spelling. * fix(native-chat): probe stalled WSL routes instead of a fixed quarantine Remaining review follow-ups: - Escalating route quarantine: first strike lifts after 5s so a distro that was cold-booting when its op hit the deadline recovers on the next poll (~35s total instead of ~90s); repeat stalls double the back-off toward the prior 2x-timeout cap, and any settle the deadline did not force clears the strikes. Queued same-route tasks fail fast at quarantine instead of stranding one waiter deadline per file in sequential scans. - Single request implementation: the vitest in-process fallback now runs the child's own dispatcher (WslTranscriptFsProcessOperations + decode), so unit suites exercise exactly what the forked process executes and the per-call-site fallback closures are gone. Dirent fixtures gained the full kind-flag set the serializer reads. - Dropped the production-dead per-route close queue; UNC FileHandles (test fallback only) mirror the process-handle close contract. - Error class, messages, and factories move to wsl-transcript-fs-error (re-exported from the gate) to keep the gate under the lines budget. * fix(native-chat): harden WSL transcript fs with route quarantine strike Extract quarantine logic into a dedicated module with strike decay: stalls older than 5 minutes restart from base back-off, and concurrent-lane timeouts count as one incident. Allow joining live in-flight tasks on quarantined routes (they cost no new I/O). Preserve quarantine across transport faults (child death). Handle file shrinking during tail reads by detecting short reads and returning empty. Defer file closes that arrive mid-read instead of refusing, preventing slot leaks. Separate process slot and boundary-finding concerns into focused modules. * fix(native-chat): enforce route quarantine windows and isolate lanes per A late result arriving after the deadline was incorrectly lifting the route quarantine, allowing subsequent work to start before the back-off period expired. Now late results are correctly recognized as stale and never cut the quarantine short. Process work is now isolated per (route, priority) lane so a scan stall cannot block exact reads on the same distro. Each lane gets its own client and process pool; late results and handle faults stay scoped to their lane. Tests now fake performance.now() alongside timers (the quarantine clock depends on it) and wait for the full back-off window to expire rather than advancing by 0. Gate state is reset between test cases since late releases never lift the quarantine. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../build-plugins/plain-node-entry-guard.ts | 3 +- config/electron-builder.config.cjs | 1 + .../scripts/electron-builder-config.test.mjs | 8 + electron.vite.config.ts | 3 + ...sion-scanner-core-parser-wsl-stall.test.ts | 25 +- ...session-scanner-discovery-wsl-gate.test.ts | 15 +- .../ai-vault/session-scanner-service-env.ts | 5 +- .../host-readable-transcript-path.ts | 25 +- ...script-read-cache-refusal-recovery.test.ts | 13 +- src/main/native-chat/transcript-read-cache.ts | 11 +- .../native-chat/transcript-tail-boundary.ts | 65 +++ .../transcript-tail-reader-wsl-gate.test.ts | 11 +- .../native-chat/transcript-tail-reader.ts | 62 +-- .../transcript-watch-wsl-stall.test.ts | 21 +- .../wsl-codex-session-path-scan.ts | 8 +- .../wsl-transcript-fs-access.test.ts | 45 +- .../native-chat/wsl-transcript-fs-access.ts | 166 +++--- .../native-chat/wsl-transcript-fs-error.ts | 56 ++ .../wsl-transcript-fs-gate.test.ts | 221 ++++++-- .../native-chat/wsl-transcript-fs-gate.ts | 179 +++--- .../wsl-transcript-fs-process-client.test.ts | 510 ++++++++++++++++++ .../wsl-transcript-fs-process-client.ts | 301 +++++++++++ .../wsl-transcript-fs-process-decode.ts | 43 ++ .../wsl-transcript-fs-process-dispatch.ts | 99 ++++ .../wsl-transcript-fs-process-entry.ts | 40 ++ .../wsl-transcript-fs-process-handle-owner.ts | 18 + .../wsl-transcript-fs-process-lane-pool.ts | 156 ++++++ ...l-transcript-fs-process-operations.test.ts | 48 ++ .../wsl-transcript-fs-process-operations.ts | 63 +++ .../wsl-transcript-fs-process-protocol.ts | 51 ++ .../wsl-transcript-fs-process-send.ts | 51 ++ .../wsl-transcript-fs-process-slot.ts | 69 +++ .../wsl-transcript-fs-process-spawn.ts | 58 ++ ...wsl-transcript-fs-route-quarantine.test.ts | 151 ++++++ .../wsl-transcript-fs-route-quarantine.ts | 58 ++ .../wsl-transcript-fs-route.test.ts | 18 + .../native-chat/wsl-transcript-fs-route.ts | 8 +- .../opencode-usage/scanner-wsl-gate.test.ts | 17 +- 38 files changed, 2358 insertions(+), 344 deletions(-) create mode 100644 src/main/native-chat/transcript-tail-boundary.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-error.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-client.test.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-client.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-decode.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-dispatch.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-entry.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-handle-owner.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-lane-pool.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-operations.test.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-operations.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-protocol.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-send.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-slot.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-process-spawn.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-route-quarantine.ts create mode 100644 src/main/native-chat/wsl-transcript-fs-route.test.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 45d34b944f7..441536e7edb 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -17,12 +17,13 @@ type OutputChunk = Rollup.OutputChunk // graph still resolves. // Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): -// forked daemon, parcel-watcher and computer sidecars, and the CLI-run +// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run // agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', + 'wsl-transcript-fs-process-entry', 'agent-hooks/managed-agent-hook-controls', 'codex/codex-app-server-grant-entry' ] as const diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 577cbd97a93..f1a536bed5a 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -192,6 +192,7 @@ module.exports = { 'out/main/hermes/**', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', + 'out/main/wsl-transcript-fs-process-entry.js', 'out/main/session-scanner-opencode-sqlite-worker-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 75c3898858e..bf28401fd35 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -207,6 +207,14 @@ describe('electron-builder config', () => { ) }) + it('unpacks the replaceable WSL transcript filesystem process entry', async () => { + const entryFilename = 'wsl-transcript-fs-process-entry.js' + expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`) + + const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8') + expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)) + }) + // Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is // invisible to it and a packed worker entry fails closed — dropping every // OpenCode session in packaged builds while dev stays green. Three legs must diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7db7df7ca69..0b9ca284b95 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -223,6 +223,9 @@ export const electronViteConfig: UserConfig = { 'session-scanner-service-entry': resolve( 'src/main/ai-vault/session-scanner-service-entry.ts' ), + 'wsl-transcript-fs-process-entry': resolve( + 'src/main/native-chat/wsl-transcript-fs-process-entry.ts' + ), // Why: libuv spawns processes inline on the calling loop, so the port // scan's probe commands run on a worker thread instead of the UI one. 'port-scan-command-worker-entry': resolve( diff --git a/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts index 44fe71c80b2..5a07845aea0 100644 --- a/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts +++ b/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts @@ -29,6 +29,8 @@ import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS, WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' @@ -107,8 +109,19 @@ function missing(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } +// Complete: UNC readdir results pass through the child dispatcher's dirent +// serializer, which reads every kind flag. function dirent(name: string) { - return { name, isFile: () => true } + return { + name, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => false, + isFIFO: () => false, + isFile: () => true, + isSocket: () => false, + isSymbolicLink: () => false + } } function candidate(agent: SessionFileCandidate['agent'], path: string): SessionFileCandidate { @@ -132,13 +145,18 @@ async function expectRefusal(target: SessionFileCandidate): Promise { await refusal } +// A result that lands past the deadline never lifts the route quarantine, so +// recovery waits out the back-off window the same way production does. async function releaseAndSettle(): Promise { releaseStall?.() releaseStall = undefined - await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) } beforeEach(() => { + // blockedRoutes is persistent gate state: a prior stall must not quarantine + // this test's route. + resetWslTranscriptFsGateForTests() resetSessionParseCacheForTests() mocks.open.mockReset() mocks.readFile.mockReset() @@ -146,7 +164,8 @@ beforeEach(() => { mocks.stat.mockReset() releaseStall = undefined mocks.stat.mockRejectedValue(missing()) - vi.useFakeTimers() + // performance.now drives the route quarantine clock, so it must be faked too. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }) }) afterEach(async () => { diff --git a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts index 962a2e20fa9..5e0e686651c 100644 --- a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts +++ b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts @@ -12,6 +12,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })) import { + resetWslTranscriptFsGateForTests, WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' @@ -20,8 +21,19 @@ import { discoverFiles, walkSessionFiles } from './session-scanner-discovery' const SLOW_MESSAGE = 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' +// Complete: UNC readdir results pass through the child dispatcher's dirent +// serializer, which reads every kind flag. function dirent(name: string): Dirent { - return { name, isDirectory: () => false, isFile: () => true } as Dirent + return { + name, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => false, + isFIFO: () => false, + isFile: () => true, + isSocket: () => false, + isSymbolicLink: () => false + } as Dirent } let releaseStall: (() => void) | undefined @@ -33,6 +45,7 @@ function stalls(): Promise { } beforeEach(() => { + resetWslTranscriptFsGateForTests() fsMocks.readdir.mockReset() fsMocks.stat.mockReset() fsMocks.readdir.mockResolvedValue([]) diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index 1bfb75344ca..e2f5309e70e 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -8,7 +8,8 @@ */ // What Node and libuv need to start and resolve a home, temp dir and locale. -const RUNTIME_ENV_ALLOWLIST = [ +// Exported for sibling plain-node forks (the WSL transcript fs process). +export const RUNTIME_ENV_ALLOWLIST = [ 'PATH', 'HOME', 'USERPROFILE', @@ -49,7 +50,7 @@ const AGENT_ROOT_ENV_ALLOWLIST = [ 'XDG_DATA_HOME' ] as const -function pickAllowedEnv( +export function pickAllowedEnv( keys: readonly string[], baseEnv: NodeJS.ProcessEnv, platform: NodeJS.Platform diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index 4d919099536..9337281cf7f 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -1,14 +1,10 @@ import { existsSync } from 'node:fs' -import { access } from 'node:fs/promises' import { isAbsolute } from 'node:path' import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' -import { - runWslTranscriptFsTask, - wslTranscriptFsRefusal, - type WslTranscriptFsError -} from './wsl-transcript-fs-gate' +import { wslGatedAccess } from './wsl-transcript-fs-access' +import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' /** * True for guest-absolute Linux paths that Win32 cannot open as-is. @@ -50,15 +46,18 @@ async function pathExistsAsync(path: string, signal?: AbortSignal): Promise => { - try { - await access(path) - return true - } catch { - return false + try { + return await wslGatedAccess(path, 'exact', signal) + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error } + // A caller abort stays authoritative — it must never read as "missing". + if (signal?.aborted) { + throw error + } + return false } - return runWslTranscriptFsTask({ operation: 'access', path, priority: 'exact', signal }, probe) } // Why: resolveSessionFilePath runs on a 500ms–5s poll loop. listWslDistrosAsync diff --git a/src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts b/src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts index 4e865b306fc..6fd5aa85aee 100644 --- a/src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts +++ b/src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts @@ -22,7 +22,9 @@ import { readNativeChatTranscriptCached } from './transcript-read-cache' import { + resetWslTranscriptFsGateForTests, WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, + WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS, WSL_TRANSCRIPT_FS_SLOW_MESSAGE } from './wsl-transcript-fs-gate' @@ -55,6 +57,8 @@ function transcriptHandle(body = BODY) { } beforeEach(() => { + // The deadline quarantines the route, and a late release never lifts it. + resetWslTranscriptFsGateForTests() clearNativeChatTranscriptCache() mocks.resolve.mockReset() mocks.stat.mockReset() @@ -63,7 +67,8 @@ beforeEach(() => { mocks.resolve.mockResolvedValue(UNC_PATH) // The file itself never changes across the refusal and the recovery. mocks.stat.mockResolvedValue({ mtimeMs: 42, size: BODY.length }) - vi.useFakeTimers() + // performance.now drives the route quarantine clock, so it must be faked too. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }) }) afterEach(async () => { @@ -82,7 +87,8 @@ describe('cached native chat transcript read after WSL gate refusals', () => { expect(await refused).toEqual({ error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE }) releaseStall?.() releaseStall = undefined - await vi.advanceTimersByTimeAsync(0) + // The deadline's route back-off has to expire before the retry is admitted. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) mocks.open.mockResolvedValue(transcriptHandle()) await expect(readNativeChatTranscriptCached('claude', 'session-id')).resolves.toMatchObject({ @@ -102,7 +108,8 @@ describe('cached native chat transcript read after WSL gate refusals', () => { releaseStall?.() releaseStall = undefined - await vi.advanceTimersByTimeAsync(0) + // The deadline's route back-off has to expire before the retry is admitted. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) mocks.open.mockResolvedValue(transcriptHandle()) const recovered = await readNativeChatTranscriptCached('claude', 'session-id') diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index ee0c8ddcafd..ab5c220bfd1 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -3,11 +3,10 @@ import { resolveSessionFilePath } from './session-file-resolver' import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' import { wslGatedStat } from './wsl-transcript-fs-access' import { - WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE, - WSL_TRANSCRIPT_FS_SLOW_MESSAGE, + isWslTranscriptFsRefusalMessage, WslTranscriptFsError, wslTranscriptFsRefusal -} from './wsl-transcript-fs-gate' +} from './wsl-transcript-fs-error' // Why: both the desktop IPC handler and the runtime RPC handler read the same // host-filesystem transcript, so a single process-global cache keyed by the @@ -150,11 +149,7 @@ export async function readNativeChatTranscriptCached( // The reader flattens a refusal into its message, so that is the only handle // this layer has on one. function isGateRefusal(result: ReadTranscriptResult): boolean { - return ( - 'error' in result && - (result.error === WSL_TRANSCRIPT_FS_SLOW_MESSAGE || - result.error === WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE) - ) + return 'error' in result && isWslTranscriptFsRefusalMessage(result.error) } /** Test-only: drop the transcript parse cache between runs. */ diff --git a/src/main/native-chat/transcript-tail-boundary.ts b/src/main/native-chat/transcript-tail-boundary.ts new file mode 100644 index 00000000000..b4479ac2bd0 --- /dev/null +++ b/src/main/native-chat/transcript-tail-boundary.ts @@ -0,0 +1,65 @@ +import { wslGatedRead } from './wsl-transcript-fs-access' +import type { TranscriptFileHandle } from './wsl-transcript-fs-access' + +export const TAIL_CHUNK_BYTES = 64 * 1024 + +/** + * The byte at `position`, or null when the file shrank below it between the + * caller's stat and this read (allocUnsafe would otherwise hand back garbage). + */ +export async function readTranscriptByteAt( + handle: TranscriptFileHandle, + filePath: string, + position: number, + signal?: AbortSignal +): Promise { + const byte = Buffer.allocUnsafe(1) + const { bytesRead } = await wslGatedRead(handle, filePath, byte, 0, 1, position, 'exact', signal) + signal?.throwIfAborted() + return bytesRead === 1 ? byte[0] : null +} + +/** End offset (exclusive) of the last newline-terminated line at or before `end`. */ +export async function findLastCompleteLineEnd( + handle: TranscriptFileHandle, + filePath: string, + end: number, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const lastByte = await readTranscriptByteAt(handle, filePath, end - 1, signal) + if (lastByte === null) { + // File shrank between stat and probe. + return 0 + } + if (lastByte === 0x0a) { + return end + } + let cursor = end + while (cursor > 0) { + signal?.throwIfAborted() + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await wslGatedRead( + handle, + filePath, + buffer, + 0, + buffer.length, + start, + 'exact', + signal + ) + signal?.throwIfAborted() + if (bytesRead < buffer.length) { + // File shrank mid-walk: any boundary computed from stale offsets is wrong. + return 0 + } + const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) + if (newline !== -1) { + return start + newline + 1 + } + cursor = start + } + return 0 +} diff --git a/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts index da040de2794..89e06bc8814 100644 --- a/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts +++ b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts @@ -18,7 +18,11 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })) import { readNativeChatTranscriptTail } from './transcript-tail-reader' -import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, WslTranscriptFsError } from './wsl-transcript-fs-gate' +import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, + WslTranscriptFsError +} from './wsl-transcript-fs-gate' const SLOW_MESSAGE = 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' @@ -48,7 +52,9 @@ describe('native chat transcript tail under WSL gate refusals', () => { describe('native chat transcript tail with stalled post-resolution UNC I/O', () => { const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl' // A stalled task holds its gate permit until the underlying call settles, so - // each case releases its stall before the next one runs. + // each case releases its stall before the next one runs. The deadline also + // quarantines the route, and a late release never lifts that, so each case + // starts from a reset gate. let releaseStall: (() => void) | undefined function stalls(): Promise { @@ -69,6 +75,7 @@ describe('native chat transcript tail with stalled post-resolution UNC I/O', () } beforeEach(() => { + resetWslTranscriptFsGateForTests() mocks.stat.mockReset() mocks.open.mockReset() releaseStall = undefined diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index e9cbe430090..b6fa9bafdb0 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -1,4 +1,3 @@ -import type { FileHandle } from 'node:fs/promises' import type { AgentType, NativeChatMessage, @@ -17,6 +16,11 @@ import { nativeChatTurnLifecycleDecoderForAgent, type NativeChatTurnLifecycleDecoder } from './transcript-turn-lifecycle' +import { + findLastCompleteLineEnd, + readTranscriptByteAt, + TAIL_CHUNK_BYTES +} from './transcript-tail-boundary' import { closeTranscriptHandle, wslGatedOpen, @@ -26,7 +30,6 @@ import { import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 -const TAIL_CHUNK_BYTES = 64 * 1024 export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null @@ -90,11 +93,13 @@ export async function readNativeChatTranscriptTailFile( return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } const newestFirst: { message: NativeChatMessage; offset: number }[] = [] - const finalByte = Buffer.allocUnsafe(1) - await wslGatedRead(handle, filePath, finalByte, 0, 1, consumedTo - 1, 'exact', signal) - signal?.throwIfAborted() - ignoreNextMalformedRecord = finalByte[0] !== 0x0a - let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) + const finalByte = await readTranscriptByteAt(handle, filePath, consumedTo - 1, signal) + if (finalByte === null) { + // File shrank between stat and probe: report empty, the next poll re-stats. + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + ignoreNextMalformedRecord = finalByte !== 0x0a + let cursor = consumedTo - (finalByte === 0x0a ? 1 : 0) while (cursor > 0 && newestFirst.length <= limit) { signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) @@ -110,6 +115,11 @@ export async function readNativeChatTranscriptTailFile( signal ) signal?.throwIfAborted() + // A short read means the file shrank mid-walk: stop paging back rather + // than stitch non-adjacent bytes into records. + if (bytesRead < buffer.length) { + break + } let segmentEnd = bytesRead for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { if (buffer[index] !== 0x0a) { @@ -201,44 +211,6 @@ export async function readNativeChatTranscriptTailFile( } } -async function findLastCompleteLineEnd( - handle: FileHandle, - filePath: string, - end: number, - signal?: AbortSignal -): Promise { - signal?.throwIfAborted() - const lastByte = Buffer.allocUnsafe(1) - await wslGatedRead(handle, filePath, lastByte, 0, 1, end - 1, 'exact', signal) - signal?.throwIfAborted() - if (lastByte[0] === 0x0a) { - return end - } - let cursor = end - while (cursor > 0) { - signal?.throwIfAborted() - const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) - const buffer = Buffer.allocUnsafe(cursor - start) - const { bytesRead } = await wslGatedRead( - handle, - filePath, - buffer, - 0, - buffer.length, - start, - 'exact', - signal - ) - signal?.throwIfAborted() - const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) - if (newline !== -1) { - return start + newline + 1 - } - cursor = start - } - return 0 -} - export async function readNativeChatTranscriptTail( args: ResolveSessionFileOptions & { agent: AgentType diff --git a/src/main/native-chat/transcript-watch-wsl-stall.test.ts b/src/main/native-chat/transcript-watch-wsl-stall.test.ts index 3fd066325bb..3c508151016 100644 --- a/src/main/native-chat/transcript-watch-wsl-stall.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-stall.test.ts @@ -26,7 +26,10 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })) import { subscribeNativeChatTranscript } from './transcript-watch' -import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' +import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS +} from './wsl-transcript-fs-gate' const SLOW_MESSAGE = 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' @@ -61,6 +64,9 @@ function trackUnhandled(reason: unknown): void { } beforeEach(() => { + // blockedRoutes is persistent gate state: a prior stall must not quarantine + // this test's route. + resetWslTranscriptFsGateForTests() mocks.resolve.mockReset() mocks.stat.mockReset() mocks.open.mockReset() @@ -82,7 +88,13 @@ const EMPTY_STATS = { size: 0, mtimeMs: 1, ctimeMs: 1, ino: 1, dev: 1, mtime: ne describe('native chat transcript subscription with a stalled install stat', () => { it('keeps watching and emits exactly one retryable snapshot, then the real one', async () => { mocks.resolve.mockResolvedValue(UNC_PATH) - mocks.stat.mockImplementationOnce(stalls).mockResolvedValue(EMPTY_STATS) + // Every stat stalls until the distro "wakes": the first-strike quarantine + // runs on the real performance.now clock, so depending on wall time a retry + // probe may be re-admitted mid-test and must find the mount still hung. + const pendingStats: ((stats: typeof EMPTY_STATS) => void)[] = [] + mocks.stat.mockImplementation( + () => new Promise((resolve) => pendingStats.push(resolve as never)) + ) const snapshots: Snapshot[] = [] // Not awaited yet: the setup install is itself blocked on the stalled stat. @@ -99,7 +111,10 @@ describe('native chat transcript subscription with a stalled install stat', () = expect(snapshots).toHaveLength(1) // The distro wakes: the same subscription still delivers a real snapshot. - releaseStall?.() + mocks.stat.mockResolvedValue(EMPTY_STATS) + for (const release of pendingStats.splice(0)) { + release(EMPTY_STATS) + } await vi.advanceTimersByTimeAsync(500) expect(snapshots.length).toBeGreaterThan(1) diff --git a/src/main/native-chat/wsl-codex-session-path-scan.ts b/src/main/native-chat/wsl-codex-session-path-scan.ts index 804bcfe858e..fbffa297ee8 100644 --- a/src/main/native-chat/wsl-codex-session-path-scan.ts +++ b/src/main/native-chat/wsl-codex-session-path-scan.ts @@ -1,8 +1,7 @@ import type { Dirent } from 'node:fs' -import { readdir } from 'node:fs/promises' import { basename, extname } from 'node:path' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' -import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate' +import { wslGatedReaddir } from './wsl-transcript-fs-access' type ScanWaiter = { sessionId: string @@ -23,10 +22,7 @@ type ScanGeneration = { const inFlightScans = new Map() function readDirectory(dirPath: string, signal: AbortSignal): Promise { - return runWslTranscriptFsTask( - { operation: 'readdir', path: dirPath, priority: 'scan', signal }, - () => readdir(dirPath, { withFileTypes: true }) - ) + return wslGatedReaddir(dirPath, 'scan', signal) } function sessionFileName(path: string): string { diff --git a/src/main/native-chat/wsl-transcript-fs-access.test.ts b/src/main/native-chat/wsl-transcript-fs-access.test.ts index 6f978453239..aaaf18f2698 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.test.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.test.ts @@ -4,7 +4,6 @@ import type * as NodeFsPromisesModule from 'node:fs/promises' import type * as GateModule from './wsl-transcript-fs-gate' const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl' -const OTHER_DISTRO_UNC_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\a.jsonl' const LEGACY_UNC_PATH = '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl' const WINDOWS_PATH = 'C:\\Users\\ada\\.codex\\sessions\\a.jsonl' const POSIX_PATH = '/home/ada/.codex/sessions/a.jsonl' @@ -166,7 +165,7 @@ describe('transcript filesystem accessor on WSL UNC', () => { mocks.open.mockResolvedValue(handle) await expect(readTranscriptSlice(UNC_PATH, 4, 8, 'scan')).rejects.toThrow('EIO') - expect(handle.close).toHaveBeenCalledTimes(1) + expect(handle.close).toHaveBeenCalled() }) it('yields Buffer chunks and closes the handle when the consumer destroys the stream', async () => { @@ -193,7 +192,7 @@ describe('transcript filesystem accessor on WSL UNC', () => { expect(chunks).toHaveLength(1) expect(Buffer.isBuffer(chunks[0])).toBe(true) expect((chunks[0] as Buffer).toString('utf-8')).toBe('{"a":1}\n{"b":2}\n') - expect(handle.close).toHaveBeenCalledTimes(1) + expect(handle.close).toHaveBeenCalled() }) it('swallows close failures so teardown never rejects', async () => { @@ -203,31 +202,7 @@ describe('transcript filesystem accessor on WSL UNC', () => { await new Promise((resolve) => setImmediate(resolve)) }) - it('drains handle closes one at a time so teardown cannot flood the thread pool', async () => { - let releaseFirst: (() => void) | undefined - const first = fakeHandle() - first.close.mockReturnValue( - new Promise((resolve) => { - releaseFirst = resolve - }) - ) - const second = fakeHandle() - - await closeTranscriptHandle(first as never, UNC_PATH) - await closeTranscriptHandle(second as never, UNC_PATH) - await new Promise((resolve) => setImmediate(resolve)) - - // A blocked uv_fs_close holds a libuv thread the gate cannot see, so the - // second one must wait rather than occupy a thread of its own. - expect(first.close).toHaveBeenCalledTimes(1) - expect(second.close).not.toHaveBeenCalled() - - releaseFirst?.() - await new Promise((resolve) => setImmediate(resolve)) - expect(second.close).toHaveBeenCalledTimes(1) - }) - - it('keeps a blocked close on one distro from stranding teardown on another', async () => { + it('does not await a UNC close, mirroring the process-handle contract', async () => { let releaseStuck: (() => void) | undefined const stuck = fakeHandle() stuck.close.mockReturnValue( @@ -235,17 +210,11 @@ describe('transcript filesystem accessor on WSL UNC', () => { releaseStuck = resolve }) ) - const healthy = fakeHandle() try { - await closeTranscriptHandle(stuck as never, UNC_PATH) - await closeTranscriptHandle(healthy as never, OTHER_DISTRO_UNC_PATH) - await new Promise((resolve) => setImmediate(resolve)) - - // A close that never settles on a stalled mount would hold a shared lane - // for the process lifetime, leaking every later descriptor with it. + // A close blocked on a stalled mount must not block the caller's teardown. + await expect(closeTranscriptHandle(stuck as never, UNC_PATH)).resolves.toBeUndefined() expect(stuck.close).toHaveBeenCalledTimes(1) - expect(healthy.close).toHaveBeenCalledTimes(1) } finally { releaseStuck?.() await new Promise((resolve) => setImmediate(resolve)) @@ -289,7 +258,7 @@ describe('transcript filesystem accessor on WSL UNC', () => { } ) - it('closes an abandoned open whose syscall lands after the caller gave up', async () => { + it('disposes a late open result after the deadline already settled the task', async () => { vi.useFakeTimers() let release: ((handle: unknown) => void) | undefined mocks.open.mockReturnValue( @@ -306,7 +275,7 @@ describe('transcript filesystem accessor on WSL UNC', () => { release?.(handle) await vi.advanceTimersByTimeAsync(0) - // Nobody received the handle, so the gate owns closing it. + // Nobody was left to own the descriptor, so the gate's disposer closed it. expect(handle.close).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() diff --git a/src/main/native-chat/wsl-transcript-fs-access.ts b/src/main/native-chat/wsl-transcript-fs-access.ts index b50c1b1f5f6..c048fed0605 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.ts @@ -1,29 +1,58 @@ import { createReadStream, type Dirent, type Stats } from 'node:fs' -import { lstat, open, readdir, readFile, stat, type FileHandle } from 'node:fs/promises' +import { access, lstat, open, readdir, readFile, stat, type FileHandle } from 'node:fs/promises' import { Readable } from 'node:stream' import { StringDecoder } from 'node:string_decoder' import { isWslUncPath } from '../../shared/wsl-paths' import { runWslTranscriptFsTask, type WslTranscriptFsTaskPriority } from './wsl-transcript-fs-gate' -import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route' +import { + closeWslTranscriptFsProcess, + isWslTranscriptFsProcessHandle, + openWslTranscriptFsProcess, + readWslTranscriptFsProcess, + runWslTranscriptFsProcess, + type WslTranscriptFsProcessHandle +} from './wsl-transcript-fs-process-dispatch' +import type { WslTranscriptFsReusableProcessCall } from './wsl-transcript-fs-process-protocol' +import { wslTranscriptFsLaneKey } from './wsl-transcript-fs-route' /** Never nest a gated call inside another — that deadlocks the scan slot. */ // Why: one deadline per chunk instead of one for the whole file, so a large // healthy-but-slow transcript is not false-failed by a whole-file timeout. export const WSL_TRANSCRIPT_READ_CHUNK_BYTES = 1024 * 1024 -type Operation = Parameters[0]['operation'] -function runPathOperation( - operation: Operation, - path: string, +export type TranscriptFileHandle = FileHandle | WslTranscriptFsProcessHandle + +// One request object drives both the gate's dedupe/route key and the child +// call, so the two can never disagree on the operation or path. +function runReusableFsOperation( + request: WslTranscriptFsReusableProcessCall, priority: WslTranscriptFsTaskPriority, signal: AbortSignal | undefined, - task: () => Promise, - options?: { dedupe?: boolean; onAbandonedResult?: (value: T) => void } + localTask: () => Promise ): Promise { - return isWslUncPath(path) - ? runWslTranscriptFsTask({ operation, path, priority, signal, ...options }, task) - : task() + return isWslUncPath(request.path) + ? runWslTranscriptFsTask( + { operation: request.operation, path: request.path, priority, signal }, + (taskSignal) => + runWslTranscriptFsProcess( + request, + taskSignal, + wslTranscriptFsLaneKey(request.path, priority) + ) + ) + : localTask() +} + +export function wslGatedAccess( + path: string, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + return runReusableFsOperation({ operation: 'access', path }, priority, signal, async () => { + await access(path) + return true + }) } export function wslGatedStat( @@ -31,7 +60,7 @@ export function wslGatedStat( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise { - return runPathOperation('stat', path, priority, signal, () => stat(path)) + return runReusableFsOperation({ operation: 'stat', path }, priority, signal, () => stat(path)) } export function wslGatedLstat( @@ -39,7 +68,7 @@ export function wslGatedLstat( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise { - return runPathOperation('lstat', path, priority, signal, () => lstat(path)) + return runReusableFsOperation({ operation: 'lstat', path }, priority, signal, () => lstat(path)) } export function wslGatedReaddir( @@ -47,7 +76,7 @@ export function wslGatedReaddir( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise { - return runPathOperation('readdir', path, priority, signal, () => + return runReusableFsOperation({ operation: 'readdir', path }, priority, signal, () => readdir(path, { withFileTypes: true }) ) } @@ -58,7 +87,9 @@ export function wslGatedReadFile( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise { - return runPathOperation('readfile', path, priority, signal, () => readFile(path, encoding)) + return runReusableFsOperation({ operation: 'readfile', path, encoding }, priority, signal, () => + readFile(path, encoding) + ) } // dedupe:false — two joiners would share one FileHandle and both close it. @@ -66,13 +97,22 @@ export function wslGatedOpen( path: string, priority: WslTranscriptFsTaskPriority, signal?: AbortSignal -): Promise { - return runPathOperation('open', path, priority, signal, () => open(path, 'r'), { - dedupe: false, - // An unabortable open can still succeed after its caller timed out or - // cancelled; without this the descriptor leaks for the process lifetime. - onAbandonedResult: (handle) => void closeTranscriptHandle(handle, path) - }) +): Promise { + if (!isWslUncPath(path)) { + return open(path, 'r') + } + return runWslTranscriptFsTask( + { + operation: 'open', + path, + priority, + signal, + dedupe: false, + onAbandonedResult: (handle) => void closeTranscriptHandle(handle, path) + }, + (taskSignal) => + openWslTranscriptFsProcess(path, taskSignal, wslTranscriptFsLaneKey(path, priority)) + ) } /** @@ -82,7 +122,7 @@ export function wslGatedOpen( * (often `Buffer.allocUnsafe`) stays uninitialized. */ export function wslGatedRead( - handle: FileHandle, + handle: TranscriptFileHandle, path: string, buffer: Buffer, offset: number, @@ -91,69 +131,37 @@ export function wslGatedRead( priority: WslTranscriptFsTaskPriority, signal?: AbortSignal ): Promise<{ bytesRead: number; buffer: Buffer }> { - return runPathOperation( - 'read', - path, - priority, - signal, - () => handle.read(buffer, offset, length, position), - { dedupe: false } + // Handle kind decides before path spelling: a process-owned handle must never + // hit the FileHandle branch even if a caller re-derives the path off-UNC. + if (!isWslUncPath(path) && !isWslTranscriptFsProcessHandle(handle)) { + return (handle as FileHandle).read(buffer, offset, length, position) + } + return runWslTranscriptFsTask( + { operation: 'read', path, priority, signal, dedupe: false }, + async (taskSignal) => { + if (!isWslTranscriptFsProcessHandle(handle)) { + // The vitest fallback: a FileHandle read fills the caller's buffer itself. + return (handle as FileHandle).read(buffer, offset, length, position) + } + const body = await readWslTranscriptFsProcess(handle, position, length, taskSignal) + buffer.set(body, offset) + return { bytesRead: body.byteLength, buffer } + } ) } -// A blocked `uv_fs_close` holds a libuv threadpool thread just like a blocked -// read does, but it is invisible to the gate — so UNC teardown drains one handle -// at a time. Unbounded fire-and-forget closes are what would exhaust the pool the -// gate's two permits are sized against, re-creating the process-wide stall the -// gate exists to prevent; serializing them costs at most one extra busy thread. -// The queue cannot grow without bound: opens on a stuck route already fast-fail. -// Keyed by route like the gate's own admission, because a close that blocks on a -// stalled distro never settles — a shared queue would strand every later close, -// including handles on healthy distros, for the process lifetime. -const MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE = 1 -type RouteCloseQueue = { queued: FileHandle[]; active: number } -const closeQueuesByRoute = new Map() - -function drainQueuedCloses(route: string): void { - const lane = closeQueuesByRoute.get(route) - if (!lane) { - return +/** Never gated; process-owned handles retire on the client's bounded deadline. */ +export function closeTranscriptHandle(handle: TranscriptFileHandle, path: string): Promise { + if (isWslTranscriptFsProcessHandle(handle)) { + void closeWslTranscriptFsProcess(handle).catch(() => {}) + return Promise.resolve() } - while (lane.active < MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE) { - const handle = lane.queued.shift() - if (!handle) { - if (lane.active === 0) { - closeQueuesByRoute.delete(route) - } - return - } - lane.active += 1 - void handle - .close() - .catch(() => {}) - .finally(() => { - lane.active -= 1 - drainQueuedCloses(route) - }) - } -} - -/** - * Never gated. Off UNC this is the prior contract verbatim — the caller awaits - * fd teardown and a close failure surfaces. On UNC it is fire-and-forget: - * closing a handle on a stalled mount can itself block, and a gated close would - * burn a permit and a waiter deadline purely for teardown. One leaked fd until - * the OS unblocks beats a second blocked waiter. - */ -export function closeTranscriptHandle(handle: FileHandle, path: string): Promise { if (!isWslUncPath(path)) { return handle.close() } - const route = wslTranscriptFsRouteKey(path) - const lane = closeQueuesByRoute.get(route) ?? { queued: [], active: 0 } - closeQueuesByRoute.set(route, lane) - lane.queued.push(handle) - drainQueuedCloses(route) + // Only the vitest fallback pairs a FileHandle with a UNC path; mirror the + // process-handle contract there: fire-and-forget, close failures swallowed. + void handle.close().catch(() => {}) return Promise.resolve() } diff --git a/src/main/native-chat/wsl-transcript-fs-error.ts b/src/main/native-chat/wsl-transcript-fs-error.ts new file mode 100644 index 00000000000..f9f38812545 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-error.ts @@ -0,0 +1,56 @@ +export const WSL_TRANSCRIPT_FS_SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' +export const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE = + 'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.' +const WSL_TRANSCRIPT_FS_PROCESS_FAILURE_PREFIX = + 'WSL transcript files are temporarily unavailable because the filesystem helper process failed' + +export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable' + +export class WslTranscriptFsError extends Error { + constructor( + readonly code: WslTranscriptFsFailureCode, + message: string + ) { + super(message) + this.name = 'WslTranscriptFsError' + } +} + +export function wslTranscriptFsTimeoutError(): WslTranscriptFsError { + return new WslTranscriptFsError('timeout', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) +} + +export function wslTranscriptFsCapacityError(): WslTranscriptFsError { + return new WslTranscriptFsError('capacity', WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE) +} + +export function wslTranscriptFsUnavailableError(): WslTranscriptFsError { + return new WslTranscriptFsError('unavailable', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) +} + +/** Helper-process transport fault: nothing was consulted about the mount. */ +export function wslTranscriptFsProcessFailureError(detail: unknown): WslTranscriptFsError { + const text = detail instanceof Error ? detail.message : String(detail) + return new WslTranscriptFsError( + 'unavailable', + `${WSL_TRANSCRIPT_FS_PROCESS_FAILURE_PREFIX} (${text}). Try again shortly or restart Orca if the issue continues.` + ) +} + +/** Layers that only see a flattened message use this to spot a refusal. */ +export function isWslTranscriptFsRefusalMessage(message: string): boolean { + return ( + message === WSL_TRANSCRIPT_FS_SLOW_MESSAGE || + message === WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE || + message.startsWith(WSL_TRANSCRIPT_FS_PROCESS_FAILURE_PREFIX) + ) +} + +/** Narrow a caught error to a gate refusal, rethrowing anything else. */ +export function wslTranscriptFsRefusal(error: unknown): WslTranscriptFsError { + if (error instanceof WslTranscriptFsError) { + return error + } + throw error +} diff --git a/src/main/native-chat/wsl-transcript-fs-gate.test.ts b/src/main/native-chat/wsl-transcript-fs-gate.test.ts index 9c979002d01..b886168949a 100644 --- a/src/main/native-chat/wsl-transcript-fs-gate.test.ts +++ b/src/main/native-chat/wsl-transcript-fs-gate.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { + resetWslTranscriptFsGateForTests, runWslTranscriptFsTask, WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS, WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK, + WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS, WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, WslTranscriptFsError } from './wsl-transcript-fs-gate' @@ -43,6 +45,7 @@ describe('WSL transcript filesystem task scheduling', () => { let warnSpy: MockInstance beforeEach(() => { + resetWslTranscriptFsGateForTests() warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) }) @@ -278,7 +281,7 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('does not let one timed-out waiter settle a later shared waiter', async () => { + it('expires every waiter when shared work reaches its enforced deadline', async () => { vi.useFakeTimers() const work = deferred() const task = vi.fn(() => work.promise) @@ -288,11 +291,11 @@ describe('WSL transcript filesystem task scheduling', () => { const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' }) await vi.advanceTimersByTimeAsync(10_000) const second = run(path, 'exact', task) + const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' }) await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000) - await firstRejected + await Promise.all([firstRejected, secondRejected]) work.resolve('shared') - await expect(second).resolves.toBe('shared') expect(task).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) } finally { @@ -380,7 +383,7 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('fails new work fast after both running permits stall', async () => { + it('restores both permits after running work exceeds its deadline', async () => { vi.useFakeTimers() const ubuntu = deferred() const debian = deferred() @@ -393,10 +396,10 @@ describe('WSL transcript filesystem task scheduling', () => { await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) await Promise.all([firstRejected, secondRejected]) const laterTask = vi.fn(async () => 'later') - await expect( - run('\\\\wsl.localhost\\Fedora\\later', 'exact', laterTask) - ).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE }) - expect(laterTask).not.toHaveBeenCalled() + await expect(run('\\\\wsl.localhost\\Fedora\\later', 'exact', laterTask)).resolves.toBe( + 'later' + ) + expect(laterTask).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) } finally { ubuntu.resolve('ubuntu') @@ -406,7 +409,144 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('fails only work needing a stuck lane fast, keeping the other permit usable', async () => { + // Why: production tasks reject promptly on abort (the isolated process is + // killed). The sole waiter's timeout must not pre-empt the deadline timer — + // that would settle the task first and skip the route quarantine entirely. + it('quarantines the route when a sole-waiter abort-responsive task hits the deadline', async () => { + vi.useFakeTimers() + try { + const path = '\\\\wsl.localhost\\Ubuntu\\solo-stall' + const stalled = runWslTranscriptFsTask( + { operation: 'open', path, priority: 'exact', dedupe: false }, + (signal) => + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + ) + ) + const stalledRejected = expect(stalled).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stalledRejected + const retryTask = vi.fn(async () => 'retry') + await expect(run(path, 'exact', retryTask)).rejects.toMatchObject({ code: 'unavailable' }) + expect(retryTask).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('lifts a first-strike quarantine quickly and escalates on repeat stalls', async () => { + // performance.now drives the quarantine clock, so it must be faked too. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }) + const path = '\\\\wsl.localhost\\Ubuntu\\cold-start' + const stallOnce = (): Promise => + runWslTranscriptFsTask( + { operation: 'open', path, priority: 'exact', dedupe: false }, + (signal) => + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + ) + ) + try { + const first = stallOnce() + const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await firstRejected + await expect(run(path, 'exact', async () => 'early')).rejects.toMatchObject({ + code: 'unavailable' + }) + + // A cold-booted distro recovers here: admitted again after the base window. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) + const second = stallOnce() + const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await secondRejected + + // Second strike doubles the back-off: still blocked after one base window. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) + await expect(run(path, 'exact', async () => 'still-blocked')).rejects.toMatchObject({ + code: 'unavailable' + }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) + await expect(run(path, 'exact', async () => 'recovered')).resolves.toBe('recovered') + + // The success reset the strikes: the next stall quarantines for the base + // window again instead of continuing the escalation. + const third = stallOnce() + const thirdRejected = expect(third).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await thirdRejected + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) + await expect(run(path, 'exact', async () => 'reset')).resolves.toBe('reset') + } finally { + vi.useRealTimers() + } + }) + + // Why: a helper transport fault (child died, fork failed) consulted nothing + // about the mount, so it must not erase a live quarantine or its strikes. + it('keeps the quarantine when a same-route transport fault settles', async () => { + vi.useFakeTimers() + const stalledExact = deferred() + const scanWork = deferred() + try { + const stalled = run('\\\\wsl.localhost\\Ubuntu\\quarantine-me', 'exact', () => { + return stalledExact.promise + }) + const stalledRejected = expect(stalled).rejects.toMatchObject({ code: 'timeout' }) + const scan = run('\\\\wsl.localhost\\Ubuntu\\scan-tree', 'scan', () => scanWork.promise) + const scanRejected = expect(scan).rejects.toMatchObject({ code: 'unavailable' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stalledRejected + + // The still-running scan's helper dies: a transport fault, not an answer. + scanWork.reject(new WslTranscriptFsError('unavailable', 'helper process died')) + await vi.advanceTimersByTimeAsync(0) + await scanRejected + + const retryTask = vi.fn(async () => 'retry') + await expect( + run('\\\\wsl.localhost\\Ubuntu\\retry', 'exact', retryTask) + ).rejects.toMatchObject({ code: 'unavailable' }) + expect(retryTask).not.toHaveBeenCalled() + } finally { + stalledExact.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('lets abandoned running work finish instead of aborting its process', async () => { + const work = deferred() + const controller = new AbortController() + const sawAbort = vi.fn() + const task = vi.fn((signal: AbortSignal) => { + signal.addEventListener('abort', sawAbort, { once: true }) + return work.promise + }) + const pending = runWslTranscriptFsTask( + { + operation: 'read', + path: '\\\\wsl.localhost\\Ubuntu\\abandoned-read', + priority: 'exact', + dedupe: false, + signal: controller.signal + }, + task + ) + await vi.waitFor(() => expect(task).toHaveBeenCalledOnce()) + const reason = new Error('caller moved on') + controller.abort(reason) + await expect(pending).rejects.toBe(reason) + // A healthy in-flight process op keeps running; only the deadline kills. + expect(sawAbort).not.toHaveBeenCalled() + work.resolve('late') + await Promise.resolve() + }) + + it('quarantines an expired route while keeping healthy routes usable', async () => { vi.useFakeTimers() const stalled = deferred() try { @@ -418,7 +558,7 @@ describe('WSL transcript filesystem task scheduling', () => { const sameLaneTask = vi.fn(async () => 'same-lane') await expect( run('\\\\wsl.localhost\\Ubuntu\\lane-stuck-sibling', 'exact', sameLaneTask) - ).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE }) + ).rejects.toMatchObject({ code: 'unavailable' }) expect(sameLaneTask).not.toHaveBeenCalled() await expect( run('\\\\wsl.localhost\\Debian\\healthy-lane', 'exact', async () => 'debian') @@ -430,7 +570,7 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('fails scans and same-route probes fast while a scan is stuck', async () => { + it('restores the scan slot while quarantining the expired route', async () => { vi.useFakeTimers() const stalled = deferred() try { @@ -442,10 +582,8 @@ describe('WSL transcript filesystem task scheduling', () => { const otherScanTask = vi.fn(async () => 'other-scan') await expect( run('\\\\wsl.localhost\\Debian\\other-tree', 'scan', otherScanTask) - ).rejects.toMatchObject({ code: 'unavailable' }) - expect(otherScanTask).not.toHaveBeenCalled() - // The whole Ubuntu mount is hung — an exact probe there would only burn - // the remaining permit on it. + ).resolves.toBe('other-scan') + expect(otherScanTask).toHaveBeenCalledOnce() await expect( run('\\\\wsl.localhost\\Ubuntu\\live-probe', 'exact', async () => 'exact') ).rejects.toMatchObject({ code: 'unavailable' }) @@ -459,7 +597,7 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('keeps a stuck exact probe from feeding a doomed scan the second permit', async () => { + it('does not spend a replacement process on a quarantined route', async () => { vi.useFakeTimers() const stalled = deferred() try { @@ -483,8 +621,9 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('admits new work again once stuck work finally settles', async () => { - vi.useFakeTimers() + it('ignores a late result after admitting replacement work', async () => { + // performance.now drives the quarantine clock, so it must be faked too. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }) const stalled = deferred() try { const stuck = run('\\\\wsl.localhost\\Ubuntu\\recovering', 'exact', () => stalled.promise) @@ -495,8 +634,15 @@ describe('WSL transcript filesystem task scheduling', () => { run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'blocked') ).rejects.toMatchObject({ code: 'unavailable' }) + // A value that only lands past the deadline is the stall itself, not + // proof of health: it must not cut the back-off short. stalled.resolve('late') await vi.advanceTimersByTimeAsync(0) + await expect( + run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'still-blocked') + ).rejects.toMatchObject({ code: 'unavailable' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) await expect( run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'recovered') ).resolves.toBe('recovered') @@ -508,8 +654,9 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('refuses to join in-flight work already past its own deadline', async () => { - vi.useFakeTimers() + it('starts a new generation after shared work reaches its deadline', async () => { + // performance.now drives the quarantine clock, so it must be faked too. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }) const work = deferred() const task = vi.fn(() => work.promise) try { @@ -521,12 +668,13 @@ describe('WSL transcript filesystem task scheduling', () => { const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' }) await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000) - await firstRejected + await Promise.all([firstRejected, secondRejected]) + work.resolve('late') + // The late value settles nothing, so the deadline's back-off still runs. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) const joinTask = vi.fn(async () => 'join') - await expect(run(path, 'exact', joinTask)).rejects.toMatchObject({ code: 'unavailable' }) - expect(joinTask).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(10_000) - await secondRejected + await expect(run(path, 'exact', joinTask)).resolves.toBe('join') + expect(joinTask).toHaveBeenCalledOnce() } finally { work.resolve('late') await vi.advanceTimersByTimeAsync(0) @@ -534,7 +682,7 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('fails joins onto queued work fast when stuck I/O keeps it from running', async () => { + it('fails queued same-route work fast when the deadline quarantines it', async () => { vi.useFakeTimers() const stalled = deferred() const queuedTask = vi.fn(async () => 'queued') @@ -542,19 +690,15 @@ describe('WSL transcript filesystem task scheduling', () => { const stuck = run('\\\\wsl.localhost\\Ubuntu\\trap-hung', 'exact', () => stalled.promise) const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) await vi.advanceTimersByTimeAsync(5_000) - // Queued behind the same route before the hang is detected. + // Queued behind the same route before the hang is detected. Left queued + // it would strand a full waiter deadline; a sequential multi-file scan + // would then pay one deadline per file. const queuedPath = '\\\\wsl.localhost\\Ubuntu\\trap-queued' const queued = run(queuedPath, 'exact', queuedTask) - const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'timeout' }) + const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'unavailable' }) await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 5_000) await stuckRejected - // Re-requests must not keep the doomed queued task alive with fresh - // deadlines — that would defeat the fail-fast for as long as I/O hangs. - await expect(run(queuedPath, 'exact', queuedTask)).rejects.toMatchObject({ - code: 'unavailable' - }) - await vi.advanceTimersByTimeAsync(5_000) await queuedRejected expect(queuedTask).not.toHaveBeenCalled() } finally { @@ -564,7 +708,7 @@ describe('WSL transcript filesystem task scheduling', () => { } }) - it('does not feed a stuck route the other permit from the queue', async () => { + it('keeps exact work moving while an isolated scan process is stalled', async () => { vi.useFakeTimers() const scanWork = deferred() const debianWork = deferred() @@ -587,16 +731,13 @@ describe('WSL transcript filesystem task scheduling', () => { // Queued before Ubuntu's hang is detected at the 60s scan deadline. const queuedU = run('\\\\wsl.localhost\\Ubuntu\\queued-route-file', 'exact', ubuntuTask) - const queuedURejected = expect(queuedU).rejects.toMatchObject({ code: 'timeout' }) + await expect(queuedU).resolves.toBe('ubuntu') await vi.advanceTimersByTimeAsync(25_000) await scanRejected debianWork.resolve('debian') await vi.advanceTimersByTimeAsync(0) - expect(ubuntuTask).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(5_000) - await queuedURejected - expect(ubuntuTask).not.toHaveBeenCalled() + expect(ubuntuTask).toHaveBeenCalledOnce() } finally { scanWork.resolve('late') debianWork.resolve('debian') diff --git a/src/main/native-chat/wsl-transcript-fs-gate.ts b/src/main/native-chat/wsl-transcript-fs-gate.ts index 84848e13f11..d4413c91323 100644 --- a/src/main/native-chat/wsl-transcript-fs-gate.ts +++ b/src/main/native-chat/wsl-transcript-fs-gate.ts @@ -1,4 +1,16 @@ -import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route' +import { wslTranscriptFsLaneKey, wslTranscriptFsRouteKey } from './wsl-transcript-fs-route' +import { + WslTranscriptFsError, + wslTranscriptFsCapacityError as capacityError, + wslTranscriptFsTimeoutError as timeoutError, + wslTranscriptFsUnavailableError as unavailableError +} from './wsl-transcript-fs-error' +import { + liftRouteQuarantine, + quarantineRoute, + resetRouteQuarantinesForTests, + routeIsBlocked +} from './wsl-transcript-fs-route-quarantine' const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2 export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000 @@ -6,30 +18,14 @@ export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000 // Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely. export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64 export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64 -export const WSL_TRANSCRIPT_FS_SLOW_MESSAGE = - 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' -export const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE = - 'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.' - -export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable' - -export class WslTranscriptFsError extends Error { - constructor( - readonly code: WslTranscriptFsFailureCode, - message: string - ) { - super(message) - this.name = 'WslTranscriptFsError' - } -} - -/** Narrow a caught error to a gate refusal, rethrowing anything else. */ -export function wslTranscriptFsRefusal(error: unknown): WslTranscriptFsError { - if (error instanceof WslTranscriptFsError) { - return error - } - throw error -} +export { + WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE, + WSL_TRANSCRIPT_FS_SLOW_MESSAGE, + WslTranscriptFsError, + wslTranscriptFsRefusal, + type WslTranscriptFsFailureCode +} from './wsl-transcript-fs-error' +export { WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS } from './wsl-transcript-fs-route-quarantine' export type WslTranscriptFsTaskPriority = 'exact' | 'scan' @@ -52,10 +48,9 @@ type ScheduledTask = { controller: AbortController waiters: Set> state: 'queued' | 'running' | 'settled' - // Set by the deadline timer, sharing the waiters' monotonic clock — wall time - // would misjudge stuckness across laptop sleep or NTP steps. - stuck: boolean - stuckTimer?: ReturnType + deadlineTimer?: ReturnType + /** When the pump admitted the task; tells the quarantine which incident it saw. */ + startedAt?: number } type UnknownScheduledTask = ScheduledTask @@ -88,10 +83,11 @@ function abandonTaskIfUnused(task: UnknownScheduledTask, reason?: unknown): void if (task.waiters.size > 0 || task.state === 'settled') { return } - task.controller.abort(reason) - // Running I/O keeps its permit; new callers need a reusable controller. + // Running I/O keeps its permit and its process: an abort here would kill a + // healthy child and pre-empt the deadline's quarantine. Only the deadline aborts. clearTask(task) if (task.state === 'queued') { + task.controller.abort(reason) task.state = 'settled' removeQueuedTask(task) pumpTasks() @@ -117,35 +113,19 @@ function timeoutMs(priority: WslTranscriptFsTaskPriority): number { : WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } -function timeoutError(): WslTranscriptFsError { - return new WslTranscriptFsError('timeout', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) -} - -function capacityError(): WslTranscriptFsError { - return new WslTranscriptFsError('capacity', WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE) -} - -function unavailableError(): WslTranscriptFsError { - return new WslTranscriptFsError('unavailable', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) -} - -/** - * The stuck running task that would doom a new task's admission: one on the - * same route (the whole distro mount is hung, whatever the priority — spending - * the other permit on it escalates one bad distro into a global outage), one - * holding the single scan slot, or (with every permit stuck) any permit. - */ -function stuckBlocker( - route: string, - priority: WslTranscriptFsTaskPriority -): UnknownScheduledTask | undefined { - const stuck = [...activeTasks].filter((task) => task.stuck) - if (stuck.length === MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) { - return stuck[0] +// Why: a task queued behind a quarantined route would otherwise strand until +// its own waiter deadline — one full deadline per file in a sequential scan. +function failQueuedRouteTasks(route: string): void { + const doomed = queuedTasks.filter((task) => task.route === route && task.state === 'queued') + for (const task of doomed) { + task.state = 'settled' + removeQueuedTask(task) + clearTask(task) + for (const waiter of task.waiters) { + removeWaiter(task, waiter) + waiter.reject(unavailableError()) + } } - return stuck.find( - (task) => task.route === route || (priority === 'scan' && task.priority === 'scan') - ) } // Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields @@ -170,8 +150,7 @@ function attachWaiter(task: ScheduledTask, signal?: AbortSignal): Promise< } signal.addEventListener('abort', waiter.onAbort, { once: true }) } - // UNC/9P calls cannot be aborted; two blocked calls can retain both slots until settling or - // Orca restarts, while caller and queue retention remains bounded. + // The task deadline also replaces the isolated process; this bounds each caller's own wait. waiter.timeout = setTimeout(() => { const error = timeoutError() if (!removeWaiter(task, waiter)) { @@ -186,15 +165,34 @@ function attachWaiter(task: ScheduledTask, signal?: AbortSignal): Promise< function settleTask(task: ScheduledTask, result: { value: T } | { error: unknown }): void { if (task.state !== 'running') { + // A result that only lands past the deadline is the stall the quarantine + // was set for, so it never lifts the back-off. A late value also has no + // owner: dispose it. + if ('value' in result) { + try { + task.onAbandonedResult?.(result.value) + } catch { + // Best-effort teardown; nothing left to report it to. + } + } return } + // Only a settle the deadline did not force proves the mount answered; a + // transport fault (WslTranscriptFsError from a dead helper) proves nothing. + if ( + 'value' in result || + (result.error !== task.controller.signal.reason && + !(result.error instanceof WslTranscriptFsError)) + ) { + liftRouteQuarantine(task.route) + } task.state = 'settled' if (task.priority === 'scan') { activeScanCount -= 1 } activeLaneKeys.delete(task.laneKey) activeTasks.delete(task as UnknownScheduledTask) - clearTimeout(task.stuckTimer) + clearTimeout(task.deadlineTimer) clearTask(task as UnknownScheduledTask) // Why: an unabortable syscall can still succeed after its last waiter timed // out or cancelled. A resource-valued result (open's FileHandle) then has no @@ -222,11 +220,6 @@ function settleTask(task: ScheduledTask, result: { value: T } | { error: u } function nextTaskIndex(): number { - // Tasks queued before their route hung must not be fed the other permit; - // their waiters drain by deadline while healthy routes use the slot. - const stuckRoutes = new Set( - [...activeTasks].filter((task) => task.stuck).map((task) => task.route) - ) for (const priority of ['exact', 'scan'] as const) { // Why: keep one libuv slot available for a live transcript probe. if (priority === 'scan' && activeScanCount > 0) { @@ -236,7 +229,7 @@ function nextTaskIndex(): number { (task) => task.priority === priority && !activeLaneKeys.has(task.laneKey) && - !stuckRoutes.has(task.route) + !routeIsBlocked(task.route) ) if (index !== -1) { return index @@ -256,21 +249,25 @@ function pumpTasks(): void { continue } task.state = 'running' + task.startedAt = performance.now() if (task.priority === 'scan') { activeScanCount += 1 } activeLaneKeys.add(task.laneKey) activeTasks.add(task) - // Anchored at run start: queue wait says nothing about the I/O itself, so - // the fail-fast may lag admission by at most one deadline period. - task.stuckTimer = setTimeout(() => { - task.stuck = true + task.deadlineTimer = setTimeout(() => { + const error = timeoutError() console.warn( - `[wsl-transcript-fs-gate] ${task.priority} filesystem task still running after ` + - `${timeoutMs(task.priority)}ms and holding a permit: ${task.key}` + `[wsl-transcript-fs-gate] ${task.priority} filesystem task exceeded ` + + `${timeoutMs(task.priority)}ms; replacing its I/O process: ${task.key}` ) + // Keep polling from churning replacement processes on the same stalled mount. + quarantineRoute(task.route, timeoutMs(task.priority), task.startedAt ?? performance.now()) + failQueuedRouteTasks(task.route) + task.controller.abort(error) + settleTask(task, { error }) }, timeoutMs(task.priority)) - task.stuckTimer.unref?.() + task.deadlineTimer.unref?.() void Promise.resolve() .then(() => { task.controller.signal.throwIfAborted() @@ -283,16 +280,11 @@ function pumpTasks(): void { } } -/** - * Test-only: drop every task and counter so a case that leaves a task stalled - * (holding a permit, marking its route stuck) cannot fast-fail the next one. - * Tasks are marked settled first, so a late resolution from the unabortable - * syscall they are still blocked on cannot decrement counters this just zeroed. - */ +/** Test-only: drop every task, route quarantine, and counter. */ export function resetWslTranscriptFsGateForTests(): void { for (const task of [...activeTasks, ...queuedTasks]) { task.state = 'settled' - clearTimeout(task.stuckTimer) + clearTimeout(task.deadlineTimer) for (const waiter of task.waiters) { removeWaiter(task, waiter) } @@ -301,6 +293,7 @@ export function resetWslTranscriptFsGateForTests(): void { queuedTasks.length = 0 inFlightTasks.clear() activeLaneKeys.clear() + resetRouteQuarantinesForTests() activeScanCount = 0 } @@ -334,21 +327,14 @@ export function runWslTranscriptFsTask( : JSON.stringify([options.operation, options.path, options.priority]) const existing = inFlightTasks.get(key) as ScheduledTask | undefined if (existing) { - // A stuck target — or a queued target that stuck I/O keeps from ever - // running — dooms every joiner to a slow timeout, and each fresh joiner - // would keep the queued task alive past its own abandonment. Fail fast. - if ( - existing.stuck || - (existing.state === 'queued' && stuckBlocker(existing.route, existing.priority)) - ) { - return Promise.reject(unavailableError()) - } + // Join even under a route quarantine: the in-flight task costs no new I/O, + // is bounded by its own deadline, and its settle may itself lift the + // quarantine. A queued task cannot linger on a quarantined route — + // failQueuedRouteTasks cleared it when the quarantine was set. return attachWaiter(existing, options.signal) } const route = wslTranscriptFsRouteKey(options.path) - // Fail fast when the permit, route, or scan slot this task needs is held by - // stuck I/O — queueing would only burn the caller's full deadline. - if (stuckBlocker(route, options.priority)) { + if (routeIsBlocked(route)) { return Promise.reject(unavailableError()) } if (queuedTasks.length >= WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS) { @@ -357,15 +343,14 @@ export function runWslTranscriptFsTask( const scheduled: ScheduledTask = { key, - laneKey: `${route}:${options.priority}`, + laneKey: wslTranscriptFsLaneKey(options.path, options.priority), route, priority: options.priority, operation: task, onAbandonedResult: options.onAbandonedResult, controller: new AbortController(), waiters: new Set(), - state: 'queued', - stuck: false + state: 'queued' } inFlightTasks.set(key, scheduled as UnknownScheduledTask) const result = attachWaiter(scheduled, options.signal) diff --git a/src/main/native-chat/wsl-transcript-fs-process-client.test.ts b/src/main/native-chat/wsl-transcript-fs-process-client.test.ts new file mode 100644 index 00000000000..177b9270076 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-client.test.ts @@ -0,0 +1,510 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import type { Dirent, Stats } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { WslTranscriptFsProcessClient } from './wsl-transcript-fs-process-client' +import { + WSL_TRANSCRIPT_FS_PROCESS_CLOSE_TIMEOUT_MS, + WSL_TRANSCRIPT_FS_PROCESS_IDLE_REAP_MS +} from './wsl-transcript-fs-process-slot' +import { + resolveWslTranscriptFsProcessEntryPath, + wslTranscriptFsProcessForkEnv +} from './wsl-transcript-fs-process-spawn' +import type { + WslTranscriptFsProcessRequest, + WslTranscriptFsProcessResponse +} from './wsl-transcript-fs-process-protocol' + +class FakeProcess extends EventEmitter { + readonly sent: WslTranscriptFsProcessRequest[] = [] + readonly kill = vi.fn(() => true) + readonly unref = vi.fn() + readonly channel = { unref: vi.fn() } + + send(message: WslTranscriptFsProcessRequest, callback?: (error: Error | null) => void): boolean { + this.sent.push(message) + callback?.(null) + return true + } + + respond(response: WslTranscriptFsProcessResponse): void { + this.emit('message', response) + } +} + +function fakeChild(process: FakeProcess): ChildProcess { + return process as unknown as ChildProcess +} + +describe('WSL transcript filesystem process client', () => { + it('reuses a healthy process for sequential operations', async () => { + const child = new FakeProcess() + const factory = vi.fn(() => fakeChild(child)) + const client = new WslTranscriptFsProcessClient(factory) + + const first = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Ubuntu\\one' }, + new AbortController().signal + ) + child.respond({ id: child.sent[0].id, ok: true, value: true }) + await expect(first).resolves.toBe(true) + + const second = client.run( + { + operation: 'readfile', + path: '\\\\wsl.localhost\\Ubuntu\\two', + encoding: 'utf8' + }, + new AbortController().signal + ) + child.respond({ id: child.sent[1].id, ok: true, value: 'body' }) + + await expect(second).resolves.toBe('body') + expect(factory).toHaveBeenCalledOnce() + expect(child.kill).not.toHaveBeenCalled() + client.dispose() + }) + + it('kills an aborted process and uses a replacement for later work', async () => { + const firstChild = new FakeProcess() + const replacement = new FakeProcess() + const factory = vi + .fn<() => ChildProcess>() + .mockReturnValueOnce(fakeChild(firstChild)) + .mockReturnValueOnce(fakeChild(replacement)) + const client = new WslTranscriptFsProcessClient(factory) + const controller = new AbortController() + const reason = new Error('deadline expired') + + const stalled = client.run( + { operation: 'stat', path: '\\\\wsl.localhost\\Ubuntu\\stalled' }, + controller.signal + ) + controller.abort(reason) + + await expect(stalled).rejects.toBe(reason) + expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL') + + const later = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Fedora\\later' }, + new AbortController().signal + ) + replacement.respond({ id: replacement.sent[0].id, ok: true, value: true }) + await expect(later).resolves.toBe(true) + expect(factory).toHaveBeenCalledTimes(2) + client.dispose() + }) + + it('multiplexes sequential opened handles through one process until close', async () => { + const owner = new FakeProcess() + const factory = vi.fn(() => fakeChild(owner)) + const client = new WslTranscriptFsProcessClient(factory) + const signal = new AbortController().signal + + const opening = client.open('\\\\wsl.localhost\\Ubuntu\\transcript', signal) + owner.respond({ id: owner.sent[0].id, ok: true, value: 41 }) + const handle = await opening + + const secondOpening = client.open('\\\\wsl.localhost\\Ubuntu\\second', signal) + owner.respond({ id: owner.sent[1].id, ok: true, value: 42 }) + const secondHandle = await secondOpening + + const reading = client.read(handle, 8, 4, signal) + expect(owner.sent[2]).toMatchObject({ operation: 'read', handleId: 41, position: 8 }) + owner.respond({ id: owner.sent[2].id, ok: true, value: Buffer.from('old!') }) + await expect(reading).resolves.toEqual(Buffer.from('old!')) + + const secondReading = client.read(secondHandle, 0, 3, signal) + expect(owner.sent[3]).toMatchObject({ operation: 'read', handleId: 42, position: 0 }) + owner.respond({ id: owner.sent[3].id, ok: true, value: Buffer.from('new') }) + await expect(secondReading).resolves.toEqual(Buffer.from('new')) + + const closing = client.close(handle) + expect(owner.sent[4]).toMatchObject({ operation: 'close', handleId: 41 }) + owner.respond({ id: owner.sent[4].id, ok: true, value: true }) + await expect(closing).resolves.toBeUndefined() + + const secondClosing = client.close(secondHandle) + expect(owner.sent[5]).toMatchObject({ operation: 'close', handleId: 42 }) + owner.respond({ id: owner.sent[5].id, ok: true, value: true }) + await expect(secondClosing).resolves.toBeUndefined() + + const later = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Ubuntu\\later' }, + signal + ) + owner.respond({ id: owner.sent[6].id, ok: true, value: true }) + await expect(later).resolves.toBe(true) + expect(factory).toHaveBeenCalledOnce() + client.dispose() + }) + + it('queues concurrent opens instead of creating another helper', async () => { + const child = new FakeProcess() + const factory = vi.fn(() => fakeChild(child)) + const client = new WslTranscriptFsProcessClient(factory) + const signal = new AbortController().signal + + const firstOpening = client.open('\\\\wsl.localhost\\Ubuntu\\first', signal) + const secondOpening = client.open('\\\\wsl.localhost\\Ubuntu\\second', signal) + expect(factory).toHaveBeenCalledOnce() + expect(child.sent).toHaveLength(1) + + child.respond({ id: child.sent[0].id, ok: true, value: 1 }) + const firstHandle = await firstOpening + await vi.waitFor(() => expect(child.sent).toHaveLength(2)) + child.respond({ id: child.sent[1].id, ok: true, value: 2 }) + const secondHandle = await secondOpening + + const firstClose = client.close(firstHandle) + child.respond({ id: child.sent[2].id, ok: true, value: true }) + await firstClose + const secondClose = client.close(secondHandle) + child.respond({ id: child.sent[3].id, ok: true, value: true }) + await secondClose + + expect(factory).toHaveBeenCalledOnce() + client.dispose() + }) + + it('returns a granted slot when its queued caller aborts before sending', async () => { + const child = new FakeProcess() + const client = new WslTranscriptFsProcessClient(() => fakeChild(child)) + const controller = new AbortController() + const signal = new AbortController().signal + + const first = client.run({ operation: 'access', path: 'first' }, signal) + const aborted = client.run({ operation: 'access', path: 'aborted' }, controller.signal) + child.respond({ id: child.sent[0].id, ok: true, value: true }) + controller.abort(new Error('cancelled after grant')) + + await expect(first).resolves.toBe(true) + await expect(aborted).rejects.toThrow('cancelled after grant') + const later = client.run({ operation: 'access', path: 'later' }, signal) + expect(child.sent).toHaveLength(2) + child.respond({ id: child.sent[1].id, ok: true, value: true }) + await expect(later).resolves.toBe(true) + client.dispose() + }) + + it('rejects a granted request if the helper exits before it sends', async () => { + const child = new FakeProcess() + const replacement = new FakeProcess() + const factory = vi + .fn<() => ChildProcess>() + .mockReturnValueOnce(fakeChild(child)) + .mockReturnValueOnce(fakeChild(replacement)) + const client = new WslTranscriptFsProcessClient(factory) + const signal = new AbortController().signal + + const first = client.run({ operation: 'access', path: 'first' }, signal) + const stranded = client.run({ operation: 'access', path: 'stranded' }, signal) + child.respond({ id: child.sent[0].id, ok: true, value: true }) + child.emit('disconnect') + + await expect(first).resolves.toBe(true) + await expect(stranded).rejects.toMatchObject({ code: 'unavailable' }) + expect(replacement.sent).toHaveLength(0) + client.dispose() + }) + + // Why: the gate waiter can give up mid-read and the caller's finally closes + // right away; a refused close would strand the pinned slot (and its child) + // forever once that read settles. + it('defers a close issued while a read is in flight instead of stranding the slot', async () => { + const owner = new FakeProcess() + const factory = vi.fn(() => fakeChild(owner)) + const client = new WslTranscriptFsProcessClient(factory) + const signal = new AbortController().signal + + const opening = client.open('\\\\wsl.localhost\\Ubuntu\\transcript', signal) + owner.respond({ id: owner.sent[0].id, ok: true, value: 5 }) + const handle = await opening + + const reading = client.read(handle, 0, 4, signal) + const closing = client.close(handle) + // The close waits for the in-flight read; nothing extra was sent yet. + expect(owner.sent).toHaveLength(2) + + owner.respond({ id: owner.sent[1].id, ok: true, value: Buffer.from('data') }) + await expect(reading).resolves.toEqual(Buffer.from('data')) + await vi.waitFor(() => expect(owner.sent).toHaveLength(3)) + expect(owner.sent[2]).toMatchObject({ operation: 'close', handleId: 5 }) + owner.respond({ id: owner.sent[2].id, ok: true, value: true }) + await expect(closing).resolves.toBeUndefined() + + // The slot returned to the pool instead of leaking pinned. + const later = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Ubuntu\\after-close' }, + signal + ) + owner.respond({ id: owner.sent[3].id, ok: true, value: true }) + await expect(later).resolves.toBe(true) + expect(factory).toHaveBeenCalledOnce() + expect(owner.kill).not.toHaveBeenCalled() + client.dispose() + }) + + it('invalidates lane handles and serves queued work from a replacement', async () => { + const owner = new FakeProcess() + const healthy = new FakeProcess() + const factory = vi + .fn<() => ChildProcess>() + .mockReturnValueOnce(fakeChild(owner)) + .mockReturnValueOnce(fakeChild(healthy)) + const client = new WslTranscriptFsProcessClient(factory) + const opening = client.open( + '\\\\wsl.localhost\\Ubuntu\\transcript', + new AbortController().signal + ) + owner.respond({ id: owner.sent[0].id, ok: true, value: 9 }) + const handle = await opening + const controller = new AbortController() + const reason = new Error('read deadline') + + const stalled = client.read(handle, 0, 1, controller.signal) + const other = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Fedora\\healthy' }, + new AbortController().signal + ) + controller.abort(reason) + + await expect(stalled).rejects.toBe(reason) + await vi.waitFor(() => expect(healthy.sent).toHaveLength(1)) + healthy.respond({ id: healthy.sent[0].id, ok: true, value: true }) + await expect(other).resolves.toBe(true) + // The handle died with its killed process — a transport condition, so later + // reads surface as a retryable refusal rather than a caller EBADF bug. + await expect(client.read(handle, 0, 1, new AbortController().signal)).rejects.toMatchObject({ + name: 'WslTranscriptFsError', + code: 'unavailable' + }) + expect(owner.kill).toHaveBeenCalledWith('SIGKILL') + expect(healthy.kill).not.toHaveBeenCalled() + + const later = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Fedora\\later' }, + new AbortController().signal + ) + healthy.respond({ id: healthy.sent[1].id, ok: true, value: true }) + await expect(later).resolves.toBe(true) + client.dispose() + }) + + it('retires a process whose handle close does not settle', async () => { + vi.useFakeTimers() + const owner = new FakeProcess() + const healthy = new FakeProcess() + const factory = vi + .fn<() => ChildProcess>() + .mockReturnValueOnce(fakeChild(owner)) + .mockReturnValueOnce(fakeChild(healthy)) + const client = new WslTranscriptFsProcessClient(factory) + try { + const opening = client.open( + '\\\\wsl.localhost\\Ubuntu\\transcript', + new AbortController().signal + ) + owner.respond({ id: owner.sent[0].id, ok: true, value: 12 }) + const handle = await opening + const closing = client.close(handle) + const closeFailure = expect(closing).rejects.toThrow('close timed out') + + const unrelated = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Fedora\\healthy' }, + new AbortController().signal + ) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_PROCESS_CLOSE_TIMEOUT_MS) + await closeFailure + await vi.waitFor(() => expect(healthy.sent).toHaveLength(1)) + healthy.respond({ id: healthy.sent[0].id, ok: true, value: true }) + await expect(unrelated).resolves.toBe(true) + expect(owner.kill).toHaveBeenCalledWith('SIGKILL') + expect(healthy.kill).not.toHaveBeenCalled() + } finally { + client.dispose() + vi.useRealTimers() + } + }) + + it('reaps an idle process after the idle deadline and forks a fresh one later', async () => { + vi.useFakeTimers() + const child = new FakeProcess() + const replacement = new FakeProcess() + const factory = vi + .fn<() => ChildProcess>() + .mockReturnValueOnce(fakeChild(child)) + .mockReturnValueOnce(fakeChild(replacement)) + const client = new WslTranscriptFsProcessClient(factory) + try { + const first = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Ubuntu\\one' }, + new AbortController().signal + ) + child.respond({ id: child.sent[0].id, ok: true, value: true }) + await expect(first).resolves.toBe(true) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_PROCESS_IDLE_REAP_MS) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + + const later = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Ubuntu\\two' }, + new AbortController().signal + ) + replacement.respond({ id: replacement.sent[0].id, ok: true, value: true }) + await expect(later).resolves.toBe(true) + expect(factory).toHaveBeenCalledTimes(2) + } finally { + client.dispose() + vi.useRealTimers() + } + }) + + it('surfaces child transport faults as unavailable gate refusals', async () => { + const child = new FakeProcess() + const client = new WslTranscriptFsProcessClient(() => fakeChild(child)) + const pending = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Ubuntu\\dead' }, + new AbortController().signal + ) + child.emit('exit', 9) + await expect(pending).rejects.toMatchObject({ + name: 'WslTranscriptFsError', + code: 'unavailable' + }) + client.dispose() + }) + + it('names the killing signal instead of a null exit code', async () => { + const child = new FakeProcess() + const client = new WslTranscriptFsProcessClient(() => fakeChild(child)) + const pending = client.run( + { operation: 'access', path: '\\\\wsl.localhost\\Ubuntu\\killed' }, + new AbortController().signal + ) + // A signal-terminated child reports code null; the message must carry the signal. + child.emit('exit', null, 'SIGKILL') + await expect(pending).rejects.toMatchObject({ + name: 'WslTranscriptFsError', + code: 'unavailable', + message: expect.stringContaining('exited (SIGKILL)') + }) + client.dispose() + }) + + it('reconstructs filesystem errors with their Node error code', async () => { + const child = new FakeProcess() + const client = new WslTranscriptFsProcessClient(() => fakeChild(child)) + const pending = client.run( + { operation: 'stat', path: '\\\\wsl.localhost\\Ubuntu\\missing' }, + new AbortController().signal + ) + + child.respond({ + id: child.sent[0].id, + ok: false, + error: { + name: 'Error', + message: 'not found', + code: 'ENOENT', + syscall: 'stat', + path: '\\\\wsl.localhost\\Ubuntu\\missing' + } + }) + + await expect(pending).rejects.toMatchObject({ + message: 'not found', + code: 'ENOENT', + syscall: 'stat' + }) + client.dispose() + }) + + it('restores Stats and Dirent methods after IPC serialization', async () => { + const child = new FakeProcess() + const client = new WslTranscriptFsProcessClient(() => fakeChild(child)) + const stats = client.run( + { operation: 'stat', path: '\\\\wsl.localhost\\Ubuntu\\file' }, + new AbortController().signal + ) + child.respond({ + id: child.sent[0].id, + ok: true, + value: { mode: 0o100644, size: 12, mtime: new Date(0) } + }) + + expect((await stats).isFile()).toBe(true) + + const entries = client.run( + { operation: 'readdir', path: '\\\\wsl.localhost\\Ubuntu\\dir' }, + new AbortController().signal + ) + child.respond({ + id: child.sent[1].id, + ok: true, + value: [ + { + name: 'child', + parentPath: '\\\\wsl.localhost\\Ubuntu\\dir', + isBlockDevice: false, + isCharacterDevice: false, + isDirectory: false, + isFIFO: false, + isFile: true, + isSocket: false, + isSymbolicLink: false + } + ] + }) + + expect((await entries)[0].isFile()).toBe(true) + client.dispose() + }) +}) + +describe('WSL transcript filesystem process entry resolution', () => { + it('prefers the unpacked sibling for a packaged main bundle', () => { + const exists = vi.fn((path: string) => path.includes('app.asar.unpacked')) + const moduleDir = join('root', 'resources', 'app.asar', 'out', 'main') + + expect( + resolveWslTranscriptFsProcessEntryPath(moduleDir, join('root', 'resources'), exists) + ).toBe( + join(moduleDir.replace('app.asar', 'app.asar.unpacked'), 'wsl-transcript-fs-process-entry.js') + ) + }) + + // Why: this module compiles into a shared rollup chunk under out/main/chunks, + // and the scanner service child has no process.resourcesPath to fall back on. + it('resolves the entry from a shared chunk one level below out/main', () => { + const moduleDir = join('root', 'out', 'main', 'chunks') + const target = join('root', 'out', 'main', 'wsl-transcript-fs-process-entry.js') + const exists = vi.fn((path: string) => path === target) + + expect(resolveWslTranscriptFsProcessEntryPath(moduleDir, undefined, exists)).toBe(target) + }) + + it('excludes ambient NODE_OPTIONS and secrets from the fork env', () => { + const env = wslTranscriptFsProcessForkEnv( + { + PATH: 'C:\\bin', + SYSTEMROOT: 'C:\\WINDOWS', + NODE_OPTIONS: '--inspect-brk', + SECRET_TOKEN: 'shh' + }, + 'win32' + ) + + expect(env).toMatchObject({ + ELECTRON_RUN_AS_NODE: '1', + PATH: 'C:\\bin', + // The shared allowlist emits the canonical Windows casing. + SystemRoot: 'C:\\WINDOWS' + }) + expect(env).not.toHaveProperty('NODE_OPTIONS') + expect(env).not.toHaveProperty('SECRET_TOKEN') + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-process-client.ts b/src/main/native-chat/wsl-transcript-fs-process-client.ts new file mode 100644 index 00000000000..b93079e4542 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-client.ts @@ -0,0 +1,301 @@ +import type { + WslTranscriptFsProcessResponse, + WslTranscriptFsReusableProcessCall +} from './wsl-transcript-fs-process-protocol' +import { + decodeWslTranscriptFsProcessError, + decodeWslTranscriptFsProcessValue +} from './wsl-transcript-fs-process-decode' +// Transport faults (spawn failure, child death) mean nothing was consulted — +// surfacing them as plain errors would read as "path missing"/"empty tree" to +// the discovery layers, which only rethrow WslTranscriptFsError. +import { wslTranscriptFsProcessFailureError } from './wsl-transcript-fs-error' +import { + attachSlotChild, + WSL_TRANSCRIPT_FS_PROCESS_CLOSE_TIMEOUT_MS, + type HandleState, + type ProcessSlot, + type SlotDisposition, + type WslTranscriptFsProcessFactory, + type WslTranscriptFsProcessHandle +} from './wsl-transcript-fs-process-slot' +import { WslTranscriptFsProcessLanePool } from './wsl-transcript-fs-process-lane-pool' +import { sendWslTranscriptFsProcessRequest } from './wsl-transcript-fs-process-send' +import { + processHandleUnavailableError, + wslTranscriptFsHandleOwners +} from './wsl-transcript-fs-process-handle-owner' + +export type { WslTranscriptFsProcessHandle } from './wsl-transcript-fs-process-slot' + +export class WslTranscriptFsProcessClient { + private readonly pool: WslTranscriptFsProcessLanePool + private readonly handles = new WeakMap() + /** Handles retired by a slot fault/kill, not by a clean close: reads on them + * are a transport condition, never a caller bug. */ + private readonly faultedHandles = new WeakSet() + private nextId = 1 + + constructor(private readonly processFactory: WslTranscriptFsProcessFactory) { + this.pool = new WslTranscriptFsProcessLanePool( + () => this.createSlot(), + (slot) => this.destroySlot(slot) + ) + } + + async run(request: WslTranscriptFsReusableProcessCall, signal: AbortSignal): Promise { + signal.throwIfAborted() + const acquired = this.takeSlotOrThrow(signal) + return acquired instanceof Promise + ? acquired.then((slot) => + this.send(this.pool.claim(slot, signal), request, signal, 'idle') + ) + : this.send(acquired, request, signal, 'idle') + } + + async open(path: string, signal: AbortSignal): Promise { + signal.throwIfAborted() + const acquired = this.takeSlotOrThrow(signal) + const slot = acquired instanceof Promise ? await acquired : acquired + this.pool.claim(slot, signal) + const handleId = await this.send(slot, { operation: 'open', path }, signal, 'pin') + if (!this.pool.has(slot)) { + throw wslTranscriptFsProcessFailureError('the process exited while opening a file') + } + const handle = Object.freeze({ + wslTranscriptFsProcessHandle: true as const + }) + slot.handles.add(handle) + this.handles.set(handle, { slot, handleId }) + wslTranscriptFsHandleOwners.set(handle, this) + this.pool.park(slot) + return handle + } + + async read( + handle: WslTranscriptFsProcessHandle, + position: number, + length: number, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + const state = this.handles.get(handle) + if (!state) { + throw processHandleUnavailableError(handle, this.faultedHandles) + } + const acquired = this.takeSlotOrThrow(signal) + const slot = acquired instanceof Promise ? await acquired : acquired + this.pool.claim(slot, signal) + if (this.handles.get(handle) !== state || state.slot !== slot) { + this.pool.park(slot) + throw processHandleUnavailableError(handle, this.faultedHandles) + } + return this.send( + slot, + { operation: 'read', handleId: state.handleId, position, length }, + signal, + 'pinned' + ) + } + + close(handle: WslTranscriptFsProcessHandle): Promise { + const state = this.handles.get(handle) + if (!state) { + return Promise.resolve() + } + if (!state.closePromise) { + state.closePromise = this.performClose(handle, state) + // The stored promise may reject before any caller chains onto it. + void state.closePromise.catch(() => {}) + } + return state.closePromise + } + + // Why: closes bypass the gate and can arrive during a read. Queue them ahead + // of later lane work so teardown cannot fork around the one-process bound. + private async performClose( + handle: WslTranscriptFsProcessHandle, + state: HandleState + ): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { + controller.abort(new Error('WSL transcript file handle close timed out')) + if (this.pool.has(state.slot) && state.slot.active) { + this.rejectActive( + state.slot, + wslTranscriptFsProcessFailureError('the process was retired by a stuck close') + ) + this.destroySlot(state.slot) + } + }, WSL_TRANSCRIPT_FS_PROCESS_CLOSE_TIMEOUT_MS) + timer.unref?.() + try { + const acquired = this.takeSlotOrThrow(controller.signal, true) + const slot = acquired instanceof Promise ? await acquired : acquired + this.pool.claim(slot, controller.signal, () => this.destroySlot(slot)) + if (this.handles.get(handle) !== state) { + this.pool.park(slot) + return + } + await this.send( + slot, + { operation: 'close', handleId: state.handleId }, + controller.signal, + 'close', + handle + ) + } finally { + clearTimeout(timer) + } + } + + private send( + slot: ProcessSlot, + request: Parameters[0]['request'], + signal: AbortSignal, + disposition: SlotDisposition, + handle?: WslTranscriptFsProcessHandle + ): Promise { + return sendWslTranscriptFsProcessRequest({ + slot, + id: this.nextId++, + request, + signal, + disposition, + handle, + onAbort: (reason) => { + this.rejectActive(slot, reason) + this.destroySlot(slot) + }, + onTransportFailure: (error) => { + this.rejectActive(slot, wslTranscriptFsProcessFailureError(error)) + this.destroySlot(slot) + } + }) + } + + dispose(): void { + const error = wslTranscriptFsProcessFailureError('the client was disposed') + this.pool.beginDispose(error) + for (const slot of this.pool.snapshot()) { + this.rejectActive(slot, error) + this.destroySlot(slot) + } + } + + private takeSlotOrThrow( + signal: AbortSignal, + prioritize = false + ): ProcessSlot | Promise { + return this.pool.acquire(signal, prioritize) + } + + private createSlot(): ProcessSlot { + try { + const child = this.processFactory() + const slot = attachSlotChild(child, { + onResponse: (response) => this.onResponse(slot, response), + onFault: (error) => this.onFault(slot, error) + }) + return slot + } catch (error) { + throw wslTranscriptFsProcessFailureError(error) + } + } + + private onResponse(slot: ProcessSlot, response: WslTranscriptFsProcessResponse): void { + const call = slot.active + if (!call || call.id !== response.id) { + return + } + this.clearActive(slot) + call.signal.removeEventListener('abort', call.onAbort) + if (!response.ok) { + call.reject(decodeWslTranscriptFsProcessError(response.error)) + } else { + try { + call.resolve(decodeWslTranscriptFsProcessValue(call.operation, response.value)) + } catch (error) { + // An undecodable ok-response means the protocol is corrupt: fail the + // call and retire the slot rather than leave the promise unsettled. + call.reject(error) + this.destroySlot(slot) + return + } + } + switch (call.disposition) { + case 'idle': + this.pool.park(slot) + break + case 'pin': + if (!response.ok) { + this.pool.park(slot) + } + break + case 'pinned': + this.pool.park(slot) + break + case 'close': + if (!response.ok) { + this.destroySlot(slot) + } else { + this.releaseHandle(slot, call.handle!) + this.pool.park(slot) + } + break + } + } + + private onFault(slot: ProcessSlot, error: Error): void { + if (!this.pool.has(slot)) { + return + } + this.rejectActive(slot, wslTranscriptFsProcessFailureError(error)) + this.destroySlot(slot) + } + + private clearActive(slot: ProcessSlot): void { + slot.active = null + } + + private rejectActive(slot: ProcessSlot, error: unknown): void { + const call = slot.active + this.clearActive(slot) + if (!call) { + return + } + call.signal.removeEventListener('abort', call.onAbort) + call.reject(error) + } + + private destroySlot(slot: ProcessSlot): void { + if (!this.pool.retire(slot)) { + return + } + for (const handle of slot.handles) { + this.faultedHandles.add(handle) + } + this.releaseAllHandles(slot) + slot.child.removeAllListeners() + try { + slot.child.kill('SIGKILL') + } catch { + // Teardown race: kill of an already-terminating child emits 'error' with + // no listeners left, which throws synchronously; the child dies anyway. + } + } + + private releaseHandle(slot: ProcessSlot, handle: WslTranscriptFsProcessHandle): void { + slot.handles.delete(handle) + this.handles.delete(handle) + // The owners entry stays (WeakMap, collected with the handle) so late + // cross-module reads still reach this client for a classified rejection. + } + + private releaseAllHandles(slot: ProcessSlot): void { + for (const handle of slot.handles) { + this.handles.delete(handle) + } + slot.handles.clear() + } +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-decode.ts b/src/main/native-chat/wsl-transcript-fs-process-decode.ts new file mode 100644 index 00000000000..aad754ea115 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-decode.ts @@ -0,0 +1,43 @@ +import { Stats, type Dirent } from 'node:fs' +import type { + WslTranscriptFsDirent, + WslTranscriptFsProcessError, + WslTranscriptFsProcessRequest +} from './wsl-transcript-fs-process-protocol' + +export function decodeWslTranscriptFsProcessError(value: WslTranscriptFsProcessError): Error { + const error = new Error(value.message) as NodeJS.ErrnoException + error.name = value.name + Object.assign(error, value) + return error +} + +function decodeDirent(value: WslTranscriptFsDirent): Dirent { + return { + name: value.name, + parentPath: value.parentPath, + isBlockDevice: () => value.isBlockDevice, + isCharacterDevice: () => value.isCharacterDevice, + isDirectory: () => value.isDirectory, + isFIFO: () => value.isFIFO, + isFile: () => value.isFile, + isSocket: () => value.isSocket, + isSymbolicLink: () => value.isSymbolicLink + } as Dirent +} + +/** Revive prototype-dependent results the structured clone stripped. */ +export function decodeWslTranscriptFsProcessValue( + operation: WslTranscriptFsProcessRequest['operation'], + value: unknown +): unknown { + if (operation === 'stat' || operation === 'lstat') { + // Copy instead of setPrototypeOf: the in-process vitest arm passes the + // suite's own fixture here, which may be shared or frozen. + return Object.assign(Object.create(Stats.prototype) as Stats, value) + } + if (operation === 'readdir') { + return (value as WslTranscriptFsDirent[]).map(decodeDirent) + } + return value +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts b/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts new file mode 100644 index 00000000000..fda412aa0fc --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts @@ -0,0 +1,99 @@ +import { open, type FileHandle } from 'node:fs/promises' +import { + invalidTranscriptHandleError, + type WslTranscriptFsReusableProcessCall +} from './wsl-transcript-fs-process-protocol' +import { decodeWslTranscriptFsProcessValue } from './wsl-transcript-fs-process-decode' +import { + WslTranscriptFsProcessClient, + type WslTranscriptFsProcessHandle +} from './wsl-transcript-fs-process-client' +import { wslTranscriptFsHandleOwners } from './wsl-transcript-fs-process-handle-owner' +import { WslTranscriptFsProcessOperations } from './wsl-transcript-fs-process-operations' +import { forkWslTranscriptFsProcess } from './wsl-transcript-fs-process-spawn' + +export type { WslTranscriptFsProcessHandle } from './wsl-transcript-fs-process-client' + +// Why: an env-only check would let a leaked VITEST=true (harnesses spreading +// process.env into a real app) silently revert production to in-process UNC +// syscalls; the worker global only exists inside an actual vitest runtime. +function inVitestWorker(): boolean { + return process.env.VITEST === 'true' && '__vitest_worker__' in globalThis +} + +// Unit suites run the child's own dispatcher (decode included) in-process, so +// there is exactly one request implementation to drift; production never +// bypasses the process boundary. +let inProcessOperations: WslTranscriptFsProcessOperations | null = null + +function runInProcess(request: WslTranscriptFsReusableProcessCall): Promise { + inProcessOperations ??= new WslTranscriptFsProcessOperations() + return inProcessOperations + .execute({ ...request, id: 0 }) + .then((value) => decodeWslTranscriptFsProcessValue(request.operation, value)) as Promise +} + +const clientsByLane = new Map() + +function getLaneClient(laneKey: string): WslTranscriptFsProcessClient { + const existing = clientsByLane.get(laneKey) + if (existing) { + return existing + } + const client = new WslTranscriptFsProcessClient(forkWslTranscriptFsProcess) + clientsByLane.set(laneKey, client) + return client +} + +export function runWslTranscriptFsProcess( + request: WslTranscriptFsReusableProcessCall, + signal: AbortSignal, + laneKey: string +): Promise { + if (inVitestWorker()) { + return runInProcess(request) + } + return getLaneClient(laneKey).run(request, signal) +} + +export function openWslTranscriptFsProcess( + path: string, + signal: AbortSignal, + laneKey: string +): Promise { + if (inVitestWorker()) { + // A real FileHandle: suites drive reads and closes through the plain + // handle branch, mirroring non-UNC ownership. + return open(path, 'r') + } + return getLaneClient(laneKey).open(path, signal) +} + +export function readWslTranscriptFsProcess( + handle: WslTranscriptFsProcessHandle, + position: number, + length: number, + signal: AbortSignal +): Promise { + const owner = wslTranscriptFsHandleOwners.get(handle) + return owner + ? owner.read(handle, position, length, signal) + : Promise.reject(invalidTranscriptHandleError()) +} + +export function closeWslTranscriptFsProcess(handle: WslTranscriptFsProcessHandle): Promise { + return wslTranscriptFsHandleOwners.get(handle)?.close(handle) ?? Promise.resolve() +} + +export function isWslTranscriptFsProcessHandle( + value: object +): value is WslTranscriptFsProcessHandle { + return 'wslTranscriptFsProcessHandle' in value +} + +export function resetWslTranscriptFsProcessClientForTests(): void { + for (const client of clientsByLane.values()) { + client.dispose() + } + clientsByLane.clear() +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-entry.ts b/src/main/native-chat/wsl-transcript-fs-process-entry.ts new file mode 100644 index 00000000000..c3d946f067c --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-entry.ts @@ -0,0 +1,40 @@ +// Forked so a killed WSL UNC syscall cannot retain a libuv thread in Orca. +import type { + WslTranscriptFsProcessError, + WslTranscriptFsProcessRequest, + WslTranscriptFsProcessResponse +} from './wsl-transcript-fs-process-protocol' +import { WslTranscriptFsProcessOperations } from './wsl-transcript-fs-process-operations' + +const operations = new WslTranscriptFsProcessOperations() + +function serializeError(error: unknown): WslTranscriptFsProcessError { + const value = error as NodeJS.ErrnoException | null + return { + name: value?.name ?? 'Error', + message: value?.message ?? String(error), + ...(typeof value?.code === 'string' ? { code: value.code } : {}), + ...(typeof value?.errno === 'number' ? { errno: value.errno } : {}), + ...(typeof value?.syscall === 'string' ? { syscall: value.syscall } : {}), + ...(typeof value?.path === 'string' ? { path: value.path } : {}) + } +} + +function respond(response: WslTranscriptFsProcessResponse): void { + try { + // The callback absorbs an async send failure (channel torn mid-write) that + // would otherwise surface as an unhandled 'error' event and crash the child. + process.send?.(response, () => {}) + } catch { + // Channel closed while the op settled (parent teardown); disconnect exits. + } +} + +process.on('message', (request: WslTranscriptFsProcessRequest) => { + void operations.execute(request).then( + (value) => respond({ id: request.id, ok: true, value }), + (error: unknown) => respond({ id: request.id, ok: false, error: serializeError(error) }) + ) +}) + +process.on('disconnect', () => process.exit(0)) diff --git a/src/main/native-chat/wsl-transcript-fs-process-handle-owner.ts b/src/main/native-chat/wsl-transcript-fs-process-handle-owner.ts new file mode 100644 index 00000000000..5b11fc208b6 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-handle-owner.ts @@ -0,0 +1,18 @@ +import { wslTranscriptFsProcessFailureError } from './wsl-transcript-fs-error' +import { invalidTranscriptHandleError } from './wsl-transcript-fs-process-protocol' +import type { WslTranscriptFsProcessClient } from './wsl-transcript-fs-process-client' +import type { WslTranscriptFsProcessHandle } from './wsl-transcript-fs-process-slot' + +export const wslTranscriptFsHandleOwners = new WeakMap< + WslTranscriptFsProcessHandle, + WslTranscriptFsProcessClient +>() + +export function processHandleUnavailableError( + handle: WslTranscriptFsProcessHandle, + faultedHandles: WeakSet +): Error { + return faultedHandles.has(handle) + ? wslTranscriptFsProcessFailureError('the process owning this file handle exited') + : invalidTranscriptHandleError() +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-lane-pool.ts b/src/main/native-chat/wsl-transcript-fs-process-lane-pool.ts new file mode 100644 index 00000000000..31f060c9498 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-lane-pool.ts @@ -0,0 +1,156 @@ +import { + WSL_TRANSCRIPT_FS_PROCESS_IDLE_REAP_MS, + type ProcessSlot +} from './wsl-transcript-fs-process-slot' +import { wslTranscriptFsProcessFailureError } from './wsl-transcript-fs-error' + +type SlotWaiter = { + resolve: (slot: ProcessSlot) => void + reject: (error: unknown) => void + signal: AbortSignal + onAbort: () => void +} + +export class WslTranscriptFsProcessLanePool { + private readonly available: ProcessSlot[] = [] + private readonly slots = new Set() + private readonly waiters: SlotWaiter[] = [] + private disposed = false + private disposeError: unknown = new Error('WSL transcript filesystem process pool is disposed') + + constructor( + private readonly createSlot: () => ProcessSlot, + private readonly retireIdleSlot: (slot: ProcessSlot) => void + ) {} + + acquire(signal: AbortSignal, prioritize = false): ProcessSlot | Promise { + signal.throwIfAborted() + if (this.disposed) { + return Promise.reject(this.disposeError) + } + const slot = this.available.pop() + if (slot) { + clearTimeout(slot.idleTimer) + return slot + } + if (this.slots.size === 0) { + return this.addSlot() + } + return new Promise((resolve, reject) => { + const waiter: SlotWaiter = { + resolve, + reject, + signal, + onAbort: () => { + const index = this.waiters.indexOf(waiter) + if (index !== -1) { + this.waiters.splice(index, 1) + } + reject(signal.reason ?? new Error('WSL filesystem process acquisition aborted')) + } + } + signal.addEventListener('abort', waiter.onAbort, { once: true }) + if (prioritize) { + this.waiters.unshift(waiter) + } else { + this.waiters.push(waiter) + } + }) + } + + park(slot: ProcessSlot): void { + if (!this.slots.has(slot)) { + return + } + const waiter = this.waiters.shift() + if (waiter) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + waiter.resolve(slot) + return + } + if (this.available.includes(slot)) { + return + } + this.available.push(slot) + if (slot.handles.size === 0) { + slot.idleTimer = setTimeout( + () => this.retireIdleSlot(slot), + WSL_TRANSCRIPT_FS_PROCESS_IDLE_REAP_MS + ) + slot.idleTimer.unref?.() + } + } + + has(slot: ProcessSlot): boolean { + return this.slots.has(slot) + } + + claim(slot: ProcessSlot, signal: AbortSignal, release?: () => void): ProcessSlot { + if (!this.slots.has(slot)) { + throw this.disposed + ? this.disposeError + : wslTranscriptFsProcessFailureError('the process exited before the queued request started') + } + if (signal.aborted) { + if (release) { + release() + } else { + this.park(slot) + } + signal.throwIfAborted() + } + return slot + } + + snapshot(): ProcessSlot[] { + return [...this.slots] + } + + beginDispose(error: unknown): void { + this.disposed = true + this.disposeError = error + for (const waiter of this.waiters.splice(0)) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + waiter.reject(error) + } + } + + retire(slot: ProcessSlot): boolean { + if (!this.slots.delete(slot)) { + return false + } + clearTimeout(slot.idleTimer) + const availableIndex = this.available.indexOf(slot) + if (availableIndex !== -1) { + this.available.splice(availableIndex, 1) + } + this.replaceForWaiter() + return true + } + + private addSlot(): ProcessSlot { + const slot = this.createSlot() + this.slots.add(slot) + return slot + } + + private replaceForWaiter(): void { + if (this.disposed || this.slots.size > 0) { + return + } + while (this.waiters.length > 0) { + const waiter = this.waiters.shift()! + waiter.signal.removeEventListener('abort', waiter.onAbort) + if (waiter.signal.aborted) { + waiter.reject(waiter.signal.reason) + continue + } + try { + waiter.resolve(this.addSlot()) + return + } catch (error) { + waiter.reject(error) + } + } + } +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-operations.test.ts b/src/main/native-chat/wsl-transcript-fs-process-operations.test.ts new file mode 100644 index 00000000000..f38a04462ab --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-operations.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { WslTranscriptFsProcessOperations } from './wsl-transcript-fs-process-operations' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('WSL transcript filesystem process operations', () => { + // Local NTFS rejects replacing an open file; WSL/9P follows Linux rename semantics. + it.skipIf(process.platform === 'win32')( + 'keeps positional reads on the opened inode after atomic path replacement', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-transcript-')) + temporaryDirectories.push(directory) + const transcriptPath = join(directory, 'session.jsonl') + const replacementPath = join(directory, 'replacement.jsonl') + await writeFile(transcriptPath, 'original transcript') + await writeFile(replacementPath, 'replacement bytes') + const operations = new WslTranscriptFsProcessOperations() + + const handleId = (await operations.execute({ + id: 1, + operation: 'open', + path: transcriptPath + })) as number + await rename(replacementPath, transcriptPath) + const body = await operations.execute({ + id: 2, + operation: 'read', + handleId, + position: 0, + length: 64 + }) + await operations.execute({ id: 3, operation: 'close', handleId }) + + expect(Buffer.from(body as Buffer).toString('utf8')).toBe('original transcript') + } + ) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-process-operations.ts b/src/main/native-chat/wsl-transcript-fs-process-operations.ts new file mode 100644 index 00000000000..b76ee84e4d6 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-operations.ts @@ -0,0 +1,63 @@ +import { access, lstat, open, readdir, readFile, stat, type FileHandle } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { + invalidTranscriptHandleError, + type WslTranscriptFsDirent, + type WslTranscriptFsProcessRequest +} from './wsl-transcript-fs-process-protocol' + +function serializeDirent(entry: Dirent): WslTranscriptFsDirent { + return { + name: entry.name, + parentPath: entry.parentPath, + isBlockDevice: entry.isBlockDevice(), + isCharacterDevice: entry.isCharacterDevice(), + isDirectory: entry.isDirectory(), + isFIFO: entry.isFIFO(), + isFile: entry.isFile(), + isSocket: entry.isSocket(), + isSymbolicLink: entry.isSymbolicLink() + } +} + +export class WslTranscriptFsProcessOperations { + private readonly handles = new Map() + private nextHandleId = 1 + + async execute(request: WslTranscriptFsProcessRequest): Promise { + switch (request.operation) { + case 'access': + await access(request.path) + return true + case 'stat': + return stat(request.path) + case 'lstat': + return lstat(request.path) + case 'readdir': + return (await readdir(request.path, { withFileTypes: true })).map(serializeDirent) + case 'readfile': + return readFile(request.path, request.encoding) + case 'open': { + const handle = await open(request.path, 'r') + const handleId = this.nextHandleId++ + this.handles.set(handleId, handle) + return handleId + } + case 'read': { + const handle = this.handles.get(request.handleId) + if (!handle) { + throw invalidTranscriptHandleError() + } + const buffer = Buffer.allocUnsafe(request.length) + const { bytesRead } = await handle.read(buffer, 0, request.length, request.position) + return buffer.subarray(0, bytesRead) + } + case 'close': { + const handle = this.handles.get(request.handleId) + this.handles.delete(request.handleId) + await handle?.close() + return true + } + } + } +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-protocol.ts b/src/main/native-chat/wsl-transcript-fs-process-protocol.ts new file mode 100644 index 00000000000..db9580cdb92 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-protocol.ts @@ -0,0 +1,51 @@ +export type WslTranscriptFsProcessCall = + | { operation: 'access'; path: string } + | { operation: 'stat' | 'lstat' | 'readdir'; path: string } + // Kept as its own member so the reusable-call Exclude below can strip it: + // Exclude compares whole union members, not individual operation literals. + | { operation: 'open'; path: string } + | { operation: 'readfile'; path: string; encoding: BufferEncoding } + | { operation: 'read'; handleId: number; position: number; length: number } + | { operation: 'close'; handleId: number } + +// The intersection distributes over the union, so `{ ...call, id }` composes +// a request without casts at the IPC boundary. +export type WslTranscriptFsProcessRequest = WslTranscriptFsProcessCall & { id: number } + +/** Calls a pooled process may serve; open/read/close manage a pinned handle. */ +export type WslTranscriptFsReusableProcessCall = Exclude< + WslTranscriptFsProcessCall, + { operation: 'open' | 'read' | 'close' } +> + +export type WslTranscriptFsDirent = { + name: string + parentPath: string + isBlockDevice: boolean + isCharacterDevice: boolean + isDirectory: boolean + isFIFO: boolean + isFile: boolean + isSocket: boolean + isSymbolicLink: boolean +} + +export type WslTranscriptFsProcessError = { + name: string + message: string + code?: string + errno?: number + syscall?: string + path?: string +} + +export type WslTranscriptFsProcessResponse = + | { id: number; ok: true; value: unknown } + | { id: number; ok: false; error: WslTranscriptFsProcessError } + +/** The owning process, client, or entry no longer knows this handle. */ +export function invalidTranscriptHandleError(): NodeJS.ErrnoException { + return Object.assign(new Error('WSL transcript file handle is no longer available'), { + code: 'EBADF' + }) +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-send.ts b/src/main/native-chat/wsl-transcript-fs-process-send.ts new file mode 100644 index 00000000000..6bddf859233 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-send.ts @@ -0,0 +1,51 @@ +import type { + WslTranscriptFsProcessCall, + WslTranscriptFsProcessRequest +} from './wsl-transcript-fs-process-protocol' +import type { + ProcessSlot, + SlotDisposition, + WslTranscriptFsProcessHandle +} from './wsl-transcript-fs-process-slot' + +export function sendWslTranscriptFsProcessRequest(args: { + slot: ProcessSlot + id: number + request: WslTranscriptFsProcessCall + signal: AbortSignal + disposition: SlotDisposition + handle?: WslTranscriptFsProcessHandle + onAbort: (reason: unknown) => void + onTransportFailure: (error: unknown) => void +}): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => { + if (args.slot.active?.id === args.id) { + args.onAbort(args.signal.reason ?? new Error('WSL filesystem process aborted')) + } + } + args.slot.active = { + id: args.id, + resolve: resolve as (value: unknown) => void, + reject, + signal: args.signal, + onAbort, + operation: args.request.operation, + disposition: args.disposition, + handle: args.handle + } + args.signal.addEventListener('abort', onAbort, { once: true }) + try { + args.slot.child.send( + { ...args.request, id: args.id } as WslTranscriptFsProcessRequest, + (error) => { + if (error && args.slot.active?.id === args.id) { + args.onTransportFailure(error) + } + } + ) + } catch (error) { + args.onTransportFailure(error) + } + }) +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-slot.ts b/src/main/native-chat/wsl-transcript-fs-process-slot.ts new file mode 100644 index 00000000000..b6fc3bf77a2 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-slot.ts @@ -0,0 +1,69 @@ +import type { ChildProcess } from 'node:child_process' +import type { + WslTranscriptFsProcessRequest, + WslTranscriptFsProcessResponse +} from './wsl-transcript-fs-process-protocol' + +/** + * The data model one pooled helper child is tracked by: at most one in-flight + * call, with any opened handles owned by that child. + */ + +export type SlotDisposition = 'idle' | 'pin' | 'pinned' | 'close' + +export type ActiveCall = { + id: number + resolve: (value: unknown) => void + reject: (error: unknown) => void + signal: AbortSignal + onAbort: () => void + operation: WslTranscriptFsProcessRequest['operation'] + disposition: SlotDisposition + handle?: WslTranscriptFsProcessHandle +} + +export type WslTranscriptFsProcessHandle = { + readonly wslTranscriptFsProcessHandle: true +} + +export type ProcessSlot = { + child: ChildProcess + active: ActiveCall | null + handles: Set + idleTimer?: ReturnType +} + +export type HandleState = { + slot: ProcessSlot + handleId: number + closePromise?: Promise +} + +export type WslTranscriptFsProcessFactory = () => ChildProcess + +export const WSL_TRANSCRIPT_FS_PROCESS_CLOSE_TIMEOUT_MS = 30_000 +// Why: idle Electron-as-Node children are tens of MB; reap them instead of +// holding RSS for the app session. +// Longer than the close deadline so a pending close never outlives its slot. +export const WSL_TRANSCRIPT_FS_PROCESS_IDLE_REAP_MS = 60_000 + +export function attachSlotChild( + child: ChildProcess, + handlers: { + onResponse: (response: WslTranscriptFsProcessResponse) => void + onFault: (error: Error) => void + } +): ProcessSlot { + const slot: ProcessSlot = { child, active: null, handles: new Set() } + child.on('message', (response: WslTranscriptFsProcessResponse) => handlers.onResponse(response)) + child.on('error', (error) => handlers.onFault(error)) + child.on('disconnect', () => handlers.onFault(new Error('WSL filesystem process disconnected'))) + // A signal-killed child has code null; name the signal, not "(null)". + child.on('exit', (code, signal) => + handlers.onFault(new Error(`WSL filesystem process exited (${signal ?? code})`)) + ) + // Neither the child nor its channel may keep the parent's event loop alive. + child.unref() + child.channel?.unref?.() + return slot +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-spawn.ts b/src/main/native-chat/wsl-transcript-fs-process-spawn.ts new file mode 100644 index 00000000000..5d67cba8bed --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-spawn.ts @@ -0,0 +1,58 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { pickAllowedEnv, RUNTIME_ENV_ALLOWLIST } from '../ai-vault/session-scanner-service-env' + +const PROCESS_ENTRY_FILENAME = 'wsl-transcript-fs-process-entry.js' + +// Why: never `...process.env` into a forked transcript reader — an ambient +// NODE_OPTIONS would halt (--inspect-brk) or --require code into every child, +// and shell-exported secrets have no business in one. Shares the AI Vault +// runtime allowlist: only what Node/libuv need to start. +export function wslTranscriptFsProcessForkEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + const env = pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform) + env.ELECTRON_RUN_AS_NODE = '1' + return env +} + +export function resolveWslTranscriptFsProcessEntryPath( + moduleDir: string, + resourcesPath: string | undefined = process.resourcesPath, + pathExists: (path: string) => boolean = existsSync +): string { + // Why: this module compiles into out/main or out/main/chunks, so probe both + // levels. ELECTRON_RUN_AS_NODE children (the scanner service) bypass asar and + // have no process.resourcesPath, so the __dirname legs must succeed there. + const toUnpackedDir = (dir: string): string => + dir.replace(/([\\/])app\.asar(?=([\\/]|$))/, '$1app.asar.unpacked') + for (const baseDir of [moduleDir, join(moduleDir, '..')].map(toUnpackedDir)) { + const candidate = join(baseDir, PROCESS_ENTRY_FILENAME) + if (pathExists(candidate)) { + return candidate + } + } + if (resourcesPath) { + const packaged = join(resourcesPath, 'app.asar.unpacked', 'out', 'main', PROCESS_ENTRY_FILENAME) + if (pathExists(packaged)) { + return packaged + } + } + return join(process.cwd(), 'out', 'main', PROCESS_ENTRY_FILENAME) +} + +export function forkWslTranscriptFsProcess(): ChildProcess { + const entryPath = resolveWslTranscriptFsProcessEntryPath(__dirname) + if (!existsSync(entryPath)) { + throw new Error(`WSL transcript filesystem process entry not found: ${entryPath}`) + } + return fork(entryPath, [], { + env: wslTranscriptFsProcessForkEnv(), + execArgv: [], + serialization: 'advanced', + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) +} diff --git a/src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts b/src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts new file mode 100644 index 00000000000..2a8a7668b57 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { + resetWslTranscriptFsGateForTests, + runWslTranscriptFsTask, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, + WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS, + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS +} from './wsl-transcript-fs-gate' +import { WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS } from './wsl-transcript-fs-route-quarantine' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + return { + promise: new Promise((res, rej) => ((resolve = res), (reject = rej))), + resolve, + reject + } +} + +function run( + path: string, + priority: 'exact' | 'scan', + task: () => Promise, + signal?: AbortSignal +): Promise { + return runWslTranscriptFsTask( + { operation: priority === 'exact' ? 'access' : 'readdir', path, priority, signal }, + task + ) +} + +describe('WSL transcript fs route quarantine strike accounting', () => { + let warnSpy: MockInstance + + beforeEach(() => { + resetWslTranscriptFsGateForTests() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + // Why: joining costs no new I/O — the in-flight task is bounded by its own + // deadline and its settle may itself lift the quarantine. Refusing would fail + // pollers whose answer is already seconds away. + it('joins a live in-flight task on a quarantined route instead of refusing', async () => { + vi.useFakeTimers() + const scanWork = deferred() + try { + const path = '\\\\wsl.localhost\\Ubuntu\\join-during-quarantine' + const scanTask = vi.fn(() => scanWork.promise) + const scanned = runWslTranscriptFsTask( + { operation: 'stat', path, priority: 'scan' }, + scanTask + ) + await vi.advanceTimersByTimeAsync(0) + expect(scanTask).toHaveBeenCalledOnce() + + const stalled = run( + '\\\\wsl.localhost\\Ubuntu\\join-hung', + 'exact', + () => new Promise(() => {}) + ) + const stalledRejected = expect(stalled).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stalledRejected + + // New work on the route is refused, but joining the live stat is free. + await expect( + run('\\\\wsl.localhost\\Ubuntu\\join-fresh', 'exact', async () => 'fresh') + ).rejects.toMatchObject({ code: 'unavailable' }) + const joinerTask = vi.fn(async () => 'never') + const joined = runWslTranscriptFsTask( + { operation: 'stat', path, priority: 'scan' }, + joinerTask + ) + + scanWork.resolve('shared') + await expect(Promise.all([scanned, joined])).resolves.toEqual(['shared', 'shared']) + expect(joinerTask).not.toHaveBeenCalled() + } finally { + scanWork.resolve('shared') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + // Why: one hung mount usually stalls the exact and scan lanes together; two + // deadline strikes for one incident would double-step the back-off. + it('counts concurrent lane deadlines on one stall as a single strike', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }) + try { + const path = '\\\\wsl.localhost\\Ubuntu\\two-lane-stall' + const exact = run(path, 'exact', () => new Promise(() => {})) + const scan = run( + '\\\\wsl.localhost\\Ubuntu\\two-lane-tree', + 'scan', + () => new Promise(() => {}) + ) + const exactRejected = expect(exact).rejects.toMatchObject({ code: 'timeout' }) + const scanRejected = expect(scan).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS) + await Promise.all([exactRejected, scanRejected]) + + // Still a first strike: admitted again after one base window (a second + // strike would demand two). + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) + await expect(run(path, 'exact', async () => 'recovered')).resolves.toBe('recovered') + } finally { + vi.useRealTimers() + } + }) + + it('starts the back-off from the base window again after strike history decays', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }) + const path = '\\\\wsl.localhost\\Ubuntu\\daily-slow-wake' + const stallOnce = (): Promise => + runWslTranscriptFsTask( + { operation: 'open', path, priority: 'exact', dedupe: false }, + (signal) => + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + ) + ) + try { + const first = stallOnce() + const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await firstRejected + + // A quiet stretch beyond the decay window forgets the strike history. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS + 1) + const second = stallOnce() + const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await secondRejected + + // First-strike back-off again, not an escalated second strike. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS) + await expect(run(path, 'exact', async () => 'fresh-start')).resolves.toBe('fresh-start') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-route-quarantine.ts b/src/main/native-chat/wsl-transcript-fs-route-quarantine.ts new file mode 100644 index 00000000000..affce12f0c5 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-route-quarantine.ts @@ -0,0 +1,58 @@ +/** + * Route-level quarantine with exponential back-off. A killed helper cannot + * report late success, so a stalled route's recovery is only probeable: block + * admissions briefly, let the next real task be the probe, escalate on repeat. + */ + +const ROUTE_RETRY_DELAY_MULTIPLIER = 2 +// A short first strike lets a cold-booting distro recover on the next poll. +export const WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS = 5_000 +// Strikes older than this stop escalating: a distro that wakes slowly once a +// day must restart from the base window, not resume yesterday's back-off. +export const WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS = 5 * 60_000 + +type RouteQuarantine = { until: number; strikes: number; setAt: number } +// Monotonic clock: wall time would misjudge the window across sleep/NTP steps. +const blockedRoutes = new Map() + +export function routeIsBlocked(route: string): boolean { + const blocked = blockedRoutes.get(route) + // Expired entries persist: their strike count seeds the next back-off. + return blocked !== undefined && performance.now() < blocked.until +} + +/** + * One more strike: block the route with doubled back-off, capped by deadline. + * `taskStartedAt` identifies the incident — the exact and scan lanes usually + * both stall on one hung mount, and a task admitted before the current + * quarantine was set is the sibling lane reporting that same stall, so it + * re-arms the window without escalating the strike count. + */ +export function quarantineRoute(route: string, deadlineMs: number, taskStartedAt: number): void { + const now = performance.now() + const previous = blockedRoutes.get(route) + const seed = + previous !== undefined && now - previous.until <= WSL_TRANSCRIPT_FS_ROUTE_STRIKE_DECAY_MS + ? previous + : undefined + const sameIncident = seed !== undefined && taskStartedAt <= seed.setAt + const strikes = seed === undefined ? 1 : sameIncident ? seed.strikes : seed.strikes + 1 + const quarantineMs = Math.min( + WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS * 2 ** (strikes - 1), + deadlineMs * ROUTE_RETRY_DELAY_MULTIPLIER + ) + blockedRoutes.set(route, { + until: Math.max(seed?.until ?? 0, now + quarantineMs), + strikes, + setAt: sameIncident ? seed.setAt : now + }) +} + +/** A real filesystem answer proves the mount is alive: forget the strikes. */ +export function liftRouteQuarantine(route: string): void { + blockedRoutes.delete(route) +} + +export function resetRouteQuarantinesForTests(): void { + blockedRoutes.clear() +} diff --git a/src/main/native-chat/wsl-transcript-fs-route.test.ts b/src/main/native-chat/wsl-transcript-fs-route.test.ts new file mode 100644 index 00000000000..f49c47c705a --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-route.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { wslTranscriptFsLaneKey } from './wsl-transcript-fs-route' + +describe('WSL transcript filesystem lane keys', () => { + it('shares one process within a route and priority while isolating other lanes', () => { + const ubuntu = '\\\\wsl.localhost\\Ubuntu\\home\\ada' + + expect(wslTranscriptFsLaneKey(`${ubuntu}\\one`, 'scan')).toBe( + wslTranscriptFsLaneKey(`${ubuntu}\\two`, 'scan') + ) + expect(wslTranscriptFsLaneKey(`${ubuntu}\\one`, 'exact')).not.toBe( + wslTranscriptFsLaneKey(`${ubuntu}\\one`, 'scan') + ) + expect(wslTranscriptFsLaneKey(`${ubuntu}\\one`, 'scan')).not.toBe( + wslTranscriptFsLaneKey('\\\\wsl.localhost\\Debian\\home\\ada\\one', 'scan') + ) + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-route.ts b/src/main/native-chat/wsl-transcript-fs-route.ts index dc576c066d7..130171672d6 100644 --- a/src/main/native-chat/wsl-transcript-fs-route.ts +++ b/src/main/native-chat/wsl-transcript-fs-route.ts @@ -2,8 +2,8 @@ import { parseWslUncPath } from '../../shared/wsl-paths' /** * The isolation unit for WSL transcript filesystem work: one stalled distro - * must not fast-fail or strand work on any other. Shared by the gate's - * admission and by the ungated close queue so both isolate on the same key. + * must not fast-fail or strand work on any other. Gate admission and helper + * ownership share this key so scheduling and process faults isolate alike. * * wsl$ and wsl.localhost spellings of one distro stay distinct routes on * purpose (provider spelling can change behavior), so a stuck spelling never @@ -17,3 +17,7 @@ export function wslTranscriptFsRouteKey(path: string): string { } return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path } + +export function wslTranscriptFsLaneKey(path: string, priority: 'exact' | 'scan'): string { + return `${wslTranscriptFsRouteKey(path)}:${priority}` +} diff --git a/src/main/opencode-usage/scanner-wsl-gate.test.ts b/src/main/opencode-usage/scanner-wsl-gate.test.ts index 44d6463d9f9..c8bb123c476 100644 --- a/src/main/opencode-usage/scanner-wsl-gate.test.ts +++ b/src/main/opencode-usage/scanner-wsl-gate.test.ts @@ -22,6 +22,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ import { listOpenCodeDatabases } from './opencode-database-discovery' import { + resetWslTranscriptFsGateForTests, WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' @@ -36,8 +37,19 @@ function stalls(): Promise { }) } +// Complete: UNC readdir results pass through the child dispatcher's dirent +// serializer, which reads every kind flag. function dirent(name: string) { - return { name, isFile: () => true } + return { + name, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => false, + isFIFO: () => false, + isFile: () => true, + isSocket: () => false, + isSymbolicLink: () => false + } } // The point of the gate: an ungated syscall on a stalled 9P mount never returns, @@ -57,6 +69,9 @@ async function settlesOnlyAtTheScanDeadline(pending: Promise): Promise let originalDatabaseOverride: string | undefined beforeEach(() => { + // blockedRoutes is persistent gate state: a prior stall must not quarantine + // this test's route. + resetWslTranscriptFsGateForTests() originalDatabaseOverride = process.env.OPENCODE_DB delete process.env.OPENCODE_DB mocks.resolveDataDirectory.mockReset() From a3f3a74dcff1d7973a17850b8a4b3deac303c718 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 18 Aug 2026 17:59:37 -0700 Subject: [PATCH 025/168] test(agent-hooks): prove the Windows hook actually receives its payload (#15403) --- .../managed-hook-stdin-lifecycle.test.ts | 22 +- .../windows-git-bash-path.test-fixture.ts | 23 ++ .../windows-hook-payload-delivery.test.ts | 205 ++++++++++++++++++ 3 files changed, 230 insertions(+), 20 deletions(-) create mode 100644 src/main/agent-hooks/windows-git-bash-path.test-fixture.ts create mode 100644 src/main/agent-hooks/windows-hook-payload-delivery.test.ts diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 3232c702dfd..c52d6656013 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -4,7 +4,7 @@ // missing-Orca-env path, so their writer may break there. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { SFTPWrapper } from 'ssh2' @@ -30,25 +30,6 @@ afterEach(() => { rmSync(isolatedUserDataDir, { recursive: true, force: true }) }) -function findGitBash(): string { - if (process.env.KIMI_SHELL_PATH) { - return process.env.KIMI_SHELL_PATH - } - const candidates = [ - process.env.ProgramFiles && join(process.env.ProgramFiles, 'Git', 'bin', 'bash.exe'), - process.env['ProgramFiles(x86)'] && - join(process.env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'), - process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') - ] - const bash = candidates.find((candidate): candidate is string => - Boolean(candidate && existsSync(candidate)) - ) - if (!bash) { - throw new Error('Git Bash is required for the Windows Kimi hook lifecycle test') - } - return bash -} - const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) @@ -84,6 +65,7 @@ import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils' import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract' import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture' +import { findGitBash } from './windows-git-bash-path.test-fixture' const REMOTE_HOME = '/home/dev' const LARGE_PAYLOAD = Buffer.alloc(1_000_000, 'x') diff --git a/src/main/agent-hooks/windows-git-bash-path.test-fixture.ts b/src/main/agent-hooks/windows-git-bash-path.test-fixture.ts new file mode 100644 index 00000000000..9bef35da989 --- /dev/null +++ b/src/main/agent-hooks/windows-git-bash-path.test-fixture.ts @@ -0,0 +1,23 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +// Why: Windows hook tests must run the registered command through MSYS too — it +// rewrites switches and paths, so a launcher can pass under cmd.exe and fail here. +export function findGitBash(): string { + if (process.env.KIMI_SHELL_PATH) { + return process.env.KIMI_SHELL_PATH + } + const candidates = [ + process.env.ProgramFiles && join(process.env.ProgramFiles, 'Git', 'bin', 'bash.exe'), + process.env['ProgramFiles(x86)'] && + join(process.env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'), + process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') + ] + const bash = candidates.find((candidate): candidate is string => + Boolean(candidate && existsSync(candidate)) + ) + if (!bash) { + throw new Error('Git Bash is required for the Windows managed hook tests') + } + return bash +} diff --git a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts new file mode 100644 index 00000000000..aabf2545172 --- /dev/null +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -0,0 +1,205 @@ +// Why: 1.4.184 registered `conhost.exe --headless /d /c