From de729d6067735c70622261d75566877ac2ba8520 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:35:35 -0700 Subject: [PATCH 01/25] Fix paired web creation failure handling (STA-4024, STA-4025, STA-4063) (#14100) --- .github/workflows/e2e.yml | 4 +- config/reliability-gates.jsonc | 196 ++++++++- .../components/editor/CombinedDiffViewer.tsx | 22 +- .../src/components/editor/EditorPanel.tsx | 10 +- .../editor/diff-section-preview.test.ts | 35 +- .../components/editor/diff-section-preview.ts | 4 + .../editor/editor-panel-render-model.test.ts | 19 +- .../editor/editor-panel-render-model.ts | 5 +- .../components/right-sidebar/FileExplorer.tsx | 4 + .../right-sidebar/FileExplorerRow.tsx | 4 +- .../right-sidebar/FileExplorerVirtualRows.tsx | 3 + .../use-add-repo-host-selection.test.ts | 21 + .../sidebar/use-add-repo-host-selection.ts | 3 +- .../file-preview-capability-cleanup.test.ts | 91 ++++ src/renderer/src/lib/file-preview.test.ts | 223 +++++++++- src/renderer/src/lib/file-preview.ts | 107 ++++- .../lib/workspace-browser-tab-open.test.ts | 22 + ...runtime-browser-capability-cleanup.test.ts | 82 ++++ .../web-runtime-browser-creation-e2e-fault.ts | 122 ++++++ ...eb-runtime-browser-materialization.test.ts | 64 +++ .../web-runtime-browser-materialization.ts | 33 ++ .../src/runtime/web-runtime-session.test.ts | 396 +++++++++++++++++- .../src/runtime/web-runtime-session.ts | 223 ++++++++-- .../web-session-browser-placement.test.ts | 222 +++++++++- .../runtime/web-session-browser-placement.ts | 163 +++++-- .../src/runtime/web-session-tabs-sync.ts | 13 +- ...er-creation-reconciliation-failure.spec.ts | 394 +++++++++++++++++ ...d-web-add-project-unavailable-host.spec.ts | 158 +++++++ 28 files changed, 2491 insertions(+), 152 deletions(-) create mode 100644 src/renderer/src/lib/file-preview-capability-cleanup.test.ts create mode 100644 src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts create mode 100644 src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts create mode 100644 src/renderer/src/runtime/web-runtime-browser-materialization.test.ts create mode 100644 src/renderer/src/runtime/web-runtime-browser-materialization.ts create mode 100644 tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts create mode 100644 tests/e2e/paired-web-add-project-unavailable-host.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5455d0b7fb8..349d2061246 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -174,7 +174,7 @@ jobs: # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron # launches but never creates a BrowserWindow. - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }} + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 pnpm run test:e2e --shard=${{ matrix.shard }} # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without @@ -233,7 +233,7 @@ jobs: TEST_FILES_JSON: ${{ inputs.test_files }} run: | mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON") - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \ + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 \ pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 - name: Upload Playwright traces diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 89d8e3e981e..52077867e57 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1633,41 +1633,93 @@ "paired remote HTML file preview", "background preview creation and explicit browser activation", "client-only split placement", + "capability-unknown and mixed-version preview rejection cleanup", + "post-create reconciliation failure and compensating rollback", "remote browser close convergence" ], "platforms": ["macos", "linux", "windows"], "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], - "coverageNotes": "A headed macOS desktop host and separate paired web client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, and browser close. Unit contracts cover owning-runtime routing, one-shot split placement, focused and unfocused creation, manual moves, overlapping opens, failure cleanup, and teardown. Linux, Windows, and a live headless-server journey remain gaps.", + "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, and a forced post-create reconciliation timeout. The same fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", + "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", + "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client creates exactly one browser page on the worktree-owning runtime and exactly one mirrored client browser workspace. The preview remains in the requested client-side split without taking focus from the editor, an explicit browser click activates it authoritatively, and closing it removes only the browser identities while the source editor and terminal survive.", - "oracle": "Open a real HTML file through Explorer on a separate client paired to an isolated headed desktop host. Require one new host browser tab, one client browser workspace and unified tab, two visible split groups, an exact client-handle-to-host-page identity, rendered fixture DOM, editor focus after preview creation, host and client browser focus only after an explicit click, and browser-only close convergence with the editor and terminal still visible. Run the byte-identical Electron test on latest main, the candidate, and the candidate with the claimed fix disabled.", + "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", + "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Repeat against headless serve. Retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], "testFiles": [ "src/main/runtime/orca-runtime-browser.test.ts", "src/main/runtime/rpc/methods/browser.test.ts", "src/renderer/src/lib/file-preview.test.ts", + "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "src/renderer/src/runtime/web-session-browser-placement.test.ts", + "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", "src/renderer/src/runtime/web-runtime-session.test.ts", "src/renderer/src/runtime/web-session-tabs-sync.test.ts", "src/renderer/src/runtime/remote-server-parity.test.ts", - "tests/e2e/paired-remote-html-browser-focus.spec.ts" + "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", + "assertions": [ + "absent status, unknown capabilities, and mixed-version hosts preserve the unsupported rejection", + "direct creation removes the caller-declared new split without issuing an RPC" + ] + }, + { + "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "assertions": [ + "known unavailability reports visibly without creating a split", + "capability loss after caller preflight reports visibly without duplicating transaction cleanup" + ] + }, + { + "file": "src/renderer/src/lib/file-preview.test.ts", + "assertions": [ + "a failed side preview reports visibly without caller-owned split cleanup", + "overlapping side previews reuse the split without the first failure deleting it" + ] + }, + { + "file": "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "assertions": [ + "success requires the exact environment and remote page handle plus a unified browser tab", + "a requested client group must contain the materialized browser tab" + ] + }, + { + "file": "src/renderer/src/runtime/web-runtime-session.test.ts", + "assertions": [ + "a post-create timeout returns false only after exact host close and a confirming snapshot", + "unconfirmed cleanup rejects so callers cannot create a competing local fallback", + "the canonical placement and a newly-created empty split are released on rollback", + "one failed preview cannot remove a shared split still reserved after an environment ownership change", + "if every overlapping creation fails, the last reservation closes the caller-created empty split" + ] + }, { "file": "src/renderer/src/runtime/web-session-browser-placement.test.ts", "assertions": [ - "pending page placements are bounded and independently reserve shared groups", + "pending page placements are bounded and reserve worktree groups across environment ownership changes", + "caller-created cleanup transfers to the last failed overlapping reservation", + "capacity rejects a new placement without evicting an active reservation or its cleanup ownership", "worktree and environment teardown clear only their own placement state" ] }, @@ -1692,9 +1744,81 @@ "the source editor and remote frame remain visible in separate groups", "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" ] + }, + { + "file": "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts", + "assertions": [ + "the exact created page exists on the host while publication is held and no browser tab materializes on the client", + "a forced post-create reconciliation timeout closes the exact host page and removes only the reserved empty split", + "a forced capability rejection after caller preflight shows a toast and restores exact host and client inventories", + "headed and headless hosts preserve the unrelated editor and terminal without local fallback" + ] } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "result": "failed", + "durationSeconds": 0.72, + "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "result": "passed", + "durationSeconds": 0.7, + "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 18.3, + "summary": "The full headed spec passed 2/2. A visible desktop host and separate paired Electron client proved both reconciliation rollback and capability rejection with a failure toast and exact host/client browser, group, editor, and terminal inventory restoration." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 9.1, + "summary": "The full isolated headless-serve journey passed 1/1, applying both reconciliation and capability faults to the same live host while preserving the visible-toast and exact inventory-restoration oracle." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "result": "passed", + "durationSeconds": 0.11, + "summary": "The exact environment, remote page, client workspace, unified browser tab, and requested group materialization contract passed 1/1 on the final rebased candidate." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 24.3, + "summary": "A visible desktop host and separate paired Electron client held one exact host page before client materialization, forced the post-create timeout, then proved exact host rollback, empty-split removal, failure UI, no local fallback, and surviving editor and terminal." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 18.5, + "summary": "The same exact-page hold, timeout, rollback, client cleanup, no-fallback, editor, and terminal oracle passed against isolated headless serve." + }, { "date": "2026-08-12", "runner": "local", @@ -1778,8 +1902,8 @@ } ], "runtimeBudget": { - "p95Seconds": 60, - "scope": "seven focused host/renderer contracts plus one isolated headed paired-runtime journey" + "p95Seconds": 90, + "scope": "focused host/renderer contracts plus normal and faulted headed/headless paired-runtime journeys" }, "flakeHistory": { "status": "unknown", @@ -1787,20 +1911,19 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The byte-identical 2ec1dcf1 Electron oracle is red on latest main 346e59c879, green on candidate d05217c40d, and red when only the candidate's side-preview focusOnCreate:false call is disabled. Latest main created two client browser workspaces; the disabled candidate focused the browser before an explicit click. Historical 5806b23f evidence separately proves the owning-runtime routing seam." + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." }, "performanceBudget": { "required": true, - "evidence": "The change adds no timer, polling, retry, subprocess, RPC method, stream opcode, receipt ledger, or host publication. Side previews install no focus listener and record no focus intent. Focused browser creation performs one bounded scan of the current worktree's local tabs. Pending client placements are capped at 128 and cleared on adoption, failure, or worktree/environment teardown." + "evidence": "The transaction adds no timer, polling, retry, subprocess, RPC method, stream opcode, or host publication. Capability rejection performs zero RPCs and one bounded empty-group close. Successful creation adds one post-operation list; known-page failure adds one exact close and one confirming list. The E2E-only one-shot faults are build-gated, retain one promise/flag and a bounded page-id set, and reset after each run. Pending client placements remain capped at 128 and clear on adoption, failure, or teardown." }, "promotionCriteria": [ "Collect Linux and physical Windows paired-server evidence.", - "Collect a live headless-serve HTML preview journey.", "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." ], "knownGaps": [ "Live paired-server validation currently covers macOS only.", - "A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.", + "Multiple simultaneous viewers are not exercised by the Electron journey.", "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." ], "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." @@ -3670,17 +3793,22 @@ "providers": ["local", "remote-runtime", "ssh", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "remote-runtime", "ssh", "wsl"], - "coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project owner capture and stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus a separate paired Electron client proves real Git, plain-folder, Clone, Create, nested import, runtime-switch, disconnect, reconnect, concurrently visible same-ID local/runtime repo/worktree/group/folder rows, reversed catalog order, reversed refresh completion, local-after-runtime catalog refresh routing, and first paired-terminal activation without local fallback; hidden-window parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/9687"], - "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project scan, add, import, catalog refresh, and worktree refresh must stay on the host selected when that operation began, even if the durable preference changes or the repo catalog is not hydrated. Generic settings mutation cannot bypass the dedicated preference IPC.", - "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. In a separate paired Electron client, select a non-default headed runtime and add a real Git repo, plain folder, clone, created project, and nested repos while switching the preference and disconnecting/reconnecting. Before Git Add, plain-folder Add, Clone, and Create completion, insert a same-ID local repo/worktree decoy; require final activation to remain on the captured runtime. Merge same-ID local/runtime repo, worktree, group, and folder rows, reverse same-host refresh completions, overlap refresh with reconnect, issue ordinary unqualified local refreshes, then create the first paired runtime terminal while the same-ID local/runtime worktrees remain visible. Require only the selected server inventory to change, exact runtime ownership in the client store, preserved host-qualified catalogs, authoritative direct-SSH pruning without runtime sibling loss, visible projects, and zero direct SSH fallback. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.", + "coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project eligibility/owner capture/stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus separate paired Electron and web clients prove selected-runtime Add Project operations and disabled creation when the only listed host is blocked or unavailable; headless-server parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/9687", + "https://linear.app/stably/issue/STA-4024/p1-paired-web-add-project-enables-unavailable-host-fallback-after-pr" + ], + "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project selected host must satisfy canSelectAddRepoHost; paired web has no local filesystem fallback, so no valid host means Browse, Clone, and Create remain disabled. Every operation stays on the host selected when it began, even if preference or catalog state changes. Generic settings mutation cannot bypass the dedicated preference IPC.", + "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat transient routing and selected-runtime Add Project operations with exact-ID host decoys and completion reordering. Separately pair the real web client to an isolated headed host, then make its sole still-listed runtime compatibility-blocked and unavailable. Require a null authoritative selection, a disabled host option, and disabled Browse, Clone, and Create actions with no local fallback. Repeat against headless serve. Run the byte-identical selector/action contract on latest main, candidate, and candidate with the eligibility seam disabled.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/runtime/runtime-server-directory-browser.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", - "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" + "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], "testFiles": [ "src/main/ipc/settings.test.ts", @@ -3717,13 +3845,15 @@ "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts", "src/renderer/src/store/slices/worktrees.test.ts", "src/renderer/src/runtime/web-runtime-session.test.ts", - "tests/e2e/pr11346-selected-runtime-add.spec.ts" + "tests/e2e/pr11346-selected-runtime-add.spec.ts", + "tests/e2e/paired-web-add-project-unavailable-host.spec.ts" ], "assertionRefs": [ { "file": "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", "assertions": [ - "paired web hides the viewing computer as an Add Project filesystem authority and selects the paired runtime" + "paired web hides the viewing computer as an Add Project filesystem authority and selects only an eligible paired runtime", + "a sole blocked or errored runtime remains listed but produces no selected Add Project host" ] }, { @@ -3855,9 +3985,35 @@ "keeps every expanded selected-runtime Add Project path in hidden-window desktop parity", "creates the first paired runtime terminal while same-ID local/runtime worktrees remain visible without clearing the selected runtime owner" ] + }, + { + "file": "tests/e2e/paired-web-add-project-unavailable-host.spec.ts", + "assertions": [ + "a version-blocked sole host stays visible and non-selectable while Browse, Clone, and Create are disabled", + "an unavailable sole host has the same disabled action contract without a local fallback", + "the rendered contract passes against headed desktop and headless serve hosts" + ] } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 12.3, + "summary": "A real paired web client against a visible desktop host kept the sole blocked and unavailable runtime listed but non-selectable, with Browse, Clone, and Create disabled and no local host fallback." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 5.8, + "summary": "The same present-but-blocked and present-but-unavailable Add Project action oracle passed against isolated headless serve." + }, { "date": "2026-07-22", "runner": "local", @@ -3932,7 +4088,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact." + "evidence": "The Add Project selector file from fix-disabled overlay 17a612c819 and candidate 8abf0f5d20 is byte-identical (SHA-256 f34c3c69c8adba23811c3a3b6a0efb669f787666c5ada086df8a167e785eea20): its blocked and unavailable sole-host cases fail with the production fix disabled on latest main ebe5125476 after PR #13909 and pass on the candidate. The focused combined selector/transaction invocation was 3 failed with 69 skipped on the red overlay and 3 passed with 75 skipped on the candidate; the different totals come from supplemental browser-transaction cases, not this selector file. The originating/latest affected main 1136503c6a and its fix-disabled overlay were also red before the provider-gating rebase. The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact." }, "performanceBudget": { "required": true, diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 58898e2d2ed..fd87b515349 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -13,7 +13,7 @@ import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll- import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' import { joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' -import { openFilePreviewToSide } from '@/lib/file-preview' +import { openFilePreviewToSide, useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' import { canOpenDiffSectionPreviewToSide } from './diff-section-preview' import { setWithLRU } from '@/lib/scroll-cache' import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' @@ -247,6 +247,7 @@ export default function CombinedDiffViewer({ selectWorktreeDiffCommentsOrEmpty(s, file.worktreeId) ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId]) + const canOpenWorkspaceFileBrowserForPath = useWorkspaceFileBrowserActionPredicate(file.worktreeId) const isDark = settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -1291,7 +1292,10 @@ export default function CombinedDiffViewer({ !canOpenDiffSectionPreviewToSide({ path: section.path, status: section.status, - isCommitSurface: isCommitMode + isCommitSurface: isCommitMode, + canOpenWorkspaceFileBrowser: canOpenWorkspaceFileBrowserForPath( + joinPath(file.filePath, section.path) + ) }) ) { return @@ -1313,7 +1317,14 @@ export default function CombinedDiffViewer({ sourceGroupId }) }, - [activeGroupId, file.filePath, file.id, file.worktreeId, isCommitMode] + [ + activeGroupId, + canOpenWorkspaceFileBrowserForPath, + file.filePath, + file.id, + file.worktreeId, + isCommitMode + ] ) const handleSectionSave = useCallback( @@ -2060,7 +2071,10 @@ export default function CombinedDiffViewer({ canOpenDiffSectionPreviewToSide({ path: section.path, status: section.status, - isCommitSurface: isCommitMode + isCommitSurface: isCommitMode, + canOpenWorkspaceFileBrowser: canOpenWorkspaceFileBrowserForPath( + joinPath(file.filePath, section.path) + ) }) ? openSectionPreview : undefined diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 233d8a9e123..60dcf72f811 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' -import { openFilePreviewToSide } from '@/lib/file-preview' +import { canShowWorkspaceFileBrowserAction, openFilePreviewToSide } from '@/lib/file-preview' import { getEditorHeaderCopyState } from './editor-header' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' @@ -45,6 +45,11 @@ function EditorPanelInner({ const activeViewStateId = activeViewStateIdProp ?? activeFileId const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null const activeWorktreeId = activeFile?.worktreeId + const canOpenWorkspaceFileBrowser = useAppStore((s) => + activeWorktreeId && activeFile + ? canShowWorkspaceFileBrowserAction(s, activeWorktreeId, activeFile.filePath) + : false + ) const markFileDirty = useAppStore((s) => s.markFileDirty) const pendingEditorReveal = useAppStore((s) => s.pendingEditorReveal) // Why: background Git refreshes for other worktrees must not wake every @@ -229,7 +234,8 @@ function EditorPanelInner({ gitStatusEntries, gitBranchEntries, markdownViewMode, - isChangesMode + isChangesMode, + canOpenWorkspaceFileBrowser }) const handleOpenPreviewToSide = (): void => { diff --git a/src/renderer/src/components/editor/diff-section-preview.test.ts b/src/renderer/src/components/editor/diff-section-preview.test.ts index 20ecbae22a2..c419171c202 100644 --- a/src/renderer/src/components/editor/diff-section-preview.test.ts +++ b/src/renderer/src/components/editor/diff-section-preview.test.ts @@ -7,7 +7,8 @@ describe('canOpenDiffSectionPreviewToSide', () => { canOpenDiffSectionPreviewToSide({ path: 'docs/demo.html', status: 'modified', - isCommitSurface: false + isCommitSurface: false, + canOpenWorkspaceFileBrowser: true }) ).toBe(true) }) @@ -17,7 +18,8 @@ describe('canOpenDiffSectionPreviewToSide', () => { canOpenDiffSectionPreviewToSide({ path: 'index.htm', status: 'added', - isCommitSurface: false + isCommitSurface: false, + canOpenWorkspaceFileBrowser: true }) ).toBe(true) }) @@ -27,14 +29,16 @@ describe('canOpenDiffSectionPreviewToSide', () => { canOpenDiffSectionPreviewToSide({ path: 'scratch.html', status: 'untracked', - isCommitSurface: false + isCommitSurface: false, + canOpenWorkspaceFileBrowser: true }) ).toBe(true) expect( canOpenDiffSectionPreviewToSide({ path: 'renamed.html', status: 'renamed', - isCommitSurface: false + isCommitSurface: false, + canOpenWorkspaceFileBrowser: true }) ).toBe(true) }) @@ -44,7 +48,8 @@ describe('canOpenDiffSectionPreviewToSide', () => { canOpenDiffSectionPreviewToSide({ path: 'Docs/DEMO.HTML', status: 'modified', - isCommitSurface: false + isCommitSurface: false, + canOpenWorkspaceFileBrowser: true }) ).toBe(true) }) @@ -54,7 +59,8 @@ describe('canOpenDiffSectionPreviewToSide', () => { canOpenDiffSectionPreviewToSide({ path: 'gone.html', status: 'deleted', - isCommitSurface: false + isCommitSurface: false, + canOpenWorkspaceFileBrowser: true }) ).toBe(false) }) @@ -64,7 +70,8 @@ describe('canOpenDiffSectionPreviewToSide', () => { canOpenDiffSectionPreviewToSide({ path: 'docs/demo.html', status: 'modified', - isCommitSurface: true + isCommitSurface: true, + canOpenWorkspaceFileBrowser: true }) ).toBe(false) }) @@ -74,7 +81,19 @@ describe('canOpenDiffSectionPreviewToSide', () => { canOpenDiffSectionPreviewToSide({ path: 'src/app.ts', status: 'modified', - isCommitSurface: false + isCommitSurface: false, + canOpenWorkspaceFileBrowser: true + }) + ).toBe(false) + }) + + it('disables previews when the workspace browser provider is unavailable', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'docs/demo.html', + status: 'modified', + isCommitSurface: false, + canOpenWorkspaceFileBrowser: false }) ).toBe(false) }) diff --git a/src/renderer/src/components/editor/diff-section-preview.ts b/src/renderer/src/components/editor/diff-section-preview.ts index 6e0fa684822..479709d3a91 100644 --- a/src/renderer/src/components/editor/diff-section-preview.ts +++ b/src/renderer/src/components/editor/diff-section-preview.ts @@ -8,7 +8,11 @@ export function canOpenDiffSectionPreviewToSide(params: { path: string status: string isCommitSurface: boolean + canOpenWorkspaceFileBrowser: boolean }): boolean { + if (!params.canOpenWorkspaceFileBrowser) { + return false + } if (params.isCommitSurface) { return false } diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index fc4efff0a5c..fc7e66a78ea 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -42,7 +42,8 @@ function renderModel(args: { gitStatusEntries: args.gitStatusByWorktree?.[activeFile.worktreeId], gitBranchEntries: undefined, markdownViewMode: args.markdownViewMode ?? {}, - isChangesMode: args.isChangesMode ?? false + isChangesMode: args.isChangesMode ?? false, + canOpenWorkspaceFileBrowser: true }) } @@ -66,6 +67,22 @@ describe('getEditorPanelRenderModel HTML preview affordance', () => { ) }) + it('disables preview when the workspace browser provider is unavailable', () => { + const activeFile = htmlFile() + const model = getEditorPanelRenderModel({ + activeFile, + fileContents: {}, + editorDrafts: {}, + gitStatusEntries: undefined, + gitBranchEntries: undefined, + markdownViewMode: {}, + isChangesMode: false, + canOpenWorkspaceFileBrowser: false + }) + + expect(model.canOpenPreviewToSide).toBe(false) + }) + it('enables preview for single HTML diffs whose file exists on disk', () => { const model = renderModel({ activeFile: htmlFile({ mode: 'diff', diffSource: 'unstaged' } as Partial), diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 4f46a0f1717..87fa868c1dd 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -26,6 +26,7 @@ type EditorPanelRenderModelParams = { gitBranchEntries: StoreState['gitBranchChangesByWorktree'][string] | undefined markdownViewMode: StoreState['markdownViewMode'] isChangesMode: boolean + canOpenWorkspaceFileBrowser: boolean } export function getEditorPanelRenderModel({ @@ -35,7 +36,8 @@ export function getEditorPanelRenderModel({ gitStatusEntries, gitBranchEntries, markdownViewMode, - isChangesMode + isChangesMode, + canOpenWorkspaceFileBrowser }: EditorPanelRenderModelParams) { const isSingleDiff = activeFile.mode === 'diff' && @@ -163,6 +165,7 @@ export function getEditorPanelRenderModel({ // when the modified side still exists on disk (canOpen excludes deleted // files and commit diffs whose content may not match the working tree). canOpenPreviewToSide: + canOpenWorkspaceFileBrowser && canPreviewLanguage(viewerLanguage) && (activeFile.mode === 'edit' || (isSingleDiff && openFileState.canOpen)), mdViewMode, diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 8db2f1d0be1..e8191d1b3e7 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -57,6 +57,7 @@ import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' import type { RightSidebarExplorerView } from '../../../../shared/types' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { createNewTerminalTab } from '@/components/terminal/terminal-tab-create' +import { useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' function FileExplorerFiles(): React.JSX.Element { const explorerView = useAppStore((s) => s.rightSidebarExplorerView) @@ -80,6 +81,8 @@ function FileExplorerFiles(): React.JSX.Element { [nameFilterQuery, showRightSidebarFiles, showRightSidebarSearch] ) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const canOpenWorkspaceFileBrowserForPath = + useWorkspaceFileBrowserActionPredicate(activeWorktreeId) const activeWorktree = useActiveWorktree() const activeRepo = useRepoById(activeWorktree?.repoId ?? null) const supportsFolderDownload = useAppStore((s) => { @@ -778,6 +781,7 @@ function FileExplorerFiles(): React.JSX.Element { connectionId={activeRepo?.connectionId ?? null} runtimeDownloadContext={runtimeDownloadContext} supportsFolderDownload={supportsFolderDownload} + canOpenInOrcaBrowser={canOpenWorkspaceFileBrowserForPath} onClick={handleRowClick} onDoubleClick={handleDoubleClick} onViewFile={handleClick} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 9fec091faf0..a5e232cb29e 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -267,6 +267,7 @@ type FileExplorerRowProps = { connectionId?: string | null runtimeDownloadContext?: RuntimeFileOperationArgs | null supportsFolderDownload?: boolean + canOpenInOrcaBrowser: boolean canCollapseFolderSubtree: boolean targetDir: string targetDepth: number @@ -455,6 +456,7 @@ export function FileExplorerRow({ connectionId, runtimeDownloadContext, supportsFolderDownload = false, + canOpenInOrcaBrowser, canCollapseFolderSubtree, targetDir, targetDepth, @@ -764,7 +766,7 @@ export function FileExplorerRow({ {translate('auto.components.right.sidebar.FileExplorerRow.1d8e182c32', 'View File')} )} - {!node.isDirectory && activeWorktreeId && ( + {!node.isDirectory && activeWorktreeId && canOpenInOrcaBrowser && ( {translate( diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index 67a52bb7c9e..d72e7a5ca19 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -29,6 +29,7 @@ type FileExplorerVirtualRowsProps = { connectionId?: string | null runtimeDownloadContext?: RuntimeFileOperationArgs | null supportsFolderDownload?: boolean + canOpenInOrcaBrowser?: (filePath: string) => boolean onClick: (node: TreeNode, event: React.MouseEvent) => void onDoubleClick: (node: TreeNode) => void onViewFile: (node: TreeNode) => void @@ -75,6 +76,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re connectionId, runtimeDownloadContext, supportsFolderDownload = false, + canOpenInOrcaBrowser = () => false, onClick, onDoubleClick, onViewFile, @@ -179,6 +181,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re connectionId={connectionId} runtimeDownloadContext={runtimeDownloadContext} supportsFolderDownload={supportsFolderDownload} + canOpenInOrcaBrowser={canOpenInOrcaBrowser(n.path)} canCollapseFolderSubtree={canCollapseFolderSubtree} targetDir={n.isDirectory ? n.path : dirname(n.path)} targetDepth={n.isDirectory ? n.depth + 1 : n.depth} diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts index 45a4fb9abbc..90ee6d9df50 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts @@ -172,6 +172,27 @@ describe('useAddRepoHostSelection', () => { expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('local') }) + it.each(['error', 'blocked'] as const)( + 'has no paired-web fallback when the only host is %s', + async (health) => { + mocks.isWebClient = true + mocks.stateValues = ['runtime:env-1', false] + mocks.hostOptions = [ + { + ...mocks.hostOptions[2], + health + } + ] + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) + + expect(result.hostOptions).toHaveLength(1) + expect(result.selectedHostId).toBeNull() + expect(result.selectedParsedHost).toBeNull() + } + ) + it('selects a local or SSH host without changing the durable active server', async () => { mocks.stateValues = ['runtime:env-1', false] mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts index 29f1fadc761..8f729a5ef66 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts @@ -73,8 +73,7 @@ export function useAddRepoHostSelection({ selectableHostOptions.find( (host) => host.id === LOCAL_EXECUTION_HOST_ID && canSelectAddRepoHost(host) ) ?? - selectableHostOptions.find((host) => canSelectAddRepoHost(host)) ?? - selectableHostOptions[0] + selectableHostOptions.find((host) => canSelectAddRepoHost(host)) const selectedHostId = selectedHost?.id ?? (isWebClient ? null : LOCAL_EXECUTION_HOST_ID) const selectedParsedHost = parseExecutionHostId(selectedHostId) const selectedSshTargetId = diff --git a/src/renderer/src/lib/file-preview-capability-cleanup.test.ts b/src/renderer/src/lib/file-preview-capability-cleanup.test.ts new file mode 100644 index 00000000000..1136dd16985 --- /dev/null +++ b/src/renderer/src/lib/file-preview-capability-cleanup.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { openFilePreviewToSide } from './file-preview' + +const mocks = vi.hoisted(() => ({ + availability: { + state: 'enabled' as const, + provider: 'paired-runtime' as const + } as { state: 'enabled'; provider: 'paired-runtime' } | { state: 'hidden'; reason: string }, + closeEmptyGroup: vi.fn(), + createBrowserTab: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'preview-group'), + createWebRuntimeSessionBrowserTab: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) + +vi.mock('@/lib/client-creation-action-policy', () => ({ + getClientCreationActionPolicy: () => ({ 'managed-browser': mocks.availability }) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: () => null, + getConnectionIdForFile: () => null +})) + +vi.mock('@/lib/connection-owner-resolution', () => ({ + getConnectionIdForFileFromState: () => null +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => 'runtime-1' +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionBrowserTab: mocks.createWebRuntimeSessionBrowserTab +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + closeEmptyGroup: mocks.closeEmptyGroup, + createBrowserTab: mocks.createBrowserTab, + createEmptySplitGroup: mocks.createEmptySplitGroup, + groupsByWorktree: {}, + layoutByWorktree: {}, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }) + } +})) + +describe('paired HTML side-preview capability cleanup', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.availability = { state: 'enabled', provider: 'paired-runtime' } + mocks.createWebRuntimeSessionBrowserTab.mockRejectedValue(new Error('capability changed')) + }) + + it('reports capability loss after preflight and removes the new split', async () => { + openFilePreviewToSide({ + language: 'html', + filePath: '/srv/repo/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'source-group' + }) + + await vi.waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith('Unable to open this file in Orca Browser.') + ) + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('reports known unavailability without creating a split', () => { + mocks.availability = { state: 'hidden', reason: 'streaming unavailable' } + + openFilePreviewToSide({ + language: 'html', + filePath: '/srv/repo/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'source-group' + }) + + expect(mocks.toastError).toHaveBeenCalledWith('streaming unavailable') + expect(mocks.createEmptySplitGroup).not.toHaveBeenCalled() + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + expect(mocks.createWebRuntimeSessionBrowserTab).not.toHaveBeenCalled() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index 05f6e485d46..8196e5b4e05 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -1,17 +1,41 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE, + canShowWorkspaceFileBrowserAction, getWorkspaceFileBrowserOpenTarget, openFileInBrowserTab, openFilePreviewToSide } from './file-preview' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' + +function browserActionState(connectionId: string | null = null): never { + return { + repos: [{ id: 'repo-1', connectionId }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + } as never +} const mocks = vi.hoisted(() => ({ + browserAvailability: { + state: 'enabled' as const, + provider: 'local-client' as 'local-client' | 'paired-runtime' + } as + | { state: 'enabled'; provider: 'local-client' | 'paired-runtime' } + | { state: 'hidden'; reason: string }, + closeEmptyGroup: vi.fn(), createBrowserTab: vi.fn(), createEmptySplitGroup: vi.fn(() => 'group-2'), createWebRuntimeSessionBrowserTab: vi.fn(), environmentId: null as string | null, - connectionId: null as string | null + connectionId: null as string | null, + layoutByWorktree: {} as Record, + toastError: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) + +vi.mock('@/lib/client-creation-action-policy', () => ({ + getClientCreationActionPolicy: () => ({ 'managed-browser': mocks.browserAvailability }) })) vi.mock('@/lib/worktree-runtime-owner', () => ({ @@ -25,10 +49,11 @@ vi.mock('@/runtime/web-runtime-session', () => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ + closeEmptyGroup: mocks.closeEmptyGroup, createBrowserTab: mocks.createBrowserTab, createEmptySplitGroup: mocks.createEmptySplitGroup, groupsByWorktree: {}, - layoutByWorktree: {}, + layoutByWorktree: mocks.layoutByWorktree, repos: [{ id: 'repo-1', connectionId: mocks.connectionId }], worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] @@ -40,8 +65,10 @@ vi.mock('@/store', () => ({ beforeEach(() => { vi.clearAllMocks() mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(true) + mocks.browserAvailability = { state: 'enabled', provider: 'local-client' } mocks.environmentId = null mocks.connectionId = null + mocks.layoutByWorktree = {} }) describe('openFileInBrowserTab', () => { @@ -59,6 +86,7 @@ describe('openFileInBrowserTab', () => { it('creates paired-runtime file browsers at the owning host', () => { mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } openFileInBrowserTab({ filePath: '/srv/repo/example.html', worktreeId: 'wt-1' }) @@ -74,6 +102,7 @@ describe('openFileInBrowserTab', () => { it('creates paired-runtime side previews in the requested split', () => { mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } openFilePreviewToSide({ language: 'html', @@ -95,6 +124,131 @@ describe('openFileInBrowserTab', () => { expect(mocks.createBrowserTab).not.toHaveBeenCalled() }) + it('reports a paired-runtime recovery failure without an unhandled rejection', async () => { + mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } + mocks.createWebRuntimeSessionBrowserTab.mockRejectedValue(new Error('cleanup unknown')) + + openFilePreviewToSide({ + language: 'html', + filePath: '/srv/repo/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + }) + + await vi.waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith('Unable to open this file in Orca Browser.') + ) + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('does not delete a split owned by an overlapping paired preview', async () => { + mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } + mocks.createEmptySplitGroup.mockImplementationOnce(() => { + mocks.layoutByWorktree = { + 'wt-1': { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: 'group-2' } + } + } + return 'group-2' + }) + let rejectFirst!: (error: Error) => void + let resolveSecond!: (created: boolean) => void + mocks.createWebRuntimeSessionBrowserTab + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectFirst = reject + }) + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveSecond = resolve + }) + ) + + const preview = { + language: 'html', + filePath: '/srv/repo/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + } + openFilePreviewToSide(preview) + openFilePreviewToSide(preview) + expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + clientTargetGroupId: 'group-2', + clientTargetGroupCreated: true + }) + ) + rejectFirst(new Error('first preview failed')) + + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + clientTargetGroupId: 'group-2', + clientTargetGroupCreated: false + }) + ) + + resolveSecond(true) + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + }) + + it('does not send a runtime-owned file path to the Electron browser provider', () => { + mocks.environmentId = 'runtime-1' + + openFilePreviewToSide({ + language: 'html', + filePath: '/srv/repo/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + }) + + expect(mocks.toastError).toHaveBeenCalledWith('Unable to open this file in Orca Browser.') + expect(mocks.createEmptySplitGroup).not.toHaveBeenCalled() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.createWebRuntimeSessionBrowserTab).not.toHaveBeenCalled() + }) + + it('rejects unavailable paired-web previews before creating a split', () => { + mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'hidden', reason: 'streaming unavailable' } + + openFilePreviewToSide({ + language: 'html', + filePath: '/srv/repo/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + }) + + expect(mocks.toastError).toHaveBeenCalledWith('streaming unavailable') + expect(mocks.createEmptySplitGroup).not.toHaveBeenCalled() + expect(mocks.createWebRuntimeSessionBrowserTab).not.toHaveBeenCalled() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('does not create a side split for unsupported SSH previews', () => { + mocks.connectionId = 'ssh-1' + + openFilePreviewToSide({ + language: 'html', + filePath: '/home/alice/report.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + }) + + expect(mocks.toastError).toHaveBeenCalledWith(REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE) + expect(mocks.createEmptySplitGroup).not.toHaveBeenCalled() + }) + it('returns unsupported for SSH worktrees without creating a local file URL tab', () => { mocks.connectionId = 'ssh-1' @@ -112,6 +266,71 @@ describe('openFileInBrowserTab', () => { }) }) +describe('canShowWorkspaceFileBrowserAction', () => { + it('hides incapable paired providers and permits capable runtime providers', () => { + mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'hidden', reason: 'streaming unavailable' } + expect( + canShowWorkspaceFileBrowserAction(browserActionState(), 'wt-1', '/repo/report.html') + ).toBe(false) + + mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } + expect( + canShowWorkspaceFileBrowserAction(browserActionState(), 'wt-1', '/repo/report.html') + ).toBe(true) + expect( + canShowWorkspaceFileBrowserAction(browserActionState('ssh-1'), 'wt-1', '/repo/report.html') + ).toBe(false) + }) + + it('permits local files but hides SSH paths from the local browser provider', () => { + expect( + canShowWorkspaceFileBrowserAction(browserActionState(), 'wt-1', '/repo/report.html') + ).toBe(true) + expect( + canShowWorkspaceFileBrowserAction(browserActionState('ssh-1'), 'wt-1', '/repo/report.html') + ).toBe(false) + }) + + it('resolves local and SSH files independently in a mixed folder workspace', () => { + const workspaceId = folderWorkspaceKey('folder-1') + const state = { + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath: '/workspace' + } + ], + projectGroups: [{ id: 'group-1', parentGroupId: null }], + repos: [ + { + id: 'repo-local', + path: '/workspace/local', + projectGroupId: 'group-1' + }, + { + id: 'repo-ssh', + path: '/workspace/remote', + projectGroupId: 'group-1', + connectionId: 'ssh-1' + } + ], + worktreesByRepo: {} + } as never + + expect( + canShowWorkspaceFileBrowserAction(state, workspaceId, '/workspace/local/report.html') + ).toBe(true) + expect( + canShowWorkspaceFileBrowserAction(state, workspaceId, '/workspace/remote/report.html') + ).toBe(false) + expect( + canShowWorkspaceFileBrowserAction(state, workspaceId, '/workspace/unknown/report.html') + ).toBe(false) + }) +}) + describe('getWorkspaceFileBrowserOpenTarget', () => { it('returns a reusable browser navigation target for local files', () => { expect( diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 788012784a0..7f50873e3a8 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -1,9 +1,14 @@ import { toast } from 'sonner' import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links' -import { getConnectionId } from '@/lib/connection-context' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { useCallback } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionBrowserTab } from '@/runtime/web-runtime-session' import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' import { findSiblingGroupId } from '@/store/slices/tabs' export type PreviewableLanguage = 'html' @@ -11,12 +16,63 @@ export const REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE = 'Open in Orca Browser is only available for local files.' const FILE_BROWSER_OPEN_FAILED_MESSAGE = 'Unable to open this file in Orca Browser.' +type WorkspaceFileBrowserActionMode = 'local-client' | 'paired-runtime' | null + +function getWorkspaceFileBrowserActionMode( + state: AppState, + worktreeId: string +): WorkspaceFileBrowserActionMode { + const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] + if (availability.state !== 'enabled') { + return null + } + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + return environmentId + ? availability.provider === 'paired-runtime' + ? 'paired-runtime' + : null + : 'local-client' +} + +export function canShowWorkspaceFileBrowserAction( + state: AppState, + worktreeId: string, + filePath: string +): boolean { + const mode = getWorkspaceFileBrowserActionMode(state, worktreeId) + return mode !== null && getConnectionIdForFileFromState(state, worktreeId, filePath) === null +} + +export function useWorkspaceFileBrowserActionPredicate( + worktreeId: string | null +): (filePath: string) => boolean { + const inputs = useAppStore( + useShallow((state) => ({ + mode: worktreeId ? getWorkspaceFileBrowserActionMode(state, worktreeId) : null, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo + })) + ) + return useCallback( + (filePath: string) => + inputs.mode !== null && + getConnectionIdForFileFromState(inputs, worktreeId, filePath) === null, + [inputs, worktreeId] + ) +} + function reportRemoteFileBrowserOpen(result: Promise): void { - void result.then((created) => { - if (!created) { + void result + .then((created) => { + if (!created) { + toast.error(FILE_BROWSER_OPEN_FAILED_MESSAGE) + } + }) + .catch(() => { toast.error(FILE_BROWSER_OPEN_FAILED_MESSAGE) - } - }) + }) } export type WorkspaceFileBrowserOpenTarget = @@ -35,7 +91,7 @@ export function getWorkspaceFileBrowserOpenTarget(params: { filePath: string worktreeId: string }): WorkspaceFileBrowserOpenTarget { - if (getConnectionId(params.worktreeId)) { + if (getConnectionIdForFile(params.worktreeId, params.filePath) !== null) { // Why: Chromium resolves file:// URLs on the local machine. Remote files // need an Orca-served URL before the browser can render them correctly. return { @@ -62,8 +118,19 @@ export function openFileInBrowserTab(params: { } const state = useAppStore.getState() + const browserAvailability = getClientCreationActionPolicy(state, params.worktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return target + } const environmentId = getRuntimeEnvironmentIdForWorktree(state, params.worktreeId) if (environmentId) { + if (browserAvailability.provider !== 'paired-runtime') { + toast.error(FILE_BROWSER_OPEN_FAILED_MESSAGE) + return target + } reportRemoteFileBrowserOpen( createWebRuntimeSessionBrowserTab({ worktreeId: params.worktreeId, @@ -103,6 +170,24 @@ export function openFilePreviewToSide(params: { const state = useAppStore.getState() const worktreeId = params.worktreeId + const target = getWorkspaceFileBrowserOpenTarget({ + filePath: params.filePath, + worktreeId + }) + if (target.status === 'unsupported') { + toast.error(target.message) + return + } + const browserAvailability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (environmentId && browserAvailability.provider !== 'paired-runtime') { + toast.error(FILE_BROWSER_OPEN_FAILED_MESSAGE) + return + } // Resolve the group this action originated from. Prefer the caller-supplied // id (the tab's own group under split-pane layouts), fall back to the @@ -129,16 +214,6 @@ export function openFilePreviewToSide(params: { return } - const target = getWorkspaceFileBrowserOpenTarget({ - filePath: params.filePath, - worktreeId - }) - if (target.status === 'unsupported') { - toast.error(target.message) - return - } - - const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) if (environmentId) { reportRemoteFileBrowserOpen( createWebRuntimeSessionBrowserTab({ diff --git a/src/renderer/src/lib/workspace-browser-tab-open.test.ts b/src/renderer/src/lib/workspace-browser-tab-open.test.ts index d414316b52f..9b85d2c3c55 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.test.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.test.ts @@ -209,6 +209,28 @@ describe('openWorkspaceBrowserTab', () => { ) }) + it('does not fall back locally when the runtime create outcome is unknown', async () => { + const createBrowserTab = vi.fn() + mocks.state = { + ...ownerState(toRuntimeExecutionHostId('hub-a')), + ...browserCapableRuntime('hub-a'), + createBrowserTab, + defaultBrowserSessionProfileId: 'client-profile', + defaultBrowserSessionProfileIdByHostId: {} + } + mocks.createRemote.mockRejectedValue(new Error('create outcome unknown')) + + await expect( + openWorkspaceBrowserTab({ + workspaceId: WORKSPACE_ID, + url: 'https://example.com/', + intent: { kind: 'url' } + }) + ).rejects.toThrow('Unable to open URL.') + + expect(createBrowserTab).not.toHaveBeenCalled() + }) + it('fails closed for invalid targets and unresolved owners, then falls back locally', async () => { const secretUrl = 'https://example.com/?q=secret-value' const request = { diff --git a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts new file mode 100644 index 00000000000..135fdad5925 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createWebRuntimeSessionBrowserTab } from './web-runtime-session' + +const ENVIRONMENT_ID = 'runtime-1' +const WORKTREE_ID = 'repo::/worktree' + +const mocks = vi.hoisted(() => ({ + closeEmptyGroup: vi.fn(), + getState: vi.fn(), + runtimeCall: vi.fn(), + setState: vi.fn(), + subscribe: vi.fn() +})) + +vi.mock('../store', () => ({ + useAppStore: { + getState: mocks.getState, + setState: mocks.setState, + subscribe: mocks.subscribe + } +})) + +vi.mock('./web-session-tabs-sync', () => ({ + acceptReplayedWebSessionTabsSnapshot: vi.fn(), + applyFreshWebSessionTabsSnapshot: vi.fn(), + applyWebSessionTabsStorePatch: vi.fn(), + resolveHostSessionTabIdForWebSessionTab: vi.fn() +})) + +vi.mock('@/lib/feature-education-telemetry', () => ({ + trackTerminalPaneSplit: vi.fn() +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: vi.fn() +})) + +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + deliverLaunchPromptToAgentTab: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn() +})) + +describe('paired browser capability cleanup', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: mocks.runtimeCall } } + }) + }) + + it.each([ + ['absent status', new Map()], + [ + 'unknown capabilities', + new Map([[ENVIRONMENT_ID, { status: { capabilities: undefined }, checkedAt: 1 }]]) + ], + [ + 'mixed-version host', + new Map([[ENVIRONMENT_ID, { status: { capabilities: [] }, checkedAt: 1 }]]) + ] + ])('rejects %s and removes the caller-created split', async (_label, runtimeStatuses) => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + runtimeStatusByEnvironmentId: runtimeStatuses, + closeEmptyGroup: mocks.closeEmptyGroup + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'preview-group', + clientTargetGroupCreated: true + }) + ).rejects.toThrow('does not support browser streaming') + + expect(mocks.closeEmptyGroup).toHaveBeenCalledOnce() + expect(mocks.closeEmptyGroup).toHaveBeenCalledWith(WORKTREE_ID, 'preview-group') + expect(mocks.runtimeCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts b/src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts new file mode 100644 index 00000000000..51c02be9842 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts @@ -0,0 +1,122 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { e2eConfig } from '@/lib/e2e-config' + +type BrowserCreationFaultSnapshot = { + armed: boolean + capabilityRejectionArmed: boolean + createdPageId: string | null + suppressedPageIds: string[] +} + +type BrowserCreationFaultApi = { + arm: () => void + armCapabilityRejection: () => void + release: () => boolean + reset: () => void + snapshot: () => BrowserCreationFaultSnapshot +} + +type BrowserCreationFaultWindow = Window & { + __webRuntimeBrowserCreationFault?: BrowserCreationFaultApi +} + +let armed = false +let capabilityRejectionArmed = false +let createdPageId: string | null = null +let failNextReconciliation = false +let releaseCreatedPage: (() => void) | null = null +let createdPageBarrier: Promise | null = null +const suppressedPageIds = new Set() +const MAX_SUPPRESSED_PAGE_IDS = 128 + +function resetFault(): void { + releaseCreatedPage?.() + armed = false + capabilityRejectionArmed = false + createdPageId = null + failNextReconciliation = false + releaseCreatedPage = null + createdPageBarrier = null + suppressedPageIds.clear() +} + +function exposeFaultApi(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as BrowserCreationFaultWindow + target.__webRuntimeBrowserCreationFault ??= { + arm: () => { + resetFault() + armed = true + createdPageBarrier = new Promise((resolve) => { + releaseCreatedPage = resolve + }) + }, + armCapabilityRejection: () => { + resetFault() + capabilityRejectionArmed = true + }, + release: () => { + if (!armed || !createdPageId || !releaseCreatedPage) { + return false + } + failNextReconciliation = true + const release = releaseCreatedPage + releaseCreatedPage = null + release() + return true + }, + reset: resetFault, + snapshot: () => ({ + armed, + capabilityRejectionArmed, + createdPageId, + suppressedPageIds: [...suppressedPageIds] + }) + } +} + +exposeFaultApi() + +export function throwIfE2eWebRuntimeBrowserCapabilityUnavailable(): void { + if (!e2eConfig.exposeStore || !capabilityRejectionArmed) { + return + } + capabilityRejectionArmed = false + throw new Error('E2E forced browser capability rejection') +} + +export async function pauseAfterE2eWebRuntimeBrowserCreate(remotePageId: string): Promise { + if (!e2eConfig.exposeStore || !armed || !createdPageBarrier) { + return + } + createdPageId = remotePageId + await createdPageBarrier +} + +export function throwIfE2eWebRuntimeBrowserReconciliationFails(): void { + if (!e2eConfig.exposeStore || !failNextReconciliation) { + return + } + failNextReconciliation = false + throw new Error('E2E forced session-tabs reconciliation timeout') +} + +export function suppressE2eWebRuntimeBrowserSnapshot( + snapshot: RuntimeMobileSessionTabsResult +): boolean { + if (!e2eConfig.exposeStore || !armed) { + return false + } + const pageIds = snapshot.tabs.flatMap((tab) => + tab.type === 'browser' && tab.browserPageId ? [tab.browserPageId] : [] + ) + for (const pageId of pageIds) { + if (suppressedPageIds.size >= MAX_SUPPRESSED_PAGE_IDS) { + break + } + suppressedPageIds.add(pageId) + } + return pageIds.length > 0 +} diff --git a/src/renderer/src/runtime/web-runtime-browser-materialization.test.ts b/src/renderer/src/runtime/web-runtime-browser-materialization.test.ts new file mode 100644 index 00000000000..1bdda2a132b --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-browser-materialization.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { hasMaterializedWebRuntimeBrowserPage } from './web-runtime-browser-materialization' + +function state(overrides: Partial = {}): AppState { + return { + browserTabsByWorktree: { + 'wt-1': [{ id: 'workspace-1', worktreeId: 'wt-1', pageIds: ['page-1'] }] + }, + browserPagesByWorkspace: { + 'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1', worktreeId: 'wt-1' }] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + entityId: 'workspace-1', + contentType: 'browser', + groupId: 'group-1' + } + ] + }, + ...overrides + } as AppState +} + +describe('hasMaterializedWebRuntimeBrowserPage', () => { + it('requires the exact remote owner, client workspace, unified tab, and requested group', () => { + const materialized = state() + + expect( + hasMaterializedWebRuntimeBrowserPage( + materialized, + 'env-1', + 'wt-1', + 'remote-page-1', + 'group-1' + ) + ).toBe(true) + expect( + hasMaterializedWebRuntimeBrowserPage( + materialized, + 'env-1', + 'wt-1', + 'remote-page-1', + 'other-group' + ) + ).toBe(false) + expect( + hasMaterializedWebRuntimeBrowserPage( + state({ unifiedTabsByWorktree: { 'wt-1': [] } }), + 'env-1', + 'wt-1', + 'remote-page-1' + ) + ).toBe(false) + expect( + hasMaterializedWebRuntimeBrowserPage(materialized, 'env-2', 'wt-1', 'remote-page-1') + ).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-browser-materialization.ts b/src/renderer/src/runtime/web-runtime-browser-materialization.ts new file mode 100644 index 00000000000..434f9a579af --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-browser-materialization.ts @@ -0,0 +1,33 @@ +import type { AppState } from '@/store/types' + +type BrowserMaterializationState = Pick< + AppState, + | 'browserPagesByWorkspace' + | 'browserTabsByWorktree' + | 'remoteBrowserPageHandlesByPageId' + | 'unifiedTabsByWorktree' +> + +export function hasMaterializedWebRuntimeBrowserPage( + state: BrowserMaterializationState, + environmentId: string, + worktreeId: string, + remotePageId: string, + expectedGroupId?: string +): boolean { + return (state.browserTabsByWorktree[worktreeId] ?? []).some((workspace) => { + const hasRemotePage = (state.browserPagesByWorkspace[workspace.id] ?? []).some((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return handle?.environmentId === environmentId && handle.remotePageId === remotePageId + }) + if (!hasRemotePage) { + return false + } + return (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => + tab.contentType === 'browser' && + tab.entityId === workspace.id && + (!expectedGroupId || tab.groupId === expectedGroupId) + ) + }) +} diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 38a6efd7e58..ba309a39f91 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -55,7 +55,8 @@ const mocks = vi.hoisted(() => ({ trackTerminalPaneSplit: vi.fn(), deliverLaunchPromptToAgentTab: vi.fn(), seedNativeChatLaunchDraftForAgentTab: vi.fn(), - getRuntimeEnvironmentIdForWorktree: vi.fn() + getRuntimeEnvironmentIdForWorktree: vi.fn(), + hasMaterializedWebRuntimeBrowserPage: vi.fn() })) vi.mock('../store', () => ({ @@ -87,7 +88,12 @@ vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab })) +vi.mock('./web-runtime-browser-materialization', () => ({ + hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage +})) + const ENVIRONMENT_ID = 'web-env-1' +const SECOND_ENVIRONMENT_ID = 'web-env-2' const RUNTIME_EXECUTION_HOST_ID = toRuntimeExecutionHostId(ENVIRONMENT_ID) const WORKTREE_ID = 'repo::/worktree' const FOCUS_LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -299,6 +305,7 @@ describe('createWebRuntimeSessionBrowserTab', () => { mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null) mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true) + mocks.hasMaterializedWebRuntimeBrowserPage.mockReturnValue(true) }) afterEach(() => { @@ -610,22 +617,46 @@ describe('createWebRuntimeSessionBrowserTab', () => { } ) - it('does not retry a failed browser create', async () => { + it.each([ + 'remote_runtime_unavailable', + 'runtime_timeout', + 'invalid_runtime_response', + 'runtime_error' + ])('reports a browser-create %s as ambiguous without retrying', async (code) => { const runtimeCall = vi.fn().mockResolvedValue({ id: 'create-lost', ok: false, - error: { code: 'remote_runtime_unavailable', message: 'response lost' } + error: { code, message: 'response lost' } }) vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) await expect( createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID, url: 'https://example.com/' }) - ).resolves.toBe(false) + ).rejects.toThrow('did not confirm whether the browser tab was created') expect(runtimeCall).toHaveBeenCalledOnce() expect(mocks.createBrowserTab).not.toHaveBeenCalled() }) + it.each(['browser_error', 'invalid_argument', 'method_not_found'])( + 'reports a definitive browser-create %s as a soft failure', + async (code) => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'create-rejected', + ok: false, + error: { code, message: 'host rejected before creation' } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID, url: 'https://example.com/' }) + ).resolves.toBe(false) + + expect(runtimeCall).toHaveBeenCalledOnce() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + } + ) + it('creates an unfocused browser while preserving its requested split', async () => { mocks.getState.mockReturnValue({ settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, @@ -691,16 +722,15 @@ describe('createWebRuntimeSessionBrowserTab', () => { ) expect( isWebSessionBrowserPlacementGroupReserved({ - environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, groupId: 'client-preview-group' }) - ).toBe(true) + ).toBe(false) expect(mocks.createBrowserTab).not.toHaveBeenCalled() expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() }) - it('releases and removes a newly-created split when host creation fails', async () => { + it('releases and removes a newly-created split when host creation is ambiguous', async () => { vi.stubGlobal('window', { api: { runtimeEnvironments: { @@ -716,18 +746,364 @@ describe('createWebRuntimeSessionBrowserTab', () => { clientTargetGroupId: 'client-preview-group', clientTargetGroupCreated: true }) - ).resolves.toBe(false) + ).rejects.toThrow('did not confirm whether the browser tab was created') expect(mocks.closeEmptyGroup).toHaveBeenCalledWith(WORKTREE_ID, 'client-preview-group') expect( isWebSessionBrowserPlacementGroupReserved({ - environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, groupId: 'client-preview-group' }) ).toBe(false) }) + it('cleans up and reports failure when the created browser cannot reconcile', async () => { + mocks.hasMaterializedWebRuntimeBrowserPage.mockReturnValue(false) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: false, + error: { code: 'remote_runtime_timeout', message: 'session tabs timed out' } + }) + .mockResolvedValueOnce({ id: 'close', ok: true, result: { closed: true } }) + .mockResolvedValueOnce({ id: 'list-after-close', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: true, + focusOnCreate: false + }) + ).resolves.toBe(false) + + expect(runtimeCall).toHaveBeenNthCalledWith(3, { + selector: ENVIRONMENT_ID, + method: 'browser.tabClose', + params: { + worktree: `id:${WORKTREE_ID}`, + page: 'remote-browser-page-1' + }, + timeoutMs: 15_000 + }) + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.closeEmptyGroup).toHaveBeenCalledWith(WORKTREE_ID, 'client-preview-group') + expect( + isWebSessionBrowserPlacementGroupReserved({ + worktreeId: WORKTREE_ID, + groupId: 'client-preview-group' + }) + ).toBe(false) + }) + + it('keeps a split reserved after ownership moves to an overlapping browser creation', async () => { + mocks.getState.mockReturnValue({ + ...mocks.getState(), + runtimeStatusByEnvironmentId: new Map( + [ENVIRONMENT_ID, SECOND_ENVIRONMENT_ID].map((environmentId) => [ + environmentId, + { status: { capabilities: ['browser.screencast.v1'] }, checkedAt: 1 } + ]) + ) + }) + let resolveFirstList!: (response: unknown) => void + let resolveSecondList!: (response: unknown) => void + const firstList = new Promise((resolve) => { + resolveFirstList = resolve + }) + const secondList = new Promise((resolve) => { + resolveSecondList = resolve + }) + let createCount = 0 + let listCount = 0 + const runtimeCall = vi.fn((request: { method: string }) => { + if (request.method === 'browser.tabCreate') { + createCount += 1 + return Promise.resolve({ + id: `create-${createCount}`, + ok: true, + result: { browserPageId: `remote-browser-page-${createCount}` } + }) + } + if (request.method === 'session.tabs.list') { + listCount += 1 + if (listCount === 1) { + return firstList + } + if (listCount === 2) { + return secondList + } + return Promise.resolve({ id: 'list-after-close', ok: true, result: makeSnapshot() }) + } + return Promise.resolve({ id: 'close', ok: true, result: { closed: true } }) + }) + mocks.hasMaterializedWebRuntimeBrowserPage.mockImplementation( + (_state, _environmentId, _worktreeId, remotePageId) => + remotePageId === 'remote-browser-page-2' + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + const first = createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: true, + focusOnCreate: false + }) + await vi.waitFor(() => expect(listCount).toBe(1)) + const second = createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: SECOND_ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: false, + focusOnCreate: false + }) + await vi.waitFor(() => expect(createCount).toBe(2)) + expect( + isWebSessionBrowserPlacementGroupReserved({ + worktreeId: WORKTREE_ID, + groupId: 'client-preview-group' + }) + ).toBe(true) + + resolveFirstList({ + id: 'first-list', + ok: false, + error: { code: 'remote_runtime_timeout', message: 'first preview timed out' } + }) + await vi.waitFor(() => expect(listCount).toBeGreaterThanOrEqual(2)) + resolveSecondList({ id: 'second-list', ok: true, result: makeSnapshot() }) + await expect(second).resolves.toBe(true) + await expect(first).resolves.toBe(false) + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + expect(mocks.hasMaterializedWebRuntimeBrowserPage).toHaveBeenCalledWith( + expect.anything(), + SECOND_ENVIRONMENT_ID, + WORKTREE_ID, + 'remote-browser-page-2', + 'client-preview-group' + ) + }) + + it('transfers empty-split cleanup when both overlapping creations fail', async () => { + mocks.getState.mockReturnValue({ + ...mocks.getState(), + runtimeStatusByEnvironmentId: new Map( + [ENVIRONMENT_ID, SECOND_ENVIRONMENT_ID].map((environmentId) => [ + environmentId, + { status: { capabilities: ['browser.screencast.v1'] }, checkedAt: 1 } + ]) + ) + }) + let rejectFirst!: (error: Error) => void + let rejectSecond!: (error: Error) => void + const runtimeCall = vi + .fn() + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectFirst = reject + }) + ) + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectSecond = reject + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + const first = createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: true, + focusOnCreate: false + }) + const second = createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: SECOND_ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: false, + focusOnCreate: false + }) + rejectFirst(new Error('first host offline')) + await expect(first).rejects.toThrow('did not confirm whether the browser tab was created') + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + + rejectSecond(new Error('second host offline')) + await expect(second).rejects.toThrow('did not confirm whether the browser tab was created') + expect(mocks.closeEmptyGroup).toHaveBeenCalledOnce() + expect(mocks.closeEmptyGroup).toHaveBeenCalledWith(WORKTREE_ID, 'client-preview-group') + }) + + it('rechecks split reservations after delayed host rollback', async () => { + mocks.getState.mockReturnValue({ + ...mocks.getState(), + runtimeStatusByEnvironmentId: new Map( + [ENVIRONMENT_ID, SECOND_ENVIRONMENT_ID].map((environmentId) => [ + environmentId, + { status: { capabilities: ['browser.screencast.v1'] }, checkedAt: 1 } + ]) + ) + }) + let resolveClose!: (response: unknown) => void + const closeResponse = new Promise((resolve) => { + resolveClose = resolve + }) + let createCount = 0 + let listCount = 0 + const runtimeCall = vi.fn((request: { method: string }) => { + if (request.method === 'browser.tabCreate') { + createCount += 1 + return Promise.resolve({ + id: `create-${createCount}`, + ok: true, + result: { browserPageId: `remote-browser-page-${createCount}` } + }) + } + if (request.method === 'browser.tabClose') { + return closeResponse + } + listCount += 1 + return Promise.resolve( + listCount === 1 + ? { + id: 'first-list', + ok: false, + error: { code: 'remote_runtime_timeout', message: 'first preview timed out' } + } + : { id: `list-${listCount}`, ok: true, result: makeSnapshot() } + ) + }) + mocks.hasMaterializedWebRuntimeBrowserPage.mockImplementation( + (_state, _environmentId, _worktreeId, remotePageId) => + remotePageId === 'remote-browser-page-2' + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + const first = createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: true, + focusOnCreate: false + }) + await vi.waitFor(() => + expect( + runtimeCall.mock.calls.some(([request]) => request.method === 'browser.tabClose') + ).toBe(true) + ) + const second = createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: SECOND_ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: false, + focusOnCreate: false + }) + await vi.waitFor(() => expect(createCount).toBe(2)) + + resolveClose({ id: 'close', ok: true, result: { closed: true } }) + await expect(second).resolves.toBe(true) + await expect(first).resolves.toBe(false) + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + }) + + it('accepts subscription materialization when the eager reconciliation fails', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: false, + error: { code: 'remote_runtime_timeout', message: 'session tabs timed out' } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect(createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID })).resolves.toBe(true) + + expect(mocks.hasMaterializedWebRuntimeBrowserPage).toHaveBeenCalledWith( + expect.anything(), + ENVIRONMENT_ID, + WORKTREE_ID, + 'remote-browser-page-1', + undefined + ) + expect(runtimeCall).toHaveBeenCalledTimes(2) + }) + + it('reports ambiguous failure when exact host cleanup is not confirmed', async () => { + mocks.hasMaterializedWebRuntimeBrowserPage.mockReturnValue(false) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: false, + error: { code: 'remote_runtime_timeout', message: 'session tabs timed out' } + }) + .mockResolvedValueOnce({ + id: 'close', + ok: false, + error: { code: 'remote_runtime_timeout', message: 'close timed out' } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: true + }) + ).rejects.toThrow('could not recover the failed browser creation') + + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.closeEmptyGroup).toHaveBeenCalledWith(WORKTREE_ID, 'client-preview-group') + }) + + it('cleans up when applying the host snapshot fails before materialization', async () => { + mocks.hasMaterializedWebRuntimeBrowserPage.mockReturnValue(false) + mocks.applyFreshWebSessionTabsSnapshot.mockImplementationOnce(() => { + throw new Error('store reconcile failed') + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + .mockResolvedValueOnce({ id: 'close', ok: true, result: { closed: true } }) + .mockResolvedValueOnce({ id: 'list-after-close', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect(createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID })).resolves.toBe( + false + ) + + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'browser.tabCreate', + 'session.tabs.list', + 'browser.tabClose', + 'session.tabs.list' + ]) + }) + it('keeps the requested worktree selected while the browser snapshot catches up', async () => { const snapshot = makeSnapshot() const setStateResults: unknown[] = [] @@ -1007,7 +1383,7 @@ describe('createWebRuntimeSessionBrowserTab', () => { url: 'https://example.com/?q=private', failureLogMode: 'operation-only' }) - ).resolves.toBe(false) + ).rejects.toThrow('did not confirm whether the browser tab was created') expect(consoleWarn).toHaveBeenCalledWith('[web-runtime-session] failed to create browser tab') expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain('private') diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index ad699fcf537..9cb617d5692 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -29,7 +29,7 @@ import type { TerminalPaneLayoutNode, TuiAgent } from '../../../shared/types' import { createBrowserUuid } from '../lib/browser-uuid' import { getRuntimeEnvironmentIdForWorktree } from '../lib/worktree-runtime-owner' import { useAppStore } from '../store' -import { unwrapRuntimeRpcResult } from './runtime-rpc-client' +import { hasRuntimeRpcErrorCode, unwrapRuntimeRpcResult } from './runtime-rpc-client' import { createAgentSessionCreateOperation, withAgentSessionCreateOperationId @@ -67,12 +67,20 @@ import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' import { parsePaneKey } from '../../../shared/stable-pane-id' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import { + claimWebSessionBrowserPlacementGroupCleanup, forgetWebSessionBrowserPlacement, - isWebSessionBrowserPlacementGroupReserved, + markWebSessionBrowserPlacementGroupMaterialized, moveWebSessionBrowserPlacement, - recordWebSessionBrowserPlacement + recordWebSessionBrowserPlacement, + releaseWebSessionBrowserPlacementGroup } from './web-session-browser-placement' import { assertRuntimeManagedBrowserCreationAvailable } from '../lib/client-creation-action-policy' +import { hasMaterializedWebRuntimeBrowserPage } from './web-runtime-browser-materialization' +import { + pauseAfterE2eWebRuntimeBrowserCreate, + throwIfE2eWebRuntimeBrowserCapabilityUnavailable, + throwIfE2eWebRuntimeBrowserReconciliationFails +} from './web-runtime-browser-creation-e2e-fault' export { HOST_TERMINAL_SURFACE_SEPARATOR, @@ -93,6 +101,22 @@ export type WebRuntimeTerminalCreateOutcome = | { status: 'created' } | { status: 'failed'; message: string } +const DEFINITIVE_BROWSER_CREATE_FAILURE_CODES = [ + 'browser_error', + 'capability_unsupported', + 'invalid_argument', + 'invalid_params', + 'method_not_found', + 'runtime_rpc_queue_overloaded', + 'selector_ambiguous', + 'selector_not_found', + 'unauthorized' +] as const + +function isDefinitiveBrowserCreateFailure(error: unknown): boolean { + return DEFINITIVE_BROWSER_CREATE_FAILURE_CODES.some((code) => hasRuntimeRpcErrorCode(error, code)) +} + const pendingWebRuntimeSplitMirrorTelemetry = new Map>() const WEB_RUNTIME_SPLIT_MIRROR_SUPPRESSION_TTL_MS = 30_000 let pendingWebRuntimeSplitMirrorTelemetryId = 0 @@ -450,32 +474,36 @@ export async function createWebRuntimeSessionBrowserTab(args: { if (!environmentId || !isWebRuntimeSessionActive(environmentId)) { return false } - assertRuntimeManagedBrowserCreationAvailable(useAppStore.getState(), environmentId) const intentOwner = captureWebSessionIntentOwner(environmentId) const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) const shouldFocusOnCreate = args.focusOnCreate !== false const shouldSelectWorktree = args.selectWorktree !== false const provisionalPageId = createBrowserUuid() - if (args.clientTargetGroupId) { - recordWebSessionBrowserPlacement({ - environmentId, - worktreeId: args.worktreeId, - remotePageId: provisionalPageId, - groupId: args.clientTargetGroupId - }) - } - if (shouldSelectWorktree) { - selectWebRuntimeSessionBrowserWorktree(args.worktreeId, environmentId) - } - const initialFocusState = shouldFocusOnCreate ? useAppStore.getState() : null - const expectedActiveWorktreeId = initialFocusState?.activeWorktreeId - const expectedActiveWorkspaceExecutionHostId = initialFocusState?.activeWorkspaceExecutionHostId - const expectedCurrentLocalTabId = initialFocusState - ? resolveWebSessionVisibleTabId(initialFocusState, args.worktreeId) - : null let unsubscribeFocusGuard = (): void => {} let guardedPageId = provisionalPageId + let createdPageId: string | null = null + let createAttempted = false try { + throwIfE2eWebRuntimeBrowserCapabilityUnavailable() + assertRuntimeManagedBrowserCreationAvailable(useAppStore.getState(), environmentId) + if (args.clientTargetGroupId) { + recordWebSessionBrowserPlacement({ + environmentId, + worktreeId: args.worktreeId, + remotePageId: provisionalPageId, + groupId: args.clientTargetGroupId, + callerCreatedGroup: args.clientTargetGroupCreated + }) + } + if (shouldSelectWorktree) { + selectWebRuntimeSessionBrowserWorktree(args.worktreeId, environmentId) + } + const initialFocusState = shouldFocusOnCreate ? useAppStore.getState() : null + const expectedActiveWorktreeId = initialFocusState?.activeWorktreeId + const expectedActiveWorkspaceExecutionHostId = initialFocusState?.activeWorkspaceExecutionHostId + const expectedCurrentLocalTabId = initialFocusState + ? resolveWebSessionVisibleTabId(initialFocusState, args.worktreeId) + : null if (shouldFocusOnCreate && matchesWebSessionIntentOwner(intentOwner)) { recordWebSessionFocusIntent( intentOwner, @@ -508,6 +536,7 @@ export async function createWebRuntimeSessionBrowserTab(args: { unsubscribeFocusGuard() }) } + createAttempted = true const created = unwrapRuntimeRpcResult( (await callEnvironment({ method: 'browser.tabCreate', @@ -524,6 +553,8 @@ export async function createWebRuntimeSessionBrowserTab(args: { timeoutMs: 15_000 })) as RuntimeRpcResponse ) + createdPageId = created.browserPageId + await pauseAfterE2eWebRuntimeBrowserCreate(created.browserPageId) if (created.browserPageId !== provisionalPageId) { moveWebSessionBrowserPlacement({ environmentId, @@ -548,13 +579,33 @@ export async function createWebRuntimeSessionBrowserTab(args: { try { await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { expectedEnvironmentPairingRevision: intentOwner.pairingRevision, - acceptCurrentSnapshot: true + acceptCurrentSnapshot: true, + afterCurrentInFlight: true, + errorMode: 'throw' }) } catch (error) { - console.warn( - '[web-runtime-session] browser created but reconciliation failed:', - error instanceof Error ? error.message : String(error) + if ( + !hasMaterializedWebRuntimeBrowserPage( + useAppStore.getState(), + environmentId, + args.worktreeId, + created.browserPageId, + args.clientTargetGroupId ?? args.targetGroupId + ) + ) { + throw error + } + } + if ( + !hasMaterializedWebRuntimeBrowserPage( + useAppStore.getState(), + environmentId, + args.worktreeId, + created.browserPageId, + args.clientTargetGroupId ?? args.targetGroupId ) + ) { + throw new Error('The created browser tab did not materialize in the client.') } const remainingFocusIntent = shouldFocusOnCreate ? peekWebSessionFocusIntent(intentOwner, args.worktreeId) @@ -566,27 +617,89 @@ export async function createWebRuntimeSessionBrowserTab(args: { clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, guardedPageId) } unsubscribeFocusGuard() - return true - } catch (error) { - unsubscribeFocusGuard() - forgetWebSessionBrowserPlacement({ - environmentId, - worktreeId: args.worktreeId, - remotePageId: provisionalPageId - }) - if (shouldFocusOnCreate) { - clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, provisionalPageId) - } - if (args.clientTargetGroupId && args.clientTargetGroupCreated) { - const reserved = isWebSessionBrowserPlacementGroupReserved({ - environmentId, + if (args.clientTargetGroupId) { + markWebSessionBrowserPlacementGroupMaterialized({ worktreeId: args.worktreeId, groupId: args.clientTargetGroupId }) - if (!reserved) { - useAppStore.getState().closeEmptyGroup(args.worktreeId, args.clientTargetGroupId) + } + forgetWebSessionBrowserPlacement({ + environmentId, + worktreeId: args.worktreeId, + remotePageId: guardedPageId + }) + return true + } catch (error) { + unsubscribeFocusGuard() + let recoveryError: unknown = null + const createOutcomeUnknown = !createdPageId && !isDefinitiveBrowserCreateFailure(error) + const ownsClientGroupCleanup = args.clientTargetGroupId + ? releaseWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: args.worktreeId, + remotePageId: guardedPageId, + groupId: args.clientTargetGroupId, + callerCreatedGroup: args.clientTargetGroupCreated === true + }) + : false + if (!args.clientTargetGroupId) { + forgetWebSessionBrowserPlacement({ + environmentId, + worktreeId: args.worktreeId, + remotePageId: guardedPageId + }) + } + if (createdPageId) { + try { + const closeResult = unwrapRuntimeRpcResult( + (await callEnvironment({ + method: 'browser.tabClose', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + page: createdPageId + }, + timeoutMs: 15_000 + })) as RuntimeRpcResponse<{ closed: boolean }> + ) + if (!closeResult.closed) { + throw new Error('The paired runtime did not close the unreconciled browser tab.') + } + await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { + expectedEnvironmentPairingRevision: intentOwner.pairingRevision, + afterCurrentInFlight: true, + errorMode: 'throw' + }) + if ( + hasMaterializedWebRuntimeBrowserPage( + useAppStore.getState(), + environmentId, + args.worktreeId, + createdPageId + ) + ) { + throw new Error('The closed browser tab remained materialized in the client.') + } + } catch (cleanupError) { + recoveryError = cleanupError + console.warn( + '[web-runtime-session] failed to clean up unreconciled browser tab:', + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + ) } } + if (shouldFocusOnCreate) { + clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, guardedPageId) + } + if ( + args.clientTargetGroupId && + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: args.worktreeId, + groupId: args.clientTargetGroupId, + ownsGroupCleanup: ownsClientGroupCleanup + }) + ) { + useAppStore.getState().closeEmptyGroup(args.worktreeId, args.clientTargetGroupId) + } if (args.failureLogMode === 'operation-only') { console.warn('[web-runtime-session] failed to create browser tab') } else { @@ -595,6 +708,19 @@ export async function createWebRuntimeSessionBrowserTab(args: { error instanceof Error ? error.message : String(error) ) } + if (recoveryError) { + throw new Error('The paired runtime could not recover the failed browser creation.', { + cause: recoveryError + }) + } + if (!createAttempted) { + throw error + } + if (createOutcomeUnknown) { + throw new Error('The paired runtime did not confirm whether the browser tab was created.', { + cause: error + }) + } return false } } @@ -624,6 +750,8 @@ export async function refreshWebRuntimeSessionTabsSnapshot( hostTabId: string hostTerminalHandle: string } + afterCurrentInFlight?: boolean + errorMode?: 'warn' | 'throw' } = {} ): Promise { const expectedEnvironmentPairingRevision = @@ -639,9 +767,13 @@ export async function refreshWebRuntimeSessionTabsSnapshot( // re-accept its current version after the exact provisional handoff is known. acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) } - const listSessionTabs = options.confirmAgentSessionHandoff - ? listRemoteRuntimeSessionTabsAfterCurrentInFlight - : listRemoteRuntimeSessionTabsDeduped + const listSessionTabs = + options.confirmAgentSessionHandoff || options.afterCurrentInFlight + ? listRemoteRuntimeSessionTabsAfterCurrentInFlight + : listRemoteRuntimeSessionTabsDeduped + if (options.afterCurrentInFlight) { + throwIfE2eWebRuntimeBrowserReconciliationFails() + } const snapshot = await listSessionTabs({ environmentId, worktreeId, @@ -679,6 +811,9 @@ export async function refreshWebRuntimeSessionTabsSnapshot( return patch === state ? state : patch }) } catch (error) { + if (options.errorMode === 'throw') { + throw error + } // Why: host creation already succeeded; the long-lived session.tabs subscription catches up if this eager refresh fails. console.warn( '[web-runtime-session] failed to refresh session-tabs snapshot:', diff --git a/src/renderer/src/runtime/web-session-browser-placement.test.ts b/src/renderer/src/runtime/web-session-browser-placement.test.ts index 97cf36da42d..ea5719b92c6 100644 --- a/src/renderer/src/runtime/web-session-browser-placement.test.ts +++ b/src/renderer/src/runtime/web-session-browser-placement.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { + claimWebSessionBrowserPlacementGroupCleanup, clearWebSessionBrowserPlacementsForEnvironment, clearWebSessionBrowserPlacementsForWorktree, forgetWebSessionBrowserPlacement, isWebSessionBrowserPlacementGroupReserved, + peekWebSessionBrowserPlacementGroup, recordWebSessionBrowserPlacement, + releaseWebSessionBrowserPlacementGroup, resetWebSessionBrowserPlacementsForTests, takeWebSessionBrowserPlacementGroup } from './web-session-browser-placement' @@ -32,7 +35,6 @@ describe('web session browser placement', () => { }) expect( isWebSessionBrowserPlacementGroupReserved({ - environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, groupId: 'preview-group' }) @@ -45,15 +47,214 @@ describe('web session browser placement', () => { }) expect( isWebSessionBrowserPlacementGroupReserved({ - environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, groupId: 'preview-group' }) ).toBe(false) }) + it('transfers caller-created group cleanup to the last failed reservation', () => { + recordWebSessionBrowserPlacement({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-1', + groupId: 'preview-group', + callerCreatedGroup: true + }) + recordWebSessionBrowserPlacement({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2', + groupId: 'preview-group' + }) + + const creatorOwnsCleanup = releaseWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-1', + groupId: 'preview-group', + callerCreatedGroup: true + }) + expect( + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: WORKTREE_ID, + groupId: 'preview-group', + ownsGroupCleanup: creatorOwnsCleanup + }) + ).toBe(false) + const followerOwnsCleanup = releaseWebSessionBrowserPlacementGroup({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2', + groupId: 'preview-group', + callerCreatedGroup: false + }) + expect( + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: WORKTREE_ID, + groupId: 'preview-group', + ownsGroupCleanup: followerOwnsCleanup + }) + ).toBe(true) + }) + + it('retains transferred cleanup when one overlapping environment clears', () => { + for (const [environmentId, remotePageId, callerCreatedGroup] of [ + [ENVIRONMENT_ID, 'page-1', true], + ['environment-2', 'page-2', false], + ['environment-3', 'page-3', false] + ] as const) { + recordWebSessionBrowserPlacement({ + environmentId, + worktreeId: WORKTREE_ID, + remotePageId, + groupId: 'preview-group', + callerCreatedGroup + }) + } + const ownsCleanup = releaseWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-1', + groupId: 'preview-group', + callerCreatedGroup: true + }) + expect( + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: WORKTREE_ID, + groupId: 'preview-group', + ownsGroupCleanup: ownsCleanup + }) + ).toBe(false) + + clearWebSessionBrowserPlacementsForEnvironment('environment-2') + const remainingOwnsCleanup = releaseWebSessionBrowserPlacementGroup({ + environmentId: 'environment-3', + worktreeId: WORKTREE_ID, + remotePageId: 'page-3', + groupId: 'preview-group', + callerCreatedGroup: false + }) + expect(remainingOwnsCleanup).toBe(true) + expect( + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: WORKTREE_ID, + groupId: 'preview-group', + ownsGroupCleanup: remainingOwnsCleanup + }) + ).toBe(false) + const clearedEnvironmentOwnsCleanup = releaseWebSessionBrowserPlacementGroup({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2', + groupId: 'preview-group', + callerCreatedGroup: false + }) + expect( + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: WORKTREE_ID, + groupId: 'preview-group', + ownsGroupCleanup: clearedEnvironmentOwnsCleanup + }) + ).toBe(true) + }) + + it('retains transferred cleanup through snapshot placement lookup', () => { + recordWebSessionBrowserPlacement({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-1', + groupId: 'preview-group', + callerCreatedGroup: true + }) + recordWebSessionBrowserPlacement({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2', + groupId: 'preview-group' + }) + const ownsCleanup = releaseWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-1', + groupId: 'preview-group', + callerCreatedGroup: true + }) + expect( + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: WORKTREE_ID, + groupId: 'preview-group', + ownsGroupCleanup: ownsCleanup + }) + ).toBe(false) + + expect( + peekWebSessionBrowserPlacementGroup({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2' + }) + ).toBe('preview-group') + expect( + releaseWebSessionBrowserPlacementGroup({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2', + groupId: 'preview-group', + callerCreatedGroup: false + }) + ).toBe(true) + }) + + it.each(['environment', 'worktree'] as const)( + 'retains transferred cleanup through %s teardown', + (scope) => { + recordWebSessionBrowserPlacement({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-1', + groupId: 'preview-group', + callerCreatedGroup: true + }) + recordWebSessionBrowserPlacement({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2', + groupId: 'preview-group' + }) + const ownsCleanup = releaseWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-1', + groupId: 'preview-group', + callerCreatedGroup: true + }) + claimWebSessionBrowserPlacementGroupCleanup({ + worktreeId: WORKTREE_ID, + groupId: 'preview-group', + ownsGroupCleanup: ownsCleanup + }) + + if (scope === 'environment') { + clearWebSessionBrowserPlacementsForEnvironment('environment-2') + } else { + clearWebSessionBrowserPlacementsForWorktree('environment-2', WORKTREE_ID) + } + + expect( + releaseWebSessionBrowserPlacementGroup({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-2', + groupId: 'preview-group', + callerCreatedGroup: false + }) + ).toBe(true) + } + ) + it('bounds pending page placements', () => { - for (let index = 0; index < 129; index += 1) { + for (let index = 0; index < 128; index += 1) { recordWebSessionBrowserPlacement({ environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, @@ -62,16 +263,24 @@ describe('web session browser placement', () => { }) } + expect(() => + recordWebSessionBrowserPlacement({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-128', + groupId: 'group-128' + }) + ).toThrow('Too many paired browser placements are pending') + expect( takeWebSessionBrowserPlacementGroup({ environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, remotePageId: 'page-0' }) - ).toBeUndefined() + ).toBe('group-0') expect( isWebSessionBrowserPlacementGroupReserved({ - environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, groupId: 'group-0' }) @@ -82,7 +291,7 @@ describe('web session browser placement', () => { worktreeId: WORKTREE_ID, remotePageId: 'page-128' }) - ).toBe('group-128') + ).toBeUndefined() }) it('refreshes existing entries at capacity without evicting another placement', () => { @@ -104,7 +313,6 @@ describe('web session browser placement', () => { expect( isWebSessionBrowserPlacementGroupReserved({ - environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, groupId: 'group-0' }) diff --git a/src/renderer/src/runtime/web-session-browser-placement.ts b/src/renderer/src/runtime/web-session-browser-placement.ts index 7df0a95b7eb..4e027a5b205 100644 --- a/src/renderer/src/runtime/web-session-browser-placement.ts +++ b/src/renderer/src/runtime/web-session-browser-placement.ts @@ -1,4 +1,11 @@ -const groupByPendingPage = new Map() +type PendingBrowserPlacement = { + groupId: string + ownsGroupCleanup: boolean +} + +const placementByPendingPage = new Map() +const materializedGroupKeys = new Set() +const pendingCleanupClaimsByGroup = new Map() const MAX_PENDING_PLACEMENTS = 128 function pageKey(environmentId: string, worktreeId: string, remotePageId: string): string { @@ -9,21 +16,43 @@ function worktreePrefix(environmentId: string, worktreeId: string): string { return `${environmentId}\0${worktreeId}\0` } +function worktreeGroupKey(worktreeId: string, groupId: string): string { + return `${worktreeId}\0${groupId}` +} + +function hasPlacementForGroup(worktreeId: string, groupId: string): boolean { + const worktreeMarker = `\0${worktreeId}\0` + for (const [key, placement] of placementByPendingPage) { + if (key.includes(worktreeMarker) && placement.groupId === groupId) { + return true + } + } + return false +} + +function forgetSettledMaterializedGroup(worktreeId: string, groupId: string): void { + const key = worktreeGroupKey(worktreeId, groupId) + if (!hasPlacementForGroup(worktreeId, groupId) && !pendingCleanupClaimsByGroup.has(key)) { + materializedGroupKeys.delete(key) + } +} + export function recordWebSessionBrowserPlacement(args: { environmentId: string worktreeId: string remotePageId: string groupId: string + callerCreatedGroup?: boolean }): void { const key = pageKey(args.environmentId, args.worktreeId, args.remotePageId) - groupByPendingPage.delete(key) - if (groupByPendingPage.size >= MAX_PENDING_PLACEMENTS) { - const oldest = groupByPendingPage.keys().next().value - if (oldest !== undefined) { - groupByPendingPage.delete(oldest) - } + const existing = placementByPendingPage.get(key) + if (!existing && placementByPendingPage.size >= MAX_PENDING_PLACEMENTS) { + throw new Error('Too many paired browser placements are pending.') } - groupByPendingPage.set(key, args.groupId) + placementByPendingPage.set(key, { + groupId: args.groupId, + ownsGroupCleanup: args.callerCreatedGroup === true || existing?.ownsGroupCleanup === true + }) } export function moveWebSessionBrowserPlacement(args: { @@ -33,14 +62,15 @@ export function moveWebSessionBrowserPlacement(args: { toRemotePageId: string }): void { const fromKey = pageKey(args.environmentId, args.worktreeId, args.fromRemotePageId) - const groupId = groupByPendingPage.get(fromKey) - groupByPendingPage.delete(fromKey) - if (groupId) { + const placement = placementByPendingPage.get(fromKey) + placementByPendingPage.delete(fromKey) + if (placement) { recordWebSessionBrowserPlacement({ environmentId: args.environmentId, worktreeId: args.worktreeId, remotePageId: args.toRemotePageId, - groupId + groupId: placement.groupId, + callerCreatedGroup: placement.ownsGroupCleanup }) } } @@ -50,7 +80,12 @@ export function forgetWebSessionBrowserPlacement(args: { worktreeId: string remotePageId: string }): void { - groupByPendingPage.delete(pageKey(args.environmentId, args.worktreeId, args.remotePageId)) + const key = pageKey(args.environmentId, args.worktreeId, args.remotePageId) + const placement = placementByPendingPage.get(key) + placementByPendingPage.delete(key) + if (placement) { + forgetSettledMaterializedGroup(args.worktreeId, placement.groupId) + } } export function takeWebSessionBrowserPlacementGroup(args: { @@ -59,46 +94,120 @@ export function takeWebSessionBrowserPlacementGroup(args: { remotePageId: string }): string | undefined { const key = pageKey(args.environmentId, args.worktreeId, args.remotePageId) - const groupId = groupByPendingPage.get(key) - groupByPendingPage.delete(key) - return groupId + const placement = placementByPendingPage.get(key) + placementByPendingPage.delete(key) + if (placement) { + forgetSettledMaterializedGroup(args.worktreeId, placement.groupId) + } + return placement?.groupId +} + +export function peekWebSessionBrowserPlacementGroup(args: { + environmentId: string + worktreeId: string + remotePageId: string +}): string | undefined { + return placementByPendingPage.get(pageKey(args.environmentId, args.worktreeId, args.remotePageId)) + ?.groupId } export function isWebSessionBrowserPlacementGroupReserved(args: { - environmentId: string worktreeId: string groupId: string }): boolean { - const prefix = worktreePrefix(args.environmentId, args.worktreeId) - for (const [key, groupId] of groupByPendingPage) { - if (key.startsWith(prefix) && groupId === args.groupId) { + const worktreeMarker = `\0${args.worktreeId}\0` + for (const [key, placement] of placementByPendingPage) { + if (key.includes(worktreeMarker) && placement.groupId === args.groupId) { return true } } return false } +export function releaseWebSessionBrowserPlacementGroup(args: { + environmentId: string + worktreeId: string + remotePageId: string + groupId: string + callerCreatedGroup: boolean +}): boolean { + const key = pageKey(args.environmentId, args.worktreeId, args.remotePageId) + const placement = placementByPendingPage.get(key) + const groupId = placement?.groupId ?? args.groupId + const groupKey = worktreeGroupKey(args.worktreeId, groupId) + const materialized = materializedGroupKeys.has(groupKey) + placementByPendingPage.delete(key) + const ownsCleanup = + !materialized && (args.callerCreatedGroup || placement?.ownsGroupCleanup === true) + if (ownsCleanup) { + pendingCleanupClaimsByGroup.set(groupKey, (pendingCleanupClaimsByGroup.get(groupKey) ?? 0) + 1) + } + forgetSettledMaterializedGroup(args.worktreeId, groupId) + return ownsCleanup +} + +export function markWebSessionBrowserPlacementGroupMaterialized(args: { + worktreeId: string + groupId: string +}): void { + if (hasPlacementForGroup(args.worktreeId, args.groupId)) { + materializedGroupKeys.add(worktreeGroupKey(args.worktreeId, args.groupId)) + } +} + +export function claimWebSessionBrowserPlacementGroupCleanup(args: { + worktreeId: string + groupId: string + ownsGroupCleanup: boolean +}): boolean { + if (!args.ownsGroupCleanup) { + return false + } + const groupKey = worktreeGroupKey(args.worktreeId, args.groupId) + const pendingClaims = pendingCleanupClaimsByGroup.get(groupKey) ?? 0 + if (pendingClaims <= 1) { + pendingCleanupClaimsByGroup.delete(groupKey) + } else { + pendingCleanupClaimsByGroup.set(groupKey, pendingClaims - 1) + } + if (materializedGroupKeys.has(groupKey)) { + forgetSettledMaterializedGroup(args.worktreeId, args.groupId) + return false + } + const worktreeMarker = `\0${args.worktreeId}\0` + let transferred = false + for (const [key, placement] of placementByPendingPage) { + if (key.includes(worktreeMarker) && placement.groupId === args.groupId) { + placementByPendingPage.set(key, { ...placement, ownsGroupCleanup: true }) + transferred = true + } + } + return !transferred +} + export function clearWebSessionBrowserPlacementsForWorktree( environmentId: string, worktreeId: string ): void { const prefix = worktreePrefix(environmentId, worktreeId) - for (const key of groupByPendingPage.keys()) { - if (key.startsWith(prefix)) { - groupByPendingPage.delete(key) + for (const key of placementByPendingPage.keys()) { + if (key.startsWith(prefix) && !placementByPendingPage.get(key)?.ownsGroupCleanup) { + placementByPendingPage.delete(key) } } } export function clearWebSessionBrowserPlacementsForEnvironment(environmentId: string): void { const prefix = `${environmentId}\0` - for (const key of groupByPendingPage.keys()) { - if (key.startsWith(prefix)) { - groupByPendingPage.delete(key) + for (const key of placementByPendingPage.keys()) { + if (key.startsWith(prefix) && !placementByPendingPage.get(key)?.ownsGroupCleanup) { + placementByPendingPage.delete(key) } } } export function resetWebSessionBrowserPlacementsForTests(): void { - groupByPendingPage.clear() + placementByPendingPage.clear() + materializedGroupKeys.clear() + pendingCleanupClaimsByGroup.clear() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index d0ce2e2c6d5..8de495919d7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -95,9 +95,10 @@ import { clearWebSessionBrowserPlacementsForEnvironment, clearWebSessionBrowserPlacementsForWorktree, isWebSessionBrowserPlacementGroupReserved, - resetWebSessionBrowserPlacementsForTests, - takeWebSessionBrowserPlacementGroup + peekWebSessionBrowserPlacementGroup, + resetWebSessionBrowserPlacementsForTests } from './web-session-browser-placement' +import { suppressE2eWebRuntimeBrowserSnapshot } from './web-runtime-browser-creation-e2e-fault' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 @@ -1520,7 +1521,7 @@ function buildMirroredBrowserTabs( const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId const pageId = existing?.page.id ?? tab.browserPageId const createdAt = existing?.page.createdAt ?? now + sortOffset + index - const recordedClientGroupId = takeWebSessionBrowserPlacementGroup({ + const recordedClientGroupId = peekWebSessionBrowserPlacementGroup({ environmentId, worktreeId: snapshot.worktree, remotePageId: tab.browserPageId @@ -1896,7 +1897,7 @@ function buildMirroredHostGroups({ if ( !seen.has(group.id) && (group.tabOrder.length > 0 || - isWebSessionBrowserPlacementGroupReserved({ environmentId, worktreeId, groupId: group.id })) + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: group.id })) ) { orderedGroups.push(group) } @@ -2318,6 +2319,9 @@ function applyWebSessionTabsSnapshotWithContext( now = Date.now(), batchContext?: WebSessionTabsBatchContext ): WebSessionTabsSyncState | Partial { + if (suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot)) { + return state + } const worktreeId = rawSnapshot.worktree if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { return state @@ -2810,7 +2814,6 @@ function applyWebSessionTabsSnapshotWithContext( group.id === targetGroupId || group.tabOrder.length > 0 || isWebSessionBrowserPlacementGroupReserved({ - environmentId, worktreeId, groupId: group.id }) diff --git a/tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts b/tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts new file mode 100644 index 00000000000..1e8de3f5287 --- /dev/null +++ b/tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts @@ -0,0 +1,394 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' +import { expect, test } from './helpers/orca-app' +import { openFileExplorer } from './helpers/file-explorer' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE_NAME = 'paired-browser-reconcile-failure.html' + +type FaultSnapshot = { + armed: boolean + capabilityRejectionArmed: boolean + createdPageId: string | null + suppressedPageIds: string[] +} + +type FaultWindow = Window & { + __webRuntimeBrowserCreationFault?: { + arm: () => void + armCapabilityRejection: () => void + release: () => boolean + reset: () => void + snapshot: () => FaultSnapshot + } +} + +type ClientTabState = { + browserTabIds: string[] + browserWorkspaceIds: string[] + editorTabIds: string[] + groupIds: string[] + terminalTabIds: string[] +} + +async function readHostTabs( + hostClient: RuntimeClient, + repoPath: string +): Promise { + const response = await hostClient.call('session.tabs.list', { + worktree: `path:${repoPath}` + }) + return response.result +} + +async function readClientTabs(page: Page, worktreeId: string): Promise { + return page.evaluate((targetWorktreeId) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Paired client store unavailable') + } + const unifiedTabs = state.unifiedTabsByWorktree[targetWorktreeId] ?? [] + return { + browserTabIds: unifiedTabs + .filter((tab) => tab.contentType === 'browser') + .map((tab) => tab.id), + browserWorkspaceIds: (state.browserTabsByWorktree[targetWorktreeId] ?? []).map( + (workspace) => workspace.id + ), + editorTabIds: unifiedTabs.filter((tab) => tab.contentType === 'editor').map((tab) => tab.id), + groupIds: (state.groupsByWorktree[targetWorktreeId] ?? []).map((group) => group.id), + terminalTabIds: unifiedTabs + .filter((tab) => tab.contentType === 'terminal') + .map((tab) => tab.id) + } + }, worktreeId) +} + +async function runReconciliationFailureJourney(args: { + hostClient: RuntimeClient + offer: RuntimeDesktopPairingOffer + repoPath: string + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + let client: PairedElectronClient | null = null + try { + client = await launchPairedElectronClient( + args.offer, + args.testInfo, + `${args.topology} browser reconciliation failure` + ) + await client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath) + ) + if (!worktreeId) { + throw new Error('Paired client worktree disappeared after discovery') + } + await page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: client.environmentId, worktreeId } + ) + await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 60_000, + message: 'paired client did not materialize the host terminal' + }) + .toMatchObject({ terminalTabIds: expect.arrayContaining([expect.any(String)]) }) + + await openFileExplorer(page) + const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME }) + await expect(fixtureRow).toBeVisible({ timeout: 30_000 }) + await fixtureRow.click() + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + const baselineClient = await readClientTabs(page, worktreeId) + expect(baselineClient.editorTabIds).not.toHaveLength(0) + expect(baselineClient.terminalTabIds).not.toHaveLength(0) + const baselineHost = await readHostTabs(args.hostClient, args.repoPath) + const baselineHostBrowserIds = baselineHost.tabs + .filter((tab) => tab.type === 'browser') + .map((tab) => tab.browserPageId) + + await page.evaluate(() => { + const fault = (window as FaultWindow).__webRuntimeBrowserCreationFault + if (!fault) { + throw new Error('Browser reconciliation E2E fault seam unavailable') + } + fault.arm() + }) + await openPreviewToSide.click() + + const faultSnapshot = await expect + .poll( + () => + page.evaluate( + () => (window as FaultWindow).__webRuntimeBrowserCreationFault?.snapshot() ?? null + ), + { timeout: 30_000, message: 'host browser creation never reached the held fault seam' } + ) + .toMatchObject({ armed: true, createdPageId: expect.any(String) }) + .then(() => + page.evaluate( + () => (window as FaultWindow).__webRuntimeBrowserCreationFault?.snapshot() ?? null + ) + ) + const createdPageId = faultSnapshot?.createdPageId + if (!createdPageId) { + throw new Error('Held browser creation did not expose its exact host page id') + } + + const heldHost = await readHostTabs(args.hostClient, args.repoPath) + expect( + heldHost.tabs.some((tab) => tab.type === 'browser' && tab.browserPageId === createdPageId) + ).toBe(true) + const heldClient = await readClientTabs(page, worktreeId) + expect(heldClient.browserTabIds).toEqual(baselineClient.browserTabIds) + expect(heldClient.browserWorkspaceIds).toEqual(baselineClient.browserWorkspaceIds) + expect(heldClient.editorTabIds).toEqual(baselineClient.editorTabIds) + expect(heldClient.terminalTabIds).toEqual(baselineClient.terminalTabIds) + expect(heldClient.groupIds).toHaveLength(baselineClient.groupIds.length + 1) + + await page.screenshot({ + path: args.testInfo.outputPath(`${args.topology}-browser-reconciliation-held.png`), + fullPage: true + }) + expect( + await page.evaluate( + () => (window as FaultWindow).__webRuntimeBrowserCreationFault?.release() ?? false + ) + ).toBe(true) + + await expect(page.getByText('Unable to open this file in Orca Browser.')).toBeVisible({ + timeout: 30_000 + }) + await expect + .poll( + async () => { + const snapshot = await readHostTabs(args.hostClient, args.repoPath) + return snapshot.tabs.some( + (tab) => tab.type === 'browser' && tab.browserPageId === createdPageId + ) + }, + { timeout: 30_000, message: 'rollback did not close the exact host browser page' } + ) + .toBe(false) + const settledClient = await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 30_000, + message: 'client split and browser state did not settle after rollback' + }) + .toMatchObject({ + browserTabIds: baselineClient.browserTabIds, + browserWorkspaceIds: baselineClient.browserWorkspaceIds, + editorTabIds: baselineClient.editorTabIds, + groupIds: baselineClient.groupIds, + terminalTabIds: baselineClient.terminalTabIds + }) + .then(() => readClientTabs(page, worktreeId)) + expect(settledClient).toEqual(baselineClient) + expect( + (await readHostTabs(args.hostClient, args.repoPath)).tabs + .filter((tab) => tab.type === 'browser') + .map((tab) => tab.browserPageId) + ).toEqual(baselineHostBrowserIds) + await page.evaluate(() => (window as FaultWindow).__webRuntimeBrowserCreationFault?.reset()) + } finally { + await client?.dispose() + } +} + +async function runCapabilityFailureJourney(args: { + hostClient: RuntimeClient + offer: RuntimeDesktopPairingOffer + repoPath: string + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + let client: PairedElectronClient | null = null + try { + client = await launchPairedElectronClient( + args.offer, + args.testInfo, + `${args.topology} browser capability failure` + ) + await client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath) + ) + if (!worktreeId) { + throw new Error('Paired client worktree disappeared after discovery') + } + await page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: client.environmentId, worktreeId } + ) + await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 60_000, + message: 'paired client did not materialize the host terminal' + }) + .toMatchObject({ terminalTabIds: expect.arrayContaining([expect.any(String)]) }) + + await openFileExplorer(page) + const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME }) + await expect(fixtureRow).toBeVisible({ timeout: 30_000 }) + await fixtureRow.click() + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + const baselineClient = await readClientTabs(page, worktreeId) + const baselineHost = await readHostTabs(args.hostClient, args.repoPath) + + await page.evaluate(() => { + const fault = (window as FaultWindow).__webRuntimeBrowserCreationFault + if (!fault) { + throw new Error('Browser capability E2E fault seam unavailable') + } + fault.armCapabilityRejection() + }) + await openPreviewToSide.click() + + await expect(page.getByText('Unable to open this file in Orca Browser.')).toBeVisible({ + timeout: 30_000 + }) + await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 30_000, + message: 'client split state did not settle after capability rejection' + }) + .toEqual(baselineClient) + expect(await readHostTabs(args.hostClient, args.repoPath)).toEqual(baselineHost) + await page.screenshot({ + path: args.testInfo.outputPath(`${args.topology}-browser-capability-rejected.png`), + fullPage: true + }) + await page.evaluate(() => (window as FaultWindow).__webRuntimeBrowserCreationFault?.reset()) + } finally { + await client?.dispose() + } +} + +test('rolls back a headed-host browser when client reconciliation times out @headful', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

browser reconciliation fault

\n' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + await runReconciliationFailureJourney({ + hostClient: new RuntimeClient(userDataDir, 5_000), + offer, + repoPath: testRepoPath, + testInfo, + topology: 'headed' + }) +}) + +test('cleans up a headed-host preview when capability rejects after preflight @headful', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

browser capability fault

\n' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + await runCapabilityFailureJourney({ + hostClient: new RuntimeClient(userDataDir, 5_000), + offer, + repoPath: testRepoPath, + testInfo, + topology: 'headed' + }) +}) + +test('keeps browser failure cleanup on a headless host', async ({ testRepoPath }, testInfo) => { + test.setTimeout(300_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

headless capability fault

\n' + ) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + await host.client.call('terminal.create', { + worktree: `path:${testRepoPath}`, + title: 'Browser failure cleanup canary' + }) + await runReconciliationFailureJourney({ + hostClient: host.client, + offer: host.offer, + repoPath: testRepoPath, + testInfo, + topology: 'headless' + }) + await runCapabilityFailureJourney({ + hostClient: host.client, + offer: host.offer, + repoPath: testRepoPath, + testInfo, + topology: 'headless' + }) + } finally { + await host.dispose() + } +}) diff --git a/tests/e2e/paired-web-add-project-unavailable-host.spec.ts b/tests/e2e/paired-web-add-project-unavailable-host.spec.ts new file mode 100644 index 00000000000..771feffdde7 --- /dev/null +++ b/tests/e2e/paired-web-add-project-unavailable-host.spec.ts @@ -0,0 +1,158 @@ +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient, + type PairedWebClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { waitForSessionReady } from './helpers/store' + +test.skip( + process.env.ORCA_E2E_WEB_CLIENT !== '1', + 'Run with ORCA_E2E_WEB_CLIENT=1 so the paired web client is built' +) + +type HostHealth = 'blocked' | 'disconnected' + +async function setOnlyRuntimeHostHealth(page: Page, health: HostHealth): Promise { + return page.evaluate((nextHealth) => { + const store = window.__store + if (!store) { + throw new Error('Paired web client store unavailable') + } + const state = store.getState() + const environment = state.runtimeEnvironments[0] + if (!environment) { + throw new Error('Paired web client has no configured runtime') + } + const current = state.runtimeStatusByEnvironmentId.get(environment.id) + if (nextHealth === 'blocked' && !current?.status) { + throw new Error('Paired web runtime status unavailable for compatibility fault') + } + store.setState({ + runtimeStatusByEnvironmentId: new Map(state.runtimeStatusByEnvironmentId).set( + environment.id, + nextHealth === 'blocked' + ? { + ...current, + checkedAt: Date.now(), + status: { + ...current!.status!, + protocolVersion: 0, + runtimeProtocolVersion: 0 + } + } + : { ...current, checkedAt: Date.now(), status: null } + ) + }) + return environment.name + }, health) +} + +async function assertCreationActionsDisabled(args: { + health: HostHealth + hostName: string + page: Page + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + await args.page + .getByRole('button', { name: /Add Project/i }) + .first() + .click() + const dialog = args.page.getByRole('dialog', { name: /Add a project/i }) + await expect(dialog).toBeVisible() + const hostPicker = dialog.getByRole('combobox') + await expect(hostPicker).toContainText(args.hostName) + await expect(hostPicker).toContainText( + args.health === 'blocked' ? 'Update needed' : 'Disconnected' + ) + await expect(dialog.getByRole('button', { name: /Browse folder|Browse host/i })).toBeDisabled() + await expect(dialog.getByRole('button', { name: /Clone from URL/i })).toBeDisabled() + await expect(dialog.getByRole('button', { name: /Create new project/i })).toBeDisabled() + + await hostPicker.click() + const hostOption = args.page + .locator('[cmdk-item][aria-disabled="true"]') + .filter({ hasText: args.hostName }) + await expect(hostOption).toHaveAttribute('aria-disabled', 'true') + await expect(hostOption).toContainText(args.health === 'blocked' ? 'Update Orca' : 'Disconnected') + await args.page.keyboard.press('Escape') + await args.page.screenshot({ + path: args.testInfo.outputPath(`${args.topology}-paired-web-add-project-${args.health}.png`), + fullPage: true + }) + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(dialog).toBeHidden() +} + +async function runUnavailableHostJourney(args: { + app: ElectronApplication + offer: RuntimeDesktopPairingOffer + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + let client: PairedWebClient | null = null + try { + client = await launchPairedWebClient(args.app, args.offer) + await args.app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows().forEach((window) => window.show()) + }) + const hostName = await setOnlyRuntimeHostHealth(client.page, 'blocked') + await assertCreationActionsDisabled({ + health: 'blocked', + hostName, + page: client.page, + testInfo: args.testInfo, + topology: args.topology + }) + expect(await setOnlyRuntimeHostHealth(client.page, 'disconnected')).toBe(hostName) + await assertCreationActionsDisabled({ + health: 'disconnected', + hostName, + page: client.page, + testInfo: args.testInfo, + topology: args.topology + }) + } finally { + await client?.dispose() + } +} + +test('disables paired-web Add Project for a blocked or unavailable headed host @headful', async ({ + electronApp, + orcaPage +}, testInfo) => { + test.setTimeout(240_000) + await waitForSessionReady(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + await runUnavailableHostJourney({ + app: electronApp, + offer, + testInfo, + topology: 'headed' + }) +}) + +test('keeps paired-web Add Project disabled for a headless unavailable host', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(240_000) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + await runUnavailableHostJourney({ + app: host.app, + offer: host.offer, + testInfo, + topology: 'headless' + }) + } finally { + await host.dispose() + } +}) From 889dd4c7d2fe255aac552e4dcc91204279dc022c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:56:07 -0700 Subject: [PATCH 02/25] fix(agents): align Floating discovery and launch authority (#14106) --- config/reliability-gates.jsonc | 124 +++++++ .../TabBar.windows-shell-launch.test.ts | 18 ++ .../terminal-pane/pty-connection.test.ts | 48 +++ .../src/hooks/useAgentDetectionTarget.test.ts | 33 +- .../src/hooks/useAgentDetectionTarget.ts | 26 +- .../src/hooks/useDetectedAgents.test.tsx | 118 ++++++- src/renderer/src/hooks/useDetectedAgents.ts | 35 +- .../src/lib/launch-agent-in-new-tab.test.ts | 27 ++ .../src/lib/local-preflight-context.test.ts | 17 + .../src/lib/local-preflight-context.ts | 4 + .../src/store/slices/detected-agents.test.ts | 54 ++++ .../src/store/slices/detected-agents.ts | 147 +-------- .../slices/local-agent-context-eviction.ts | 118 +++++++ .../slices/local-agent-legacy-loading.ts | 43 +++ .../slices/local-detected-agent-state.test.ts | 222 +++++++++++++ .../slices/local-detected-agent-state.ts | 302 ++++++++++++++++++ .../local-detected-agent-store-state.ts | 40 +++ .../repos-remove-project-purge-leak.test.ts | 8 +- src/renderer/src/store/slices/repos.ts | 10 + 19 files changed, 1238 insertions(+), 156 deletions(-) create mode 100644 src/renderer/src/store/slices/local-agent-context-eviction.ts create mode 100644 src/renderer/src/store/slices/local-agent-legacy-loading.ts create mode 100644 src/renderer/src/store/slices/local-detected-agent-state.test.ts create mode 100644 src/renderer/src/store/slices/local-detected-agent-state.ts create mode 100644 src/renderer/src/store/slices/local-detected-agent-store-state.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 52077867e57..31a74480501 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3253,6 +3253,130 @@ ], "demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug." }, + { + "id": "agent-launch.discovery-authority", + "title": "Agent discovery and launch use the same workspace authority", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session", + "layer": "renderer-store-contract", + "surfaces": ["Quick Launch agent inventory", "agent launch", "Floating Workspace"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "wsl", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos", "windows"], + "coveredProviders": ["local", "wsl"], + "coverageNotes": "A deterministic Windows-mode renderer oracle keeps an active WSL project and explicit-local Floating Workspace mounted together with disjoint agent inventories, then independently asserts discovery and launch authority. A headed physical Windows 2 journey with real Ubuntu WSL2 independently verified the rendered inventory and native PowerShell launch; SSH and paired-runtime behavior retain their existing contract coverage.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-4027/p1-floating-workspace-agent-detection-still-uses-active-wsl-project", + "https://github.com/stablyai/orca/pull/13995" + ], + "invariant": "Every agent launch surface must advertise the provider set detected on its explicit execution authority and launch on that same authority; a local Floating Workspace must never inherit an unrelated active project's WSL, SSH, or paired-runtime context.", + "oracle": "Model an active WSL project and a native-Windows Floating Workspace with WSL-only Claude and host-only Codex. Keep both detection consumers mounted, require their advertised sets to stay disjoint through refresh, require the legacy active-project inventory to remain unchanged, then independently assert the Floating launch call and PTY transport select local IPC with no WSL project runtime.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot", + "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace" + ], + "testFiles": [ + "src/renderer/src/hooks/useDetectedAgents.test.tsx", + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts", + "src/renderer/src/store/slices/detected-agents.test.ts", + "src/renderer/src/store/slices/local-detected-agent-state.test.ts", + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "src/renderer/src/lib/local-preflight-context.test.ts", + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/useDetectedAgents.test.tsx", + "assertions": [ + "the active WSL project advertises only its WSL agent while Floating advertises only the native-host agent", + "Floating refresh preserves both scoped inventories and does not overwrite the legacy active-project inventory" + ] + }, + { + "file": "src/renderer/src/store/slices/local-detected-agent-state.test.ts", + "assertions": [ + "same-context callers deduplicate without cached broadcast fanout", + "refresh supersedes stale detection and failed cold refresh remains retryable", + "project removal evicts settled and in-flight context state" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "assertions": [ + "removing one project evicts its authority-scoped agent inventory without disturbing another project" + ] + }, + { + "file": "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", + "assertions": [ + "the advertised Floating agent launches in a Floating tab with null runtime ownership and no paired-runtime create call" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "the Floating agent's actual PTY selects local IPC, carries executionHostId local, and omits the active project's WSL runtime" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", + "result": "passed", + "durationSeconds": 2, + "summary": "Seven focused files passed independent WSL/local inventory, refresh isolation, cache lifecycle, project eviction, and Floating launch-call assertions." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 20, + "summary": "The full PTY connection suite passed the independent Floating native-host transport oracle." + }, + { + "date": "2026-08-12", + "runner": "manual", + "platform": "windows", + "command": "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace", + "result": "passed", + "durationSeconds": 22.3, + "summary": "Environment abfee683-80eb-4ac3-ada0-da5d1b6303a6, runtime b3bea13e-f6b7-41e7-b850-0a118f7eda38: Floating advertised native Claude/Codex without WSL-only Gemini and launched Claude through native PowerShell with local host ownership while the WSL project remained active." + } + ], + "runtimeBudget": { + "p95Seconds": 40, + "scope": "eight focused renderer authority, cache, eviction, and launch test files" + }, + "flakeHistory": { + "status": "soaking", + "evidence": "The deterministic oracle uses mocked provider results and React effect barriers. Exact-head PR run 31644438349 passed the full Node 24/26 matrix; an earlier patch-identical run's transient shard failures passed on rerun, but long-term soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical oracle fails on merged PR #13995 and the tested latest-main snapshot ebe5125476 because Floating receives the WSL-only Claude inventory while launch remains local. The candidate passes; disabling the scoped Floating target reproduces the mismatch." + }, + "performanceBudget": { + "required": true, + "evidence": "Floating uses one stable host context key, retained tab bars do not rescan worktree indexes on store writes, concurrent probes deduplicate per context, and in-flight promise entries are deleted on settlement." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Keep the physical Windows/WSL inventory and launch journey green when authority routing changes.", + "Keep disjoint provider inventories, refresh isolation, launch authority, and bounded in-flight state green." + ], + "knownGaps": [ + "Live SSH and paired-runtime launch journeys are covered by existing authority contracts rather than this focused oracle." + ], + "demotionRule": "Demote if discovery and launch can resolve different authorities, a scoped Floating probe overwrites the active-project inventory, or local context selection adds recurring scans or retained in-flight entries." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 34ed3da7c09..32d74d051c6 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -35,8 +35,11 @@ const appStoreSnapshot: { unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record detectedAgentIds: string[] | null + localDetectedAgentIdsByContext: Record remoteDetectedAgentIds: Record isDetectingAgents: boolean + isDetectingLocalAgentsByContext: Record + isRefreshingLocalAgentsByContext: Record isDetectingRemoteAgents: Record } = { activeRepoId: null, @@ -51,8 +54,11 @@ const appStoreSnapshot: { unifiedTabsByWorktree: {}, activeGroupIdByWorktree: {}, detectedAgentIds: null, + localDetectedAgentIdsByContext: {}, remoteDetectedAgentIds: {}, isDetectingAgents: false, + isDetectingLocalAgentsByContext: {}, + isRefreshingLocalAgentsByContext: {}, isDetectingRemoteAgents: {} } const pinTabMock: (tabId: string) => void = vi.fn() @@ -73,8 +79,11 @@ const useAppStoreMock = vi.fn( unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record detectedAgentIds: string[] | null + localDetectedAgentIdsByContext: Record remoteDetectedAgentIds: Record isDetectingAgents: boolean + isDetectingLocalAgentsByContext: Record + isRefreshingLocalAgentsByContext: Record isDetectingRemoteAgents: Record pinTab: typeof pinTabMock unpinTab: typeof unpinTabMock @@ -99,8 +108,11 @@ const useAppStoreMock = vi.fn( unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, detectedAgentIds: appStoreSnapshot.detectedAgentIds, + localDetectedAgentIdsByContext: appStoreSnapshot.localDetectedAgentIdsByContext, remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds, isDetectingAgents: appStoreSnapshot.isDetectingAgents, + isDetectingLocalAgentsByContext: appStoreSnapshot.isDetectingLocalAgentsByContext, + isRefreshingLocalAgentsByContext: appStoreSnapshot.isRefreshingLocalAgentsByContext, isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents, pinTab: pinTabMock, unpinTab: unpinTabMock, @@ -168,8 +180,11 @@ useAppStoreExport.getState = vi.fn(() => ({ unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, detectedAgentIds: appStoreSnapshot.detectedAgentIds, + localDetectedAgentIdsByContext: appStoreSnapshot.localDetectedAgentIdsByContext, remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds, isDetectingAgents: appStoreSnapshot.isDetectingAgents, + isDetectingLocalAgentsByContext: appStoreSnapshot.isDetectingLocalAgentsByContext, + isRefreshingLocalAgentsByContext: appStoreSnapshot.isRefreshingLocalAgentsByContext, isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents, pinTab: pinTabMock, unpinTab: unpinTabMock, @@ -348,6 +363,9 @@ describe('TabBar PowerShell launch wiring', () => { appStoreSnapshot.worktreesByRepo = {} appStoreSnapshot.unifiedTabsByWorktree = {} appStoreSnapshot.activeGroupIdByWorktree = {} + appStoreSnapshot.localDetectedAgentIdsByContext = {} + appStoreSnapshot.isDetectingLocalAgentsByContext = {} + appStoreSnapshot.isRefreshingLocalAgentsByContext = {} vi.stubGlobal('navigator', { userAgent: 'Windows' }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index e73521d1ca3..26ec23e5e1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -25,6 +25,7 @@ import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TerminalLayoutSnapshot, TuiAgent } from '../../../../shared/types' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { beginAgentStartupDeliveryAttempt, resetAgentStartupDelayedDeliveryForTests @@ -2328,6 +2329,53 @@ describe('connectPanePty', () => { expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() }) + it('spawns a Floating agent on native Windows beside an active WSL project', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createIpcPtyTransport } = await import('./pty-transport') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-1', + tabsByWorktree: { + ...mockStoreState.tabsByWorktree, + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { id: 'tab-floating-agent', ptyId: null, launchAgent: 'codex' } + ] + }, + projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }], + settings: { + ...mockStoreState.settings, + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + tabId: 'tab-floating-agent', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + cwd: 'C:\\Users\\alice', + startup: { launchAgent: 'codex' } + }) as never + ) + await flushAsyncTicks() + + expect(createIpcPtyTransport).toHaveBeenCalledOnce() + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createdTransportOptions[0]).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + executionHostId: 'local', + connectionId: null, + launchAgent: 'codex' + }) + expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() + }) + it('observes live terminal GitHub PR URLs before agent completion', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts index 22ad26ae016..dd8f1b8176c 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -1,9 +1,31 @@ import { describe, expect, it } from 'vitest' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import { getAgentDetectionTargetKeyForWorktree } from './useAgentDetectionTarget' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + getAgentDetectionTargetKeyForWorktree, + parseAgentDetectionTargetKey +} from './useAgentDetectionTarget' describe('getAgentDetectionTargetKeyForWorktree', () => { + it('carries explicit local Floating Workspace authority into detection', () => { + const state = { + settings: { activeRuntimeEnvironmentId: 'active-wsl-project' }, + folderWorkspaces: [], + projectGroups: [], + repos: [], + worktreesByRepo: {} + } as Parameters[0] + + const key = getAgentDetectionTargetKeyForWorktree(state, FLOATING_TERMINAL_WORKTREE_ID) + + expect(parseAgentDetectionTargetKey(key)).toEqual({ + kind: 'local', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contextKey: 'host' + }) + }) + it('uses an explicit runtime owner without scanning ambiguous child SSH repos', () => { let projectGroupReads = 0 const repos: readonly Repo[] = Array.from({ length: 100 }, (_, index) => { @@ -139,3 +161,12 @@ describe('getAgentDetectionTargetKeyForWorktree', () => { expect(repoIdReads).toBe(100) }) }) + +describe('parseAgentDetectionTargetKey', () => { + it.each(['local:missing-context', 'local:%:host'])( + 'falls back to unscoped local detection for malformed key %s', + (key) => { + expect(parseAgentDetectionTargetKey(key)).toEqual({ kind: 'local' }) + } + ) +}) diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.ts b/src/renderer/src/hooks/useAgentDetectionTarget.ts index 5f00d3100b8..f8c5787a901 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.ts @@ -10,9 +10,16 @@ import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-e import { parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AgentDetectionTarget } from './useDetectedAgents' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' export const AGENT_DETECTION_LOCAL_TARGET_KEY = 'local' +function getLocalAgentDetectionTargetKey(worktreeId: string): string { + return worktreeId === FLOATING_TERMINAL_WORKTREE_ID + ? `${AGENT_DETECTION_LOCAL_TARGET_KEY}:${encodeURIComponent(worktreeId)}:host` + : AGENT_DETECTION_LOCAL_TARGET_KEY +} + type AgentDetectionOwnerState = Parameters[0] & WorktreeRuntimeOwnerState @@ -57,7 +64,7 @@ export function getAgentDetectionTargetKeyForWorktree( if (executionHost?.kind === 'runtime') { return `runtime:${executionHost.environmentId}` } - return AGENT_DETECTION_LOCAL_TARGET_KEY + return getLocalAgentDetectionTargetKey(worktreeId) } export function parseAgentDetectionTargetKey( @@ -69,6 +76,23 @@ export function parseAgentDetectionTargetKey( if (key === AGENT_DETECTION_LOCAL_TARGET_KEY) { return { kind: 'local' } } + if (key.startsWith(`${AGENT_DETECTION_LOCAL_TARGET_KEY}:`)) { + const [encodedWorktreeId, encodedContextKey] = key + .slice(`${AGENT_DETECTION_LOCAL_TARGET_KEY}:`.length) + .split(':') + if (!encodedWorktreeId || !encodedContextKey) { + return { kind: 'local' } + } + try { + return { + kind: 'local', + worktreeId: decodeURIComponent(encodedWorktreeId), + contextKey: decodeURIComponent(encodedContextKey) + } + } catch { + return { kind: 'local' } + } + } if (key.startsWith('ssh:')) { return { kind: 'ssh', connectionId: key.slice('ssh:'.length) } } diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index a4f0d7f3f96..cc1c49b5b90 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -14,7 +14,11 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const detectLocalAgents = vi.fn() const detectRemoteAgents = vi.fn() const refreshLocalAgents = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -22,8 +26,15 @@ const initialAppState = useAppStore.getInitialState() const roots: Root[] = [] let latestHookResult: UseDetectedAgentsResult | null = null -function HookProbe({ target }: { target: AgentDetectionTarget | undefined }): null { +function HookProbe({ + target, + onResult +}: { + target: AgentDetectionTarget | undefined + onResult?: (result: UseDetectedAgentsResult) => void +}): null { latestHookResult = useDetectedAgents(target) + onResult?.(latestHookResult) return null } @@ -34,13 +45,16 @@ async function flushEffects(): Promise { }) } -async function renderProbe(target: AgentDetectionTarget | undefined): Promise { +async function renderProbe( + target: AgentDetectionTarget | undefined, + onResult?: (result: UseDetectedAgentsResult) => void +): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) roots.push(root) await act(async () => { - root.render(createElement(HookProbe, { target })) + root.render(createElement(HookProbe, { target, onResult })) }) await flushEffects() return root @@ -51,6 +65,11 @@ beforeEach(() => { useAppStore.setState(initialAppState, true) latestHookResult = null detectRemoteAgents.mockReset().mockResolvedValue([]) + detectLocalAgents + .mockReset() + .mockImplementation((context) => + Promise.resolve(context?.projectRuntime?.runtime.kind === 'wsl' ? ['claude'] : ['codex']) + ) refreshLocalAgents.mockReset().mockResolvedValue({ agents: [], addedPathSegments: [], @@ -80,11 +99,100 @@ beforeEach(() => { }) }) globalThis.window.api = { - preflight: { detectRemoteAgents, refreshAgents: refreshLocalAgents }, - runtimeEnvironments: { call: runtimeEnvironmentCall } + preflight: { + detectAgents: detectLocalAgents, + detectRemoteAgents, + refreshAgents: refreshLocalAgents + }, + runtimeEnvironments: { call: runtimeEnvironmentCall }, + platform: { get: () => ({ platform: 'win32' }) } } as unknown as Window['api'] }) +describe('Floating Workspace authority', () => { + it('advertises native Windows agents beside an active WSL project', async () => { + const activeResult: { current: UseDetectedAgentsResult | null } = { current: null } + const floatingResult: { current: UseDetectedAgentsResult | null } = { current: null } + useAppStore.getState().clearLocalDetectedAgents() + useAppStore.setState({ + activeRepoId: 'repo-wsl', + activeWorktreeId: 'worktree-wsl', + projects: [ + { + id: 'repo-wsl', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + repos: [ + { + id: 'repo-wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', + displayName: 'WSL project', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { + 'repo-wsl': [ + { + id: 'worktree-wsl', + repoId: 'repo-wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', + displayName: 'main' + } + ] + } + } as never) + + await renderProbe( + { kind: 'local', worktreeId: 'worktree-wsl' } as AgentDetectionTarget, + (result) => { + activeResult.current = result + } + ) + await renderProbe( + { + kind: 'local', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contextKey: 'host' + } as AgentDetectionTarget, + (result) => { + floatingResult.current = result + } + ) + + expect(activeResult.current?.detectedIds).toEqual(['claude']) + expect(floatingResult.current?.detectedIds).toEqual(['codex']) + expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) + const detectedContexts = detectLocalAgents.mock.calls.map(([context]) => context) + expect(detectedContexts).toEqual([ + expect.objectContaining({ + projectRuntime: expect.objectContaining({ + runtime: expect.objectContaining({ kind: 'wsl', distro: 'Ubuntu' }) + }) + }), + undefined + ]) + + refreshLocalAgents.mockResolvedValueOnce({ + agents: ['codex'], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'process_env', + pathFailureReason: 'none' + }) + await act(async () => { + await floatingResult.current?.refresh() + }) + await flushEffects() + + expect(activeResult.current?.detectedIds).toEqual(['claude']) + expect(floatingResult.current?.detectedIds).toEqual(['codex']) + expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) + expect(refreshLocalAgents).toHaveBeenLastCalledWith(undefined) + }) +}) + afterEach(async () => { for (const root of roots) { await act(async () => { diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index 2776f2b69da..3015e524aa4 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -17,7 +17,7 @@ export type UseDetectedAgentsResult = { } export type AgentDetectionTarget = - | { kind: 'local' } + | { kind: 'local'; worktreeId?: string | null; contextKey?: string } | { kind: 'ssh'; connectionId: string } | { kind: 'runtime'; environmentId: string } @@ -65,6 +65,8 @@ export function useDetectedAgents( : target?.kind === 'runtime' ? target.environmentId : null + const localWorktreeId = target?.kind === 'local' ? target.worktreeId : undefined + const localContextKey = target?.kind === 'local' ? target.contextKey : undefined const remoteTargetKey = targetKind === 'ssh' && targetId ? `ssh:${targetId}` @@ -82,7 +84,9 @@ export function useDetectedAgents( if (targetKind === 'runtime' && targetId) { return s.runtimeDetectedAgentIds[targetId] ?? null } - return s.detectedAgentIds + return localContextKey + ? (s.localDetectedAgentIdsByContext[localContextKey] ?? null) + : s.detectedAgentIds }) const isLoading = useAppStore((s) => { if (isUnknown) { @@ -94,7 +98,9 @@ export function useDetectedAgents( if (targetKind === 'runtime' && targetId) { return s.isDetectingRuntimeAgents[targetId] ?? false } - return s.isDetectingAgents + return localContextKey + ? (s.isDetectingLocalAgentsByContext[localContextKey] ?? false) + : s.isDetectingAgents }) const isRefreshing = useAppStore((s) => { if (targetKind === 'runtime' && targetId) { @@ -103,7 +109,12 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return s.isDetectingRemoteAgents[targetId] ?? false } - return targetKind === 'local' ? s.isRefreshingAgents : false + if (targetKind !== 'local') { + return false + } + return localContextKey + ? (s.isRefreshingLocalAgentsByContext[localContextKey] ?? false) + : s.isRefreshingAgents }) const detectionFailed = detectedIds === null && @@ -126,8 +137,8 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return state.refreshRemoteDetectedAgents(targetId) } - return state.refreshDetectedAgents() - }, [isUnknown, targetKind, targetId]) + return state.refreshDetectedAgents(localWorktreeId) + }, [isUnknown, localWorktreeId, targetKind, targetId]) useEffect(() => { if (isUnknown) { @@ -159,10 +170,18 @@ export function useDetectedAgents( } } else { if (detectedIds === null) { - void state.ensureDetectedAgents() + void state.ensureDetectedAgents(localWorktreeId) } } - }, [isUnknown, targetKind, targetId, remoteTargetKey, detectedIds]) + }, [ + isUnknown, + targetKind, + targetId, + remoteTargetKey, + detectedIds, + localWorktreeId, + localContextKey + ]) return { detectedIds, isLoading, detectionFailed, isRefreshing, refresh } } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 38fb4f1d566..8515379d418 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() const mockQueueTabStartupCommand = vi.fn() @@ -185,6 +186,32 @@ describe('launchAgentInNewTab', () => { }) }) + it('keeps Floating Workspace authority on native Windows beside an active WSL project', async () => { + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + launchPlatform: 'win32' + }) + + expect(result).not.toBeNull() + expect(mockIsWebRuntimeSessionActive).toHaveBeenLastCalledWith(null) + expect(mockCreateWebRuntimeSessionTerminal).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + undefined, + undefined, + { launchAgent: 'codex' } + ) + }) + it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { store.settings = { agentCmdOverrides: {}, diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index b6d020f3a80..69d38f9ced6 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -486,6 +486,23 @@ describe('local preflight context', () => { ).toBeUndefined() }) + it('keeps Floating on the host despite global and explicit WSL agent settings', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + } + } as AppState + + expect( + getLocalAgentPreflightContext(state, 'win32', {}, FLOATING_TERMINAL_WORKTREE_ID) + ).toBeUndefined() + }) + it('keeps the active project runtime fallback for a detected-only worktree', () => { const state = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index fc794535447..28da8a3d5bd 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -169,6 +169,10 @@ export function getLocalAgentPreflightContext( wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext(), worktreeId?: string | null ): LocalPreflightContext { + // Why: Floating owns native host authority and must not inherit any agent runtime fallback. + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return undefined + } const projectRuntime = getLocalProjectExecutionRuntimeContext( state, worktreeId, diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index c4776039e13..f85b5977960 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -12,6 +12,7 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const detectAgents = vi.fn() const refreshAgents = vi.fn() @@ -139,6 +140,59 @@ describe('createDetectedAgentsSlice WSL context', () => { }) }) + it('publishes a Floating-first host probe to a later ordinary local caller', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore() + + const floating = store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + const ordinary = store.getState().ensureDetectedAgents() + + expect(detectAgents).toHaveBeenCalledTimes(1) + expect(store.getState().isDetectingAgents).toBe(true) + resolveDetection(['codex']) + await expect(Promise.all([floating, ordinary])).resolves.toEqual([['codex'], ['codex']]) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().isDetectingAgents).toBe(false) + }) + + it('restores the legacy inventory when returning to a cached local context', async () => { + detectAgents.mockImplementation(async (context) => + context?.projectRuntime?.runtime.kind === 'wsl' ? ['claude'] : ['codex'] + ) + const store = createTestStore({ + repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await store.getState().ensureDetectedAgents() + store.setState({ activeRepoId: null }) + await store.getState().ensureDetectedAgents() + store.setState({ activeRepoId: 'repo-1' }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(store.getState().detectedAgentIds).toEqual(['claude']) + }) + + it('retries a local context after a transient detection failure', async () => { + detectAgents + .mockRejectedValueOnce(new Error('cold-start timeout')) + .mockResolvedValueOnce(['codex']) + const store = createTestStore() + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual([]) + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['codex']) + + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + }) + it('refreshes local agents inside the active WSL repo distro when no worktree is selected', async () => { const store = createTestStore({ repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl$\\Debian\\home\\alice\\repo' })], diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index e3a4f4947a0..87e34b712ff 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -1,30 +1,10 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' -import { - getLocalAgentPreflightContext, - localPreflightContextKey -} from '@/lib/local-preflight-context' - -export type DetectedAgentsSlice = { - detectedAgentIds: TuiAgent[] | null - isDetectingAgents: boolean - isRefreshingAgents: boolean - /** Telemetry classification of the most recent refreshAgents() run. `null` - * before the first refresh resolves. Read by the wizard at agent-pick time - * to attach `path_source` / `path_failure_reason` to `onboarding_agent_picked` - * — see docs/agent-on-path-detection.md. */ - pathSource: PathSource | null - pathFailureReason: ShellHydrationFailureReason | null - /** Runs `preflight.detectAgents` once per session. Subsequent callers reuse - * the in-flight promise so every surface sees the same result. */ - ensureDetectedAgents: (worktreeId?: string | null) => Promise - /** Re-runs `preflight.refreshAgents` (re-reads shell PATH). Concurrent callers - * receive the same pending promise; store fields update once on resolve so - * every subscribed surface re-renders in the same tick. */ - refreshDetectedAgents: () => Promise - clearLocalDetectedAgents: () => void +import type { TuiAgent } from '../../../../shared/types' +import { createLocalDetectedAgentState } from './local-detected-agent-state' +import type { LocalDetectedAgentState } from './local-detected-agent-store-state' +export type DetectedAgentsSlice = LocalDetectedAgentState & { // Why: remote worktrees need per-connection agent detection. The local // detectedAgentIds field is connection-unaware, so remote state lives in a // separate map keyed by SSH connectionId. @@ -41,10 +21,6 @@ export type DetectedAgentsSlice = { // Why: these are module-scoped (not in the store) so we can deduplicate // concurrent callers without storing a Promise in Zustand state. -let detectPromise: { key: string; promise: Promise } | null = null -let refreshPromise: { key: string; promise: Promise } | null = null -let detectedContextKey: string | null = null -let localDetectionGeneration = 0 const remoteDetectPromises = new Map>() const remoteRefreshPromises = new Map>() @@ -54,119 +30,10 @@ export function _getRemoteDetectPromiseCountForTest(): number { export const createDetectedAgentsSlice: StateCreator = ( set, - get + get, + store ) => ({ - detectedAgentIds: null, - isDetectingAgents: false, - isRefreshingAgents: false, - pathSource: null, - pathFailureReason: null, - - ensureDetectedAgents: (worktreeId) => { - const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) - const contextKey = localPreflightContextKey(context) - const existing = get().detectedAgentIds - if (existing && detectedContextKey === contextKey) { - return Promise.resolve(existing) - } - if (detectPromise?.key === contextKey) { - return detectPromise.promise - } - const contextChanged = detectedContextKey !== contextKey - set({ - detectedAgentIds: contextChanged ? null : get().detectedAgentIds, - isDetectingAgents: true - }) - const requestGeneration = localDetectionGeneration - const pending = window.api.preflight - .detectAgents(context) - .then((ids) => { - const typed = ids as TuiAgent[] - if (requestGeneration === localDetectionGeneration) { - set({ detectedAgentIds: typed, isDetectingAgents: false }) - detectedContextKey = contextKey - } - return typed - }) - .catch(() => { - // Why: allow a retry on the next call if detection blew up (IPC timeout - // during cold start). Do not cache the failure or show stale context. - if (requestGeneration === localDetectionGeneration) { - detectPromise = null - set({ - detectedAgentIds: contextChanged ? [] : get().detectedAgentIds, - isDetectingAgents: false - }) - } - return [] as TuiAgent[] - }) - detectPromise = { key: contextKey, promise: pending } - return pending - }, - - refreshDetectedAgents: () => { - const context = getLocalAgentPreflightContext(get()) - const contextKey = localPreflightContextKey(context) - if (refreshPromise?.key === contextKey) { - return refreshPromise.promise - } - const contextChanged = detectedContextKey !== contextKey - set({ - detectedAgentIds: contextChanged ? null : get().detectedAgentIds, - isRefreshingAgents: true - }) - const requestGeneration = localDetectionGeneration - const pending = window.api.preflight - .refreshAgents(context) - .then((result) => { - const typed = result.agents as TuiAgent[] - if (requestGeneration === localDetectionGeneration) { - set({ - detectedAgentIds: typed, - isRefreshingAgents: false, - pathSource: result.pathSource, - pathFailureReason: result.pathFailureReason - }) - // Why: once refresh has run, treat its result as the current detection - // snapshot so `ensureDetectedAgents` short-circuits. - detectedContextKey = contextKey - detectPromise = { key: contextKey, promise: Promise.resolve(typed) } - } - return typed - }) - .catch(() => { - const fallback = contextChanged ? [] : (get().detectedAgentIds ?? []) - if (requestGeneration === localDetectionGeneration) { - set({ - detectedAgentIds: fallback, - isRefreshingAgents: false - }) - } - return fallback - }) - .finally(() => { - if (refreshPromise?.promise === pending) { - refreshPromise = null - } - }) - refreshPromise = { key: contextKey, promise: pending } - return pending - }, - - clearLocalDetectedAgents: () => { - localDetectionGeneration += 1 - detectPromise = null - refreshPromise = null - detectedContextKey = null - set({ - detectedAgentIds: null, - isDetectingAgents: false, - isRefreshingAgents: false, - pathSource: null, - pathFailureReason: null - }) - }, - + ...createLocalDetectedAgentState(set, get, store), remoteDetectedAgentIds: {}, isDetectingRemoteAgents: {}, diff --git a/src/renderer/src/store/slices/local-agent-context-eviction.ts b/src/renderer/src/store/slices/local-agent-context-eviction.ts new file mode 100644 index 00000000000..92f25c1d336 --- /dev/null +++ b/src/renderer/src/store/slices/local-agent-context-eviction.ts @@ -0,0 +1,118 @@ +import type { AppState } from '../types' + +type LocalAgentContextState = Pick< + AppState, + | 'detectedAgentIds' + | 'isDetectingAgents' + | 'isRefreshingAgents' + | 'localDetectedAgentIdsByContext' + | 'isDetectingLocalAgentsByContext' + | 'isRefreshingLocalAgentsByContext' + | 'pathSource' + | 'pathFailureReason' +> + +export type LocalAgentContextEviction = { + removedContextKeys: ReadonlySet + detectedContextKey: string | null + legacyDetectContextKey: string | null + legacyRefreshContextKey: string | null + statePatch: Partial +} + +export function removeLocalAgentContextEntries( + entries: Record, + contextKeys: ReadonlySet +): Record { + let filtered = entries + for (const contextKey of contextKeys) { + if (!(contextKey in filtered)) { + continue + } + if (filtered === entries) { + filtered = { ...entries } + } + delete filtered[contextKey] + } + return filtered +} + +export function removeLocalAgentContextEntry(entries: Record, contextKey: string) { + return removeLocalAgentContextEntries(entries, new Set([contextKey])) +} + +export function getLocalAgentContextEviction(args: { + projectIds: readonly string[] + state: LocalAgentContextState + internalContextKeys: Iterable + detectedContextKey: string | null + legacyDetectContextKey: string | null + legacyRefreshContextKey: string | null +}): LocalAgentContextEviction | null { + const prefixes = args.projectIds + .map((projectId) => projectId.trim()) + .filter(Boolean) + .map((projectId) => `${projectId}:`) + if (prefixes.length === 0) { + return null + } + const contextKeys = new Set([ + ...Object.keys(args.state.localDetectedAgentIdsByContext), + ...Object.keys(args.state.isDetectingLocalAgentsByContext), + ...Object.keys(args.state.isRefreshingLocalAgentsByContext), + ...args.internalContextKeys + ]) + const removedContextKeys = new Set( + [...contextKeys].filter((contextKey) => + prefixes.some((prefix) => contextKey.startsWith(prefix)) + ) + ) + if (removedContextKeys.size === 0) { + return null + } + const clearDetected = Boolean( + (args.detectedContextKey && removedContextKeys.has(args.detectedContextKey)) || + (args.legacyDetectContextKey && removedContextKeys.has(args.legacyDetectContextKey)) + ) + const clearRefreshing = Boolean( + args.legacyRefreshContextKey && removedContextKeys.has(args.legacyRefreshContextKey) + ) + return { + removedContextKeys, + detectedContextKey: + args.detectedContextKey && removedContextKeys.has(args.detectedContextKey) + ? null + : args.detectedContextKey, + legacyDetectContextKey: + args.legacyDetectContextKey && removedContextKeys.has(args.legacyDetectContextKey) + ? null + : args.legacyDetectContextKey, + legacyRefreshContextKey: + args.legacyRefreshContextKey && removedContextKeys.has(args.legacyRefreshContextKey) + ? null + : args.legacyRefreshContextKey, + statePatch: { + ...(clearDetected + ? { + detectedAgentIds: null, + isDetectingAgents: false, + pathSource: null, + pathFailureReason: null + } + : {}), + ...(clearRefreshing ? { isRefreshingAgents: false } : {}), + localDetectedAgentIdsByContext: removeLocalAgentContextEntries( + args.state.localDetectedAgentIdsByContext, + removedContextKeys + ), + isDetectingLocalAgentsByContext: removeLocalAgentContextEntries( + args.state.isDetectingLocalAgentsByContext, + removedContextKeys + ), + isRefreshingLocalAgentsByContext: removeLocalAgentContextEntries( + args.state.isRefreshingLocalAgentsByContext, + removedContextKeys + ) + } + } +} diff --git a/src/renderer/src/store/slices/local-agent-legacy-loading.ts b/src/renderer/src/store/slices/local-agent-legacy-loading.ts new file mode 100644 index 00000000000..18e99768003 --- /dev/null +++ b/src/renderer/src/store/slices/local-agent-legacy-loading.ts @@ -0,0 +1,43 @@ +import type { AppState } from '../types' +import { removeLocalAgentContextEntry } from './local-agent-context-eviction' + +type LocalAgentLegacyLoadingState = Pick< + AppState, + 'detectedAgentIds' | 'isDetectingAgents' | 'isRefreshingAgents' +> + +type LocalAgentLegacyLoadingPatch = Partial + +export function getLegacyLoadingPatch( + state: LocalAgentLegacyLoadingState, + contextMatches: boolean, + phase: 'detect' | 'refresh' +): LocalAgentLegacyLoadingPatch | null { + const detectedAgentIds = contextMatches ? state.detectedAgentIds : null + const alreadyLoading = phase === 'detect' ? state.isDetectingAgents : state.isRefreshingAgents + if (state.detectedAgentIds === detectedAgentIds && alreadyLoading) { + return null + } + return phase === 'detect' + ? { detectedAgentIds, isDetectingAgents: true } + : { detectedAgentIds, isRefreshingAgents: true } +} + +export function getSupersededDetectPatch( + state: LocalAgentLegacyLoadingState & Pick, + contextKey: string, + supersedesDetect: boolean, + clearsLegacyDetect: boolean +): Partial { + return { + ...(clearsLegacyDetect ? { isDetectingAgents: false } : {}), + ...(supersedesDetect + ? { + isDetectingLocalAgentsByContext: removeLocalAgentContextEntry( + state.isDetectingLocalAgentsByContext, + contextKey + ) + } + : {}) + } +} diff --git a/src/renderer/src/store/slices/local-detected-agent-state.test.ts b/src/renderer/src/store/slices/local-detected-agent-state.test.ts new file mode 100644 index 00000000000..77303b8bc89 --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agent-state.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { createDetectedAgentsSlice } from './detected-agents' + +const detectAgents = vi.fn() +const refreshAgents = vi.fn() + +globalThis.window = { + api: { + preflight: { detectAgents, refreshAgents }, + platform: { get: () => ({ platform: 'win32' }) } + } as unknown as Window['api'] +} as Window & typeof globalThis + +function makeRepo(id: string): Repo { + return { + id, + path: `C:\\${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 0 + } +} + +function createTestStore(repos: Repo[]) { + const store = create()( + (...args) => createDetectedAgentsSlice(...args) as unknown as AppState + ) + store.setState({ + repos, + projects: [], + worktreesByRepo: {}, + activeRepoId: repos[0]?.id ?? null, + activeWorktreeId: null + } as Partial) + return store +} + +describe('local detected agent context lifecycle', () => { + beforeEach(() => { + detectAgents.mockReset().mockResolvedValue(['claude']) + refreshAgents.mockReset().mockResolvedValue({ + agents: ['codex'], + pathSource: 'process_env', + pathFailureReason: 'none' + }) + }) + + it('deduplicates Floating-first callers without cached or settlement broadcast fanout', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore([]) + let broadcasts = 0 + const unsubscribe = store.subscribe(() => { + broadcasts += 1 + }) + + const floating = store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + const ordinary = store.getState().ensureDetectedAgents() + const joinedOrdinary = store.getState().ensureDetectedAgents() + + expect(detectAgents).toHaveBeenCalledTimes(1) + expect(ordinary).toBe(floating) + expect(joinedOrdinary).toBe(floating) + broadcasts = 0 + resolveDetection(['codex']) + await Promise.all([floating, ordinary, joinedOrdinary]) + expect(broadcasts).toBe(1) + + broadcasts = 0 + await store.getState().ensureDetectedAgents() + await store.getState().ensureDetectedAgents() + expect(broadcasts).toBe(0) + unsubscribe() + }) + + it('evicts removed project contexts without retaining settled loading entries', async () => { + const repo1 = makeRepo('repo-1') + const repo2 = makeRepo('repo-2') + const store = createTestStore([repo1, repo2]) + + await store.getState().ensureDetectedAgents() + store.setState({ activeRepoId: 'repo-2' }) + await store.getState().ensureDetectedAgents() + + expect(Object.keys(store.getState().localDetectedAgentIdsByContext)).toEqual([ + 'repo-1:windows-host', + 'repo-2:windows-host' + ]) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({}) + + store.setState({ repos: [repo2] }) + store.getState().clearLocalDetectedAgentContextsForProjects(['repo-1']) + + expect(store.getState().localDetectedAgentIdsByContext).toEqual({ + 'repo-2:windows-host': ['claude'] + }) + + store.setState({ repos: [repo1, repo2], activeRepoId: 'repo-1' }) + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + expect(detectAgents).toHaveBeenCalledTimes(3) + }) + + it('does not restore a project context cleared while detection is in flight', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore([makeRepo('repo-1')]) + + const pending = store.getState().ensureDetectedAgents() + store.getState().clearLocalDetectedAgentContextsForProjects(['repo-1']) + resolveDetection(['claude']) + + await expect(pending).resolves.toEqual(['claude']) + expect(store.getState().localDetectedAgentIdsByContext).toEqual({}) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(store.getState().detectedAgentIds).toBeNull() + }) + + it('does not let an older detect overwrite a successful refresh', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore([]) + + const detect = store.getState().ensureDetectedAgents() + await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex']) + resolveDetection(['claude']) + await expect(detect).resolves.toEqual(['claude']) + + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['codex']) + expect(store.getState().isDetectingAgents).toBe(false) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + }) + + it('does not let an older failed detect erase a successful refresh', async () => { + let rejectDetection: (error: Error) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectDetection = reject + }) + ) + const store = createTestStore([]) + + const detect = store.getState().ensureDetectedAgents() + await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex']) + rejectDetection(new Error('older detect failed')) + await expect(detect).resolves.toEqual([]) + + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['codex']) + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['codex']) + expect(detectAgents).toHaveBeenCalledTimes(1) + }) + + it('joins an authoritative refresh instead of starting a later detect', async () => { + let resolveRefresh: (result: { + agents: string[] + pathSource: string + pathFailureReason: string + }) => void = () => {} + refreshAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + const store = createTestStore([]) + + const refresh = store.getState().refreshDetectedAgents() + const ensure = store.getState().ensureDetectedAgents() + expect(ensure).toBe(refresh) + expect(detectAgents).not.toHaveBeenCalled() + + resolveRefresh({ + agents: ['codex'], + pathSource: 'process_env', + pathFailureReason: 'none' + }) + await expect(Promise.all([refresh, ensure])).resolves.toEqual([['codex'], ['codex']]) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['codex']) + }) + + it('retries after an authoritative refresh fails without a usable cache', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + refreshAgents.mockRejectedValueOnce(new Error('transient refresh failure')) + const store = createTestStore([]) + + const supersededDetect = store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + await expect( + store.getState().refreshDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + ).resolves.toEqual([]) + resolveDetection(['stale']) + await expect(supersededDetect).resolves.toEqual(['stale']) + + await expect( + store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) + ).resolves.toEqual(['claude']) + expect(detectAgents).toHaveBeenCalledTimes(2) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['claude']) + }) +}) diff --git a/src/renderer/src/store/slices/local-detected-agent-state.ts b/src/renderer/src/store/slices/local-detected-agent-state.ts new file mode 100644 index 00000000000..43b39ff5980 --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agent-state.ts @@ -0,0 +1,302 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { TuiAgent } from '../../../../shared/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + getLocalAgentPreflightContext, + localPreflightContextKey +} from '@/lib/local-preflight-context' +import * as contextEviction from './local-agent-context-eviction' +import { getLegacyLoadingPatch, getSupersededDetectPatch } from './local-agent-legacy-loading' +import { createEmptyLocalDetectedAgentState } from './local-detected-agent-store-state' +import type { LocalDetectedAgentState } from './local-detected-agent-store-state' + +type LocalDetectedAgentStateCreator = StateCreator + +export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (set, get) => { + const detectPromises = new Map>() + const refreshPromises = new Map>() + const failedDetectContextKeys = new Set() + let detectedContextKey: string | null = null + let legacyDetectContextKey: string | null = null + let legacyRefreshContextKey: string | null = null + let localDetectionGeneration = 0 + + return { + ...createEmptyLocalDetectedAgentState(), + + ensureDetectedAgents: (worktreeId) => { + const isFloating = worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) + const contextKey = localPreflightContextKey(context) + const existing = get().localDetectedAgentIdsByContext[contextKey] + const inflightRefresh = refreshPromises.get(contextKey) + if (inflightRefresh) { + if (!isFloating) { + legacyRefreshContextKey = contextKey + } + return inflightRefresh + } + if (existing != null && !failedDetectContextKeys.has(contextKey)) { + if (!isFloating) { + detectedContextKey = contextKey + const state = get() + if (state.detectedAgentIds !== existing || state.isDetectingAgents) { + set({ detectedAgentIds: existing, isDetectingAgents: false }) + } + } + return Promise.resolve(existing) + } + const requestGeneration = localDetectionGeneration + const exposeInflightToLegacy = (): void => { + if (!isFloating) { + legacyDetectContextKey = contextKey + } + if (isFloating) { + return + } + const state = get() + const patch = getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'detect') + if (patch) { + set(patch) + } + } + const inflight = detectPromises.get(contextKey) + if (inflight) { + exposeInflightToLegacy() + return inflight + } + if (!isFloating) { + legacyDetectContextKey = contextKey + } + set((state) => ({ + ...(isFloating + ? {} + : (getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'detect') ?? {})), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: existing ?? null + }, + isDetectingLocalAgentsByContext: { + ...state.isDetectingLocalAgentsByContext, + [contextKey]: true + } + })) + const pending = window.api.preflight + .detectAgents(context) + .then((ids) => { + const typed = ids as TuiAgent[] + if ( + requestGeneration === localDetectionGeneration && + detectPromises.get(contextKey) === pending + ) { + failedDetectContextKeys.delete(contextKey) + const exposeToLegacy = legacyDetectContextKey === contextKey + if (exposeToLegacy) { + legacyDetectContextKey = null + detectedContextKey = contextKey + } + set((state) => ({ + ...(exposeToLegacy ? { detectedAgentIds: typed, isDetectingAgents: false } : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: typed + }, + isDetectingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isDetectingLocalAgentsByContext, + contextKey + ) + })) + } + return typed + }) + .catch(() => { + if ( + requestGeneration === localDetectionGeneration && + detectPromises.get(contextKey) === pending + ) { + failedDetectContextKeys.add(contextKey) + const exposeToLegacy = legacyDetectContextKey === contextKey + if (exposeToLegacy) { + legacyDetectContextKey = null + } + set((state) => ({ + ...(exposeToLegacy ? { detectedAgentIds: [], isDetectingAgents: false } : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: [] + }, + isDetectingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isDetectingLocalAgentsByContext, + contextKey + ) + })) + } + return [] as TuiAgent[] + }) + .finally(() => { + if (detectPromises.get(contextKey) === pending) { + detectPromises.delete(contextKey) + } + }) + detectPromises.set(contextKey, pending) + return pending + }, + + refreshDetectedAgents: (worktreeId) => { + const isFloating = worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) + const contextKey = localPreflightContextKey(context) + const cached = get().localDetectedAgentIdsByContext[contextKey] + const hadUsableCache = cached != null && !failedDetectContextKeys.has(contextKey) + const requestGeneration = localDetectionGeneration + const exposeInflightToLegacy = (): void => { + if (!isFloating) { + legacyRefreshContextKey = contextKey + } + if (isFloating) { + return + } + const state = get() + const patch = getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'refresh') + if (patch) { + set(patch) + } + } + const inflight = refreshPromises.get(contextKey) + if (inflight) { + exposeInflightToLegacy() + return inflight + } + if (!isFloating) { + legacyRefreshContextKey = contextKey + } + const supersedesDetect = detectPromises.delete(contextKey) + const clearsLegacyDetect = legacyDetectContextKey === contextKey + if (clearsLegacyDetect) { + legacyDetectContextKey = null + } + set((state) => ({ + ...(isFloating + ? {} + : (getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'refresh') ?? {})), + ...getSupersededDetectPatch(state, contextKey, supersedesDetect, clearsLegacyDetect), + isRefreshingLocalAgentsByContext: { + ...state.isRefreshingLocalAgentsByContext, + [contextKey]: true + } + })) + const pending = window.api.preflight + .refreshAgents(context) + .then((result) => { + const typed = result.agents as TuiAgent[] + if ( + requestGeneration === localDetectionGeneration && + refreshPromises.get(contextKey) === pending + ) { + failedDetectContextKeys.delete(contextKey) + const exposeToLegacy = legacyRefreshContextKey === contextKey + if (exposeToLegacy) { + legacyRefreshContextKey = null + detectedContextKey = contextKey + } + set((state) => ({ + ...(exposeToLegacy + ? { + detectedAgentIds: typed, + isRefreshingAgents: false, + pathSource: result.pathSource, + pathFailureReason: result.pathFailureReason + } + : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: typed + }, + isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isRefreshingLocalAgentsByContext, + contextKey + ) + })) + } + return typed + }) + .catch(() => { + const fallback = isFloating + ? (get().localDetectedAgentIdsByContext[contextKey] ?? []) + : detectedContextKey !== contextKey + ? [] + : (get().detectedAgentIds ?? []) + if ( + requestGeneration === localDetectionGeneration && + refreshPromises.get(contextKey) === pending + ) { + if (!hadUsableCache) { + failedDetectContextKeys.add(contextKey) + } + const exposeToLegacy = legacyRefreshContextKey === contextKey + if (exposeToLegacy) { + legacyRefreshContextKey = null + } + set((state) => ({ + ...(exposeToLegacy ? { detectedAgentIds: fallback, isRefreshingAgents: false } : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: fallback + }, + isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( + state.isRefreshingLocalAgentsByContext, + contextKey + ) + })) + } + return fallback + }) + .finally(() => { + if (refreshPromises.get(contextKey) === pending) { + refreshPromises.delete(contextKey) + } + }) + refreshPromises.set(contextKey, pending) + return pending + }, + + clearLocalDetectedAgentContextsForProjects: (projectIds) => { + const eviction = contextEviction.getLocalAgentContextEviction({ + projectIds, + state: get(), + internalContextKeys: [ + ...detectPromises.keys(), + ...refreshPromises.keys(), + ...failedDetectContextKeys + ], + detectedContextKey, + legacyDetectContextKey, + legacyRefreshContextKey + }) + if (!eviction) { + return + } + for (const contextKey of eviction.removedContextKeys) { + detectPromises.delete(contextKey) + refreshPromises.delete(contextKey) + failedDetectContextKeys.delete(contextKey) + } + detectedContextKey = eviction.detectedContextKey + legacyDetectContextKey = eviction.legacyDetectContextKey + legacyRefreshContextKey = eviction.legacyRefreshContextKey + set(eviction.statePatch) + }, + + clearLocalDetectedAgents: () => { + localDetectionGeneration += 1 + detectPromises.clear() + refreshPromises.clear() + failedDetectContextKeys.clear() + detectedContextKey = null + legacyDetectContextKey = null + legacyRefreshContextKey = null + set(createEmptyLocalDetectedAgentState()) + } + } +} diff --git a/src/renderer/src/store/slices/local-detected-agent-store-state.ts b/src/renderer/src/store/slices/local-detected-agent-store-state.ts new file mode 100644 index 00000000000..c961bb5114f --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agent-store-state.ts @@ -0,0 +1,40 @@ +import type { AppState } from '../types' +import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' + +export type LocalDetectedAgentState = { + detectedAgentIds: TuiAgent[] | null + isDetectingAgents: boolean + isRefreshingAgents: boolean + localDetectedAgentIdsByContext: Record + isDetectingLocalAgentsByContext: Record + isRefreshingLocalAgentsByContext: Record + pathSource: PathSource | null + pathFailureReason: ShellHydrationFailureReason | null + ensureDetectedAgents: (worktreeId?: string | null) => Promise + refreshDetectedAgents: (worktreeId?: string | null) => Promise + clearLocalDetectedAgentContextsForProjects: (projectIds: readonly string[]) => void + clearLocalDetectedAgents: () => void +} + +export function createEmptyLocalDetectedAgentState(): Pick< + AppState, + | 'detectedAgentIds' + | 'isDetectingAgents' + | 'isRefreshingAgents' + | 'localDetectedAgentIdsByContext' + | 'isDetectingLocalAgentsByContext' + | 'isRefreshingLocalAgentsByContext' + | 'pathSource' + | 'pathFailureReason' +> { + return { + detectedAgentIds: null, + isDetectingAgents: false, + isRefreshingAgents: false, + localDetectedAgentIdsByContext: {}, + isDetectingLocalAgentsByContext: {}, + isRefreshingLocalAgentsByContext: {}, + pathSource: null, + pathFailureReason: null + } +} diff --git a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts index 15ca69fda3c..69bebba9240 100644 --- a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts +++ b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts @@ -50,7 +50,11 @@ function seedTwoProjects(store: ReturnType): void { groupsByWorktree: { [W1]: [], [W2]: [] }, browserTabsByWorktree: { [W1]: [], [W2]: [] }, gitStatusHugeByWorktree: { [W1]: { limit: 1000 }, [W2]: { limit: 2000 } }, - everActivatedWorktreeIds: new Set([W1, W2]) + everActivatedWorktreeIds: new Set([W1, W2]), + localDetectedAgentIdsByContext: { + 'repo-1:windows-host': ['claude'], + 'repo-2:windows-host': ['codex'] + } }) } @@ -68,6 +72,7 @@ describe('removeProject purges per-worktree state (leak regression)', () => { expect(s.browserTabsByWorktree[W1]).toBeUndefined() expect(s.gitStatusHugeByWorktree[W1]).toBeUndefined() expect(s.everActivatedWorktreeIds.has(W1)).toBe(false) + expect(s.localDetectedAgentIdsByContext['repo-1:windows-host']).toBeUndefined() }) it('keeps per-worktree state for projects that are NOT removed', async () => { @@ -83,5 +88,6 @@ describe('removeProject purges per-worktree state (leak regression)', () => { expect(s.browserTabsByWorktree[W2]).toBeDefined() expect(s.gitStatusHugeByWorktree[W2]).toEqual({ limit: 2000 }) expect(s.everActivatedWorktreeIds.has(W2)).toBe(true) + expect(s.localDetectedAgentIdsByContext['repo-2:windows-host']).toEqual(['codex']) }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index dbc55c8c320..8bdda9f41e3 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -3519,6 +3519,15 @@ export const createRepoSlice: StateCreator = (set, // Kill PTYs for all worktrees belonging to this repo const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) + const localAgentContextProjectIds = + ownerHostId === LOCAL_EXECUTION_HOST_ID + ? [ + projectId, + ...(get().worktreesByRepo[projectId] ?? []) + .filter((worktree) => worktreeBelongsToHost(worktree, ownerHostId)) + .flatMap((worktree) => (worktree.projectId ? [worktree.projectId] : [])) + ] + : [] const killedTabIds = new Set() if (target.kind === 'environment') { await Promise.allSettled( @@ -3546,6 +3555,7 @@ export const createRepoSlice: StateCreator = (set, // Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree. get().purgeWorktreeTerminalState(worktreeIds) + get().clearLocalDetectedAgentContextsForProjects(localAgentContextProjectIds) set((s) => { const nextWorktrees = { ...s.worktreesByRepo } From 9a10561258a5bb885893c9c3c4792872cb08ae55 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:33:19 -0700 Subject: [PATCH 03/25] fix(terminal): retain SSH startup delivery through reconnect (#14161) --- .github/workflows/e2e.yml | 31 +- config/reliability-gates.jsonc | 63 +++- config/scripts/pr-e2e-gate-contract.test.mjs | 28 +- .../run-ssh-docker-terminal-parking-e2e.mjs | 12 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../terminal-pane/pty-connection.test.ts | 213 ++++++-------- .../terminal-pane/pty-connection.ts | 92 ++---- .../terminal-pane/pty-transport-types.ts | 2 + .../terminal-pane/pty-transport.test.ts | 18 ++ .../components/terminal-pane/pty-transport.ts | 4 + tests/e2e/helpers/docker-ssh-relay-target.ts | 15 + .../helpers/startup-exec-readiness-oracle.ts | 274 ++++++++++++++++++ .../e2e/paired-startup-exec-readiness.spec.ts | 184 ++++++++++++ tests/e2e/ssh-startup-exec-readiness.spec.ts | 111 +++++++ 15 files changed, 822 insertions(+), 227 deletions(-) create mode 100644 tests/e2e/helpers/startup-exec-readiness-oracle.ts create mode 100644 tests/e2e/paired-startup-exec-readiness.spec.ts create mode 100644 tests/e2e/ssh-startup-exec-readiness.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 349d2061246..9838baf4afd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -126,7 +126,7 @@ jobs: # scripts still need the Linux toolchain even though this shard reuses # the prebuilt Electron output. - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 + run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh # Why: Electron on Linux needs an X display even when the app # suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a @@ -202,7 +202,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb + run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh - name: Setup Node.js uses: actions/setup-node@v6 @@ -232,7 +232,14 @@ jobs: env: TEST_FILES_JSON: ${{ inputs.test_files }} run: | - mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON") + mapfile -t TEST_FILES < <(jq -r '.[] | select( + . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and + . != "tests/e2e/paired-startup-exec-readiness.spec.ts" + )' <<<"$TEST_FILES_JSON") + if [ "${#TEST_FILES[@]}" -eq 0 ]; then + echo "Changed startup-readiness specs are owned by the dedicated live lane." + exit 0 + fi xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 \ pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 @@ -248,10 +255,13 @@ jobs: ssh-docker-watcher-isolation: name: ssh docker watcher isolation needs: build - if: inputs.test_files == '' + if: >- + inputs.test_files == '' || + contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || + contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') runs-on: ubuntu-latest - # Why 35: the terminal parking + retention specs below add two more - # docker-rig tests capped at 240s each on top of the watcher isolation pair. + # Why 35: the parking, retention, startup-exec, and paired parity specs run + # serially on isolated Electron/SSH fixtures after watcher isolation. timeout-minutes: 35 steps: @@ -261,7 +271,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb + run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh - name: Setup Node.js uses: actions/setup-node@v6 @@ -295,10 +305,9 @@ jobs: - name: Run Docker SSH watcher isolation E2E run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation - # Why always(): these specs gate the C1 terminal parking/retention budget - # over a real SSH host and run in no other lane, so a watcher-isolation - # failure above must not silently skip them. - - name: Run Docker SSH terminal parking + retention E2E + # Why always(): this lane gates SSH parking/retention plus startup-exec + # readiness across live SSH, headed paired, and headless serve topologies. + - name: Run Docker SSH terminal parking + startup readiness E2E if: always() run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 31a74480501..eab6a047bc6 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -21,18 +21,21 @@ "platforms": ["macos", "linux"], "providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"], "coveredPlatforms": ["macos", "linux"], - "coveredProviders": ["local", "local-daemon", "ssh-relay"], - "coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity/readiness composition plus provider- and renderer-owned delivery. Renderer-owned relay fallback publishes the existing ready marker, so mixed client/host versions need no new wire capability. Live SSH, paired-runtime, and WSL remain gaps.", + "coveredProviders": ["local", "local-daemon", "ssh-relay", "paired-runtime"], + "coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity/readiness composition plus provider-owned delivery; explicit terminal-paste remains renderer-owned. Live isolated journeys cover a macOS headed desktop owner with paired web client, headless orca serve with paired web client, and a Docker Linux OpenSSH/Bash target with one readiness-time reconnect. Linux desktop, WSL/Windows, live zsh-over-SSH, and physical remote hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/13767", - "https://linear.app/stably/issue/STA-4068" + "https://linear.app/stably/issue/STA-4067", + "https://linear.app/stably/issue/STA-4068", + "https://github.com/stablyai/orca/pull/14161" ], - "invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, prompt fallback releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger early fallback release.", + "invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, proof-based recovery releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger that proof. The existing bounded compatibility timeout may enqueue input without proof, but must not publish readiness, lose or duplicate that input across reconnect, or execute it before the replacement shell can accept it.", "oracle": "Split Fish output immediately after the ready marker and after the following ESC introducer; local and daemon owners must emit the exact ESC [ ? 2004 h bytes and headless xterm must not render literal control text. Run the real generated wrappers under node-pty with disposable zsh, bash, and Fish startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must use the same lossless scanner composition and release through the same prompt contract.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/relay/pty-handler.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts", + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1" ], "testFiles": [ "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", @@ -47,7 +50,9 @@ "src/main/providers/local-pty-shell-ready.test.ts", "src/main/providers/local-pty-provider.test.ts", "src/relay/pty-shell-launch.test.ts", - "src/relay/pty-handler.test.ts" + "src/relay/pty-handler.test.ts", + "tests/e2e/ssh-startup-exec-readiness.spec.ts", + "tests/e2e/paired-startup-exec-readiness.spec.ts" ], "assertionRefs": [ { @@ -88,6 +93,21 @@ "renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness", "normal renderer-owned ready markers pass through unchanged and retain no readiness state" ] + }, + { + "file": "tests/e2e/ssh-startup-exec-readiness.spec.ts", + "assertions": [ + "a Docker Linux OpenSSH/Bash replacement shell retains one compatibility-fallback input across reconnect without executing it before the blocked replacement can accept it", + "the owning terminal handle and raw PTY identity remain singular and stable", + "painted output, authoritative terminal output, and the first interactive input each appear exactly once" + ] + }, + { + "file": "tests/e2e/paired-startup-exec-readiness.spec.ts", + "assertions": [ + "a headed desktop owner and paired client recover startup exec on one stable owning PTY", + "an isolated headless orca serve owner provides the same exact-once output and input contract" + ] } ], "evidenceRuns": [ @@ -108,31 +128,50 @@ "result": "passed", "durationSeconds": 6, "summary": "473 tests passed with one local Fish skip, including every-boundary scanner composition, real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "linux", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 17, + "summary": "The live OpenSSH/Bash relay journey crossed the bounded compatibility fallback while rc-file exec was blocked, proved one queued command echo with no command execution, then passed a disconnect/reconnect with exact-once startup output/input and stable pane plus owner PTY identities." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 14, + "summary": "The shared oracle passed a headed desktop owner plus paired web client and an isolated headless orca serve owner plus paired web client." } ], "runtimeBudget": { - "p95Seconds": 6, - "scope": "real local zsh/bash exec and silent-read integration gate" + "p95Seconds": 120, + "scope": "focused local wrapper contracts plus isolated SSH and paired live journeys" }, "flakeHistory": { "status": "unknown", - "evidence": "Fresh deterministic local runs pass; CI and soak history have not started." + "evidence": "Fresh deterministic and isolated live runs pass locally; CI and soak history have not started." }, "redGreenEvidence": { "status": "complete", - "evidence": "The Fish chunk oracle patch SHA-256 fd49a4d3d91bd5f0d10c1b1f44f4cfbd92cef9155a5505b415c8680184cdc765 is byte-identical across runs. Scan target e4c278eb52, latest main 150cc6f50c, and candidate ae7f30318a with shared scanner integration disabled each failed only the ESC-introducer split in both local and daemon owners (2 failures, 161 passes). Candidate ae7f30318a passed all 163 local/daemon cases plus the independent headless-xterm render oracle. The original exec-readiness oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 remains 14/14 green." + "evidence": "Daemon oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 was byte-identical across runs: affected baseline 09ec516ae5 failed 4 of 14, while pre-PR main bc2e30000b and this PR passed 14 of 14. The unchanged live SSH oracle failed on bc2e30000b and with the ownership change reverted because the ledger stayed pending after reconnect, then passed on this PR. Fish chunk oracle patch SHA-256 fd49a4d3d91bd5f0d10c1b1f44f4cfbd92cef9155a5505b415c8680184cdc765 was also byte-identical: scan target e4c278eb52, main 150cc6f50c, and candidate ae7f30318a with shared scanner integration disabled failed only the ESC-introducer split in local and daemon owners, while ae7f30318a passed all 163 cases plus headless-xterm rendering." }, "performanceBudget": { "required": true, "evidence": "Two bounded linear scans partition identity ownership at the exact ready-marker boundary and retain only the existing bounded prefixes. The normal ready-marker path cancels before external probing. A missing marker triggers a debounced stty read only after a line-editor enable sequence, followed by narrow PID status and executable-image inspection only when termios matches. Slow ordinary startup output triggers zero probes; rejected protocol emissions are capped at four probes per startup. There is no per-command hook or steady-state polling." }, "promotionCriteria": [ - "Collect Linux, SSH-daemon, and paired-runtime live evidence.", + "Collect CI and soak history for the Docker SSH and paired live journeys.", "Collect CI and soak history without unexplained flakes.", "Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green." ], "knownGaps": [ "The fallback requires a readable POSIX PTY slave; WSL through wsl.exe, ConPTY, and native Windows retain the existing timeout backstop.", + "The live SSH journey covers Docker Linux OpenSSH with Bash, not zsh, WSL, Windows SSH, a Linux desktop renderer, or a physical remote host.", "An exec integration that forks a replacement shell instead of retaining the identified shell PID retains the existing timeout backstop.", "A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.", "The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.", diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 860e70c892b..d44039a2ca8 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -6,6 +6,10 @@ import { parse } from 'yaml' const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) +const sshDockerRunner = readFileSync( + join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'), + 'utf8' +) const filterStep = prWorkflow.jobs['e2e-paths'].steps.find( (step) => step.name === 'Filter changed E2E specs' @@ -64,14 +68,36 @@ describe('PR E2E gate contract', () => { expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''") expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''") expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined() - expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''") const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find( (step) => step.name === 'Run changed E2E specs' ) expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}') + expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"') + expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"') + expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]') expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1') }) + it('keeps startup-exec live parity in the isolated SSH lane', () => { + const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if + expect(sshLaneCondition).toContain("inputs.test_files == ''") + expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') + expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') + expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') + expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') + expect(sshDockerRunner).toContain("'electron-headless'") + expect(sshDockerRunner).toContain("'electron-headful'") + }) + + it('installs zsh in every Linux lane that can run paired startup readiness', () => { + for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) { + const installStep = e2eWorkflow.jobs[jobName].steps.find((step) => + step.name.startsWith('Install native build') + ) + expect(installStep.run, jobName).toMatch(/\bzsh\b/) + } + }) + it('keeps dedicated E2E workflows out of pull request CI', () => { const dedicatedWorkflows = [ 'golden-e2e-experiment.yml', diff --git a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs index 8d4183b4515..b894ac15217 100644 --- a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs +++ b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs @@ -5,7 +5,8 @@ const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArg const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const env = { ...process.env, - ORCA_E2E_SSH_DOCKER: '1' + ORCA_E2E_SSH_DOCKER: '1', + ORCA_E2E_WEB_CLIENT: '1' } // Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. @@ -21,9 +22,8 @@ if (runtime.status !== 0) { process.exit(runtime.status ?? 1) } -// Why both specs in one runner: they share the docker SSH rig and the same -// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and -// restore, the retention spec proves the C1 budget bounds what they retain. +// Why one runner: these specs share the Docker SSH rig; startup-exec also joins +// the paired desktop/headless parity proof under the same isolated CI lane. const result = spawnSync( pnpm, [ @@ -32,10 +32,14 @@ const result = spawnSync( 'test', 'tests/e2e/ssh-terminal-parking.spec.ts', 'tests/e2e/terminal-retention-budget.spec.ts', + 'tests/e2e/ssh-startup-exec-readiness.spec.ts', + 'tests/e2e/paired-startup-exec-readiness.spec.ts', '--config', 'tests/playwright.config.ts', '--project', 'electron-headless', + '--project', + 'electron-headful', '--workers=1', ...extraArgs ], diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8c901236967..3401c8939f6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1472,6 +1472,7 @@ export type PreloadApi = { env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 7a1d4030a32..040d7b63cf3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -946,6 +946,7 @@ const api = { env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 26ec23e5e1c..765eacfb1dd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4368,7 +4368,7 @@ describe('connectPanePty', () => { ) }) - it('delivers terminal-paste startup commands through xterm before submitting', async () => { + it('keeps SSH terminal-paste startup commands renderer-owned', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -4384,7 +4384,7 @@ describe('connectPanePty', () => { transport.connect.mockImplementation( async ({ callbacks }: { callbacks: ConnectCallbacks }) => { capturedDataCallback.current = callbacks.onData ?? null - return 'pty-local-paste' + return 'pty-ssh-paste' } ) transportFactoryQueue.push(transport) @@ -4392,7 +4392,8 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } const pane = createPane(1) @@ -4406,6 +4407,7 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) expect(createdTransportOptions[0]?.command).toBeUndefined() + expect(createdTransportOptions[0]?.commandDelivery).toBeUndefined() expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.('user@host $ ') @@ -6500,121 +6502,77 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('waits for shell-ready before unhinted SSH startup commands', async () => { - // Capture the setTimeout callback directly so we can fire it without vi.useFakeTimers() (which would also replace beforeEach's rAF mock). - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout - - try { - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { - current: null - } - const transport = createMockTransport('pty-id') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-ssh-1' - } - ) - transportFactoryQueue.push(transport) - - // SSH connection: the relay gets command metadata while the renderer owns delivery. - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], - // Why: startup delivery assumes a live connection; a disconnected target routes through the deferred-connect gate instead of spawning synchronously. - sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ startup: { command: "claude 'say test'" } }) - - connectPanePty(pane as never, manager as never, deps as never) - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('user@remote $ ') - expect(transport.sendInput).not.toHaveBeenCalled() - - capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ') - for (const fn of pendingTimeouts.splice(0)) { - fn() - } - - expect(createdTransportOptions[0]).toEqual( - expect.objectContaining({ startupCommandDelivery: 'shell-ready' }) - ) - expect(transport.sendInput).toHaveBeenCalledWith("claude 'say test'\r") - } finally { - globalThis.setTimeout = originalSetTimeout + it('delegates ordinary SSH startup delivery to the provider without a renderer duplicate', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-1' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: "claude 'say test'" } }) as never + ) + await flushAsyncTicks() + capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ') + + expect(createdTransportOptions[0]).toEqual( + expect.objectContaining({ + command: "claude 'say test'", + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + ) + expect(transport.sendInput).not.toHaveBeenCalledWith("claude 'say test'\r") }) - it('waits for the SSH shell-ready marker before sending hinted startup commands', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout - - try { - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { - current: null - } - const transport = createMockTransport('pty-id') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-ssh-1' - } - ) - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], - // Why: startup delivery assumes a live connection; a disconnected target routes through the deferred-connect gate instead of spawning synchronously. - sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: "codex 'linked issue context'", - startupCommandDelivery: 'shell-ready' - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('user@remote $ ') - for (const fn of pendingTimeouts.splice(0)) { - fn() - } - expect(transport.sendInput).not.toHaveBeenCalled() - - capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ') - for (const fn of pendingTimeouts.splice(0)) { - fn() - } - - expect(transport.sendInput).toHaveBeenCalledWith("codex 'linked issue context'\r") - } finally { - globalThis.setTimeout = originalSetTimeout + it('arms draft observation after a provider-owned SSH startup succeeds', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-codex' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } + const prompt = 'https://linear.app/stably/issue/STA-4067' + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-ssh', + draftPrompt: prompt + } + }) as never + ) + await flushAsyncTicks() + capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') + await flushAsyncTicks() + + expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') + expect(transport.sendInput).not.toHaveBeenCalledWith('codex\r') + expect(transport.sendInputAccepted).toHaveBeenCalledWith(`\x1b[200~${prompt}\x1b[201~`) }) it('orders a startup draft behind existing user input when Codex renders its composer', async () => { @@ -6783,7 +6741,7 @@ describe('connectPanePty', () => { ).toBe(true) }) - it('falls back for SSH shell-ready startup commands when no marker arrives', async () => { + it('does not fall back to renderer delivery when provider-owned SSH output has no marker', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -6834,13 +6792,14 @@ describe('connectPanePty', () => { fn() } - expect(transport.sendInput).toHaveBeenCalledWith("codex 'linked issue context'\r") + expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') + expect(transport.sendInput).not.toHaveBeenCalledWith("codex 'linked issue context'\r") } finally { globalThis.setTimeout = originalSetTimeout } }) - it('falls back for quiet SSH shell-ready startup commands with no output', async () => { + it('does not duplicate a quiet provider-owned SSH startup command', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -6892,13 +6851,14 @@ describe('connectPanePty', () => { fn() } - expect(transport.sendInput).toHaveBeenCalledWith("codex 'linked issue context'\r") + expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') + expect(transport.sendInput).not.toHaveBeenCalledWith("codex 'linked issue context'\r") } finally { globalThis.setTimeout = originalSetTimeout } }) - it('waits for shell-ready for SSH Codex native prefill commands without an explicit hint', async () => { + it('uses provider delivery for SSH Codex native prefill commands without an explicit hint', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -6946,13 +6906,16 @@ describe('connectPanePty', () => { fn() } - expect(transport.sendInput).toHaveBeenCalledWith("codex --prefill 'linked issue context'\r") + expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') + expect(transport.sendInput).not.toHaveBeenCalledWith( + "codex --prefill 'linked issue context'\r" + ) } finally { globalThis.setTimeout = originalSetTimeout } }) - it('uses the sequenced startup command hint for SSH shell-ready detection', async () => { + it('keeps sequenced SSH startup wrappers provider-owned', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -7006,7 +6969,8 @@ describe('connectPanePty', () => { fn() } - expect(transport.sendInput).toHaveBeenCalledWith(`${wrapperCommand}\r`) + expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') + expect(transport.sendInput).not.toHaveBeenCalledWith(`${wrapperCommand}\r`) } finally { globalThis.setTimeout = originalSetTimeout } @@ -8286,7 +8250,7 @@ describe('connectPanePty', () => { expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-ssh-pty') }) - it('submits a cold-restore resume command after SSH expired-session fallback', async () => { + it('delegates a cold-restore resume command after SSH expired-session fallback', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -8359,6 +8323,7 @@ describe('connectPanePty', () => { 2, expect.objectContaining({ command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + commandDelivery: 'provider', startupCommandDelivery: 'shell-ready', env: expect.objectContaining({ ORCA_PANE_KEY: paneKey, @@ -8369,7 +8334,7 @@ describe('connectPanePty', () => { }) }) ) - expect(transport.sendInput).toHaveBeenCalledWith( + expect(transport.sendInput).not.toHaveBeenCalledWith( "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r" ) } finally { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 16b2bc34346..6c5d86d11de 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -128,7 +128,6 @@ import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' -import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { INITIAL_MODE_2031_REPLY_SCAN_STATE, @@ -344,7 +343,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 const REMOTE_PTY_ID_PREFIX = 'remote:' const PTY_CONNECT_DIAG_LIMIT = 200 -const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 // Why: the notice deliberately omits the rejected path — saved cwds can @@ -1137,7 +1135,6 @@ export function connectPanePty( let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} let startupInjectTimer: ReturnType | null = null - let sshShellReadyFallbackTimer: ReturnType | null = null let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null @@ -3756,6 +3753,8 @@ export function connectPanePty( onDone: scheduleCommandCodeOutputDoneStatus }) const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' + const shouldUseProviderSshStartupDelivery = + Boolean(connectionId) && !shouldDeliverStartupViaTerminalPaste const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false @@ -3809,6 +3808,7 @@ export function connectPanePty( env: paneEnv, ...(paneStartup?.envToDelete ? { envToDelete: paneStartup.envToDelete } : {}), command: shouldDeliverStartupViaTerminalPaste ? undefined : paneStartup?.command, + ...(shouldUseProviderSshStartupDelivery ? { commandDelivery: 'provider' as const } : {}), startupCommandDelivery: shouldDeliverStartupViaTerminalPaste ? undefined : connectionId && paneStartup?.command @@ -4826,51 +4826,13 @@ export function connectPanePty( .catch(() => {}) } - // Why: for ordinary local startup commands, the local PTY provider already - // writes via the shell-ready barrier. terminal-paste and SSH startup - // commands stay renderer-delivered so xterm/relay can apply their handling. - let pendingStartupCommand: PendingStartupCommand | null = - shouldDeliverStartupViaTerminalPaste || connectionId - ? paneStartup?.command - ? { command: paneStartup.command } - : null + // Why: local and ordinary SSH startup commands are provider-owned so delivery + // survives renderer replacement. Only explicit terminal-paste stays renderer-owned. + let pendingStartupCommand: PendingStartupCommand | null = shouldDeliverStartupViaTerminalPaste + ? paneStartup?.command + ? { command: paneStartup.command } : null - // Why: every renderer-delivered SSH command needs the relay's readiness marker; - // the existing fallback preserves shells that cannot emit it. - const shouldWaitForSshShellReady = - Boolean(connectionId) && - Boolean(pendingStartupCommand) && - !shouldDeliverStartupViaTerminalPaste - let sshShellReadyMarkerScan = shouldWaitForSshShellReady - ? createShellReadyMarkerScanState() : null - let sshStartupShellReady = !shouldWaitForSshShellReady - const armSshStartupShellReady = (): void => { - if (!connectionId || !pendingStartupCommand || shouldDeliverStartupViaTerminalPaste) { - return - } - if (startupInjectTimer !== null) { - clearTimeout(startupInjectTimer) - startupInjectTimer = null - } - if (sshShellReadyFallbackTimer !== null) { - clearTimeout(sshShellReadyFallbackTimer) - sshShellReadyFallbackTimer = null - } - sshShellReadyMarkerScan = createShellReadyMarkerScanState() - sshStartupShellReady = false - } - const markSshStartupShellReady = (): void => { - if (sshStartupShellReady) { - return - } - sshStartupShellReady = true - if (sshShellReadyFallbackTimer !== null) { - clearTimeout(sshShellReadyFallbackTimer) - sshShellReadyFallbackTimer = null - } - schedulePendingStartupCommandDelivery() - } const startupDraftReadyScanner = ownsStartupDraftPaste ? createDraftPasteReadyScanner( startupDraftAgentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' @@ -5176,18 +5138,6 @@ export function connectPanePty( if (!startup) { return } - if (!sshStartupShellReady) { - if (sshShellReadyFallbackTimer === null) { - // Why: some SSH shells cannot emit Orca's ready marker. Prefer the - // marker when available, but fall back to the old renderer delivery - // behavior instead of dropping the startup command forever. - sshShellReadyFallbackTimer = setTimeout(() => { - sshShellReadyFallbackTimer = null - markSshStartupShellReady() - }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) - } - return - } if (startupInjectTimer !== null) { clearTimeout(startupInjectTimer) } @@ -5288,12 +5238,6 @@ export function connectPanePty( // a restart-in-place would leak the old TUI's flags into a fresh shell. kittyKeyboardModes.reset() prepareFreshShellViewportForSpawn(options) - if (connectionId && startupOverride?.command) { - // Why: SSH providers use `command` only as spawn metadata; the renderer - // must still submit the resume command to the fresh remote shell. - pendingStartupCommand = { command: startupOverride.command } - } - armSshStartupShellReady() const coldRestoreOverride = startupOverride && 'launchConfig' in startupOverride ? (startupOverride as ColdRestoreAgentResumeStartup) @@ -5316,6 +5260,9 @@ export function connectPanePty( cols, rows, ...(startupOverride?.command ? { command: startupOverride.command } : {}), + ...(connectionId && startupOverride?.command && !shouldDeliverStartupViaTerminalPaste + ? { commandDelivery: 'provider' as const } + : {}), ...(connectionId && startupOverride?.command ? { startupCommandDelivery: 'shell-ready' as const } : {}), @@ -5445,6 +5392,12 @@ export function connectPanePty( void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } if (resolvedPtyId && connectionId) { + if ( + shouldUseProviderSshStartupDelivery && + (startupOverride?.command || paneStartup?.command) + ) { + armStartupDraftReadinessObservation() + } schedulePendingStartupCommandDelivery() } finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation) @@ -7727,13 +7680,6 @@ export function connectPanePty( // Why: output is the agent-start signal that ends the relaxed no-evidence process-scan cadence (a starting agent always prints). agentCompletionCoordinator.observeOutputActivity() } - if (sshShellReadyMarkerScan) { - const scanned = scanForShellReadyMarker(sshShellReadyMarkerScan, data) - if (scanned.matched) { - markSshStartupShellReady() - } - data = scanned.output - } observeStartupDraftPasteReadiness(data) resetHiddenOutputRestoreIfPtyChanged() observeLiveMode2031Chunk(data) @@ -9414,10 +9360,6 @@ export function connectPanePty( clearTimeout(startupInjectTimer) startupInjectTimer = null } - if (sshShellReadyFallbackTimer !== null) { - clearTimeout(sshShellReadyFallbackTimer) - sshShellReadyFallbackTimer = null - } cleanupStartupDraftPasteTimers() releaseUnattemptedStartupDraftPasteDelivery() unregisterAgentHookTerminalLifecycle() diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 9befbf45a00..1ba841bd823 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -144,6 +144,7 @@ export type PtyTransport = { * Ignored by remote-runtime transports (not gate-markable). */ initiallyHidden?: boolean command?: string + commandDelivery?: 'renderer' | 'provider' env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -224,6 +225,7 @@ export type IpcPtyTransportOptions = { env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata agentPrompt?: string diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 14ff327146e..1954b857d70 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -96,6 +96,24 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('threads provider command ownership through the spawn IPC', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({ + command: 'printf ready', + commandDelivery: 'provider' + }) + + await transport.connect({ url: '', callbacks: {} }) + + expect(window.api.pty.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'printf ready', + commandDelivery: 'provider' + }) + ) + transport.disconnect() + }) + it('routes a rejected daemon write to the owning transport recovery callback', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const recovery = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 96199cb3aa2..9eddfae0155 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -560,6 +560,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra env, envToDelete, command, + commandDelivery, launchConfig, resumeProviderSession, launchToken, @@ -819,6 +820,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ? { envToDelete: options.envToDelete ?? envToDelete } : {}), command: options.command ?? command, + ...((options.commandDelivery ?? commandDelivery) + ? { commandDelivery: options.commandDelivery ?? commandDelivery } + : {}), ...((options.launchConfig ?? launchConfig) ? { launchConfig: options.launchConfig ?? launchConfig } : {}), diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-relay-target.ts index 6c6f75dfc75..aa61f163aae 100644 --- a/tests/e2e/helpers/docker-ssh-relay-target.ts +++ b/tests/e2e/helpers/docker-ssh-relay-target.ts @@ -45,6 +45,21 @@ export function execDockerSshRelayTargetCommand( }) } +export function execDockerSshRelayTargetControlCommand( + target: DockerSshRelayTarget, + command: string +): string { + return run('docker', [ + 'exec', + target.containerName, + 'bash', + '--noprofile', + '--norc', + '-c', + command + ]) +} + function sshArgs(target: DockerSshRelayTarget, command: string): string[] { return [ '-i', diff --git a/tests/e2e/helpers/startup-exec-readiness-oracle.ts b/tests/e2e/helpers/startup-exec-readiness-oracle.ts new file mode 100644 index 00000000000..48b2928ed6d --- /dev/null +++ b/tests/e2e/helpers/startup-exec-readiness-oracle.ts @@ -0,0 +1,274 @@ +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import type { + RuntimeTerminalListResult, + RuntimeTerminalRead, + RuntimeTerminalShow +} from '../../../src/shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id' +import { expect } from './orca-app' +import { getTerminalContent, waitForActivePanePtyId } from './terminal' + +const RECOVERY_DEADLINE_MS = 8_000 + +export type StartupExecTerminal = { + hostPtyId: string + panePtyId: string + parentTabId: string + startupMarker: string + tabId: string + terminal: string + worktreeId: string +} + +export type BashExecProfileBarrier = { + releasePath: string + startedPath: string +} + +const BASH_EXEC_LINE = 'exec -a figterm-sta4067 /bin/bash --noprofile --norc -l -i' +const ZSH_EXEC_LINE = 'exec -a figterm-sta4067 /bin/zsh -o noglobalrcs -l -i' + +function execProfileContents( + runId: string, + execLine: string, + barrier?: BashExecProfileBarrier +): string { + const guard = `ORCA_STA4067_EXEC_${runId.replaceAll(/[^A-Za-z0-9_]/g, '_')}` + const barrierScript = barrier + ? [ + `: > ${shellQuote(barrier.startedPath)}`, + `while [[ ! -e ${shellQuote(barrier.releasePath)} ]]; do sleep 0.02; done` + ].join('\n ') + : '' + return `if [[ -z "\${${guard}:-}" ]]; then + export ${guard}=1 + ${barrierScript} + ${execLine} +fi +` +} + +export function bashExecProfileContents(runId: string, barrier?: BashExecProfileBarrier): string { + return execProfileContents(runId, BASH_EXEC_LINE, barrier) +} + +export function zshExecProfileContents(runId: string, barrier?: BashExecProfileBarrier): string { + return execProfileContents(runId, ZSH_EXEC_LINE, barrier) +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function splitMarker(marker: string): [string, string] { + const midpoint = Math.floor(marker.length / 2) + return [marker.slice(0, midpoint), marker.slice(midpoint)] +} + +function markerCommand(marker: string): string { + const [left, right] = splitMarker(marker) + return `printf '%s%s\\n' ${shellQuote(left)} ${shellQuote(right)}` +} + +function count(text: string, marker: string): number { + return text.split(marker).length - 1 +} + +async function expectSingleOwningPty( + page: Page, + worktreeId: string, + tabId: string, + terminal: string, + ptyId: string +): Promise { + const listed = await callStartupExecRuntime(page, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + expect( + listed.terminals + .filter((candidate) => candidate.tabId === tabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + ).toEqual([{ handle: terminal, ptyId }]) +} + +export async function callStartupExecRuntime( + page: Page, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export function installBashExecProfile( + homePath: string, + runId: string, + barrier?: BashExecProfileBarrier +): () => void { + const profilePath = path.join(homePath, '.bash_profile') + writeFileSync(profilePath, bashExecProfileContents(runId, barrier)) + return () => rmSync(profilePath, { force: true }) +} + +export function installZshExecProfile( + homePath: string, + runId: string, + barrier?: BashExecProfileBarrier +): () => void { + const profilePath = path.join(homePath, '.zprofile') + writeFileSync(profilePath, zshExecProfileContents(runId, barrier)) + return () => rmSync(profilePath, { force: true }) +} + +export async function createStartupExecTerminal( + page: Page, + worktreeId: string, + runId: string, + ledgerPath: string, + tabIdNamespace: 'owning-client' | 'paired-client', + shell: '/bin/bash' | '/bin/zsh' = '/bin/bash', + shellEnv: Record = {} +): Promise { + const startupMarker = `STA4067_STARTUP_READY_${runId}` + const command = [ + `printf '%s|%s\\n' "$$" "$(tty)" > ${shellQuote(ledgerPath)}`, + markerCommand(startupMarker) + ].join('; ') + const created = await callStartupExecRuntime<{ + tab: { parentTabId: string; terminal: string | null } + }>(page, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command, + env: { ...shellEnv, SHELL: shell }, + startupCommandDelivery: 'shell-ready', + activate: false, + select: false, + navigation: 'caller' + }) + if (!created.tab.terminal) { + throw new Error('Startup-exec terminal did not publish an authoritative handle') + } + const terminal = created.tab.terminal + const tabId = + tabIdNamespace === 'paired-client' + ? toWebTerminalSurfaceTabId(created.tab.parentTabId) + : created.tab.parentTabId + await page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`) + await expect(tab).toBeVisible({ timeout: 30_000 }) + await tab.click() + await expect(tab).toHaveAttribute('data-active', 'true') + const panePtyId = await waitForActivePanePtyId(page, 30_000) + const shown = await callStartupExecRuntime<{ terminal: RuntimeTerminalShow }>( + page, + 'terminal.show', + { terminal } + ) + if (!shown.terminal.ptyId) { + throw new Error('Startup-exec terminal has no owning PTY identity') + } + await expectSingleOwningPty( + page, + worktreeId, + created.tab.parentTabId, + terminal, + shown.terminal.ptyId + ) + return { + hostPtyId: shown.terminal.ptyId, + panePtyId, + parentTabId: created.tab.parentTabId, + startupMarker, + tabId, + terminal, + worktreeId + } +} + +async function readTerminal(page: Page, terminal: string): Promise { + return ( + await callStartupExecRuntime<{ terminal: RuntimeTerminalRead }>(page, 'terminal.read', { + terminal + }) + ).terminal +} + +export async function expectStartupExecRecovery( + page: Page, + created: StartupExecTerminal, + runId: string +): Promise { + await expect + .poll(() => readTerminal(page, created.terminal), { timeout: RECOVERY_DEADLINE_MS }) + .toMatchObject({ + tail: expect.arrayContaining([expect.stringContaining(created.startupMarker)]) + }) + await expect + .poll(() => getTerminalContent(page), { timeout: RECOVERY_DEADLINE_MS }) + .toContain(created.startupMarker) + const painted = await getTerminalContent(page) + expect(count(painted, created.startupMarker)).toBe(1) + + const authoritative = await readTerminal(page, created.terminal) + expect(authoritative.status).toBe('running') + expect(count(authoritative.tail.join('\n'), created.startupMarker)).toBe(1) + const shown = await callStartupExecRuntime<{ terminal: RuntimeTerminalShow }>( + page, + 'terminal.show', + { terminal: created.terminal } + ) + expect(shown.terminal).toMatchObject({ + connected: true, + ptyId: created.hostPtyId, + writable: true + }) + await expectSingleOwningPty( + page, + created.worktreeId, + created.parentTabId, + created.terminal, + created.hostPtyId + ) + + const inputMarker = `STA4067_FIRST_INPUT_${runId}` + const textarea = page.locator('.xterm-helper-textarea:visible').first() + await textarea.focus() + await page.keyboard.type(markerCommand(inputMarker)) + await page.keyboard.press('Enter') + await expect + .poll(() => getTerminalContent(page), { timeout: RECOVERY_DEADLINE_MS }) + .toContain(inputMarker) + expect(count(await getTerminalContent(page), inputMarker)).toBe(1) + expect(count((await readTerminal(page, created.terminal)).tail.join('\n'), inputMarker)).toBe(1) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(created.panePtyId) +} + +export async function expectStartupCommandQueuedByCompatibilityFallback( + page: Page, + created: StartupExecTerminal +): Promise { + const [commandEchoMarker] = splitMarker(created.startupMarker) + await expect + .poll(() => readTerminal(page, created.terminal), { timeout: RECOVERY_DEADLINE_MS }) + .toMatchObject({ tail: expect.arrayContaining([expect.stringContaining(commandEchoMarker)]) }) + expect( + count((await readTerminal(page, created.terminal)).tail.join('\n'), commandEchoMarker) + ).toBe(1) +} + +export async function closeStartupExecTerminal(page: Page, terminal: string | null): Promise { + if (terminal) { + await callStartupExecRuntime(page, 'terminal.closeTab', { terminal }).catch(() => undefined) + } +} diff --git a/tests/e2e/paired-startup-exec-readiness.spec.ts b/tests/e2e/paired-startup-exec-readiness.spec.ts new file mode 100644 index 00000000000..cd96de038ce --- /dev/null +++ b/tests/e2e/paired-startup-exec-readiness.spec.ts @@ -0,0 +1,184 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { + callStartupExecRuntime, + closeStartupExecTerminal, + createStartupExecTerminal, + expectStartupExecRecovery, + installZshExecProfile +} from './helpers/startup-exec-readiness-oracle' + +test.skip(process.platform === 'win32', 'Paired startup-exec readiness uses POSIX shells.') + +async function waitForWorktree(page: Page, id: string): Promise { + await expect + .poll( + () => + page.evaluate( + (worktreeId) => + window.__store + ?.getState() + .allWorktrees() + .some((candidate) => candidate.id === worktreeId), + id + ), + { timeout: 30_000 } + ) + .toBe(true) + await expect + .poll( + async () => { + const listed = await callStartupExecRuntime<{ worktrees: { id: string }[] }>( + page, + 'worktree.list', + {} + ) + return listed.worktrees.some((candidate) => candidate.id === id) + }, + { timeout: 30_000 } + ) + .toBe(true) +} + +function expectLedger(ledgerPath: string): void { + const [pid, tty] = readFileSync(ledgerPath, 'utf8').trim().split('|') + expect(Number(pid)).toBeGreaterThan(1) + expect(tty).toMatch(/^\/dev\//) +} + +async function releaseExecBarrier( + startedPath: string, + releasePath: string, + ledgerPath: string +): Promise { + await expect + .poll( + () => + existsSync(startedPath) + ? 'started' + : existsSync(ledgerPath) + ? readFileSync(ledgerPath, 'utf8').trim() + : 'pending', + { timeout: 30_000 } + ) + .toBe('started') + expect(existsSync(ledgerPath)).toBe(false) + writeFileSync(releasePath, '') +} + +function cleanupExecBarrier(startedPath: string, releasePath: string): void { + rmSync(startedPath, { force: true }) + rmSync(releasePath, { force: true }) +} + +test('recovers startup exec through a headed paired desktop owner @headful', async ({ + electronApp, + orcaPage +}) => { + test.setTimeout(90_000) + const runId = `headed_${Date.now()}` + const homePath = await electronApp.evaluate(({ app }) => app.getPath('home')) + const ledgerPath = path.join(homePath, `.sta4067-${runId}.ledger`) + const startedPath = path.join(homePath, `.sta4067-${runId}.started`) + const releasePath = path.join(homePath, `.sta4067-${runId}.release`) + const removeProfile = installZshExecProfile(homePath, runId, { releasePath, startedPath }) + const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('Headed owner has no active worktree') + } + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + let terminal: string | null = null + try { + await waitForWorktree(client.page, worktreeId) + const created = await createStartupExecTerminal( + client.page, + worktreeId, + runId, + ledgerPath, + 'paired-client', + '/bin/zsh', + { ORCA_ORIG_ZDOTDIR: homePath, ORCA_ZSHENV_SOURCE_DIR: homePath } + ) + terminal = created.terminal + await releaseExecBarrier(startedPath, releasePath, ledgerPath) + await expectStartupExecRecovery(client.page, created, runId) + expectLedger(ledgerPath) + } finally { + await closeStartupExecTerminal(orcaPage, terminal) + await client.dispose() + removeProfile() + cleanupExecBarrier(startedPath, releasePath) + } +}) + +test('recovers the same startup exec through an isolated headless orca serve', async ({ + testRepoPath +}) => { + test.setTimeout(120_000) + const runId = `headless_${Date.now()}` + const host = await launchHeadlessPairedRuntimeHost() + let terminal: string | null = null + let removeProfile: (() => void) | undefined + let client: Awaited> | undefined + let startedPath = '' + let releasePath = '' + try { + const homePath = await host.app.evaluate(({ app }) => app.getPath('home')) + const ledgerPath = path.join(homePath, `.sta4067-${runId}.ledger`) + startedPath = path.join(homePath, `.sta4067-${runId}.started`) + releasePath = path.join(homePath, `.sta4067-${runId}.release`) + removeProfile = installZshExecProfile(homePath, runId, { releasePath, startedPath }) + client = await launchPairedWebClient(host.app, host.offer, { waitForWorkspace: false }) + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + await client.page.locator('[data-worktree-sidebar]').waitFor({ + state: 'visible', + timeout: 30_000 + }) + await expect + .poll( + () => client.page.evaluate(() => window.__store?.getState().allWorktrees()[0]?.id ?? null), + { timeout: 30_000 } + ) + .not.toBeNull() + const worktreeId = await client.page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? null + ) + if (!worktreeId) { + throw new Error('Headless owner did not publish its worktree') + } + const created = await createStartupExecTerminal( + client.page, + worktreeId, + runId, + ledgerPath, + 'paired-client', + '/bin/zsh', + { ORCA_ORIG_ZDOTDIR: homePath, ORCA_ZSHENV_SOURCE_DIR: homePath } + ) + terminal = created.terminal + await releaseExecBarrier(startedPath, releasePath, ledgerPath) + await expectStartupExecRecovery(client.page, created, runId) + expectLedger(ledgerPath) + } finally { + try { + if (client) { + await closeStartupExecTerminal(client.page, terminal) + await client.dispose() + } + removeProfile?.() + if (startedPath) { + cleanupExecBarrier(startedPath, releasePath) + } + } finally { + await host.dispose() + } + } +}) diff --git a/tests/e2e/ssh-startup-exec-readiness.spec.ts b/tests/e2e/ssh-startup-exec-readiness.spec.ts new file mode 100644 index 00000000000..e77c9afd67f --- /dev/null +++ b/tests/e2e/ssh-startup-exec-readiness.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from './helpers/orca-app' +import { + connectDockerSshRelayTarget, + reconnectDockerSshRelayTarget +} from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetControlCommand, + startDockerSshRelayTarget, + type DockerSshRelayTarget, + writeDockerSshRelayTargetFile +} from './helpers/docker-ssh-relay-target' +import { + bashExecProfileContents, + closeStartupExecTerminal, + createStartupExecTerminal, + expectStartupCommandQueuedByCompatibilityFallback, + expectStartupExecRecovery +} from './helpers/startup-exec-readiness-oracle' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +test.describe('startup exec readiness over live SSH', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH E2E.') + test.skip(process.platform === 'win32', 'Docker SSH E2E uses POSIX ssh tooling.') + + test('survives an SSH reconnect while the replacement shell is not ready @headful', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(150_000) + const runId = `ssh_${Date.now()}` + const startedPath = `/tmp/sta4067-${runId}.started` + const releasePath = `/tmp/sta4067-${runId}.release` + const ledgerPath = `/tmp/sta4067-${runId}.ledger` + let target: DockerSshRelayTarget | null = null + let terminal: string | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target, { + relayGracePeriodSeconds: 15 + }) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + writeDockerSshRelayTargetFile( + target, + '/root/.bash_profile', + bashExecProfileContents(runId, { releasePath, startedPath }) + ) + const created = await createStartupExecTerminal( + orcaPage, + remote.worktreeId, + runId, + ledgerPath, + 'owning-client' + ) + terminal = created.terminal + await expect + .poll( + () => + execDockerSshRelayTargetControlCommand( + target!, + `if test -e '${startedPath}'; then echo ready; else echo pending; fi` + ), + { timeout: 30_000 } + ) + .toBe('ready') + await expectStartupCommandQueuedByCompatibilityFallback(orcaPage, created) + expect( + execDockerSshRelayTargetControlCommand( + target, + `if test ! -e '${ledgerPath}'; then echo pending; else cat '${ledgerPath}'; fi` + ) + ).toBe('pending') + + await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) + execDockerSshRelayTargetControlCommand(target, `: > '${releasePath}'`) + await expect + .poll( + () => + execDockerSshRelayTargetControlCommand( + target!, + `if test -e '${ledgerPath}'; then cat '${ledgerPath}'; else echo pending; fi` + ), + { timeout: 8_000 } + ) + .toMatch(/^[0-9]+\|\/dev\/pts\/[0-9]+$/) + await expectStartupExecRecovery(orcaPage, created, runId) + + const [pidText, tty] = execDockerSshRelayTargetControlCommand( + target, + `cat '${ledgerPath}'` + ).split('|') + const pid = Number(pidText) + expect(pid).toBeGreaterThan(1) + expect(tty).toMatch(/^\/dev\/pts\/[0-9]+$/) + expect(execDockerSshRelayTargetControlCommand(target, `readlink '/proc/${pid}/exe'`)).toMatch( + /\/bash$/ + ) + expect( + execDockerSshRelayTargetControlCommand(target, `ps -o tpgid= -p '${pid}' | tr -d ' '`) + ).toBe(String(pid)) + } finally { + await closeStartupExecTerminal(orcaPage, terminal) + cleanupDockerSshRelayTarget(target) + } + }) +}) From 602f0cbe63cb3016f2c8f076896921117fb5ad76 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:08:37 -0700 Subject: [PATCH 04/25] fix(sidebar): stabilize downward worktree card dragging Fix downward worktree card dragging with virtualization-safe global indices and stable preview geometry. Add unit and Electron regression coverage. --- .../src/components/sidebar/WorktreeList.tsx | 4 +- .../sidebar/worktree-drag-preview-offsets.ts | 74 ++++- .../sidebar/worktree-list-virtual-rows.ts | 1 + .../sidebar/worktree-manual-order.test.ts | 22 ++ .../worktree-sidebar-drag-geometry.test.ts | 8 +- .../sidebar/worktree-sidebar-drag-geometry.ts | 16 +- .../worktree-sidebar-drop-preview.test.ts | 74 +++++ .../sidebar/worktree-sidebar-drop-preview.ts | 24 +- tests/e2e/worktree-card-downward-drag.spec.ts | 278 ++++++++++++++++++ 9 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 tests/e2e/worktree-card-downward-drag.spec.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 25dccde1957..be462b94bf0 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -104,6 +104,7 @@ import { getVirtualRowTransform, pruneStaleVirtualRowElementCache, shouldUseHeaderTopSpacing, + WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, type RenderRow } from './worktree-list-virtual-rows' import { @@ -1625,6 +1626,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupIds: group.worktreeIds, draggedIds: args.draggedIds, draggingWorktreeId: args.draggingWorktreeId, + fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, grab: args.grab, anchor: args.anchor }) @@ -2109,7 +2111,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [stickyHeaderIndexes] ), overscan: 10, - gap: 6, + gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, diff --git a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts index cf97cad8958..86bf48a9b0a 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts @@ -11,28 +11,37 @@ function arraysEqual(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]) } -function getFallbackStride(rects: readonly WorktreeDragPreviewRect[]): number { +function getFallbackStride(rects: readonly WorktreeDragPreviewRect[], defaultGap: number): number { const sortedRects = [...rects].sort((a, b) => a.groupIndex - b.groupIndex) const strides: number[] = [] for (let index = 1; index < sortedRects.length; index++) { - strides.push(sortedRects[index]!.top - sortedRects[index - 1]!.top) + const previous = sortedRects[index - 1]! + const current = sortedRects[index]! + const indexDelta = current.groupIndex - previous.groupIndex + if (indexDelta > 0) { + strides.push((current.top - previous.top) / indexDelta) + } } if (strides.length > 0) { strides.sort((a, b) => a - b) return strides[Math.floor(strides.length / 2)]! } const firstRect = sortedRects[0] - return firstRect ? firstRect.bottom - firstRect.top : 0 + return firstRect ? firstRect.bottom - firstRect.top + defaultGap : 0 } -function getFallbackGap(rects: readonly WorktreeDragPreviewRect[]): number { +function getFallbackGap(rects: readonly WorktreeDragPreviewRect[], defaultGap: number): number { const sortedRects = [...rects].sort((a, b) => a.groupIndex - b.groupIndex) const gaps: number[] = [] for (let index = 1; index < sortedRects.length; index++) { - gaps.push(Math.max(0, sortedRects[index]!.top - sortedRects[index - 1]!.bottom)) + const previous = sortedRects[index - 1]! + const current = sortedRects[index]! + if (current.groupIndex === previous.groupIndex + 1) { + gaps.push(Math.max(0, current.top - previous.bottom)) + } } if (gaps.length === 0) { - return 0 + return defaultGap } gaps.sort((a, b) => a - b) return gaps[Math.floor(gaps.length / 2)]! @@ -70,6 +79,8 @@ export function buildWorktreeDragPreviewOffsets(args: { groupIds: readonly string[] draggedIds: readonly string[] draggingWorktreeId?: string | null + draggedPreviewHeight?: number | null + fallbackGap?: number dropIndex: number rects: readonly WorktreeDragPreviewRect[] }): WorktreeDragPreviewLayout { @@ -106,13 +117,18 @@ export function buildWorktreeDragPreviewOffsets(args: { } } - const fallbackStride = getFallbackStride(args.rects) - const fallbackGap = getFallbackGap(args.rects) + const defaultGap = + typeof args.fallbackGap === 'number' && + Number.isFinite(args.fallbackGap) && + args.fallbackGap >= 0 + ? args.fallbackGap + : 0 + const fallbackStride = getFallbackStride(args.rects, defaultGap) + const fallbackGap = getFallbackGap(args.rects, defaultGap) const groupRects = args.groupIds.flatMap((id) => { const rect = rectById.get(id) return rect ? [rect] : [] }) - const baseTop = groupRects[0]?.top ?? 0 const fallbackHeight = Math.max(0, fallbackStride - fallbackGap) const gapAfterById = new Map() for (let index = 0; index < groupRects.length; index++) { @@ -120,19 +136,51 @@ export function buildWorktreeDragPreviewOffsets(args: { const nextRect = groupRects[index + 1] gapAfterById.set( rect.worktreeId, - nextRect ? Math.max(0, nextRect.top - rect.bottom) : fallbackGap + nextRect?.groupIndex === rect.groupIndex + 1 + ? Math.max(0, nextRect.top - rect.bottom) + : fallbackGap ) } + const previewDraggedId = layoutDraggedIds[0] ?? null + const draggedPreviewHeight = + previewDraggedId === args.draggingWorktreeId && + typeof args.draggedPreviewHeight === 'number' && + Number.isFinite(args.draggedPreviewHeight) && + args.draggedPreviewHeight > 0 + ? args.draggedPreviewHeight + : null + const getHeight = (id: string): number => { + const rect = rectById.get(id) + if (rect) { + return rect.bottom - rect.top + } + return id === previewDraggedId && draggedPreviewHeight !== null + ? draggedPreviewHeight + : fallbackHeight + } + const getStride = (id: string): number => getHeight(id) + (gapAfterById.get(id) ?? fallbackGap) + + const firstGroupRect = groupRects[0] + let baseTop = firstGroupRect?.top ?? 0 + if (firstGroupRect) { + // Why: virtualized leading rows are absent from rects, so derive the full + // list origin from the first mounted row before replaying its order. + for (let index = 0; index < firstGroupRect.groupIndex; index++) { + const id = args.groupIds[index] + if (id) { + baseTop -= getStride(id) + } + } + } + const targetTopById = new Map() let nextTop = baseTop // Why: lineage drag units can be much taller than ordinary cards, so replay // layout with measured heights instead of mapping indexes to old slot tops. for (const id of nextIds) { targetTopById.set(id, nextTop) - const rect = rectById.get(id) - const height = rect ? rect.bottom - rect.top : fallbackHeight - nextTop += height + (gapAfterById.get(id) ?? fallbackGap) + nextTop += getStride(id) } const offsets = new Map() diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 69b8c71c010..4d6b0b7162c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -5,6 +5,7 @@ import { getLineageGroupKey, PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 export const HOST_HEADER_ROW_HEIGHT = 32 +export const WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP = 6 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4 const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 const PENDING_CREATION_ROW_HEIGHT = 56 diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 47e728e1cb6..5d7839956c5 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -87,6 +87,28 @@ describe('buildWorktreeDragPreviewOffsets', () => { ]) }) + it('keeps downward offsets stable after virtualization unmounts the leading rows', () => { + const { offsets, placeholderTop } = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], + draggedIds: ['a'], + draggingWorktreeId: 'a', + draggedPreviewHeight: 50, + dropIndex: 6, + rects: [ + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, + { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } + ] + }) + + expect(Array.from(offsets)).toEqual([ + ['d', -56], + ['e', -56], + ['f', -56] + ]) + expect(placeholderTop).toBe(280) + }) + it('slides intervening rows down while dragging a row up', () => { const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b', 'c'], diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts index 1f44a94f7fa..2a1d9652713 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts @@ -159,20 +159,20 @@ describe('worktree sidebar drag geometry under mid-drag card growth', () => { scrollTop: 0 } - expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, rects: COLLAPSED })).toBeNull() + expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, groupIds: GROUP_IDS })).toBeNull() expect( resolveWorktreeSidebarDropAnchorIndex({ anchor: { beforeWorktreeId: 'c', pointerY: 0, scrollTop: 0 }, - rects: COLLAPSED + groupIds: GROUP_IDS }) ).toBe(2) // A null anchor id means end-of-group, which survives any row count change. expect( resolveWorktreeSidebarDropAnchorIndex({ anchor: { beforeWorktreeId: null, pointerY: 0, scrollTop: 0 }, - rects: COLLAPSED + groupIds: GROUP_IDS }) - ).toBe(COLLAPSED.length) + ).toBe(GROUP_IDS.length) }) it('keeps one live coordinate space across a session refresh', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts index c903868b7ca..691bd7ab67e 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts @@ -70,25 +70,25 @@ export function shouldReevaluateWorktreeSidebarDropAnchor(args: { /** * Resolve a held anchor back to a drop index in the current layout. Returns null - * when the anchored card is gone (deleted, filtered, or unmounted by - * virtualization), so the caller falls back to a fresh geometric decision. + * when the anchored card is gone (deleted or filtered), so the caller falls + * back to a fresh geometric decision. */ export function resolveWorktreeSidebarDropAnchorIndex(args: { anchor: WorktreeSidebarDropAnchor - rects: readonly WorktreeSidebarDragRect[] + groupIds: readonly string[] }): number | null { if (args.anchor.beforeWorktreeId === null) { - return args.rects.length + return args.groupIds.length } - const target = args.rects.find((rect) => rect.worktreeId === args.anchor.beforeWorktreeId) - return target ? target.groupIndex : null + const targetIndex = args.groupIds.indexOf(args.anchor.beforeWorktreeId) + return targetIndex !== -1 ? targetIndex : null } export function getWorktreeSidebarDropAnchorId(args: { - rects: readonly WorktreeSidebarDragRect[] + groupIds: readonly string[] dropIndex: number }): string | null { - return args.rects.find((rect) => rect.groupIndex === args.dropIndex)?.worktreeId ?? null + return args.groupIds[args.dropIndex] ?? null } /** diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index ef6147458a1..e1f1b870287 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -174,6 +174,80 @@ describe('computeWorktreeSidebarDropPreview', () => { expect(new Set(dropIndexes).size).toBe(1) }) + + it('keeps a downward end drop stable when leading rows are virtualized', () => { + const groupIds = ['a', 'b', 'c', 'd', 'e', 'f'] + const mountedRects = [ + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, + { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } + ] + const input = { + pointerY: 320, + containerTop: 0, + scrollTop: 0, + rects: mountedRects, + groupIds, + draggedIds: ['a'], + draggingWorktreeId: 'a', + grab: { offsetY: 25, height: 50 } + } + + const first = computeWorktreeSidebarDropPreview(input)! + const held = computeWorktreeSidebarDropPreview({ + ...input, + anchor: { beforeWorktreeId: first.dropAnchorId, pointerY: 320, scrollTop: 0 } + })! + + for (const preview of [first, held]) { + expect(preview).toMatchObject({ + dropIndex: 6, + dropIndicatorY: 277, + dropAnchorId: null + }) + expect(Array.from(preview.previewOffsetsByWorktreeId)).toEqual([ + ['d', -56], + ['e', -56], + ['f', -56] + ]) + } + }) + + it('uses the full group index when the dragged row is outside the mounted window', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 193, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, + { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], + draggedIds: ['e'], + draggingWorktreeId: 'e', + grab: { offsetY: 25, height: 50 } + }) + + expect(preview?.dropIndex).toBe(3) + }) + + it('preserves the virtual row gap when only one row remains mounted', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 305, + containerTop: 0, + scrollTop: 0, + rects: [{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }], + groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], + draggedIds: ['a'], + draggingWorktreeId: 'a', + fallbackGap: 6, + grab: { offsetY: 25, height: 50 } + }) + + expect(preview).toMatchObject({ dropIndex: 6, dropIndicatorY: 277 }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['f', -56]]) + }) }) describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index e25f2dd2b37..c2d5012a9be 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -178,6 +178,7 @@ export function computeWorktreeSidebarDropPreview(args: { groupIds: readonly string[] draggedIds: readonly string[] draggingWorktreeId?: string | null + fallbackGap?: number // Where the card was grabbed, so the drop follows the card rather than the bare // pointer. Omitted for native HTML5 drags, which have no reliable grab offset. grab?: WorktreeSidebarDragGrab | null @@ -194,10 +195,13 @@ export function computeWorktreeSidebarDropPreview(args: { } const localY = args.pointerY - args.containerTop + args.scrollTop - const activeIndex = args.draggingWorktreeId - ? rects.findIndex((rect) => rect.worktreeId === args.draggingWorktreeId) + const activeGroupIndex = args.draggingWorktreeId + ? args.groupIds.indexOf(args.draggingWorktreeId) : -1 - const activeRect = activeIndex >= 0 ? rects[activeIndex]! : null + const activeRect = + activeGroupIndex >= 0 + ? (rects.find((rect) => rect.worktreeId === args.draggingWorktreeId) ?? null) + : null const referenceY = getWorktreeSidebarDragReferenceY({ localY, grab: args.grab ?? null, @@ -217,15 +221,19 @@ export function computeWorktreeSidebarDropPreview(args: { } const heldIndex = args.anchor - ? resolveWorktreeSidebarDropAnchorIndex({ anchor: args.anchor, rects }) + ? resolveWorktreeSidebarDropAnchorIndex({ anchor: args.anchor, groupIds: args.groupIds }) : null let dropIndex: number if (heldIndex !== null) { dropIndex = heldIndex } else if (boundaryDrop.kind === 'drop') { dropIndex = boundaryDrop.dropIndex - } else if (activeRect) { - dropIndex = getWorktreeSidebarClosestCenterDropIndex({ referenceY, rects, activeIndex }) + } else if (activeGroupIndex >= 0) { + dropIndex = getWorktreeSidebarClosestCenterDropIndex({ + referenceY, + rects, + activeIndex: activeGroupIndex + }) } else { dropIndex = getWorktreeSidebarPointerDropIndex({ referenceY, rects }) } @@ -234,6 +242,8 @@ export function computeWorktreeSidebarDropPreview(args: { groupIds: args.groupIds, draggedIds: args.draggedIds, draggingWorktreeId: args.draggingWorktreeId, + draggedPreviewHeight: args.grab?.height, + fallbackGap: args.fallbackGap, dropIndex, rects }) @@ -246,6 +256,6 @@ export function computeWorktreeSidebarDropPreview(args: { activeRect }), previewOffsetsByWorktreeId: offsets, - dropAnchorId: getWorktreeSidebarDropAnchorId({ rects, dropIndex }) + dropAnchorId: getWorktreeSidebarDropAnchorId({ groupIds: args.groupIds, dropIndex }) } } diff --git a/tests/e2e/worktree-card-downward-drag.spec.ts b/tests/e2e/worktree-card-downward-drag.spec.ts new file mode 100644 index 00000000000..b5cb685a4a6 --- /dev/null +++ b/tests/e2e/worktree-card-downward-drag.spec.ts @@ -0,0 +1,278 @@ +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const SYNTHETIC_COUNT = 60 + +type PreviewOffsetSample = { + worktreeId: string + targetOffset: number + renderedOffset: number +} + +async function seedVirtualizedManualWorktrees(page: Page): Promise<{ + sourceId: string + nextId: string + idPrefix: string +}> { + const repo = await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + return { id: repo.id, path: repo.path } + }) + const now = Date.now() + const syntheticWorktrees = Array.from({ length: SYNTHETIC_COUNT }, (_, index) => { + const suffix = String(index).padStart(2, '0') + const worktreePath = path.join(repo.path, '..', `downward-drag-${suffix}`) + return { + id: `${repo.id}::downward-drag-${suffix}`, + instanceId: `downward-drag-${suffix}`, + repoId: repo.id, + path: worktreePath, + displayName: `Downward drag ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 100_000 - index, + manualOrder: 100_000 - index, + lastActivityAt: now - index, + head: '0000000000000000000000000000000000000000', + branch: `downward-drag-${suffix}`, + isBare: false, + isMainWorktree: false + } + }) + await page.evaluate( + ({ repoId, worktrees }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const seededWorktrees = (state.worktreesByRepo[repoId] ?? []).map((worktree, index) => ({ + ...worktree, + manualOrder: -1_000 - index + })) + + store.setState({ + groupBy: 'none', + sortBy: 'manual', + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + sidebarOpen: true, + worktreesByRepo: { + ...state.worktreesByRepo, + [repoId]: [...worktrees, ...seededWorktrees] + }, + updateWorktreesMeta: async (updatesByWorktreeId) => { + store.setState((current) => ({ + sortEpoch: current.sortEpoch + 1, + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => ({ + ...worktree, + ...updatesByWorktreeId.get(worktree.id) + })) + ]) + ) + })) + } + }) + }, + { repoId: repo.id, worktrees: syntheticWorktrees } + ) + const sourceId = syntheticWorktrees[0]!.id + return { + sourceId, + nextId: syntheticWorktrees[1]!.id, + idPrefix: sourceId.slice(0, -2) + } +} + +async function sampleMountedPreviewOffsets( + page: Page, + sourceId: string +): Promise { + return page.evaluate(async (draggedId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + if (!scroller) { + throw new Error('Worktree sidebar is not available') + } + const samples: PreviewOffsetSample[][] = [] + for (let frame = 0; frame < 20; frame++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + if (document.querySelector(`[data-worktree-drag-id=${JSON.stringify(draggedId)}]`)) { + continue + } + const frameSamples = [ + ...document.querySelectorAll('[data-worktree-virtual-row]') + ].flatMap((row) => { + const rowRect = row.getBoundingClientRect() + const scrollerRect = scroller.getBoundingClientRect() + if (rowRect.bottom <= scrollerRect.top || rowRect.top >= scrollerRect.bottom) { + return [] + } + const worktree = row.querySelector('[data-worktree-drag-id]') + const worktreeId = worktree?.getAttribute('data-worktree-drag-id') + const rowStart = Number(row.dataset.worktreeVirtualRowStart) + const matches = [...row.style.transform.matchAll(/translateY\((-?[\d.]+)px\)/g)] + const targetOffset = Number(matches[1]?.[1] ?? 0) + const renderedTop = new DOMMatrixReadOnly(getComputedStyle(row).transform).m42 + const renderedOffset = renderedTop - rowStart + return worktreeId && + Number.isFinite(rowStart) && + Number.isFinite(targetOffset) && + Number.isFinite(renderedOffset) + ? [{ worktreeId, targetOffset, renderedOffset }] + : [] + }) + if (frameSamples.length > 0) { + samples.push(frameSamples) + } + } + return samples + }, sourceId) +} + +test('dragging a virtualized worktree downward keeps rows stable', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1_000, height: 620 }) + const { sourceId, nextId, idPrefix } = await seedVirtualizedManualWorktrees(orcaPage) + const scroller = orcaPage.locator('[data-worktree-sidebar]') + const source = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(sourceId)}]` + ) + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect(source).toBeVisible() + + const sourceBox = await source.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } + }) + const nextSource = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(nextId)}]` + ) + const sourceStride = await nextSource.evaluate( + (element, sourceTop) => element.getBoundingClientRect().top - sourceTop, + sourceBox.y + ) + expect(Number.isFinite(sourceStride)).toBe(true) + expect(sourceStride).toBeGreaterThan(1) + const scrollerBox = await scroller.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } + }) + + await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await orcaPage.mouse.down() + try { + const edgeX = scrollerBox.x + 2 + const edgeY = scrollerBox.y + scrollerBox.height - 8 + // Keep the pointer in the edge zone while the renderer advances autoscroll. + for (let step = 0; step < 12; step++) { + await orcaPage.mouse.move(edgeX, edgeY, { steps: 2 }) + if ((await source.count()) === 0) { + break + } + await orcaPage.waitForTimeout(100) + } + if ((await source.count()) > 0) { + for (let step = 0; step < 8 && (await source.count()) > 0; step++) { + await scroller.evaluate((element) => { + element.scrollTop = Math.min( + element.scrollHeight, + element.scrollTop + element.clientHeight + ) + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await orcaPage.waitForTimeout(100) + } + } + await expect + .poll(() => source.count(), { + timeout: 10_000, + message: 'Downward autoscroll did not virtualize the dragged source row' + }) + .toBe(0) + + const samples = await sampleMountedPreviewOffsets(orcaPage, sourceId) + expect(samples.length).toBeGreaterThan(0) + const observationsById = new Map() + for (const sample of samples.flat()) { + const distanceFromValidTarget = Math.min( + Math.abs(sample.targetOffset), + Math.abs(sample.targetOffset + sourceStride) + ) + expect(distanceFromValidTarget).toBeLessThanOrEqual(1) + expect(sample.renderedOffset).toBeGreaterThanOrEqual(-sourceStride - 2) + expect(sample.renderedOffset).toBeLessThanOrEqual(2) + const observations = observationsById.get(sample.worktreeId) ?? [] + observations.push(sample) + observationsById.set(sample.worktreeId, observations) + } + + const recurrentRows = [...observationsById.values()].filter( + (observations) => observations.length > 1 + ) + expect(recurrentRows.length).toBeGreaterThan(0) + expect( + recurrentRows.some((observations) => + observations.some((sample) => sample.renderedOffset <= -sourceStride * 0.8) + ) + ).toBe(true) + for (const observations of recurrentRows) { + let renderedReversal = 0 + for (let index = 1; index < observations.length; index++) { + renderedReversal += Math.max( + 0, + observations[index]!.renderedOffset - observations[index - 1]!.renderedOffset + ) + } + expect(renderedReversal).toBeLessThanOrEqual(sourceStride) + } + } finally { + await orcaPage.mouse.up() + } + + await expect(orcaPage.locator('[data-worktree-sidebar-drag-preview="true"]')).toHaveCount(0) + await expect(orcaPage.locator('html')).not.toHaveAttribute( + 'data-worktree-sidebar-pointer-dragging' + ) + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + scroller + .locator('[role="option"]') + .evaluateAll( + (options, prefix) => + options + .map((option) => option.getAttribute('data-worktree-id')) + .find((worktreeId) => worktreeId?.startsWith(prefix)) ?? null, + idPrefix + ), + { message: 'The downward drop did not move the source away from the first slot' } + ) + .toBe(nextId) +}) From 585dd6d3a9c65216e1a5a7cd6b2e82858a5e38a8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:22:48 -0700 Subject: [PATCH 05/25] fix terminal attribution shim removal edge cases (#14187) * fix(terminal): fully retire attribution shim * fix(terminal): harden shim tombstone path lookup --- .../app/h/[hostId]/session/[worktreeId].tsx | 57 ++- .../session/ai-vault-resume-launch.test.ts | 81 ++++- mobile/src/session/ai-vault-resume-launch.ts | 16 +- .../ai-vault-resume-preparation.test.ts | 15 +- ...mobile-terminal-attribution-compat.test.ts | 81 +++++ .../mobile-terminal-attribution-compat.ts | 34 ++ .../src/session/pr-ai-triage-launch.test.ts | 54 ++- mobile/src/session/pr-ai-triage-launch.ts | 27 +- ...se-mobile-diff-review-send-actions.test.ts | 68 ++++ .../use-mobile-diff-review-send-actions.ts | 27 +- mobile/src/transport/rpc-client.ts | 11 +- mobile/src/transport/types.ts | 1 + src/cli/handlers/terminal.test.ts | 64 ++++ src/cli/handlers/terminal.ts | 69 +++- src/cli/index.test.ts | 284 ++++++++++----- src/cli/runtime/client.ts | 1 + src/cli/runtime/transport.test.ts | 42 +++ src/cli/runtime/transport.ts | 1 + src/main/daemon/pty-subprocess.test.ts | 41 ++- src/main/daemon/pty-subprocess.ts | 5 + src/main/index.ts | 4 +- src/main/ipc/pty.test.ts | 43 +++ src/main/ipc/pty.ts | 5 + ...ntime-environment-connectivity-handlers.ts | 4 +- ...runtime-environment-request-connections.ts | 6 +- .../runtime-environment-transport-routing.ts | 12 +- src/main/ipc/runtime-environments.test.ts | 48 ++- src/main/ipc/runtime-environments.ts | 4 +- src/main/persistence.test.ts | 44 +++ src/main/persistence.ts | 28 +- src/main/providers/local-pty-provider.test.ts | 18 +- src/main/providers/local-pty-provider.ts | 3 + src/main/providers/provider-dispatch.test.ts | 2 + .../pty/legacy-terminal-posix-tombstone.ts | 69 ++++ src/main/pty/legacy-terminal-shim-dir.test.ts | 335 +++++++++++++++--- src/main/pty/legacy-terminal-shim-dir.ts | 274 ++++++++++++-- src/main/runtime/rpc/core.ts | 1 + .../rpc/dispatcher-computer-errors.test.ts | 22 +- src/main/runtime/rpc/dispatcher.ts | 21 ++ .../rpc/methods/terminal-split-env.test.ts | 37 ++ src/main/runtime/rpc/methods/terminal.ts | 2 + src/main/runtime/rpc/streaming.test.ts | 22 ++ src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + .../runtime-environment-subscriptions.test.ts | 9 +- .../runtime-environment-subscriptions.ts | 2 + src/relay/pty-handler.test.ts | 52 ++- src/relay/pty-handler.ts | 3 + .../terminal-pane/pty-transport.test.ts | 60 +++- .../remote-runtime-pty-transport.test.ts | 193 ++++++++-- .../remote-runtime-pty-transport.ts | 121 +++++-- ...ch-agent-background-session-remote.test.ts | 5 +- .../lib/runtime-agent-background-create.ts | 26 +- .../worktree-activation-created-agent.test.ts | 74 +++- .../worktree-activation-empty-remote.test.ts | 65 +++- .../abortable-runtime-environment-call.ts | 12 +- .../remote-agent-session-launch.test.ts | 136 ++++--- .../runtime/remote-agent-session-launch.ts | 71 ++-- .../runtime/runtime-environment-authority.ts | 46 +++ ...untime-rpc-client-pairing-revision.test.ts | 60 +++- .../src/runtime/runtime-rpc-client.ts | 6 +- .../runtime-rpc-environment-call.test.ts | 28 ++ .../runtime/runtime-rpc-environment-call.ts | 7 +- .../src/runtime/web-runtime-session.test.ts | 215 ++++++++--- .../src/runtime/web-runtime-session.ts | 102 ++++-- .../legacy-terminal-attribution-env.test.ts | 99 ++++++ src/shared/legacy-terminal-attribution-env.ts | 68 ++++ src/shared/protocol-version.ts | 4 + src/shared/remote-runtime-client.test.ts | 29 +- src/shared/remote-runtime-client.ts | 6 +- src/shared/remote-runtime-memory-limits.ts | 1 + ...remote-runtime-shared-control-admission.ts | 11 +- ...emote-runtime-shared-control-connection.ts | 22 +- .../remote-runtime-shared-control-send.ts | 3 +- ...ntime-shared-control-subscription-start.ts | 5 +- ...ntime-shared-control-subscriptions.test.ts | 34 ++ ...te-runtime-shared-control-subscriptions.ts | 2 + .../remote-runtime-shared-control-types.ts | 1 + src/shared/runtime-rpc-envelope.ts | 2 + 79 files changed, 3001 insertions(+), 566 deletions(-) create mode 100644 mobile/src/session/mobile-terminal-attribution-compat.test.ts create mode 100644 mobile/src/session/mobile-terminal-attribution-compat.ts create mode 100644 src/main/pty/legacy-terminal-posix-tombstone.ts create mode 100644 src/main/runtime/rpc/methods/terminal-split-env.test.ts create mode 100644 src/renderer/src/runtime/runtime-environment-authority.ts create mode 100644 src/renderer/src/runtime/runtime-rpc-environment-call.test.ts create mode 100644 src/shared/legacy-terminal-attribution-env.test.ts create mode 100644 src/shared/legacy-terminal-attribution-env.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cf0f1dd7edb..f0aebf02efc 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -127,6 +127,15 @@ import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal- import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' +import { + addLegacyTerminalAttributionDisableRequest, + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + withLegacyTerminalAttributionDisabledEnv +} from '../../../../../src/shared/legacy-terminal-attribution-env' +import { + assertMobileTerminalAttributionDisableSupported, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS +} from '../../../../src/session/mobile-terminal-attribution-compat' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate' import { @@ -3650,20 +3659,27 @@ export default function SessionScreen() { .slice(2, 10)}` try { - const response = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}`, - afterTabId: activeSessionTabId ?? undefined, - clientMutationId, - ...(options?.startupCommand ? { command: options.startupCommand } : {}), - ...(options?.startupCommandDelivery - ? { startupCommandDelivery: options.startupCommandDelivery } - : {}), - ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), - ...(agent ? { agent } : {}), - activate: false, - select: true, - navigation: 'caller' - }) + const authority = await assertMobileTerminalAttributionDisableSupported(client) + const response = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + afterTabId: activeSessionTabId ?? undefined, + clientMutationId, + ...(options?.startupCommand ? { command: options.startupCommand } : {}), + ...(options?.startupCommandDelivery + ? { startupCommandDelivery: options.startupCommandDelivery } + : {}), + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), + ...(agent ? { agent } : {}), + activate: false, + select: true, + navigation: 'caller' + }, + { ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId } + ) if (response.ok) { const result = (response as RpcSuccess).result as TerminalCreateResult const created = result.tab @@ -3763,10 +3779,17 @@ export default function SessionScreen() { showToast(message, 1800) } } - } catch { - const message = options?.errorToast ?? 'Failed to create terminal' + } catch (error) { + const errorMessage = error instanceof Error ? error.message : null + const message = + errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ? errorMessage + : (options?.errorToast ?? errorMessage ?? 'Failed to create terminal') setCreateError(message) - if (options?.errorToast) { + if ( + options?.errorToast || + errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) { triggerError() showToast(message, 1800) } diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 9efc0e50f1a..3e9af99191c 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -226,6 +226,13 @@ describe('resumeAiVaultSessionInTerminal', () => { it('creates a fresh terminal and sends the command with Enter', async () => { const sendRequest = vi .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } @@ -249,13 +256,20 @@ describe('resumeAiVaultSessionInTerminal', () => { navigation: 'caller' }) ).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' }) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true + }) expect(sendRequest).toHaveBeenNthCalledWith( - 1, + 2, 'session.tabs.createTerminal', { worktree: 'id:worktree-1', - env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, - envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], + env: { + ANTHROPIC_BASE_URL: 'http://localhost:3000', + ORCA_ATTRIBUTION_BYPASS: '1' + }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'], launchConfig: { agentCommand: 'claude', agentArgs: '', @@ -269,10 +283,14 @@ describe('resumeAiVaultSessionInTerminal', () => { }, // Why: a socket drop mid-resume must reject within the request timeout // instead of parking on the reconnect waiter with the spinner pinned. - { timeoutMs: RESUME_RPC_TIMEOUT_MS } + { + timeoutMs: RESUME_RPC_TIMEOUT_MS, + budgetSpansConnect: true, + expectedRuntimeId: 'runtime' + } ) expect(sendRequest).toHaveBeenNthCalledWith( - 2, + 3, 'terminal.send', { terminal: 'pty-1', @@ -284,17 +302,35 @@ describe('resumeAiVaultSessionInTerminal', () => { }) it('throws when terminal creation fails', async () => { - const sendRequest = vi.fn().mockResolvedValueOnce({ - ok: false, - error: { message: 'no terminal' } - }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) + .mockResolvedValueOnce({ + ok: false, + error: { message: 'no terminal' } + }) await expect( resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) ).rejects.toThrow('no terminal') }) it('throws when the created terminal response is malformed', async () => { - const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) + .mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } }) await expect( resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) ).rejects.toThrow('Created terminal response was invalid') @@ -303,6 +339,13 @@ describe('resumeAiVaultSessionInTerminal', () => { it('throws when terminal send fails or is locked', async () => { const failedSend = vi .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } @@ -316,6 +359,13 @@ describe('resumeAiVaultSessionInTerminal', () => { const lockedSend = vi .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } @@ -327,6 +377,17 @@ describe('resumeAiVaultSessionInTerminal', () => { }) ).rejects.toThrow('Terminal input is locked') }) + + it('refuses an old host before creating a resume terminal', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ ok: true, result: { appVersion: '1.4.89' } }) + + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) + ).rejects.toThrow('Update the host and try again') + expect(sendRequest).toHaveBeenCalledTimes(1) + }) }) describe('createMobileAiVaultResumeMutationRegistry', () => { diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index aa8c1bf98a6..6975b6f355d 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -16,6 +16,10 @@ import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-res import type { TuiAgent } from '../../../src/shared/types' import { parseWslUncPath } from '../../../src/shared/wsl-paths' import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../src/shared/legacy-terminal-attribution-env' import type { RpcClient } from '../transport/rpc-client' import { readMobileReviewCreatedTerminal, @@ -23,6 +27,7 @@ import { type MobileReviewTerminalTab } from './mobile-diff-review-rpc' import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target' +import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat' const NODE_PLATFORMS = new Set([ 'aix', @@ -169,12 +174,13 @@ export async function resumeAiVaultSessionInTerminal( worktreeId: string, launch: MobileAiVaultResumeLaunch & { clientMutationId?: string } ): Promise { + const authority = await assertMobileTerminalAttributionDisableSupported(client) const created = await client.sendRequest( 'session.tabs.createTerminal', { worktree: `id:${worktreeId}`, - ...(launch.env ? { env: launch.env } : {}), - ...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}), + env: withLegacyTerminalAttributionDisabledEnv(launch.env), + envToDelete: addLegacyTerminalAttributionDisableRequest(launch.envToDelete), ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}), ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}), @@ -182,7 +188,11 @@ export async function resumeAiVaultSessionInTerminal( select: true, navigation: 'caller' }, - { timeoutMs: RESUME_RPC_TIMEOUT_MS } + { + timeoutMs: RESUME_RPC_TIMEOUT_MS, + budgetSpansConnect: true, + expectedRuntimeId: authority.runtimeId + } ) if (!created.ok) { throw new Error(created.error?.message || 'Failed to create terminal') diff --git a/mobile/src/session/ai-vault-resume-preparation.test.ts b/mobile/src/session/ai-vault-resume-preparation.test.ts index a0eed5515c6..3e64a06a0f8 100644 --- a/mobile/src/session/ai-vault-resume-preparation.test.ts +++ b/mobile/src/session/ai-vault-resume-preparation.test.ts @@ -53,6 +53,14 @@ describe('prepareMobileAiVaultSessionResume', () => { const sendRequest = vi .fn() .mockResolvedValueOnce({ ok: false, error }) + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime-1', + appVersion: '1.4.90', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } @@ -64,9 +72,12 @@ describe('prepareMobileAiVaultSessionResume', () => { await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch) expect(prepared).toBe(legacy) - expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete') + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'] + }) expect(sendRequest).toHaveBeenNthCalledWith( - 3, + 4, 'terminal.send', { terminal: 'pty-1', diff --git a/mobile/src/session/mobile-terminal-attribution-compat.test.ts b/mobile/src/session/mobile-terminal-attribution-compat.test.ts new file mode 100644 index 00000000000..9f0ebf873bd --- /dev/null +++ b/mobile/src/session/mobile-terminal-attribution-compat.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE } from '../../../src/shared/legacy-terminal-attribution-env' +import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat' + +function status(result: unknown) { + return { + id: 'status', + ok: true as const, + result: { runtimeId: 'runtime', ...(result as object) }, + _meta: { runtimeId: 'runtime' } + } +} + +describe('mobile terminal attribution compatibility', () => { + it('refuses hosts before terminal creation environment forwarding', async () => { + const sendRequest = vi.fn().mockResolvedValue( + status({ + capabilities: [ + 'runtime.status.compat.v1', + 'runtime.environments.v1', + 'mobile.tasks.v1', + 'workspace-run-context.v1' + ] + }) + ) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + expect(sendRequest).toHaveBeenCalledWith('status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true + }) + }) + + it('rejects historical hosts without explicit removal capability', async () => { + const sendRequest = vi.fn().mockResolvedValue(status({ appVersion: '1.4.90' })) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + }) + + it('accepts capability-proven current development hosts', async () => { + const sendRequest = vi + .fn() + .mockResolvedValue( + status({ appVersion: '0.0.0-dev', capabilities: ['terminal.attribution-removed.v1'] }) + ) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).resolves.toEqual( + { runtimeId: 'runtime' } + ) + }) + + it('fails closed when host status cannot be verified', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + id: 'status', + ok: false, + error: { code: 'offline', message: 'Host is offline' }, + _meta: { runtimeId: 'runtime' } + }) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + 'Host is offline' + ) + }) + + it('fails closed with an actionable error for malformed host status', async () => { + const sendRequest = vi.fn().mockResolvedValue( + status({ + appVersion: 1.49, + capabilities: 'terminal.attribution-removed.v1' + }) + ) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + 'Update the host and try again' + ) + }) +}) diff --git a/mobile/src/session/mobile-terminal-attribution-compat.ts b/mobile/src/session/mobile-terminal-attribution-compat.ts new file mode 100644 index 00000000000..ceb2d3f694e --- /dev/null +++ b/mobile/src/session/mobile-terminal-attribution-compat.ts @@ -0,0 +1,34 @@ +import { + hostSupportsSessionTabTerminalCreateAttributionDisable, + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE +} from '../../../src/shared/legacy-terminal-attribution-env' +import type { RpcClient } from '../transport/rpc-client' + +export const MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS = 30_000 +export const MOBILE_TERMINAL_CREATE_RPC_OPTIONS = { + timeoutMs: MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS, + budgetSpansConnect: true +} as const + +export type MobileTerminalAttributionAuthority = Readonly<{ runtimeId: string }> + +export async function assertMobileTerminalAttributionDisableSupported( + client: Pick +): Promise { + const response = await client.sendRequest( + 'status.get', + undefined, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS + ) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to verify the workspace host version.') + } + if (!hostSupportsSessionTabTerminalCreateAttributionDisable(response.result)) { + throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE) + } + const runtimeId = Reflect.get(response.result as object, 'runtimeId') + if (typeof runtimeId !== 'string' || !runtimeId) { + throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE) + } + return Object.freeze({ runtimeId }) +} diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index c2273c9046e..fe9271f5e4d 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -12,6 +12,10 @@ function failure(message: string): RpcResponse { const createdTerminal = success({ tab: { type: 'terminal', id: 't1', terminal: 'term-1' } }) const sendAccepted = success({ send: { accepted: true } }) +const safeHost = success({ + runtimeId: 'r', + capabilities: ['terminal.attribution-removed.v1'] +}) function clientReturning(...responses: RpcResponse[]) { const sendRequest = vi.fn(async () => responses[sendRequest.mock.calls.length - 1]) @@ -20,16 +24,27 @@ function clientReturning(...responses: RpcResponse[]) { describe('createTerminalAndSendPrompt', () => { it('creates a terminal then sends the prompt with enter', async () => { - const client = clientReturning(createdTerminal, sendAccepted) + const client = clientReturning(safeHost, createdTerminal, sendAccepted) await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing') - expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', { - worktree: 'id:wt-1', - activate: false, - select: true, - navigation: 'caller' + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true }) - expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', { + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'session.tabs.createTerminal', + { + worktree: 'id:wt-1', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + activate: false, + select: true, + navigation: 'caller' + }, + { timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'r' } + ) + expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'terminal.send', { terminal: 'term-1', text: 'do the thing', enter: true @@ -37,28 +52,41 @@ describe('createTerminalAndSendPrompt', () => { }) it('throws and skips terminal.send when createTerminal fails', async () => { - const client = clientReturning(failure('boom')) + const client = clientReturning(safeHost, failure('boom')) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('boom') - expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenCalledTimes(2) }) it('throws when the created-terminal response is malformed', async () => { - const client = clientReturning(success({ tab: { type: 'terminal' } })) + const client = clientReturning(safeHost, success({ tab: { type: 'terminal' } })) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow( 'Created terminal response was invalid' ) - expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenCalledTimes(2) }) it('throws when terminal.send returns a failure', async () => { - const client = clientReturning(createdTerminal, failure('send failed')) + const client = clientReturning(safeHost, createdTerminal, failure('send failed')) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('send failed') }) it('throws when terminal input is locked', async () => { - const client = clientReturning(createdTerminal, success({ send: { accepted: false } })) + const client = clientReturning( + safeHost, + createdTerminal, + success({ send: { accepted: false } }) + ) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow( 'Terminal input is locked' ) }) + + it('refuses an old host before terminal creation', async () => { + const client = clientReturning(success({ appVersion: '1.4.89' })) + + await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow( + 'Update the host and try again' + ) + expect(client.sendRequest).toHaveBeenCalledTimes(1) + }) }) diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 6d6c0bbc8b2..1f764be1789 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -3,6 +3,14 @@ import { readMobileReviewCreatedTerminal, readMobileReviewTerminalSendAccepted } from './mobile-diff-review-rpc' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../src/shared/legacy-terminal-attribution-env' +import { + assertMobileTerminalAttributionDisableSupported, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS +} from './mobile-terminal-attribution-compat' // Pure launch path for the PR triage actions ("Fix checks with AI" / "Resolve // conflicts with AI"). Reuses the same two RPCs the diff-review send flow uses — @@ -15,12 +23,19 @@ export async function createTerminalAndSendPrompt( worktreeId: string, prompt: string ): Promise { - const created = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}`, - activate: false, - select: true, - navigation: 'caller' - }) + const authority = await assertMobileTerminalAttributionDisableSupported(client) + const created = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + activate: false, + select: true, + navigation: 'caller' + }, + { ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId } + ) if (!created.ok) { throw new Error(created.error?.message || 'Failed to create terminal') } diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index 1d06190948c..9ae2749412b 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -163,6 +163,74 @@ describe('useMobileDiffReviewSendActions', () => { expect(setSendSheet).toHaveBeenCalledWith(null) }) + it('creates an unattributed terminal before sending review notes', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + }, + _meta: { runtimeId: 'runtime' } + }) + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'terminal-1' } }, + _meta: { runtimeId: 'runtime' } + }) + .mockResolvedValueOnce(sendResponse(true)) + await mount({ sendRequest } as unknown as RpcClient) + + await act(async () => { + await actions?.createTerminalAndSend([COMMENT]) + }) + + expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true + }) + expect(sendRequest).toHaveBeenNthCalledWith( + 2, + 'session.tabs.createTerminal', + { + worktree: 'id:wt-1', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + activate: false, + select: true, + navigation: 'caller' + }, + { timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'runtime' } + ) + expect(sendRequest).toHaveBeenNthCalledWith( + 3, + 'terminal.send', + expect.objectContaining({ terminal: 'terminal-1', enter: true }) + ) + }) + + it('refuses to create review terminals on hosts that strip the bypass', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + id: 'status', + ok: true, + result: { appVersion: '1.4.89' }, + _meta: { runtimeId: 'runtime' } + }) + await mount({ sendRequest } as unknown as RpcClient) + + let error: unknown + await act(async () => { + error = await actions?.createTerminalAndSend([COMMENT]).catch((err) => err) + }) + + expect((error as Error).message).toContain('Update the host and try again') + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).not.toHaveBeenCalledWith('terminal.send', expect.anything()) + }) + it('only heals the terminal that was marked', async () => { const sendRequest = vi.fn().mockResolvedValue(sendResponse(true)) await mount({ sendRequest } as unknown as RpcClient) diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index 0867a3884b3..266caaf6879 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -13,6 +13,14 @@ import { } from './mobile-diff-review-rpc' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import type { ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../src/shared/legacy-terminal-attribution-env' +import { + assertMobileTerminalAttributionDisableSupported, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS +} from './mobile-terminal-attribution-compat' type SendActionsInput = { client: RpcClient | null @@ -104,12 +112,19 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { if (!client || connState !== 'connected') { throw new Error('Waiting for desktop...') } - const response = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}`, - activate: false, - select: true, - navigation: 'caller' - }) + const authority = await assertMobileTerminalAttributionDisableSupported(client) + const response = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + activate: false, + select: true, + navigation: 'caller' + }, + { ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId } + ) if (!response.ok) { throw new Error(response.error?.message || 'Failed to create terminal') } diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9b0afe937f3..f316f8c2b76 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -54,6 +54,7 @@ type ConnectWaiter = { export type SendRequestOptions = { timeoutMs?: number + expectedRuntimeId?: string /** Spend `timeoutMs` across connect-wait AND the request instead of giving each * phase its own. Interactive chat writes need it: they run as sequential loops * under one shared budget, so a per-phase clock lets the composer sit `sending` @@ -1013,7 +1014,15 @@ export function connect( } }) - if (!sendEncrypted({ id, deviceToken, method, params })) { + if ( + !sendEncrypted({ + id, + deviceToken, + method, + params, + ...(options?.expectedRuntimeId ? { expectedRuntimeId: options.expectedRuntimeId } : {}) + }) + ) { pending.delete(id) clearTimeout(timeout) reject(new Error('Connection interrupted')) diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 9d96f0561ad..8fe2bdd78f9 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -18,6 +18,7 @@ export type RpcRequest = { deviceToken: string method: string params?: unknown + expectedRuntimeId?: string } export type RpcSuccess = { diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts index 7dadf41c2d3..8c5ff460c77 100644 --- a/src/cli/handlers/terminal.test.ts +++ b/src/cli/handlers/terminal.test.ts @@ -61,3 +61,67 @@ describe('terminal close CLI', () => { expect(help).toContain('durable persistence') }) }) + +describe('terminal split CLI', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sends the old-host attribution bypass through the legacy-compatible env field', async () => { + const call = vi.fn(async (method: string) => + method === 'status.get' + ? { + result: { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + } + } + : { result: { split: { handle: 'term-2', parentHandle: 'term-1' } } } + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal split']({ + flags: new Map([ + ['terminal', 'term-1'], + ['direction', 'horizontal'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(call).toHaveBeenNthCalledWith(1, 'status.get') + expect(call).toHaveBeenNthCalledWith( + 2, + 'terminal.split', + { + terminal: 'term-1', + direction: 'horizontal', + command: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'] + }, + { expectedRuntimeId: 'runtime-1' } + ) + }) + + it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => { + const call = vi.fn().mockResolvedValue({ + result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] } + }) + + await expect( + TERMINAL_HANDLERS['terminal split']({ + flags: new Map([['terminal', 'term-1']]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + ).rejects.toMatchObject({ + code: 'runtime_update_required', + message: expect.stringContaining('Update the host and try again') + }) + expect(call).toHaveBeenCalledTimes(1) + expect(call).not.toHaveBeenCalledWith('terminal.split', expect.anything()) + }) +}) diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 551ab8d95c1..677cdc81034 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -8,7 +8,8 @@ import type { RuntimeTerminalSend, RuntimeTerminalShow, RuntimeTerminalSplit, - RuntimeTerminalWait + RuntimeTerminalWait, + RuntimeStatus } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification' @@ -37,6 +38,14 @@ import { getRequiredWorktreeSelector, getTerminalHandle } from '../selectors' +import { + addLegacyTerminalAttributionDisableRequest, + hostSupportsTerminalCreateAttributionDisable, + hostSupportsTerminalSplitAttributionDisable, + TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + withLegacyTerminalAttributionDisabledEnv +} from '../../shared/legacy-terminal-attribution-env' // Why: terminal wait legitimately needs to outlive the CLI's default RPC // timeout. Even without an explicit server timeout, the client must allow @@ -137,17 +146,30 @@ export const TERMINAL_HANDLERS: Record = { const useRendererBackedInteractiveTerminal = !client.isRemote && shouldUseRendererBackedInteractiveTerminal(command) const focus = flags.get('focus') === true - const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { - worktree: await getBrowserWorktreeSelector(flags, cwd, client), - command, - title: getOptionalStringFlag(flags, 'title'), - // Why: interactive local agent TUIs need the renderer-backed terminal - // path for browser-side features, but CLI creates must stay backgrounded - // unless the caller explicitly asks for focus. - focus, - ...(focus ? { presentation: 'focused' } : {}), - ...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {}) - }) + const status = await client.call('status.get') + if (!hostSupportsTerminalCreateAttributionDisable(status.result)) { + throw new RuntimeClientError( + 'runtime_update_required', + TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + } + const result = await client.call<{ terminal: RuntimeTerminalCreate }>( + 'terminal.create', + { + worktree: await getBrowserWorktreeSelector(flags, cwd, client), + command, + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + title: getOptionalStringFlag(flags, 'title'), + // Why: interactive local agent TUIs need the renderer-backed terminal + // path for browser-side features, but CLI creates must stay backgrounded + // unless the caller explicitly asks for focus. + focus, + ...(focus ? { presentation: 'focused' } : {}), + ...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {}) + }, + { expectedRuntimeId: status.result.runtimeId } + ) printResult(result, json, formatTerminalCreate) }, // `focus` resolves to this canonical path via CommandSpec.aliases before dispatch. @@ -168,11 +190,24 @@ export const TERMINAL_HANDLERS: Record = { ) { throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical') } - const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { - terminal: await getTerminalHandle(flags, cwd, client), - direction: directionFlag, - command: getOptionalStringFlag(flags, 'command') - }) + const status = await client.call('status.get') + if (!hostSupportsTerminalSplitAttributionDisable(status.result)) { + throw new RuntimeClientError( + 'runtime_update_required', + TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + } + const result = await client.call<{ split: RuntimeTerminalSplit }>( + 'terminal.split', + { + terminal: await getTerminalHandle(flags, cwd, client), + direction: directionFlag, + command: getOptionalStringFlag(flags, 'command'), + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined) + }, + { expectedRuntimeId: status.result.runtimeId } + ) printResult(result, json, formatTerminalSplit) } } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 4867aebc29b..2f86ee9583f 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -3112,6 +3112,10 @@ describe('orca cli worktree awareness', () => { it('passes explicit focus through terminal.create', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3136,13 +3140,19 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: undefined, - title: 'RUNNER', - focus: true, - presentation: 'focused' - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'RUNNER', + focus: true, + presentation: 'focused' + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('prints terminal.read fallback screen lines in json mode', async () => { @@ -3184,6 +3194,10 @@ describe('orca cli worktree awareness', () => { it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3209,19 +3223,29 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex', - title: 'Codex', - focus: false, - rendererBacked: true, - activate: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex', + focus: false, + rendererBacked: true, + activate: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3248,20 +3272,30 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex', - title: 'Codex', - focus: true, - presentation: 'focused', - rendererBacked: true, - activate: true - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex', + focus: true, + presentation: 'focused', + rendererBacked: true, + activate: true + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for explicit Codex exec commands', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3287,17 +3321,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex exec summarize', - title: 'Codex exec', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex exec summarize', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex exec', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for Codex exec commands after global options', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3323,17 +3367,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex -m gpt-5 --sandbox workspace-write exec summarize', - title: 'Codex exec', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex -m gpt-5 --sandbox workspace-write exec summarize', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex exec', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for Codex review commands after long options', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3359,17 +3413,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex --model=gpt-5 --sandbox=workspace-write review', - title: 'Codex review', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex --model=gpt-5 --sandbox=workspace-write review', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex review', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for Codex help commands', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3395,17 +3459,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex --help', - title: 'Codex help', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex --help', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex help', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3431,19 +3505,29 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex -m gpt-5 "fix the flaky test"', - title: 'Codex prompt', - focus: false, - rendererBacked: true, - activate: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex -m gpt-5 "fix the flaky test"', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex prompt', + focus: false, + rendererBacked: true, + activate: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3469,19 +3553,29 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'claude', - title: 'Claude', - focus: false, - rendererBacked: true, - activate: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'claude', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Claude', + focus: false, + rendererBacked: true, + activate: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps Claude print commands on the background terminal path', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3507,12 +3601,18 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'claude -p "summarize"', - title: 'Claude print', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'claude -p "summarize"', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Claude print', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('uses the resolved enclosing worktree for other worktree consumers', async () => { @@ -3819,6 +3919,10 @@ describe('orca cli worktree awareness', () => { it('sends explicit remote terminal create worktree selectors unchanged', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3842,12 +3946,18 @@ describe('orca cli worktree awareness', () => { '/tmp/client/repo/src' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'id:repo-1::/srv/orca/feature', - command: undefined, - title: undefined, - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'id:repo-1::/srv/orca/feature', + command: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: undefined, + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('collects and formats memory diagnostics', async () => { @@ -3955,6 +4065,10 @@ describe('orca cli worktree awareness', () => { it('does not force remote Codex terminal creates through a local renderer path', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3982,12 +4096,18 @@ describe('orca cli worktree awareness', () => { '/tmp/client/repo/src' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'id:repo-1::/srv/orca/feature', - command: 'codex', - title: 'Codex', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'id:repo-1::/srv/orca/feature', + command: 'codex', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not resolve implicit remote browser targets from client cwd', async () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 3a8951de554..b6c49ff7c22 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -87,6 +87,7 @@ export class RuntimeClient { } : {} const envelope = { + expectedRuntimeId: options?.expectedRuntimeId, orchestrationCapability: options?.orchestrationCapability, orchestrationContractVersion: method.startsWith('orchestration.') ? ORCHESTRATION_CONTRACT_VERSION diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index 8de35f619e5..7858d6cd131 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -50,6 +50,48 @@ describe('runtime transport timeout validation', () => { // Why: these tests create Unix domain socket servers in temp directories. // Windows does not support Unix domain sockets in the same way. describe.skipIf(process.platform === 'win32')('runtime transport', () => { + it('forwards the expected runtime fence in local requests', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) + const endpoint = join(userDataPath, 'runtime.sock') + let receivedRequest: Record | null = null + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + receivedRequest = JSON.parse(String(data).trim()) as Record + socket.write( + `${JSON.stringify({ + id: receivedRequest.id, + ok: true, + result: { terminal: { handle: 'terminal-1' } }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + + await sendRequest( + { + runtimeId: 'runtime-1', + pid: 123, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }, + 'terminal.create', + { worktree: 'id:worktree-1' }, + 1_000, + { expectedRuntimeId: 'runtime-1' } + ) + + expect(receivedRequest).toMatchObject({ + method: 'terminal.create', + expectedRuntimeId: 'runtime-1' + }) + }) + it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) const endpoint = join(userDataPath, 'runtime.sock') diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 091ca9da0e0..998052dc4b6 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -186,6 +186,7 @@ export async function sendRequest( authToken: metadata.authToken, method, params, + expectedRuntimeId: envelope?.expectedRuntimeId, orchestrationCapability: envelope?.orchestrationCapability, orchestrationContractVersion: envelope?.orchestrationContractVersion, orchestrationRequestId: envelope?.orchestrationRequestId, diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index c7b41d182da..81d77da5720 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -78,6 +78,10 @@ vi.mock('../providers/windows-conpty-process-membership', () => ({ import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess' import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' +import { + LEGACY_TERMINAL_SHIM_ENV_KEYS, + stripLegacyTerminalShimEnv +} from '../pty/legacy-terminal-shim-dir' const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_OPENCODE_CONFIG_DIR', @@ -1275,6 +1279,35 @@ describe('createPtySubprocess', () => { expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined() }) + it('does not inherit legacy attribution state from a pre-upgrade daemon', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const saved = Object.fromEntries( + [...LEGACY_TERMINAL_SHIM_ENV_KEYS, 'PATH'].map((key) => [key, process.env[key]]) + ) + process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1' + process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix' + process.env.PATH = `/tmp/orca-terminal-attribution/posix${delimiter}/usr/bin` + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.PATH).toBe('/usr/bin') + for (const key of LEGACY_TERMINAL_SHIM_ENV_KEYS) { + expect(env[key]).toBeUndefined() + } + }) + it('does not inherit NODE_ENV from the daemon process env', () => { // Why: a dev-mode Orca forks the daemon with NODE_ENV=development; leaking // Orca's build mode into user shells breaks `next build` and Vitest. @@ -1295,7 +1328,9 @@ describe('createPtySubprocess', () => { const env = spawnMock.mock.calls.at(-1)?.[2].env expect(env.NODE_ENV).toBeUndefined() - expect(env.PATH).toBe(process.env.PATH) + const expectedEnv = { PATH: process.env.PATH ?? '' } + stripLegacyTerminalShimEnv(expectedEnv, process.platform) + expect(env.PATH).toBe(expectedEnv.PATH) }) it('keeps an explicitly requested NODE_ENV for daemon PTY shells', () => { @@ -2207,6 +2242,8 @@ describe('createPtySubprocess', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const expectedEnv = { PATH: process.env.PATH ?? '' } + stripLegacyTerminalShimEnv(expectedEnv, 'win32') Object.defineProperty(process, 'platform', { value: 'win32' }) try { @@ -2218,7 +2255,7 @@ describe('createPtySubprocess', () => { } const env = spawnMock.mock.calls.at(-1)![2].env - expect(env.PATH).toBe(process.env.PATH) + expect(env.PATH).toBe(expectedEnv.PATH) }) it('preserves a duplicated path block supplied by main', () => { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 67c4319cd70..a6eade9f621 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -35,6 +35,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home- import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' @@ -629,6 +630,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links. FORCE_HYPERLINK: '1' } as Record + // Why: an older client may not ask a newly upgraded daemon to delete inherited shim state. + stripLegacyTerminalShimEnv(env, process.platform) composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) deleteRequestedDaemonEnvKeys(env, opts.envToDelete) if (opts.env?.TERM) { @@ -834,6 +837,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined promoteAgentTeamsShimPath(env, requestedPath) + // Why: raw requested PATH promotion runs after the inherited-env scrub. + stripLegacyTerminalShimEnv(env, process.platform) // Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix. ensureNodePtySpawnHelperExecutable() diff --git a/src/main/index.ts b/src/main/index.ts index a2a3998c85c..b4e0e1b5ae4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -185,7 +185,7 @@ import { logStartupMilestone } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' -import { removeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' +import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' import { createServeDesktopActivationGate, @@ -2172,7 +2172,7 @@ void app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) // Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow. - removeLegacyTerminalShimDir(app.getPath('userData')) + neutralizeLegacyTerminalShimDir(app.getPath('userData')) const windowsShellPathHydration = createWindowsShellPathHydration() configureWindowsHostGitEnvironmentReadiness( process.platform === 'win32' ? windowsShellPathHydration.whenReady : null diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e89a4c21806..ae9166148af 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -248,6 +248,7 @@ import { type PrepareCodexSessionResume } from './pty' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' import { @@ -4399,6 +4400,16 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('asks surviving pre-upgrade daemons to delete legacy attribution env', async () => { + const spawnOptions = await daemonSpawnAndGetOptions({}) + + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS]) + ) + expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT') + expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH') + }) + it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => { type RuntimeSpawnController = { spawn(args: { @@ -4436,6 +4447,38 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('asks surviving pre-upgrade daemons to delete legacy attribution env for runtime PTYs', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS]) + ) + expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT') + expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH') + }) + it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => { // Why: the runtime controller is the `orca` CLI / automation spawn path and // assembles envToDelete separately from the renderer's pty:spawn handler; diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ddc829d2d13..67c0cb04edc 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -113,6 +113,7 @@ import { import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim' import { isLegacyTerminalShimPathEntry, + LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS, stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' import { registerPty, unregisterPty } from '../memory/pty-registry' @@ -4715,6 +4716,8 @@ export function registerPtyHandlers( spawnOptions.envToDelete = mergePtyEnvDeletions( authEnvToDelete, args.envToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. getInheritedClaudeSessionStampEnvKeysToDelete(env) @@ -6354,6 +6357,8 @@ export function registerPtyHandlers( envToDelete, args.envToDelete ?? [], agentTeamsEnvToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 9d3732f886b..2c4d1462540 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -183,6 +183,7 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string } ): Promise> => { const environment = resolveEnvironment(getUserDataPath(), args.selector) @@ -197,7 +198,8 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { args.method, args.params, args.timeoutMs, - args.expectedEnvironmentPairingRevision + args.expectedEnvironmentPairingRevision, + args.expectedRuntimeId ? { expectedRuntimeId: args.expectedRuntimeId } : undefined ) } catch (error) { const failure = runtimeEnvironmentCallFailure(environment, args.method, error) diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 58fb95dd6e1..c9782d61772 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -77,13 +77,15 @@ export function subscribeRemoteRuntimeSharedControlRequest( onBinary?: (bytes: Uint8Array) => void onError: (error: { code: string; message: string }) => void onClose?: () => void - } + }, + envelope?: RuntimeOrchestrationEnvelope ): Promise { return getSharedControlConnection(environmentId, pairing).subscribe( method, params, timeoutMs, - callbacks + callbacks, + envelope?.expectedRuntimeId ) } diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 884b0b2874a..0792a1ea009 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -193,11 +193,14 @@ export async function subscribeRuntimeEnvironment( | { type: 'close' } ) => void onClose: () => void - } + }, + expectedRuntimeId?: string ): Promise { const environment = resolveEnvironment(userDataPath, selector) const pairing = getPreferredPairingOffer(environment) const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + const envelope: RuntimeOrchestrationEnvelope | undefined = + expectedRuntimeId === undefined ? undefined : { expectedRuntimeId } let markedUsed = false const markUsedOnce = (runtimeId: string): void => { if (markedUsed) { @@ -240,7 +243,8 @@ export async function subscribeRuntimeEnvironment( method, params, effectiveTimeoutMs, - callbacksWithMarkUsed + callbacksWithMarkUsed, + envelope ) } return await subscribeRemoteRuntimeRequest( @@ -248,7 +252,9 @@ export async function subscribeRuntimeEnvironment( method, params, effectiveTimeoutMs, - callbacksWithMarkUsed + callbacksWithMarkUsed, + undefined, + envelope ) } catch (error) { if (error instanceof Error) { diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 9ca8da98600..b351b88681a 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -1094,7 +1094,13 @@ describe('registerRuntimeEnvironmentHandlers', () => { await add(null, { name: 'desk', pairingCode: pairingCode() }) const subscribe = handler< - { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { + selector: string + method: string + params?: unknown + subscriptionId?: string + expectedRuntimeId?: string + }, { subscriptionId: string; requestId: string } >('runtimeEnvironments:subscribe') await subscribe( @@ -1107,7 +1113,12 @@ describe('registerRuntimeEnvironmentHandlers', () => { removeListener: vi.fn() } }, - { selector: 'desk', method: 'browser.screencast', params: { pageId: 'page-1' } } + { + selector: 'desk', + method: 'browser.screencast', + params: { pageId: 'page-1' }, + expectedRuntimeId: 'runtime-1' + } ) await subscribe( { @@ -1127,14 +1138,18 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'browser.screencast', { pageId: 'page-1' }, 15_000, - expect.any(Object) + expect.any(Object), + undefined, + { expectedRuntimeId: 'runtime-1' } ) expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith( expect.any(Object), 'terminal.multiplex', { client: { id: 'client-1' } }, 15_000, - expect.any(Object) + expect.any(Object), + undefined, + undefined ) expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled() }) @@ -1163,7 +1178,13 @@ describe('registerRuntimeEnvironmentHandlers', () => { await add(null, { name: 'desk', pairingCode: pairingCode() }) const subscribe = handler< - { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { + selector: string + method: string + params?: unknown + subscriptionId?: string + expectedRuntimeId?: string + }, { subscriptionId: string; requestId: string } >('runtimeEnvironments:subscribe') await expect( @@ -1177,7 +1198,11 @@ describe('registerRuntimeEnvironmentHandlers', () => { removeListener: vi.fn() } }, - { selector: 'desk', method: 'session.tabs.subscribeAll' } + { + selector: 'desk', + method: 'session.tabs.subscribeAll', + expectedRuntimeId: 'runtime-1' + } ) ).resolves.toMatchObject({ requestId: 'tabs-shared' }) @@ -1187,7 +1212,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'session.tabs.subscribeAll', undefined, 15_000, - expect.any(Object) + expect.any(Object), + { expectedRuntimeId: 'runtime-1' } ) expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled() }) @@ -1315,7 +1341,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'session.tabs.subscribeAll', undefined, 15_000, - expect.any(Object) + expect.any(Object), + undefined, + undefined ) expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled() }) @@ -1659,7 +1687,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'terminal.subscribe', { terminal: 't1' }, 25, - expect.any(Object) + expect.any(Object), + undefined, + undefined ) expect(sent).toEqual([ expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }), diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index e31f8569dec..3ddf4e2aa6c 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -90,6 +90,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { timeoutMs?: number subscriptionId?: string expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string } ): Promise<{ subscriptionId: string; requestId: string }> => { const subscriptionId = @@ -180,7 +181,8 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { retained?.removeDestroyedListener() remoteRuntimeSubscriptions.delete(subscriptionId) } - } + }, + args.expectedRuntimeId ) } catch (error) { removeDestroyedListener() diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 06ee8a9d6a0..b0a1ee2ac92 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5659,6 +5659,50 @@ describe('Store', () => { expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) + it('retires the persisted GitHub attribution setting without dropping unknown settings', async () => { + const settledStore = await createStore() + settledStore.flush() + const settled = readDataFile() as { settings: Record } + settled.settings.enableGitHubAttribution = true + settled.settings.futureSetting = { enabled: true } + writeDataFile(settled) + + vi.useFakeTimers() + try { + const store = await createStore() + + expect(store.getSettings()).not.toHaveProperty('enableGitHubAttribution') + expect(store.getSettings()).toHaveProperty('futureSetting', { enabled: true }) + vi.advanceTimersByTime(5_000) + await store.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution') + expect(persisted.settings).toHaveProperty('futureSetting', { enabled: true }) + }) + + it('ignores retired GitHub attribution updates and strips stale in-memory values on save', async () => { + const store = await createStore() + const listener = vi.fn() + store.onSettingsChanged(listener) + + const updated = store.updateSettings({ enableGitHubAttribution: true } as never, { + notifyListeners: true + }) + + expect(updated).not.toHaveProperty('enableGitHubAttribution') + expect(listener).not.toHaveBeenCalled() + const settings = store.getSettings() as GlobalSettings & Record + settings.enableGitHubAttribution = false + settings.futureSetting = 'kept' + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution') + expect(persisted.settings?.futureSetting).toBe('kept') + }) + it('normalizes terminal cursor style before persistence and listener broadcasts', async () => { const store = await createStore() store.updateSettings({ terminalCursorStyle: 'underline' }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b62b25f7977..fbb4a2089aa 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -651,6 +651,11 @@ type LegacyTerminalScrollbackSettings = { terminalScrollbackBytes?: unknown } +type RetiredGlobalSettings = { + terminalScrollbackBytes?: unknown + enableGitHubAttribution?: unknown +} + const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3 function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings { @@ -659,12 +664,16 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal : {} } -function stripLegacyTerminalScrollbackBytes( +function stripRetiredGlobalSettings( settings: Partial | undefined ): Partial { - const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? - {}) as Partial & { terminalScrollbackBytes?: unknown } + const { + terminalScrollbackBytes: _legacyScrollbackBytes, + enableGitHubAttribution: _legacyGitHubAttribution, + ...rest + } = (settings ?? {}) as Partial & RetiredGlobalSettings void _legacyScrollbackBytes + void _legacyGitHubAttribution return rest } @@ -3149,6 +3158,13 @@ export class Store { if (migratedTerminalScrollback.needsSave) { this.loadNeedsSave = true } + if ( + parsed.settings && + typeof parsed.settings === 'object' && + Object.hasOwn(parsed.settings, 'enableGitHubAttribution') + ) { + this.loadNeedsSave = true + } const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( parsed.settings ) @@ -3421,7 +3437,7 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripLegacyTerminalScrollbackBytes(parsed.settings), + ...stripRetiredGlobalSettings(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -4013,7 +4029,7 @@ export class Store { ) })), settings: { - ...this.state.settings, + ...stripRetiredGlobalSettings(this.state.settings), opencodeSessionCookie: encryptToSentinel( PROTECTED_SECRET_SLOT.opencodeSessionCookie, this.state.settings.opencodeSessionCookie @@ -5819,7 +5835,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + const sanitizedUpdates = stripRetiredGlobalSettings(updates) if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 178b60d6caa..eedcb87abfc 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { delimiter } from 'node:path' import type * as MacosTccLoginShell from './macos-tcc-login-shell' +import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' const { existsSyncMock, @@ -576,7 +577,9 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[2].env.NODE_ENV).toBeUndefined() - expect(spawnCall[2].env.PATH).toBe(process.env.PATH) + const expectedEnv = { PATH: process.env.PATH ?? '' } + stripLegacyTerminalShimEnv(expectedEnv, process.platform) + expect(spawnCall[2].env.PATH).toBe(expectedEnv.PATH) }) it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => { @@ -752,6 +755,19 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.ORCA_STALE_TEST_ENV).toBeUndefined() }) + it('does not re-promote a legacy attribution path for Agent Teams', async () => { + await provider.spawn({ + cols: 80, + rows: 24, + env: { + PATH: '/tmp/orca-terminal-attribution/posix:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + } + }) + + expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/usr/bin') + }) + it('drops stale inherited Git config indices behind a smaller explicit count', async () => { const keys = [ 'GIT_CONFIG_COUNT', diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3026ded42a3..b4af3c907fb 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -36,6 +36,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' import { SessionNotFoundError } from '../daemon/daemon-errors' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' @@ -829,6 +830,8 @@ export class LocalPtyProvider implements IPtyProvider { finalEnv, requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined ) + // Why: raw requested PATH promotion runs after the host-env scrub. + stripLegacyTerminalShimEnv(finalEnv, process.platform) // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). const worktreeId = args.worktreeId diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 57d7e3f815c..2ba058ffd9c 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -62,6 +62,7 @@ import { unregisterSshPtyProvider } from '../ipc/pty' import type { IPtyProvider } from './types' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir' describe('PTY provider dispatch', () => { const handlers = new Map unknown>() @@ -147,6 +148,7 @@ describe('PTY provider dispatch', () => { const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0] expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual( [ + ...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS, 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID' diff --git a/src/main/pty/legacy-terminal-posix-tombstone.ts b/src/main/pty/legacy-terminal-posix-tombstone.ts new file mode 100644 index 00000000000..4520b9e4708 --- /dev/null +++ b/src/main/pty/legacy-terminal-posix-tombstone.ts @@ -0,0 +1,69 @@ +const SHELL_DOLLAR = '$' + +const POSIX_TOMBSTONE = String.raw`#!/usr/bin/env bash +set -u + +command_name="__ORCA_COMMAND__" +wrapper_dir="$(cd -- "$(dirname -- "${SHELL_DOLLAR}{BASH_SOURCE[0]}")" && pwd)" +legacy_wrapper_dir="${SHELL_DOLLAR}{ORCA_ATTRIBUTION_SHIM_DIR:-}" +cleaned_path="${SHELL_DOLLAR}{PATH:-}" + +filter_path() { + local legacy_target="$legacy_wrapper_dir" + while [[ "$legacy_target" != "/" && "$legacy_target" == */ ]]; do + legacy_target="${SHELL_DOLLAR}{legacy_target%/}" + done + local remaining="$cleaned_path" + local filtered_path="" + local separator="" + path_entry_kept=0 + local entry normalized candidate has_more + while true; do + if [[ "$remaining" == *:* ]]; then + entry="${SHELL_DOLLAR}{remaining%%:*}" + remaining="${SHELL_DOLLAR}{remaining#*:}" + has_more=1 + else + entry="$remaining" + has_more=0 + fi + normalized="$entry" + while [[ "$normalized" != "/" && "$normalized" == */ ]]; do + normalized="${SHELL_DOLLAR}{normalized%/}" + done + candidate="${SHELL_DOLLAR}{entry:-.}" + if [[ -n "$legacy_target" && "$normalized" == "$legacy_target" ]]; then + : + elif [[ "$candidate" -ef "$wrapper_dir" ]]; then + : + else + filtered_path+="$separator$entry" + separator=":" + path_entry_kept=1 + fi + [[ "$has_more" == 1 ]] || break + done + cleaned_path="$filtered_path" +} + +filter_path +unset ORCA_ENABLE_GIT_ATTRIBUTION ORCA_GIT_COMMIT_TRAILER ORCA_GH_PR_FOOTER +unset ORCA_GH_ISSUE_FOOTER ORCA_ATTRIBUTION_SHIM_DIR ORCA_REAL_GIT ORCA_REAL_GH ORCA_ATTRIBUTION_BYPASS + +real_command="" +if [[ "$path_entry_kept" == 1 ]]; then + real_command="$(PATH="$cleaned_path" type -P "$command_name" || true)" +fi +if [[ -n "$real_command" && "$real_command" -ef "${SHELL_DOLLAR}{BASH_SOURCE[0]}" ]]; then + real_command="" +fi +if [[ -z "$real_command" ]]; then + printf 'Orca compatibility wrapper could not locate %s on PATH.\n' "$command_name" >&2 + exit 127 +fi +PATH="$cleaned_path" exec "$real_command" "$@" +` + +export function renderLegacyTerminalPosixTombstone(command: 'git' | 'gh'): string { + return POSIX_TOMBSTONE.replaceAll('__ORCA_COMMAND__', command) +} diff --git a/src/main/pty/legacy-terminal-shim-dir.test.ts b/src/main/pty/legacy-terminal-shim-dir.test.ts index f7115f74613..32e59800266 100644 --- a/src/main/pty/legacy-terminal-shim-dir.test.ts +++ b/src/main/pty/legacy-terminal-shim-dir.test.ts @@ -1,64 +1,209 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - __resetLegacyTerminalShimRemovalForTests, - removeLegacyTerminalShimDir, + __resetLegacyTerminalShimNeutralizationForTests, + neutralizeLegacyTerminalShimDir, stripLegacyTerminalShimEnv } from './legacy-terminal-shim-dir' -// Why: the failure case is produced with a read-only parent dir, which Windows ignores and root bypasses. +const itOnPosix = process.platform === 'win32' ? it.skip : it +// Why: the failure case uses directory permissions, which Windows ignores and root bypasses. const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it -describe('legacy terminal shim removal', () => { +describe('legacy terminal shim neutralization', () => { + const tempRoots: string[] = [] + + const makeUserDataDir = (): string => { + const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + tempRoots.push(userData) + return userData + } + beforeEach(() => { - __resetLegacyTerminalShimRemovalForTests() + __resetLegacyTerminalShimNeutralizationForTests() }) - it('deletes the orphaned wrapper directory left by older installs', () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) - const shimDir = join(userData, 'orca-terminal-attribution', 'posix') - mkdirSync(shimDir, { recursive: true }) - writeFileSync(join(shimDir, 'git'), '#!/usr/bin/env bash\n') - - removeLegacyTerminalShimDir(userData) - - expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false) - expect(existsSync(userData)).toBe(true) + afterEach(() => { + vi.useRealTimers() + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }) + } }) - it('does not throw when nothing is left to remove', () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) - expect(() => removeLegacyTerminalShimDir(userData)).not.toThrow() - }) - - itOnPosixNonRoot('stays retryable when the removal fails, and latches once it succeeds', () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-')) + it('atomically replaces the legacy command paths with executable tombstones', () => { + const userData = makeUserDataDir() const legacyRoot = join(userData, 'orca-terminal-attribution') - mkdirSync(join(legacyRoot, 'posix'), { recursive: true }) - writeFileSync(join(legacyRoot, 'posix', 'git'), '#!/usr/bin/env bash\n') - // Why: a read-only parent makes the unlink fail the way a locked Windows wrapper does. - chmodSync(userData, 0o500) + const posixDir = join(legacyRoot, 'posix') + const win32Dir = join(legacyRoot, 'win32') + mkdirSync(posixDir, { recursive: true }) + mkdirSync(win32Dir, { recursive: true }) + writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper') + writeFileSync(join(win32Dir, 'gh.cmd'), 'legacy attribution wrapper') + + neutralizeLegacyTerminalShimDir(userData) + + for (const path of [ + join(posixDir, 'git'), + join(posixDir, 'gh'), + join(win32Dir, 'git.cmd'), + join(win32Dir, 'gh.cmd') + ]) { + expect(existsSync(path)).toBe(true) + expect(readFileSync(path, 'utf8')).not.toContain('Co-authored-by: Orca') + if (process.platform !== 'win32') { + expect(statSync(path).mode & 0o111).not.toBe(0) + } + } + expect(readFileSync(join(legacyRoot, 'VERSION'), 'utf8')).toBe('7\n') + }) + + it('rejects stale Windows real-command paths inside the wrapper directory', () => { + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + + neutralizeLegacyTerminalShimDir(userData) + + const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8') + expect(cmd).toContain( + 'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="' + ) + const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8') + expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase') + expect(powershell).toContain('$realCommand = $null') + expect(powershell.indexOf('[StringComparison]::OrdinalIgnoreCase')).toBeLessThan( + powershell.indexOf('Test-Path -LiteralPath $realCommand') + ) + }) + + it('removes every Windows PATH occurrence of both captured wrapper directories', () => { + const userData = makeUserDataDir() + const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') + + neutralizeLegacyTerminalShimDir(userData) + + const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8') + const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"' + expect(cmd.indexOf(cmdCapture)).toBeLessThan(cmd.indexOf('set "ORCA_ATTRIBUTION_SHIM_DIR="')) + expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"') + expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b') + expect(cmd).toContain( + 'if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\\" exit /b' + ) + + const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8') + expect(powershell.indexOf('$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR')).toBeLessThan( + powershell.indexOf('Remove-Item "Env:$_"') + ) + expect(powershell).toContain('$wrapperDirs = @($wrapperDir, $legacyWrapperDir)') + expect(powershell).toContain("$env:PATH = (($env:PATH -split ';') | Where-Object {") + expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase') + }) + + it('does not throw when the legacy directory is absent', () => { + const userData = makeUserDataDir() + + expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow() + expect(existsSync(join(userData, 'orca-terminal-attribution', 'posix', 'git'))).toBe(true) + }) + + itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => { + vi.useFakeTimers() + const userData = makeUserDataDir() + const posixDir = join(userData, 'orca-terminal-attribution', 'posix') + const gitWrapper = join(posixDir, 'git') + mkdirSync(posixDir, { recursive: true }) + writeFileSync(gitWrapper, 'legacy attribution wrapper') + chmodSync(posixDir, 0o500) try { - removeLegacyTerminalShimDir(userData) - expect(existsSync(legacyRoot)).toBe(true) + neutralizeLegacyTerminalShimDir(userData) + expect(readFileSync(gitWrapper, 'utf8')).toBe('legacy attribution wrapper') } finally { - chmodSync(userData, 0o700) + chmodSync(posixDir, 0o700) } - // The failed attempt must not have latched the guard. - removeLegacyTerminalShimDir(userData) - expect(existsSync(legacyRoot)).toBe(false) + try { + await vi.advanceTimersByTimeAsync(1_000) + expect(readFileSync(gitWrapper, 'utf8')).not.toContain('legacy attribution wrapper') - // A success latches it: a directory recreated afterwards is left alone. - mkdirSync(legacyRoot, { recursive: true }) - removeLegacyTerminalShimDir(userData) - expect(existsSync(legacyRoot)).toBe(true) + writeFileSync(gitWrapper, 'recreated after success') + neutralizeLegacyTerminalShimDir(userData) + expect(readFileSync(gitWrapper, 'utf8')).toBe('recreated after success') + } finally { + vi.useRealTimers() + } + }) + + itOnPosix('keeps a real Bash command hash working with trailing PATH separators', async () => { + const userData = makeUserDataDir() + const shimDir = join(userData, 'orca-terminal-attribution', 'posix') + const shimGit = join(shimDir, 'git') + const realBin = join(userData, 'real-bin') + const realGit = join(realBin, 'git') + mkdirSync(shimDir, { recursive: true }) + mkdirSync(realBin, { recursive: true }) + writeFileSync(shimGit, '#!/usr/bin/env bash\nexit 99\n', { mode: 0o755 }) + writeFileSync( + realGit, + "#!/usr/bin/env bash\nprintf 'arg=<%s>\\n' \"$@\"\ncat\nprintf 'fixture stderr\\n' >&2\nexit 23\n", + { mode: 0o755 } + ) + const child = spawn('bash', ['--noprofile', '--norc'], { + cwd: shimDir, + env: { + ...process.env, + PATH: `${shimDir}//::${realBin}:${process.env.PATH ?? ''}`, + ORCA_ENABLE_GIT_ATTRIBUTION: '1', + ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca ', + ORCA_ATTRIBUTION_SHIM_DIR: '' + }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + const ready = waitForOutput(child.stdout, '__ORCA_HASH_READY__\n') + child.stdin.write(`hash -p ${quoteBash(shimGit)} git\nprintf '__ORCA_HASH_READY__\\n'\n`) + + try { + await ready + } catch (error) { + child.kill('SIGKILL') + throw error + } + neutralizeLegacyTerminalShimDir(userData) + const closed = waitForChildClose(child, 2_000) + child.stdin.end("printf 'stdin payload\\n' | git commit -m 'subject with spaces'; exit $?\n") + + try { + expect(await closed).toBe(23) + } finally { + child.kill('SIGKILL') + } + expect(stdout).toContain('arg=\narg=<-m>\narg=\nstdin payload\n') + expect(stdout).not.toContain('Co-authored-by: Orca') + expect(stderr).toBe('fixture stderr\n') }) it('drops inherited shim env and its PATH entry without touching real entries', () => { - // Why: a daemon predating the removal reseeds these from its own process.env. const env: Record = { PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`, ORCA_ENABLE_GIT_ATTRIBUTION: '1', @@ -73,20 +218,50 @@ describe('legacy terminal shim removal', () => { stripLegacyTerminalShimEnv(env, 'linux') - expect(env.PATH).toBe('/usr/local/bin:/usr/bin') - expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() - expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined() - expect(env.ORCA_GH_PR_FOOTER).toBeUndefined() - expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined() - expect(env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() - expect(env.ORCA_REAL_GIT).toBeUndefined() - expect(env.ORCA_REAL_GH).toBeUndefined() - expect(env.HOME).toBe('/home/u') + expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' }) }) - it('strips the Windows shim entry on the inherited Path spelling', () => { + it('uses the captured POSIX shim directory literally when it contains a colon', () => { + const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix' const env: Record = { - Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32` + PATH: `/usr/local/bin:${shimDir}:/usr/bin`, + ORCA_ATTRIBUTION_SHIM_DIR: shimDir + } + + stripLegacyTerminalShimEnv(env, 'linux') + + expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin' }) + }) + + it('treats legacy Windows environment keys case-insensitively', () => { + const shimDir = 'C:\\Users\\orca;user\\orca-terminal-attribution\\win32' + const env: Record = { + Path: `${shimDir};C:\\Windows\\System32`, + orca_attribution_shim_dir: shimDir, + Orca_Enable_Git_Attribution: '1', + orca_real_git: 'C:\\Git\\git.exe' + } + + stripLegacyTerminalShimEnv(env, 'win32') + + expect(env).toEqual({ Path: 'C:\\Windows\\System32' }) + }) + + it('strips legacy entries from every Windows PATH spelling', () => { + const env: Record = { + PATH: 'C:\\Orca\\orca-terminal-attribution\\win32', + Path: 'C:\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32' + } + + stripLegacyTerminalShimEnv(env, 'win32') + + expect(env.PATH).toBeUndefined() + expect(env.Path).toBe('C:\\Windows\\System32') + }) + + it('matches a re-cased Windows shim path', () => { + const env: Record = { + Path: 'C:\\Orca\\Orca-Terminal-Attribution\\Win32;C:\\Windows\\System32' } stripLegacyTerminalShimEnv(env, 'win32') @@ -94,25 +269,26 @@ describe('legacy terminal shim removal', () => { expect(env.Path).toBe('C:\\Windows\\System32') }) - it('matches a re-cased shim entry on case-insensitive filesystems', () => { - const env: Record = { - Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\Orca-Terminal-Attribution\\win32;C:\\Windows\\System32` - } + it('preserves explicit empty PATH values', () => { + const windowsEnv: Record = { PATH: '', Path: 'C:\\Windows' } + const posixEnv: Record = { PATH: '' } - stripLegacyTerminalShimEnv(env, 'win32') + stripLegacyTerminalShimEnv(windowsEnv, 'win32') + stripLegacyTerminalShimEnv(posixEnv, 'linux') - expect(env.Path).toBe('C:\\Windows\\System32') + expect(windowsEnv).toEqual({ PATH: '', Path: 'C:\\Windows' }) + expect(posixEnv).toEqual({ PATH: '' }) }) it('keeps neighbouring directories that merely share the name prefix', () => { const env: Record = { - PATH: '/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin' + PATH: '/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin' } stripLegacyTerminalShimEnv(env, 'linux') expect(env.PATH).toBe( - '/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin' + '/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin' ) }) @@ -122,3 +298,46 @@ describe('legacy terminal shim removal', () => { expect(env.PATH).toBe('/usr/local/bin:/usr/bin') }) }) + +function quoteBash(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise { + return new Promise((resolve, reject) => { + let output = '' + const onData = (chunk: string | Buffer): void => { + output += chunk.toString() + if (output.includes(marker)) { + cleanup() + resolve() + } + } + const onEnd = (): void => { + cleanup() + reject(new Error(`Bash exited before emitting ${marker}`)) + } + const cleanup = (): void => { + stream.off('data', onData) + stream.off('end', onEnd) + } + stream.on('data', onData) + stream.on('end', onEnd) + }) +} + +function waitForChildClose( + child: ReturnType, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`Bash did not exit within ${timeoutMs}ms`)) + }, timeoutMs) + child.once('close', (exitCode) => { + clearTimeout(timeout) + resolve(exitCode) + }) + }) +} diff --git a/src/main/pty/legacy-terminal-shim-dir.ts b/src/main/pty/legacy-terminal-shim-dir.ts index d46eb7c6516..0d64ebaed05 100644 --- a/src/main/pty/legacy-terminal-shim-dir.ts +++ b/src/main/pty/legacy-terminal-shim-dir.ts @@ -1,71 +1,269 @@ -import { rmSync } from 'node:fs' +import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { resolvePathEnvKey } from './windows-environment-path' +import { + LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY, + LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY +} from '../../shared/legacy-terminal-attribution-env' +import { renderLegacyTerminalPosixTombstone } from './legacy-terminal-posix-tombstone' const LEGACY_SHIM_ROOT_DIR = 'orca-terminal-attribution' -const LEGACY_SHIM_ENV_KEYS = [ +const LEGACY_SHIM_VERSION = '7' +const NEUTRALIZATION_RETRY_DELAYS_MS = [1_000, 5_000, 15_000, 30_000] +export const LEGACY_TERMINAL_SHIM_ENV_KEYS = [ 'ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_GIT_COMMIT_TRAILER', 'ORCA_GH_PR_FOOTER', 'ORCA_GH_ISSUE_FOOTER', 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_REAL_GIT', - 'ORCA_REAL_GH' + 'ORCA_REAL_GH', + LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY +] as const +export const LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS = [ + LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY ] as const -let removed = false +const WIN32_PASSTHROUGH_WRAPPER = String.raw`@echo off +setlocal +set "orca_real=%ORCA_REAL___ORCA_UPPER_COMMAND__%" +set "orca_wrapper_dir=%~dp0" +set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%" +set "orca_clean_path=" +for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P" +set "PATH=%orca_clean_path%" +set "ORCA_ENABLE_GIT_ATTRIBUTION=" +set "ORCA_GIT_COMMIT_TRAILER=" +set "ORCA_GH_PR_FOOTER=" +set "ORCA_GH_ISSUE_FOOTER=" +set "ORCA_ATTRIBUTION_SHIM_DIR=" +set "ORCA_ATTRIBUTION_BYPASS=" +set "ORCA_REAL_GIT=" +set "ORCA_REAL_GH=" +if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real=" +if defined orca_real if exist "%orca_real%" goto run +for /f "delims=" %%G in ('where.exe __ORCA_COMMAND__.exe 2^>nul') do if not defined orca_real set "orca_real=%%G" +if not defined orca_real ( + echo Orca compatibility wrapper could not locate __ORCA_COMMAND__ on PATH. 1>&2 + exit /b 127 +) +:run +"%orca_real%" %* +exit /b %ERRORLEVEL% -/** Why: a daemon that outlives an upgrade keeps seeding these from its own process.env - * (pty-subprocess reads process.env as the authoritative base), and the wrappers they - * point at are gated only on inherited env. Deleting the scripts makes both inert. */ -export function removeLegacyTerminalShimDir(userDataPath: string): void { - if (removed) { +:orca_append_path +for %%G in ("%~1") do set "orca_path_entry_dir=%%~fG\" +if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b +if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\" exit /b +if defined orca_clean_path (set "orca_clean_path=%orca_clean_path%;%~1") else set "orca_clean_path=%~1" +exit /b +` + +const POWERSHELL_PASSTHROUGH_WRAPPER = String.raw`$ErrorActionPreference = 'Stop' +$commandName = '__ORCA_COMMAND__' +$realCommand = [Environment]::GetEnvironmentVariable('ORCA_REAL___ORCA_UPPER_COMMAND__') +$wrapperDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR +$wrapperDirs = @($wrapperDir, $legacyWrapperDir) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') } +$env:PATH = (($env:PATH -split ';') | Where-Object { + $pathEntry = $_ + $pathEntry -and -not ($wrapperDirs | Where-Object { + [string]::Equals($_, $pathEntry.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) + }) +}) -join ';' +'ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_GIT_COMMIT_TRAILER', 'ORCA_GH_PR_FOOTER', 'ORCA_GH_ISSUE_FOOTER', 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_REAL_GIT', 'ORCA_REAL_GH', 'ORCA_ATTRIBUTION_BYPASS' | ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue } +if ($realCommand) { + try { + $capturedDir = Split-Path -Parent ([IO.Path]::GetFullPath($realCommand)) + if ([string]::Equals($capturedDir.TrimEnd('\'), $wrapperDir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { + $realCommand = $null + } + } catch { + $realCommand = $null + } +} +if (-not $realCommand -or -not (Test-Path -LiteralPath $realCommand)) { + $resolved = Get-Command "$commandName.exe" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + $realCommand = if ($resolved) { $resolved.Source } else { $null } +} +if (-not $realCommand) { + [Console]::Error.WriteLine("Orca compatibility wrapper could not locate $commandName on PATH.") + exit 127 +} +& $realCommand @args +exit $LASTEXITCODE +` + +let neutralized = false +let neutralizationRetryTimer: ReturnType | null = null +let neutralizationRetryAttempt = 0 + +export function neutralizeLegacyTerminalShimDir(userDataPath: string): void { + if (neutralized) { return } try { - // Why: a surviving pre-upgrade pane can hold the wrapper open on Windows; retry like - // the other userData removals rather than forfeiting cleanup for the whole run. - rmSync(join(userDataPath, LEGACY_SHIM_ROOT_DIR), { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 50 - }) - removed = true + const rootDir = join(userDataPath, LEGACY_SHIM_ROOT_DIR) + writeNeutralWrappers(rootDir) + writeFileAtomically(join(rootDir, 'VERSION'), `${LEGACY_SHIM_VERSION}\n`, 0o644) + neutralized = true + clearNeutralizationRetry() } catch { - // Best-effort: a locked file must not block startup, and the next launch retries. + // Why: Windows can keep a running .cmd open briefly after startup. + scheduleNeutralizationRetry(userDataPath) + } +} + +function scheduleNeutralizationRetry(userDataPath: string): void { + if ( + neutralizationRetryTimer || + neutralizationRetryAttempt >= NEUTRALIZATION_RETRY_DELAYS_MS.length + ) { + return + } + const delayMs = NEUTRALIZATION_RETRY_DELAYS_MS[neutralizationRetryAttempt] + neutralizationRetryAttempt += 1 + neutralizationRetryTimer = setTimeout(() => { + neutralizationRetryTimer = null + neutralizeLegacyTerminalShimDir(userDataPath) + }, delayMs) + neutralizationRetryTimer.unref?.() +} + +function clearNeutralizationRetry(): void { + if (neutralizationRetryTimer) { + clearTimeout(neutralizationRetryTimer) + neutralizationRetryTimer = null + } + neutralizationRetryAttempt = 0 +} + +function writeNeutralWrappers(rootDir: string): void { + const posixDir = join(rootDir, 'posix') + const win32Dir = join(rootDir, 'win32') + mkdirSync(posixDir, { recursive: true }) + mkdirSync(win32Dir, { recursive: true }) + for (const command of ['git', 'gh'] as const) { + const upperCommand = command.toUpperCase() + writeFileAtomically(join(posixDir, command), renderLegacyTerminalPosixTombstone(command), 0o755) + writeFileAtomically( + join(win32Dir, `${command}.cmd`), + renderWindowsWrapper(WIN32_PASSTHROUGH_WRAPPER, command, upperCommand), + 0o755 + ) + writeFileAtomically( + join(win32Dir, `${command}-wrapper.ps1`), + renderWindowsWrapper(POWERSHELL_PASSTHROUGH_WRAPPER, command, upperCommand), + 0o755 + ) + } +} + +function renderWindowsWrapper(template: string, command: string, upperCommand: string): string { + return template + .replaceAll('__ORCA_UPPER_COMMAND__', upperCommand) + .replaceAll('__ORCA_COMMAND__', command) +} + +function writeFileAtomically(filePath: string, contents: string, mode: number): void { + const temporaryPath = `${filePath}.orca-neutralizing-${process.pid}` + try { + rmSync(temporaryPath, { force: true, recursive: true }) + writeFileSync(temporaryPath, contents, { encoding: 'utf8', flag: 'wx', mode }) + chmodSync(temporaryPath, mode) + renameSync(temporaryPath, filePath) + } finally { + rmSync(temporaryPath, { force: true, recursive: true }) } } export function isLegacyTerminalShimPathEntry(entry: string): boolean { - return entry.replaceAll('\\', '/').toLowerCase().includes(`/${LEGACY_SHIM_ROOT_DIR}/`) + const normalized = entry.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase() + return ( + normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/posix`) || + normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/win32`) + ) } export function stripLegacyTerminalShimEnv( env: Record, platform: NodeJS.Platform = process.platform ): void { - for (const key of LEGACY_SHIM_ENV_KEYS) { - delete env[key] + const windows = platform === 'win32' + const legacyKeySet = new Set( + LEGACY_TERMINAL_SHIM_ENV_KEYS.map((key) => (windows ? key.toLowerCase() : key)) + ) + const shimDirKey = 'ORCA_ATTRIBUTION_SHIM_DIR'.toLowerCase() + const explicitShimDirs = Object.entries(env) + .filter(([key]) => + windows ? key.toLowerCase() === shimDirKey : key === 'ORCA_ATTRIBUTION_SHIM_DIR' + ) + .map(([, value]) => value) + .filter(Boolean) + for (const key of Object.keys(env)) { + if (windows ? legacyKeySet.has(key.toLowerCase()) : legacyKeySet.has(key)) { + delete env[key] + } } - const pathKey = resolvePathEnvKey(env, platform) - const current = env[pathKey] - if (!current) { - return + const delimiter = windows ? ';' : ':' + const pathKeys = windows + ? Object.keys(env).filter((key) => key.toLowerCase() === 'path') + : ['PATH'] + for (const pathKey of pathKeys) { + const current = env[pathKey] + if (!current) { + continue + } + const withoutExplicitDirs = explicitShimDirs.reduce( + (pathValue, shimDir) => removeLiteralPathEntry(pathValue, shimDir, delimiter, windows), + current + ) + const cleaned = withoutExplicitDirs + .split(delimiter) + .filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry)) + .join(delimiter) + if (cleaned) { + env[pathKey] = cleaned + } else { + delete env[pathKey] + } } - const delimiter = platform === 'win32' ? ';' : ':' - const cleaned = current - .split(delimiter) - .filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry)) - .join(delimiter) - if (cleaned) { - env[pathKey] = cleaned - } else { - delete env[pathKey] +} + +function removeLiteralPathEntry( + pathValue: string, + entry: string, + delimiter: string, + caseInsensitive: boolean +): string { + let result = pathValue + const needle = caseInsensitive ? entry.toLowerCase() : entry + let searchStart = 0 + for (;;) { + const comparable = caseInsensitive ? result.toLowerCase() : result + const index = comparable.indexOf(needle, searchStart) + if (index === -1) { + return result + } + const end = index + entry.length + const startsAtBoundary = index === 0 || result[index - 1] === delimiter + const endsAtBoundary = end === result.length || result[end] === delimiter + if (!startsAtBoundary || !endsAtBoundary) { + searchStart = index + 1 + continue + } + if (result[end] === delimiter) { + result = result.slice(0, index) + result.slice(end + 1) + } else if (index > 0) { + result = result.slice(0, index - 1) + result.slice(end) + } else { + result = '' + } + searchStart = Math.max(0, index - 1) } } /** Test-only: the module-level once-guard would otherwise leak across cases. */ -export function __resetLegacyTerminalShimRemovalForTests(): void { - removed = false +export function __resetLegacyTerminalShimNeutralizationForTests(): void { + neutralized = false + clearNeutralizationRetry() } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index b415ddd0e71..fa3c812ce82 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -46,6 +46,7 @@ export type RpcRequest = { authToken: string method: string params?: unknown + expectedRuntimeId?: string orchestrationCapability?: string orchestrationContractVersion?: number orchestrationRequestId?: string diff --git a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts index 862973e3913..10bc94b771b 100644 --- a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts +++ b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { RpcDispatcher } from './dispatcher' import { defineMethod, InvalidArgumentError, type RpcRequest } from './core' @@ -41,6 +41,26 @@ const METHODS = [ ] describe('RpcDispatcher computer-use validation errors', () => { + it('rejects a replacement runtime before invoking a one-shot handler', async () => { + const handler = vi.fn(() => ({ ok: true })) + const dispatcher = new RpcDispatcher({ + runtime: makeRuntime(), + methods: [defineMethod({ name: 'terminal.create', params: z.object({}), handler })] + }) + + const response = await dispatcher.dispatch({ + ...makeRequest('terminal.create', {}), + expectedRuntimeId: 'previous-runtime' + }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'runtime_replaced' }, + _meta: { runtimeId: 'test-runtime' } + }) + expect(handler).not.toHaveBeenCalled() + }) + it('adds recovery steps to one-shot computer schema failures', async () => { const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS }) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index be11b2b0c64..995d0f00d64 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -43,6 +43,14 @@ export class RpcDispatcher { async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise { const meta = this.meta() + if (request.expectedRuntimeId && request.expectedRuntimeId !== meta.runtimeId) { + return errorResponse( + request.id, + meta, + 'runtime_replaced', + 'The runtime changed before the request could be applied. Refresh and try again.' + ) + } const method = this.registry.get(request.method) if (!method) { return errorResponse( @@ -139,6 +147,19 @@ export class RpcDispatcher { options?: RpcDispatchStreamingOptions ): Promise { const meta = this.meta() + if (request.expectedRuntimeId && request.expectedRuntimeId !== meta.runtimeId) { + reply( + JSON.stringify( + errorResponse( + request.id, + meta, + 'runtime_replaced', + 'The runtime changed before the request could be applied. Refresh and try again.' + ) + ) + ) + return + } const method = this.registry.get(request.method) if (!method) { reply( diff --git a/src/main/runtime/rpc/methods/terminal-split-env.test.ts b/src/main/runtime/rpc/methods/terminal-split-env.test.ts new file mode 100644 index 00000000000..3bbf1291f00 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-split-env.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { TERMINAL_METHODS } from './terminal' + +describe('terminal split environment forwarding', () => { + it('forwards requested environment deletions to the runtime owner', async () => { + const splitTerminal = vi.fn().mockResolvedValue({ handle: 'term-split' }) + const runtime = { + getRuntimeId: () => 'runtime-1', + splitTerminal + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const request: RpcRequest = { + id: 'split-1', + authToken: 'token', + method: 'terminal.split', + params: { + terminal: 'term-1', + direction: 'horizontal', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'] + } + } + + await dispatcher.dispatch(request) + + expect(splitTerminal).toHaveBeenCalledWith('term-1', { + direction: 'horizontal', + command: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + telemetrySource: undefined + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 69f4eb72190..32c31149f3f 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -985,6 +985,7 @@ const TerminalSplit = TerminalHandle.extend({ .optional(), command: OptionalString, env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() }) @@ -1472,6 +1473,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ direction: params.direction, command: params.command, env: params.env, + envToDelete: params.envToDelete, telemetrySource: params.telemetrySource }) }) diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 382353de803..4b4ce8a9f21 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -231,6 +231,28 @@ describe('RpcDispatcher streaming', () => { expect(response.streaming).toBeUndefined() }) + it('rejects a replacement runtime before streaming dispatch invokes a mutation', async () => { + const messages: string[] = [] + const handler = vi.fn(() => ({ terminal: { handle: 'terminal-1' } })) + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [defineMethod({ name: 'terminal.create', params: z.object({}), handler })] + }) + + await dispatcher.dispatchStreaming( + { ...makeRequest('terminal.create', {}), expectedRuntimeId: 'previous-runtime' }, + (message) => messages.push(message) + ) + + expect(messages).toHaveLength(1) + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'runtime_replaced' }, + _meta: { runtimeId: 'test-runtime' } + }) + expect(handler).not.toHaveBeenCalled() + }) + it('returns error for unknown method via dispatchStreaming', async () => { const messages: string[] = [] const dispatcher = new RpcDispatcher({ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3401c8939f6..0f90182d053 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3413,6 +3413,7 @@ export type PreloadApi = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string }) => Promise> subscribe: ( args: { @@ -3421,6 +3422,7 @@ export type PreloadApi = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string }, callbacks: { onResponse: (response: RuntimeRpcResponse) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 040d7b63cf3..58aec899f03 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4428,6 +4428,7 @@ const api = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string }): Promise> => ipcRenderer.invoke('runtimeEnvironments:call', args), subscribe: async ( @@ -4437,6 +4438,7 @@ const api = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string }, callbacks: { onResponse: (response: RuntimeRpcResponse) => void diff --git a/src/preload/runtime-environment-subscriptions.test.ts b/src/preload/runtime-environment-subscriptions.test.ts index 585b0f75fa9..a77614e1196 100644 --- a/src/preload/runtime-environment-subscriptions.test.ts +++ b/src/preload/runtime-environment-subscriptions.test.ts @@ -68,7 +68,12 @@ describe('subscribeRuntimeEnvironmentFromPreload', () => { const cleanupPromise = subscribeRuntimeEnvironmentFromPreload( ipc, - { selector: 'desk', method: 'terminal.subscribe' }, + { + selector: 'desk', + method: 'terminal.subscribe', + expectedEnvironmentPairingRevision: 7, + expectedRuntimeId: 'runtime-1' + }, { onResponse, onBinary }, () => 'sub-1' ) @@ -80,6 +85,8 @@ describe('subscribeRuntimeEnvironmentFromPreload', () => { expect(ipc.invoke).toHaveBeenCalledWith('runtimeEnvironments:subscribe', { selector: 'desk', method: 'terminal.subscribe', + expectedEnvironmentPairingRevision: 7, + expectedRuntimeId: 'runtime-1', subscriptionId: 'sub-1' }) diff --git a/src/preload/runtime-environment-subscriptions.ts b/src/preload/runtime-environment-subscriptions.ts index f9d46b467aa..c804c651f73 100644 --- a/src/preload/runtime-environment-subscriptions.ts +++ b/src/preload/runtime-environment-subscriptions.ts @@ -5,6 +5,8 @@ type RuntimeEnvironmentSubscribeArgs = { method: string params?: unknown timeoutMs?: number + expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string } type RuntimeEnvironmentSubscriptionCallbacks = { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index b97c1bd0847..06eaa15d5ff 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -11,6 +11,7 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../shared/setup-agent-sequencing' import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' +import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -308,7 +309,56 @@ describe('PtyHandler', () => { const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } expect(spawnOptions.env.NODE_ENV).toBeUndefined() - expect(spawnOptions.env.PATH).toBe(process.env.PATH) + const expectedEnv = { PATH: process.env.PATH ?? '' } + stripLegacyTerminalShimEnv(expectedEnv, process.platform) + expect(spawnOptions.env.PATH).toBe(expectedEnv.PATH) + }) + + it('does not inherit legacy attribution state from the relay process', async () => { + const keys = ['ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_ATTRIBUTION_SHIM_DIR', 'PATH'] as const + const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1' + process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix' + process.env.PATH = '/tmp/orca-terminal-attribution/posix:/usr/bin' + + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + const spawnedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as { + env: Record + } + expect(spawnedEnv.env.PATH).toBe('/usr/bin') + expect(spawnedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() + expect(spawnedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + + const state = (await dispatcher.callRequest('pty.serialize', { + ids: ['pty-1'] + })) as string + await handler.dispose({ waitForPhysicalExit: false }) + mockPtySpawn.mockClear() + 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() + } + + const revivedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as { + env: Record + } + expect(revivedEnv.env.PATH).toBe('/usr/bin') + expect(revivedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() + expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } }) it('keeps a renderer-supplied NODE_ENV for the spawned shell', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index e7c09df95de..3f8bd593e08 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -47,6 +47,7 @@ import { isTuiAgent } from '../shared/tui-agent-config' import type { TuiAgent } from '../shared/types' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' +import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' import { PTY_STARTUP_INGRESS_VERSION, PtyStartupIngress, @@ -615,6 +616,8 @@ export class PtyHandler { } } const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record + // Why: an older client may not ask a newly upgraded relay to delete inherited shim state. + stripLegacyTerminalShimEnv(result, process.platform) // Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values. for (const key of envToDelete) { delete result[key] diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 1954b857d70..a86b0c6bd1e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -9,6 +9,7 @@ import { encodeTerminalStreamText } from '../../../../shared/terminal-stream-protocol' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' +import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES @@ -2512,7 +2513,10 @@ describe('createRemoteRuntimePtyTransport', () => { result: { runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: [ + 'agent-session.host-authority.v1', + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY + ] }, _meta: { runtimeId: 'runtime-remote' } } @@ -2611,12 +2615,14 @@ describe('createRemoteRuntimePtyTransport', () => { worktree: 'id:repo1::/remote/wt', clientMutationId: expect.any(String), command: 'claude', - env: { ORCA_TAB_ID: 'tab-1' }, + env: { ORCA_TAB_ID: 'tab-1', ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], tabId: 'tab-1', leafId: '11111111-1111-4111-8111-111111111111', focus: false, presentation: 'background' }, + expectedEnvironmentPairingRevision: undefined, timeoutMs: 15_000 }) expect(runtimeSubscribe).toHaveBeenCalledWith( @@ -2668,20 +2674,34 @@ describe('createRemoteRuntimePtyTransport', () => { }) it('reports a host stable-pane adoption as reattach without fresh-spawn ownership', async () => { - runtimeCall.mockResolvedValue({ - id: 'rpc-create', - ok: true, - result: { - terminal: { - handle: 'term-original', - worktreeId: 'repo1::/remote/wt', - title: 'Original', - surface: 'background', - isReattach: true - } - }, - _meta: { runtimeId: 'runtime-remote' } - }) + runtimeCall.mockImplementation(async (args: { method?: string }) => + args.method === 'status.get' + ? { + id: 'rpc-status', + ok: true, + result: { + runtimeId: 'runtime-remote', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY] + }, + _meta: { runtimeId: 'runtime-remote' } + } + : { + id: 'rpc-create', + ok: true, + result: { + terminal: { + handle: 'term-original', + worktreeId: 'repo1::/remote/wt', + title: 'Original', + surface: 'background', + isReattach: true + } + }, + _meta: { runtimeId: 'runtime-remote' } + } + ) const onPtySpawn = vi.fn() const onReattachDetermined = vi.fn() const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') @@ -2809,6 +2829,7 @@ describe('createRemoteRuntimePtyTransport', () => { }, presentation: 'background' }, + expectedEnvironmentPairingRevision: undefined, timeoutMs: 15_000 }) expect(runtimeCall).not.toHaveBeenCalledWith( @@ -2834,9 +2855,13 @@ describe('createRemoteRuntimePtyTransport', () => { id: 'rpc-status', ok: true, result: { + runtimeId: 'runtime-remote', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: [ + 'agent-session.host-authority.v1', + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY + ] } } ) @@ -2882,6 +2907,7 @@ describe('createRemoteRuntimePtyTransport', () => { }, presentation: 'background' }, + expectedEnvironmentPairingRevision: undefined, timeoutMs: 15_000 }) expect(runtimeCall).not.toHaveBeenCalledWith( diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 4087cf59a15..c160188f64d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -14,7 +14,12 @@ import { TERMINAL_INPUT_MAX_BYTES } from '../../../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' -import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION, + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' describe('createRemoteRuntimePtyTransport', () => { const runtimeCall = vi.fn() @@ -113,6 +118,19 @@ describe('createRemoteRuntimePtyTransport', () => { } } + function currentRuntimeStatus(capabilities: string[] = []): unknown { + return { + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, ...capabilities] + } + } + } + function emitOutput(streamId: number, data: string): void { subscriptionCallbacks?.onBinary?.( encodeTerminalStreamFrame({ @@ -187,6 +205,18 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockReset() refreshSessionTabsSnapshot.mockClear() runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY] + } + } + } if (request.method === 'session.tabs.activate') { const params = request.params as { tabId: string; leafId?: string } const resolvedLeafId = params.leafId ?? 'pane:1' @@ -541,10 +571,7 @@ describe('createRemoteRuntimePtyTransport', () => { let createCalls = 0 runtimeCall.mockImplementation(async (args: { method: string; params?: unknown }) => { if (args.method === 'status.get') { - return { - ok: true, - result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] } - } + return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY]) } if (args.method === 'terminal.create') { createCalls += 1 @@ -592,13 +619,14 @@ describe('createRemoteRuntimePtyTransport', () => { try { const startedAt = Date.now() let createCalls = 0 + let statusCalls = 0 runtimeCall.mockImplementation(async (args: { method: string }) => { if (args.method === 'status.get') { - vi.setSystemTime(startedAt + 59_000) - return { - ok: true, - result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] } + statusCalls += 1 + if (statusCalls > 1) { + vi.setSystemTime(startedAt + 59_000) } + return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY]) } if (args.method === 'terminal.create') { createCalls += 1 @@ -632,7 +660,7 @@ describe('createRemoteRuntimePtyTransport', () => { it('does not retry an unknown create outcome against an older runtime', async () => { runtimeCall.mockImplementation(async (args: { method: string }) => { if (args.method === 'status.get') { - return { ok: true, result: { capabilities: [] } } + return currentRuntimeStatus() } throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { code: 'runtime_timeout' @@ -653,12 +681,16 @@ describe('createRemoteRuntimePtyTransport', () => { }) it('surfaces an authoritative capability-probe failure after an unknown create outcome', async () => { + let statusCalls = 0 runtimeCall.mockImplementation(async (args: { method: string }) => { - if (args.method === 'status.get') { + if (args.method === 'status.get' && ++statusCalls > 1) { throw Object.assign(new Error('Remote runtime pairing credentials expired.'), { code: 'unauthorized' }) } + if (args.method === 'status.get') { + return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY]) + } throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { code: 'runtime_timeout' }) @@ -694,10 +726,7 @@ describe('createRemoteRuntimePtyTransport', () => { }, args.timeoutMs) }) } - return { - ok: true, - result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] } - } + return currentRuntimeStatus([TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY]) } if (args.method === 'terminal.create' && reachable) { return { ok: true, result: { terminal: { handle: 'terminal-recovered' } } } @@ -763,9 +792,10 @@ describe('createRemoteRuntimePtyTransport', () => { return { ok: true, result: { + runtimeId: 'runtime-1', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } } @@ -813,9 +843,65 @@ describe('createRemoteRuntimePtyTransport', () => { .filter((args) => args.method === 'terminal.createAgentSession') expect(allCreates.every((args) => args.params?.clientOperationId === operationId)).toBe(true) expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false) - expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'status.get')).toHaveLength( - 1 + expect( + runtimeCall.mock.calls.filter(([args]) => args.method === 'status.get').length + ).toBeGreaterThan(1) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('refuses structured replay after an in-place host downgrade', async () => { + vi.useFakeTimers() + try { + let statusCalls = 0 + const onError = vi.fn() + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'status.get') { + statusCalls += 1 + return { + ok: true, + result: { + runtimeId: 'runtime-1', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: + statusCalls === 1 + ? ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] + : ['agent-session.host-authority.v1'] + } + } + } + if (args.method === 'terminal.createAgentSession') { + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + return { ok: true, result: {} } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'codex' + }) + + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(250) + await connect + + expect(onError).toHaveBeenCalledWith( + expect.stringContaining('no fallback launch was attempted') ) + expect( + runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.createAgentSession') + ).toHaveLength(1) + expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'status.get')).toHaveLength( + 2 + ) + expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false) transport.destroy?.() } finally { vi.useRealTimers() @@ -3855,6 +3941,9 @@ describe('createRemoteRuntimePtyTransport', () => { it('closes a remote terminal created after the pane was destroyed', async () => { let resolveCreate: (value: unknown) => void = () => {} runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return new Promise((resolve) => { resolveCreate = resolve @@ -3870,6 +3959,11 @@ describe('createRemoteRuntimePtyTransport', () => { }) const connect = transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + ) transport.destroy?.() resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } }) await connect @@ -3878,13 +3972,17 @@ describe('createRemoteRuntimePtyTransport', () => { selector: 'env-1', method: 'terminal.close', params: { terminal: 'terminal-late' }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: undefined }) }) it('cannot let a stale create completion replace a newer attached terminal', async () => { let resolveCreate: (value: unknown) => void = () => {} runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return new Promise((resolve) => { resolveCreate = resolve @@ -3900,6 +3998,11 @@ describe('createRemoteRuntimePtyTransport', () => { }) const connect = transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + ) transport.attach({ existingPtyId: 'remote:env-2@@terminal-attached', callbacks: {} }) resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } }) await connect @@ -3910,7 +4013,8 @@ describe('createRemoteRuntimePtyTransport', () => { selector: 'env-1', method: 'terminal.close', params: { terminal: 'terminal-late' }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: undefined }) transport.destroy?.() }) @@ -3924,7 +4028,7 @@ describe('createRemoteRuntimePtyTransport', () => { result: { runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } }) } @@ -3974,7 +4078,7 @@ describe('createRemoteRuntimePtyTransport', () => { result: { runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } }) } @@ -4089,7 +4193,7 @@ describe('createRemoteRuntimePtyTransport', () => { method: 'terminal.create', params: expect.objectContaining({ command: "codex 'linked issue context'", - envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'], startupCommandDelivery: 'shell-ready', terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' } }) @@ -4105,7 +4209,11 @@ describe('createRemoteRuntimePtyTransport', () => { result: { runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1', 'agent-session.omp-resume-path.v1'] + capabilities: [ + 'agent-session.host-authority.v1', + 'agent-session.omp-resume-path.v1', + 'terminal.attribution-removed.v1' + ] } } : { ok: true, result: { terminal: { handle: 'terminal-1' } } } @@ -4169,7 +4277,7 @@ describe('createRemoteRuntimePtyTransport', () => { result: { runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } : { @@ -4219,9 +4327,10 @@ describe('createRemoteRuntimePtyTransport', () => { ? { ok: true, result: { + runtimeId: 'runtime-1', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: [] + capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY] } } : { ok: true, result: { terminal: { handle: 'terminal-legacy' } } } @@ -4257,7 +4366,12 @@ describe('createRemoteRuntimePtyTransport', () => { worktree: 'id:wt-1', clientMutationId: expect.any(String), command: "codex '--model' 'gpt-5' 'resume' 'session-1'", - env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' }, + env: { + CODEX_PROFILE: 'captured', + ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token', + ORCA_ATTRIBUTION_BYPASS: '1' + }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], launchConfig: { agentArgs: '--model gpt-5', agentEnv: { CODEX_PROFILE: 'captured' } @@ -4269,7 +4383,9 @@ describe('createRemoteRuntimePtyTransport', () => { focus: false, presentation: 'background' }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: 'runtime-1' }) expect(runtimeCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.createAgentSession' }) @@ -5686,6 +5802,9 @@ describe('createRemoteRuntimePtyTransport', () => { it('returns runtime acceptance for acknowledged terminal input', async () => { runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) } @@ -5725,6 +5844,9 @@ describe('createRemoteRuntimePtyTransport', () => { vi.useFakeTimers() try { runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) } @@ -5770,6 +5892,9 @@ describe('createRemoteRuntimePtyTransport', () => { it('returns false when acknowledged terminal input is rejected by the runtime', async () => { runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) } @@ -5795,6 +5920,9 @@ describe('createRemoteRuntimePtyTransport', () => { it('splits large acknowledged remote input before terminal.send RPCs', async () => { runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) } @@ -5834,6 +5962,9 @@ describe('createRemoteRuntimePtyTransport', () => { vi.useFakeTimers() try { runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) } @@ -5883,6 +6014,9 @@ describe('createRemoteRuntimePtyTransport', () => { const firstChunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) const rejectedChunk = `tail${'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES - 4)}` runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) } @@ -5921,6 +6055,9 @@ describe('createRemoteRuntimePtyTransport', () => { it('rejects oversized acknowledged remote input before runtime RPCs', async () => { runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve(currentRuntimeStatus()) + } if (args.method === 'terminal.create') { return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 6eff59d84ce..35189447c57 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -20,8 +20,11 @@ import type { RuntimeTerminalSend } from '../../../../shared/runtime-types' import { + AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, - TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + type RuntimeCapability } from '../../../../shared/protocol-version' import { isTerminalInputTooLargeWithDeferredMeasurement, @@ -34,7 +37,11 @@ import type { PtyTransportRecoveryState } from './pty-transport-types' import { createPtyOutputProcessor } from './pty-transport' -import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client' +import { + RuntimeRpcCallError, + unwrapRuntimeRpcResult, + type LiveRuntimeEnvironmentAuthority +} from '../../runtime/runtime-rpc-client' import { getRemoteRuntimePtyEnvironmentId, getRemoteRuntimeTerminalHandle, @@ -83,6 +90,10 @@ import { ptyShutdownLifecycleHandlers } from './pty-shutdown-data-suspension' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../../shared/legacy-terminal-attribution-env' const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8 const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 @@ -92,6 +103,8 @@ const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000 const HOST_SESSION_INVENTORY_MAX_WINDOWS_PER_RECOVERY = 2 const HOST_SESSION_SAME_HANDLE_END_REUSE_LIMIT = 2 const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const +const AGENT_SESSION_REPLAY_UPDATE_REQUIRED_MESSAGE = + 'Remote agent recovery requires the same updated Orca server that accepted the launch. Update the workspace host and try again; no fallback launch was attempted.' type HostHandleReplacementPolicy = 'reuse' | 'prefer-replacement' | 'require-replacement' @@ -358,6 +371,7 @@ export function createRemoteRuntimePtyTransport( // Why: reconnect retries must replay one host operation instead of creating // another fresh agent when the first response was lost. const agentCreateOperation = createAgentSessionCreateOperation() + let agentSessionCreateAuthority: LiveRuntimeEnvironmentAuthority | null = null const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -852,14 +866,16 @@ export function createRemoteRuntimePtyTransport( environmentId: string, method: string, params?: unknown, - timeoutMs = 15_000 + timeoutMs = 15_000, + expectedRuntimeId?: string ): Promise { const response = await window.api.runtimeEnvironments.call({ selector: environmentId, method, params, timeoutMs, - expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision + expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision, + ...(expectedRuntimeId ? { expectedRuntimeId } : {}) }) return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } @@ -908,7 +924,8 @@ export function createRemoteRuntimePtyTransport( reconcileExisting: boolean ) => Promise, environmentId: string, - expectedLifecycleEpoch: number + expectedLifecycleEpoch: number, + beforeReplay?: (timeoutMs: number) => Promise ): Promise { let retryAttempt = 0 // Structured operations already carry their replay proof; ordinary terminal.create @@ -993,6 +1010,9 @@ export function createRemoteRuntimePtyTransport( break } try { + if (reconcileExisting) { + await beforeReplay?.(Math.min(5_000, createRemainingMs ?? 5_000)) + } return await invoke(Math.min(15_000, createRemainingMs ?? 15_000), reconcileExisting) } catch (error) { lastError = error @@ -1975,8 +1995,10 @@ export function createRemoteRuntimePtyTransport( const commandToSend = options.command ?? command const startupCommandDeliveryToSend = options.startupCommandDelivery ?? startupCommandDelivery - const envToSend = options.env ?? env - const envToDeleteToSend = options.envToDelete ?? envToDelete + const envToSend = withLegacyTerminalAttributionDisabledEnv(options.env ?? env) + const envToDeleteToSend = addLegacyTerminalAttributionDisableRequest( + options.envToDelete ?? envToDelete + ) const launchConfigToSend = options.launchConfig ?? launchConfig const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession const launchTokenToSend = options.launchToken ?? launchToken @@ -2004,7 +2026,7 @@ export function createRemoteRuntimePtyTransport( presentation: 'background' as const, ...(activate === true ? { activate: true } : {}) } - const legacyCreate = () => + const legacyCreate = ({ authority }: { authority: LiveRuntimeEnvironmentAuthority }) => createWithUnknownOutcomeRecovery( 'terminal', (timeoutMs, reconcileExisting) => @@ -2015,13 +2037,49 @@ export function createRemoteRuntimePtyTransport( ...legacyCreateParams, ...(reconcileExisting ? { reconcileExisting: true } : {}) }, - timeoutMs + timeoutMs, + authority.runtimeId ), createEnvironmentId, connectLifecycleEpoch ) - const hostAuthorityCreate = () => - createWithUnknownOutcomeRecovery( + const requiredAgentSessionCapabilities: readonly RuntimeCapability[] = + resumeProviderSessionToSend && launchAgentToSend === 'omp' + ? [AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY] + : [] + const revalidateAgentSessionReplay = async ( + timeoutMs: number, + authority: LiveRuntimeEnvironmentAuthority + ): Promise => { + const status = await callRuntimeForEnvironment( + createEnvironmentId, + 'status.get', + undefined, + timeoutMs, + authority.runtimeId + ) + const requiredCapabilities = [ + AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, + ...requiredAgentSessionCapabilities + ] + const capabilities = Array.isArray(status.capabilities) ? status.capabilities : [] + if ( + status.runtimeId !== authority.runtimeId || + requiredCapabilities.some((capability) => !capabilities.includes(capability)) + ) { + throw new Error(AGENT_SESSION_REPLAY_UPDATE_REQUIRED_MESSAGE) + } + } + const hostAuthorityCreate = (authority: LiveRuntimeEnvironmentAuthority) => { + if ( + agentSessionCreateAuthority && + agentSessionCreateAuthority.runtimeId !== authority.runtimeId + ) { + throw new Error(AGENT_SESSION_REPLAY_UPDATE_REQUIRED_MESSAGE) + } + agentSessionCreateAuthority ??= authority + return createWithUnknownOutcomeRecovery( 'agent-session', (timeoutMs) => resumeProviderSessionToSend @@ -2043,7 +2101,8 @@ export function createRemoteRuntimePtyTransport( placement: { tabId, leafId }, presentation: 'background' }, - timeoutMs + timeoutMs, + authority.runtimeId ) : callRuntimeForEnvironment( createEnvironmentId, @@ -2065,23 +2124,37 @@ export function createRemoteRuntimePtyTransport( }, agentCreateOperation.clientOperationId ), - timeoutMs + timeoutMs, + authority.runtimeId ), createEnvironmentId, - connectLifecycleEpoch + connectLifecycleEpoch, + (timeoutMs) => revalidateAgentSessionReplay(timeoutMs, authority) ) + } const created = launchAgentToSend ? agentSessionRequiresHostAuthorityReplay - ? await hostAuthorityCreate() + ? agentSessionCreateAuthority + ? await hostAuthorityCreate(agentSessionCreateAuthority) + : await runRemoteAgentSessionLaunch({ + environmentId: createEnvironmentId, + expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision, + hostAuthority: hostAuthorityCreate, + requiredHostAuthorityCapabilities: requiredAgentSessionCapabilities, + legacy: legacyCreate + }) : await runRemoteAgentSessionLaunch({ environmentId: createEnvironmentId, + expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision, hostAuthority: hostAuthorityCreate, - ...(resumeProviderSessionToSend && launchAgentToSend === 'omp' - ? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } - : {}), + requiredHostAuthorityCapabilities: requiredAgentSessionCapabilities, legacy: legacyCreate }) - : await legacyCreate() + : await runRemoteAgentSessionLaunch({ + environmentId: createEnvironmentId, + expectedEnvironmentPairingRevision: runtimeEnvironmentPairingRevision, + legacy: legacyCreate + }) if (!created) { if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false @@ -2157,13 +2230,21 @@ export function createRemoteRuntimePtyTransport( if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false const message = runtimeTerminalErrorMessage(error) + const recoverable = isRecoverableRemoteRuntimeConnectionError( + toRemoteRuntimeClientErrorLike(error) + ) if (isRemoteTerminalGoneMessage(message)) { recovery.cancel() handleRemoteTerminalError(error) } else if ( - isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) + recoverable || + terminalCreateNeedsReconciliation || + agentSessionRequiresHostAuthorityReplay ) { recovery.markDisconnected() + if (!recoverable) { + surfaceErrorMessage(message) + } } else { recovery.cancel() emitRecoveryState() diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 6cf8e9b717d..4066f67522f 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -378,6 +378,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', ) expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', + expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: 'remote-runtime', method: 'terminal.createAgentSession', params: expect.objectContaining({ clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), @@ -420,7 +422,7 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: [] + capabilities: ['terminal.attribution-removed.v1'] } }) } @@ -442,6 +444,7 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith( expect.objectContaining({ + expectedRuntimeId: 'old-runtime', method: 'terminal.create', params: expect.objectContaining({ worktree: 'id:wt-1', diff --git a/src/renderer/src/lib/runtime-agent-background-create.ts b/src/renderer/src/lib/runtime-agent-background-create.ts index 70915ae63d4..a6c430f172b 100644 --- a/src/renderer/src/lib/runtime-agent-background-create.ts +++ b/src/renderer/src/lib/runtime-agent-background-create.ts @@ -3,6 +3,11 @@ import type { StartupCommandDelivery } from '../../../shared/codex-startup-deliv import type { SessionOptionValue } from '../../../shared/native-chat-session-options' import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import type { TuiAgent } from '../../../shared/types' +import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../shared/legacy-terminal-attribution-env' import { createAgentSessionCreateOperation, toAgentLaunchPreferences, @@ -33,7 +38,8 @@ export async function createRuntimeAgentBackgroundTerminal(args: { const launchPreferences = toAgentLaunchPreferences(args.sessionOptions) return await runRemoteAgentSessionLaunch({ environmentId: args.environmentId, - hostAuthority: () => + requiredHostAuthorityCapabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY], + hostAuthority: (authority) => operation.run((clientOperationId) => callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( { kind: 'environment', environmentId: args.environmentId }, @@ -52,10 +58,14 @@ export async function createRuntimeAgentBackgroundTerminal(args: { }, clientOperationId ), - { timeoutMs: 15_000 } + { + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: authority.expectedEnvironmentPairingRevision, + expectedRuntimeId: authority.runtimeId + } ) ), - legacy: ({ skipCompatibilityCheck }) => + legacy: ({ skipCompatibilityCheck, authority }) => callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( { kind: 'environment', environmentId: args.environmentId }, 'terminal.create', @@ -65,7 +75,8 @@ export async function createRuntimeAgentBackgroundTerminal(args: { ...(args.legacy.startupCommandDelivery ? { startupCommandDelivery: args.legacy.startupCommandDelivery } : {}), - env: args.legacy.env, + env: withLegacyTerminalAttributionDisabledEnv(args.legacy.env), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), launchConfig: args.legacy.launchConfig, launchToken: args.legacy.launchToken, launchAgent: args.agent, @@ -74,7 +85,12 @@ export async function createRuntimeAgentBackgroundTerminal(args: { leafId: args.leafId, presentation: 'background' }, - { timeoutMs: 15_000, skipCompatibilityCheck } + { + timeoutMs: 15_000, + skipCompatibilityCheck, + expectedEnvironmentPairingRevision: authority.expectedEnvironmentPairingRevision, + expectedRuntimeId: authority.runtimeId + } ) }) } diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index cc2af6e7d7c..e8a1ef3f052 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../shared/constants' +import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { useAppStore } from '@/store' import { activateAndRevealWorktree, @@ -23,6 +24,35 @@ function makeWebRuntimeWorktree() { } } +function makeWakeTerminalRuntimeCall(worktreeId: string) { + return vi.fn(async (args: { method?: string }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY] } + } + } + if (args.method === 'session.tabs.list') { + return { + ok: true, + result: { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + } + } + return { + ok: true, + result: { tab: { id: 'host-tab-1::leaf-1', leafId: 'leaf-1' } } + } + }) +} + /** Activates and asserts a focusable tab appeared with no queued startup, returning its id. */ function activateAndExpectNoRelaunch( worktreeId: string, @@ -536,16 +566,7 @@ describe('activateAndRevealWorktree', () => { it('respawns a host terminal when waking a slept web workspace with dead local PTYs', async () => { const worktree = makeWebRuntimeWorktree() - const callRuntimeEnvironment = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true } - }) - .mockResolvedValueOnce({ - ok: true, - result: { tabId: 'host-tab-1', terminal: 'term_host' } - }) + const callRuntimeEnvironment = makeWakeTerminalRuntimeCall(worktree.id) ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true vi.stubGlobal('window', { api: { @@ -612,9 +633,19 @@ describe('activateAndRevealWorktree', () => { }) ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) - await new Promise((resolve) => setTimeout(resolve, 0)) + await vi.waitFor(() => { + expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3) + }) - expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect(callRuntimeEnvironment).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + selector: 'web-runtime-1', + method: 'status.get' + }) + ) + expect(callRuntimeEnvironment).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ selector: 'web-runtime-1', method: 'session.tabs.createTerminal' @@ -624,10 +655,7 @@ describe('activateAndRevealWorktree', () => { it('respawns wake terminals on the explicit owner runtime when focus changed', async () => { const worktree = makeWorktree() - const callRuntimeEnvironment = vi.fn().mockResolvedValue({ - ok: true, - result: { tabId: 'host-tab-1', terminal: 'term_host' } - }) + const callRuntimeEnvironment = makeWakeTerminalRuntimeCall(worktree.id) ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true vi.stubGlobal('window', { api: { @@ -676,9 +704,19 @@ describe('activateAndRevealWorktree', () => { }) ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) - await new Promise((resolve) => setTimeout(resolve, 0)) + await vi.waitFor(() => { + expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3) + }) - expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect(callRuntimeEnvironment).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + selector: 'owner-runtime', + method: 'status.get' + }) + ) + expect(callRuntimeEnvironment).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ selector: 'owner-runtime', method: 'session.tabs.createTerminal' diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts index 49f38e7aa7c..c4e2edc4907 100644 --- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts +++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../shared/constants' +import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { Worktree } from '../../../shared/types' import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn' import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync' @@ -48,21 +49,43 @@ function makeWorktree(): Worktree { describe('empty remote worktree activation', () => { it('creates a host terminal when waking an empty remote workspace', async () => { const worktree = makeWorktree() - const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({ - ok: true, - result: { - tab: { - type: 'terminal', - id: 'host-tab-1::leaf-1', - parentTabId: 'host-tab-1', - leafId: 'leaf-1', - title: 'Terminal 1', - terminal: 'term_host', - status: 'ready', - isActive: true - }, - publicationEpoch: 'epoch-1', - snapshotVersion: 1 + const callRuntimeEnvironment = vi.fn(async (args: { method?: string }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { capabilities: [TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY] } + } + } + if (args.method === 'session.tabs.list') { + return { + ok: true, + result: { + worktree: worktree.id, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + } + } + return { + ok: true, + result: { + tab: { + type: 'terminal', + id: 'host-tab-1::leaf-1', + parentTabId: 'host-tab-1', + leafId: 'leaf-1', + title: 'Terminal 1', + terminal: 'term_host', + status: 'ready', + isActive: true + }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } } }) ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true @@ -100,10 +123,18 @@ describe('empty remote worktree activation', () => { ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) await vi.waitFor(() => { - expect(callRuntimeEnvironment).toHaveBeenCalled() + expect(callRuntimeEnvironment).toHaveBeenCalledTimes(3) }) - expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect(callRuntimeEnvironment).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + selector: 'web-runtime-1', + method: 'status.get' + }) + ) + expect(callRuntimeEnvironment).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ selector: 'web-runtime-1', method: 'session.tabs.createTerminal', diff --git a/src/renderer/src/runtime/abortable-runtime-environment-call.ts b/src/renderer/src/runtime/abortable-runtime-environment-call.ts index 619dfc517ce..357bf1b2d08 100644 --- a/src/renderer/src/runtime/abortable-runtime-environment-call.ts +++ b/src/renderer/src/runtime/abortable-runtime-environment-call.ts @@ -12,7 +12,8 @@ export async function callAbortableRuntimeEnvironment( params: unknown, timeoutMs: number | undefined, signal: AbortSignal, - expectedEnvironmentPairingRevision?: number + expectedEnvironmentPairingRevision?: number, + expectedRuntimeId?: string ): Promise> { if (signal.aborted) { throw createRuntimeRpcAbortError() @@ -47,7 +48,14 @@ export async function callAbortableRuntimeEnvironment( signal.addEventListener('abort', onAbort, { once: true }) void window.api.runtimeEnvironments .subscribe( - { selector: environmentId, method, params, timeoutMs, expectedEnvironmentPairingRevision }, + { + selector: environmentId, + method, + params, + timeoutMs, + expectedEnvironmentPairingRevision, + expectedRuntimeId + }, { onResponse: (response) => finish(() => resolve(response)), onError: (error) => finish(() => reject(new Error(error.message))), diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts index 0fb6399d688..aea62019626 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.test.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - supportsCapability: vi.fn() -})) +const mocks = vi.hoisted(() => ({ probe: vi.fn() })) vi.mock('./runtime-rpc-client', () => ({ RuntimeRpcCallError: class RuntimeRpcCallError extends Error { @@ -12,75 +10,110 @@ vi.mock('./runtime-rpc-client', () => ({ this.code = response.error.code } }, - runtimeEnvironmentSupportsCapability: mocks.supportsCapability + probeLiveRuntimeEnvironmentCapabilities: mocks.probe })) import { RuntimeRpcCallError } from './runtime-rpc-client' import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' +const authority = { + runtimeId: 'runtime-1', + expectedEnvironmentPairingRevision: 7, + capabilities: [ + 'terminal.attribution-removed.v1', + 'agent-session.host-authority.v1', + 'agent-session.omp-resume-path.v1' + ] +} as const + describe('remote agent-session launch routing', () => { beforeEach(() => { - mocks.supportsCapability.mockReset() + mocks.probe.mockReset() + mocks.probe.mockResolvedValue({ supported: true, authority }) }) - it('uses host authority only when the host advertises it', async () => { + it('uses one live all-capability probe before host authority', async () => { + const hostAuthority = vi.fn().mockResolvedValue('structured') + const legacy = vi.fn().mockResolvedValue('legacy') + + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + expectedEnvironmentPairingRevision: 7, + hostAuthority, + requiredHostAuthorityCapabilities: ['agent-session.omp-resume-path.v1'], + legacy + }) + ).resolves.toBe('structured') + + expect(mocks.probe).toHaveBeenCalledWith({ + environmentId: 'env-1', + requiredCapabilities: [ + 'terminal.attribution-removed.v1', + 'agent-session.host-authority.v1', + 'agent-session.omp-resume-path.v1' + ], + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: 7 + }) + expect(hostAuthority).toHaveBeenCalledWith(authority) + expect(legacy).not.toHaveBeenCalled() + }) + + it('uses fenced legacy when a structured-only capability is absent', async () => { + mocks.probe.mockResolvedValue({ supported: false, authority }) const hostAuthority = vi.fn().mockResolvedValue('structured') const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockResolvedValue(true) await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, - hostAuthorityCapability: 'agent-session.omp-resume-path.v1', + requiredHostAuthorityCapabilities: ['agent-session.omp-resume-path.v1'], legacy }) - ).resolves.toBe('structured') - expect(mocks.supportsCapability).toHaveBeenCalledWith( - 'env-1', - 'agent-session.omp-resume-path.v1' - ) - expect(hostAuthority).toHaveBeenCalledOnce() + ).resolves.toBe('legacy') + + expect(hostAuthority).not.toHaveBeenCalled() + expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority }) + }) + + it('fails closed without attribution-removal capability evidence', async () => { + mocks.probe.mockResolvedValue({ + supported: false, + authority: { ...authority, capabilities: ['agent-session.host-authority.v1'] } + }) + const legacy = vi.fn().mockResolvedValue('legacy') + + await expect( + runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority: vi.fn(), legacy }) + ).rejects.toThrow('Update the host and try again') expect(legacy).not.toHaveBeenCalled() }) - it('preserves the exact legacy path when the capability is absent', async () => { - const hostAuthority = vi.fn().mockResolvedValue('structured') + it('fails closed when the live capability probe fails', async () => { + mocks.probe.mockRejectedValue( + Object.assign(new Error('status temporarily unavailable'), { code: 'runtime_timeout' }) + ) const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockResolvedValue(false) - await expect( - runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy }) - ).resolves.toBe('legacy') - expect(legacy).toHaveBeenCalledOnce() - expect(hostAuthority).not.toHaveBeenCalled() + const result = expect( + runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority: vi.fn(), legacy }) + ).rejects + await result.toThrow('Update the host and try again') + await result.toMatchObject({ code: 'runtime_timeout' }) + expect(legacy).not.toHaveBeenCalled() }) - it('keeps legacy behavior when a read-only capability probe fails', async () => { - const hostAuthority = vi.fn().mockResolvedValue('structured') - const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockRejectedValue(new Error('status temporarily unavailable')) - - await expect( - runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy }) - ).resolves.toBe('legacy') - expect(legacy).toHaveBeenCalledOnce() - expect(hostAuthority).not.toHaveBeenCalled() - }) - - it('does not bypass an incompatible runtime protocol', async () => { + it('preserves an incompatible runtime protocol error', async () => { const compatibilityError = Object.assign(new Error('runtime incompatible'), { code: 'runtime_compat_block' }) + mocks.probe.mockRejectedValue(compatibilityError) const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockRejectedValue(compatibilityError) await expect( - runRemoteAgentSessionLaunch({ - environmentId: 'env-1', - hostAuthority: vi.fn(), - legacy - }) + runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority: vi.fn(), legacy }) ).rejects.toBe(compatibilityError) expect(legacy).not.toHaveBeenCalled() }) @@ -88,7 +121,6 @@ describe('remote agent-session launch routing', () => { it('never downgrades after structured dispatch has started', async () => { const structuredError = new Error('structured response was lost') const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockResolvedValue(true) await expect( runRemoteAgentSessionLaunch({ @@ -100,14 +132,13 @@ describe('remote agent-session launch routing', () => { expect(legacy).not.toHaveBeenCalled() }) - it('uses legacy only for the host pre-side-effect lower-owner response', async () => { - const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockResolvedValue(true) + it('uses fenced legacy for the pre-side-effect lower-owner response', async () => { const legacyRequired = new RuntimeRpcCallError({ id: 'request-1', ok: false, error: { code: 'agent_session_legacy_required', message: 'legacy required' } }) + const legacy = vi.fn().mockResolvedValue('legacy') await expect( runRemoteAgentSessionLaunch({ @@ -116,17 +147,16 @@ describe('remote agent-session launch routing', () => { legacy }) ).resolves.toBe('legacy') - expect(legacy).toHaveBeenCalledOnce() + expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority }) }) - it('uses legacy when a replaced old host does not recognize the structured method', async () => { - const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockResolvedValue(true) + it('does not downgrade when a replacement host rejects the structured method', async () => { const methodNotFound = new RuntimeRpcCallError({ id: 'request-1', ok: false, error: { code: 'method_not_found', message: 'Unknown method' } }) + const legacy = vi.fn().mockResolvedValue('legacy') await expect( runRemoteAgentSessionLaunch({ @@ -134,16 +164,16 @@ describe('remote agent-session launch routing', () => { hostAuthority: vi.fn().mockRejectedValue(methodNotFound), legacy }) - ).resolves.toBe('legacy') - expect(legacy).toHaveBeenCalledOnce() + ).rejects.toBe(methodNotFound) + expect(legacy).not.toHaveBeenCalled() }) - it('uses legacy directly when no structured form exists', async () => { + it('probes and fences legacy when no structured form exists', async () => { const legacy = vi.fn().mockResolvedValue('legacy') await expect(runRemoteAgentSessionLaunch({ environmentId: 'env-1', legacy })).resolves.toBe( 'legacy' ) - expect(mocks.supportsCapability).not.toHaveBeenCalled() + expect(legacy).toHaveBeenCalledWith({ skipCompatibilityCheck: true, authority }) }) }) diff --git a/src/renderer/src/runtime/remote-agent-session-launch.ts b/src/renderer/src/runtime/remote-agent-session-launch.ts index b00c6cbbdab..82c61d89ab1 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.ts @@ -1,46 +1,61 @@ import { AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from '../../../shared/agent-session-host-authority' -import type { RuntimeCapability } from '../../../shared/protocol-version' -import { RuntimeRpcCallError, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import { + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../shared/protocol-version' +import { TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE } from '../../../shared/legacy-terminal-attribution-env' +import { + probeLiveRuntimeEnvironmentCapabilities, + RuntimeRpcCallError, + type LiveRuntimeEnvironmentAuthority +} from './runtime-rpc-client' import { isRuntimeCompatBlockError } from './runtime-protocol-compat' export async function runRemoteAgentSessionLaunch(args: { environmentId: string - hostAuthority?: () => Promise - hostAuthorityCapability?: RuntimeCapability - legacy: (options: { skipCompatibilityCheck: boolean }) => Promise + expectedEnvironmentPairingRevision?: number + hostAuthority?: (authority: LiveRuntimeEnvironmentAuthority) => Promise + requiredHostAuthorityCapabilities?: readonly RuntimeCapability[] + legacy: (options: { + skipCompatibilityCheck: boolean + authority: LiveRuntimeEnvironmentAuthority + }) => Promise }): Promise { - if (!args.hostAuthority) { - return await args.legacy({ skipCompatibilityCheck: false }) - } - let supported: boolean + const requiredCapabilities = [ + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, + ...(args.hostAuthority ? [AGENT_SESSION_HOST_AUTHORITY_CAPABILITY] : []), + ...(args.requiredHostAuthorityCapabilities ?? []) + ] + let probe: Awaited> try { - supported = await runtimeEnvironmentSupportsCapability( - args.environmentId, - args.hostAuthorityCapability ?? AGENT_SESSION_HOST_AUTHORITY_CAPABILITY - ) + probe = await probeLiveRuntimeEnvironmentCapabilities({ + environmentId: args.environmentId, + requiredCapabilities, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision + }) } catch (error) { if (isRuntimeCompatBlockError(error)) { throw error } - // Why: a failed read-only probe has not launched anything, so preserving - // the legacy path cannot duplicate an agent and keeps transient upgrades neutral. - return await args.legacy({ skipCompatibilityCheck: true }) + const code = error && typeof error === 'object' ? Reflect.get(error, 'code') : undefined + // Why: unknown-outcome recovery still needs to classify a transient failed probe. + throw Object.assign( + new Error(TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, { cause: error }), + typeof code === 'string' ? { code } : {} + ) } - // Why: choose before invoking either path; an ambiguous structured outcome - // must never trigger a legacy retry that could spawn a duplicate. - if (!supported) { - return await args.legacy({ skipCompatibilityCheck: true }) + if (!probe.authority.capabilities.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY)) { + throw new Error(TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE) + } + if (!args.hostAuthority || !probe.supported) { + return await args.legacy({ skipCompatibilityCheck: true, authority: probe.authority }) } try { - return await args.hostAuthority() + return await args.hostAuthority(probe.authority) } catch (error) { - if ( - error instanceof RuntimeRpcCallError && - (error.code === 'agent_session_legacy_required' || error.code === 'method_not_found') - ) { - // Why: both responses prove no structured side effect began: the new host rejected an old - // lower owner before dispatch, or an old host never recognized the method. - return await args.legacy({ skipCompatibilityCheck: true }) + if (error instanceof RuntimeRpcCallError && error.code === 'agent_session_legacy_required') { + return await args.legacy({ skipCompatibilityCheck: true, authority: probe.authority }) } throw error } diff --git a/src/renderer/src/runtime/runtime-environment-authority.ts b/src/renderer/src/runtime/runtime-environment-authority.ts new file mode 100644 index 00000000000..f773941bdf3 --- /dev/null +++ b/src/renderer/src/runtime/runtime-environment-authority.ts @@ -0,0 +1,46 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { RuntimeCapability } from '../../../shared/protocol-version' +import { callRuntimeEnvironmentWithRevision } from './runtime-rpc-environment-call' +import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' +import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' +import { unwrapRuntimeRpcResult } from './runtime-rpc-result' + +export type LiveRuntimeEnvironmentAuthority = Readonly<{ + runtimeId: string + expectedEnvironmentPairingRevision: number | undefined + capabilities: readonly RuntimeCapability[] +}> + +export async function probeLiveRuntimeEnvironmentCapabilities(args: { + environmentId: string + requiredCapabilities: readonly RuntimeCapability[] + timeoutMs?: number + expectedEnvironmentPairingRevision?: number +}): Promise<{ supported: boolean; authority: LiveRuntimeEnvironmentAuthority }> { + const environmentId = args.environmentId.trim() + const expectedEnvironmentPairingRevision = captureRuntimeEnvironmentRequestRevision( + environmentId, + args.expectedEnvironmentPairingRevision + ) + const response = await callRuntimeEnvironmentWithRevision({ + environmentId, + method: 'status.get', + params: undefined, + timeoutMs: args.timeoutMs, + expectedEnvironmentPairingRevision + }) + const status = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + assertRuntimeStatusCompatible(status) + const capabilities = status.capabilities ?? [] + return { + supported: args.requiredCapabilities.every((capability) => capabilities.includes(capability)), + authority: Object.freeze({ + runtimeId: status.runtimeId, + expectedEnvironmentPairingRevision, + capabilities: Object.freeze([...capabilities]) + }) + } +} diff --git a/src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts b/src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts index b3e4c103f4f..00995d0db6d 100644 --- a/src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts @@ -3,7 +3,11 @@ import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' -import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' +import { + callRuntimeRpc, + clearRuntimeCompatibilityCacheForTests, + probeLiveRuntimeEnvironmentCapabilities +} from './runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' const runtimeEnvironmentCall = vi.fn() @@ -62,3 +66,57 @@ it('captures the pairing revision before awaiting the compatibility probe', asyn expectedEnvironmentPairingRevision: 10 }) }) + +it('live capability probes ignore a warm cache and pin the captured pairing revision', async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-live', createdAt: 1, pairingRevision: 20 }]) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'warm-status', + ok: true, + result: { + runtimeId: 'runtime-a', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['terminal.attribution-removed.v1'] + } + }) + .mockResolvedValueOnce({ id: 'repo', ok: true, result: { repos: [] } }) + .mockImplementationOnce(async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-live', createdAt: 1, pairingRevision: 21 }]) + return { + id: 'live-status', + ok: true, + result: { + runtimeId: 'runtime-b', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [] + } + } + }) + + await callRuntimeRpc({ kind: 'environment', environmentId: 'env-live' }, 'repo.list') + await expect( + probeLiveRuntimeEnvironmentCapabilities({ + environmentId: 'env-live', + requiredCapabilities: ['terminal.attribution-removed.v1'] + }) + ).resolves.toEqual({ + supported: false, + authority: { + runtimeId: 'runtime-b', + expectedEnvironmentPairingRevision: 20, + capabilities: [] + } + }) + + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ + selector: 'env-live', + method: 'status.get', + params: undefined, + timeoutMs: undefined, + expectedEnvironmentPairingRevision: 20 + }) +}) diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 57ba17b75a0..5f129da43a0 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -19,6 +19,8 @@ export { RuntimeRpcCallError, unwrapRuntimeRpcResult } from './runtime-rpc-result' +export { probeLiveRuntimeEnvironmentCapabilities } from './runtime-environment-authority' +export type { LiveRuntimeEnvironmentAuthority } from './runtime-environment-authority' const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 @@ -54,6 +56,7 @@ export async function callRuntimeRpc( skipCompatibilityCheck?: boolean signal?: AbortSignal expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string } = {} ): Promise { const expectedEnvironmentPairingRevision = @@ -88,7 +91,8 @@ export async function callRuntimeRpc( params: nextParams, timeoutMs: options.timeoutMs, signal: options.signal, - expectedEnvironmentPairingRevision + expectedEnvironmentPairingRevision, + expectedRuntimeId: options.expectedRuntimeId }) return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } diff --git a/src/renderer/src/runtime/runtime-rpc-environment-call.test.ts b/src/renderer/src/runtime/runtime-rpc-environment-call.test.ts new file mode 100644 index 00000000000..3f8b8c53c5c --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-environment-call.test.ts @@ -0,0 +1,28 @@ +import { expect, it, vi } from 'vitest' +import { callRuntimeEnvironmentWithRevision } from './runtime-rpc-environment-call' + +it('preserves abort semantics for runtime-fenced requests', async () => { + const controller = new AbortController() + const unsubscribe = vi.fn() + const call = vi.fn() + const subscribe = vi.fn().mockResolvedValue({ unsubscribe, sendBinary: vi.fn() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call, subscribe } } }) + + const request = callRuntimeEnvironmentWithRevision({ + environmentId: 'env-1', + method: 'status.get', + params: undefined, + signal: controller.signal, + expectedRuntimeId: 'runtime-1' + }) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalled()) + expect(subscribe).toHaveBeenCalledWith( + expect.objectContaining({ expectedRuntimeId: 'runtime-1' }), + expect.any(Object) + ) + controller.abort() + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + expect(call).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/runtime/runtime-rpc-environment-call.ts b/src/renderer/src/runtime/runtime-rpc-environment-call.ts index 5acc0fa0a85..dc5f85f8a62 100644 --- a/src/renderer/src/runtime/runtime-rpc-environment-call.ts +++ b/src/renderer/src/runtime/runtime-rpc-environment-call.ts @@ -7,6 +7,7 @@ export async function callRuntimeEnvironmentWithRevision(args: { timeoutMs?: number signal?: AbortSignal expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string }): Promise { if (args.signal) { return callAbortableRuntimeEnvironment( @@ -15,7 +16,8 @@ export async function callRuntimeEnvironmentWithRevision(args: { args.params, args.timeoutMs, args.signal, - args.expectedEnvironmentPairingRevision + args.expectedEnvironmentPairingRevision, + args.expectedRuntimeId ) } return window.api.runtimeEnvironments.call({ @@ -23,6 +25,7 @@ export async function callRuntimeEnvironmentWithRevision(args: { method: args.method, params: args.params, timeoutMs: args.timeoutMs, - expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision + expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision, + expectedRuntimeId: args.expectedRuntimeId }) } diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index ba309a39f91..3e64191dcab 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -56,9 +56,12 @@ const mocks = vi.hoisted(() => ({ deliverLaunchPromptToAgentTab: vi.fn(), seedNativeChatLaunchDraftForAgentTab: vi.fn(), getRuntimeEnvironmentIdForWorktree: vi.fn(), - hasMaterializedWebRuntimeBrowserPage: vi.fn() + hasMaterializedWebRuntimeBrowserPage: vi.fn(), + toastError: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) + vi.mock('../store', () => ({ useAppStore: { getState: mocks.getState, @@ -112,6 +115,21 @@ function makeSnapshot(): RuntimeMobileSessionTabsResult { } } +function makeAttributionSafeStatus(capabilities: string[] = ['terminal.attribution-removed.v1']) { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + appVersion: '1.4.181', + capabilities + } + } +} + describe('refreshWebRuntimeSessionTabsSnapshot', () => { afterEach(() => { resetWebAgentSessionHandoffsForTests() @@ -1430,6 +1448,7 @@ describe('createWebRuntimeSessionTerminal', () => { }) const runtimeCall = vi .fn() + .mockResolvedValueOnce(makeAttributionSafeStatus()) .mockResolvedValueOnce({ id: 'create', ok: true, @@ -1450,6 +1469,43 @@ describe('createWebRuntimeSessionTerminal', () => { expect(selectedHosts).toEqual([RUNTIME_EXECUTION_HOST_ID]) }) + it('refuses session-tab creation when an older host drops terminal environment fields', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'status', + ok: true, + result: { capabilities: ['mobile.tasks.v1'] } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect(createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID })).resolves.toEqual({ + status: 'failed', + message: + 'Creating terminals requires a newer workspace host that can verify safe terminal environment forwarding. Update the host and try again.' + }) + + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual(['status.get']) + }) + + it('returns a failed outcome when attribution cleanup exceeds the deletion limit', async () => { + const runtimeCall = vi.fn() + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + envToDelete: Array.from({ length: 32 }, (_, index) => `KEY_${index}`) + }) + ).resolves.toEqual({ + status: 'failed', + message: 'Terminal environment deletion limit leaves no room for attribution cleanup' + }) + expect(runtimeCall).not.toHaveBeenCalled() + }) + it.each([ { sessionKind: 'fresh' as const, activate: true }, { sessionKind: 'fresh' as const, activate: false }, @@ -1469,7 +1525,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } } @@ -1558,7 +1614,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } }) .mockResolvedValueOnce({ @@ -1622,6 +1678,7 @@ describe('createWebRuntimeSessionTerminal', () => { expect(runtimeCall).toHaveBeenNthCalledWith(2, { selector: ENVIRONMENT_ID, expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: 'runtime-1', method: 'terminal.createAgentSession', params: { clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), @@ -1666,6 +1723,7 @@ describe('createWebRuntimeSessionTerminal', () => { it('keeps exact legacy ordering when structured creation cannot express afterTabId', async () => { const runtimeCall = vi .fn() + .mockResolvedValueOnce(makeAttributionSafeStatus()) .mockResolvedValueOnce({ id: 'legacy-create', ok: true, @@ -1691,8 +1749,10 @@ describe('createWebRuntimeSessionTerminal', () => { }) ).resolves.toEqual({ status: 'created' }) - expect(runtimeCall).toHaveBeenNthCalledWith(1, { + expect(runtimeCall).toHaveBeenNthCalledWith(2, { selector: ENVIRONMENT_ID, + expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: 'runtime-1', method: 'session.tabs.createTerminal', params: { worktree: `id:${WORKTREE_ID}`, @@ -1700,6 +1760,8 @@ describe('createWebRuntimeSessionTerminal', () => { targetGroupId: 'group-left', command: undefined, cwd: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], startupCommandDelivery: undefined, agent: 'codex', activate: false, @@ -1709,6 +1771,7 @@ describe('createWebRuntimeSessionTerminal', () => { timeoutMs: 15_000 }) expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'status.get', 'session.tabs.createTerminal', 'session.tabs.list' ]) @@ -1725,6 +1788,7 @@ describe('createWebRuntimeSessionTerminal', () => { }) const runtimeCall = vi .fn() + .mockResolvedValueOnce(makeAttributionSafeStatus()) .mockResolvedValueOnce({ id: 'create-terminal', ok: true, @@ -1781,7 +1845,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } } @@ -1842,7 +1906,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } } @@ -1895,7 +1959,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: [] + capabilities: ['terminal.attribution-removed.v1'] } } } @@ -1926,6 +1990,8 @@ describe('createWebRuntimeSessionTerminal', () => { expect(runtimeCall).toHaveBeenNthCalledWith(2, { selector: ENVIRONMENT_ID, + expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: 'old-runtime', method: 'session.tabs.createTerminal', params: { worktree: `id:${WORKTREE_ID}`, @@ -1933,6 +1999,8 @@ describe('createWebRuntimeSessionTerminal', () => { targetGroupId: 'group-left', command: undefined, cwd: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], startupCommandDelivery: undefined, launchAgent: 'codex', activate: false, @@ -1959,7 +2027,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: [] + capabilities: ['terminal.attribution-removed.v1'] } } } @@ -1994,6 +2062,8 @@ describe('createWebRuntimeSessionTerminal', () => { expect(runtimeCall).toHaveBeenNthCalledWith(2, { selector: ENVIRONMENT_ID, + expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: 'old-runtime', method: 'session.tabs.createTerminal', params: { worktree: `id:${WORKTREE_ID}`, @@ -2001,7 +2071,8 @@ describe('createWebRuntimeSessionTerminal', () => { targetGroupId: undefined, command: "codex resume 'session-1'", cwd: undefined, - env: { CODEX_PROFILE: 'captured' }, + env: { CODEX_PROFILE: 'captured', ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], startupCommandDelivery: undefined, launchConfig: { agentCommand: 'codex', @@ -2030,7 +2101,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } } @@ -2071,9 +2142,14 @@ describe('createWebRuntimeSessionTerminal', () => { expect(methods).toEqual(['status.get', 'session.tabs.createTerminal', 'session.tabs.list']) expect(runtimeCall.mock.calls[1]?.[0]).toMatchObject({ + expectedRuntimeId: 'new-runtime', params: { command: "omp --resume '/custom/omp/project/session.jsonl'", - env: { PI_CODING_AGENT_DIR: '/custom/omp' }, + env: { + PI_CODING_AGENT_DIR: '/custom/omp', + ORCA_ATTRIBUTION_BYPASS: '1' + }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], launchAgent: 'omp' } }) @@ -2090,7 +2166,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } } @@ -2152,7 +2228,7 @@ describe('createWebRuntimeSessionTerminal', () => { graphStatus: 'ready', runtimeProtocolVersion: 3, minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1'] + capabilities: ['agent-session.host-authority.v1', 'terminal.attribution-removed.v1'] } } } @@ -2782,17 +2858,25 @@ describe('splitWebRuntimeTerminal', () => { }) it('passes telemetry source to the host split while allowing the mirrored split event to be suppressed', async () => { - const runtimeCall = vi.fn().mockResolvedValue({ - id: 'split', - ok: true, - result: { - split: { - handle: 'terminal-2', - tabId: 'tab-1', - paneRuntimeId: -1 - } - } - }) + const runtimeCall = vi.fn(async (request: { method: string }) => + request.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { capabilities: ['terminal.attribution-removed.v1'] } + } + : { + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + paneRuntimeId: -1 + } + } + } + ) vi.stubGlobal('window', { api: { runtimeEnvironments: { @@ -2811,13 +2895,22 @@ describe('splitWebRuntimeTerminal', () => { consumePendingWebRuntimeSplitMirrorTelemetry('remote:web-env-1@@terminal-1', 'horizontal') ).toBe(true) - await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) - expect(runtimeCall).toHaveBeenCalledWith({ + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2)) + expect(runtimeCall).toHaveBeenNthCalledWith(1, { selector: 'web-env-1', + expectedEnvironmentPairingRevision: undefined, + method: 'status.get', + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: 'web-env-1', + expectedEnvironmentPairingRevision: undefined, method: 'terminal.split', params: { terminal: 'terminal-1', direction: 'horizontal', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], telemetrySource: 'keyboard' }, timeoutMs: 15_000 @@ -2826,11 +2919,19 @@ describe('splitWebRuntimeTerminal', () => { it('does not track rejected host split RPCs', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const runtimeCall = vi.fn().mockResolvedValue({ - id: 'split', - ok: false, - error: { code: 'terminal_exited', message: 'Terminal exited' } - }) + const runtimeCall = vi.fn(async (request: { method: string }) => + request.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { capabilities: ['terminal.attribution-removed.v1'] } + } + : { + id: 'split', + ok: false, + error: { code: 'terminal_exited', message: 'Terminal exited' } + } + ) vi.stubGlobal('window', { api: { runtimeEnvironments: { @@ -2843,23 +2944,53 @@ describe('splitWebRuntimeTerminal', () => { splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'context_menu') ).toBe(true) - await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledTimes(1)) + expect(mocks.toastError).toHaveBeenCalledWith('Terminal exited') expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled() }) - it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => { + it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const runtimeCall = vi.fn().mockResolvedValue({ - id: 'split', + id: 'status', ok: true, - result: { - split: { - handle: 'terminal-2', - tabId: 'tab-1', - ptyId: 'pty-2' - } - } + result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] } }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + expect(splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard')).toBe( + true + ) + + await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledTimes(1)) + expect(runtimeCall).toHaveBeenCalledTimes(1) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.split' }) + ) + expect(mocks.toastError).toHaveBeenCalledWith( + expect.stringContaining('Update the host and try again') + ) + }) + + it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => { + const runtimeCall = vi.fn(async (request: { method: string }) => + request.method === 'status.get' + ? { id: 'status', ok: true, result: { capabilities: ['terminal.attribution-removed.v1'] } } + : { + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + ptyId: 'pty-2' + } + } + } + ) vi.stubGlobal('window', { api: { runtimeEnvironments: { @@ -2874,7 +3005,7 @@ describe('splitWebRuntimeTerminal', () => { true ) - await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2)) }) }) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 9cb617d5692..089ceafd53c 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -8,6 +8,7 @@ import type { RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabsResult, RuntimeSessionTabCloseReason, + RuntimeStatus, RuntimeTerminalCreate, RuntimeTerminalClose, RuntimeTerminalSplit @@ -18,7 +19,10 @@ import type { SleepingAgentLaunchConfig, AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' -import { AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import type { AgentLaunchPreferences, AgentPromptDelivery, @@ -81,6 +85,15 @@ import { throwIfE2eWebRuntimeBrowserCapabilityUnavailable, throwIfE2eWebRuntimeBrowserReconciliationFails } from './web-runtime-browser-creation-e2e-fault' +import { + addLegacyTerminalAttributionDisableRequest, + hostSupportsSessionTabTerminalCreateAttributionDisable, + hostSupportsTerminalSplitAttributionDisable, + SESSION_TAB_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + withLegacyTerminalAttributionDisabledEnv +} from '../../../shared/legacy-terminal-attribution-env' +import { toast } from 'sonner' export { HOST_TERMINAL_SURFACE_SEPARATOR, @@ -130,6 +143,7 @@ function captureRuntimeEnvironmentCall( method: string params?: unknown timeoutMs?: number + expectedRuntimeId?: string }) => Promise> { return (args) => window.api.runtimeEnvironments.call({ @@ -264,6 +278,14 @@ async function createWebRuntimeSessionTerminalResult( } const intentOwner = captureWebSessionIntentOwner(environmentId) const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) + const assertSessionTabCreateAttributionDisableSupported = async (): Promise => { + const response = await callEnvironment({ method: 'status.get', timeoutMs: 15_000 }) + const status = unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + if (!hostSupportsSessionTabTerminalCreateAttributionDisable(status)) { + throw new Error(SESSION_TAB_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE) + } + return status + } if (args.selectWorktree !== false) { selectWebRuntimeSessionWorktree(args.worktreeId, environmentId) @@ -272,6 +294,8 @@ async function createWebRuntimeSessionTerminalResult( let createdTabId: string | undefined let createdLeafId: string | undefined try { + const env = withLegacyTerminalAttributionDisabledEnv(args.env) + const envToDelete = addLegacyTerminalAttributionDisableRequest(args.envToDelete) const agent = args.launchAgent ?? args.agent const agentArgsOverride = args.agentArgs !== undefined ? args.agentArgs : args.launchConfig?.agentArgs @@ -283,7 +307,7 @@ async function createWebRuntimeSessionTerminalResult( ? undefined : args.agentSessionKind === 'resume' ? args.providerSession - ? async () => + ? async (authority: { runtimeId: string }) => unwrapRuntimeRpcResult( (await callEnvironment({ method: 'terminal.ensureAgentSession', @@ -301,11 +325,12 @@ async function createWebRuntimeSessionTerminalResult( : {}), presentation: 'background' }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedRuntimeId: authority.runtimeId })) as RuntimeRpcResponse ) : undefined - : async () => + : async (authority: { runtimeId: string }) => await createAgentSessionCreateOperation().run(async (clientOperationId) => unwrapRuntimeRpcResult( (await callEnvironment({ @@ -328,7 +353,8 @@ async function createWebRuntimeSessionTerminalResult( }, clientOperationId ), - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedRuntimeId: authority.runtimeId })) as RuntimeRpcResponse ) ) @@ -336,11 +362,15 @@ async function createWebRuntimeSessionTerminalResult( terminal: CreatedAgentTerminalIdentity }>({ environmentId, + expectedEnvironmentPairingRevision: intentOwner.pairingRevision, ...(hostAuthority ? { hostAuthority } : {}), - ...(args.agentSessionKind === 'resume' && agent === 'omp' - ? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } - : {}), - legacy: async () => { + requiredHostAuthorityCapabilities: [ + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, + ...(args.agentSessionKind === 'resume' && agent === 'omp' + ? [AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY] + : []) + ], + legacy: async ({ authority }) => { const response = await callEnvironment({ method: 'session.tabs.createTerminal', params: { @@ -349,8 +379,8 @@ async function createWebRuntimeSessionTerminalResult( targetGroupId: args.targetGroupId, command: args.command, cwd: args.cwd, - ...(args.env ? { env: args.env } : {}), - ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), + env, + ...(envToDelete ? { envToDelete } : {}), startupCommandDelivery: args.startupCommandDelivery, ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), ...(args.launchToken ? { launchToken: args.launchToken } : {}), @@ -362,7 +392,8 @@ async function createWebRuntimeSessionTerminalResult( select: args.activate !== false, navigation: 'caller' }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedRuntimeId: authority.runtimeId }) const legacyCreated = unwrapRuntimeRpcResult( response as RuntimeRpcResponse @@ -394,6 +425,7 @@ async function createWebRuntimeSessionTerminalResult( }) } } else { + const status = await assertSessionTabCreateAttributionDisableSupported() const response = await callEnvironment({ method: 'session.tabs.createTerminal', params: { @@ -402,8 +434,8 @@ async function createWebRuntimeSessionTerminalResult( targetGroupId: args.targetGroupId, command: args.command, cwd: args.cwd, - ...(args.env ? { env: args.env } : {}), - ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), + env, + ...(envToDelete ? { envToDelete } : {}), startupCommandDelivery: args.startupCommandDelivery, ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), ...(args.launchToken ? { launchToken: args.launchToken } : {}), @@ -413,7 +445,8 @@ async function createWebRuntimeSessionTerminalResult( select: args.activate !== false, navigation: 'caller' }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedRuntimeId: status.runtimeId }) const created = unwrapRuntimeRpcResult( response as RuntimeRpcResponse @@ -1185,26 +1218,37 @@ export function splitWebRuntimeTerminal( direction, pendingMirrorSuppressionId ) - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'terminal.split', - params: { - terminal: remote.handle, - direction, - telemetrySource - }, - timeoutMs: 15_000 + const callEnvironment = captureRuntimeEnvironmentCall(environmentId) + void callEnvironment({ + method: 'status.get', + timeoutMs: 15_000 + }) + .then((response) => { + const status = unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + if (!hostSupportsTerminalSplitAttributionDisable(status)) { + throw new Error(TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE) + } + return callEnvironment({ + method: 'terminal.split', + params: { + terminal: remote.handle, + direction, + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + telemetrySource + }, + timeoutMs: 15_000, + expectedRuntimeId: status.runtimeId + }) }) .then((response) => { unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ split: RuntimeTerminalSplit }>) }) .catch((error) => { releasePendingMirrorSuppression() - console.warn( - '[web-runtime-session] failed to split terminal:', - error instanceof Error ? error.message : String(error) - ) + const message = error instanceof Error ? error.message : String(error) + toast.error(message) + console.warn('[web-runtime-session] failed to split terminal:', message) }) return true } diff --git a/src/shared/legacy-terminal-attribution-env.test.ts b/src/shared/legacy-terminal-attribution-env.test.ts new file mode 100644 index 00000000000..1bb84cc5f96 --- /dev/null +++ b/src/shared/legacy-terminal-attribution-env.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + addLegacyTerminalAttributionDisableRequest, + hostSupportsSessionTabTerminalCreateAttributionDisable, + hostSupportsTerminalCreateAttributionDisable, + hostSupportsTerminalSplitAttributionDisable, + LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY, + LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY, + withLegacyTerminalAttributionDisabledEnv +} from './legacy-terminal-attribution-env' + +describe('legacy terminal attribution environment', () => { + it('adds the inert old-host gate once', () => { + expect(addLegacyTerminalAttributionDisableRequest(['CODEX_HOME'])).toEqual([ + 'CODEX_HOME', + LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY + ]) + expect( + addLegacyTerminalAttributionDisableRequest([LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY]) + ).toEqual([LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY]) + }) + + it('fails instead of dropping a caller deletion at the wire limit', () => { + const full = Array.from({ length: 32 }, (_, index) => `KEY_${index}`) + expect(() => addLegacyTerminalAttributionDisableRequest(full)).toThrow( + 'Terminal environment deletion limit' + ) + }) + + it('adds the wrapper bypass without mutating caller env', () => { + const env = { CODEX_HOME: '/tmp/codex' } + expect(withLegacyTerminalAttributionDisabledEnv(env)).toEqual({ + CODEX_HOME: '/tmp/codex', + [LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY]: '1' + }) + expect(env).toEqual({ CODEX_HOME: '/tmp/codex' }) + }) + + it('accepts only split hosts that removed attribution for every pane owner', () => { + expect(hostSupportsTerminalSplitAttributionDisable({ appVersion: '1.4.18' })).toBe(false) + expect(hostSupportsTerminalSplitAttributionDisable({ appVersion: '1.4.19' })).toBe(false) + expect(hostSupportsTerminalSplitAttributionDisable({ appVersion: 'v1.4.181' })).toBe(false) + expect(hostSupportsTerminalSplitAttributionDisable({ capabilities: ['mobile.tasks.v1'] })).toBe( + false + ) + expect( + hostSupportsTerminalSplitAttributionDisable({ + capabilities: ['terminal.attribution-removed.v1'] + }) + ).toBe(true) + }) + + it('accepts only capability-proven terminal creation hosts', () => { + expect(hostSupportsSessionTabTerminalCreateAttributionDisable({ appVersion: '1.4.89' })).toBe( + false + ) + expect(hostSupportsSessionTabTerminalCreateAttributionDisable({ appVersion: '1.4.90' })).toBe( + false + ) + expect( + hostSupportsSessionTabTerminalCreateAttributionDisable({ appVersion: 'not-semver' }) + ).toBe(false) + expect( + hostSupportsSessionTabTerminalCreateAttributionDisable({ + capabilities: ['workspace-run-context.v1'] + }) + ).toBe(false) + expect( + hostSupportsSessionTabTerminalCreateAttributionDisable({ + capabilities: ['terminal.quick-commands.v1'] + }) + ).toBe(false) + }) + + it('accepts capability-proven current hosts regardless of development version', () => { + const status = { + appVersion: '0.0.0-dev', + capabilities: ['terminal.attribution-removed.v1'] + } + expect(hostSupportsTerminalSplitAttributionDisable(status)).toBe(true) + expect(hostSupportsTerminalCreateAttributionDisable(status)).toBe(true) + expect(hostSupportsSessionTabTerminalCreateAttributionDisable(status)).toBe(true) + }) + + it('fails closed for malformed host status fields', () => { + expect( + hostSupportsSessionTabTerminalCreateAttributionDisable({ + appVersion: 1.49, + capabilities: 'terminal.attribution-removed.v1' + }) + ).toBe(false) + expect( + hostSupportsTerminalSplitAttributionDisable({ + appVersion: {}, + capabilities: ['terminal.attribution-removed.v1', 1] + }) + ).toBe(false) + }) +}) diff --git a/src/shared/legacy-terminal-attribution-env.ts b/src/shared/legacy-terminal-attribution-env.ts new file mode 100644 index 00000000000..5237280a486 --- /dev/null +++ b/src/shared/legacy-terminal-attribution-env.ts @@ -0,0 +1,68 @@ +import { TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY } from './protocol-version' + +export const LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY = 'ORCA_ENABLE_GIT_ATTRIBUTION' +export const LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY = 'ORCA_ATTRIBUTION_BYPASS' +export const TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE = + 'Terminal splitting requires an updated workspace host that can verify attribution removal for every pane owner. Update the host and try again.' +export const TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE = + 'Creating terminals requires an updated workspace host that can verify attribution removal. Update the host and try again.' +export const MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE = + 'Creating terminals from Orca Mobile requires a newer workspace host that can verify safe terminal environment forwarding. Update the host and try again.' +export const SESSION_TAB_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE = + 'Creating terminals requires a newer workspace host that can verify safe terminal environment forwarding. Update the host and try again.' +const MAX_TERMINAL_ENV_DELETION_KEYS = 32 + +type TerminalAttributionHostStatus = { + appVersion?: string + capabilities?: string[] +} + +function readTerminalAttributionHostStatus(value: unknown): TerminalAttributionHostStatus { + if (!value || typeof value !== 'object') { + return {} + } + const appVersion = Reflect.get(value, 'appVersion') + const capabilities = Reflect.get(value, 'capabilities') + return { + ...(typeof appVersion === 'string' ? { appVersion } : {}), + ...(Array.isArray(capabilities) && capabilities.every((entry) => typeof entry === 'string') + ? { capabilities } + : {}) + } +} + +export function hostSupportsTerminalSplitAttributionDisable(value: unknown): boolean { + const status = readTerminalAttributionHostStatus(value) + // Why: only removal-capable hosts cover both runtime- and renderer-owned split targets. + return status.capabilities?.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY) === true +} + +export function hostSupportsTerminalCreateAttributionDisable(value: unknown): boolean { + const status = readTerminalAttributionHostStatus(value) + return status.capabilities?.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY) === true +} + +export function hostSupportsSessionTabTerminalCreateAttributionDisable(value: unknown): boolean { + const status = readTerminalAttributionHostStatus(value) + return status.capabilities?.includes(TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY) === true +} + +export function withLegacyTerminalAttributionDisabledEnv( + env: Record | undefined +): Record { + return { ...env, [LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY]: '1' } +} + +export function addLegacyTerminalAttributionDisableRequest( + envToDelete: readonly string[] | undefined +): string[] | undefined { + const deduplicated = [...new Set(envToDelete ?? [])] + const gateIndex = deduplicated.indexOf(LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY) + if (gateIndex !== -1) { + deduplicated.splice(gateIndex, 1) + } + if (deduplicated.length >= MAX_TERMINAL_ENV_DELETION_KEYS) { + throw new Error('Terminal environment deletion limit leaves no room for attribution cleanup') + } + return [...deduplicated, LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY] +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index eb61be79c97..28a89f9814b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -84,6 +84,9 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const +// Why: old host-authority launches cannot accept env deletions and may still inject attribution. +export const TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY = + 'terminal.attribution-removed.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -121,6 +124,7 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + TERMINAL_ATTRIBUTION_REMOVED_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index 9ead47f4067..4c3bfba011e 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -35,6 +35,25 @@ afterEach(async () => { }) describe('subscribeRemoteRuntimeRequest', () => { + it('serializes the expected runtime fence on subscription requests', async () => { + const server = await createSubscriptionServer() + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.subscribe', + {}, + 1000, + { onResponse: vi.fn(), onError: vi.fn() }, + undefined, + { expectedRuntimeId: 'runtime-1' } + ) + + await expect(server.nextRequest).resolves.toMatchObject({ + method: 'terminal.subscribe', + expectedRuntimeId: 'runtime-1' + }) + subscription.close() + }) + it('includes WebSocket close details when subscription admission is rejected', async () => { const server = await createClosingServer(1013, 'Maximum connections reached') @@ -335,6 +354,7 @@ async function createSubscriptionServer( pairing: PairingOffer nextBinary: Promise nextAuth: Promise + nextRequest: Promise> }> { const serverKeyPair = generateKeyPair() let resolveBinary: (bytes: Uint8Array) => void = () => {} @@ -345,6 +365,10 @@ async function createSubscriptionServer( const nextAuth = new Promise((resolve) => { resolveAuth = resolve }) + let resolveRequest: (request: Record) => void = () => {} + const nextRequest = new Promise>((resolve) => { + resolveRequest = resolve + }) const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) servers.push(wss) @@ -386,7 +410,8 @@ async function createSubscriptionServer( return } - const request = JSON.parse(plaintext) as { id: string } + const request = JSON.parse(plaintext) as { id: string } & Record + resolveRequest(request) sendEncrypted(ws, sharedKey, { id: request.id, ok: true, @@ -419,7 +444,7 @@ async function createSubscriptionServer( if (!pairing) { throw new Error('Failed to create test pairing') } - return { pairing, nextBinary, nextAuth } + return { pairing, nextBinary, nextAuth, nextRequest } } function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void { diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index c53565dc8a2..3b308724b57 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -497,14 +497,16 @@ export async function subscribeRemoteRuntimeRequest( params: unknown, timeoutMs: number, callbacks: RemoteRuntimeSubscriptionCallbacks, - livenessOptions?: RemoteRuntimeSocketLivenessOptions + livenessOptions?: RemoteRuntimeSocketLivenessOptions, + envelope?: RuntimeOrchestrationEnvelope ): Promise { const requestId = randomUUID() const serializedRequest = serializeRemoteRuntimeRpcRequest({ requestId, deviceToken: pairing.deviceToken, method, - params + params, + envelope }) const serializedAuth = serializeRemoteRuntimePayload({ type: 'e2ee_auth', diff --git a/src/shared/remote-runtime-memory-limits.ts b/src/shared/remote-runtime-memory-limits.ts index a41409ffdfe..8b5ce04cc56 100644 --- a/src/shared/remote-runtime-memory-limits.ts +++ b/src/shared/remote-runtime-memory-limits.ts @@ -71,6 +71,7 @@ export function serializeRemoteRuntimeRpcRequest(args: { deviceToken: args.deviceToken, method: args.method, params: args.params, + expectedRuntimeId: args.envelope?.expectedRuntimeId, orchestrationCapability: args.envelope?.orchestrationCapability, orchestrationContractVersion: args.envelope?.orchestrationContractVersion, orchestrationRequestId: args.envelope?.orchestrationRequestId, diff --git a/src/shared/remote-runtime-shared-control-admission.ts b/src/shared/remote-runtime-shared-control-admission.ts index e526ba1bb95..f6638ed7f63 100644 --- a/src/shared/remote-runtime-shared-control-admission.ts +++ b/src/shared/remote-runtime-shared-control-admission.ts @@ -12,6 +12,7 @@ export function admitSharedControlSubscription(args: { deviceToken: string method: string params: unknown + expectedRuntimeId?: string }): number { if (args.subscriptions.size >= REMOTE_RUNTIME_MAX_SUBSCRIPTIONS) { throw new RemoteRuntimeClientError( @@ -33,12 +34,18 @@ export function admitSharedControlSubscription(args: { return retainedParamsBytes } -function serializeRequest(args: { deviceToken: string; method: string; params: unknown }): void { +function serializeRequest(args: { + deviceToken: string + method: string + params: unknown + expectedRuntimeId?: string +}): void { serializeRemoteRuntimeRpcRequest({ requestId: '00000000-0000-4000-8000-000000000000', deviceToken: args.deviceToken, method: args.method, - params: args.params + params: args.params, + envelope: { expectedRuntimeId: args.expectedRuntimeId } }) } diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 4fb40525245..93925ccf15e 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -37,7 +37,7 @@ export class RemoteRuntimeSharedControlConnection { private sharedKey: Uint8Array | null = null private socketCleanup: (() => void) | null = null private readonly reconnect = new SharedControlReconnectScheduler() - private readonly readyStableReset: SharedControlReadyStableResetTimer + private readonly stableReset: SharedControlReadyStableResetTimer private intentionallyClosed = false private lastConnectedAt: number | null = null private lastClose: { code: number; reason: string } | null = null @@ -51,15 +51,13 @@ export class RemoteRuntimeSharedControlConnection { constructor( private readonly pairing: PairingOffer, - private readonly options: { + private readonly opts: { environmentId?: string reconnectStableResetMs?: number liveness?: RemoteRuntimeSocketLivenessOptions } = {} ) { - this.readyStableReset = new SharedControlReadyStableResetTimer( - options.reconnectStableResetMs ?? 30_000 - ) + this.stableReset = new SharedControlReadyStableResetTimer(opts.reconnectStableResetMs ?? 30_000) } request( @@ -85,13 +83,15 @@ export class RemoteRuntimeSharedControlConnection { method: string, params: unknown, timeoutMs: number, - callbacks: SharedControlSubscriptionCallbacks + callbacks: SharedControlSubscriptionCallbacks, + expectedRuntimeId?: string ): Promise { return startSharedControlSubscription({ subscriptions: this.subscriptions, deviceToken: this.pairing.deviceToken, method, params, + expectedRuntimeId, callbacks, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), sendSubscription: (subscription) => this.sendSubscription(subscription), @@ -182,7 +182,7 @@ export class RemoteRuntimeSharedControlConnection { onError: (error) => this.handleSocketClosed(error, socketGeneration), onTextFrame: (frame) => this.handleTextFrame(frame, socketGeneration), liveness: { - options: this.options.liveness, + options: this.opts.liveness, onDead: (error) => this.handleSocketClosed(error, socketGeneration) } }) @@ -204,7 +204,7 @@ export class RemoteRuntimeSharedControlConnection { frame, state: this.state, sharedKey: this.sharedKey, - environmentId: this.options.environmentId, + environmentId: this.opts.environmentId, deviceToken: this.pairing.deviceToken, pendingRequests: this.pendingRequests, subscriptions: this.subscriptions, @@ -217,7 +217,7 @@ export class RemoteRuntimeSharedControlConnection { sendEncrypted: (payload) => this.sendEncrypted(payload), markReady: () => { this.lastConnectedAt = Date.now() - this.readyStableReset.schedule({ + this.stableReset.schedule({ getState: () => this.state, getSocket: () => this.ws, reset: () => this.reconnect.resetAttempt() @@ -301,7 +301,7 @@ export class RemoteRuntimeSharedControlConnection { private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void { closeSharedControlSocket({ - environmentId: this.options.environmentId, + environmentId: this.opts.environmentId, state: this.state, pendingRequests: this.pendingRequests, subscriptions: this.subscriptions, @@ -311,7 +311,7 @@ export class RemoteRuntimeSharedControlConnection { ws: this.ws, error, preserveReadyWaitersAndPendingRequests, - clearReadyStableTimer: () => this.readyStableReset.clear() + clearReadyStableTimer: () => this.stableReset.clear() }) this.ws = this.sharedKey = null this.socketCleanup = null diff --git a/src/shared/remote-runtime-shared-control-send.ts b/src/shared/remote-runtime-shared-control-send.ts index 2a7ff16cfa5..1862751cd6b 100644 --- a/src/shared/remote-runtime-shared-control-send.ts +++ b/src/shared/remote-runtime-shared-control-send.ts @@ -36,7 +36,8 @@ export function sendSharedControlSubscription(args: { id: args.subscription.requestId, deviceToken: args.deviceToken, method: args.subscription.method, - params: args.subscription.params + params: args.subscription.params, + expectedRuntimeId: args.subscription.expectedRuntimeId }) ) { args.subscription.sent = true diff --git a/src/shared/remote-runtime-shared-control-subscription-start.ts b/src/shared/remote-runtime-shared-control-subscription-start.ts index 2b22aaabc5b..1cc4a91a5a8 100644 --- a/src/shared/remote-runtime-shared-control-subscription-start.ts +++ b/src/shared/remote-runtime-shared-control-subscription-start.ts @@ -14,6 +14,7 @@ export async function startSharedControlSubscription(args: { deviceToken: string method: string params: unknown + expectedRuntimeId?: string callbacks: SharedControlSubscriptionCallbacks ensureReady: () => Promise sendSubscription: (subscription: SharedControlLogicalSubscription) => void @@ -23,13 +24,15 @@ export async function startSharedControlSubscription(args: { subscriptions: args.subscriptions, deviceToken: args.deviceToken, method: args.method, - params: args.params + params: args.params, + expectedRuntimeId: args.expectedRuntimeId }) const requestId = randomUUID() const subscription = createSharedControlSubscription({ requestId, method: args.method, params: args.params, + expectedRuntimeId: args.expectedRuntimeId, retainedParamsBytes, callbacks: args.callbacks }) diff --git a/src/shared/remote-runtime-shared-control-subscriptions.test.ts b/src/shared/remote-runtime-shared-control-subscriptions.test.ts index de5d5718b55..b61b2eff34e 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.test.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' +import { sendSharedControlSubscription } from './remote-runtime-shared-control-send' import { closeSharedControlLogicalSubscription, createSharedControlSubscription, @@ -33,6 +34,39 @@ function okResponse(subscriptionId: string): RuntimeRpcResponse { } describe('closeSharedControlLogicalSubscription — replay-window leak', () => { + it('preserves the runtime fence when reconnect replay resends a subscription', () => { + const subscriptions = new Map>() + const subscription = createSharedControlSubscription({ + requestId: 'req-fenced', + method: 'runtime.clientEvents.subscribe', + params: null, + expectedRuntimeId: 'runtime-1', + retainedParamsBytes: 0, + callbacks: { onResponse: vi.fn(), onError: vi.fn() } + }) + subscriptions.set(subscription.requestId, subscription) + const payloads: unknown[] = [] + const send = (current: SharedControlLogicalSubscription): void => + sendSharedControlSubscription({ + subscriptions, + subscription: current, + deviceToken: 'device-token', + send: (payload) => { + payloads.push(payload) + return true + } + }) + + send(subscription) + replaySharedControlSubscriptions({ subscriptions, send }) + + expect(payloads).toHaveLength(2) + expect(payloads).toEqual([ + expect.objectContaining({ expectedRuntimeId: 'runtime-1' }), + expect.objectContaining({ expectedRuntimeId: 'runtime-1' }) + ]) + }) + it('sends the unsubscribe when closed after an established subscribe replay completes', () => { const { subscriptions, subscription } = makeSubscriptions() // First establishment: server assigned a concrete subscription id. diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index 4a93076d23f..92da86bcd5d 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -14,6 +14,7 @@ export function createSharedControlSubscription(args: { requestId: string method: string params: unknown + expectedRuntimeId?: string retainedParamsBytes: number callbacks: SharedControlSubscriptionCallbacks }): SharedControlLogicalSubscription { @@ -21,6 +22,7 @@ export function createSharedControlSubscription(args: { requestId: args.requestId, method: args.method, params: args.params, + expectedRuntimeId: args.expectedRuntimeId, retainedParamsBytes: args.retainedParamsBytes, callbacks: args.callbacks, sent: false, diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index c6e55134762..592fdc47168 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -33,6 +33,7 @@ export type SharedControlLogicalSubscription = { requestId: string method: string params: unknown + expectedRuntimeId?: string retainedParamsBytes: number callbacks: SharedControlSubscriptionCallbacks sent: boolean diff --git a/src/shared/runtime-rpc-envelope.ts b/src/shared/runtime-rpc-envelope.ts index d407de72cb9..38cbc4659b1 100644 --- a/src/shared/runtime-rpc-envelope.ts +++ b/src/shared/runtime-rpc-envelope.ts @@ -76,6 +76,8 @@ export type RuntimeRpcFailure = { export type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure export type RuntimeOrchestrationEnvelope = { + /** Reject before dispatch when the request reaches a replacement runtime. */ + expectedRuntimeId?: string orchestrationCapability?: string orchestrationContractVersion?: number orchestrationRequestId?: string From c61f29639c869fb7466a914a370b2cff0451df02 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:24:06 -0700 Subject: [PATCH 06/25] fix(workspaces): recognize pasted Linear issue URLs (#14190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workspaces): recognize pasted Linear issue URLs * fix(workspaces): resolve legacy Linear URLs and keep typed-name fallback Saved API-key Linear workspaces often omit organizationUrlKey, so pasted issue URLs never called fetchLinearIssue. Probe those unknown-org workspaces and accept only a matching issue URL. Keep "Use … as workspace name" visible in Smart Entry when a Linear URL owns the results. sourceIntent still focuses the issue row. Co-authored-by: Orca * fix(workspaces): jump to existing worktrees from pasted task URLs Cmd+J now treats GitHub, GitLab, Jira, and Linear issue/PR URLs as decisive search, so pasting one lists already-linked worktrees first and keeps a create preview underneath. GitHub/GitLab/Jira still hand the raw URL to the composer for cross-project detection. Co-authored-by: Orca * fix(workspaces): resolve GitHub issue titles in Cmd+J URL paste Pasted GitHub issue/PR URLs now fetch the title for the create preview, same as Linear. Existing linked worktrees stay selected first so Enter jumps; create still hands the raw URL to the composer. Co-authored-by: Orca * fix(workspaces): attach resolved GitHub items from Cmd+J create Pasting a GitHub issue/PR URL into Cmd+J already previewed the title, but Enter still opened the composer with the raw URL. Await the in-flight lookup and hand the linked work item through, matching Linear and Task page create. Also fix CI type, lint, and focus-routing source checks. Co-authored-by: Orca * fix(workspaces): add Cmd+J task-URL locale keys Unblock PR CI localization catalog checks and make the Linear lookup-miss e2e wait out the resolving state before advancing to the agent field. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../WorktreeJumpPalette.linear-url.test.tsx | 741 ++++++++++++++++++ .../src/components/WorktreeJumpPalette.tsx | 417 ++++++++-- .../cmd-j/PaletteCreateWorktreeRow.tsx | 172 ++++ .../palette-activation-focus-routing.test.ts | 5 +- ...rkspaceNameField-source-boundaries.test.ts | 25 + ...SmartWorkspaceNameField.ime-enter.test.tsx | 92 +++ .../new-workspace/SmartWorkspaceNameField.tsx | 169 +++- .../smart-workspace-command-value.test.ts | 11 + .../smart-workspace-source-results.test.ts | 191 ++++- ...mp-palette-source-context-boundary.test.ts | 12 +- src/renderer/src/i18n/locales/en.json | 14 +- .../src/lib/cmd-j-github-url-lookup.test.ts | 73 ++ .../src/lib/cmd-j-github-url-lookup.ts | 28 + .../src/lib/cmd-j-linear-issue-intent.test.ts | 112 +++ .../src/lib/linear-issue-url-lookup.test.ts | 148 ++++ .../src/lib/linear-issue-url-lookup.ts | 78 ++ .../src/lib/worktree-palette-search.test.ts | 69 ++ .../src/lib/worktree-palette-search.ts | 19 + .../worktree-palette-task-url-match.test.ts | 236 ++++++ .../lib/worktree-palette-task-url-match.ts | 319 ++++++++ src/shared/linear-links.ts | 123 +++ .../smart-workspace-linear-intent.ts | 69 ++ .../smart-workspace-source-query.ts | 10 + .../smart-workspace-source-results.ts | 53 +- tests/e2e/linear-url-workspace-entry.spec.ts | 230 ++++++ 25 files changed, 3316 insertions(+), 100 deletions(-) create mode 100644 src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx create mode 100644 src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx create mode 100644 src/renderer/src/lib/cmd-j-github-url-lookup.test.ts create mode 100644 src/renderer/src/lib/cmd-j-github-url-lookup.ts create mode 100644 src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts create mode 100644 src/renderer/src/lib/linear-issue-url-lookup.test.ts create mode 100644 src/renderer/src/lib/linear-issue-url-lookup.ts create mode 100644 src/renderer/src/lib/worktree-palette-task-url-match.test.ts create mode 100644 src/renderer/src/lib/worktree-palette-task-url-match.ts create mode 100644 src/shared/new-workspace/smart-workspace-linear-intent.ts create mode 100644 src/shared/new-workspace/smart-workspace-source-query.ts create mode 100644 tests/e2e/linear-url-workspace-entry.spec.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx new file mode 100644 index 00000000000..709d57777c7 --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx @@ -0,0 +1,741 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { flushSync } from 'react-dom' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactI18Next from 'react-i18next' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { GitHubWorkItem, LinearIssue, Repo } from '../../../shared/types' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { resolveWorkspaceCreationTarget } from '@/lib/project-host-workspace-target' +import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds' +import WorktreeJumpPalette from './WorktreeJumpPalette' +import { makeRecentTabState, makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures' + +const { lookupCmdJGitHubUrlWorkItem } = vi.hoisted(() => ({ + lookupCmdJGitHubUrlWorkItem: vi.fn() +})) + +vi.mock('@/lib/cmd-j-github-url-lookup', () => ({ + lookupCmdJGitHubUrlWorkItem +})) + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key + }) + } +}) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ + useSettingsNavigationMetadata: () => [] +})) + +vi.mock('@/components/sidebar/StatusIndicator', () => ({ + default: () => +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +vi.mock('@/components/cmd-j/palette-host-badge', () => ({ + getPaletteHostBadge: () => null +})) + +vi.mock('@/components/ui/command', async () => { + const React = await import('react') + return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandDialog: ({ + children, + open, + commandProps, + onOpenChange + }: { + children: React.ReactNode + open?: boolean + commandProps?: { value?: string; onValueChange?: (next: string) => void } + onOpenChange?: (open: boolean) => void + }) => { + requestCommandDialogClose = () => onOpenChange?.(false) + return open ? ( +
+ {children} +
+ ) : null + }, + CommandInput: ({ + value, + onValueChange + }: { + value?: string + onValueChange?: (next: string) => void + }) => { + setCommandQuery = onValueChange ?? null + return ( + onValueChange?.(event.currentTarget.value)} + /> + ) + }, + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ) + }), + CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandItem: ({ + children, + onSelect, + value, + ...props + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value?: string + } & Omit, 'onSelect' | 'value'>) => ( + + ) + } +}) + +const LINEAR_URL = 'https://linear.app/stably/issue/STA-4084/restore-osc-133-shell-integration' +const initialAppState = useAppStore.getInitialState() +let testRoot: Root +let testContainer: HTMLDivElement +let setCommandQuery: ((next: string) => void) | null = null +let requestCommandDialogClose: (() => void) | null = null + +function makeLinearIssue(patch: Partial = {}): LinearIssue { + return { + id: 'issue-sta-4084', + workspaceId: 'linear-workspace-1', + workspaceName: 'Stably', + identifier: 'STA-4084', + title: 'Restore OSC 133 shell integration', + branchName: 'sta-4084-restore-osc-133-shell-integration', + url: LINEAR_URL, + state: { name: 'Todo', type: 'unstarted', color: '#999999' }, + team: { id: 'team-sta', name: 'Stably', key: 'STA' }, + labels: [], + labelIds: [], + estimate: null, + priority: 2, + updatedAt: '2026-08-12T12:00:00.000Z', + ...patch + } +} + +function deferredValue(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + return { + promise: new Promise((next) => { + resolve = next + }), + resolve + } +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderPalette(overrides: Partial): Promise { + useAppStore.setState({ + activeModal: 'worktree-palette', + activeWorktreeId: null, + repos: [makeRepo()], + tabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + unifiedTabsByWorktree: {}, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, + lastVisitedAtByWorktreeId: {}, + linearStatus: { + connected: true, + viewer: null, + workspaces: [ + { + id: 'linear-workspace-1', + displayName: 'Linear User', + email: null, + organizationId: 'linear-organization-1', + organizationName: 'Stably', + organizationUrlKey: 'stably' + } + ], + selectedWorkspaceId: 'all' + }, + ...makeRecentTabState(), + ...overrides + } as Partial) + + await act(async () => { + testRoot.render() + }) + await flushEffects() +} + +function getRenderedRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item]')].map( + (node) => node.dataset.commandItem ?? '' + ) +} + +function getCommandValue(): string { + return ( + testContainer.querySelector('[data-command-dialog]')?.dataset.commandValue ?? '' + ) +} + +describe('WorktreeJumpPalette Linear URL intent', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + requestCommandDialogClose = null + lookupCmdJGitHubUrlWorkItem.mockReset() + lookupCmdJGitHubUrlWorkItem.mockResolvedValue(null) + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + vi.useRealTimers() + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('delays loading feedback before replacing the pending row with an issue preview', async () => { + const issueLookup = deferredValue() + const fetchLinearIssue = vi.fn(() => issueLookup.promise) + await renderPalette({ + fetchLinearIssue, + linearStatus: { + connected: true, + viewer: null, + workspaces: [ + { + id: 'linear-workspace-1', + displayName: 'Linear User', + email: null, + organizationId: 'linear-organization-1', + organizationName: 'Stably', + organizationUrlKey: 'stably' + } + ], + selectedWorkspaceId: 'all' + } + }) + + vi.useFakeTimers() + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + + const pendingRow = testContainer.querySelector( + '[data-cmd-j-linear-issue-preview="true"]' + ) + expect(getRenderedRowIds().find(Boolean)).toBe('__create_worktree__') + expect(getCommandValue()).toBe('__create_worktree__') + expect(pendingRow?.dataset.cmdJLinearIssueState).toBe('loading') + expect(pendingRow?.getAttribute('aria-busy')).toBe('true') + expect(pendingRow?.getAttribute('aria-label')).toBe( + 'Create worktree from Linear issue STA-4084' + ) + expect(pendingRow?.textContent).toContain('Create worktree from Linear issue') + expect(pendingRow?.textContent).not.toContain('Loading Linear issue…') + expect(fetchLinearIssue).toHaveBeenCalledWith( + 'STA-4084', + 'linear-workspace-1', + expect.objectContaining({ + sourceContext: expect.objectContaining({ + provider: 'linear', + projectId: 'repo:repo-1', + projectHostSetupId: 'repo-1', + repoId: 'repo-1', + hostId: 'local' + }) + }) + ) + + await act(async () => { + await vi.advanceTimersByTimeAsync(199) + }) + expect(pendingRow?.textContent).not.toContain('Loading Linear issue…') + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(pendingRow?.getAttribute('aria-label')).toBe('Loading Linear issue STA-4084') + expect(pendingRow?.textContent).toContain('Loading Linear issue…') + + await act(async () => { + issueLookup.resolve(makeLinearIssue()) + await issueLookup.promise + }) + await flushEffects() + + const resolvedRow = testContainer.querySelector( + '[data-cmd-j-linear-issue-preview="true"]' + ) + expect(resolvedRow?.dataset.cmdJLinearIssueState).toBe('resolved') + expect(resolvedRow?.textContent).toContain('STA-4084') + expect(resolvedRow?.textContent).toContain('Restore OSC 133 shell integration') + expect(getRenderedRowIds().find(Boolean)).toBe('__create_worktree__') + expect(getCommandValue()).toBe('__create_worktree__') + }) + + it('rejects oversized Linear URLs before lookup or create-row rendering', async () => { + const fetchLinearIssue = vi.fn(async () => makeLinearIssue()) + await renderPalette({ fetchLinearIssue }) + + await act(async () => + setCommandQuery?.(`${LINEAR_URL}/${'a'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES)}`) + ) + await flushEffects() + + expect(fetchLinearIssue).not.toHaveBeenCalled() + expect(getRenderedRowIds()).not.toContain('__create_worktree__') + expect(testContainer.querySelector('[data-cmd-j-linear-issue-preview="true"]')).toBeNull() + }) + + it('opens the composer with the resolved issue and generated workspace name', async () => { + await renderPalette({ + fetchLinearIssue: vi.fn(async () => makeLinearIssue()), + prefetchWorktreeCreateBase: vi.fn(async () => {}) + }) + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + + await act(async () => { + testContainer.querySelector('[data-command-item="__create_worktree__"]')?.click() + await Promise.resolve() + }) + await flushEffects() + + expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') + expect(useAppStore.getState().modalData).toMatchObject({ + prefilledName: 'sta-4084-restore-osc-133-shell-integration', + initialRepoId: 'repo-1', + telemetrySource: 'command_palette', + linkedWorkItem: { + provider: 'linear', + type: 'issue', + linearIdentifier: 'STA-4084', + linearWorkspaceId: 'linear-workspace-1', + title: 'Restore OSC 133 shell integration' + }, + taskSourceContext: { + provider: 'linear', + projectId: 'repo:repo-1', + projectHostSetupId: 'repo-1', + repoId: 'repo-1', + hostId: 'local', + providerIdentity: { + provider: 'linear', + workspaceId: 'linear-workspace-1', + workspaceName: 'Stably', + teamId: 'team-sta', + teamKey: 'STA' + } + } + }) + }) + + it('keeps runtime folder projects as the Linear lookup and composer owner', async () => { + const folderRepo: Repo = { + ...makeRepo(), + id: 'folder-repo', + kind: 'folder' as const, + executionHostId: 'runtime:folder-env' + } + const fetchLinearIssue = vi.fn(async () => makeLinearIssue()) + const prefetchWorktreeCreateBase = vi.fn(async () => {}) + await renderPalette({ + activeRepoId: folderRepo.id, + repos: [folderRepo], + fetchLinearIssue, + prefetchWorktreeCreateBase + }) + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + + expect(fetchLinearIssue).toHaveBeenCalledWith( + 'STA-4084', + 'linear-workspace-1', + expect.objectContaining({ + sourceContext: expect.objectContaining({ + provider: 'linear', + projectId: 'repo:folder-repo', + projectHostSetupId: 'folder-repo', + repoId: 'folder-repo', + hostId: 'runtime:folder-env' + }) + }) + ) + + await act(async () => { + testContainer.querySelector('[data-command-item="__create_worktree__"]')?.click() + await Promise.resolve() + }) + await flushEffects() + + expect(useAppStore.getState().modalData).toMatchObject({ + initialRepoId: 'folder-repo', + taskSourceContext: { + provider: 'linear', + projectId: 'repo:folder-repo', + projectHostSetupId: 'folder-repo', + repoId: 'folder-repo', + hostId: 'runtime:folder-env' + } + }) + const projection = projectHostSetupProjectionFromRepos([folderRepo]) + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [folderRepo], + projects: projection.projects, + projectHostSetups: projection.setups, + initialRepoId: 'folder-repo', + projectId: 'repo:folder-repo', + hostId: 'runtime:folder-env', + projectHostSetupId: 'folder-repo' + }) + ).toMatchObject({ status: 'ready', target: { repoId: 'folder-repo' } }) + expect(prefetchWorktreeCreateBase).not.toHaveBeenCalled() + }) + + it('maps a runtime-owned active repo to its eligible project sibling', async () => { + const unrelatedRepo = { ...makeRepo(), id: 'unrelated-repo' } + const localSibling = { + ...makeRepo(), + id: 'local-sibling', + upstream: { owner: 'stablyai', repo: 'orca' } + } + const runtimeOwnedRepo = { + ...makeRepo(), + id: 'runtime-owned', + connectionId: 'runtime-ssh-workspace-1', + upstream: { owner: 'stablyai', repo: 'orca' } + } + const fetchLinearIssue = vi.fn(async () => makeLinearIssue()) + await renderPalette({ + activeRepoId: runtimeOwnedRepo.id, + repos: [unrelatedRepo, localSibling, runtimeOwnedRepo], + fetchLinearIssue + }) + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + + expect(fetchLinearIssue).toHaveBeenCalledWith( + 'STA-4084', + 'linear-workspace-1', + expect.objectContaining({ + sourceContext: expect.objectContaining({ + projectId: 'github:stablyai/orca', + repoId: 'local-sibling' + }) + }) + ) + }) + + it('does not open the composer after the palette closes during lookup', async () => { + const issueLookup = deferredValue() + await renderPalette({ fetchLinearIssue: vi.fn(() => issueLookup.promise) }) + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + + await act(async () => { + testContainer.querySelector('[data-command-item="__create_worktree__"]')?.click() + useAppStore.getState().closeModal() + }) + await act(async () => { + issueLookup.resolve(makeLinearIssue()) + await issueLookup.promise + }) + await flushEffects() + + expect(useAppStore.getState().activeModal).toBe('none') + }) + + it('restores focus when a loading selection is abandoned', async () => { + const focusTarget = document.createElement('button') + document.body.appendChild(focusTarget) + focusTarget.focus() + const issueLookup = deferredValue() + await renderPalette({ + activeWorktreeId: 'wt-alpha', + fetchLinearIssue: vi.fn(() => issueLookup.promise) + }) + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + await act(async () => { + testContainer.querySelector('[data-command-item="__create_worktree__"]')?.click() + }) + await act(async () => setCommandQuery?.('ordinary name')) + testContainer.querySelector('[data-command-input="true"]')?.focus() + + await act(async () => requestCommandDialogClose?.()) + await act(async () => { + issueLookup.resolve(makeLinearIssue()) + await issueLookup.promise + await new Promise((resolve) => window.setTimeout(resolve, 50)) + }) + + expect(document.activeElement).toBe(focusTarget) + expect(useAppStore.getState().activeModal).toBe('none') + }) + + it('awaits the live URL after replacing an already resolved URL', async () => { + const secondLookup = deferredValue() + await renderPalette({ + fetchLinearIssue: vi.fn((identifier: string) => + identifier === 'STA-4084' ? Promise.resolve(makeLinearIssue()) : secondLookup.promise + ) + }) + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + await act(async () => + setCommandQuery?.('https://linear.app/stably/issue/STA-4099/current-second-issue') + ) + await flushEffects() + + await act(async () => { + testContainer.querySelector('[data-command-item="__create_worktree__"]')?.click() + }) + expect(useAppStore.getState().activeModal).toBe('worktree-palette') + + await act(async () => { + secondLookup.resolve( + makeLinearIssue({ + id: 'issue-sta-4099', + identifier: 'STA-4099', + title: 'Current second issue', + url: 'https://linear.app/stably/issue/STA-4099/current-second-issue' + }) + ) + await secondLookup.promise + }) + await flushEffects() + + expect(useAppStore.getState().modalData).toMatchObject({ + linkedWorkItem: { linearIdentifier: 'STA-4099', title: 'Current second issue' } + }) + }) + + it('does not let a stale lookup replace a newer URL preview', async () => { + const firstLookup = deferredValue() + const secondLookup = deferredValue() + await renderPalette({ + fetchLinearIssue: vi.fn((identifier: string) => + identifier === 'STA-4084' ? firstLookup.promise : secondLookup.promise + ) + }) + + vi.useFakeTimers() + await act(async () => setCommandQuery?.('https://linear.app/stably/issue/STA-4084/first')) + await flushEffects() + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + expect(testContainer.textContent).toContain('Loading Linear issue…') + + await act(async () => { + flushSync(() => setCommandQuery?.('https://linear.app/stably/issue/STA-4099/second')) + const currentPendingRow = testContainer.querySelector( + '[data-cmd-j-linear-issue-preview="true"]' + ) + expect(currentPendingRow?.getAttribute('aria-label')).toBe( + 'Create worktree from Linear issue STA-4099' + ) + expect(currentPendingRow?.textContent).toContain('Create worktree from Linear issue') + expect(currentPendingRow?.textContent).not.toContain('Loading Linear issue…') + }) + await flushEffects() + + await act(async () => { + firstLookup.resolve(makeLinearIssue({ title: 'Stale first issue' })) + await firstLookup.promise + }) + await flushEffects() + + expect(testContainer.textContent).toContain('STA-4099') + expect(testContainer.textContent).not.toContain('Stale first issue') + + await act(async () => { + secondLookup.resolve( + makeLinearIssue({ + id: 'issue-sta-4099', + identifier: 'STA-4099', + title: 'Current second issue', + url: 'https://linear.app/stably/issue/STA-4099/current-second-issue' + }) + ) + await secondLookup.promise + }) + await flushEffects() + + expect(testContainer.textContent).toContain('Current second issue') + expect(testContainer.textContent).not.toContain('Stale first issue') + expect(getCommandValue()).toBe('__create_worktree__') + }) + + it('selects an existing Linear-linked worktree and keeps create underneath', async () => { + const linked = makeWorktree('wt-linked', 'Linked Linear workspace', { + linkedLinearIssue: 'STA-4084', + linkedLinearIssueOrganizationUrlKey: 'stably' + }) + const other = makeWorktree('wt-other', 'Unrelated workspace') + await renderPalette({ + fetchLinearIssue: vi.fn(async () => makeLinearIssue()), + worktreesByRepo: { 'repo-1': [other, linked] } + }) + + await act(async () => setCommandQuery?.(LINEAR_URL)) + await flushEffects() + + expect(getRenderedRowIds().filter(Boolean)).toEqual([ + 'worktree:wt-linked', + '__create_worktree__' + ]) + expect(getCommandValue()).toBe('worktree:wt-linked') + expect(testContainer.querySelector('[data-cmd-j-linear-issue-preview="true"]')).not.toBeNull() + }) + + it('resolves a pasted GitHub issue URL and opens create with the linked issue', async () => { + const githubIssueUrl = 'https://github.com/stablyai/orca/issues/14198' + const githubIssue = { + id: 'issue-14198', + type: 'issue', + number: 14198, + title: 'Agent terminals disappearing randomly', + state: 'open', + url: githubIssueUrl, + labels: [], + updatedAt: '2026-08-12T12:00:00.000Z', + author: 'nwparker', + repoId: 'repo-1' + } satisfies GitHubWorkItem + lookupCmdJGitHubUrlWorkItem.mockResolvedValue(githubIssue) + await renderPalette({ + prefetchWorktreeCreateBase: vi.fn(async () => {}) + }) + + await act(async () => setCommandQuery?.(githubIssueUrl)) + await flushEffects() + + const preview = testContainer.querySelector('[data-cmd-j-task-url-preview="true"]') + expect(getRenderedRowIds().find(Boolean)).toBe('__create_worktree__') + expect(getCommandValue()).toBe('__create_worktree__') + expect(preview?.dataset.cmdJTaskUrlProvider).toBe('github') + expect(preview?.dataset.cmdJTaskUrlState).toBe('resolved') + expect(preview?.getAttribute('aria-label')).toBe( + 'Create worktree from GitHub issue stablyai/orca#14198: Agent terminals disappearing randomly' + ) + expect(preview?.textContent).toContain('#14198') + expect(preview?.textContent).toContain('Agent terminals disappearing randomly') + expect(lookupCmdJGitHubUrlWorkItem).toHaveBeenCalledWith( + expect.objectContaining({ + link: expect.objectContaining({ type: 'issue', number: 14198 }), + repo: expect.objectContaining({ id: 'repo-1' }) + }) + ) + + await act(async () => { + preview?.click() + await Promise.resolve() + }) + await flushEffects() + + expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') + expect(useAppStore.getState().modalData).toMatchObject({ + prefilledName: 'agent-terminals-disappearing-randomly', + initialRepoId: 'repo-1', + telemetrySource: 'command_palette', + linkedWorkItem: { + provider: 'github', + type: 'issue', + number: 14198, + title: 'Agent terminals disappearing randomly', + url: githubIssueUrl, + repoId: 'repo-1' + }, + initialGitHubWorkItem: githubIssue + }) + }) + + it('previews a pasted GitHub pull URL', async () => { + await renderPalette({}) + await act(async () => setCommandQuery?.('https://github.com/stablyai/orca/pull/12789')) + await flushEffects() + + const preview = testContainer.querySelector('[data-cmd-j-task-url-preview="true"]') + expect(preview?.dataset.cmdJTaskUrlProvider).toBe('github') + expect(preview?.getAttribute('aria-label')).toBe( + 'Create worktree from GitHub pull request stablyai/orca#12789' + ) + expect(preview?.textContent).toContain('#12789') + }) + + it('selects an existing GitHub-linked worktree and keeps create underneath', async () => { + const linked = makeWorktree('wt-linked', 'Linked GitHub workspace', { linkedIssue: 14198 }) + const other = makeWorktree('wt-other', 'Unrelated workspace', { linkedIssue: 7 }) + await renderPalette({ + repos: [{ ...makeRepo(), displayName: 'stablyai/orca' }], + worktreesByRepo: { 'repo-1': [other, linked] } + }) + + await act(async () => setCommandQuery?.('https://github.com/stablyai/orca/issues/14198')) + await flushEffects() + + expect(getRenderedRowIds().filter(Boolean)).toEqual([ + 'worktree:wt-linked', + '__create_worktree__' + ]) + expect(getCommandValue()).toBe('worktree:wt-linked') + expect( + testContainer.querySelector('[data-cmd-j-task-url-preview="true"]')?.dataset + .cmdJTaskUrlProvider + ).toBe('github') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 45a31847834..f95bc25de5b 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -15,7 +15,6 @@ import { FileText, FolderTree, Globe, - Plus, Server, ServerOff, Smartphone, @@ -175,23 +174,39 @@ import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-foc 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' import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { usePluginCommands } from '@/store/plugin-panels' import { getComposerEligibleRepos, + resolveComposerActiveRepoId, resolveComposerGitRepoId } from '@/lib/new-workspace-composer-repo' +import { resolveWorkspaceCreationTarget } from '@/lib/project-host-workspace-target' import { lookupGitHubWorkItemForSource } from '@/lib/github-work-item-source-lookup' +import { lookupCmdJGitHubUrlWorkItem } from '@/lib/cmd-j-github-url-lookup' +import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import { lookupLinearIssueUrl } from '@/lib/linear-issue-url-lookup' +import { + getCmdJTaskUrlCreatePreview, + parseCmdJTaskSourceUrl, + withResolvedCmdJGitHubPreview +} 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 { TerminalTab, Worktree } from '../../../shared/types' +import type { GitHubWorkItem, LinearIssue, TerminalTab, Worktree } from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' -import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context' +import { + buildTaskSourceContextFromRepo, + normalizeTaskSourceContext, + type TaskSourceContext +} from '../../../shared/task-source-context' +import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import { translate } from '@/i18n/i18n' type WorktreePaletteItem = { @@ -254,6 +269,22 @@ type CreateWorktreePaletteItem = { type: 'create-worktree' } +type CmdJLinearIssuePreview = { + query: string + issue: LinearIssue | null + loading: boolean + initialRepoId: string | null + sourceContext: TaskSourceContext | null +} + +type CmdJGitHubWorkItemPreview = { + query: string + item: GitHubWorkItem | null + loading: boolean + initialRepoId: string | null + sourceContext: TaskSourceContext | null +} + // Why: keep quick actions curated — Cmd+J is a fast intent surface, not a dump of every setup button. type PaletteItem = | WorktreePaletteItem @@ -374,14 +405,28 @@ function getComposerPrefetchRepoId( state: ReturnType, initialRepoId?: string ): string | null { + const eligibleRepos = getComposerEligibleRepos(state.repos) return resolveComposerGitRepoId({ - eligibleRepos: getComposerEligibleRepos(state.repos), + eligibleRepos, initialRepoId, - activeRepoId: state.activeRepoId, + activeRepoId: resolveComposerActiveRepoId(state.repos, eligibleRepos, state.activeRepoId), focusedHostScope: state.workspaceHostScope }) } +function getComposerDefaultWorkspaceTarget(state: ReturnType) { + const eligibleRepos = getComposerEligibleRepos(state.repos) + const activeRepoId = resolveComposerActiveRepoId(state.repos, eligibleRepos, state.activeRepoId) + const resolution = resolveWorkspaceCreationTarget({ + eligibleRepos, + projects: state.projects, + projectHostSetups: state.projectHostSetups, + activeRepoId, + focusedHostScope: state.workspaceHostScope + }) + return resolution.status === 'ready' ? resolution.target : null +} + function appendPaletteListEntries( target: PaletteListEntry[], source: readonly PaletteItem[] @@ -639,7 +684,29 @@ function WorktreeJumpPaletteContent({ const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) + const liveQueryRef = useRef(query) + liveQueryRef.current = query + const taskSourceUrl = useMemo(() => parseCmdJTaskSourceUrl(query), [query]) + const linearIssueUrlIntent = taskSourceUrl?.provider === 'linear' ? taskSourceUrl.intent : null + const githubUrlLink = taskSourceUrl?.provider === 'github' ? taskSourceUrl.link : null + const parsedTaskUrlCreatePreview = useMemo( + () => (taskSourceUrl ? getCmdJTaskUrlCreatePreview(taskSourceUrl) : null), + [taskSourceUrl] + ) const [selectedItemId, setSelectedItemId] = useState('') + const [linearIssuePreview, setLinearIssuePreview] = useState(null) + const [githubWorkItemPreview, setGithubWorkItemPreview] = + useState(null) + const githubLookupGenerationRef = useRef(0) + const githubLookupRef = useRef<{ + query: string + promise: Promise + } | null>(null) + const linearIssueLookupGenerationRef = useRef(0) + const linearIssueLookupRef = useRef<{ + query: string + promise: Promise + } | null>(null) // Why: the id cmdk auto-selected for the last committed list, so a late recent-order snapshot can // tell "nobody has moved the highlight yet" from "the user arrowed somewhere deliberately". const autoSelectedItemIdRef = useRef(null) @@ -743,7 +810,8 @@ function WorktreeJumpPaletteContent({ [defaultHostId, projectGroups] ) - const hasQuery = deferredQuery.trim().length > 0 + const paletteSearchQuery = taskSourceUrl ? query.trim() : deferredQuery.trim() + const hasQuery = paletteSearchQuery.length > 0 const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 // Why: keep running-agent workspaces visible under "Hide sleeping" even when the live PTY is momentarily absent, matching sidebar. #7197 @@ -920,7 +988,7 @@ function WorktreeJumpPaletteContent({ () => searchWorktrees( sortedWorktrees, - deferredQuery.trim(), + paletteSearchQuery, repoMap, prCache, issueCache, @@ -929,7 +997,7 @@ function WorktreeJumpPaletteContent({ ), [ sortedWorktrees, - deferredQuery, + paletteSearchQuery, repoMap, prCache, issueCache, @@ -1613,7 +1681,10 @@ function WorktreeJumpPaletteContent({ const digitShortcutModifiers = useShortcutKeyComboDetails(DIGIT_INDEX_ACTION_ID)[0]?.keys.slice(0, -1) ?? [] - const { createWorktreeName, showCreateAction } = useMemo( + const { + createWorktreeName: deferredCreateWorktreeName, + showCreateAction: deferredShowCreateAction + } = useMemo( () => getWorktreePaletteCreateActionState({ canCreateWorktree, @@ -1621,6 +1692,154 @@ function WorktreeJumpPaletteContent({ }), [canCreateWorktree, deferredQuery] ) + const createWorktreeName = taskSourceUrl ? query.trim() : deferredCreateWorktreeName + const showCreateAction = deferredShowCreateAction || taskSourceUrl !== null + + // Why: arm the lookup before Enter can target the newly rendered Linear row. + useLayoutEffect(() => { + const generation = ++linearIssueLookupGenerationRef.current + linearIssueLookupRef.current = null + if (!visible || !linearIssueUrlIntent) { + setLinearIssuePreview(null) + return + } + + const state = useAppStore.getState() + const workspaceTarget = getComposerDefaultWorkspaceTarget(state) + const initialRepoId = workspaceTarget?.repoId ?? null + const sourceContext = workspaceTarget + ? buildTaskSourceContextFromRepo({ + provider: 'linear', + projectId: workspaceTarget.projectId, + repo: workspaceTarget.repo, + projectHostSetupId: workspaceTarget.projectHostSetupId + }) + : null + const pendingPreview: CmdJLinearIssuePreview = { + query: createWorktreeName, + issue: null, + loading: true, + initialRepoId, + sourceContext + } + setLinearIssuePreview(pendingPreview) + + const promise = lookupLinearIssueUrl({ + intent: linearIssueUrlIntent, + knownStatus: state.linearStatus, + sourceContext, + fetchLinearIssue: state.fetchLinearIssue + }) + .catch(() => null) + .then( + (issue): CmdJLinearIssuePreview => ({ + ...pendingPreview, + issue, + loading: false + }) + ) + linearIssueLookupRef.current = { query: createWorktreeName, promise } + void promise.then((preview) => { + if (linearIssueLookupGenerationRef.current === generation) { + setLinearIssuePreview(preview) + } + }) + + return () => { + if (linearIssueLookupGenerationRef.current === generation) { + linearIssueLookupGenerationRef.current += 1 + } + } + }, [createWorktreeName, linearIssueUrlIntent, visible]) + + useLayoutEffect(() => { + const generation = ++githubLookupGenerationRef.current + githubLookupRef.current = null + if (!visible || !githubUrlLink) { + setGithubWorkItemPreview(null) + return + } + + const state = useAppStore.getState() + const workspaceTarget = getComposerDefaultWorkspaceTarget(state) + const initialRepoId = workspaceTarget?.repoId ?? null + const sourceContext = workspaceTarget + ? buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: workspaceTarget.projectId, + repo: workspaceTarget.repo, + projectHostSetupId: workspaceTarget.projectHostSetupId + }) + : null + const pendingPreview: CmdJGitHubWorkItemPreview = { + query: createWorktreeName, + item: null, + loading: true, + initialRepoId, + sourceContext + } + setGithubWorkItemPreview(pendingPreview) + + const promise = lookupCmdJGitHubUrlWorkItem({ + link: githubUrlLink, + repo: workspaceTarget?.repo ?? null, + sourceContext + }) + .catch(() => null) + .then( + (item): CmdJGitHubWorkItemPreview => ({ + ...pendingPreview, + item: item ?? null, + loading: false + }) + ) + githubLookupRef.current = { query: createWorktreeName, promise } + void promise.then((preview) => { + if (githubLookupGenerationRef.current === generation) { + setGithubWorkItemPreview(preview) + } + }) + + return () => { + if (githubLookupGenerationRef.current === generation) { + githubLookupGenerationRef.current += 1 + } + } + }, [createWorktreeName, githubUrlLink, visible]) + + const taskUrlCreatePreview = useMemo(() => { + if (!parsedTaskUrlCreatePreview) { + return null + } + const preview = + githubWorkItemPreview?.query === createWorktreeName ? githubWorkItemPreview : null + return withResolvedCmdJGitHubPreview( + parsedTaskUrlCreatePreview, + preview?.item?.title ?? null, + preview?.loading === true + ) + }, [createWorktreeName, githubWorkItemPreview, parsedTaskUrlCreatePreview]) + + const currentGitHubWorkItemPreview = + githubWorkItemPreview?.query === createWorktreeName ? githubWorkItemPreview : null + const currentLinearIssuePreview = + linearIssuePreview?.query === createWorktreeName ? linearIssuePreview : null + const [linearLoadingFeedbackQuery, setLinearLoadingFeedbackQuery] = useState(null) + useEffect(() => { + if (!currentLinearIssuePreview?.loading) { + setLinearLoadingFeedbackQuery(null) + return + } + setLinearLoadingFeedbackQuery(null) + const timer = window.setTimeout( + () => setLinearLoadingFeedbackQuery(currentLinearIssuePreview.query), + 200 + ) + return () => window.clearTimeout(timer) + }, [currentLinearIssuePreview?.loading, currentLinearIssuePreview?.query]) + const showLinearLoadingFeedback = + currentLinearIssuePreview?.loading === true && + linearLoadingFeedbackQuery === currentLinearIssuePreview.query const listEntries = useMemo(() => { const entries: PaletteListEntry[] = [] @@ -1747,6 +1966,18 @@ function WorktreeJumpPaletteContent({ } } + // Why: a pasted issue/PR URL is decisive. Show linked worktrees first so + // Enter jumps; keep create available underneath when the user wants a new one. + if (taskSourceUrl) { + if (visibleWorktreeItems.length > 0) { + pushWorktreeSection() + } + if (showCreateAction) { + entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) + } + return entries + } + if (!hasQuery) { // Why: the recent section leads the empty-query view; nothing else in this branch is populated. pushOpenTabSection() @@ -1823,7 +2054,14 @@ function WorktreeJumpPaletteContent({ entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) } return entries - }, [hasQuery, openTabsLeadSections, paletteSections, showCreateAction, worktreeItems.length]) + }, [ + hasQuery, + openTabsLeadSections, + paletteSections, + showCreateAction, + taskSourceUrl, + worktreeItems.length + ]) // Why derive from listEntries: multi-primary interleave must stay identical for // empty-state counts and keyboard selection — dual-path builders drifted before. @@ -2262,12 +2500,15 @@ function WorktreeJumpPaletteContent({ }, [handleSelectItem, hasQuery, query.length, visible]) const handleCreateWorktree = useCallback(() => { - skipRestoreFocusRef.current = true const trimmed = createWorktreeName.trim() + if (liveQueryRef.current.trim() !== trimmed) { + return + } const ghLink = parseGitHubIssueOrPRLink(trimmed) const ghNumber = parseGitHubIssueOrPRNumber(trimmed) const openComposer = (data: Record): void => { + skipRestoreFocusRef.current = true prefetchCreateWorkspaceBaseForComposer( typeof data.initialRepoId === 'string' ? data.initialRepoId : undefined ) @@ -2279,28 +2520,107 @@ function WorktreeJumpPaletteContent({ ) } - // Case 1: user pasted a GH issue/PR URL. - if (ghLink) { - const { number } = ghLink - const state = useAppStore.getState() - - // Why: the existing-worktree check only needs the issue/PR number (repo-agnostic in worktree meta). - const matches = allWorktrees.filter( - (w) => !w.isArchived && (w.linkedIssue === number || w.linkedPR === number) - ) - const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0] - if (activeMatch) { - closeModal() - // Why: #9939 — jumping to an already-open workspace must focus its own terminal. - const activation = activateAndRevealWorktree(activeMatch.id) - if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) { - focusFallbackSurface() + if (linearIssueUrlIntent) { + const openLinearIssueComposer = async (): Promise => { + const lookup = linearIssueLookupRef.current + const preview = currentLinearIssuePreview?.loading + ? await lookup?.promise + : (currentLinearIssuePreview ?? (await lookup?.promise)) + if ( + lookup?.query !== trimmed || + linearIssueLookupRef.current !== lookup || + liveQueryRef.current.trim() !== trimmed + ) { + return } - recordFeatureInteraction('cmd-j-workspace-open') - return - } + const composerData = preview?.issue + ? (() => { + const taskSourceContext = preview.sourceContext + ? normalizeTaskSourceContext({ + ...preview.sourceContext, + providerIdentity: { + provider: 'linear', + workspaceId: preview.issue.workspaceId ?? null, + workspaceName: preview.issue.workspaceName ?? null, + teamId: preview.issue.team.id, + teamKey: preview.issue.team.key + }, + accountLabel: preview.issue.workspaceName ?? null + }) + : null + return { + prefilledName: getLinearIssueWorkspaceName(preview.issue), + linkedWorkItem: buildLinearIssueLinkedWorkItem(preview.issue), + ...(preview.initialRepoId ? { initialRepoId: preview.initialRepoId } : {}), + ...(taskSourceContext ? { taskSourceContext } : {}) + } + })() + : preview?.initialRepoId + ? { prefilledName: trimmed, initialRepoId: preview.initialRepoId } + : { prefilledName: trimmed } - // Why: hand the raw URL to the composer so it runs Cmd+N cross-project detection; pre-resolving here silently linked to the wrong project. + if (useAppStore.getState().activeModal !== 'worktree-palette') { + return + } + openComposer(composerData) + } + void openLinearIssueComposer() + return + } + + // Case 1: user pasted a GH/GitLab/Jira URL. + // Why: GitHub uses the in-flight Cmd+J preview so Enter attaches the issue/PR + // entity. GitLab/Jira still hand the raw URL to the composer. Linked worktrees + // are listed above this row — selecting one jumps there. + if (ghLink) { + const openGitHubUrlComposer = async (): Promise => { + const lookup = githubLookupRef.current + const preview = currentGitHubWorkItemPreview?.loading + ? await lookup?.promise + : (currentGitHubWorkItemPreview ?? (await lookup?.promise)) + if ( + lookup?.query !== trimmed || + githubLookupRef.current !== lookup || + liveQueryRef.current.trim() !== trimmed + ) { + return + } + const item = preview?.item ?? null + const composerData = item + ? (() => { + const linkedWorkItem: LinkedWorkItemSummary = { + provider: 'github', + type: item.type, + number: item.number, + title: item.title, + url: item.url, + ...(item.repoId ? { repoId: item.repoId } : {}) + } + return { + prefilledName: + getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ?? + getLinkedWorkItemSuggestedName({ title: item.title }), + linkedWorkItem, + initialGitHubWorkItem: item, + ...(preview?.initialRepoId ? { initialRepoId: preview.initialRepoId } : {}), + ...(preview?.sourceContext ? { taskSourceContext: preview.sourceContext } : {}) + } + })() + : preview?.initialRepoId + ? { prefilledName: trimmed, initialRepoId: preview.initialRepoId } + : { prefilledName: trimmed } + + if (useAppStore.getState().activeModal !== 'worktree-palette') { + return + } + openComposer(composerData) + } + void openGitHubUrlComposer() + return + } + + if (taskUrlCreatePreview) { + const state = useAppStore.getState() const eligibleRepos = state.repos.filter((r) => isGitRepoKind(r)) const repoForLookup = (state.activeRepoId && eligibleRepos.find((r) => r.id === state.activeRepoId)) || @@ -2321,6 +2641,7 @@ function WorktreeJumpPaletteContent({ ) const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0] if (activeMatch) { + skipRestoreFocusRef.current = true closeModal() // Why: #9939 — jumping to an already-open workspace must focus its own terminal. const activation = activateAndRevealWorktree(activeMatch.id) @@ -2347,6 +2668,7 @@ function WorktreeJumpPaletteContent({ }) const lookupToken = createLookupGuard.start() preserveCreateLookupOnCloseRef.current = true + skipRestoreFocusRef.current = true recordFeatureInteraction('cmd-j-create-workspace') closeModal() void lookupGitHubWorkItemForSource({ @@ -2400,8 +2722,12 @@ function WorktreeJumpPaletteContent({ closeModal, createLookupGuard, createWorktreeName, + currentGitHubWorkItemPreview, + currentLinearIssuePreview, focusFallbackSurface, + linearIssueUrlIntent, openModal, + taskUrlCreatePreview, prefetchCreateWorkspaceBaseForComposer, recordFeatureInteraction, repoMap @@ -2603,26 +2929,21 @@ function WorktreeJumpPaletteContent({ } if (entry.type === 'create-worktree') { + const linearPreviewIssue = currentLinearIssuePreview?.issue ?? null + const linearPreviewLoading = + linearIssueUrlIntent !== null && currentLinearIssuePreview?.loading !== false return ( - -
-
-
-
- {translate( - 'auto.components.WorktreeJumpPalette.95be6587d3', - 'Create worktree "{{value0}}"', - { value0: createWorktreeName } - )} -
-
-
+ createWorktreeName={createWorktreeName} + linearIdentifier={linearIssueUrlIntent?.identifier ?? null} + linearIssue={linearPreviewIssue} + linearPending={linearPreviewLoading} + showLinearLoadingFeedback={showLinearLoadingFeedback} + taskUrlPreview={taskUrlCreatePreview} + onSelect={handleCreateWorktree} + /> ) } diff --git a/src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx b/src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx new file mode 100644 index 00000000000..2079a0c8fbf --- /dev/null +++ b/src/renderer/src/components/cmd-j/PaletteCreateWorktreeRow.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react' +import { Github, Gitlab, Plus } from 'lucide-react' +import { LinearIcon } from '@/components/icons/LinearIcon' +import { JiraIcon } from '@/components/icons/JiraIcon' +import { CommandItem } from '@/components/ui/command' +import { CREATE_WORKTREE_ITEM_ID } from '@/lib/worktree-palette-create-action' +import type { CmdJTaskUrlCreatePreview } from '@/lib/worktree-palette-task-url-match' +import type { LinearIssue } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +function TaskUrlProviderIcon({ + provider +}: { + provider: CmdJTaskUrlCreatePreview['provider'] +}): React.JSX.Element { + if (provider === 'github') { + return