diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 687fbfa5ecb..21f7fa08eb8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -30,7 +30,8 @@ Fixes # ## AI Disclosure - + + ## Review diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5455d0b7fb8..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 @@ -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 @@ -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,8 +232,15 @@ jobs: env: 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 \ + 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 - name: Upload Playwright traces @@ -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/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 4591514322e..5a2562bf2ba 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -66,12 +66,14 @@ jobs: run: | xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run golden E2E tests on macOS if: runner.os == 'macOS' run: | env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run golden E2E tests on Windows if: runner.os == 'Windows' diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 5d9e4f29b72..370fbcc0a32 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -783,7 +783,7 @@ jobs: { echo "## Release E2E Signal" echo "" - echo "- Platform golden E2E is release-blocking: terminal rendering on Linux/macOS and fresh startup on Windows." + echo "- Platform golden E2E is release-blocking: terminal rendering and restrictive-umask profile writes on Linux/macOS, plus fresh startup on Windows." echo "- Full E2E runs separately after publication and cannot change the release result." echo "- Terminal rendering release evidence is diagnostic/non-blocking." echo "" @@ -871,11 +871,15 @@ jobs: - name: Run terminal rendering golden on Linux if: runner.os == 'Linux' - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + run: | + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run terminal rendering golden on macOS if: runner.os == 'macOS' - run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + run: | + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run fresh-startup golden on Windows if: runner.os == 'Windows' diff --git a/.gitignore b/.gitignore index 63c741afe42..8fb2d756f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ docs/** !docs/reference/remote-wire-compatibility.md !docs/reference/renderer-agent-status-performance.md !docs/reference/windows-setup-shell.md +!docs/reference/worktree-scan-fingerprint.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 89d8e3e981e..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.", @@ -1633,41 +1672,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 +1783,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 +1941,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 +1950,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." @@ -3130,6 +3292,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", @@ -3670,17 +3956,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 +4008,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 +4148,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 +4251,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/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 2cf5e18af73..c6e96c88b2c 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => { 'build:linux', 'test:e2e', 'test:e2e:terminal-rendering-golden', + 'test:e2e:posix-profile-index-golden', 'test:e2e:terminal-rendering-release-evidence', 'test:e2e:headful' ] @@ -543,6 +544,15 @@ describe('Electron runtime package contract', () => { expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( '@windows-fresh-startup-golden' ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + 'golden-posix-profile-index-fsync.spec.ts' + ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + 'golden-posix-fresh-startup.spec.ts' + ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + '@posix-profile-index-golden' + ) expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( 'terminal-opencode-emoji-table-rendering.spec.ts' ) @@ -557,7 +567,11 @@ describe('Electron runtime package contract', () => { expect(goldenRunSteps.get('linux')?.run).toContain( 'pnpm run test:e2e:terminal-rendering-golden' ) + expect(goldenRunSteps.get('linux')?.run).toContain( + 'pnpm run test:e2e:posix-profile-index-golden' + ) expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:posix-profile-index-golden') expect(goldenRunSteps.get('windows')).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' @@ -574,9 +588,16 @@ describe('Electron runtime package contract', () => { expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence') expect(releaseGoldenJob['continue-on-error']).toBeUndefined() expect(releaseGoldenMatrix).toEqual(goldenMatrix) - expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain( - 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden' + const releaseLinuxRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run terminal rendering golden on Linux' ) + expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden') + const releaseMacRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run terminal rendering golden on macOS' + ) + expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden') const releaseWindowsRunStep = releaseGoldenJob.steps.find( (step) => step.name === 'Run fresh-startup golden on Windows' ) 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/docs/reference/worktree-scan-fingerprint.md b/docs/reference/worktree-scan-fingerprint.md new file mode 100644 index 00000000000..f82d453a82c --- /dev/null +++ b/docs/reference/worktree-scan-fingerprint.md @@ -0,0 +1,274 @@ +# Steady-state worktree rescan: Git-admin fingerprint gate + +## Status + +Adopted for the main-process worktree resolution cache +(`OrcaRuntimeService.listRepoWorktreesForResolution`). It keeps the existing +30-second freshness contract for externally created, removed, moved, locked, and +re-checked-out worktrees while removing the `git worktree list` subprocess that +previously ran for every registered repository every 30 seconds. + +## Context + +A production trace (10 registered repositories, 3 h 27 min) recorded 4,272 +`git worktree` invocations and 8,663 total Git subprocesses. The invocations +arrived in full-fleet sweeps roughly every 30.5 s — one sweep per repository per +`WORKTREE_SCAN_CACHE_TTL_MS`. + +### What actually expires the cache + +The originating report assumed some specific terminal/status/orchestration +request was invalidating the 30-second cache. That is not what happens, and the +distinction changes the fix: + +- `resolvedWorktreeCache` (the whole-fleet snapshot) has a **1-second** TTL + (`RESOLVED_WORKTREE_CACHE_TTL_MS`). Any caller polling faster than 1 Hz + recomputes the snapshot. +- `computeResolvedWorktrees` fans out over **every** registered repo + (`src/main/runtime/orca-runtime.ts`), calling + `listRepoWorktreesForResolution` per repo. +- That per-repo call is backed by `worktreeScanCache` with a **30-second** TTL. + When it expires, the next poll shells out. + +So no request "expires" the 30-second cache. It expires on wall-clock time, and +whichever poller arrives first afterwards pays a full-fleet `git worktree list` +fan-out. The many high-frequency callers — `listTerminals` without a selector, +`showTerminal`, `getWorktreePs`, `listManagedWorktrees`, +`resolveWorktreeSelector`, orchestration authority refresh — only determine +*who* pays, not *how often*. Steady-state subprocess volume is therefore +`repos / 30 s`, independent of poll rate, which is exactly the observed +~1 sweep / 30.5 s. + +The in-Orca mutation surface is already event-driven: create, remove, rename, +folder-rename, sparse edits, repo add/update/remove, SSH reconnect, and +mixed-version remote invalidation all call +`invalidateWorktreeScanCacheForRepo` / `invalidateResolvedWorktreeCache` +(≈40 call sites). The 30-second TTL exists for exactly one reason: discovering +worktree changes made **outside** Orca (`git worktree add/remove/move/prune`, +`git checkout` in another worktree, `rm -rf` of a worktree directory). + +That is a filesystem question, and the filesystem can answer it without a +subprocess. + +## Goals + +- Remove the periodic all-repository `git worktree list` fan-out in steady + state. +- Preserve the current ≈30 s discovery latency for externally created, removed, + moved, pruned, locked/unlocked, and re-checked-out worktrees. +- Keep a bounded reconciliation so anything the cheap probe cannot observe still + converges. +- Change nothing for SSH repos, WSL-routed repos, folder workspaces, bare repos, + or hosts where the probe cannot resolve Git's admin layout. +- Fail open: any probe error must behave exactly like today (run the real scan). + +## Non-goals + +- Changing `RESOLVED_WORKTREE_CACHE_TTL_MS` or the whole-fleet snapshot shape. +- Changing the renderer-facing `worktrees:list` / `worktrees:listAll` IPC scan + cache in `src/main/ipc/worktrees.ts` (separate 5 s cache, invalidated by + `registerWorktreeChangeInvalidator`, not a polling source in the trace). +- Scoping `resolveWorktreeSelector` to a single repository. See + "Rejected alternatives". +- Adding a filesystem watcher per repository. +- Any wire/RPC/persisted-schema change. + +## Design + +Introduce a cheap, subprocess-free **Git worktree admin fingerprint** for a +local repository, and consult it before re-running a scan whose TTL has expired. + +### Fingerprint inputs + +`readRepoWorktreeAdminFingerprint(repoPath)` in +`src/main/runtime/repo-worktree-admin-fingerprint.ts` resolves the repo's Git +common directory without a subprocess (read `.git`; if it is a `gitdir:` file, +follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a +bare gitdir), then records: + +| Input | External change it catches | +| --- | --- | +| sorted entry names of `/worktrees` | `worktree add`, `worktree remove`, `worktree prune` | +| existence of `repoPath` | main checkout deleted | +| `/packed-refs` mtime + size | a tip moved while its loose ref is packed away | +| `/reftable` mtime + size | a tip moved under the reftable backend | +| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) | +| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip | +| per entry: `gitdir` contents | `worktree move`, `worktree repair` | +| per entry: `locked` presence | `worktree lock` / `unlock` | +| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) | + +"per checkout" covers the main worktree and each linked worktree. Reading the +ref HEAD names is what makes an ordinary commit visible: committing rewrites +`refs/heads/` and leaves `HEAD` untouched, but moves the oid +`git worktree list --porcelain` prints. A symref target is only followed when it +is a relative path under `refs/`, so a hand-edited `HEAD` cannot steer the probe +outside the ref store. + +Every input is a `stat`, `readdir`, or small `readFile` on an already-hot inode, +and the fingerprint depends on nothing but the repo path — no prior scan result +is threaded in. Per-repo fan-out is capped at 8 concurrent linked-worktree +probes, mirroring `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`. A 10-repo fleet with +10 worktrees each costs a few hundred filesystem calls per 30 s window versus 10 +process spawns; the spawns dominate by orders of magnitude, and process-table +churn was the reported symptom. + +The fingerprint is a NUL-delimited string; any read failure yields `null`, which +the caller treats as "cannot prove unchanged". + +### Cache decision + +`listRepoWorktreesForResolution` gains one branch on the expired-TTL path: + +```text +cached entry exists, same generation + runtimeKey, TTL expired + └─ probe eligible? (no connectionId, no wslDistro, fingerprint recorded) + ├─ no → real scan (today's behaviour) + └─ yes → read fingerprint now + ├─ null or different → real scan + ├─ equal, last real scan < 5 min → extend TTL, no subprocess + └─ equal, last real scan ≥ 5 min → real scan (bounded reconcile) +``` + +`WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS` is 5 min, matching the existing +`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS` precedent. A cached result whose scan +failed (`ok: false`) is never extended, so a transient Git failure still retries +on the 30 s TTL. + +### Git version compatibility + +Every path the probe reads is part of Git's on-disk layout well before the 2.25 +baseline in [`git-compatibility.md`](./git-compatibility.md): `.git` as a +directory or `gitdir:` file, `commondir`, `worktrees//{HEAD,gitdir,locked}`, +`packed-refs`, and loose `refs/`. `reftable` arrived in 2.45; on older Git it +simply stats as missing, which is a stable value and therefore harmless. No new +Git command is introduced — this change only skips one. + +Agent-scratch repos already carry a 5-minute scan TTL +(`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS`), which equals the reconciliation +interval, so the gate never fires for them and their behaviour is unchanged. + +### Ordering + +The fingerprint stored with a scan result is captured **before** the scan runs. +A mutation landing while the scan is in flight therefore leaves the stored +fingerprint stale-by-construction, so the next probe sees a difference and +rescans. Capturing after the scan would let that mutation be masked forever. + +### Interaction with existing invalidation + +Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint +included) and bumps the generation, so every event-driven path still forces a +real scan on the next read. The fingerprint only ever *extends* an entry that +the TTL alone would have refreshed. + +## Freshness budget + +| Change | Before | After | +| --- | --- | --- | +| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) | +| SSH reconnect / provider generation bump | immediate (event) | immediate (event) | +| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s | +| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s | +| External `rm -rf ` | ≤ 30 s | ≤ 30 s | +| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min | +| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min | +| SSH / WSL repos, folder workspaces | unchanged | unchanged | + +The two regressions are bounded by the reconciliation interval and are both +changes Orca does not make itself. + +### Main-thread cost + +Both the scan and the probe are asynchronous, so neither "runs on the main +thread" in the naive sense — but they are not equally free there. Measured on +macOS with a 1 ms interval sampling event-loop lag while each ran 30 times +against a repo with 20 linked worktrees: + +| | wall per call | main-thread stall per call | worst single stall | +| --- | --- | --- | --- | +| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms | +| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms | + +`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency +is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout +collection and decoding are real synchronous main-process work. Ten repos +refreshing together therefore cost ≈27 ms of event-loop stall per sweep before +this change and ≈0.1 ms after — this reduces main-thread pressure rather than +adding to it, which is why moving either side onto a worker thread would not +help. + +### Residual risk + +A repo registered at a UNC path (`\\wsl$\...`) but executed by the local Windows +Git runtime is still probed, because it is not WSL-routed from Orca's point of +view. The probe is correct there and strictly cheaper than the subprocess it +replaces, but its filesystem calls cross the 9p boundary like the existing +sparse-checkout probes already do. + +## Measured effect + +`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts` drives the +reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30 +simulated minutes — and counts `git worktree list` invocations: + +| | `git worktree list` per 30 min | per hour | +| --- | --- | --- | +| TTL only (before) | 600 | 1,200 | +| fingerprint gate (after) | 60 | 120 | + +A 90 % reduction, with the remainder being the bounded reconciliation. Repos +with genuine external activity keep rescanning at the 30 s cadence because the +fingerprint flips. + +Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272 +`git worktree` invocations would become ≈427. + +## Rejected alternatives + +**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess +reduction, but it degrades *every* external-change latency to 5 min, including +the common "I ran `git worktree add` in a terminal" case. The fingerprint buys +the same reduction without that regression. + +**Per-repo `fs.watch` on `/worktrees`.** Lower latency, but adds +persistent watcher handles per repo, inherits recursive-watch platform +differences, and would need its own dormancy/rearm story. The existing watcher +infrastructure is scoped to workspace files; extending it to Git admin dirs is a +larger change with a worse risk profile for the same steady-state win. + +**Scope `resolveWorktreeSelector` to the owning repository.** The original brief +asks for this. `listTerminals` already avoids the fan-out for explicit worktree +ids via `buildResolvedWorktreeFromId` + +`listKnownResolvedWorktreesForExplicitTarget`. Extending that to +`resolveWorktreeSelector` means splitting the highest-fan-in method in the +runtime (38 call sites) and rebuilding lineage projection for a repo subset — +material regression risk. Once the fingerprint gate lands, the remaining +fan-out cost for a targeted call is a batch of stats, not a subprocess, so the +gain no longer justifies the risk. Tracked as follow-up, not in this change. + +## Test plan + +`src/main/runtime/repo-worktree-admin-fingerprint.test.ts` (real temp dirs, real +`git` binary — a mocked filesystem would only restate the assumptions): + +- stable across repeated reads with no change +- changes after `worktree add`, `worktree remove`, `worktree move`, + `worktree lock`, `checkout` in a linked worktree, `checkout` in the main + worktree, a commit in either, and `rm -rf` of a worktree directory +- still tracks a tip whose loose ref has been packed away by `git pack-refs` +- a linked worktree path and the main repo path produce the same fingerprint +- a bare repo resolves through its own gitdir and still tracks `worktree add` +- `null` for a non-Git directory and for a missing path + +`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts`: + +- unchanged fingerprint suppresses the rescan past the 30 s TTL and re-arms it +- changed fingerprint rescans at the 30 s TTL +- the 5-minute reconciliation forces a rescan while the fingerprint is unchanged +- `notifyBranchRenamed` (event invalidation) still forces an immediate rescan +- a `null` fingerprint (probe failure) falls back to scanning +- SSH repos never consult the probe +- a failed scan is never extended +- concurrent callers share one probe and one scan +- the 1 Hz / 10-repo workload measurement above diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index 65412d51958..df60f8fc117 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -10,6 +10,11 @@ export default function MobileFilePreviewRoute() { source?: string | string[] absolutePath?: string | string[] grantId?: string | string[] + terminal?: string | string[] + pathText?: string | string[] + cwd?: string | string[] + nativeChatTab?: string | string[] + nativeChatSession?: string | string[] line?: string | string[] column?: string | string[] name?: string | string[] diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cf0f1dd7edb..123a1212eb0 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 { @@ -917,6 +926,9 @@ export default function SessionScreen() { } | null>(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) + // Why: a lease-only subscribe never renders, so the reconciler needs to tell it apart + // from a stream that does — an uncovered handle holding one is a blank terminal. + const leaseOnlyHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) // Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337). @@ -1233,6 +1245,7 @@ export default function SessionScreen() { terminalUnsubsRef.current.get(handle)?.() terminalUnsubsRef.current.delete(handle) subscribingHandlesRef.current.delete(handle) + leaseOnlyHandlesRef.current.delete(handle) terminalDiagnosticsRef.current.terminalUnsubscribed(handle) subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) // Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale. @@ -1265,6 +1278,7 @@ export default function SessionScreen() { clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() + leaseOnlyHandlesRef.current.clear() initializedHandlesRef.current.clear() terminalDiagnosticsRef.current.clearTerminalCache() viewportResubscribeBudgetRef.current.clear() @@ -1331,6 +1345,11 @@ export default function SessionScreen() { } subscribingHandlesRef.current.add(handle) + if (covered) { + leaseOnlyHandlesRef.current.add(handle) + } else { + leaseOnlyHandlesRef.current.delete(handle) + } const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 subscribeSeqRef.current.set(handle, seq) diagnostics.streamArmed(handle, seq, viewportRef.current) @@ -1519,6 +1538,7 @@ export default function SessionScreen() { streamRevision: coveredStreamRevision, subscriptionsRef: terminalUnsubsRef, subscribingRef: subscribingHandlesRef, + leaseOnlyRef: leaseOnlyHandlesRef, webReadyRef: webReadyHandlesRef, initializedRef: initializedHandlesRef, subscribe: subscribeToTerminal, @@ -3085,6 +3105,10 @@ export default function SessionScreen() { hostId, worktreeId, worktreeName: routeWorktreeName, + nativeChatSessionId: + activeSessionTab?.type === 'terminal' + ? (activeSessionTab.agentStatus?.providerSession?.id ?? null) + : null, activeHandleRef, terminalCwdRef, openBrowser: (url) => void handleCreateBrowserRef.current?.(url), @@ -3650,20 +3674,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 +3794,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/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts index 5e320a3f10c..c51151b9ebf 100644 --- a/mobile/src/components/markdown-file-path-detection.ts +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -3,6 +3,8 @@ // viewer). We deliberately favor precision over recall: a missed path is a minor // annoyance, but a false positive on prose or a version number is a broken tap. +import { parseFileLinkLocation } from '../../../src/shared/file-link-location' + export type FilePathSegment = | { type: 'text'; value: string } | { type: 'file'; value: string; path: string } @@ -101,8 +103,6 @@ function hasMidTokenAt(candidate: string): boolean { return /[^\\/]@/.test(candidate) } -const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/ - /** * Split an agent-style `path:line(:col)` citation into its parts. Windows drive * colons are safe: only a trailing all-digit suffix is treated as a line ref. @@ -112,14 +112,14 @@ export function splitFilePathLineSuffix(pathText: string): { line: number | null column: number | null } { - const match = LINE_SUFFIX_PATTERN.exec(pathText) - if (!match) { + const parsed = parseFileLinkLocation(pathText) + if (!parsed || (parsed.line === null && parsed.column === null)) { return { path: pathText, line: null, column: null } } return { - path: match[1]!, - line: Number.parseInt(match[2]!, 10), - column: match[3] ? Number.parseInt(match[3], 10) : null + path: parsed.pathText, + line: parsed.line, + column: parsed.column } } diff --git a/mobile/src/files/MobileFilePreviewScreen.test.ts b/mobile/src/files/MobileFilePreviewScreen.test.ts index 1595454a12a..fa61f7351c6 100644 --- a/mobile/src/files/MobileFilePreviewScreen.test.ts +++ b/mobile/src/files/MobileFilePreviewScreen.test.ts @@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => { ).toBe(false) }) + it('treats native-chat artifact grants as read-only', () => { + expect( + isEditableMobileTerminalArtifactPreview( + { + status: 'ready', + kind: 'text', + content: '

Result

', + truncated: false, + byteLength: 15 + }, + true + ) + ).toBe(false) + }) + it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => { const sourceKey = sourceKeyForPreview({ source: 'terminalArtifact', diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 0879e02ec7e..3f56890243a 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -181,7 +181,8 @@ export function MobileFilePreviewScreen({ route }: Props) { ) const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview' const isEditableTerminalArtifact = - previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview) + previewSource?.source === 'terminalArtifact' && + isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true) const canSaveArtifact = isEditableTerminalArtifact && draftSourceKeyRef.current === previewSourceKey && diff --git a/mobile/src/files/mobile-file-preview-editability.ts b/mobile/src/files/mobile-file-preview-editability.ts index ff0fa18d3cb..d8ed196e5cb 100644 --- a/mobile/src/files/mobile-file-preview-editability.ts +++ b/mobile/src/files/mobile-file-preview-editability.ts @@ -3,7 +3,13 @@ import type { MobileFilePreviewSource } from './mobile-file-preview-request' -export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean { +export function isEditableMobileTerminalArtifactPreview( + preview: MobileFilePreviewResult, + readOnly = false +): boolean { + if (readOnly) { + return false + } return ( (preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) || preview.status === 'empty' diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index f7f77fabd19..fe15cad0b07 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -179,6 +179,47 @@ describe('mobile-file-preview-request', () => { }) }) + it('refreshes a native-chat artifact grant with the same transcript provenance', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_expired'), + ok({ + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-2', + readOnly: true + } + }), + ok({ content: '

Result

', truncated: false, byteLength: 15 }) + ]) + + const onTerminalArtifactSourceRefreshed = vi.fn() + await loadMobileFilePreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }, + undefined, + { onTerminalArtifactSourceRefreshed } + ) + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ grantId: 'grant-2', readOnly: true }) + ) + }) + it.each([ ['terminal_file_grant_expired'], ['terminal_file_grant_mismatch'], @@ -242,6 +283,25 @@ describe('mobile-file-preview-request', () => { }) }) + it('does not send writes for read-only native-chat artifacts', async () => { + const client = clientWith(ok({ ok: true })) + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + readOnly: true + }, + '

Changed

' + ) + ).resolves.toMatchObject({ status: 'error' }) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + it('does not refresh and retry a failed terminal artifact save without a base content check', async () => { const client = clientWithResponses([fail('terminal_file_grant_mismatch')]) diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/mobile-file-preview-request.ts index ff6cd755c50..63b61326654 100644 --- a/mobile/src/files/mobile-file-preview-request.ts +++ b/mobile/src/files/mobile-file-preview-request.ts @@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview( content: string, options: TerminalArtifactSaveOptions = {} ): Promise { + if (source.readOnly) { + return previewError('This file is read-only') + } let writeSource = source if (typeof options.baseContent === 'string') { const verified = await verifyTerminalArtifactBaseContent( diff --git a/mobile/src/files/mobile-file-preview-route.test.ts b/mobile/src/files/mobile-file-preview-route.test.ts index ac1888ed46f..784313464ee 100644 --- a/mobile/src/files/mobile-file-preview-route.test.ts +++ b/mobile/src/files/mobile-file-preview-route.test.ts @@ -65,6 +65,8 @@ describe('mobile-file-preview-route', () => { terminal: 'term-1', pathText: 'result.json', cwd: '/tmp/run', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1', line: '12', column: '3' }) @@ -80,6 +82,8 @@ describe('mobile-file-preview-route', () => { terminal: 'term-1', pathText: 'result.json', cwd: '/tmp/run', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1', line: '12', column: '3' } diff --git a/mobile/src/files/mobile-file-preview-route.ts b/mobile/src/files/mobile-file-preview-route.ts index 1b277de7faa..f0e7dfbbf49 100644 --- a/mobile/src/files/mobile-file-preview-route.ts +++ b/mobile/src/files/mobile-file-preview-route.ts @@ -10,6 +10,8 @@ export type MobileFilePreviewRouteParams = { terminal?: string pathText?: string cwd?: string + nativeChatTab?: string + nativeChatSession?: string line?: string column?: string name?: string @@ -35,6 +37,8 @@ type RawPreviewRouteParams = { terminal?: MobileFilePreviewParamValue pathText?: MobileFilePreviewParamValue cwd?: MobileFilePreviewParamValue + nativeChatTab?: MobileFilePreviewParamValue + nativeChatSession?: MobileFilePreviewParamValue line?: MobileFilePreviewParamValue column?: MobileFilePreviewParamValue name?: MobileFilePreviewParamValue @@ -76,6 +80,8 @@ export function normalizeMobileFilePreviewRouteParams( terminal: optionalSingleParam(params.terminal), pathText: optionalSingleParam(params.pathText), cwd: optionalSingleParam(params.cwd), + nativeChatTab: optionalSingleParam(params.nativeChatTab), + nativeChatSession: optionalSingleParam(params.nativeChatSession), line: optionalSingleParam(params.line), column: optionalSingleParam(params.column), name: optionalSingleParam(params.name), diff --git a/mobile/src/files/mobile-file-preview-source.test.ts b/mobile/src/files/mobile-file-preview-source.test.ts index b5fec782cfc..9c326fd0278 100644 --- a/mobile/src/files/mobile-file-preview-source.test.ts +++ b/mobile/src/files/mobile-file-preview-source.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { sourceKeyForPreview } from './mobile-file-preview-source' +import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source' describe('mobile-file-preview-source', () => { it('uses structured preview keys so colons in paths cannot collide', () => { @@ -25,4 +25,21 @@ describe('mobile-file-preview-source', () => { sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' }) ).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts'])) }) + + it('makes native-chat artifact previews read-only', () => { + expect( + previewSourceFromRoute({ + hostId: 'host-1', + worktreeId: 'wt-1', + source: 'terminalArtifact', + absolutePath: '/tmp/result.html', + grantId: 'grant-1', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1' + }) + ).toMatchObject({ + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }, + readOnly: true + }) + }) }) diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/mobile-file-preview-source.ts index b1b23757a6e..2c1f01631e6 100644 --- a/mobile/src/files/mobile-file-preview-source.ts +++ b/mobile/src/files/mobile-file-preview-source.ts @@ -15,7 +15,16 @@ export function previewSourceFromRoute( grantId: params.grantId, ...(params.terminal ? { terminalHandle: params.terminal } : {}), ...(params.pathText ? { pathText: params.pathText } : {}), - ...(params.cwd ? { cwd: params.cwd } : {}) + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.nativeChatTab && params.nativeChatSession + ? { + nativeChatContext: { + tabId: params.nativeChatTab, + sessionId: params.nativeChatSession + }, + readOnly: true as const + } + : {}) } } if (!params.relativePath) { @@ -28,12 +37,12 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str if (!source) { return null } - return source.source === 'terminalArtifact' - ? JSON.stringify([ - 'terminal', - source.worktreeId, - source.absolutePath, - source.terminalHandle ?? '' - ]) - : JSON.stringify(['worktree', source.worktreeId, source.relativePath]) + if (source.source !== 'terminalArtifact') { + return JSON.stringify(['worktree', source.worktreeId, source.relativePath]) + } + const key = ['terminal', source.worktreeId, source.absolutePath, source.terminalHandle ?? ''] + if (source.nativeChatContext) { + key.push(source.nativeChatContext.tabId, source.nativeChatContext.sessionId) + } + return JSON.stringify(key) } diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts index a8864119be4..cbe5ffdebd8 100644 --- a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts +++ b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts @@ -1,3 +1,4 @@ +import type { RuntimeNativeChatFileContext } from '../../../src/shared/runtime-types' import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' @@ -12,6 +13,8 @@ export type MobileTerminalArtifactPreviewSource = { terminalHandle?: string pathText?: string cwd?: string + nativeChatContext?: RuntimeNativeChatFileContext + readOnly?: true } export type TerminalArtifactRetryOptions = { @@ -32,7 +35,8 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure( worktree: `id:${source.worktreeId}`, pathText: source.pathText ?? source.absolutePath, ...(source.cwd ? { cwd: source.cwd } : {}), - ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}) + ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}), + ...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}) }) if (!refreshed.ok) { return null @@ -51,7 +55,9 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure( grantId: result.openTarget.grantId, ...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}), ...(source.pathText ? { pathText: source.pathText } : {}), - ...(source.cwd ? { cwd: source.cwd } : {}) + ...(source.cwd ? { cwd: source.cwd } : {}), + ...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}), + ...(source.readOnly || result.openTarget.readOnly === true ? { readOnly: true as const } : {}) } } @@ -68,7 +74,7 @@ function isTerminalArtifactGrantFailure( function isTerminalArtifactResolution(result: unknown): result is { exists: true isDirectory: false - openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string } + openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string; readOnly?: true } } { if (!result || typeof result !== 'object') { return false 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-file-tap-open.ts b/mobile/src/session/mobile-file-tap-open.ts index 627d9a5ff39..13c306af649 100644 --- a/mobile/src/session/mobile-file-tap-open.ts +++ b/mobile/src/session/mobile-file-tap-open.ts @@ -1,5 +1,6 @@ import type { RuntimeFileOpenResult, + RuntimeNativeChatFileContext, RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path' @@ -22,6 +23,7 @@ export type OpenMobileFileTapOptions = { terminalHandle?: string | null pathText: string cwd?: string | null + nativeChatContext?: RuntimeNativeChatFileContext | null line: number | null column: number | null pushPreviewRoute: (href: ReturnType) => void @@ -80,7 +82,8 @@ async function openMobileFileTapAsync( ...(options.terminalHandle && options.terminalHandle.trim().length > 0 ? { terminal: options.terminalHandle } : {}), - ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}) + ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}), + ...(options.nativeChatContext ? { nativeChatContext: options.nativeChatContext } : {}) }, { timeoutMs: 10_000 } ) @@ -116,6 +119,12 @@ async function openMobileFileTapAsync( ...(options.terminalHandle && options.terminalHandle.trim().length > 0 ? { terminal: options.terminalHandle } : {}), + ...(options.nativeChatContext + ? { + nativeChatTab: options.nativeChatContext.tabId, + nativeChatSession: options.nativeChatContext.sessionId + } + : {}), name: displayNameFromPath(resolved.openTarget.absolutePath), ...(options.line !== null ? { line: String(options.line) } : {}), ...(options.column !== null ? { column: String(options.column) } : {}), diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts index d44530e5cd0..bef222721ac 100644 --- a/mobile/src/session/mobile-native-chat-open-file.test.ts +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -65,6 +65,54 @@ describe('openMobileNativeChatFileTap', () => { ) }) + it('carries the native-chat session identity into resolution and grant refresh', async () => { + const sendRequest = vi.fn(async () => + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/Users/ada/orca-plans/result.html', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + readOnly: true + } + }) + ) + const options = baseOptions({ sendRequest }) + + openMobileNativeChatFileTap({ + ...options, + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + await Promise.resolve() + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { + worktree: 'id:wt-1', + pathText: '~/orca-plans/result.html', + crossWorkspace: true, + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }, + { timeoutMs: 10_000 } + ) + expect(options.pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + source: 'terminalArtifact', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1' + }) + }) + }) + it('parses a :line:col citation and opens the mobile preview route', async () => { const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts')) const options = baseOptions({ sendRequest }) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts index df860efc6b1..99c4c47cbcd 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -12,6 +12,7 @@ const base = { activeTabType: 'terminal', streamActive: true, streamCovered: false, + streamIsLeaseOnly: false, webViewReady: true } @@ -93,8 +94,40 @@ describe('mobile native-chat terminal stream lifecycle', () => { ).toBe('rearm') }) + it('resumes an uncovered handle still holding a lease-only stream', () => { + // Leaving a chat tab for a terminal tab subscribes the incoming handle before the + // route learns chat is gone, so the terminal streams nothing but its input lease. + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamIsLeaseOnly: true })).toBe( + 'resume' + ) + // Same wait the other resume paths take — never init a WebView that cannot receive it. + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamIsLeaseOnly: true, + webViewReady: false + }) + ).toBe('none') + }) + + it('leaves a lease-only stream alone while chat still covers it (#10681)', () => { + // Lease-only is the correct shape under chat; trading it for output here would + // drop the input floor the composer depends on. + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamCovered: true, + streamIsLeaseOnly: true + }) + ).toBe('none') + }) + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { - expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') + // A full stream on an uncovered handle is the one genuinely settled shape. + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamIsLeaseOnly: false })).toBe( + 'none' + ) expect( resolveMobileNativeChatTerminalStreamAction({ ...base, diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts index b9b58448113..9d2727f2c30 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -8,6 +8,10 @@ export function resolveMobileNativeChatTerminalStreamAction(args: { activeTabType: string | null streamActive: boolean streamCovered: boolean + /** The active stream was opened as an input lease, so the host sends `subscribed` + * and nothing else — no scrollback, no data. Distinguishes a stream that renders + * from one that only holds the input floor. */ + streamIsLeaseOnly: boolean webViewReady: boolean }): MobileNativeChatTerminalStreamAction { if (!args.activeHandle || args.activeTabType !== 'terminal') { @@ -22,7 +26,13 @@ export function resolveMobileNativeChatTerminalStreamAction(args: { // the composer locked forever — nothing else re-subscribes a covered handle. return args.streamActive ? 'none' : 'rearm' } - return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' + // Why `streamIsLeaseOnly`: switching from a chat tab to a terminal tab subscribes the + // incoming handle before the route learns chat is gone, so it lands a lease-only stream + // on an uncovered handle. Without this input that is indistinguishable from a healthy + // stream and the terminal renders blank until restart. + return (args.streamCovered || args.streamIsLeaseOnly || !args.streamActive) && args.webViewReady + ? 'resume' + : 'none' } export function isTerminalCoveredByNativeChat( 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/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 0cfa617f269..21f07562459 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -34,12 +34,13 @@ describe('useMobileFileTapHandlers', () => { hostId: 'host-1', worktreeId: 'wt-1', worktreeName: 'Orca', + nativeChatSessionId: 'session-1', activeHandleRef: { current: 'terminal-1' as string | null }, terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) }, openBrowser: vi.fn(), fetchSessionTabs: vi.fn(async () => {}), getSessionTabs: () => [], - getActiveSessionTabId: () => null, + getActiveSessionTabId: () => 'terminal-tab', getActiveSessionTabType: () => 'terminal', switchSessionTab: vi.fn(), scheduleDelayedAction: vi.fn(), @@ -131,7 +132,12 @@ describe('useMobileFileTapHandlers', () => { expect(sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'mobile/src/x.ts', crossWorkspace: true }, + { + worktree: 'id:wt-1', + pathText: 'mobile/src/x.ts', + crossWorkspace: true, + nativeChatContext: { tabId: 'terminal-tab', sessionId: 'session-1' } + }, { timeoutMs: 10_000 } ) expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12") diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts index 276be5b566f..67995115f45 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.ts @@ -10,6 +10,7 @@ type MobileFileTapHandlerOptions = { hostId: string worktreeId: string worktreeName?: string + nativeChatSessionId: string | null activeHandleRef: MutableRefObject terminalCwdRef: MutableRefObject> openBrowser: (url: string) => void @@ -48,6 +49,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, scheduleDelayedAction, reportChatTapFailure, @@ -71,6 +73,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, scheduleDelayedAction, reportChatTapFailure, @@ -87,6 +90,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, router, scheduleDelayedAction, @@ -144,12 +148,18 @@ export function useMobileFileTapHandlers( return } const activationSeq = ++activationSeqRef.current + const nativeChatSessionId = current.nativeChatSessionId + const nativeChatTabId = current.getActiveSessionTabId() openMobileNativeChatFileTap({ client: current.client, hostId: current.hostId, worktreeId: current.worktreeId, worktreeName: current.worktreeName, pathText, + nativeChatContext: + nativeChatSessionId && nativeChatTabId + ? { tabId: nativeChatTabId, sessionId: nativeChatSessionId } + : null, pushPreviewRoute: (href) => routerRef.current.push(href), openBrowser: current.openBrowser, triggerOpenFeedback: triggerSelection, diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts index ce282fcd5b1..47b3d982095 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -8,10 +8,25 @@ describe('useMobileNativeChatTerminalStream', () => { let harnessRenderCount = 0 const subscriptionsRef = { current: new Map void>() } const subscribingRef = { current: new Set() } + const leaseOnlyRef = { current: new Set() } const webReadyRef = { current: new Set(['terminal-1']) } const initializedRef = { current: new Set(['terminal-1']) } - const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) - const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) + /** Mirrors the route's subscribe-time coverage guess, which is what decides whether + * the host opens a lease-only stream or a streaming one. */ + let subscribeOpensLeaseOnly = false + const registerSubscription = (handle: string): void => { + subscriptionsRef.current.set(handle, () => {}) + if (subscribeOpensLeaseOnly) { + leaseOnlyRef.current.add(handle) + } else { + leaseOnlyRef.current.delete(handle) + } + } + const subscribe = vi.fn(registerSubscription) + const unsubscribe = vi.fn((handle: string) => { + subscriptionsRef.current.delete(handle) + leaseOnlyRef.current.delete(handle) + }) const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } const notifyListedHandlesRef = { current: (_liveHandles: ReadonlySet): void => {} } const hasTabsRecoveryNeedRef = { current: (): boolean => false } @@ -19,6 +34,8 @@ describe('useMobileNativeChatTerminalStream', () => { beforeEach(() => { subscriptionsRef.current = new Map([['terminal-1', () => {}]]) subscribingRef.current = new Set() + leaseOnlyRef.current = new Set() + subscribeOpensLeaseOnly = false webReadyRef.current = new Set(['terminal-1']) initializedRef.current = new Set(['terminal-1']) harnessRenderCount = 0 @@ -51,6 +68,7 @@ describe('useMobileNativeChatTerminalStream', () => { streamRevision, subscriptionsRef, subscribingRef, + leaseOnlyRef, webReadyRef, initializedRef, subscribe, @@ -107,6 +125,111 @@ describe('useMobileNativeChatTerminalStream', () => { expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') }) + /** What switchTab does synchronously on the tap, before the route re-renders: the + * coverage it reads still describes the chat tab being left, so the incoming + * terminal gets a lease-only subscribe — the shape that renders nothing. */ + function handOffToTerminalTwo(): void { + subscribeOpensLeaseOnly = true + subscribe('terminal-2') + subscribeOpensLeaseOnly = false + subscribe.mockClear() + unsubscribe.mockClear() + } + + it('trades a lease-only stream for output when a chat tab hands off to a terminal tab', async () => { + webReadyRef.current.add('terminal-2') + initializedRef.current.add('terminal-2') + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + handOffToTerminalTwo() + + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) + ) + }) + + expect(unsubscribe).toHaveBeenCalledWith('terminal-2') + expect(subscribe).toHaveBeenCalledWith('terminal-2') + expect(leaseOnlyRef.current.has('terminal-2')).toBe(false) + // The lease-only stream carried no scrollback, so xterm is empty — a stale + // initialized mark would drop the replacement snapshot and keep the tab blank. + expect(initializedRef.current.has('terminal-2')).toBe(false) + }) + + it('settles once a lease-only stream has been replaced by a full one', async () => { + webReadyRef.current.add('terminal-2') + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + handOffToTerminalTwo() + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) + ) + }) + subscribe.mockClear() + unsubscribe.mockClear() + + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: false, + activeHandle: 'terminal-2', + streamRevision: 1 + }) + ) + }) + + // A stream that renders is settled — resubscribing it again would be a loop. + expect(subscribe).not.toHaveBeenCalled() + expect(unsubscribe).not.toHaveBeenCalled() + }) + + it('repairs a lease-only stream once its WebView reports ready', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + handOffToTerminalTwo() + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) + ) + }) + + // Cold WebView: the resume must wait, and the route's own web-ready path bails + // because the lease-only subscription already counts as live. + expect(subscribe).not.toHaveBeenCalled() + + await act(async () => { + webReadyRef.current.add('terminal-2') + notifyWebReadyRef.current('terminal-2', false) + }) + + expect(unsubscribe).toHaveBeenCalledWith('terminal-2') + expect(subscribe).toHaveBeenCalledWith('terminal-2') + }) + + it('keeps a covered lease-only stream instead of trading it for output (#10681)', async () => { + subscribeOpensLeaseOnly = true + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + unsubscribe.mockClear() + + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true, streamRevision: 1 })) + }) + + // Lease-only is the correct shape under chat; swapping it for output would drop + // the input floor the composer holds. + expect(subscribe).not.toHaveBeenCalled() + expect(unsubscribe).not.toHaveBeenCalled() + expect(leaseOnlyRef.current.has('terminal-1')).toBe(true) + }) + it('re-subscribes a covered stream torn down under chat (#10681)', async () => { await act(async () => { renderer = create(createElement(Harness, { showNativeChat: true })) @@ -174,9 +297,7 @@ describe('useMobileNativeChatTerminalStream', () => { expect(subscribe).toHaveBeenCalledTimes(5) } finally { - subscribe.mockImplementation((handle: string) => - subscriptionsRef.current.set(handle, () => {}) - ) + subscribe.mockImplementation(registerSubscription) } }) diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts index 0f0a642a149..b5663b8dc8a 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -23,6 +23,8 @@ export function useMobileNativeChatTerminalStream(args: { streamRevision: number subscriptionsRef: MutableRefObject void>> subscribingRef: MutableRefObject> + /** Handles whose current subscribe went out as an input lease only. */ + leaseOnlyRef: MutableRefObject> webReadyRef: MutableRefObject> initializedRef: MutableRefObject> subscribe: (handle: string) => void @@ -55,13 +57,21 @@ export function useMobileNativeChatTerminalStream(args: { healthyStreamProofRef.current = null } }, []) - const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { - // Why: ordinary WebView startups must not rerender the large session route; - // only readiness that can release a native-chat lease needs reconciliation. - if (!wasAlreadyReady && coveredHandleRef.current === handle) { - setWebReadyRevision((revision) => revision + 1) - } - }, []) + const notifyWebReady = useCallback( + (handle: string, wasAlreadyReady: boolean) => { + // Why: ordinary WebView startups must not rerender the large session route; + // only readiness that can release a native-chat lease needs reconciliation. + // A lease-only stream counts: the route's own web-ready path bails on any live + // subscription, so nothing else would ever trade that lease for output. + if ( + !wasAlreadyReady && + (coveredHandleRef.current === handle || args.leaseOnlyRef.current.has(handle)) + ) { + setWebReadyRevision((revision) => revision + 1) + } + }, + [args.leaseOnlyRef] + ) const notifyListedHandles = useCallback( (liveHandles: ReadonlySet) => { // Why: the rearm budget bounds one teardown, not the handle's lifetime. A covered @@ -106,7 +116,7 @@ export function useMobileNativeChatTerminalStream(args: { (rearmAttemptsRef.current.get(handle) ?? 0) >= MAX_REARM_ATTEMPTS ) }, []) - useEffect(() => { + const reconcileStream = useCallback(() => { const handle = args.activeHandle if (coveredHandleRef.current && coveredHandleRef.current !== handle) { forgetRearmState(coveredHandleRef.current) @@ -115,12 +125,15 @@ export function useMobileNativeChatTerminalStream(args: { const streamActive = handle != null && (args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle)) + const streamIsLeaseOnly = + streamActive && handle != null && args.leaseOnlyRef.current.has(handle) const action = resolveMobileNativeChatTerminalStreamAction({ showNativeChat: args.showNativeChat, activeHandle: handle, activeTabType: args.activeTabType, streamActive, streamCovered: coveredHandleRef.current === handle, + streamIsLeaseOnly, webViewReady: handle != null && args.webReadyRef.current.has(handle) }) const streamHolding = action === 'none' && streamActive && coveredHandleRef.current === handle @@ -181,7 +194,11 @@ export function useMobileNativeChatTerminalStream(args: { args.subscribe(handle) return } - if (coveredHandleRef.current === handle) { + if (coveredHandleRef.current === handle || streamIsLeaseOnly) { + // Why clear `initialized`: a lease-only stream delivered no scrollback, so xterm + // holds nothing — a stale mark would drop the replacement snapshot and the + // terminal would stay blank through the resubscribe. + args.initializedRef.current.delete(handle) args.unsubscribe(handle) coveredHandleRef.current = null } @@ -190,6 +207,7 @@ export function useMobileNativeChatTerminalStream(args: { args.activeHandle, args.activeTabType, args.initializedRef, + args.leaseOnlyRef, args.leaseReady, args.showNativeChat, args.streamRevision, @@ -203,6 +221,7 @@ export function useMobileNativeChatTerminalStream(args: { rearmBudgetRevision, webReadyRevision ]) + useEffect(() => reconcileStream(), [reconcileStream]) useEffect(() => cancelHealthyStreamProof, [cancelHealthyStreamProof]) // Why memoized: the session route keeps this object in callback dep arrays, and a // fresh literal per render would rebuild them on every keystroke. All three members diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index ac2a8ea0ff3..8e7545a4cfb 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -3,11 +3,12 @@ // one path span containing the tapped column — mobile opens a tapped path, it // does not render hover links over the whole line. -export type TappedFilePath = { - pathText: string - line: number | null - column: number | null -} +import { + parseFileLinkLocation, + type ParsedFileLinkLocation +} from '../../../src/shared/file-link-location' + +export type TappedFilePath = ParsedFileLinkLocation // Separator-anchored path tokens (absolute, relative, ~/, drive-letter, UNC) OR // a bare filename with an extension (README.md, index.ts), optionally suffixed @@ -47,18 +48,13 @@ function trimBoundaryPunctuation( } export function parsePathWithOptionalLineColumn(value: string): TappedFilePath | null { - const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value) - if (!match) { + const parsed = parseFileLinkLocation(value) + if (!parsed) { return null } - const pathText = match[1] + const { pathText, line, column } = parsed // Reject a directory-only token (trailing separator) for either slash style. - if (!pathText || pathText.endsWith('/') || pathText.endsWith('\\')) { - return null - } - const line = match[2] ? Number.parseInt(match[2], 10) : null - const column = match[3] ? Number.parseInt(match[3], 10) : null - if ((line !== null && line < 1) || (column !== null && column < 1)) { + if (pathText.endsWith('/') || pathText.endsWith('\\')) { return null } return { pathText, line, column } 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/package.json b/package.json index c9cf4a72891..1c2c1deb2df 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", 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/ai-vault/remote-session-file-stat.ts b/src/main/ai-vault/remote-session-file-stat.ts index aa5809a776d..57e6d2c8ed6 100644 --- a/src/main/ai-vault/remote-session-file-stat.ts +++ b/src/main/ai-vault/remote-session-file-stat.ts @@ -2,7 +2,7 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types import type { ExecutionHostId } from '../../shared/execution-host' import type { FileStat } from '../providers/types' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { recordSessionScanIssue } from './session-scan-issues' import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -32,7 +32,7 @@ export async function statRemoteSessionFile( } catch (error) { throwIfAiVaultScanCancelled(options?.signal) if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) { - recordRemoteSessionScanIssue(issues, { + recordSessionScanIssue(issues, { executionHostId, agent, path, diff --git a/src/main/ai-vault/remote-session-scan-issues.ts b/src/main/ai-vault/remote-session-scan-issues.ts deleted file mode 100644 index aee229f443c..00000000000 --- a/src/main/ai-vault/remote-session-scan-issues.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' - -const REMOTE_SCAN_ISSUE_LIMIT = 500 - -export function recordRemoteSessionScanIssue( - issues: AiVaultScanIssue[], - issue: AiVaultScanIssue -): void { - if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) { - issues.push(issue) - return - } - if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) { - // Kinded: this row is a scan notice, not a skipped transcript — the panel - // counts unkinded issues as skipped transcript files. - issues.push({ - executionHostId: issue.executionHostId, - agent: issue.agent, - kind: 'notice', - path: 'Agent Session History scan', - message: 'Additional scan issues were omitted.' - }) - } -} diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 91f21eab720..329bdb2e584 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -7,7 +7,7 @@ import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { recordSessionScanIssue } from './session-scan-issues' import type { RemoteScannerContext, RemoteSessionCandidate, @@ -124,7 +124,7 @@ function recordRemoteDirectoryIssue( err: unknown ): void { if (!isMissingRemoteSessionPathError(err)) { - recordRemoteSessionScanIssue(issues, { + recordSessionScanIssue(issues, { executionHostId, agent: source.agent, kind: 'host', diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 2523cd22a43..568b42c27cd 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -24,7 +24,7 @@ import { createAntigravityWorkspaceResolver } from './session-scanner-antigravit import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { recordSessionScanIssue } from './session-scan-issues' import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' @@ -205,7 +205,7 @@ async function scanRemoteInScopeSessions(args: { } if (index < candidates.length && sessions.length < args.limit) { - recordRemoteSessionScanIssue(args.issues, { + recordSessionScanIssue(args.issues, { executionHostId: args.context.executionHostId, agent: 'codex', kind: 'scope', @@ -241,7 +241,7 @@ async function parseRemoteSessionCandidate( return session } catch (err) { throwIfAiVaultScanCancelled(context.signal) - recordRemoteSessionScanIssue(issues, { + recordSessionScanIssue(issues, { executionHostId: context.executionHostId, agent: candidate.source.agent, path: candidate.file.path, diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index f250846a20e..ac5a35f2a53 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -1,10 +1,10 @@ -import { stat } from 'node:fs/promises' import type { AiVaultAgent, AiVaultFirstUserPromptResult, AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' @@ -118,7 +118,9 @@ async function fileWithMtimeForPath( } try { - const info = await stat(filePath) + // 'scan' matches the parser this feeds, so the two halves of one re-parse + // share a lane instead of the stat jumping the live-transcript queue. + const info = await wslGatedStat(filePath, 'scan') return { path: filePath, mtimeMs: info.mtimeMs, diff --git a/src/main/ai-vault/session-scan-issues.ts b/src/main/ai-vault/session-scan-issues.ts new file mode 100644 index 00000000000..b3e21f9c5a8 --- /dev/null +++ b/src/main/ai-vault/session-scan-issues.ts @@ -0,0 +1,27 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +// Why: a stalled WSL distro or an unreachable remote host fails one probe per +// discovered path, so an uncapped list ships one row per transcript file in the +// tree over IPC. Local and remote scans share the bound — the failure shape is +// the same and both feed the same panel. +const SCAN_ISSUE_LIMIT = 500 + +/** Append a scan issue, collapsing everything past the cap into one notice. */ +export function recordSessionScanIssue(issues: AiVaultScanIssue[], issue: AiVaultScanIssue): void { + if (issues.length < SCAN_ISSUE_LIMIT - 1) { + issues.push(issue) + return + } + if (issues.length === SCAN_ISSUE_LIMIT - 1) { + // Kinded: this row is a scan notice, not a skipped transcript — the panel + // counts unkinded issues as skipped transcript files. Spread first so a + // remote issue keeps its executionHostId and a local one never gains an + // explicit `undefined` (which would outrank the scanner's own stamping). + issues.push({ + ...issue, + kind: 'notice', + path: 'Agent Session History scan', + message: 'Additional scan issues were omitted.' + }) + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts index 989ac7b4179..ccfb506b7f7 100644 --- a/src/main/ai-vault/session-scanner-antigravity-history.ts +++ b/src/main/ai-vault/session-scanner-antigravity-history.ts @@ -1,8 +1,28 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values' const HISTORY_MATCH_WINDOW_MS = 2_000 +/** + * The local-scan `readHistory`; remote scans inject their own transport. A + * missing or unreadable history file is genuinely "no enrichment", but a gate + * refusal must propagate so the caller records a scan issue — degrading it to + * null lists the session with a missing cwd and no retry signal, and the + * resolver's memo relies on the rejection to evict rather than pin a stall. + */ +export async function readLocalAntigravityHistory(path: string): Promise { + try { + return await wslGatedReadFile(path, 'utf-8', 'scan') + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + return null + } +} + type AntigravityHistoryEntry = { timestampMs: number workspace: string @@ -26,8 +46,19 @@ export function createAntigravityWorkspaceResolver( } let index = indexes.get(historyPath) if (!index) { - index = readHistory(historyPath).then(indexAntigravityHistory) - indexes.set(historyPath, index) + // Why: a read failure is transient (a stalled WSL distro refuses here), + // so it must not be memoized — every later session under this history + // file would inherit the rejection for the process lifetime. + const pending: Promise = readHistory(historyPath) + .then(indexAntigravityHistory) + .catch((error: unknown) => { + if (indexes.get(historyPath) === pending) { + indexes.delete(historyPath) + } + throw error + }) + index = pending + indexes.set(historyPath, pending) } const workspace = findAntigravityWorkspace(session, await index) return workspace ? { ...session, cwd: workspace } : session diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 818167fbd0c..1896138749c 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -26,11 +26,16 @@ export async function parseAntigravitySessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseAntigravitySessionLines({ file, lines, platform }) + const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + return await parseAntigravitySessionLines({ file, lines, platform }) + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-parse throw cannot leak the gated transcript handle. + lines.close() + input.destroy() + } } export async function parseAntigravitySessionContent( diff --git a/src/main/ai-vault/session-scanner-claude-subagents.ts b/src/main/ai-vault/session-scanner-claude-subagents.ts index 5c269d91cf3..a2dc5745948 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.ts @@ -1,5 +1,3 @@ -import { createReadStream } from 'node:fs' -import { readdir, readFile, stat } from 'node:fs/promises' import { basename, extname, join } from 'node:path' import { createInterface } from 'node:readline' import type { @@ -8,6 +6,14 @@ import type { AiVaultSubagentListResult, AiVaultSubagentRunStatus } from '../../shared/ai-vault-types' +import { + openTranscriptReadStream, + wslGatedReaddir, + wslGatedReadFile, + wslGatedStat +} from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { recordSessionScanIssue } from './session-scan-issues' import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import { @@ -29,6 +35,10 @@ const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000 // Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY): // opening every subagent read stream at once stalls over SSH/WSL UNC paths. const SUBAGENT_PARSE_CONCURRENCY = 8 +// 'scan', not 'exact', even though a user expands this on demand: it is bulk +// directory work feeding the same parsers as the main scan, and the exact lane +// stays reserved for live transcript probes. +const SUBAGENT_FS_PRIORITY = 'scan' const TASK_NOTIFICATION_MARKER = '' const TOOL_USE_RESULT_MARKER = '"toolUseResult"' @@ -71,8 +81,13 @@ export async function listClaudeSubagentSessions(args: { let entries try { - entries = await readdir(subagentsDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(subagentsDir, SUBAGENT_FS_PRIORITY) + } catch (err) { + // A gate refusal is a stalled distro, not a session without subagents — + // report it so the panel offers a retry instead of showing an empty list. + if (err instanceof WslTranscriptFsError) { + recordSessionScanIssue(issues, { agent: 'claude', path: subagentsDir, message: err.message }) + } return { sessions: [], issues } } @@ -130,7 +145,7 @@ async function parseSubagentTranscript(args: { issues: AiVaultScanIssue[] }): Promise { try { - const fileStat = await stat(args.filePath) + const fileStat = await wslGatedStat(args.filePath, SUBAGENT_FS_PRIORITY) const session = await parseClaudeSessionFile( { path: args.filePath, @@ -161,7 +176,11 @@ async function parseSubagentTranscript(args: { } } } catch (err) { - args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { + agent: 'claude', + path: args.filePath, + message: errorMessage(err) + }) return null } } @@ -187,11 +206,13 @@ function resolveSubagentStatus(args: { // 'async_launched', notification 'running') are superseded by terminal ones. async function collectSubagentTaskStatuses(parentFilePath: string): Promise> { const statuses = new Map() + const input = openTranscriptReadStream( + parentFilePath, + { encoding: 'utf-8' }, + SUBAGENT_FS_PRIORITY + ) + const lines = createInterface({ input, crlfDelay: Infinity }) try { - const lines = createInterface({ - input: createReadStream(parentFilePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) for await (const line of lines) { const hasNotification = line.includes(TASK_NOTIFICATION_MARKER) const hasTaskResult = @@ -232,6 +253,11 @@ async function collectSubagentTaskStatuses(parentFilePath: string): Promise { const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json` try { - const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown) + const raw = await wslGatedReadFile(metaPath, 'utf-8', SUBAGENT_FS_PRIORITY) + const record = asRecord(JSON.parse(raw) as unknown) return { description: normalizeTitleText(extractString(record?.description) ?? ''), agentType: extractString(record?.agentType) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 0f73fa54126..6887b7ed5d8 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -1,4 +1,4 @@ -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' @@ -42,7 +42,7 @@ export async function parseCodexSessionFile( executionHostId?: ExecutionHostId ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) diff --git a/src/main/ai-vault/session-scanner-codex-title-index.ts b/src/main/ai-vault/session-scanner-codex-title-index.ts index 8ef2d5645cb..635a215562a 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.ts @@ -1,7 +1,7 @@ -import { createReadStream } from 'node:fs' -import { stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' +import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' // Codex names threads lazily in /session_index.jsonl; transcripts @@ -77,7 +77,7 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise ({ - signature, - titles - })) + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then(({ titles, refused }) => { + // Why: a gate refusal is a stalled distro, not a title-less index — caching + // it would pin "no titles" until the index's size or mtime changes. + if (refused && codexSessionIndexTitleCache.get(codexHome) === pending) { + codexSessionIndexTitleCache.delete(codexHome) + } + return { signature, titles } + }) storeCodexSessionIndexTitleCacheEntry(codexHome, pending) return (await pending).titles } @@ -133,11 +137,11 @@ function storeCodexSessionIndexTitleCacheEntry( async function readCodexSessionIndexTitlesFromDisk( indexPath: string -): Promise> { +): Promise<{ titles: Map; refused: boolean }> { const titleBySessionId = new Map() try { const lines = createInterface({ - input: createReadStream(indexPath, { encoding: 'utf-8' }), + input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) for await (const line of lines) { @@ -151,8 +155,9 @@ async function readCodexSessionIndexTitlesFromDisk( titleBySessionId.set(sessionId, title) } } - } catch { + } catch (error) { // Codex creates the index opportunistically; older homes may only have raw transcripts. + return { titles: titleBySessionId, refused: error instanceof WslTranscriptFsError } } - return titleBySessionId + return { titles: titleBySessionId, refused: false } } diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts index 4d0140fc3df..7e5450393e0 100644 --- a/src/main/ai-vault/session-scanner-copilot-parser.ts +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -35,7 +35,7 @@ export async function parseCopilotSessionFile( platform: NodeJS.Platform = process.platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseCopilotSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts new file mode 100644 index 00000000000..44fe71c80b2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type * as NodePathModule from 'node:path' +import type { SessionFileCandidate } from './session-scanner-types' + +const mocks = vi.hoisted(() => ({ + open: vi.fn(), + readFile: vi.fn(), + readdir: vi.fn(), + stat: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + open: mocks.open, + readFile: mocks.readFile, + readdir: mocks.readdir, + stat: mocks.stat +})) + +// Derived sibling paths must retain Windows separators in this cross-platform test. +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual.win32, default: actual.win32 } +}) + +import { + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from './session-scanner-parse-cache' +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' + +function uncPath(distro: string, ...segments: string[]): string { + return `\\\\wsl.localhost\\${distro}\\home\\ada\\${segments.join('\\')}` +} + +const KIMI_HOME = ['.kimi-code', 'sessions', 'wd_app_9f2', 'session_abc'] +const GROK_DIR = ['.grok', 'sessions', 'ses-1'] +const OPENCODE_ROOT = ['.local', 'share', 'opencode', 'storage'] + +const KIMI_STATE = JSON.stringify({ + title: 'Kimi session', + createdAt: '2026-06-01T10:00:00.000Z', + updatedAt: '2026-06-01T10:05:00.000Z', + agents: { main: { type: 'main', parentAgentId: null } } +}) + +const KIMI_WIRE = `${JSON.stringify({ + type: 'context.append_message', + message: { role: 'user', origin: { kind: 'user' }, content: 'ship it' } +})}\n` + +const GROK_SESSION = JSON.stringify({ + info: { id: 'ses-1', cwd: '/repo/app' }, + generated_title: 'Grok session', + created_at: '2026-06-01T10:00:00.000Z' +}) + +const GROK_HISTORY = `${JSON.stringify({ + type: 'user', + content: 'ship it' +})}\n` + +const OPENCODE_SESSION = JSON.stringify({ + id: 'ses-1', + title: 'OpenCode session', + directory: '/repo/app', + time: { created: 1780000000000 } +}) + +const OPENCODE_MESSAGE = JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'ship it' }], + time: { created: 1780000000000 } +}) + +type ReadResult = { bytesRead: number; buffer: Buffer } + +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) } as T) + }) +} + +function stallingHandle() { + return { read: vi.fn(stalls), close: vi.fn(async () => {}) } +} + +function servingHandle(body: string) { + const bytes = Buffer.from(body) + return { + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = bytes.subarray(position, Math.min(position + length, bytes.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + }), + close: vi.fn(async () => {}) + } +} + +function missing(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function dirent(name: string) { + return { name, isFile: () => true } +} + +function candidate(agent: SessionFileCandidate['agent'], path: string): SessionFileCandidate { + return { + agent, + file: { + path, + mtimeMs: 1, + modifiedAt: '2026-06-01T10:05:00.000Z', + sizeBytes: 128 + }, + codexHome: null + } +} + +async function expectRefusal(target: SessionFileCandidate): Promise { + const refusal = expect(parseAgentSessionFileCached(target, 'linux')).rejects.toBeInstanceOf( + WslTranscriptFsError + ) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + await refusal +} + +async function releaseAndSettle(): Promise { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + resetSessionParseCacheForTests() + mocks.open.mockReset() + mocks.readFile.mockReset() + mocks.readdir.mockReset() + mocks.stat.mockReset() + releaseStall = undefined + mocks.stat.mockRejectedValue(missing()) + vi.useFakeTimers() +}) + +afterEach(async () => { + await releaseAndSettle() + vi.useRealTimers() +}) + +describe('Kimi session parse against a stalled WSL transcript', () => { + it('refuses a stalled state.json read instead of reporting no session', async () => { + const state = uncPath('KimiState', ...KIMI_HOME, 'state.json') + mocks.readFile.mockImplementation(stalls) + + await expectRefusal(candidate('kimi', state)) + }) + + it('refuses a stalled wire read, then caches the full session once it recovers', async () => { + const state = uncPath('KimiWire', ...KIMI_HOME, 'state.json') + mocks.readFile.mockResolvedValue(KIMI_STATE) + mocks.open.mockResolvedValue(stallingHandle()) + + await expectRefusal(candidate('kimi', state)) + await releaseAndSettle() + + mocks.open.mockResolvedValue(servingHandle(KIMI_WIRE)) + const recovered = await parseAgentSessionFileCached(candidate('kimi', state), 'linux') + + expect(recovered?.messageCount).toBe(1) + }) + + // Pins the narrowness of the parser's `instanceof WslTranscriptFsError` + // rethrow: widening it to every error turns this into a hard scan issue. + it('still returns a metadata-only session when the wire file is simply missing', async () => { + const state = uncPath('KimiMissing', ...KIMI_HOME, 'state.json') + mocks.readFile.mockResolvedValue(KIMI_STATE) + mocks.open.mockRejectedValue(missing()) + + const session = await parseAgentSessionFileCached(candidate('kimi', state), 'linux') + + expect(session).toMatchObject({ agent: 'kimi', title: 'Kimi session', messageCount: 0 }) + }) +}) + +describe('Grok session parse against a stalled WSL transcript', () => { + it('refuses a stalled chat_history read instead of reporting a summary-only session', async () => { + const file = uncPath('GrokHistory', ...GROK_DIR, 'session.json') + mocks.readFile.mockResolvedValue(GROK_SESSION) + mocks.open.mockResolvedValue(stallingHandle()) + + await expectRefusal(candidate('grok', file)) + await releaseAndSettle() + + mocks.open.mockResolvedValue(servingHandle(GROK_HISTORY)) + const recovered = await parseAgentSessionFileCached(candidate('grok', file), 'linux') + + expect(recovered?.previewMessages).toEqual([ + expect.objectContaining({ role: 'user', text: 'ship it' }) + ]) + }) + + it('still returns the summary-only session when chat_history is missing', async () => { + const file = uncPath('GrokMissing', ...GROK_DIR, 'session.json') + mocks.readFile.mockResolvedValue(GROK_SESSION) + mocks.open.mockRejectedValue(missing()) + + const session = await parseAgentSessionFileCached(candidate('grok', file), 'linux') + + expect(session).toMatchObject({ + agent: 'grok', + title: 'Grok session', + previewMessages: [] + }) + }) +}) + +describe('OpenCode session parse against a stalled WSL transcript', () => { + function sessionPath(distro: string): string { + return uncPath(distro, ...OPENCODE_ROOT, 'session', 'prj', 'ses-1.json') + } + + it('refuses a stalled message-directory listing instead of an empty transcript', async () => { + mocks.readFile.mockResolvedValue(OPENCODE_SESSION) + mocks.readdir.mockImplementation(stalls) + + await expectRefusal(candidate('opencode', sessionPath('OpenCodeDir'))) + }) + + it('refuses a stalled per-message read instead of a partial transcript', async () => { + const file = sessionPath('OpenCodeMessage') + mocks.readdir.mockResolvedValue([dirent('msg-1.json')]) + mocks.readFile.mockImplementation((path: string) => + path === file ? Promise.resolve(OPENCODE_SESSION) : stalls() + ) + + await expectRefusal(candidate('opencode', file)) + await releaseAndSettle() + + mocks.readFile.mockImplementation((path: string) => + Promise.resolve(path === file ? OPENCODE_SESSION : OPENCODE_MESSAGE) + ) + const recovered = await parseAgentSessionFileCached(candidate('opencode', file), 'linux') + + expect(recovered?.messageCount).toBe(1) + }) + + // Same guard as Kimi's: a live process mid-write must stay a parseable + // session, not a refusal. + it('still returns the session when a live process left a half-written message', async () => { + const file = sessionPath('OpenCodeHalfWritten') + mocks.readdir.mockResolvedValue([dirent('msg-1.json')]) + mocks.readFile.mockImplementation((path: string) => + Promise.resolve(path === file ? OPENCODE_SESSION : '{"role":"user",') + ) + + const session = await parseAgentSessionFileCached(candidate('opencode', file), 'linux') + + expect(session).toMatchObject({ agent: 'opencode', title: 'OpenCode session', messageCount: 0 }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts index b18402584e5..0f44aa923fa 100644 --- a/src/main/ai-vault/session-scanner-cursor-parser.ts +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -33,7 +33,7 @@ export async function parseCursorSessionFile( platform: NodeJS.Platform = process.platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseCursorSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 4ad6fa45f78..a43603bca47 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' @@ -27,7 +27,11 @@ export async function parseDevinSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return parseDevinSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform + ) } export function parseDevinSessionContent( diff --git a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts index ce24afccc2c..962a2e20fa9 100644 --- a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts +++ b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts @@ -1,6 +1,54 @@ -import { describe, expect, it } from 'vitest' -import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' -import { walkSessionFiles } from './session-scanner-discovery' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Dirent } from 'node:fs' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readdir: fsMocks.readdir, + stat: fsMocks.stat +})) + +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' +import { discoverFiles, walkSessionFiles } from './session-scanner-discovery' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' + +function dirent(name: string): Dirent { + return { name, isDirectory: () => false, isFile: () => true } as Dirent +} + +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as T) + }) +} + +beforeEach(() => { + fsMocks.readdir.mockReset() + fsMocks.stat.mockReset() + fsMocks.readdir.mockResolvedValue([]) + releaseStall = undefined +}) + +// A stalled task holds its gate permit until the call settles; releasing it +// keeps the next case from fast-failing on an already-exhausted gate. +afterEach(async () => { + if (!vi.isFakeTimers()) { + return + } + releaseStall?.() + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() +}) describe('walkSessionFiles WSL gate refusals', () => { it('rethrows gate refusals instead of reporting an empty tree', async () => { @@ -26,3 +74,64 @@ describe('walkSessionFiles WSL gate refusals', () => { ).resolves.toEqual([]) }) }) + +describe('discoverFiles containment for a stalled WSL root', () => { + const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions' + + it('contains a stalled readdir to one root so a Promise.all fan-out still resolves', async () => { + vi.useFakeTimers() + { + fsMocks.readdir.mockImplementation((dir: string) => + dir === UNC_ROOT ? stalls() : Promise.resolve([]) + ) + const issues: AiVaultScanIssue[] = [] + const discovery = discoverFiles({ + rootDir: UNC_ROOT, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'] + }) + const healthy = discoverFiles({ + rootDir: '/home/ada/.codex/sessions', + limit: 10, + agent: 'codex', + issues: [], + extensions: ['.jsonl'] + }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + await expect(Promise.all([discovery, healthy])).resolves.toEqual([ + { agent: 'codex', rootDir: UNC_ROOT, files: [] }, + { agent: 'codex', rootDir: '/home/ada/.codex/sessions', files: [] } + ]) + expect(issues).toEqual([{ agent: 'codex', path: UNC_ROOT, message: SLOW_MESSAGE }]) + } + }) + + it('reports every stalled file without spending one deadline per file', async () => { + vi.useFakeTimers() + { + const names = ['a', 'b', 'c', 'd', 'e'].map((name) => dirent(`${name}.jsonl`)) + fsMocks.readdir.mockResolvedValue(names) + fsMocks.stat.mockImplementation(stalls) + const issues: AiVaultScanIssue[] = [] + const discovery = discoverFiles({ + rootDir: UNC_ROOT, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'] + }) + + // Two deadlines total, not five: once the first stalled stat is marked + // stuck, every later file fast-fails on the route instead of queueing. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2) + + await expect(discovery).resolves.toEqual({ agent: 'codex', rootDir: UNC_ROOT, files: [] }) + expect(issues).toHaveLength(names.length) + expect(issues.every((issue) => issue.path.startsWith(UNC_ROOT))).toBe(true) + } + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 7f72082a266..9ec569bc547 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,8 +1,9 @@ import type { Dirent } from 'node:fs' -import { readdir, stat } from 'node:fs/promises' import { extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { recordSessionScanIssue } from './session-scan-issues' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -15,15 +16,31 @@ export async function discoverFiles(args: { filePredicate?: (path: string) => boolean directoryPredicate?: (name: string, depth: number) => boolean }): Promise { - const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { - extensions: new Set(args.extensions), - filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate - }) + let paths: string[] + try { + paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate + }) + } catch (err) { + // Why: discoverAiVaultSessionSources fans out with Promise.all, so one + // stalled distro would otherwise reject the whole vault scan — including + // every healthy local agent. Contain it to this root. + if (!(err instanceof WslTranscriptFsError)) { + throw err + } + recordSessionScanIssue(args.issues, { + agent: args.agent, + path: args.rootDir, + message: err.message + }) + return { agent: args.agent, rootDir: args.rootDir, files: [] } + } const files: FileWithMtime[] = [] for (const path of paths) { try { - const fileStat = await stat(path) + const fileStat = await wslGatedStat(path, 'scan') files.push({ path, mtimeMs: fileStat.mtimeMs, @@ -34,7 +51,11 @@ export async function discoverFiles(args: { nlink: fileStat.nlink }) } catch (err) { - args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { + agent: args.agent, + path, + message: errorMessage(err) + }) } } return { @@ -64,7 +85,7 @@ export async function walkSessionFiles( try { entries = options.readDirectory ? await options.readDirectory(dirPath) - : await readdir(dirPath, { withFileTypes: true }) + : await wslGatedReaddir(dirPath, 'scan', options.signal) } catch (error) { options.signal?.throwIfAborted() // Why: a gate refusal means the scan could not run, not that the tree is diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index 557920e7f40..c896b47a3e8 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -34,11 +34,16 @@ export async function parseDroidSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseDroidSessionLines({ file, lines, platform }) + const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + return await parseDroidSessionLines({ file, lines, platform }) + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-parse throw cannot leak the gated transcript handle. + lines.close() + input.destroy() + } } export async function parseDroidSessionContent( diff --git a/src/main/ai-vault/session-scanner-fs-import-guard.test.ts b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts new file mode 100644 index 00000000000..472ca932eac --- /dev/null +++ b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts @@ -0,0 +1,81 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const AI_VAULT_DIR = join(import.meta.dirname, '.') +const NATIVE_CHAT_DIR = join(import.meta.dirname, '..', 'native-chat') + +// Transcript I/O in these modules must go through the WSL admission gate, so a +// stalled 9P mount cannot hang a scan or a Native Chat load (STA-4049). +const NATIVE_CHAT_MODULES = [ + 'transcript-read-cache.ts', + 'transcript-reader.ts', + 'transcript-tail-reader.ts', + 'transcript-file-version.ts', + 'transcript-incremental-reader.ts', + 'transcript-watch-engine.ts' +] + +// Adding to this list must be a reviewed act: each entry names why the file +// touches a path that can never be a WSL UNC transcript. +const ALLOWLIST = new Set([ + // `existsSync` on bundled module paths inside the app dir. + 'session-scanner-service-entry-path.ts', + 'session-scanner-service-spawn.ts', + 'session-scanner-worker-spawn.ts', + 'session-scanner-opencode-sqlite-worker-spawn.ts', + // On-demand IPC readers, gated in the STA-4049 follow-up. + 'session-scanner-claude-subagents.ts', + 'session-scanner-omp-subagent-listing.ts', + // Test-only fixture builder. + 'session-scanner-test-fixtures.ts' +]) + +const FS_IMPORT = /import\s+([\s\S]*?)\s+from\s+['"]node:fs(?:\/promises)?['"]/g + +function valueImportsOfNodeFs(source: string): string[] { + return [...source.matchAll(FS_IMPORT)] + .map((match) => match[1]!.trim()) + .filter((clause) => !clause.startsWith('type')) +} + +async function guardedFiles(): Promise<{ name: string; path: string }[]> { + const aiVault = (await readdir(AI_VAULT_DIR)) + .filter((name) => name.startsWith('session-scanner') || name === 'session-title-file-reader.ts') + .filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) + .filter((name) => !ALLOWLIST.has(name)) + .map((name) => ({ name, path: join(AI_VAULT_DIR, name) })) + return [ + ...aiVault, + ...NATIVE_CHAT_MODULES.map((name) => ({ name, path: join(NATIVE_CHAT_DIR, name) })) + ] +} + +describe('WSL transcript gate import guard', () => { + it('leaves no raw node:fs value import in a gated transcript module', async () => { + const files = await guardedFiles() + const offenders: string[] = [] + for (const file of files) { + for (const clause of valueImportsOfNodeFs(await readFile(file.path, 'utf-8'))) { + offenders.push(`${file.name}: import ${clause} from 'node:fs…'`) + } + } + expect(offenders).toEqual([]) + }) + + it('actually covers the modules it claims to', async () => { + const names = (await guardedFiles()).map((file) => file.name) + expect(names).toEqual(expect.arrayContaining(NATIVE_CHAT_MODULES)) + // The V12–V17 sites the previous `*parser*` wording silently skipped. + expect(names).toEqual( + expect.arrayContaining([ + 'session-title-file-reader.ts', + 'session-scanner.ts', + 'session-scanner-values.ts', + 'session-scanner-codex-title-index.ts', + 'session-scanner-kimi-paths.ts', + 'session-scanner-opencode-sources.ts' + ]) + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index c3cda8856bb..d8d8a485d8f 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,6 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { @@ -34,7 +33,11 @@ export async function parseGeminiSessionFile( return parseGeminiJsonlSessionFile(file, platform) } - return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return parseGeminiJsonSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform + ) } export async function parseGeminiSessionContent( @@ -83,7 +86,7 @@ export async function parseGeminiJsonlSessionFile( platform: NodeJS.Platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseGeminiJsonlSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 250ebaf9ac9..d99893acaa2 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,6 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -41,7 +40,9 @@ export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + const metadata = asRecord( + JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown + ) if (!metadata) { return null } @@ -175,11 +176,16 @@ export async function parseMessageGraphSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseMessageGraphSessionLines({ agent, file, lines, platform }) + const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + return await parseMessageGraphSessionLines({ agent, file, lines, platform }) + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-parse throw cannot leak the gated transcript handle. + lines.close() + input.destroy() + } } export async function parseMessageGraphSessionContent( diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 46ed29e8f1f..94e41e4b052 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -1,5 +1,5 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -36,7 +36,7 @@ export async function parseGrokSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown) if (!record) { return null } @@ -62,12 +62,13 @@ async function consumeGrokChatHistory( accumulator: SessionAccumulator, sessionDir: string ): Promise { + const input = openTranscriptReadStream( + join(sessionDir, 'chat_history.jsonl'), + { encoding: 'utf-8' }, + 'scan' + ) + const lines = createInterface({ input, crlfDelay: Infinity }) try { - const lines = createInterface({ - input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -111,8 +112,18 @@ async function consumeGrokChatHistory( seedFirstUserPrompt: false }) } - } catch { - // Summary-only sessions still provide enough metadata for the Vault list. + } catch (error) { + // Summary-only sessions still provide enough metadata for the Vault list. A + // gate refusal is a different thing — the history exists but is unreachable, + // so a message-less session must not be cached under the summary's mtime. + if (error instanceof WslTranscriptFsError) { + throw error + } + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-read failure cannot leak the gated transcript handle. + lines.close() + input.destroy() } } diff --git a/src/main/ai-vault/session-scanner-hermes-parser.ts b/src/main/ai-vault/session-scanner-hermes-parser.ts index be844cc6627..017fd9c8fdc 100644 --- a/src/main/ai-vault/session-scanner-hermes-parser.ts +++ b/src/main/ai-vault/session-scanner-hermes-parser.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' @@ -26,7 +26,11 @@ export async function parseHermesSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return parseHermesSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform + ) } export async function parseHermesSessionContent( diff --git a/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts new file mode 100644 index 00000000000..1a5da65f1a9 --- /dev/null +++ b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const CODEX_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' +const CODEX_SESSION_FILE = `${CODEX_HOME}\\sessions\\2026\\01\\01\\rollout-1.jsonl` +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const KIMI_INDEX = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.kimi\\session_index.jsonl' + +const mocks = vi.hoisted(() => ({ stat: vi.fn(), open: vi.fn(), readFile: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat, + open: mocks.open, + readFile: mocks.readFile +})) + +import { + readCodexSessionIndexTitle, + resetCodexSessionIndexTitleCacheForTests, + _hasCodexSessionIndexTitleCacheEntryForTest +} from './session-scanner-codex-title-index' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' +import { readJsonObjectIfExists } from './session-scanner-values' +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' + +// Identity must not change between the refused and the recovered read, or the +// cache would miss for that reason instead of because the entry was evicted. +const INDEX_STATS = { size: 128, mtimeMs: 7, ctimeMs: 7 } + +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as never) + }) +} + +function servingHandle(body: string) { + const bytes = Buffer.from(body) + return { + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = bytes.subarray(position, Math.min(position + length, bytes.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + }), + close: vi.fn(async () => {}) + } +} + +async function releaseAndSettle(): Promise { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + resetCodexSessionIndexTitleCacheForTests() + clearKimiSessionIndexCache() + mocks.stat.mockReset() + mocks.open.mockReset() + mocks.readFile.mockReset() + mocks.stat.mockResolvedValue(INDEX_STATS) + releaseStall = undefined + vi.useFakeTimers() +}) + +afterEach(async () => { + await releaseAndSettle() + vi.useRealTimers() +}) + +describe('memoized WSL session indexes under a stalled mount', () => { + it('evicts the Codex title index instead of pinning "no titles"', async () => { + mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) }) + const refused = readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + expect(await refused).toBeNull() + expect(_hasCodexSessionIndexTitleCacheEntryForTest(CODEX_HOME)).toBe(false) + + await releaseAndSettle() + mocks.open.mockResolvedValue( + servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Ship the gate' })}\n`) + ) + + // Same size/mtime as the refused read: only the eviction can produce a hit. + expect(await readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)).toBe( + 'Ship the gate' + ) + }) + + it('evicts the Kimi work-dir index instead of pinning "no cwd"', async () => { + mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) }) + const refused = readKimiWorkDirBySessionId(KIMI_INDEX) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + expect(await refused).toEqual(new Map()) + expect(hasKimiSessionIndexCacheEntryForTests(KIMI_INDEX)).toBe(false) + + await releaseAndSettle() + mocks.open.mockResolvedValue( + servingHandle(`${JSON.stringify({ sessionId: SESSION_ID, workDir: '/repo/app' })}\n`) + ) + + expect(await readKimiWorkDirBySessionId(KIMI_INDEX)).toEqual( + new Map([[SESSION_ID, '/repo/app']]) + ) + }) + + it('surfaces a refused optional JSON enrichment instead of caching it as absent', async () => { + mocks.readFile.mockImplementation(stalls) + const pending = readJsonObjectIfExists(`${CODEX_HOME}\\history.json`).catch( + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + // A throw, not `null`: `parseSessionCandidate` turns it into a scan issue, + // where `null` would cache an un-enriched session that never re-reads. + expect(await pending).toBeInstanceOf(WslTranscriptFsError) + }) + + it('still degrades a missing optional JSON file to null', async () => { + mocks.readFile.mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' as const }) + ) + await expect(readJsonObjectIfExists(`${CODEX_HOME}\\missing.json`)).resolves.toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-kimi-parser.ts b/src/main/ai-vault/session-scanner-kimi-parser.ts index 61341098e0d..ba6deaa8fb8 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.ts @@ -1,5 +1,5 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { @@ -36,8 +36,16 @@ export async function parseKimiSessionFile( ): Promise { let stateRecord: Record | null try { - stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) - } catch { + stateRecord = asRecord( + JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown + ) + } catch (error) { + // Why: a missing/half-written state file is genuinely "no session", but a + // gate refusal is not — returning null would let the parse cache store that + // degraded answer under the file's unchanged mtime and never retry. + if (error instanceof WslTranscriptFsError) { + throw error + } return null } if (!stateRecord) { @@ -83,11 +91,9 @@ async function consumeKimiWireTranscript( } } + const input = openTranscriptReadStream(wirePath, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) try { - const lines = createInterface({ - input: createReadStream(wirePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -111,9 +117,18 @@ async function consumeKimiWireTranscript( break } } - } catch { + } catch (error) { // No transcript yet (session created but never ran a turn) — metadata-only - // sessions still belong in the panel. + // sessions still belong in the panel. A gate refusal instead means the wire + // bytes exist but are unreachable; a partial session must not be cached. + if (error instanceof WslTranscriptFsError) { + throw error + } + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-read failure cannot leak the gated transcript handle. + lines.close() + input.destroy() } flushAssistant() } diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index 7ed457f6c07..590e4aee814 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -1,8 +1,8 @@ -import { stat } from 'node:fs/promises' -import { createReadStream } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' +import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { asRecord, extractString } from './session-scanner-values' import { KimiSessionIndexCache, @@ -78,9 +78,9 @@ export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolea export async function readKimiWorkDirBySessionId(indexPath: string): Promise> { const generation = workDirCacheByIndexPath.beginRead() - let identity: Awaited> + let identity: Awaited> try { - identity = await stat(indexPath) + identity = await wslGatedStat(indexPath, 'scan') } catch { // Missing index (e.g. user deleted it): sessions still list, just without cwd. workDirCacheByIndexPath.delete(indexPath, generation) @@ -95,18 +95,30 @@ export async function readKimiWorkDirBySessionId(indexPath: string): Promise parseKimiSessionIndex(indexPath) + () => + parseKimiSessionIndex(indexPath).then(({ map, refused }) => { + // Why: a gate refusal is a stalled distro, not a cwd-less index — evict + // so the partial map is not served until the index's identity changes. + // Inside the loader, not at the call site: only the caller that missed + // runs this, so a concurrent hit would otherwise serve and keep it. + if (refused) { + workDirCacheByIndexPath.delete(indexPath, generation) + } + return map + }) ) } -async function parseKimiSessionIndex(indexPath: string): Promise> { +async function parseKimiSessionIndex( + indexPath: string +): Promise<{ map: Map; refused: boolean }> { const map = new Map() // Why: never reject. This promise is memoized and shared by every session // under one Kimi home; a mid-read failure (file deleted after stat, EACCES) // must degrade to whatever was parsed so the other sessions still list. try { const lines = createInterface({ - input: createReadStream(indexPath, { encoding: 'utf-8' }), + input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) for await (const line of lines) { @@ -126,8 +138,9 @@ async function parseKimiSessionIndex(indexPath: string): Promise { const filePath = join(args.artifactDir, args.name) try { - const fileStat = await stat(filePath) + const fileStat = await wslGatedStat(filePath, OMP_SUBAGENT_FS_PRIORITY) // The shared OMP parser decorates every parse with an artifact-dir count, so // a child row carries its own grandchild count. It is accurate but has no // renderer — subagent rows don't expand — and this lister is local-only, so @@ -107,7 +116,11 @@ async function parseOmpSubagentTranscript(args: { } } } catch (err) { - args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { + agent: 'omp', + path: filePath, + message: errorMessage(err) + }) return null } } diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts index 9136e8c0d34..57cadebeee0 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -1,4 +1,4 @@ -import { readdir } from 'node:fs/promises' +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, extname, join } from 'node:path' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' @@ -51,7 +51,7 @@ export function isOmpSubagentTranscriptFileName(name: string, isFile: boolean): export async function countOmpSubagentTranscripts(transcriptFilePath: string): Promise { let entries try { - entries = await readdir(ompArtifactDirFor(transcriptFilePath), { withFileTypes: true }) + entries = await wslGatedReaddir(ompArtifactDirFor(transcriptFilePath), 'scan') } catch { return 0 } diff --git a/src/main/ai-vault/session-scanner-opencode-parser.ts b/src/main/ai-vault/session-scanner-opencode-parser.ts index 4231f7d9de4..30223e70866 100644 --- a/src/main/ai-vault/session-scanner-opencode-parser.ts +++ b/src/main/ai-vault/session-scanner-opencode-parser.ts @@ -1,4 +1,5 @@ -import { readFile, readdir } from 'node:fs/promises' +import { wslGatedReaddir, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { join } from 'node:path' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' @@ -26,7 +27,7 @@ export async function parseOpenCodeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown) if (!record) { return null } @@ -47,8 +48,13 @@ export async function parseOpenCodeSessionFile( async function readOpenCodeMessagesInOrder(messageDir: string): Promise[]> { let entries try { - entries = await readdir(messageDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(messageDir, 'scan') + } catch (error) { + // A session with no message dir yet is empty; a gate refusal is not — an + // empty transcript would otherwise be cached under the session's mtime. + if (error instanceof WslTranscriptFsError) { + throw error + } return [] } const messages: { name: string; createdMs: number; message: Record }[] = [] @@ -59,11 +65,15 @@ async function readOpenCodeMessagesInOrder(messageDir: string): Promise | null = null try { message = asRecord( - JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown + JSON.parse(await wslGatedReadFile(join(messageDir, entry.name), 'utf-8', 'scan')) as unknown ) - } catch { + } catch (error) { // A live OpenCode process can leave a half-written file; skip it rather - // than discard the whole session. + // than discard the whole session. A refused read is not a bad file — the + // partial transcript it would produce must not become the cached answer. + if (error instanceof WslTranscriptFsError) { + throw error + } continue } if (!message) { diff --git a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts new file mode 100644 index 00000000000..accc60d008f --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Dirent } from 'node:fs' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' +const WSL_DATA_DIR = `${WSL_HOME}/.local/share/opencode` + +const mocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readdir: mocks.readdir, + stat: mocks.stat +})) + +// The SQLite leg spawns a real worker thread, which fake timers cannot drive. +vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({ + listOpenCodeSqliteSessionsViaWorker: async () => [] +})) + +import { opencodeDiscoveries } from './session-scanner-opencode-sources' +import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate' + +let releaseStall: (() => void) | undefined + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as never) + }) +} + +beforeEach(() => { + mocks.readdir.mockReset() + mocks.stat.mockReset() + releaseStall = undefined + vi.useFakeTimers() +}) + +afterEach(async () => { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() +}) + +describe('OpenCode source discovery with a stalled WSL data directory', () => { + it('reports the stalled home as an issue and still resolves every discovery', async () => { + mocks.readdir.mockImplementation((dir: string) => + dir === WSL_DATA_DIR ? stalls() : Promise.resolve([] as Dirent[]) + ) + const issues: AiVaultScanIssue[] = [] + const discoveries = Promise.all( + opencodeDiscoveries( + { opencodeStorageDir: '/home/ada/.local/share/opencode/storage' }, + [WSL_HOME], + 10, + issues + ) + ) + // Two deadlines: the data-dir probe, then the storage scan that queued + // behind it before the route was flagged stuck. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2) + + // Zero databases for that home is the degraded answer; without the issue it + // would be indistinguishable from "OpenCode was never installed there". + const resolved = await discoveries + expect(resolved).toHaveLength(2) + expect(resolved.every((discovery) => discovery.files.length === 0)).toBe(true) + expect(issues.some((issue) => issue.path === WSL_DATA_DIR)).toBe(true) + expect(issues.every((issue) => issue.agent === 'opencode')).toBe(true) + }) + + it('reports a refused primary data directory instead of scanning clean', async () => { + const previousXdg = process.env.XDG_DATA_HOME + const previousDb = process.env.OPENCODE_DB + process.env.XDG_DATA_HOME = WSL_HOME + delete process.env.OPENCODE_DB + try { + mocks.readdir.mockImplementation(stalls) + const issues: AiVaultScanIssue[] = [] + const discoveries = Promise.all(opencodeDiscoveries({}, [], 10, issues)) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2) + + // The primary source is the one per-root containment cannot reach, so a + // silent [] here reads as "no OpenCode sessions" on a clean scan. + await expect(discoveries).resolves.toHaveLength(1) + expect( + issues.some((issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode`) + ).toBe(true) + } finally { + restoreEnv('XDG_DATA_HOME', previousXdg) + restoreEnv('OPENCODE_DB', previousDb) + } + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index 6d84965f8c0..365e6334512 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,8 +1,10 @@ -import { readdir } from 'node:fs/promises' import { dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory' import { listOpenCodeDatabases } from '../opencode-usage/scanner' +import { recordSessionScanIssue } from './session-scan-issues' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' @@ -16,7 +18,7 @@ export function opencodeDiscoveries( return storageDirs.map(async (storageDir, index) => discoverOpenCodeSessions({ storageDir, - dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues), limitPerAgent: limit, issues }) @@ -37,32 +39,47 @@ async function opencodeDbPathsForSource( options: AiVaultScanOptions, wslHomeDirs: readonly string[], storageDir: string, - sourceIndex: number + sourceIndex: number, + issues: AiVaultScanIssue[] ): Promise { if (options.opencodeDbPaths) { return sourceIndex === 0 ? options.opencodeDbPaths : [] } // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. if (sourceIndex === 0 && options.opencodeStorageDir) { - return listOpenCodeDatabasesInDirectory(dirname(storageDir)) + return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues) } if (sourceIndex === 0) { - return listOpenCodeDatabases() + return listOpenCodeDatabases((path, error) => { + recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message }) + }) } const wslHomeDir = wslHomeDirs[sourceIndex - 1] return wslHomeDir - ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues) : [] } -async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { +async function listOpenCodeDatabasesInDirectory( + dataDir: string, + issues: AiVaultScanIssue[] +): Promise { try { - const entries = await readdir(dataDir, { withFileTypes: true }) + const entries = await wslGatedReaddir(dataDir, 'scan') return entries .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) .map((entry) => join(dataDir, entry.name)) .sort() - } catch { + } catch (error) { + // A stalled WSL data dir still degrades to "no databases", but the gap has + // to be reportable — an empty list otherwise reads as "OpenCode not used". + if (error instanceof WslTranscriptFsError) { + recordSessionScanIssue(issues, { + agent: 'opencode', + path: dataDir, + message: error.message + }) + } return [] } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 727d70ac319..563055ffa39 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,5 +1,7 @@ -import { createReadStream } from 'node:fs' -import { open } from 'node:fs/promises' +import { + openTranscriptReadStream, + readTranscriptSlice +} from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' @@ -305,13 +307,8 @@ async function parseResumableCandidate(args: { // agent transcripts are append-only so that trade is accepted (worst case is // a stale vault row until the file is next truncated or the app restarts). async function endsWithNewlineAt(path: string, offset: number): Promise { - const handle = await open(path, 'r') - try { - const { bytesRead, buffer } = await handle.read(Buffer.alloc(1), 0, 1, offset - 1) - return bytesRead === 1 && buffer[0] === NEWLINE_BYTE - } finally { - await handle.close() - } + const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan') + return slice.length === 1 && slice[0] === NEWLINE_BYTE } type JsonlReadResult = { @@ -336,7 +333,7 @@ async function consumeCompleteJsonlLines(args: { let remainderParts: Buffer[] = [] let remainderLength = 0 - const stream = createReadStream(args.path, { start: args.start }) + const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan') for await (const chunk of stream as AsyncIterable) { bytesRead += chunk.length // Why check the chunk alone: the pieces held over are all mid-line, so none diff --git a/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts new file mode 100644 index 00000000000..9eea5176c66 --- /dev/null +++ b/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type { SessionFileCandidate } from './session-scanner-types' + +const STALLED_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\a.jsonl' +const SIBLING_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\b.jsonl' + +const mocks = vi.hoisted(() => ({ open: vi.fn(), readdir: vi.fn() })) + +// readdir too: every claude parse counts sibling subagent transcripts, and an +// unmocked one would reach the host UNC path and stall under fake timers. +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + open: mocks.open, + readdir: mocks.readdir +})) + +import { + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from './session-scanner-parse-cache' +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' + +type ReadResult = { bytesRead: number; buffer: Buffer } + +// A stalled task keeps the gate's single scan slot until the call settles, so +// every case releases its stall before the next one runs. +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) }) + }) +} + +function servingHandle(body: Buffer) { + return { + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = body.subarray(position, Math.min(position + length, body.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + }), + close: vi.fn(async () => {}) + } +} + +function record(index: number, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +function body(count: number): Buffer { + return Buffer.from( + Array.from({ length: count }, (_, index) => `${record(index, `line ${index}`)}\n`).join('') + ) +} + +function candidate(path: string, bytes: Buffer, mtimeMs: number): SessionFileCandidate { + return { + agent: 'claude', + file: { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: bytes.length + }, + codexHome: null + } +} + +async function releaseAndSettle(): Promise { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + resetSessionParseCacheForTests() + mocks.open.mockReset() + mocks.readdir.mockReset() + mocks.readdir.mockResolvedValue([]) + releaseStall = undefined + vi.useFakeTimers() +}) + +afterEach(async () => { + await releaseAndSettle() + vi.useRealTimers() +}) + +describe('AI Vault session parse with a stalled WSL transcript body read', () => { + it('refuses the stalled candidate and still parses a sibling on a healthy distro', async () => { + mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) }) + // Rejecting (not resolving null) is what lets `parseSessionCandidate` + // report it as an issue instead of silently dropping the session. The + // assertion is attached before the clock moves so the rejection is never + // momentarily unhandled. + const refusal = expect( + parseAgentSessionFileCached(candidate(STALLED_PATH, body(2), 1), 'linux') + ).rejects.toBeInstanceOf(WslTranscriptFsError) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + await refusal + + await releaseAndSettle() + const siblingBytes = body(2) + mocks.open.mockResolvedValue(servingHandle(siblingBytes)) + + const sibling = await parseAgentSessionFileCached( + candidate(SIBLING_PATH, siblingBytes, 1), + 'linux' + ) + expect(sibling?.messageCount).toBe(2) + }) + + it('caches no resume point when the newline probe is refused mid-append', async () => { + const first = body(2) + mocks.open.mockResolvedValue(servingHandle(first)) + const seeded = await parseAgentSessionFileCached(candidate(STALLED_PATH, first, 1), 'linux') + expect(seeded?.messageCount).toBe(2) + + const grown = body(3) + const serving = servingHandle(grown) + mocks.open.mockResolvedValue({ + // Only the 1-byte resume probe stalls; the body read stays healthy. + read: vi.fn((buffer: Buffer, offset: number, length: number, position: number) => + length === 1 ? stalls() : serving.read(buffer, offset, length, position) + ), + close: vi.fn(async () => {}) + }) + const refused = expect( + parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux') + ).rejects.toBeInstanceOf(WslTranscriptFsError) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + await refused + + await releaseAndSettle() + mocks.open.mockResolvedValue(servingHandle(grown)) + const recovered = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux') + + resetSessionParseCacheForTests() + const cold = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux') + expect(recovered).toEqual(cold) + expect(recovered?.messageCount).toBe(3) + }) +}) diff --git a/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts new file mode 100644 index 00000000000..7470b3430d2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Readable } from 'node:stream' +import type * as NodeReadlineModule from 'node:readline' +import type * as WslFsAccessModule from '../native-chat/wsl-transcript-fs-access' +import type { FileWithMtime } from './session-scanner-types' + +// readline.close() leaves its input open, so a parser that stops consuming +// mid-file (a throw, a break) strands the gated transcript handle until the +// gate's deadline and delays every later scan. These pin the finally blocks. +const mocks = vi.hoisted(() => ({ + openStream: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), + createInterface: vi.fn() +})) + +vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => ({ + ...(await importOriginal()), + openTranscriptReadStream: mocks.openStream, + wslGatedReadFile: mocks.readFile, + wslGatedStat: mocks.stat +})) + +vi.mock('node:readline', async (importOriginal) => ({ + ...(await importOriginal()), + createInterface: mocks.createInterface +})) + +import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' +import { parseDroidSessionFile } from './session-scanner-droid-parser' +import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers' +import { parseGrokSessionFile } from './session-scanner-grok-parser' +import { parseKimiSessionFile } from './session-scanner-kimi-parser' +import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths' + +const PARSE_FAILURE = 'parser failed mid-transcript' + +const opened: { path: string; stream: Readable }[] = [] +const interfaces: { close: ReturnType }[] = [] + +function file(path: string): FileWithMtime { + return { path, mtimeMs: 1, modifiedAt: '2026-06-01T10:05:00.000Z', sizeBytes: 128 } +} + +function lastOpened(): { path: string; stream: Readable } { + const entry = opened.at(-1) + if (!entry) { + throw new Error('no transcript stream was opened') + } + return entry +} + +function expectStreamTornDown(): void { + expect(lastOpened().stream.destroyed).toBe(true) + expect(interfaces.at(-1)?.close).toHaveBeenCalled() +} + +beforeEach(() => { + opened.length = 0 + interfaces.length = 0 + clearKimiSessionIndexCache() + mocks.openStream.mockReset() + mocks.readFile.mockReset() + mocks.stat.mockReset() + mocks.createInterface.mockReset() + + mocks.openStream.mockImplementation((path: string) => { + const stream = new Readable({ + read() { + this.push(null) + } + }) + opened.push({ path, stream }) + return stream + }) + // One line, then a consumer-side throw: the parser must still tear the + // stream down on its way out. + mocks.createInterface.mockImplementation(() => { + const lines = { + close: vi.fn(), + [Symbol.asyncIterator]: async function* () { + yield '{}' + throw new Error(PARSE_FAILURE) + } + } + interfaces.push(lines) + return lines + }) +}) + +describe('session parsers that stop consuming a gated transcript early', () => { + it.each([ + ['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')], + ['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')], + ['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')] + ])('destroys the stream when the %s parse throws', async (_agent, parse) => { + await expect(parse()).rejects.toThrow(PARSE_FAILURE) + + expectStreamTornDown() + }) + + it('destroys the chat_history stream when the Grok parse swallows the failure', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({ info: { id: 'ses-1' } })) + + // Grok degrades to a summary-only session on a non-gate failure, so the + // teardown has no rejection to ride out on. + await expect( + parseGrokSessionFile(file('/w/.grok/sessions/ses-1/session.json')) + ).resolves.toBeTruthy() + + expect(lastOpened().path).toContain('chat_history.jsonl') + expectStreamTornDown() + }) + + it('destroys the wire stream when the Kimi parse swallows the failure', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({ title: 'Kimi session' })) + // No session_index.jsonl, so only the wire transcript opens a stream. + mocks.stat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + await expect( + parseKimiSessionFile(file('/w/.kimi-code/sessions/wd_app/session_abc/state.json')) + ).resolves.toBeTruthy() + + expect(lastOpened().path).toContain('wire.jsonl') + expectStreamTornDown() + }) +}) diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 92db2241320..f554268c7e5 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' @@ -210,7 +210,7 @@ export async function parseClaudeSessionFile( platform: NodeJS.Platform = process.platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseClaudeSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index b0a4792ffd6..e787aef628e 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -1,13 +1,18 @@ -import { createReadStream } from 'node:fs' -import { readdir, stat } from 'node:fs/promises' import { createInterface } from 'node:readline' import { extname, join } from 'node:path' +import { + openTranscriptReadStream, + wslGatedReaddir, + wslGatedStat +} from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { isPathInsideOrEqual, normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' +import { recordSessionScanIssue } from './session-scan-issues' import type { FileWithMtime } from './session-scanner-types' import { errorMessage, extractString, parseJsonObject } from './session-scanner-values' @@ -26,7 +31,18 @@ export function resetProjectDirCwdCacheForTests(): void { projectDirCwdCache.clear() } -async function cachedProjectDirCwd(projectDir: string): Promise { +// A gate refusal is a stalled WSL root, not an empty one: keep the existing +// degrade-to-empty containment but make the gap visible in the scan issues. +function recordRefusal(issues: AiVaultScanIssue[], path: string, error: unknown): void { + if (error instanceof WslTranscriptFsError) { + recordSessionScanIssue(issues, { agent: 'claude', path, message: error.message }) + } +} + +async function cachedProjectDirCwd( + projectDir: string, + issues: AiVaultScanIssue[] +): Promise { const cached = projectDirCwdCache.get(projectDir) if (cached !== undefined) { // Refresh recency so hot in-scope dirs outlive one-off ones at the cap. @@ -34,7 +50,7 @@ async function cachedProjectDirCwd(projectDir: string): Promise { projectDirCwdCache.set(projectDir, cached) return cached } - const cwd = await readProjectDirCwd(projectDir) + const cwd = await readProjectDirCwd(projectDir, issues) if (cwd) { if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) { const oldest = projectDirCwdCache.keys().next() @@ -70,8 +86,8 @@ export async function discoverInScopeClaudeFiles(args: { const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths) const collected = new Map() for (const rootDir of args.rootDirs) { - for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) { - const cwd = await cachedProjectDirCwd(projectDir) + for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes, args.issues)) { + const cwd = await cachedProjectDirCwd(projectDir, args.issues) if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) { continue } @@ -148,12 +164,14 @@ function isCwdInsideScopePath(scopePath: string, cwd: string): boolean { async function listProjectDirs( rootDir: string, - scopeProjectPrefixes: ReadonlySet + scopeProjectPrefixes: ReadonlySet, + issues: AiVaultScanIssue[] ): Promise { let entries try { - entries = await readdir(rootDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(rootDir, 'scan') + } catch (err) { + recordRefusal(issues, rootDir, err) return [] } return entries @@ -163,10 +181,13 @@ async function listProjectDirs( .map((entry) => join(rootDir, entry.name)) } -async function readProjectDirCwd(projectDir: string): Promise { - const files = await newestClaudeFilesInDir(projectDir) +async function readProjectDirCwd( + projectDir: string, + issues: AiVaultScanIssue[] +): Promise { + const files = await newestClaudeFilesInDir(projectDir, issues) for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) { - const cwd = await readFirstCwd(file) + const cwd = await readFirstCwd(file, issues) if (cwd) { return cwd } @@ -174,11 +195,15 @@ async function readProjectDirCwd(projectDir: string): Promise { return null } -async function newestClaudeFilesInDir(projectDir: string): Promise { +async function newestClaudeFilesInDir( + projectDir: string, + issues: AiVaultScanIssue[] +): Promise { let entries try { - entries = await readdir(projectDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(projectDir, 'scan') + } catch (err) { + recordRefusal(issues, projectDir, err) return [] } const newest: { path: string; mtimeMs: number }[] = [] @@ -190,18 +215,19 @@ async function newestClaudeFilesInDir(projectDir: string): Promise { try { addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, { path, - mtimeMs: (await stat(path)).mtimeMs + mtimeMs: (await wslGatedStat(path, 'scan')).mtimeMs }) - } catch { + } catch (err) { // Best effort: unreadable candidates are ignored here and reported during // full collection if the project directory proves in-scope. + recordRefusal(issues, path, err) } } return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path) } -async function readFirstCwd(filePath: string): Promise { - const input = createReadStream(filePath, { encoding: 'utf-8' }) +async function readFirstCwd(filePath: string, issues: AiVaultScanIssue[]): Promise { + const input = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'scan') const lines = createInterface({ input, crlfDelay: Infinity }) let read = 0 try { @@ -214,7 +240,8 @@ async function readFirstCwd(filePath: string): Promise { return cwd } } - } catch { + } catch (err) { + recordRefusal(issues, filePath, err) return null } finally { // readline.close() leaves the underlying stream open; destroy it so the early @@ -234,8 +261,9 @@ async function collectClaudeFiles(args: { }): Promise { let entries try { - entries = await readdir(args.projectDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(args.projectDir, 'scan') + } catch (err) { + recordRefusal(args.issues, args.projectDir, err) return } for (const entry of entries) { @@ -247,7 +275,7 @@ async function collectClaudeFiles(args: { continue } try { - const fileStat = await stat(path) + const fileStat = await wslGatedStat(path, 'scan') addBoundedFile(args.collected, args.limit, { path, mtimeMs: fileStat.mtimeMs, @@ -255,7 +283,7 @@ async function collectClaudeFiles(args: { sizeBytes: fileStat.size }) } catch (err) { - args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { agent: 'claude', path, message: errorMessage(err) }) } } } diff --git a/src/main/ai-vault/session-scanner-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-subagent-transcripts.ts index d7df4aeedac..8d477f450af 100644 --- a/src/main/ai-vault/session-scanner-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-subagent-transcripts.ts @@ -1,4 +1,4 @@ -import { readdir } from 'node:fs/promises' +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, extname, join } from 'node:path' // Exported so discovery can prune these subtrees using the same literal that @@ -38,7 +38,7 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string { export async function countSubagentTranscripts(transcriptFilePath: string): Promise { let entries try { - entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true }) + entries = await wslGatedReaddir(subagentTranscriptsDirFor(transcriptFilePath), 'scan') } catch { return 0 } diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index b058876fda3..f7d62611adb 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join } from 'node:path' -import { readFile } from 'node:fs/promises' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { asRecord } from './session-scanner-record-value' export { asRecord } @@ -76,8 +77,15 @@ export async function readJsonObjectIfExists( filePath: string ): Promise | null> { try { - return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown) - } catch { + return asRecord(JSON.parse(await wslGatedReadFile(filePath, 'utf-8', 'scan')) as unknown) + } catch (error) { + // A missing or malformed file is genuinely "no enrichment", but a gate + // refusal must reach `parseSessionCandidate` as an issue — degrading it to + // null caches the un-enriched session under an unchanged mtime, and the + // non-resumable agents that use this never re-read it. + if (error instanceof WslTranscriptFsError) { + throw error + } return null } } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 3a98b7018ac..b4ef6f06ce7 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,4 +1,3 @@ -import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -14,6 +13,7 @@ import { } from './codex-session-root-dedup' import { createAntigravityWorkspaceResolver, + readLocalAntigravityHistory, type AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' @@ -27,6 +27,7 @@ import { parseAgentSessionFileCached, type SessionParseStats } from './session-scanner-parse-cache' +import { recordSessionScanIssue } from './session-scan-issues' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' import { DEFAULT_CODEX_HOME_DIR, @@ -71,7 +72,9 @@ export async function scanAiVaultSessions( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() - const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver( + readLocalAntigravityHistory + ) // Why: persisted entries must be seeded before any candidate is parsed, or // the cold scan gains nothing from the cache file (#9210). throwIfAiVaultScanCancelled(options.signal) @@ -255,7 +258,7 @@ async function parseSessionCandidates(args: { for (const result of results) { if (result.issue) { - args.issues.push(result.issue) + recordSessionScanIssue(args.issues, result.issue) } if (result.session) { sessions.push(result.session) @@ -305,14 +308,6 @@ async function parseSessionCandidate( } } -async function readOptionalTextFile(path: string): Promise { - try { - return await readFile(path, 'utf-8') - } catch { - return null - } -} - function withSessionExecutionHost( session: AiVaultSession, executionHostId: ExecutionHostId diff --git a/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts b/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts new file mode 100644 index 00000000000..337c2064d8a --- /dev/null +++ b/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\stalled.jsonl' +const STALLED_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const LOCAL_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + +const mocks = vi.hoisted(() => ({ lstat: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + // Only the WSL request is faked; the local sibling reads a real temp file. + lstat: (path: string, ...rest: unknown[]) => + path.startsWith('\\\\') + ? mocks.lstat(path) + : (original.lstat as (...args: unknown[]) => unknown)(path, ...rest) + } +}) + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader' +import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' +import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate' + +let releaseStall: (() => void) | undefined +let tempRoot: string | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as never) + }) +} + +function userRecord(sessionId: string, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp: new Date(1740000000000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +beforeEach(async () => { + resetSessionParseCacheForTests() + mocks.lstat.mockReset() + releaseStall = undefined + tempRoot = await mkdtemp(join(tmpdir(), 'orca-title-stall-')) +}) + +afterEach(async () => { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = undefined + } +}) + +describe('AI Vault session titles with a stalled WSL transcript lstat', () => { + it('drops only the stalled request and still returns the rest of the batch', async () => { + const localPath = join(tempRoot!, `${LOCAL_SESSION_ID}.jsonl`) + await writeFile(localPath, `${userRecord(LOCAL_SESSION_ID, 'hello world')}\n`) + mocks.lstat.mockImplementation(stalls) + vi.useFakeTimers() + + const pending = readAiVaultSessionTitlesFromFiles([ + { agent: 'claude', sessionId: STALLED_SESSION_ID, transcriptPath: UNC_PATH }, + { agent: 'claude', sessionId: LOCAL_SESSION_ID, transcriptPath: localPath } + ]) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + const { titles } = await pending + expect(titles).toEqual([{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world' }]) + }) +}) diff --git a/src/main/ai-vault/session-title-file-reader.ts b/src/main/ai-vault/session-title-file-reader.ts index 799d2b95da9..15228d132bb 100644 --- a/src/main/ai-vault/session-title-file-reader.ts +++ b/src/main/ai-vault/session-title-file-reader.ts @@ -1,4 +1,4 @@ -import { lstat } from 'node:fs/promises' +import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT, type AiVaultSessionTitle, @@ -27,7 +27,7 @@ async function readOneTitle( return cache?.get(request) ?? null } try { - const stats = await lstat(transcriptPath) + const stats = await wslGatedLstat(transcriptPath, 'scan', signal) if (!stats.isFile() || signal?.aborted) { return null } 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/folder-workspace-diff-comments.ts b/src/main/folder-workspace-diff-comments.ts new file mode 100644 index 00000000000..1a7435e8930 --- /dev/null +++ b/src/main/folder-workspace-diff-comments.ts @@ -0,0 +1,37 @@ +import type { DiffComment, FolderWorkspace } from '../shared/types' + +// Why shape-only: this replaces folder-workspaces.ts's verbatim `Array.isArray(raw.diffComments)` read. +// Filtering members would make the fix itself a new deletion path for user-authored prose. +export function normalizeFolderWorkspaceDiffComments( + value: unknown +): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined + } + const normalized: Record = {} + let kept = false + for (const [id, comments] of Object.entries(value)) { + if (!Array.isArray(comments)) { + continue + } + normalized[id] = comments as DiffComment[] + kept = true + } + return kept ? normalized : undefined +} + +// Why derive on every write: the map self-GCs, so delete paths need no pruning code. +export function collectFolderWorkspaceDiffComments( + workspaces: readonly FolderWorkspace[] | undefined +): Record | undefined { + const collected: Record = {} + let kept = false + for (const workspace of workspaces ?? []) { + const comments = workspace.diffComments + if (Array.isArray(comments) && comments.length > 0) { + collected[workspace.id] = comments + kept = true + } + } + return kept ? collected : undefined +} 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/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 958fff9e8a3..83970cfa4ca 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -45,6 +45,7 @@ const SNAPSHOT = { worktreeName: 'Dashboard', hostKind: 'ssh', executionHostId: 'ssh:build-box', + hostLabel: 'Build box', workspaceKind: 'worktree', workspaceStatusId: 'in-review', workspaceStatusLabel: 'In review', @@ -68,6 +69,7 @@ const SNAPSHOT = { parentWorktreeId: 'parent-worktree-1', hostKind: 'ssh', executionHostId: 'ssh:build-box', + hostLabel: 'Build box', workspaceKind: 'worktree', workspaceStatusId: 'in-review', workspaceStatusLabel: 'In review', @@ -143,6 +145,12 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], executionHostId: `ssh:${'x'.repeat(4_097)}` }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], hostLabel: 'x'.repeat(1_025) }] + }) + ).toBe(false) expect( isDashboardSnapshot({ ...SNAPSHOT, @@ -236,6 +244,12 @@ describe('dashboard payload validation', () => { workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], hostLabel: 'x'.repeat(1_025) }] + }) + ).toBe(false) }) it('validates bounded launch choices and spawn requests', () => { diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index f819392dae1..6186e638fd1 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -272,6 +272,7 @@ function isDashboardCard(value: unknown): boolean { (card.executionHostId === undefined || (isBoundedString(card.executionHostId, MAX_ID_LENGTH) && normalizeExecutionHostId(card.executionHostId) !== null)) && + isOptionalBoundedString(card.hostLabel, MAX_LABEL_LENGTH) && (card.workspaceKind === undefined || (typeof card.workspaceKind === 'string' && DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) && diff --git a/src/main/ipc/dashboard-workspace-payload-validation.ts b/src/main/ipc/dashboard-workspace-payload-validation.ts index 88e16d9af45..f64cf63d42a 100644 --- a/src/main/ipc/dashboard-workspace-payload-validation.ts +++ b/src/main/ipc/dashboard-workspace-payload-validation.ts @@ -50,6 +50,7 @@ export function isDashboardWorkspace(value: unknown): value is DashboardWorkspac HOST_KINDS.has(workspace.hostKind) && isString(workspace.executionHostId, MAX_ID_LENGTH) && normalizeExecutionHostId(workspace.executionHostId) !== null && + isOptionalString(workspace.hostLabel, DASHBOARD_MAX_LABEL_LENGTH) && typeof workspace.workspaceKind === 'string' && WORKSPACE_KINDS.has(workspace.workspaceKind) && isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) && diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e89a4c21806..d44ad8ef651 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; @@ -6253,7 +6296,7 @@ describe('registerPtyHandlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) }) - it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => { + it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const store = { markSshRemotePtyLease: vi.fn() @@ -6296,22 +6339,30 @@ describe('registerPtyHandlers', () => { ) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { kill: (ptyId: string) => boolean + retireRejectedPty: (ptyId: string) => void } try { expect(controller.kill('remote-pty')).toBe(true) await new Promise((resolve) => setImmediate(resolve)) + expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'ssh-1', + 'remote-pty', + 'terminated' + ) + controller.retireRejectedPty('remote-pty') } finally { warnSpy.mockRestore() deletePtyOwnership('remote-pty') } - expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith( + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( 'ssh-1', 'remote-pty', 'terminated' ) expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined) }) it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ddc829d2d13..c2877303561 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' @@ -3562,6 +3563,23 @@ export function registerPtyHandlers( syntheticKillExitPtyIds.set(id, cleanupTimer) } + // Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late + // synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the + // surface the retirement just removed. Timed like the duplicate window so a reused id is free. + const retiredRejectedPtyIds = new Map() + + function rememberRetiredRejectedPty(id: string): void { + const existing = retiredRejectedPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + const cleanupTimer = setTimeout(() => { + retiredRejectedPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + retiredRejectedPtyIds.set(id, cleanupTimer) + } + function consumeSyntheticKillExit(id: string): boolean { const cleanupTimer = syntheticKillExitPtyIds.get(id) if (!cleanupTimer) { @@ -4715,6 +4733,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) @@ -5469,19 +5489,23 @@ export function registerPtyHandlers( // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) .then((providerExitObserved) => { + const retired = retiredRejectedPtyIds.has(ptyId) const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved) { + if (!providerExitObserved && !retired) { runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) } }) .catch((err) => { + const retired = retiredRejectedPtyIds.has(ptyId) if (isPtyAlreadyGoneError(err)) { const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) + if (!retired) { + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + } return } console.warn( @@ -5489,7 +5513,9 @@ export function registerPtyHandlers( ) // Why: close runtime tails without clearing provider ownership, so // a retry can still target a PTY that survived the failed shutdown. - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + if (!retired) { + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } }) return true } @@ -5500,12 +5526,31 @@ export function registerPtyHandlers( console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + if (!retiredRejectedPtyIds.has(ptyId)) { + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } }) return true } return killWithCurrentProvider() }, + retireRejectedPty: (ptyId) => { + rememberRetiredRejectedPty(ptyId) + // Why: a completed stop already cleared provider state, tombstoned the lease and told the + // renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an + // SSH pane retires for good instead of staying preserved by the stop's negative exit. + if (!ptyOwnership.has(ptyId)) { + runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) + return + } + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, 0, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: 0 }) + }, markReversibleStops: (ptyIds) => { for (const ptyId of ptyIds) { reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) @@ -6354,6 +6399,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/native-chat/native-chat-file-provenance.test.ts b/src/main/native-chat/native-chat-file-provenance.test.ts new file mode 100644 index 00000000000..f92bd829026 --- /dev/null +++ b/src/main/native-chat/native-chat-file-provenance.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import type { RuntimeMobileSessionClientTab } from '../../shared/runtime-types' +import { nativeChatTranscriptIncludesPath } from './native-chat-file-provenance' + +function terminalTab(): RuntimeMobileSessionClientTab { + return { + type: 'terminal', + id: 'tab-1', + title: 'Codex', + parentTabId: 'parent-1', + leafId: 'leaf-1', + launchAgent: 'codex', + agentStatus: { + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey: 'parent-1:leaf-1', + stateHistory: [], + agentType: 'codex', + providerSession: { + key: 'session_id', + id: 'session-1', + transcriptPath: '/host/transcripts/session-1.jsonl' + } + }, + isActive: true, + status: 'ready', + terminal: 'term-1' + } +} + +function message(role: NativeChatMessage['role'], text: string): NativeChatMessage { + return { + id: `${role}-1`, + role, + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' + } +} + +describe('nativeChatTranscriptIncludesPath', () => { + it('accepts a path in recent assistant output from the host-bound session', async () => { + const readTranscript = vi.fn(async () => ({ + messages: [message('assistant', 'Open ~/orca-plans/result.html to review it.')] + })) + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'session-1' }, + pathText: '~/orca-plans/result.html', + absolutePath: '/Users/ada/orca-plans/result.html', + readTranscript + }) + ).resolves.toBe(true) + expect(readTranscript).toHaveBeenCalledWith({ + agent: 'codex', + sessionId: 'session-1', + transcriptPath: '/host/transcripts/session-1.jsonl', + limit: 2000 + }) + }) + + it('rejects a client session identity that does not match the host tab', async () => { + const readTranscript = vi.fn() + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'forged-session' }, + pathText: '/etc/passwd', + absolutePath: '/etc/passwd', + readTranscript + }) + ).resolves.toBe(false) + expect(readTranscript).not.toHaveBeenCalled() + }) + + it('accepts a path followed by a sentence-final period', async () => { + const readTranscript = vi.fn(async () => ({ + messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.')] + })) + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'session-1' }, + pathText: '/tmp/orca-pr14166-external.txt', + absolutePath: '/tmp/orca-pr14166-external.txt', + readTranscript + }) + ).resolves.toBe(true) + }) + + it('does not accept a longer filename sharing the requested path prefix', async () => { + const readTranscript = vi.fn(async () => ({ + messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.backup')] + })) + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'session-1' }, + pathText: '/tmp/orca-pr14166-external.txt', + absolutePath: '/tmp/orca-pr14166-external.txt', + readTranscript + }) + ).resolves.toBe(false) + }) + + it('does not treat user-authored transcript text as agent provenance', async () => { + const readTranscript = vi.fn(async () => ({ + messages: [message('user', 'Please open /etc/passwd')] + })) + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'session-1' }, + pathText: '/etc/passwd', + absolutePath: '/etc/passwd', + readTranscript + }) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/native-chat/native-chat-file-provenance.ts b/src/main/native-chat/native-chat-file-provenance.ts new file mode 100644 index 00000000000..07b0b47082e --- /dev/null +++ b/src/main/native-chat/native-chat-file-provenance.ts @@ -0,0 +1,74 @@ +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionTerminalClientTab, + RuntimeNativeChatFileContext +} from '../../shared/runtime-types' +import { recentTerminalOutputIncludesPath } from '../runtime/terminal-output-path-candidates' +import { readNativeChatTranscriptTail } from './transcript-tail-reader' + +// Match the largest transcript window mobile can render so any visible citation remains provable. +const NATIVE_CHAT_FILE_PROVENANCE_WINDOW = 2000 + +type TranscriptRead = (args: { + agent: AgentType + sessionId: string + transcriptPath?: string + limit: number +}) => Promise<{ messages: NativeChatMessage[] } | { error: string }> + +function transcriptTextIncludesPath(text: string, pathText: string, absolutePath: string): boolean { + if (recentTerminalOutputIncludesPath(text, pathText, absolutePath)) { + return true + } + // Mobile excludes a sentence-final period from the tappable path span. + return recentTerminalOutputIncludesPath(text, `${pathText}.`, `${absolutePath}.`) +} + +export async function nativeChatTranscriptIncludesPath(args: { + tabs: readonly RuntimeMobileSessionClientTab[] + context: RuntimeNativeChatFileContext + pathText: string + absolutePath: string + readTranscript?: TranscriptRead +}): Promise { + const tab = args.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalClientTab => + candidate.type === 'terminal' && candidate.id === args.context.tabId + ) + const providerSession = tab?.agentStatus?.providerSession + const agent = tab?.agentStatus?.agentType ?? tab?.launchAgent + if ( + !tab || + !providerSession || + providerSession.id !== args.context.sessionId || + !agent || + !resolveNativeChatTranscriptAgent(agent) + ) { + return false + } + + try { + const result = await (args.readTranscript ?? readNativeChatTranscriptTail)({ + agent, + sessionId: providerSession.id, + ...(providerSession.transcriptPath ? { transcriptPath: providerSession.transcriptPath } : {}), + limit: NATIVE_CHAT_FILE_PROVENANCE_WINDOW + }) + if (!('messages' in result)) { + return false + } + return result.messages.some( + (message) => + message.role === 'assistant' && + message.blocks.some( + (block) => + block.type === 'text' && + transcriptTextIncludesPath(block.text, args.pathText, args.absolutePath) + ) + ) + } catch { + return false + } +} diff --git a/src/main/native-chat/transcript-file-version.ts b/src/main/native-chat/transcript-file-version.ts index 69634c0a957..2de3c76821f 100644 --- a/src/main/native-chat/transcript-file-version.ts +++ b/src/main/native-chat/transcript-file-version.ts @@ -1,4 +1,6 @@ -import { stat } from 'node:fs/promises' +import { readTranscriptSlice, wslGatedStat } from './wsl-transcript-fs-access' + +const BOUNDARY_FINGERPRINT_BYTES = 64 export type TranscriptFileVersion = { identity: string @@ -7,8 +9,11 @@ export type TranscriptFileVersion = { ctimeMs: number } -export async function readTranscriptFileVersion(filePath: string): Promise { - const value = await stat(filePath) +export async function readTranscriptFileVersion( + filePath: string, + signal?: AbortSignal +): Promise { + const value = await wslGatedStat(filePath, 'exact', signal) return { identity: `${value.dev}:${value.ino}`, size: value.size, @@ -17,6 +22,19 @@ export async function readTranscriptFileVersion(filePath: string): Promise { + if (offset <= 0) { + return '' + } + const start = Math.max(0, offset - BOUNDARY_FINGERPRINT_BYTES) + const slice = await readTranscriptSlice(filePath, start, offset - start, 'exact', signal) + return slice.toString('base64') +} + export function transcriptFileVersionChanged( current: TranscriptFileVersion, previous: TranscriptFileVersion diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index 8ad8cc335d1..1dd60f58292 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -1,10 +1,10 @@ -import { open, stat } from 'node:fs/promises' import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, type NativeChatLineDecoder } from './transcript-tail-reader' +import { openTranscriptReadStream, wslGatedStat } from './wsl-transcript-fs-access' const APPEND_BATCH_MESSAGE_LIMIT = 40 @@ -30,16 +30,21 @@ export async function readIncrementalTranscriptMessages( decode: NativeChatLineDecoder, onBatch?: (messages: NativeChatMessage[]) => void, decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null, - onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void + onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void, + signal?: AbortSignal ): Promise { - const end = (await stat(filePath)).size + const end = (await wslGatedStat(filePath, 'exact', signal)).size if (end <= state.offset) { return [] } const messages: NativeChatMessage[] = [] - const handle = await open(filePath, 'r') + const stream = openTranscriptReadStream( + filePath, + { start: state.offset, end: end - 1 }, + 'exact', + signal + ) try { - const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false }) let absoluteOffset = state.offset for await (const rawChunk of stream) { const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) @@ -62,7 +67,9 @@ export async function readIncrementalTranscriptMessages( } return messages } finally { - await handle.close() + // Early exits (throw/oversized-record bail) must not leak the fd or, on + // UNC, the gated handle the generator's finally closes. + stream.destroy() } function retainPart(part: Buffer): void { diff --git a/src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts b/src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts new file mode 100644 index 00000000000..4e865b306fc --- /dev/null +++ b/src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl' +const mocks = vi.hoisted(() => ({ + resolve: vi.fn<() => Promise>(), + stat: vi.fn(), + open: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat, + open: mocks.open +})) + +import { + clearNativeChatTranscriptCache, + readNativeChatTranscriptCached +} from './transcript-read-cache' +import { + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, + WSL_TRANSCRIPT_FS_SLOW_MESSAGE +} from './wsl-transcript-fs-gate' + +const BODY = Buffer.from( + `${JSON.stringify({ + type: 'user', + uuid: 'u-0', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'hello' } + })}\n` +) + +let releaseStall: (() => void) | undefined + +function stalls(value: T): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(value) + }) +} + +function transcriptHandle(body = BODY) { + return { + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = body.subarray(position, Math.min(position + length, body.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + }), + close: vi.fn(async () => {}) + } +} + +beforeEach(() => { + clearNativeChatTranscriptCache() + mocks.resolve.mockReset() + mocks.stat.mockReset() + mocks.open.mockReset() + releaseStall = undefined + mocks.resolve.mockResolvedValue(UNC_PATH) + // The file itself never changes across the refusal and the recovery. + mocks.stat.mockResolvedValue({ mtimeMs: 42, size: BODY.length }) + vi.useFakeTimers() +}) + +afterEach(async () => { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() +}) + +describe('cached native chat transcript read after WSL gate refusals', () => { + it('retries a refused stat instead of caching it as a missing file', async () => { + mocks.stat.mockReturnValueOnce(stalls({ mtimeMs: 42, size: BODY.length })) + const refused = readNativeChatTranscriptCached('claude', 'session-id') + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + + expect(await refused).toEqual({ error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE }) + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + mocks.open.mockResolvedValue(transcriptHandle()) + + await expect(readNativeChatTranscriptCached('claude', 'session-id')).resolves.toMatchObject({ + messages: [expect.objectContaining({ role: 'user' })] + }) + expect(mocks.stat).toHaveBeenCalledTimes(2) + }) + + it('retries the body on the next call even though the mtime is unchanged', async () => { + mocks.open.mockResolvedValue({ + read: vi.fn((buffer: Buffer) => stalls({ bytesRead: 0, buffer })), + close: vi.fn(async () => {}) + }) + const refused = readNativeChatTranscriptCached('claude', 'session-id') + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + expect(await refused).toEqual({ error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE }) + + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + mocks.open.mockResolvedValue(transcriptHandle()) + + const recovered = await readNativeChatTranscriptCached('claude', 'session-id') + + expect(mocks.open).toHaveBeenCalledTimes(2) + expect(recovered).toMatchObject({ + messages: [expect.objectContaining({ role: 'user' })] + }) + }) +}) diff --git a/src/main/native-chat/transcript-read-cache-wsl-stall.test.ts b/src/main/native-chat/transcript-read-cache-wsl-stall.test.ts new file mode 100644 index 00000000000..1ce0a22e455 --- /dev/null +++ b/src/main/native-chat/transcript-read-cache-wsl-stall.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl' + +const mocks = vi.hoisted(() => ({ + resolve: vi.fn<() => Promise>(), + stat: vi.fn(), + read: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) +vi.mock('./transcript-reader', () => ({ + readNativeChatTranscript: mocks.read +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat +})) + +import { + clearNativeChatTranscriptCache, + readNativeChatTranscriptCached +} from './transcript-read-cache' +import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' + +beforeEach(() => { + mocks.resolve.mockReset() + mocks.stat.mockReset() + mocks.read.mockReset() + mocks.resolve.mockResolvedValue(UNC_PATH) + clearNativeChatTranscriptCache() +}) + +describe('cached native chat transcript read with a stalled post-resolution stat', () => { + it('reports a retryable error without notFound and leaves nothing cached', async () => { + vi.useFakeTimers() + try { + mocks.stat.mockReturnValueOnce(new Promise(() => {})) + const stalled = readNativeChatTranscriptCached('claude', 'session-id') + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + + const result = await stalled + expect(result).toEqual({ error: SLOW_MESSAGE }) + expect(result).not.toHaveProperty('notFound') + } finally { + vi.useRealTimers() + } + }) + + it('re-attempts the stat on the next call instead of serving a poisoned entry', async () => { + // The stalled task above still holds its permit, so this fast-fails on the + // stuck route rather than waiting out a second deadline — but it must still + // reach `stat` again once a different distro answers. + const healthyPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\session.jsonl' + mocks.resolve.mockResolvedValue(healthyPath) + mocks.stat.mockResolvedValue({ mtimeMs: 5, size: 0 }) + mocks.read.mockResolvedValue({ messages: [] }) + + await readNativeChatTranscriptCached('claude', 'session-id') + + expect(mocks.stat).toHaveBeenCalledWith(healthyPath) + }) + + it('serves the cached parse rather than a retry banner once the distro stalls', async () => { + const path = '\\\\wsl.localhost\\Fedora\\home\\ada\\.claude\\projects\\p\\session.jsonl' + mocks.resolve.mockResolvedValue(path) + mocks.stat.mockResolvedValue({ mtimeMs: 9, size: 4 }) + mocks.read.mockResolvedValue({ messages: [{ role: 'user', text: 'hi' }] }) + const warm = await readNativeChatTranscriptCached('claude', 'session-id') + + vi.useFakeTimers() + try { + mocks.stat.mockReturnValueOnce(new Promise(() => {})) + const stalled = readNativeChatTranscriptCached('claude', 'session-id') + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + + // The stat is what the stall refuses; the parse it would have validated is + // complete and unchanged, so the chat stays rendered. + await expect(stalled).resolves.toBe(warm) + expect(mocks.read).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index d2a4b4d3de2..ee0c8ddcafd 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -1,8 +1,13 @@ -import { stat } from 'node:fs/promises' import type { AgentType } from '../../shared/native-chat-types' import { resolveSessionFilePath } from './session-file-resolver' import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' -import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' +import { wslGatedStat } from './wsl-transcript-fs-access' +import { + WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE, + WSL_TRANSCRIPT_FS_SLOW_MESSAGE, + WslTranscriptFsError, + wslTranscriptFsRefusal +} from './wsl-transcript-fs-gate' // Why: both the desktop IPC handler and the runtime RPC handler read the same // host-filesystem transcript, so a single process-global cache keyed by the @@ -72,9 +77,14 @@ function cacheKey(agent: AgentType, filePath: string): string { async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> { try { - const stats = await stat(filePath) + const stats = await wslGatedStat(filePath, 'exact') return { mtimeMs: stats.mtimeMs, bytes: stats.size } - } catch { + } catch (err) { + // Why: swallowing a gate refusal into an unknown mtime falls through to a + // full read that burns a second deadline. Let the caller report it instead. + if (err instanceof WslTranscriptFsError) { + throw err + } return { mtimeMs: Number.NaN, bytes: 0 } } } @@ -105,8 +115,22 @@ export async function readNativeChatTranscriptCached( } const key = cacheKey(agent, filePath) - const { mtimeMs, bytes } = await fileStat(filePath) const cached = cache.get(key) + let mtimeMs: number + let bytes: number + try { + ;({ mtimeMs, bytes } = await fileStat(filePath)) + } catch (err) { + // Why: the refusal says the distro stalled, not that this parse went stale — + // a complete cached transcript beats a retry banner. With nothing cached the + // error stands, and no `notFound`, so the next call re-stats a woken distro. + if (cached) { + // Bump recency so a session read through a stall survives eviction. + setCached(key, cached) + return cached.result + } + return { error: wslTranscriptFsRefusal(err).message } + } if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { // Bump recency so a frequently-read session survives eviction. setCached(key, cached) @@ -114,12 +138,25 @@ export async function readNativeChatTranscriptCached( } const result = await readNativeChatTranscript(agent, sessionId, { filePath }) - if (Number.isFinite(mtimeMs)) { + // Why: a body-read refusal is transient unavailability, but the file's mtime + // is unchanged by it — caching it would serve the retryable error to every + // later call until the transcript itself changes, even after the distro woke. + if (Number.isFinite(mtimeMs) && !isGateRefusal(result)) { setCached(key, { result, mtimeMs, bytes }) } return result } +// The reader flattens a refusal into its message, so that is the only handle +// this layer has on one. +function isGateRefusal(result: ReadTranscriptResult): boolean { + return ( + 'error' in result && + (result.error === WSL_TRANSCRIPT_FS_SLOW_MESSAGE || + result.error === WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE) + ) +} + /** Test-only: drop the transcript parse cache between runs. */ export function clearNativeChatTranscriptCache(): void { cache.clear() diff --git a/src/main/native-chat/transcript-reader-wsl-stall.test.ts b/src/main/native-chat/transcript-reader-wsl-stall.test.ts new file mode 100644 index 00000000000..47d50ab74bc --- /dev/null +++ b/src/main/native-chat/transcript-reader-wsl-stall.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl' + +const mocks = vi.hoisted(() => ({ open: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + open: mocks.open +})) + +import { readNativeChatTranscript } from './transcript-reader' +import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' + +const LINE = JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'hi' } +}) + +function handleServing(body: Buffer, delayMs = 0) { + return { + read: vi.fn( + (buffer: Buffer, offset: number, length: number, position: number) => + new Promise((resolve) => { + const slice = body.subarray(position, Math.min(position + length, body.length)) + slice.copy(buffer, offset) + const settle = () => resolve({ bytesRead: slice.length, buffer }) + if (delayMs > 0) { + setTimeout(settle, delayMs) + } else { + settle() + } + }) + ), + close: vi.fn(async () => {}) + } +} + +beforeEach(() => { + mocks.open.mockReset() +}) + +describe('native chat transcript read with a stalled post-resolution body read', () => { + it('surfaces a retryable error without notFound when the first chunk never settles', async () => { + vi.useFakeTimers() + try { + mocks.open.mockResolvedValue({ + read: vi.fn(() => new Promise(() => {})), + close: vi.fn(async () => {}) + }) + const reading = readNativeChatTranscript('claude', 'session-id', { filePath: UNC_PATH }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + + const result = await reading + expect(result).toEqual({ error: SLOW_MESSAGE }) + expect(result).not.toHaveProperty('notFound') + } finally { + vi.useRealTimers() + } + }) + + it('succeeds when every chunk is slow but under the deadline, past the whole-file budget', async () => { + vi.useFakeTimers() + const healthyPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\session.jsonl' + try { + const chunkDelayMs = WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS / 2 + mocks.open.mockResolvedValue(handleServing(Buffer.from(`${LINE}\n`), chunkDelayMs)) + const reading = readNativeChatTranscript('claude', 'session-id', { filePath: healthyPath }) + + // Two chunk admissions (body + EOF probe) span more than one exact deadline. + await vi.advanceTimersByTimeAsync(chunkDelayMs * 4) + + const result = await reading + expect(result).toMatchObject({ messages: [expect.objectContaining({ id: 'u1' })] }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index a8e14e0e180..a4d3acb7465 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,4 +1,3 @@ -import { createReadStream } from 'node:fs' import type { AgentType, NativeChatMessage, @@ -7,6 +6,7 @@ import type { import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { openTranscriptReadStream } from './wsl-transcript-fs-access' import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' import { decodeClaudeTranscriptLine, @@ -82,7 +82,7 @@ async function readTranscript( filePath: string, decode: (line: string, fallbackId: string) => NativeChatMessage | null ): Promise { - const stream = createReadStream(filePath, { encoding: 'utf-8' }) + const stream = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'exact') const { messages } = await decodeTranscriptStream(stream, filePath, 0, decode, true) return messages } diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts index 2bfa6f6f9d2..bc80d88bc0a 100644 --- a/src/main/native-chat/transcript-stream-lines.test.ts +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -32,6 +32,23 @@ describe('decodeTranscriptStream', () => { expect(incremental.messages[0]?.id).toBe(full.messages[1]?.id) }) + it('keeps a codepoint split across two Buffer chunks intact', async () => { + const line = `{"text":"😀é中"}\n` + const bytes = Buffer.from(line, 'utf8') + // Split inside the emoji's 4-byte sequence, as a 1 MiB gated chunk boundary does. + const split = Buffer.from('{"text":"', 'utf8').length + 2 + const result = await decodeTranscriptStream( + Readable.from([bytes.subarray(0, split), bytes.subarray(split)]), + '/chat.jsonl', + 0, + decode, + false + ) + + expect(result.messages[0]?.blocks[0]).toEqual({ type: 'text', text: line.slice(0, -1) }) + expect(result.consumedBytes).toBe(bytes.length) + }) + it('does not consume a partial trailing JSONL record', async () => { const complete = '{"first":true}\n' const partial = '{"second"' diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index a764941ad30..2d9e30915bc 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -1,4 +1,5 @@ import type { Readable } from 'node:stream' +import { StringDecoder } from 'node:string_decoder' import type { NativeChatMessage } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' @@ -12,11 +13,14 @@ export async function decodeTranscriptStream( includeTrailingLine: boolean ): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { const messages: NativeChatMessage[] = [] + // Why: a Buffer chunk can end mid-codepoint, and decoding it standalone would + // both corrupt the line and shift `consumedBytes` (which seeds fallback ids). + const decoder = new StringDecoder('utf8') let pending = '' let consumedBytes = 0 for await (const chunk of stream) { - pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') + pending += typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk)) let newlineIndex = pending.indexOf('\n') while (newlineIndex !== -1) { const segment = pending.slice(0, newlineIndex + 1) @@ -26,6 +30,7 @@ export async function decodeTranscriptStream( newlineIndex = pending.indexOf('\n') } } + pending += decoder.end() if (includeTrailingLine && pending.length > 0) { decodeLine(pending, consumedBytes) diff --git a/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts index ff5194668cd..da040de2794 100644 --- a/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts +++ b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts @@ -1,15 +1,27 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' const mocks = vi.hoisted(() => ({ - resolve: vi.fn<() => Promise>() + resolve: vi.fn<() => Promise>(), + stat: vi.fn(), + open: vi.fn() })) vi.mock('./session-file-resolver', () => ({ resolveSessionFilePath: mocks.resolve })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat, + open: mocks.open +})) + import { readNativeChatTranscriptTail } from './transcript-tail-reader' -import { WslTranscriptFsError } from './wsl-transcript-fs-gate' +import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, WslTranscriptFsError } from './wsl-transcript-fs-gate' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' describe('native chat transcript tail under WSL gate refusals', () => { beforeEach(() => { @@ -32,3 +44,69 @@ describe('native chat transcript tail under WSL gate refusals', () => { ).rejects.toThrow('resolver crashed') }) }) + +describe('native chat transcript tail with stalled post-resolution UNC I/O', () => { + const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl' + // A stalled task holds its gate permit until the underlying call settles, so + // each case releases its stall before the next one runs. + let releaseStall: (() => void) | undefined + + function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as T) + }) + } + + async function tailAfterStall(): Promise { + const pending = readNativeChatTranscriptTail({ + agent: 'codex', + sessionId: 'session-id', + filePath: UNC_PATH, + limit: 10 + }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + return pending + } + + beforeEach(() => { + mocks.stat.mockReset() + mocks.open.mockReset() + releaseStall = undefined + vi.useFakeTimers() + }) + + afterEach(async () => { + releaseStall?.() + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + }) + + it('reports a stalled stat as retryable without notFound', async () => { + mocks.stat.mockImplementation(stalls) + const result = await tailAfterStall() + expect(result).toEqual({ error: SLOW_MESSAGE }) + expect(result).not.toHaveProperty('notFound') + }) + + it('reports a stalled open as retryable without notFound', async () => { + mocks.stat.mockResolvedValue({ size: 4096 }) + mocks.open.mockImplementation(stalls) + const result = await tailAfterStall() + expect(result).toEqual({ error: SLOW_MESSAGE }) + expect(result).not.toHaveProperty('notFound') + }) + + it('closes the handle without a second admission when a positional read stalls', async () => { + const close = vi.fn(async () => {}) + mocks.stat.mockResolvedValue({ size: 4096 }) + mocks.open.mockResolvedValue({ read: vi.fn(stalls), close }) + + const result = await tailAfterStall() + + expect(result).toEqual({ error: SLOW_MESSAGE }) + expect(result).not.toHaveProperty('notFound') + // Fire-and-forget close: never gated, never awaited, exactly once. + await vi.advanceTimersByTimeAsync(0) + expect(close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 1264c2e3d98..e9cbe430090 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -1,4 +1,4 @@ -import { open, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' import type { AgentType, NativeChatMessage, @@ -17,6 +17,12 @@ import { nativeChatTurnLifecycleDecoderForAgent, type NativeChatTurnLifecycleDecoder } from './transcript-turn-lifecycle' +import { + closeTranscriptHandle, + wslGatedOpen, + wslGatedRead, + wslGatedStat +} from './wsl-transcript-fs-access' import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 @@ -59,12 +65,15 @@ export async function readNativeChatTranscriptTailFile( oversizedRecordCount?: number }> { signal?.throwIfAborted() - const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + const end = Math.min( + (await wslGatedStat(filePath, 'exact', signal)).size, + endOffset ?? Number.MAX_SAFE_INTEGER + ) signal?.throwIfAborted() if (end === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } - const handle = await open(filePath, 'r') + const handle = await wslGatedOpen(filePath, 'exact', signal) const lineParts: Buffer[] = [] let lineBytes = 0 let lineOversized = false @@ -76,13 +85,13 @@ export async function readNativeChatTranscriptTailFile( signal?.throwIfAborted() const consumedTo = includeTrailingLine ? end - : await findLastCompleteLineEnd(handle, end, signal) + : await findLastCompleteLineEnd(handle, filePath, end, signal) if (consumedTo === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } const newestFirst: { message: NativeChatMessage; offset: number }[] = [] const finalByte = Buffer.allocUnsafe(1) - await handle.read(finalByte, 0, 1, consumedTo - 1) + await wslGatedRead(handle, filePath, finalByte, 0, 1, consumedTo - 1, 'exact', signal) signal?.throwIfAborted() ignoreNextMalformedRecord = finalByte[0] !== 0x0a let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) @@ -90,7 +99,16 @@ export async function readNativeChatTranscriptTailFile( signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + const { bytesRead } = await wslGatedRead( + handle, + filePath, + buffer, + 0, + buffer.length, + start, + 'exact', + signal + ) signal?.throwIfAborted() let segmentEnd = bytesRead for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { @@ -126,7 +144,7 @@ export async function readNativeChatTranscriptTailFile( ...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {}) } } finally { - await handle.close() + await closeTranscriptHandle(handle, filePath) } function retainPart(part: Buffer): void { @@ -184,13 +202,14 @@ export async function readNativeChatTranscriptTailFile( } async function findLastCompleteLineEnd( - handle: Awaited>, + handle: FileHandle, + filePath: string, end: number, signal?: AbortSignal ): Promise { signal?.throwIfAborted() const lastByte = Buffer.allocUnsafe(1) - await handle.read(lastByte, 0, 1, end - 1) + await wslGatedRead(handle, filePath, lastByte, 0, 1, end - 1, 'exact', signal) signal?.throwIfAborted() if (lastByte[0] === 0x0a) { return end @@ -200,7 +219,16 @@ async function findLastCompleteLineEnd( signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + const { bytesRead } = await wslGatedRead( + handle, + filePath, + buffer, + 0, + buffer.length, + start, + 'exact', + signal + ) signal?.throwIfAborted() const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) if (newline !== -1) { diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index ff1df71ee0f..3c51319eae4 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -1,6 +1,6 @@ -import { open, stat } from 'node:fs/promises' import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { + boundaryFingerprint, readTranscriptFileVersion, transcriptFileVersionChanged, type TranscriptFileVersion @@ -18,6 +18,8 @@ import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import { createTranscriptWatchScheduler } from './transcript-watch-scheduler' +import { wslGatedStat } from './wsl-transcript-fs-access' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' const ROTATION_RETRY_MS = 25 const MAX_ROTATION_RETRY_MS = 2_000 @@ -27,21 +29,6 @@ export function getActiveNativeChatWatcherCount(): number { return activeWatcherCount } -async function boundaryFingerprint(filePath: string, offset: number): Promise { - if (offset <= 0) { - return '' - } - const start = Math.max(0, offset - 64) - const handle = await open(filePath, 'r') - try { - const buffer = Buffer.allocUnsafe(offset - start) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) - return buffer.subarray(0, bytesRead).toString('base64') - } finally { - await handle.close() - } -} - /** * Install the live-tail engine on an already-resolved file path. Returns null * when the file doesn't exist yet, so the caller falls back to resolve-polling. @@ -51,11 +38,20 @@ async function boundaryFingerprint(filePath: string, offset: number): Promise NativeChatMessage | null, - args: SubscribeNativeChatTranscriptArgs + args: SubscribeNativeChatTranscriptArgs, + /** Cancels the install probe so an unsubscribe during it detaches the gate + * waiter immediately instead of at the 30s deadline. */ + signal?: AbortSignal ): Promise { try { - await stat(filePath) - } catch { + await wslGatedStat(filePath, 'exact', signal) + } catch (error) { + // Why: "not flushed yet" degrades to resolve-polling, but a stalled distro + // must reach the caller so it can surface a retryable message instead of + // stranding the client at `loading`. + if (error instanceof WslTranscriptFsError) { + throw error + } return null } const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args @@ -75,6 +71,11 @@ export async function installTranscriptWatcher( // a persistently-failing retry loop can't spam the subscriber with error frames. let initialErrorEmitted = false let closed = false + // Why: every gated call on the drain path must detach the moment we + // unsubscribe, instead of holding a waiter until its 30s deadline, and an + // aborted signal also makes the gate refuse admission for anything the + // in-flight drain would start after teardown. + const gateAbort = new AbortController() let reading = false let pendingReadRequested = false let rotationRetryCount = 0 @@ -106,7 +107,8 @@ export async function installTranscriptWatcher( decodeLifecycle ?? undefined, (nextLifecycle) => { lifecycle = nextLifecycle - } + }, + gateAbort.signal ) if (!closed && (remaining.length > 0 || lifecycle)) { onAppend(remaining, lifecycle) @@ -114,8 +116,8 @@ export async function installTranscriptWatcher( } async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise { - watchedBoundary = await boundaryFingerprint(filePath, state.offset) - const completedVersion = await readTranscriptFileVersion(filePath) + watchedBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal) + const completedVersion = await readTranscriptFileVersion(filePath, gateAbort.signal) if (transcriptFileVersionChanged(completedVersion, startVersion)) { // Why: a write racing this drain needs another pass even when the reader // happened to reach its new EOF; timestamp-only rewrites may need replace. @@ -135,8 +137,8 @@ export async function installTranscriptWatcher( } async function drainOnce(): Promise { - const current = await readTranscriptFileVersion(filePath) - const currentBoundary = await boundaryFingerprint(filePath, state.offset) + const current = await readTranscriptFileVersion(filePath, gateAbort.signal) + const currentBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal) if (closed) { return } @@ -168,7 +170,8 @@ export async function installTranscriptWatcher( decode, false, undefined, - decodeLifecycle + decodeLifecycle, + gateAbort.signal ) : null if (closed) { @@ -196,7 +199,8 @@ export async function installTranscriptWatcher( decode, false, undefined, - decodeLifecycle + decodeLifecycle, + gateAbort.signal ) : null if (closed) { @@ -225,7 +229,8 @@ export async function installTranscriptWatcher( decodeLifecycle ?? undefined, (nextLifecycle) => { lifecycle = nextLifecycle - } + }, + gateAbort.signal ) if (closed) { return @@ -253,7 +258,7 @@ export async function installTranscriptWatcher( pendingReadRequested = false try { await drainOnce() - } catch { + } catch (error) { // Why: unlink/recreate can detach fs.watch from the pathname. Keep one // capped-backoff retry alive until a successor appears or we unsubscribe. // A still-pending initial drain also surfaces one error snapshot so a @@ -261,7 +266,12 @@ export async function installTranscriptWatcher( // throwing; initialDrain stays true so a recovered read can still win. if (!closed && initialDrain && onInitialSnapshot && !initialErrorEmitted) { initialErrorEmitted = true - onInitialSnapshot([], false, 0, 'Transcript unavailable') + onInitialSnapshot( + [], + false, + 0, + error instanceof WslTranscriptFsError ? error.message : 'Transcript unavailable' + ) } scheduleRotationRetry() break @@ -277,7 +287,7 @@ export async function installTranscriptWatcher( return } try { - const current = await readTranscriptFileVersion(filePath) + const current = await readTranscriptFileVersion(filePath, gateAbort.signal) if (closed) { return } @@ -317,6 +327,7 @@ export async function installTranscriptWatcher( return } closed = true + gateAbort.abort(new Error('Native Chat transcript watcher unsubscribed')) scheduler.dispose() nativeWatcher.dispose() activeWatcherCount-- diff --git a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts index 5a483c11c48..814addff83c 100644 --- a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts @@ -68,7 +68,12 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => { }) await vi.advanceTimersByTimeAsync(50) - expect(mocks.install).toHaveBeenCalledWith(ROLLOUT_UNC, expect.anything(), expect.anything()) + expect(mocks.install).toHaveBeenCalledWith( + ROLLOUT_UNC, + expect.anything(), + expect.anything(), + expect.any(AbortSignal) + ) expect(mocks.install.mock.calls.every(([path]) => path !== ROLLOUT_LINUX)).toBe(true) subscription.unsubscribe() }) @@ -84,7 +89,12 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => { }) await vi.advanceTimersByTimeAsync(50) - expect(mocks.install).toHaveBeenCalledWith(ROLLOUT_LINUX, expect.anything(), expect.anything()) + expect(mocks.install).toHaveBeenCalledWith( + ROLLOUT_LINUX, + expect.anything(), + expect.anything(), + expect.any(AbortSignal) + ) subscription.unsubscribe() }) }) diff --git a/src/main/native-chat/transcript-watch-wsl-stall.test.ts b/src/main/native-chat/transcript-watch-wsl-stall.test.ts new file mode 100644 index 00000000000..3fd066325bb --- /dev/null +++ b/src/main/native-chat/transcript-watch-wsl-stall.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type { NativeChatMessage } from '../../shared/native-chat-types' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl' + +const mocks = vi.hoisted(() => ({ + resolve: vi.fn<() => Promise>(), + stat: vi.fn(), + open: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ resolveSessionFilePath: mocks.resolve })) +vi.mock('./transcript-native-watcher', () => ({ + createTranscriptNativeWatcher: () => ({ + bind: () => true, + needsRebind: () => false, + invalidate: () => {}, + dispose: () => {} + }) +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat, + open: mocks.open +})) + +import { subscribeNativeChatTranscript } from './transcript-watch' +import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' + +type Snapshot = [NativeChatMessage[], boolean, number, string | undefined] + +let releaseStall: (() => void) | undefined +let unhandled: unknown[] = [] + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as T) + }) +} + +function subscribeCollecting(snapshots: Snapshot[]) { + return subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + onAppend: () => {}, + onInitialSnapshot: (messages, hasMore, beforeOffset, error) => { + snapshots.push([messages, hasMore, beforeOffset, error]) + }, + initialLimit: 10, + resolvePollIntervalMs: 50, + reconciliationIntervalMs: 1_000 + }) +} + +function trackUnhandled(reason: unknown): void { + unhandled.push(reason) +} + +beforeEach(() => { + mocks.resolve.mockReset() + mocks.stat.mockReset() + mocks.open.mockReset() + releaseStall = undefined + unhandled = [] + process.on('unhandledRejection', trackUnhandled) + vi.useFakeTimers() +}) + +afterEach(async () => { + releaseStall?.() + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + process.off('unhandledRejection', trackUnhandled) +}) + +const EMPTY_STATS = { size: 0, mtimeMs: 1, ctimeMs: 1, ino: 1, dev: 1, mtime: new Date(0) } + +describe('native chat transcript subscription with a stalled install stat', () => { + it('keeps watching and emits exactly one retryable snapshot, then the real one', async () => { + mocks.resolve.mockResolvedValue(UNC_PATH) + mocks.stat.mockImplementationOnce(stalls).mockResolvedValue(EMPTY_STATS) + const snapshots: Snapshot[] = [] + + // Not awaited yet: the setup install is itself blocked on the stalled stat. + const subscribing = subscribeCollecting(snapshots) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + const subscription = await subscribing + expect(subscription.watching).toBe(true) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + expect(snapshots).toEqual([[[], false, 0, SLOW_MESSAGE]]) + + // Later poll ticks fast-fail on the still-stuck route without re-emitting. + await vi.advanceTimersByTimeAsync(500) + expect(snapshots).toHaveLength(1) + + // The distro wakes: the same subscription still delivers a real snapshot. + releaseStall?.() + await vi.advanceTimersByTimeAsync(500) + + expect(snapshots.length).toBeGreaterThan(1) + expect(snapshots.at(-1)?.[3]).toBeUndefined() + subscription.unsubscribe() + expect(unhandled).toEqual([]) + }) + + it('keeps the poll loop alive on a non-gate resolve failure without emitting a frame', async () => { + // First resolve misses (transcript not flushed) so setup falls through to + // the poll loop; every later tick throws a non-gate error. + mocks.resolve + .mockResolvedValueOnce(null) + .mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' })) + const snapshots: Snapshot[] = [] + + const subscription = await subscribeCollecting(snapshots) + const attemptsAfterSetup = mocks.resolve.mock.calls.length + await vi.advanceTimersByTimeAsync(500) + + expect(snapshots).toEqual([]) + // Still retrying: the rethrow-free branch must not kill `void runAttempt()`. + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(attemptsAfterSetup) + expect(unhandled).toEqual([]) + subscription.unsubscribe() + }) + + it('detaches drain waiters on unsubscribe and starts no further gated work', async () => { + mocks.resolve.mockResolvedValue(UNC_PATH) + mocks.stat.mockResolvedValue({ ...EMPTY_STATS, size: 64 }) + let releaseOpen: ((handle: unknown) => void) | undefined + mocks.open.mockReturnValue( + new Promise((resolve) => { + releaseOpen = resolve + }) + ) + const handle = { + read: vi.fn(async () => ({ bytesRead: 0, buffer: Buffer.alloc(0) })), + close: vi.fn(async () => {}) + } + + const subscription = await subscribeCollecting([]) + await vi.advanceTimersByTimeAsync(100) + expect(mocks.open).toHaveBeenCalledTimes(1) + const statCallsAtTeardown = mocks.stat.mock.calls.length + + subscription.unsubscribe() + // No timer advance: the waiter must detach on unsubscribe, not 30s later. + releaseOpen?.(handle) + await vi.advanceTimersByTimeAsync(0) + + // The drain that was in flight must not admit anything more. + expect(handle.read).not.toHaveBeenCalled() + expect(mocks.stat.mock.calls.length).toBe(statCallsAtTeardown) + // Nobody is left to own the late handle, so the gate closes it. + expect(handle.close).toHaveBeenCalledTimes(1) + expect(unhandled).toEqual([]) + }) + + it('tears down cleanly while an admission is still stalled', async () => { + mocks.resolve.mockResolvedValue(UNC_PATH) + mocks.stat.mockImplementation(stalls) + const snapshots: Snapshot[] = [] + + const subscribing = subscribeCollecting(snapshots) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + const subscription = await subscribing + + expect(() => subscription.unsubscribe()).not.toThrow() + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + expect(unhandled).toEqual([]) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 2370d7b6410..b2ef5b05ec3 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -11,7 +11,7 @@ import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' -import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' +import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' export { readNativeChatTranscriptTail } from './transcript-tail-reader' export { getActiveNativeChatWatcherCount } from './transcript-watch-engine' @@ -33,7 +33,7 @@ async function attemptInstall( if (!filePath) { return null } - const installed = await installTranscriptWatcher(filePath, decode, args) + const installed = await installTranscriptWatcher(filePath, decode, args, signal) if (signal?.aborted) { installed?.unsubscribe() signal.throwIfAborted() @@ -78,6 +78,9 @@ function subscribeViaResolvePoll( // the exact-path install doesn't wait on the slower id-glob (#10326). let hostReadableExactPath: string | null = null let lastWslTranslateAt = 0 + // Latches only once a frame was actually emitted, so a subscriber without the + // callback can't suppress it for a later one. + let gateErrorEmitted = false const resolveController = new AbortController() function scheduleAttempt(): void { @@ -141,9 +144,18 @@ function subscribeViaResolvePoll( lastFallbackResolveAt = Date.now() result = await attemptInstall(args, decode, resolveController.signal) } - } catch { + } catch (error) { // Why: a transient resolve failure (EACCES/EIO during the glob) must not - // kill the poll loop with an unhandled rejection — retry like a miss. + // kill the poll loop with an unhandled rejection — retry like a miss. A + // stalled WSL distro would otherwise poll silently forever, leaving the + // client at 'loading'; emit its retryable message once and keep polling, + // so a later tick's real snapshot still replaces it. Narrowed with a bare + // instanceof, never wslTranscriptFsRefusal — that helper rethrows, and + // runAttempt is invoked as `void runAttempt()`. + if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) { + gateErrorEmitted = true + args.onInitialSnapshot([], false, 0, error.message) + } result = null } if (closed) { diff --git a/src/main/native-chat/wsl-transcript-fs-access.test.ts b/src/main/native-chat/wsl-transcript-fs-access.test.ts new file mode 100644 index 00000000000..6f978453239 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-access.test.ts @@ -0,0 +1,400 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsModule from 'node:fs' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type * as GateModule from './wsl-transcript-fs-gate' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl' +const OTHER_DISTRO_UNC_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\a.jsonl' +const LEGACY_UNC_PATH = '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl' +const WINDOWS_PATH = 'C:\\Users\\ada\\.codex\\sessions\\a.jsonl' +const POSIX_PATH = '/home/ada/.codex/sessions/a.jsonl' + +const mocks = vi.hoisted(() => ({ + stat: vi.fn(), + lstat: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), + open: vi.fn(), + createReadStream: vi.fn(), + runTask: vi.fn() +})) + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + createReadStream: mocks.createReadStream +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat, + lstat: mocks.lstat, + readdir: mocks.readdir, + readFile: mocks.readFile, + open: mocks.open +})) + +vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => { + const original = await importOriginal() + mocks.runTask.mockImplementation(original.runWslTranscriptFsTask) + return { ...original, runWslTranscriptFsTask: mocks.runTask } +}) + +import { + closeTranscriptHandle, + openTranscriptReadStream, + readTranscriptSlice, + wslGatedLstat, + wslGatedOpen, + wslGatedRead, + wslGatedReaddir, + wslGatedReadFile, + wslGatedStat, + WSL_TRANSCRIPT_READ_CHUNK_BYTES +} from './wsl-transcript-fs-access' +import { + resetWslTranscriptFsGateForTests, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, + WslTranscriptFsError +} from './wsl-transcript-fs-gate' + +function fakeHandle() { + return { read: vi.fn(), close: vi.fn(async () => {}) } +} + +beforeEach(() => { + // The gate mock delegates to the real implementation, so its module state + // survives between cases — a case that leaves a task stalled would otherwise + // mark that route stuck and fast-fail every later case on it. + resetWslTranscriptFsGateForTests() + // runTask keeps the real gate implementation installed by the mock factory; + // only its call log is cleared. + mocks.runTask.mockClear() + for (const [name, mock] of Object.entries(mocks)) { + if (name !== 'runTask') { + mock.mockReset() + } + } +}) + +describe('transcript filesystem accessor off WSL UNC', () => { + it.each([ + ['a posix path', POSIX_PATH], + ['a Windows drive path', WINDOWS_PATH] + ])('never enters the gate for %s', async (_label, path) => { + mocks.stat.mockResolvedValue({ size: 1 }) + mocks.lstat.mockResolvedValue({ size: 1 }) + mocks.readdir.mockResolvedValue([]) + mocks.readFile.mockResolvedValue('body') + const handle = fakeHandle() + handle.read.mockResolvedValue({ bytesRead: 0, buffer: Buffer.alloc(0) }) + mocks.open.mockResolvedValue(handle) + mocks.createReadStream.mockReturnValue('raw-stream') + + await wslGatedStat(path, 'exact') + await wslGatedLstat(path, 'scan') + await wslGatedReaddir(path, 'scan') + await wslGatedReadFile(path, 'utf-8', 'scan') + const opened = await wslGatedOpen(path, 'exact') + await wslGatedRead(opened, path, Buffer.alloc(1), 0, 1, 0, 'exact') + const stream = openTranscriptReadStream(path, { encoding: 'utf-8' }, 'scan') + + expect(mocks.runTask).not.toHaveBeenCalled() + expect(mocks.stat).toHaveBeenCalledWith(path) + expect(mocks.readdir).toHaveBeenCalledWith(path, { withFileTypes: true }) + expect(mocks.readFile).toHaveBeenCalledWith(path, 'utf-8') + expect(mocks.open).toHaveBeenCalledWith(path, 'r') + // Off UNC the raw stream is handed back verbatim, encoding included. + expect(stream).toBe('raw-stream') + expect(mocks.createReadStream).toHaveBeenCalledWith(path, { + encoding: 'utf-8', + signal: undefined + }) + }) + + it('forwards the caller signal so the local stream honours cancellation', () => { + mocks.createReadStream.mockReturnValue('raw-stream') + const controller = new AbortController() + + openTranscriptReadStream(POSIX_PATH, { start: 4 }, 'exact', controller.signal) + + expect(mocks.createReadStream).toHaveBeenCalledWith(POSIX_PATH, { + start: 4, + signal: controller.signal + }) + }) +}) + +describe('transcript filesystem accessor on WSL UNC', () => { + it.each([ + ['wsl.localhost', UNC_PATH], + ['wsl$', LEGACY_UNC_PATH] + ])('routes %s paths through the gate', async (_label, path) => { + mocks.stat.mockResolvedValue({ size: 7 }) + mocks.lstat.mockResolvedValue({ size: 7 }) + mocks.readdir.mockResolvedValue([]) + mocks.readFile.mockResolvedValue('body') + + await wslGatedStat(path, 'exact') + await wslGatedLstat(path, 'scan') + await wslGatedReaddir(path, 'scan') + await wslGatedReadFile(path, 'utf-8', 'scan') + + expect(mocks.runTask.mock.calls.map(([options]) => options.operation)).toEqual([ + 'stat', + 'lstat', + 'readdir', + 'readfile' + ]) + }) + + it('opts positional reads and opens out of coalescing', async () => { + const handle = fakeHandle() + handle.read.mockResolvedValue({ bytesRead: 1, buffer: Buffer.alloc(1) }) + mocks.open.mockResolvedValue(handle) + + const opened = await wslGatedOpen(UNC_PATH, 'exact') + await wslGatedRead(opened, UNC_PATH, Buffer.alloc(1), 0, 1, 0, 'exact') + + for (const call of mocks.runTask.mock.calls) { + expect(call[0]).toMatchObject({ dedupe: false }) + } + }) + + it('reads a slice and closes the handle even when the read rejects', async () => { + const handle = fakeHandle() + handle.read.mockRejectedValue(new Error('EIO')) + mocks.open.mockResolvedValue(handle) + + await expect(readTranscriptSlice(UNC_PATH, 4, 8, 'scan')).rejects.toThrow('EIO') + expect(handle.close).toHaveBeenCalledTimes(1) + }) + + it('yields Buffer chunks and closes the handle when the consumer destroys the stream', async () => { + const handle = fakeHandle() + handle.read.mockImplementation( + async (buffer: Buffer, offset: number, length: number, position: number) => { + const body = Buffer.from('{"a":1}\n{"b":2}\n') + const slice = body.subarray(position, Math.min(position + length, body.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + } + ) + mocks.open.mockResolvedValue(handle) + + const stream = openTranscriptReadStream(UNC_PATH, {}, 'exact') + const chunks: unknown[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + break + } + stream.destroy() + await new Promise((resolve) => setImmediate(resolve)) + + expect(chunks).toHaveLength(1) + expect(Buffer.isBuffer(chunks[0])).toBe(true) + expect((chunks[0] as Buffer).toString('utf-8')).toBe('{"a":1}\n{"b":2}\n') + expect(handle.close).toHaveBeenCalledTimes(1) + }) + + it('swallows close failures so teardown never rejects', async () => { + const handle = fakeHandle() + handle.close.mockRejectedValue(new Error('stalled close')) + await expect(closeTranscriptHandle(handle as never, UNC_PATH)).resolves.toBeUndefined() + await new Promise((resolve) => setImmediate(resolve)) + }) + + it('drains handle closes one at a time so teardown cannot flood the thread pool', async () => { + let releaseFirst: (() => void) | undefined + const first = fakeHandle() + first.close.mockReturnValue( + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + const second = fakeHandle() + + await closeTranscriptHandle(first as never, UNC_PATH) + await closeTranscriptHandle(second as never, UNC_PATH) + await new Promise((resolve) => setImmediate(resolve)) + + // A blocked uv_fs_close holds a libuv thread the gate cannot see, so the + // second one must wait rather than occupy a thread of its own. + expect(first.close).toHaveBeenCalledTimes(1) + expect(second.close).not.toHaveBeenCalled() + + releaseFirst?.() + await new Promise((resolve) => setImmediate(resolve)) + expect(second.close).toHaveBeenCalledTimes(1) + }) + + it('keeps a blocked close on one distro from stranding teardown on another', async () => { + let releaseStuck: (() => void) | undefined + const stuck = fakeHandle() + stuck.close.mockReturnValue( + new Promise((resolve) => { + releaseStuck = resolve + }) + ) + const healthy = fakeHandle() + + try { + await closeTranscriptHandle(stuck as never, UNC_PATH) + await closeTranscriptHandle(healthy as never, OTHER_DISTRO_UNC_PATH) + await new Promise((resolve) => setImmediate(resolve)) + + // A close that never settles on a stalled mount would hold a shared lane + // for the process lifetime, leaking every later descriptor with it. + expect(stuck.close).toHaveBeenCalledTimes(1) + expect(healthy.close).toHaveBeenCalledTimes(1) + } finally { + releaseStuck?.() + await new Promise((resolve) => setImmediate(resolve)) + } + }) + + it.each([ + ['stat', wslGatedStat, mocks.stat], + ['lstat', wslGatedLstat, mocks.lstat] + ])( + 'detaches a cancelled %s waiter without waiting out the deadline', + async (_op, gated, mock) => { + let release: (() => void) | undefined + mock.mockReturnValue( + new Promise((resolve) => { + release = () => resolve({ size: 0 }) + }) + ) + try { + const controller = new AbortController() + const reason = new Error('unsubscribed') + const pending = gated(UNC_PATH, 'exact', controller.signal) + const settled = pending.then( + () => 'resolved', + (error: unknown) => error + ) + // Let the gate admit and start the syscall, so this covers the waiter on a + // RUNNING unabortable call rather than a still-queued one. + await new Promise((resolve) => setImmediate(resolve)) + expect(mock).toHaveBeenCalledTimes(1) + + controller.abort(reason) + + // No timer advance: the waiter must be gone the moment the caller cancels, + // not 30s later when its deadline fires. + await expect(settled).resolves.toBe(reason) + } finally { + release?.() + await new Promise((resolve) => setImmediate(resolve)) + } + } + ) + + it('closes an abandoned open whose syscall lands after the caller gave up', async () => { + vi.useFakeTimers() + let release: ((handle: unknown) => void) | undefined + mocks.open.mockReturnValue( + new Promise((resolve) => { + release = resolve + }) + ) + try { + const handle = fakeHandle() + const refused = wslGatedOpen(UNC_PATH, 'exact').catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + await expect(refused).resolves.toBeInstanceOf(WslTranscriptFsError) + + release?.(handle) + await vi.advanceTimersByTimeAsync(0) + + // Nobody received the handle, so the gate owns closing it. + expect(handle.close).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('transcript handle close off WSL UNC', () => { + it('awaits teardown and surfaces the failure, as the raw close did', async () => { + const handle = fakeHandle() + let closed = false + handle.close.mockImplementation(async () => { + await new Promise((resolve) => setImmediate(resolve)) + closed = true + }) + + await closeTranscriptHandle(handle as never, POSIX_PATH) + expect(closed).toBe(true) + + handle.close.mockRejectedValue(new Error('EIO on close')) + await expect(closeTranscriptHandle(handle as never, WINDOWS_PATH)).rejects.toThrow( + 'EIO on close' + ) + }) +}) + +describe('per-chunk admission', () => { + it('carries a codepoint straddling the 1 MiB chunk boundary across chunks', async () => { + const emoji = Buffer.from('😀', 'utf8') + // Two of the emoji's four bytes land in chunk 1, two in chunk 2 — derived + // from the production constant so the fixture cannot drift off the boundary. + const body = Buffer.concat([ + Buffer.alloc(WSL_TRANSCRIPT_READ_CHUNK_BYTES - 2, 0x61), + emoji, + Buffer.from('tail\n', 'utf8') + ]) + const handle = fakeHandle() + handle.read.mockImplementation( + async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = body.subarray(position, Math.min(position + length, body.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + } + ) + mocks.open.mockResolvedValue(handle) + + const stream = openTranscriptReadStream(UNC_PATH, { encoding: 'utf-8' }, 'exact') + let decoded = '' + for await (const chunk of stream) { + expect(typeof chunk).toBe('string') + decoded += chunk as string + } + + expect(decoded).not.toContain('�') + expect(decoded).toBe(body.toString('utf8')) + expect(handle.read.mock.calls.length).toBeGreaterThan(1) + }) + + it('surfaces a gate refusal mid-stream as an error event', async () => { + vi.useFakeTimers() + try { + let served = 0 + const handle = fakeHandle() + handle.read.mockImplementation((buffer: Buffer) => { + if (served++ === 0) { + buffer.write('x') + return Promise.resolve({ bytesRead: 1, buffer }) + } + return new Promise(() => {}) + }) + mocks.open.mockResolvedValue(handle) + + const stream = openTranscriptReadStream(UNC_PATH, {}, 'exact') + const drained = (async () => { + for await (const chunk of stream) { + void chunk + } + })() + const settled = drained.then( + () => null, + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + + await expect(settled).resolves.toBeInstanceOf(WslTranscriptFsError) + expect(handle.close).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-access.ts b/src/main/native-chat/wsl-transcript-fs-access.ts new file mode 100644 index 00000000000..b50c1b1f5f6 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-access.ts @@ -0,0 +1,276 @@ +import { createReadStream, type Dirent, type Stats } from 'node:fs' +import { lstat, open, readdir, readFile, stat, type FileHandle } from 'node:fs/promises' +import { Readable } from 'node:stream' +import { StringDecoder } from 'node:string_decoder' +import { isWslUncPath } from '../../shared/wsl-paths' +import { runWslTranscriptFsTask, type WslTranscriptFsTaskPriority } from './wsl-transcript-fs-gate' +import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route' + +/** Never nest a gated call inside another — that deadlocks the scan slot. */ + +// Why: one deadline per chunk instead of one for the whole file, so a large +// healthy-but-slow transcript is not false-failed by a whole-file timeout. +export const WSL_TRANSCRIPT_READ_CHUNK_BYTES = 1024 * 1024 +type Operation = Parameters[0]['operation'] + +function runPathOperation( + operation: Operation, + path: string, + priority: WslTranscriptFsTaskPriority, + signal: AbortSignal | undefined, + task: () => Promise, + options?: { dedupe?: boolean; onAbandonedResult?: (value: T) => void } +): Promise { + return isWslUncPath(path) + ? runWslTranscriptFsTask({ operation, path, priority, signal, ...options }, task) + : task() +} + +export function wslGatedStat( + path: string, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + return runPathOperation('stat', path, priority, signal, () => stat(path)) +} + +export function wslGatedLstat( + path: string, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + return runPathOperation('lstat', path, priority, signal, () => lstat(path)) +} + +export function wslGatedReaddir( + path: string, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + return runPathOperation('readdir', path, priority, signal, () => + readdir(path, { withFileTypes: true }) + ) +} + +export function wslGatedReadFile( + path: string, + encoding: BufferEncoding, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + return runPathOperation('readfile', path, priority, signal, () => readFile(path, encoding)) +} + +// dedupe:false — two joiners would share one FileHandle and both close it. +export function wslGatedOpen( + path: string, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + return runPathOperation('open', path, priority, signal, () => open(path, 'r'), { + dedupe: false, + // An unabortable open can still succeed after its caller timed out or + // cancelled; without this the descriptor leaks for the process lifetime. + onAbandonedResult: (handle) => void closeTranscriptHandle(handle, path) + }) +} + +/** + * `path` is only the gate's route key and UNC guard — the handle carries no + * path and is never re-opened. dedupe:false because `read` fills the CALLER's + * buffer: a joiner would receive the first caller's buffer while its own + * (often `Buffer.allocUnsafe`) stays uninitialized. + */ +export function wslGatedRead( + handle: FileHandle, + path: string, + buffer: Buffer, + offset: number, + length: number, + position: number, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise<{ bytesRead: number; buffer: Buffer }> { + return runPathOperation( + 'read', + path, + priority, + signal, + () => handle.read(buffer, offset, length, position), + { dedupe: false } + ) +} + +// A blocked `uv_fs_close` holds a libuv threadpool thread just like a blocked +// read does, but it is invisible to the gate — so UNC teardown drains one handle +// at a time. Unbounded fire-and-forget closes are what would exhaust the pool the +// gate's two permits are sized against, re-creating the process-wide stall the +// gate exists to prevent; serializing them costs at most one extra busy thread. +// The queue cannot grow without bound: opens on a stuck route already fast-fail. +// Keyed by route like the gate's own admission, because a close that blocks on a +// stalled distro never settles — a shared queue would strand every later close, +// including handles on healthy distros, for the process lifetime. +const MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE = 1 +type RouteCloseQueue = { queued: FileHandle[]; active: number } +const closeQueuesByRoute = new Map() + +function drainQueuedCloses(route: string): void { + const lane = closeQueuesByRoute.get(route) + if (!lane) { + return + } + while (lane.active < MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE) { + const handle = lane.queued.shift() + if (!handle) { + if (lane.active === 0) { + closeQueuesByRoute.delete(route) + } + return + } + lane.active += 1 + void handle + .close() + .catch(() => {}) + .finally(() => { + lane.active -= 1 + drainQueuedCloses(route) + }) + } +} + +/** + * Never gated. Off UNC this is the prior contract verbatim — the caller awaits + * fd teardown and a close failure surfaces. On UNC it is fire-and-forget: + * closing a handle on a stalled mount can itself block, and a gated close would + * burn a permit and a waiter deadline purely for teardown. One leaked fd until + * the OS unblocks beats a second blocked waiter. + */ +export function closeTranscriptHandle(handle: FileHandle, path: string): Promise { + if (!isWslUncPath(path)) { + return handle.close() + } + const route = wslTranscriptFsRouteKey(path) + const lane = closeQueuesByRoute.get(route) ?? { queued: [], active: 0 } + closeQueuesByRoute.set(route, lane) + lane.queued.push(handle) + drainQueuedCloses(route) + return Promise.resolve() +} + +/** + * Open, read one slice, close — for callers that want bytes at an offset and + * never touch the handle. Each of the two syscalls is admitted separately, so a + * stalled mount fails at whichever one it blocks on. + */ +export async function readTranscriptSlice( + path: string, + position: number, + length: number, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + const handle = await wslGatedOpen(path, priority, signal) + try { + const buffer = Buffer.allocUnsafe(length) + const { bytesRead } = await wslGatedRead( + handle, + path, + buffer, + 0, + length, + position, + priority, + signal + ) + return buffer.subarray(0, bytesRead) + } finally { + await closeTranscriptHandle(handle, path) + } +} + +export type TranscriptReadStreamOptions = { + start?: number + /** Inclusive, matching `createReadStream`. */ + end?: number + /** Set it to get decoded string chunks on both branches; leave it unset and + * the UNC branch yields `Buffer`s the consumer must decode incrementally + * itself, since a chunk boundary can split a multibyte codepoint. */ + encoding?: BufferEncoding +} + +async function* gatedChunks( + path: string, + options: TranscriptReadStreamOptions, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): AsyncGenerator { + const handle = await wslGatedOpen(path, priority, signal) + // Why: chunk boundaries fall mid-codepoint, so decoding each slice + // independently would emit U+FFFD on both sides of any straddling character. + const decoder = options.encoding ? new StringDecoder(options.encoding) : null + try { + let position = options.start ?? 0 + for (;;) { + const length = + options.end === undefined + ? WSL_TRANSCRIPT_READ_CHUNK_BYTES + : Math.min(WSL_TRANSCRIPT_READ_CHUNK_BYTES, options.end - position + 1) + if (length <= 0) { + break + } + const buffer = Buffer.allocUnsafe(length) + const { bytesRead } = await wslGatedRead( + handle, + path, + buffer, + 0, + length, + position, + priority, + signal + ) + if (bytesRead <= 0) { + break + } + position += bytesRead + const chunk = buffer.subarray(0, bytesRead) + if (!decoder) { + yield chunk + continue + } + const decoded = decoder.write(chunk) + if (decoded) { + yield decoded + } + } + // Trailing bytes of an incomplete sequence at EOF, replacement-char'd once. + const trailing = decoder?.end() + if (trailing) { + yield trailing + } + } finally { + // Runs on `.destroy()` too (Readable.from calls the generator's return()), + // so an aborted head-read cannot leak the gated handle. + await closeTranscriptHandle(handle, path) + } +} + +/** + * A read stream whose UNC branch admits and deadlines each 1 MiB chunk + * separately. A gate refusal mid-stream surfaces as an `'error'` event carrying + * the `WslTranscriptFsError`, which every existing consumer funnels into its + * `catch`. + */ +export function openTranscriptReadStream( + path: string, + options: TranscriptReadStreamOptions, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Readable { + if (!isWslUncPath(path)) { + // Node destroys the stream with an AbortError on abort, matching how the + // gated branch surfaces cancellation to the same consumers. + return createReadStream(path, { ...options, signal }) + } + return Readable.from(gatedChunks(path, options, priority, signal)) +} diff --git a/src/main/native-chat/wsl-transcript-fs-gate.test.ts b/src/main/native-chat/wsl-transcript-fs-gate.test.ts index 1f82ab25277..9c979002d01 100644 --- a/src/main/native-chat/wsl-transcript-fs-gate.test.ts +++ b/src/main/native-chat/wsl-transcript-fs-gate.test.ts @@ -139,6 +139,29 @@ describe('WSL transcript filesystem task scheduling', () => { expect(duplicateTask).not.toHaveBeenCalled() }) + it('never joins an exact probe onto the same file queued as scan work', async () => { + const transcript = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\live.jsonl' + const statTask = (priority: 'exact' | 'scan', task: () => Promise): Promise => + runWslTranscriptFsTask({ operation: 'stat', path: transcript, priority }, task) + // Hold the single scan slot on another distro so the same-file scan stat + // cannot run — a joiner would inherit exactly that queued position. + const blocked = deferred() + const holderTask = vi.fn(() => blocked.promise) + const holder = run('\\\\wsl.localhost\\Debian\\tree', 'scan', holderTask) + await vi.waitFor(() => expect(holderTask).toHaveBeenCalledOnce()) + + const scanTask = vi.fn(async () => 'scan') + const scanned = statTask('scan', scanTask) + const exactTask = vi.fn(async () => 'exact') + + await expect(statTask('exact', exactTask)).resolves.toBe('exact') + expect(exactTask).toHaveBeenCalledOnce() + expect(scanTask).not.toHaveBeenCalled() + + blocked.resolve('holder') + await expect(Promise.all([holder, scanned])).resolves.toEqual(['holder', 'scan']) + }) + it('keeps shared work needed by a remaining waiter', async () => { const pending = deferred() const task = vi.fn(() => pending.promise) @@ -632,3 +655,74 @@ describe('WSL transcript filesystem task scheduling', () => { } }) }) + +describe('WSL transcript filesystem task coalescing opt-out', () => { + const READ_PATH = '\\\\wsl.localhost\\Alpine\\home\\ada\\transcript.jsonl' + + function gatedRead(task: (signal: AbortSignal) => Promise): Promise { + return runWslTranscriptFsTask( + { operation: 'read', path: READ_PATH, priority: 'exact', dedupe: false }, + task + ) + } + + it('fills each caller its own buffer for identical positional reads', async () => { + const first = Buffer.alloc(4, 0xa1) + const second = Buffer.alloc(4, 0xb2) + const bodies = [Buffer.from('AAAA'), Buffer.from('BBBB')] + + const reads = Promise.all([ + gatedRead(async () => { + bodies.shift()!.copy(first) + return first + }), + gatedRead(async () => { + bodies.shift()!.copy(second) + return second + }) + ]) + + await expect(reads).resolves.toEqual([first, second]) + expect(first.toString()).toBe('AAAA') + expect(second.toString()).toBe('BBBB') + // Neither caller kept its sentinel nor received the other's bytes. + expect(first.equals(second)).toBe(false) + }) + + it('gives each open call a distinct handle', async () => { + const handles = [ + { id: 1, closed: false }, + { id: 2, closed: false } + ] + let served = 0 + const opened = await Promise.all( + handles.map(() => + runWslTranscriptFsTask( + { operation: 'open', path: READ_PATH, priority: 'exact', dedupe: false }, + async () => handles[served++] + ) + ) + ) + + expect(opened[0]).not.toBe(opened[1]) + opened[0].closed = true + expect(opened[1].closed).toBe(false) + }) + + it('still coalesces identical work when dedupe is omitted', async () => { + const stalled = deferred() + const task = vi.fn(() => stalled.promise) + const path = '\\\\wsl.localhost\\Alpine\\home\\ada' + const first = run(path, 'scan', task) + await vi.waitFor(() => expect(task).toHaveBeenCalledOnce()) + const joiner = run( + path, + 'scan', + vi.fn(async () => 'never') + ) + + stalled.resolve('shared') + await expect(Promise.all([first, joiner])).resolves.toEqual(['shared', 'shared']) + expect(task).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-gate.ts b/src/main/native-chat/wsl-transcript-fs-gate.ts index 52b4b22f81b..84848e13f11 100644 --- a/src/main/native-chat/wsl-transcript-fs-gate.ts +++ b/src/main/native-chat/wsl-transcript-fs-gate.ts @@ -1,4 +1,4 @@ -import { parseWslUncPath } from '../../shared/wsl-paths' +import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route' const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2 export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000 @@ -6,9 +6,9 @@ export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000 // Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely. export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64 export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64 -const WSL_TRANSCRIPT_FS_SLOW_MESSAGE = +export const WSL_TRANSCRIPT_FS_SLOW_MESSAGE = 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' -const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE = +export const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE = 'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.' export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable' @@ -47,6 +47,8 @@ type ScheduledTask = { route: string priority: WslTranscriptFsTaskPriority operation: (signal: AbortSignal) => Promise + /** Dispose a value no waiter is left to own (see settleTask). */ + onAbandonedResult?: (value: T) => void controller: AbortController waiters: Set> state: 'queued' | 'running' | 'settled' @@ -59,6 +61,7 @@ type ScheduledTask = { type UnknownScheduledTask = ScheduledTask let activeScanCount = 0 +let undedupedTaskId = 0 const activeLaneKeys = new Set() const queuedTasks: UnknownScheduledTask[] = [] const inFlightTasks = new Map() @@ -68,18 +71,6 @@ function abortReason(signal: AbortSignal): unknown { return signal.reason ?? new Error('WSL transcript filesystem task aborted') } -// wsl$ and wsl.localhost spellings of one distro stay distinct routes on -// purpose (provider spelling can change behavior), so a stuck spelling never -// fast-fails its twin — the twin is bounded by its own waiter deadlines. -function routeKey(path: string): string { - const normalized = path.replace(/\\/g, '/') - const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i) - if (match) { - return `${match[1].toLowerCase()}/${match[2].trim().toLowerCase()}` - } - return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path -} - function removeQueuedTask(task: UnknownScheduledTask): void { const index = queuedTasks.indexOf(task) if (index !== -1) { @@ -159,11 +150,9 @@ function stuckBlocker( // Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields // before the waiter is attached, so no aborted-signal recheck is needed. -function attachWaiter( - task: ScheduledTask, - priority: WslTranscriptFsTaskPriority, - signal?: AbortSignal -): Promise { +// The task's own priority is every waiter's deadline: the dedupe key carries +// priority, so a joiner can only ever join a task scheduled at its own. +function attachWaiter(task: ScheduledTask, signal?: AbortSignal): Promise { if (task.waiters.size >= WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK) { return Promise.reject(capacityError()) } @@ -190,7 +179,7 @@ function attachWaiter( } reject(error) abandonTaskIfUnused(task as UnknownScheduledTask, error) - }, timeoutMs(priority)) + }, timeoutMs(task.priority)) waiter.timeout.unref?.() }) } @@ -207,6 +196,10 @@ function settleTask(task: ScheduledTask, result: { value: T } | { error: u activeTasks.delete(task as UnknownScheduledTask) clearTimeout(task.stuckTimer) clearTask(task as UnknownScheduledTask) + // Why: an unabortable syscall can still succeed after its last waiter timed + // out or cancelled. A resource-valued result (open's FileHandle) then has no + // owner left to close it, so ownership passes to the task's disposer. + const abandoned = task.waiters.size === 0 for (const waiter of task.waiters) { removeWaiter(task, waiter) if ('value' in result) { @@ -216,6 +209,15 @@ function settleTask(task: ScheduledTask, result: { value: T } | { error: u } } task.waiters.clear() + if (abandoned && 'value' in result) { + // Contained: a disposer that throws must not skip the pump below and wedge + // every queued task behind this one. + try { + task.onAbandonedResult?.(result.value) + } catch { + // Best-effort teardown; nothing left to report it to. + } + } pumpTasks() } @@ -281,13 +283,39 @@ function pumpTasks(): void { } } +/** + * Test-only: drop every task and counter so a case that leaves a task stalled + * (holding a permit, marking its route stuck) cannot fast-fail the next one. + * Tasks are marked settled first, so a late resolution from the unabortable + * syscall they are still blocked on cannot decrement counters this just zeroed. + */ +export function resetWslTranscriptFsGateForTests(): void { + for (const task of [...activeTasks, ...queuedTasks]) { + task.state = 'settled' + clearTimeout(task.stuckTimer) + for (const waiter of task.waiters) { + removeWaiter(task, waiter) + } + } + activeTasks.clear() + queuedTasks.length = 0 + inFlightTasks.clear() + activeLaneKeys.clear() + activeScanCount = 0 +} + /** Bound 9P work without letting scans delay exact transcript probes. */ export function runWslTranscriptFsTask( options: { - operation: 'access' | 'readdir' + operation: 'access' | 'readdir' | 'stat' | 'lstat' | 'open' | 'read' | 'readfile' path: string priority: WslTranscriptFsTaskPriority signal?: AbortSignal + /** Opt out of coalescing when the result is not the whole answer (`open`, + * positional `read`): a joiner would share the handle or the buffer. */ + dedupe?: boolean + /** Release a late result no waiter is left to own (see settleTask). */ + onAbandonedResult?: (value: T) => void }, task: (signal: AbortSignal) => Promise ): Promise { @@ -295,8 +323,15 @@ export function runWslTranscriptFsTask( return Promise.reject(abortReason(options.signal)) } // Why: route spelling and Linux path case can change provider behavior, so - // only byte-identical filesystem requests are safe to share. - const key = JSON.stringify([options.operation, options.path]) + // only byte-identical filesystem requests are safe to share. Priority is part + // of the key because it selects the task's lane, scan slot and deadline: an + // exact probe that joined a queued scan task would inherit scan scheduling and + // starve behind the very traffic the two priorities exist to bypass. A counter + // key never collides, so an un-deduped task simply never finds a join target. + const key = + options.dedupe === false + ? `undeduped:${++undedupedTaskId}` + : JSON.stringify([options.operation, options.path, options.priority]) const existing = inFlightTasks.get(key) as ScheduledTask | undefined if (existing) { // A stuck target — or a queued target that stuck I/O keeps from ever @@ -308,9 +343,9 @@ export function runWslTranscriptFsTask( ) { return Promise.reject(unavailableError()) } - return attachWaiter(existing, options.priority, options.signal) + return attachWaiter(existing, options.signal) } - const route = routeKey(options.path) + const route = wslTranscriptFsRouteKey(options.path) // Fail fast when the permit, route, or scan slot this task needs is held by // stuck I/O — queueing would only burn the caller's full deadline. if (stuckBlocker(route, options.priority)) { @@ -326,13 +361,14 @@ export function runWslTranscriptFsTask( route, priority: options.priority, operation: task, + onAbandonedResult: options.onAbandonedResult, controller: new AbortController(), waiters: new Set(), state: 'queued', stuck: false } inFlightTasks.set(key, scheduled as UnknownScheduledTask) - const result = attachWaiter(scheduled, options.priority, options.signal) + const result = attachWaiter(scheduled, options.signal) queuedTasks.push(scheduled as UnknownScheduledTask) queueMicrotask(pumpTasks) return result diff --git a/src/main/native-chat/wsl-transcript-fs-route.ts b/src/main/native-chat/wsl-transcript-fs-route.ts new file mode 100644 index 00000000000..dc576c066d7 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-route.ts @@ -0,0 +1,19 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' + +/** + * The isolation unit for WSL transcript filesystem work: one stalled distro + * must not fast-fail or strand work on any other. Shared by the gate's + * admission and by the ungated close queue so both isolate on the same key. + * + * wsl$ and wsl.localhost spellings of one distro stay distinct routes on + * purpose (provider spelling can change behavior), so a stuck spelling never + * fast-fails its twin — the twin is bounded by its own waiter deadlines. + */ +export function wslTranscriptFsRouteKey(path: string): string { + const normalized = path.replace(/\\/g, '/') + const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i) + if (match) { + return `${match[1].toLowerCase()}/${match[2].trim().toLowerCase()}` + } + return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path +} diff --git a/src/main/opencode-usage/scanner-wsl-gate.test.ts b/src/main/opencode-usage/scanner-wsl-gate.test.ts new file mode 100644 index 00000000000..b7c94000635 --- /dev/null +++ b/src/main/opencode-usage/scanner-wsl-gate.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UNC_DATA_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode' +const UNC_DATABASE = `${UNC_DATA_DIR}\\opencode.db` + +const mocks = vi.hoisted(() => ({ + resolveDataDirectory: vi.fn<() => string>(), + readdir: vi.fn(), + stat: vi.fn() +})) + +vi.mock('../opencode/opencode-data-directory', () => ({ + resolveOpenCodeDataDirectory: mocks.resolveDataDirectory +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readdir: mocks.readdir, + stat: mocks.stat +})) + +import { listOpenCodeDatabases } from './scanner' +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' + +// A stalled task holds the gate's single scan slot until it settles, so every +// case releases its stall before the next one runs. +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve([] as T) + }) +} + +function dirent(name: string) { + return { name, isFile: () => true } +} + +// The point of the gate: an ungated syscall on a stalled 9P mount never returns, +// so the call must still be pending at the deadline and settle just after it. +async function settlesOnlyAtTheScanDeadline(pending: Promise): Promise { + let settled = false + const tracked = pending.then((value) => { + settled = true + return value + }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS - 1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(2) + return await tracked +} + +let originalDatabaseOverride: string | undefined + +beforeEach(() => { + originalDatabaseOverride = process.env.OPENCODE_DB + delete process.env.OPENCODE_DB + mocks.resolveDataDirectory.mockReset() + mocks.readdir.mockReset() + mocks.stat.mockReset() + releaseStall = undefined + mocks.resolveDataDirectory.mockReturnValue(UNC_DATA_DIR) + vi.useFakeTimers() +}) + +afterEach(async () => { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + if (originalDatabaseOverride === undefined) { + delete process.env.OPENCODE_DB + } else { + process.env.OPENCODE_DB = originalDatabaseOverride + } +}) + +// Why this file exists: the AI Vault's primary OpenCode source reaches these +// syscalls transitively, so the import-guard that pins direct `node:fs` use in +// the transcript modules never saw them (STA-4049). +describe('OpenCode database discovery on a stalled WSL data directory', () => { + it('gates the data-directory listing instead of hanging the scan', async () => { + mocks.readdir.mockImplementation(stalls) + // Asserted because an empty list alone also matches a missing data dir; the + // AI Vault's OpenCode source turns this callback into the scan issue. + const onRefusal = vi.fn() + + expect(await settlesOnlyAtTheScanDeadline(listOpenCodeDatabases(onRefusal))).toEqual([]) + expect(onRefusal).toHaveBeenCalledWith(UNC_DATA_DIR, expect.any(WslTranscriptFsError)) + }) + + it('gates an absolute UNC OPENCODE_DB probe instead of hanging the scan', async () => { + process.env.OPENCODE_DB = UNC_DATABASE + mocks.stat.mockImplementation(stalls) + const onRefusal = vi.fn() + + expect(await settlesOnlyAtTheScanDeadline(listOpenCodeDatabases(onRefusal))).toEqual([]) + expect(mocks.readdir).not.toHaveBeenCalled() + // Loose on the path: `isAbsolute` is host-flavoured, so a UNC override only + // stays verbatim on Windows. The refusal reaching the caller is the contract. + expect(onRefusal).toHaveBeenCalledWith( + expect.stringContaining('opencode.db'), + expect.any(WslTranscriptFsError) + ) + }) + + it('still lists databases when the distro answers', async () => { + mocks.readdir.mockResolvedValue([dirent('opencode.db'), dirent('notes.txt')]) + + await expect(listOpenCodeDatabases()).resolves.toEqual([join(UNC_DATA_DIR, 'opencode.db')]) + expect(mocks.readdir).toHaveBeenCalledWith(UNC_DATA_DIR, { withFileTypes: true }) + }) +}) diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index b96e14be503..f3a4e6b13e1 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -1,7 +1,9 @@ /* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ -import { readdir, realpath, stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import { basename, isAbsolute, join, posix, win32 } from 'node:path' import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' @@ -75,7 +77,14 @@ function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOve } } -export async function listOpenCodeDatabases(): Promise { +// Why gated: the AI Vault's primary OpenCode source delegates here from inside +// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled +// distro would otherwise hang the whole scan on a raw syscall (STA-4049). +export async function listOpenCodeDatabases( + /** Lets a caller report the refusal; an empty list otherwise reads as + * "OpenCode not used" rather than "we could not look". */ + onRefusal?: (path: string, error: WslTranscriptFsError) => void +): Promise { const dataDirectory = resolveOpenCodeDataDirectory() const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) if (databaseOverride.isConfigured) { @@ -83,23 +92,37 @@ export async function listOpenCodeDatabases(): Promise { return [] } try { - return (await stat(databaseOverride.path)).isFile() ? [databaseOverride.path] : [] - } catch { + return (await wslGatedStat(databaseOverride.path, 'scan')).isFile() + ? [databaseOverride.path] + : [] + } catch (error) { + reportRefusal(databaseOverride.path, error, onRefusal) return [] } } try { - const entries = await readdir(dataDirectory, { withFileTypes: true }) + const entries = await wslGatedReaddir(dataDirectory, 'scan') return entries .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) .map((entry) => join(dataDirectory, entry.name)) .sort() - } catch { + } catch (error) { + reportRefusal(dataDirectory, error, onRefusal) return [] } } +function reportRefusal( + path: string, + error: unknown, + onRefusal?: (path: string, error: WslTranscriptFsError) => void +): void { + if (error instanceof WslTranscriptFsError) { + onRefusal?.(path, error) + } +} + function compareOpenCodeClaimPriority(left: string, right: string): number { // Why: the canonical opencode.db is the live database; it must claim // duplicated sessions ahead of stale sibling copies. Remaining ties use diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index a2d3446a99c..128dc56cea3 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync import { join } from 'node:path' import { tmpdir } from 'node:os' import { + createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, DEFAULT_LOCAL_ORCA_PROFILE_NAME, ORCA_PROFILE_INDEX_SCHEMA_VERSION, @@ -154,6 +155,27 @@ describe('profile index store', () => { expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile') }) + const posixIt = process.platform === 'win32' ? it.skip : it + posixIt('writes a fresh profile index when umask removes owner-write permission', async () => { + const store = await loadProfileIndexStore() + const indexPath = store.getOrcaProfileIndexPath() + const profile = createDefaultLocalOrcaProfile(1) + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: profile.id, + profiles: [profile] + } + const originalUmask = process.umask(0o200) + + try { + expect(() => store.writeProfileIndex(indexPath, index)).not.toThrow() + } finally { + process.umask(originalUmask) + } + + expect(readJson(indexPath)).toEqual(index) + }) + it('recovers a corrupted profile index from the backup copy', async () => { const store = await loadProfileIndexStore() store.ensureActiveOrcaProfile() diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 06ee8a9d6a0..17a73fa0e48 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -36,6 +36,7 @@ import { ONBOARDING_FLOW_VERSION } from '../shared/constants' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { folderWorkspaceToWorktree } from '../shared/folder-workspace-worktree' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host' import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' @@ -5418,6 +5419,369 @@ describe('Store', () => { expect(session.browserPagesByWorkspace?.['browser-workspace']).toBeUndefined() }) + // ── 8b. Folder-workspace review notes across a build rollback ── + + function makeFolderNote(id: string, body: string, workspaceId = 'fw-1') { + return { + id, + worktreeId: folderWorkspaceKey(workspaceId), + filePath: 'README.md', + source: 'markdown' as const, + lineNumber: 1, + body, + createdAt: 100, + side: 'modified' as const + } + } + + function folderWorkspaceRecord(overrides: Record = {}) { + return { + id: 'fw-1', + projectGroupId: 'root', + name: 'Refund fix', + folderPath: '/workspace/platform', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10, + lastActivityAt: 5, + createdAt: 2, + updatedAt: 3, + ...overrides + } + } + + function writeFolderWorkspaceProfile(options: { + workspaces: Record[] + diffCommentsMap?: unknown + connectionId?: string | null + extraTopLevel?: Record + }): void { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + projectGroups: [ + { + id: 'root', + name: 'Platform', + parentPath: '/workspace/platform', + parentGroupId: null, + connectionId: options.connectionId ?? null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + folderWorkspaces: options.workspaces, + ...('diffCommentsMap' in options + ? { folderWorkspaceDiffComments: options.diffCommentsMap } + : {}), + ...options.extraTopLevel + }) + } + + // The previous build's normalizeFolderWorkspaces has no diffComments projection (the line this + // checkout carries at src/shared/folder-workspaces.ts:107 does not exist on v1.4.179–v1.4.181), + // and its full-state write re-serializes everything else verbatim (the `{ ...defaults, ...parsed }` + // load spread and the omit-style getDurableState()). + function simulatePreviousBuildLoadAndFlush(onDisk: PersistedState): PersistedState { + return { + ...onDisk, + folderWorkspaces: onDisk.folderWorkspaces.map( + ({ diffComments: _droppedByOldNormalizer, ...rest }) => rest + ) + } + } + + it('keeps folder-workspace review notes across a previous-build rollback and re-upgrade', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ + projectGroupId: group.id, + name: 'Refund fix' + }) + const note = makeFolderNote('note-1', 'Review this paragraph', workspace.id) + store.updateFolderWorkspace(workspace.id, { diffComments: [note] }) + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.folderWorkspaceDiffComments?.[workspace.id]).toEqual([note]) + expect(persisted.folderWorkspaces[0]).not.toHaveProperty('diffComments') + // The strip lives at the serialization boundary only; live records keep their notes. + expect(store.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note]) + expect(folderWorkspaceToWorktree(store.getFolderWorkspace(workspace.id)!).diffComments).toEqual( + [note] + ) + + writeDataFile(simulatePreviousBuildLoadAndFlush(persisted)) + + const restored = await createStore() + expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note]) + restored.flush() + expect((readDataFile() as PersistedState).folderWorkspaceDiffComments?.[workspace.id]).toEqual([ + note + ]) + }) + + it('survives repeated rollback / re-upgrade hops', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix' }) + const note = makeFolderNote('note-1', 'Review this paragraph', workspace.id) + store.updateFolderWorkspace(workspace.id, { diffComments: [note] }) + store.flush() + + for (let hop = 0; hop < 2; hop++) { + writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState)) + const reupgraded = await createStore() + expect(reupgraded.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note]) + reupgraded.flush() + } + }) + + it('keeps notes and remote provenance for an SSH folder workspace across a rollback', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + connectionId: 'ssh-1', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Remote fix' }) + expect(workspace.connectionId).toBe('ssh-1') + const note = makeFolderNote('note-remote', 'Remote review', workspace.id) + store.updateFolderWorkspace(workspace.id, { diffComments: [note] }) + store.flush() + + writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState)) + + const restored = await createStore() + expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note]) + expect(restored.getFolderWorkspace(workspace.id)?.connectionId).toBe('ssh-1') + }) + + it('round-trips unknown top-level state keys through load and flush', async () => { + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord({ unknownNestedField: 'from-a-newer-build' })], + extraTopLevel: { unknownTopLevelKey: { kept: 'verbatim' } } + }) + + const store = await createStore() + store.updateFolderWorkspace('fw-1', { comment: 'touch' }) + store.flush() + + const persisted = readDataFile() as PersistedState & { unknownTopLevelKey?: unknown } + expect( + persisted.unknownTopLevelKey, + 'Unknown top-level keys must survive load + flush. Note preservation relies on this: converting the ' + + '`{ ...defaults, ...parsed }` load spread or the omit-style getDurableState() into an ' + + 'allowlist re-opens folder-workspace note loss for the then-previous build.' + ).toEqual({ kept: 'verbatim' }) + expect( + persisted.folderWorkspaces[0], + 'Documents existing behavior, not a guarantee: normalizeFolderWorkspaces rebuilds each ' + + 'record field-by-field, so nested unknown fields are dropped. That is why notes moved ' + + 'to a top-level key.' + ).not.toHaveProperty('unknownNestedField') + }) + + it('migrates legacy inline folder-workspace notes into the top-level map', async () => { + const note = makeFolderNote('note-1', 'Legacy inline note') + writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord({ diffComments: [note] })] }) + + const store = await createStore() + expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([note]) + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.folderWorkspaceDiffComments?.['fw-1']).toEqual([note]) + expect(persisted.folderWorkspaces[0]).not.toHaveProperty('diffComments') + }) + + it('keeps notes across a rollback when the session never edits anything', async () => { + const note = makeFolderNote('note-1', 'Legacy inline note') + writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord({ diffComments: [note] })] }) + + // Launch and quit on the fixed build, with no mutation: loadNeedsSave must make the + // relocation durable on its own. This is the reported P0 sequence. + const upgraded = await createStore() + upgraded.flush() + + writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState)) + + const restored = await createStore() + expect(restored.getFolderWorkspace('fw-1')?.diffComments).toEqual([note]) + }) + + it('keeps notes authored inline on a rolled-back #14112 build over a staler map entry', async () => { + const mapped = makeFolderNote('note-mapped', 'Note from the fixed build') + const authored = makeFolderNote('note-inline', 'Note authored while rolled back') + + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord()], + diffCommentsMap: { 'fw-1': [mapped] } + }) + // #14112 persists notes inline and never learned about the top-level map, so a note authored + // there lands inline while the untouched map entry goes stale. Inline is the newer write. + const rolledBack = readDataFile() as PersistedState + writeDataFile({ + ...rolledBack, + folderWorkspaces: rolledBack.folderWorkspaces.map((workspace) => ({ + ...workspace, + diffComments: [authored] + })) + }) + + const store = await createStore() + expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([authored]) + store.flush() + expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({ + 'fw-1': [authored] + }) + }) + + it('never lets an empty or unrelated map entry delete inline notes', async () => { + const inline = makeFolderNote('note-inline', 'Inline note') + const mapped = makeFolderNote('note-mapped', 'Mapped note') + + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord({ diffComments: [inline] })], + diffCommentsMap: { 'fw-1': [] } + }) + expect((await createStore()).getFolderWorkspace('fw-1')?.diffComments).toEqual([inline]) + + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord({ diffComments: [inline] })], + diffCommentsMap: { other: [mapped] } + }) + const store = await createStore() + expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([inline]) + store.flush() + expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({ + 'fw-1': [inline] + }) + }) + + it('drops orphaned note entries and prunes them when workspaces are deleted', async () => { + const note = makeFolderNote('note-1', 'Kept note') + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord()], + diffCommentsMap: { 'fw-1': [note], ghost: [makeFolderNote('note-ghost', 'Orphan', 'ghost')] } + }) + + const store = await createStore() + store.flush() + expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({ + 'fw-1': [note] + }) + + const reloaded = await createStore() + reloaded.flush() + expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).not.toHaveProperty( + 'ghost' + ) + + // Delete paths carry no pruning code: the map is derived from live workspaces on every write. + expect(reloaded.removeFolderWorkspace('fw-1')).toBe(true) + reloaded.flush() + expect(readDataFile() as PersistedState).not.toHaveProperty('folderWorkspaceDiffComments') + + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord()], + diffCommentsMap: { 'fw-1': [note] } + }) + const groupDelete = await createStore() + expect(groupDelete.deleteProjectGroup('root')).toBe(true) + groupDelete.flush() + expect(readDataFile() as PersistedState).not.toHaveProperty('folderWorkspaceDiffComments') + }) + + it('writes no folderWorkspaceDiffComments key for note-free profiles', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'No notes' }) + store.flush() + expect('folderWorkspaceDiffComments' in (readDataFile() as object)).toBe(false) + + store.updateFolderWorkspace(workspace.id, { diffComments: [] }) + store.flush() + expect('folderWorkspaceDiffComments' in (readDataFile() as object)).toBe(false) + }) + + it.each([ + ['null root', null, undefined], + ['string root', 'oops', undefined], + ['array root', [], undefined], + ['non-array entry value', { 'fw-1': 'oops' }, undefined], + ['array of non-DiffComment members', { 'fw-1': [7] }, [7]] + ])( + 'tolerates a corrupt folderWorkspaceDiffComments map (%s)', + async (_label, diffCommentsMap, expected) => { + writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord()], diffCommentsMap }) + + const store = await createStore() + + expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual(expected) + } + ) + + it('passes note members through verbatim on load and re-write', async () => { + // Shape-only guard: the moment member filtering is added here, the fix itself becomes a new + // deletion path for user-authored prose. + const noteWithExtras = { ...makeFolderNote('note-1', 'Body'), unknownNoteField: 'kept' } + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord(), folderWorkspaceRecord({ id: 'fw-2', name: 'Second' })], + diffCommentsMap: { 'fw-1': [7], 'fw-2': [noteWithExtras] } + }) + + const store = await createStore() + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.folderWorkspaceDiffComments).toEqual({ + 'fw-1': [7], + 'fw-2': [noteWithExtras] + }) + }) + + it('derives the written note map from live workspaces, never a stale loaded copy', async () => { + const loadedNote = makeFolderNote('note-loaded', 'Loaded note') + const editedNote = makeFolderNote('note-edited', 'Edited note') + writeFolderWorkspaceProfile({ + workspaces: [folderWorkspaceRecord()], + diffCommentsMap: { 'fw-1': [loadedNote] } + }) + + const store = await createStore() + store.updateFolderWorkspace('fw-1', { diffComments: [editedNote] }) + store.flush() + + expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({ + 'fw-1': [editedNote] + }) + }) + // ── 9. Settings: get/update ──────────────────────────────────────── it('updateSettings merges partial updates', async () => { @@ -5659,6 +6023,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' }) @@ -9357,6 +9765,99 @@ describe('Store', () => { }) }) + it('requires an expected split source incarnation in the owning host partition', async () => { + for (const hostId of [undefined, 'ssh:ssh-1']) { + const store = await createStore() + const sourceSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' } + } + } + } + if (hostId) { + store.setWorkspaceSession( + { + ...sourceSession, + terminalPtyIncarnationsByPaneKey: { + [`tab1:${TEST_LEAF_1}`]: 'live-incarnation' + } + }, + undefined + ) + } + store.setWorkspaceSession(sourceSession, hostId) + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_2, + ptyId: 'pty-split', + expectedSourceBinding: { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-source', + incarnationId: 'live-incarnation' + } + }, + hostId + ) + ).toBe(false) + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.root).toEqual({ + type: 'leaf', + leafId: TEST_LEAF_1 + }) + } + }) + + // Why: a session restored without an incarnation map still owns a valid source binding, so the + // split path must be able to fence on the pane alone instead of an id persistence never recorded. + it('admits a split whose source pane has no persisted incarnation', async () => { + const store = await createStore() + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' } + } + } + }, + undefined + ) + + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_2, + ptyId: 'pty-split', + expectedSourceBinding: { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-source' + } + }) + ).toBe(true) + }) + it('rejects competing PTY and incarnation changes during reconciliation', async () => { const store = await createStore() const paneKey = `tab1:${TEST_LEAF_1}` diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b62b25f7977..f9fee511d39 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -256,6 +256,10 @@ import { normalizeUiLanguage } from '../shared/ui-language' import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom' import { persistedUIValuesEqual } from '../shared/persisted-ui-equality' import { ActiveViewPreference } from './active-view-preference' +import { + collectFolderWorkspaceDiffComments, + normalizeFolderWorkspaceDiffComments +} from './folder-workspace-diff-comments' import { normalizeFolderWorkspaceName, normalizeFolderWorkspaces @@ -651,6 +655,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 +668,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 } @@ -2849,6 +2862,7 @@ export class Store { // profile avoids serializing the multi-MB recovery store on navigation. this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() + this.hydrateFolderWorkspaceDiffComments() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -2869,6 +2883,33 @@ export class Store { } } + // Why: notes live top-level on disk so an older build's field-by-field + // normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here. + private hydrateFolderWorkspaceDiffComments(): void { + const stored = this.state.folderWorkspaceDiffComments + let relocatedInline = false + for (const workspace of this.state.folderWorkspaces ?? []) { + if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { + // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the + // older map untouched, so inline is the last notes-aware write. Also makes the relocation + // durable even if the user never edits anything this session. + relocatedInline = true + continue + } + const comments = stored?.[workspace.id] + // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. + if (Array.isArray(comments) && comments.length > 0) { + workspace.diffComments = comments + } + } + if (relocatedInline) { + this.loadNeedsSave = true + } + // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in + // state would make it a stale second source of truth that getDurableState() spreads back out. + delete this.state.folderWorkspaceDiffComments + } + private adaptFlatFolderScanProjectGroups(): boolean { // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. const groups = this.state.projectGroups ?? [] @@ -3149,6 +3190,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 ) @@ -3411,6 +3459,9 @@ export class Store { parsed.folderWorkspaces, normalizedProjectGroups ), + folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( + parsed.folderWorkspaceDiffComments + ), worktreeLineageById: parsed.worktreeLineageById ?? {}, mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( parsed.mobileClientTabSelectionsByDeviceId @@ -3421,7 +3472,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 ), @@ -4005,6 +4056,13 @@ export class Store { // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), + // Why both keys unconditionally: the explicit keys always win over the spread, and + // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. + // The strip builds a new array here only; this.state records keep their notes in memory. + folderWorkspaces: (this.state.folderWorkspaces ?? []).map( + ({ diffComments: _relocated, ...rest }) => rest + ), + folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces), sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ ...record, ownerLease: encryptToSentinel( @@ -4013,7 +4071,7 @@ export class Store { ) })), settings: { - ...this.state.settings, + ...stripRetiredGlobalSettings(this.state.settings), opencodeSessionCookie: encryptToSentinel( PROTECTED_SECRET_SLOT.opencodeSessionCookie, this.state.settings.opencodeSessionCookie @@ -5819,7 +5877,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/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index ce96eddb5e0..de78d21b10b 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -157,6 +157,7 @@ function createRuntimeFileCommands(options?: { resolveTerminalContext?: ReturnType resolveTerminalFileUriHostname?: ReturnType hasRecentTerminalOutputPath?: ReturnType + hasRecentNativeChatOutputPath?: ReturnType }) { const store = { getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined) @@ -192,6 +193,9 @@ function createRuntimeFileCommands(options?: { ? { resolveTerminalFileUriHostname: options.resolveTerminalFileUriHostname } : {}), hasRecentTerminalOutputPath: options?.hasRecentTerminalOutputPath ?? vi.fn(() => true), + ...(options?.hasRecentNativeChatOutputPath + ? { hasRecentNativeChatOutputPath: options.hasRecentNativeChatOutputPath } + : {}), resolveRuntimeGitTarget: options?.resolveRuntimeGitTarget ?? vi.fn(), openFile: options?.openFile ?? vi.fn(), ...(options?.openDiff ? { openDiff: options.openDiff } : {}) @@ -945,11 +949,18 @@ describe('RuntimeFileCommands', () => { ) } - function createRemoteTerminalArtifactGrantFixture(artifactPath = '/tmp/result.json') { - const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + function createRemoteTerminalArtifactGrantFixture( + artifactPath = '/tmp/result.json', + nativeChat = false + ) { + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + ...(nativeChat ? { hasRecentNativeChatOutputPath: vi.fn(() => true) } : {}) + }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) let realArtifactPath = artifactPath - const stat = vi.fn().mockResolvedValue({ type: 'file', size: 11, mtime: 3 }) + let artifactStat = { type: 'file', size: 11, mtime: 3 } + const stat = vi.fn(async () => artifactStat) const readTerminalArtifact = vi .fn() .mockResolvedValue({ content: '{"ok":true}', isBinary: false }) @@ -967,10 +978,20 @@ describe('RuntimeFileCommands', () => { writeTerminalArtifact, moveArtifactTarget: (nextPath: string) => { realArtifactPath = nextPath + }, + replaceArtifact: () => { + artifactStat = { type: 'file', size: 12, mtime: 4 } } } } + function resolveRemoteNativeChatArtifact(commands: RuntimeFileCommands, artifactPath: string) { + return commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a', null, true, { + tabId: 'tab-1', + sessionId: 'session-1' + }) + } + it('resolves an absolute path inside the worktree to a relative path', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) statAsFile() @@ -992,6 +1013,28 @@ describe('RuntimeFileCommands', () => { }) }) + it('keeps in-worktree resolution unchanged when chat provenance is present', async () => { + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + statAsFile() + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '/repo/src/index.ts', + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(result).toMatchObject({ relativePath: 'src/index.ts', exists: true }) + expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() + }) + it('resolves an absolute path through a known sibling workspace', async () => { const sibling = { id: 'wt-2', @@ -1367,9 +1410,13 @@ describe('RuntimeFileCommands', () => { expect(statMock).not.toHaveBeenCalled() }) - it('does not mint an absolute terminal artifact grant without a source terminal', async () => { + it('keeps old-client behavior without native-chat provenance', async () => { const artifactPath = await tempFile('result.json', '{}') - const { commands } = createRuntimeFileCommands({ path: '/repo' }) + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) const result = await commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a') @@ -1380,6 +1427,139 @@ describe('RuntimeFileCommands', () => { isDirectory: false }) expect(result.openTarget).toBeUndefined() + expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() + }) + + it('mints an exact-path grant for an out-of-worktree path cited by native chat', async () => { + const artifactPath = await tempFile('chat-result.html', '

Result

') + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + artifactPath, + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith( + 'wt-1', + { tabId: 'tab-1', sessionId: 'session-1' }, + artifactPath, + artifactPath + ) + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: null, + absolutePath: await realpath(artifactPath), + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: await realpath(artifactPath), + readOnly: true + } + }) + const target = absoluteFileTarget(result) + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + '

Changed

', + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_read_only') + await expect(readFile(artifactPath, 'utf8')).resolves.toBe('

Result

') + }) + + it('binds a cited symlink alias to its canonical read-only target', async () => { + const artifactPath = await tempFile('chat-target.html', '

Result

') + const citedPath = join(artifactPath, '..', 'chat-citation.html') + await symlink(artifactPath, citedPath) + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + citedPath, + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith( + 'wt-1', + { tabId: 'tab-1', sessionId: 'session-1' }, + citedPath, + citedPath + ) + expect(result).toMatchObject({ + absolutePath: await realpath(artifactPath), + exists: true, + openTarget: { + kind: 'absolute-file', + absolutePath: await realpath(artifactPath), + readOnly: true + } + }) + }) + + it('refuses an out-of-worktree chat path without transcript provenance', async () => { + const artifactPath = await tempFile('uncited-result.html', '

Secret

') + const hasRecentNativeChatOutputPath = vi.fn(() => false) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + artifactPath, + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(result).toMatchObject({ relativePath: null, exists: false }) + expect(result.openTarget).toBeUndefined() + expect(hasRecentNativeChatOutputPath).toHaveBeenCalledTimes(1) + }) + + it('refuses native-chat tilde paths when the workspace runs in WSL', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: String.raw`\\wsl.localhost\Ubuntu\work\repo`, + hasRecentNativeChatOutputPath + }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '~/.ssh/config', + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(result).toMatchObject({ relativePath: null, absolutePath: null, exists: false }) + expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() }) it('does not mint an absolute terminal artifact grant for an unobserved path', async () => { @@ -2213,6 +2393,69 @@ describe('RuntimeFileCommands', () => { expect(writeTerminalArtifact).toHaveBeenCalled() }) + it('reads a remote non-temp artifact cited by native chat', async () => { + const artifactPath = '/home/me/report.json' + const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture( + artifactPath, + true + ) + const result = await resolveRemoteNativeChatArtifact(commands, artifactPath) + const target = absoluteFileTarget(result) + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).resolves.toMatchObject({ content: '{"ok":true}' }) + expect(readTerminalArtifact).toHaveBeenCalledWith( + artifactPath, + expect.objectContaining({ expectedRealPath: artifactPath }) + ) + }) + + it('rejects a retargeted remote native-chat artifact grant', async () => { + const artifactPath = '/home/me/report.json' + const { commands, readTerminalArtifact, moveArtifactTarget } = + createRemoteTerminalArtifactGrantFixture(artifactPath, true) + const result = await resolveRemoteNativeChatArtifact(commands, artifactPath) + const target = absoluteFileTarget(result) + + moveArtifactTarget('/home/me/private.json') + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + expect(readTerminalArtifact).not.toHaveBeenCalled() + }) + + it('rejects a replaced remote native-chat artifact grant', async () => { + const artifactPath = '/home/me/report.json' + const { commands, readTerminalArtifact, replaceArtifact } = + createRemoteTerminalArtifactGrantFixture(artifactPath, true) + const result = await resolveRemoteNativeChatArtifact(commands, artifactPath) + const target = absoluteFileTarget(result) + + replaceArtifact() + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + expect(readTerminalArtifact).not.toHaveBeenCalled() + }) + it('rejects remote terminal artifact reads when a grant no longer resolves to the granted path', async () => { const { commands, readTerminalArtifact, moveArtifactTarget } = createRemoteTerminalArtifactGrantFixture() @@ -2294,6 +2537,28 @@ describe('RuntimeFileCommands', () => { expect(stat).not.toHaveBeenCalled() }) + it('still refuses a native-chat ~/ path on a remote worktree', async () => { + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '~/notes.md', + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(result).toMatchObject({ relativePath: null, exists: false }) + expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() + }) + it('reports a missing remote file as not existing', async () => { const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index f85bff8abde..fd8f76796a6 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -45,6 +45,7 @@ import type { RuntimeFileReadChunkResult, RuntimeFilePreviewResult, RuntimeFileReadResult, + RuntimeNativeChatFileContext, RuntimeTerminalPathResolution } from '../../shared/runtime-types' import { @@ -183,6 +184,8 @@ type TerminalFileGrant = { clientId?: string expiresAt: number statIdentity: string | null + readOnly: boolean + provenance: 'terminal-output' | 'native-chat' expiryTimer?: ReturnType } @@ -524,6 +527,12 @@ export type RuntimeFileCommandHost = { pathText: string, absolutePath: string ): boolean | Promise + hasRecentNativeChatOutputPath?( + worktreeId: string, + context: RuntimeNativeChatFileContext, + pathText: string, + absolutePath: string + ): boolean | Promise resolveRuntimeGitTarget( selector: string ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> @@ -726,7 +735,8 @@ export class RuntimeFileCommands { cwd?: string | null, clientId?: string, terminalHandle?: string | null, - crossWorkspace?: boolean + crossWorkspace?: boolean, + nativeChatContext?: RuntimeNativeChatFileContext | null ): Promise { const store = this.host.requireStore() const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) @@ -750,9 +760,9 @@ export class RuntimeFileCommands { isDirectory: false } - // Why: remote home is unknown (only local os.homedir), so a tapped ~/… on a remote worktree is not-openable, not guessed. + // Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home. const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\') - if (isTilde && connectionId) { + if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) { return empty } const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText @@ -802,6 +812,26 @@ export class RuntimeFileCommands { } } + if ( + nativeChatContext && + (await this.host.hasRecentNativeChatOutputPath?.( + worktree.id, + nativeChatContext, + pathText, + absolutePath + )) + ) { + const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId) + return await this.resolveAbsoluteFileGrant({ + worktreeId: worktree.id, + artifactPath, + connectionId, + clientId, + readOnly: true, + provenance: 'native-chat' + }) + } + // Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths. if (!normalizedTerminalHandle || !terminalCwd) { return { ...empty, relativePath, absolutePath } @@ -831,38 +861,13 @@ export class RuntimeFileCommands { ) { return { ...empty, relativePath, absolutePath } } - const stats = connectionId - ? await this.statRemoteTerminalPath(artifactPath, connectionId) - : await this.statLocalTerminalPath(artifactPath) - const isDirectory = stats.isDirectory() - if (!isDirectory && isTerminalArtifactHardLinked(stats)) { - return { ...empty, relativePath, absolutePath } - } - const grant = isDirectory - ? null - : this.createTerminalFileGrant({ - worktreeId: worktree.id, - absolutePath: artifactPath, - provider: connectionId ? 'ssh' : 'local', - connectionId, - clientId, - stats - }) - return { - worktree: worktree.id, - relativePath: null, - absolutePath: artifactPath, - exists: true, - isDirectory, - openTarget: grant - ? { - kind: 'absolute-file', - provider: grant.provider, - absolutePath: artifactPath, - grantId: grant.id - } - : undefined - } + return await this.resolveAbsoluteFileGrant({ + worktreeId: worktree.id, + artifactPath, + rejectedAbsolutePath: absolutePath, + connectionId, + clientId + }) } catch (error) { // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. if ( @@ -909,6 +914,72 @@ export class RuntimeFileCommands { return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath) } + private async resolveNativeChatArtifactPath( + absolutePath: string, + connectionId?: string + ): Promise { + if (!connectionId) { + return canonicalPathForArtifactComparison(absolutePath) + } + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.realpath(absolutePath) + } + + private async resolveAbsoluteFileGrant(args: { + worktreeId: string + artifactPath: string + rejectedAbsolutePath?: string + connectionId?: string + clientId?: string + readOnly?: boolean + provenance?: TerminalFileGrant['provenance'] + }): Promise { + const stats = args.connectionId + ? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId) + : await this.statLocalTerminalPath(args.artifactPath) + const isDirectory = stats.isDirectory() + if (!isDirectory && isTerminalArtifactHardLinked(stats)) { + return { + worktree: args.worktreeId, + relativePath: null, + absolutePath: args.rejectedAbsolutePath ?? args.artifactPath, + exists: false, + isDirectory: false + } + } + const grant = isDirectory + ? null + : this.createTerminalFileGrant({ + worktreeId: args.worktreeId, + absolutePath: args.artifactPath, + provider: args.connectionId ? 'ssh' : 'local', + connectionId: args.connectionId, + clientId: args.clientId, + readOnly: args.readOnly === true, + provenance: args.provenance ?? 'terminal-output', + stats + }) + return { + worktree: args.worktreeId, + relativePath: null, + absolutePath: args.artifactPath, + exists: true, + isDirectory, + openTarget: grant + ? { + kind: 'absolute-file', + provider: grant.provider, + absolutePath: args.artifactPath, + grantId: grant.id, + ...(grant.readOnly ? { readOnly: true } : {}) + } + : undefined + } + } + private async resolveAllowedRemoteTerminalArtifactPath( absolutePath: string, connectionId: string @@ -953,6 +1024,8 @@ export class RuntimeFileCommands { provider: 'local' | 'ssh' connectionId?: string clientId?: string + readOnly?: boolean + provenance: TerminalFileGrant['provenance'] stats: RuntimeFileStatLike }): TerminalFileGrant { assertTerminalArtifactNotHardLinked(args.stats) @@ -964,7 +1037,9 @@ export class RuntimeFileCommands { ...(args.connectionId ? { connectionId: args.connectionId } : {}), ...(args.clientId ? { clientId: args.clientId } : {}), expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, - statIdentity: terminalFileStatIdentity(args.stats) + statIdentity: terminalFileStatIdentity(args.stats), + readOnly: args.readOnly === true, + provenance: args.provenance } this.terminalFileGrants.set(grant.id, grant) this.scheduleTerminalFileGrantExpiry(grant) @@ -1129,6 +1204,9 @@ export class RuntimeFileCommands { absolutePath, clientId ) + if (grant.readOnly) { + throw new Error('terminal_file_grant_read_only') + } if (isMobileBinaryPath(grant.absolutePath)) { throw new Error('binary_file') } @@ -1280,12 +1358,15 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - const allowedPath = await this.resolveAllowedRemoteTerminalArtifactPath( - grant.absolutePath, - grant.connectionId - ) - // Why: relay I/O follows symlinks, so re-canonicalize a remote temp-artifact grant after the process can mutate it. - if (allowedPath !== grant.absolutePath) { + const canonicalPath = + grant.provenance === 'native-chat' + ? await provider.realpath(grant.absolutePath) + : await this.resolveAllowedRemoteTerminalArtifactPath( + grant.absolutePath, + grant.connectionId + ) + // Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path. + if (canonicalPath !== grant.absolutePath) { throw new Error('terminal_file_grant_stale') } return provider diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index c13f8825380..3aa6ae47603 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -69,35 +69,69 @@ function remoteSnapshot(): RuntimeMobileSessionTabsSnapshot { } } -function createHarness(includeSource = true) { +function createHarness( + includeSource = true, + options: { + connectionId?: string | null + deferReveal?: boolean + deferSpawn?: boolean + includePairedSnapshot?: boolean + rendererMounted?: boolean + sourceIncarnationId?: string + stopAndWaitResult?: boolean + } = {} +) { let session = persistedSession(includeSource) + const connectionId = options.connectionId ?? null + const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local' + const requestedSessionHostIds: (string | undefined)[] = [] const repo = { id: REPO_ID, path: '/workspace', displayName: 'repo', badgeColor: 'blue', - addedAt: 1 + addedAt: 1, + ...(connectionId ? { connectionId } : {}) } const store = { getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), - getWorkspaceSession: () => session, + getWorkspaceSession: (hostId?: string) => { + requestedSessionHostIds.push(hostId) + return hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession() + }, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, persistPtyBinding: () => true } - const spawn = vi.fn(async () => ({ id: SPLIT_PTY_ID })) + let resolveSpawn: ((result: { id: string }) => void) | undefined + const spawn = options.deferSpawn + ? vi.fn( + () => + new Promise<{ id: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + : vi.fn(async () => ({ id: SPLIT_PTY_ID })) const kill = vi.fn(() => true) - const revealTerminalSession = vi - .fn() - .mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`)) + const retireRejectedPty = vi.fn() + const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true) + let resolveReveal: ((result: { tabId: string }) => void) | undefined + const revealTerminalSession = options.deferReveal + ? vi.fn( + () => + new Promise<{ tabId: string }>((resolve) => { + resolveReveal = resolve + }) + ) + : vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`)) const runtime = new OrcaRuntimeService(store as never) Object.assign(runtime, { resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ id: WORKTREE_ID, path: '/workspace', - connectionId: null, + connectionId, repo, folderWorkspace: null })) @@ -106,35 +140,77 @@ function createHarness(includeSource = true) { spawn, write: () => true, kill, + retireRejectedPty, + ...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}), getForegroundProcess: async () => null }) runtime.setNotifier({ revealTerminalSession } as never) runtime.syncWindowGraph(1, { - tabs: [], - leaves: [], - mobileSessionTabs: includeSource ? [remoteSnapshot()] : [] + tabs: + includeSource && options.rendererMounted + ? [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Restored terminal', + activeLeafId: SOURCE_LEAF_ID, + layout: { type: 'leaf', leafId: SOURCE_LEAF_ID } as const + } + ] + : [], + leaves: + includeSource && options.rendererMounted + ? [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: SOURCE_LEAF_ID, + paneRuntimeId: 1, + ptyId: SOURCE_PTY_ID + } + ] + : [], + mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : [] }) - runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, null, { + runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, { tabId: TAB_ID, - leafId: SOURCE_LEAF_ID + leafId: SOURCE_LEAF_ID, + ...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {}) }) const internals = runtime as unknown as { - getTerminalHandleForPaneKey: (paneKey: string) => string | null issuePtyHandle: (pty: unknown) => string mobileSessionTabsByWorktree: Map ptysById: Map } - const handle = - internals.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, SOURCE_LEAF_ID)) ?? - internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID)) + const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID)) return { runtime, handle, spawn, kill, + retireRejectedPty, + stopAndWait, revealTerminalSession, getSession: () => session, - getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID) + getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID), + requestedSessionHostIds, + replaceSourceIncarnation: (incarnationId: string) => + runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, { + tabId: TAB_ID, + leafId: SOURCE_LEAF_ID, + incarnationId + }), + replacePersistedSourceIncarnation: (incarnationId: string) => { + session = { + ...session, + terminalPtyIncarnationsByPaneKey: { + ...session.terminalPtyIncarnationsByPaneKey, + [makePaneKey(TAB_ID, SOURCE_LEAF_ID)]: incarnationId + } + } + }, + resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }), + resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID }) } } @@ -185,4 +261,118 @@ describe('remote runtime terminal split authority', () => { expect.soft(harness.kill).not.toHaveBeenCalled() expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled() }) + + it.each([ + { label: 'local', connectionId: null, expectedHostId: 'local' }, + { label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' } + ])( + 'rejects a same-ID $label source replacement when restored persistence lacks an incarnation', + async ({ connectionId, expectedHostId }) => { + const harness = createHarness(true, { + connectionId, + deferSpawn: true, + includePairedSnapshot: false, + rendererMounted: true, + sourceIncarnationId: 'source-before' + }) + + const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' }) + await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce()) + + expect( + harness.getSession().terminalPtyIncarnationsByPaneKey?.[makePaneKey(TAB_ID, SOURCE_LEAF_ID)] + ).toBeUndefined() + expect(harness.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + expectedSourceBinding: expect.objectContaining({ ptyId: SOURCE_PTY_ID }) + }) + ) + // Why: persistence never recorded this incarnation, so sending it would make the store + // reject every split from a restored pane; the live id is fenced in-runtime instead. + expect(harness.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() }) + }) + ) + + harness.replaceSourceIncarnation('source-after') + harness.resolveSpawn() + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) + expect(harness.requestedSessionHostIds).toContain(expectedHostId) + } + ) + + it('rejects a same-ID paired-runtime source replacement recovered without an incarnation map', async () => { + const harness = createHarness(true, { + deferSpawn: true, + includePairedSnapshot: true, + rendererMounted: false, + sourceIncarnationId: 'remote-before' + }) + + const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce()) + expect(harness.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() }) + }) + ) + + harness.replaceSourceIncarnation('remote-after') + harness.resolveSpawn() + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) + expect(harness.revealTerminalSession).not.toHaveBeenCalled() + }) + + it('rejects a persisted-only source incarnation change during spawn', async () => { + const harness = createHarness(true, { + deferSpawn: true, + includePairedSnapshot: false, + stopAndWaitResult: true + }) + harness.replacePersistedSourceIncarnation('persisted-before') + + const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce()) + harness.replacePersistedSourceIncarnation('persisted-after') + harness.resolveSpawn() + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(harness.stopAndWait).toHaveBeenCalledWith( + SPLIT_PTY_ID, + expect.objectContaining({ deadlineMs: expect.any(Number) }) + ) + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID) + }) + + it('revalidates a projected paired-runtime source after renderer adoption', async () => { + const harness = createHarness(false, { + deferReveal: true, + includePairedSnapshot: true, + sourceIncarnationId: 'projected-before', + stopAndWaitResult: false + }) + + const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) + expect(harness.spawn).toHaveBeenCalledWith( + expect.not.objectContaining({ expectedSourceBinding: expect.anything() }) + ) + + harness.replaceSourceIncarnation('projected-after') + harness.resolveReveal() + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(harness.stopAndWait).toHaveBeenCalledWith( + SPLIT_PTY_ID, + expect.objectContaining({ deadlineMs: expect.any(Number) }) + ) + expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) + expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID) + }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec4d930ad79..aa68dad27ae 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14898,6 +14898,8 @@ describe('OrcaRuntimeService', () => { const splitSpawn = spawn.mock.calls[0]?.[0] as | { expectedSourceBinding?: { incarnationId?: string } } | undefined + // Why: persistence never recorded an incarnation for this pane, so sending the live-only id + // would make the store's fence reject every split from a restored session. expect(splitSpawn?.expectedSourceBinding).not.toHaveProperty('incarnationId') runtime.syncWindowGraph(1, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7cad4ad94bc..55d2195c31e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -711,6 +711,7 @@ import { scanLocalRepoWorktreesForResolution, type RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' +import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -1012,6 +1013,7 @@ import { recentTerminalOutputIncludesPath, recentTerminalPathCandidatesIncludePath } from './terminal-output-path-candidates' +import { nativeChatTranscriptIncludesPath } from '../native-chat/native-chat-file-provenance' import { getSelectedReviewBranch, getSelectedReviewLookupHints, @@ -1724,6 +1726,7 @@ type RuntimePtyController = { * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ attach?(ptyId: string): Promise kill(ptyId: string): boolean + retireRejectedPty?(ptyId: string): void stopAndWait?( ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } @@ -1827,6 +1830,9 @@ const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 +// Why: the split already failed; the caller waits on this teardown only to learn whether the +// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection. +const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000 const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 const SSH_PANE_RECOVERY_GRACE_MS = 30_000 // Why: long enough that a keystroke burst to a proven-dead leaf probes once, @@ -2531,12 +2537,25 @@ type RuntimeWorktreeScanCache = { runtimeKey: string result: RuntimeWorktreeScanResult expiresAt: number + /** + * Git-admin state read as of the scan's start, kept unresolved so no caller ever waits on the + * probe to receive a scan result. Resolves to `null` when the state could not be read. + */ + adminFingerprint: Promise + /** When the Git scan behind `result` actually ran, so reconciliation can be bounded. */ + scannedAt: number } type RuntimeWorktreeScanInFlight = { generation: number runtimeKey: string - promise: Promise + promise: Promise +} + +type RuntimeWorktreeScanRefresh = { + result: RuntimeWorktreeScanResult + adminFingerprint: Promise + scannedAt: number } type WorktreeLineageCandidate = { @@ -8764,6 +8783,13 @@ export class OrcaRuntimeService { this.resolveTerminalFileUriHostname(terminalHandle), hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) => this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath), + hasRecentNativeChatOutputPath: (worktreeId, context, pathText, absolutePath) => + nativeChatTranscriptIncludesPath({ + tabs: this.getMobileSessionTabsForWorktree(worktreeId).tabs, + context, + pathText, + absolutePath + }), resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { if (!this.notifier?.openFile) { @@ -27362,6 +27388,8 @@ export class OrcaRuntimeService { if (!sourceAuthority) { throw new Error('terminal_split_source_not_found') } + const sourceIncarnationId = + sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId const leafId = randomUUID() const preAllocatedHandle = this.createPreAllocatedTerminalHandle() const paneKey = makePaneKey(parentTabId, leafId) @@ -27388,6 +27416,9 @@ export class OrcaRuntimeService { tabId: parentTabId, leafId: parsedPaneKey.leafId, ptyId: pty.ptyId, + // Why: the store can only match its own persisted map, so a live-only id it never + // recorded would reject every split from a session restored without incarnations. + // The live id is fenced by revalidateSourceAuthority below instead. ...(sourceAuthority.persistedIncarnationId ? { incarnationId: sourceAuthority.persistedIncarnationId } : {}) @@ -27426,17 +27457,29 @@ export class OrcaRuntimeService { } try { - const revalidatedAuthority = this.resolveTerminalSplitSourceAuthority( - workspace.id, - parentTabId, - parsedPaneKey.leafId, - pty.ptyId - ) - if (!revalidatedAuthority || (sourceAuthority.persisted && !revalidatedAuthority.persisted)) { - throw new Error('terminal_split_source_not_found') + const revalidateSourceAuthority = (): void => { + const current = this.resolveTerminalSplitSourceAuthority( + workspace.id, + parentTabId, + parsedPaneKey.leafId, + pty.ptyId + ) + if ( + !current || + (sourceAuthority.persisted && !current.persisted) || + (sourceIncarnationId !== null && + (current.liveIncarnationId ?? current.persistedIncarnationId) !== sourceIncarnationId) + ) { + throw new Error('terminal_split_source_not_found') + } } + revalidateSourceAuthority() if (!sourceAuthority.persisted) { await revealSplit() + // Why: rejecting here unmounts the pane the reveal just added only because the retire + // below always emits its exit and the tab still holds the source sibling — the renderer's + // exit handler closes non-final panes. Never close it by tabId: that drops the whole tab. + revalidateSourceAuthority() } if (createdPty) { const persisted = this.persistHeadlessTerminalSplit({ @@ -27460,7 +27503,19 @@ export class OrcaRuntimeService { } } catch (error) { this.setPairedRendererSessionOwnership(result.id, false) - this.ptyController.kill?.(result.id) + let stopped = false + try { + stopped = + (await this.ptyController.stopAndWait?.(result.id, { + deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS + })) ?? false + } catch { + // Best-effort fallback below preserves the original split authority error. + } + if (!stopped) { + this.ptyController.kill(result.id) + } + this.ptyController.retireRejectedPty?.(result.id) throw error } const committedSourceAuthority = sourceAuthority.persisted @@ -27489,6 +27544,7 @@ export class OrcaRuntimeService { rendererMounted: boolean persistedWorktreeId: string | null persistedIncarnationId: string | null + liveIncarnationId: string | null } | null { const session = this.getWorkspaceSessionForWorktree(worktreeId) const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null @@ -27527,7 +27583,8 @@ export class OrcaRuntimeService { persisted: true, rendererMounted, persistedWorktreeId: sessionWorktreeId, - persistedIncarnationId + persistedIncarnationId, + liveIncarnationId } } // Why: renderer adoption can precede graph sync; this path still requires reveal success before commit. @@ -27549,7 +27606,8 @@ export class OrcaRuntimeService { persisted: false, rendererMounted, persistedWorktreeId: null, - persistedIncarnationId: null + persistedIncarnationId: null, + liveIncarnationId } } @@ -29255,26 +29313,30 @@ export class OrcaRuntimeService { } const inFlight = this.worktreeScanInFlight.get(repo.id) if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) { - return inFlight.promise + return (await inFlight.promise).result } - const promise = this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) + const reusableCached = + cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null + const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached) this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise }) try { - const result = await promise + const refresh = await promise // Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll. if ( - (result.ok || !repo.connectionId) && + (refresh.result.ok || !repo.connectionId) && generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) && this.worktreeScanInFlight.get(repo.id)?.promise === promise ) { this.worktreeScanCache.set(repo.id, { generation, runtimeKey, - result, - expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo) + result: refresh.result, + expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo), + adminFingerprint: refresh.adminFingerprint, + scannedAt: refresh.scannedAt }) } - return result + return refresh.result } finally { if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) { this.worktreeScanInFlight.delete(repo.id) @@ -29282,6 +29344,50 @@ export class OrcaRuntimeService { } } + /** + * Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap + * Git-admin fingerprint proves nothing changed since the cached scan. + */ + private async refreshRepoWorktreeScan( + repo: Repo, + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + cached: RuntimeWorktreeScanCache | null + ): Promise { + const scannedAt = Date.now() + // SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them. + const fingerprintCapable = + !repo.connectionId && + // Why: a repo whose scan TTL already reaches the reconciliation interval can never reuse a + // fingerprint, so reading one would be pure work. Agent-scratch roots are that case today. + resolveWorktreeScanCacheTtlMs(repo) < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS && + !getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime).wslDistro + // Why issue it before the scan: a change landing while the scan runs must not be stamped as + // already-observed, or the next probe would mask it until the reconciliation deadline. + const adminFingerprint = fingerprintCapable + ? readRepoWorktreeAdminFingerprint(repo.path) + : UNFINGERPRINTED_WORKTREE_SCAN + const reusable = + fingerprintCapable && + cached?.result.ok === true && + scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS + ? cached + : null + if (reusable) { + // Why await only here: this is the one branch whose decision needs the probe. A scan-bound + // caller must never wait on it, or every cold read pays filesystem latency it cannot use. + const [current, previous] = await Promise.all([adminFingerprint, reusable.adminFingerprint]) + if (current !== null && current === previous) { + return { + result: reusable.result, + adminFingerprint: reusable.adminFingerprint, + scannedAt: reusable.scannedAt + } + } + } + const result = await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) + return { result, adminFingerprint, scannedAt } + } + private async listRepoWorktreesForResolutionUncached( repo: Repo, projectRuntime: ProjectExecutionRuntimeResolution | undefined @@ -35606,6 +35712,12 @@ const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 // fan-out was measured at ~128 git execs/min on real installs, mostly against // these (crash-cluster diagnostics, 2026-07). const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 +// Why: the Git-admin fingerprint reads HEAD and its ref tip exactly, but sparse-checkout pattern +// edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size +// stamp, so a real scan still runs on this interval even while the probe reports "unchanged". +export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000 +/** Stand-in for a repo the local admin probe cannot or need not describe; never matches a real read. */ +const UNFINGERPRINTED_WORKTREE_SCAN: Promise = Promise.resolve(null) const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { diff --git a/src/main/runtime/repo-worktree-admin-fingerprint.test.ts b/src/main/runtime/repo-worktree-admin-fingerprint.test.ts new file mode 100644 index 00000000000..15d4aad4484 --- /dev/null +++ b/src/main/runtime/repo-worktree-admin-fingerprint.test.ts @@ -0,0 +1,152 @@ +// Real-binary coverage: the fingerprint's whole job is to predict what `git worktree list` would +// report, so a mocked filesystem would only prove the assumptions, not the Git layout they model. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint' + +const execFileAsync = promisify(execFile) + +let scratchDir = '' +let repoPath = '' +let worktreePath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +async function fingerprint(path = repoPath): Promise { + return await readRepoWorktreeAdminFingerprint(path) +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git records /private/var/... in `gitdir`. + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-fingerprint-'))) + repoPath = join(scratchDir, 'repo') + worktreePath = join(scratchDir, 'trees', 'feature') + await mkdir(repoPath, { recursive: true }) + await mkdir(join(scratchDir, 'trees'), { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.email', 'fingerprint@example.invalid'], repoPath) + await git(['config', 'user.name', 'Fingerprint'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('readRepoWorktreeAdminFingerprint', () => { + it('is stable while nothing changes', async () => { + const first = await fingerprint() + expect(first).not.toBeNull() + expect(await fingerprint()).toBe(first) + }) + + it('changes when a worktree is added', async () => { + const before = await fingerprint() + await git( + ['worktree', 'add', '-q', join(scratchDir, 'trees', 'second'), '-b', 'second'], + repoPath + ) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree is removed', async () => { + const before = await fingerprint() + await git(['worktree', 'remove', worktreePath], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree directory is deleted outside Git', async () => { + // The admin dir is untouched by `rm -rf`, but the row's `prunable` flag flips. + const before = await fingerprint() + await rm(worktreePath, { recursive: true, force: true }) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree is moved', async () => { + const before = await fingerprint() + await git(['worktree', 'move', worktreePath, join(scratchDir, 'trees', 'moved')], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree is locked', async () => { + const before = await fingerprint() + await git(['worktree', 'lock', worktreePath], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a linked worktree switches branch', async () => { + const before = await fingerprint() + // A longer ref name keeps the difference visible even on a coarse mtime clock. + await git(['checkout', '-q', '-b', 'feature-with-a-much-longer-name'], worktreePath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a linked worktree records a commit', async () => { + // Committing rewrites refs/heads/feature and leaves HEAD alone, but moves the reported HEAD oid. + const before = await fingerprint() + await writeFile(join(worktreePath, 'work.txt'), 'work\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'work'], worktreePath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when the main worktree records a commit', async () => { + const before = await fingerprint() + await writeFile(join(repoPath, 'more.txt'), 'more\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'more'], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('still tracks a tip whose loose ref has been packed away', async () => { + await git(['pack-refs', '--all'], repoPath) + const before = await fingerprint() + await writeFile(join(worktreePath, 'packed.txt'), 'packed\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'packed'], worktreePath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when the main worktree switches branch', async () => { + const before = await fingerprint() + await git(['checkout', '-q', '-b', 'main-with-a-much-longer-name'], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('resolves the same admin state from a linked worktree path', async () => { + // A linked worktree can itself be the registered repo path; both must describe one common dir. + expect(await fingerprint(worktreePath)).toBe(await fingerprint(repoPath)) + }) + + it('reads a bare repo through its own gitdir', async () => { + const barePath = join(scratchDir, 'bare.git') + await git(['clone', '-q', '--bare', repoPath, barePath], scratchDir) + const before = await fingerprint(barePath) + expect(before).not.toBeNull() + await git( + ['worktree', 'add', '-q', join(scratchDir, 'trees', 'from-bare'), '-b', 'bare-tree'], + barePath + ) + expect(await fingerprint(barePath)).not.toBe(before) + }) + + it('returns null for a directory that is not a git repo', async () => { + const plainPath = join(scratchDir, 'plain') + await mkdir(plainPath, { recursive: true }) + expect(await fingerprint(plainPath)).toBeNull() + }) + + it('returns null for a missing path', async () => { + expect(await fingerprint(join(scratchDir, 'absent'))).toBeNull() + }) +}) diff --git a/src/main/runtime/repo-worktree-admin-fingerprint.ts b/src/main/runtime/repo-worktree-admin-fingerprint.ts new file mode 100644 index 00000000000..1c3cf103e67 --- /dev/null +++ b/src/main/runtime/repo-worktree-admin-fingerprint.ts @@ -0,0 +1,191 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import path from 'node:path' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' + +// NUL can appear in neither a path nor a Git ref, so field boundaries stay unambiguous. +const FIELD_SEPARATOR = '\u0000' +const MISSING = '-' +// Why: this runs per repo on a polling path, so a repo with hundreds of worktrees must not queue its +// whole admin dir onto the fs threadpool at once. Mirrors SPARSE_CHECKOUT_DETECTION_CONCURRENCY. +const LINKED_WORKTREE_PROBE_CONCURRENCY = 8 + +/** + * Cheap, subprocess-free summary of a local repo's Git worktree administrative state. + * + * Equal fingerprints mean `git worktree list --porcelain` would report the same rows, so a caller + * can extend a scan cache instead of spawning Git. `null` means "cannot prove unchanged" (not a + * Git repo, unreadable layout, permission error) and callers must fall back to a real scan. + * + * Not covered: sparse-checkout pattern edits, and a tip moved through a ref store this cannot read + * exactly (packed refs, the reftable backend), which fall back to a coarser mtime + size stamp. + * Callers bound both with a periodic unconditional rescan. + */ +export async function readRepoWorktreeAdminFingerprint(repoPath: string): Promise { + try { + const commonDir = await resolveGitCommonDir(repoPath) + if (!commonDir) { + return null + } + const adminDir = path.join(commonDir, 'worktrees') + const names = await readLinkedWorktreeNames(adminDir) + const [mainHead, mainExists, packedRefs, reftable] = await Promise.all([ + readHeadStamp(commonDir, commonDir), + readExistenceStamp(repoPath), + // A tip whose loose ref file was packed away still moves these. + readFileStamp(path.join(commonDir, 'packed-refs')), + readFileStamp(path.join(commonDir, 'reftable')) + ]) + const linked = await mapWithConcurrency( + names, + LINKED_WORKTREE_PROBE_CONCURRENCY, + async (name) => await readLinkedWorktreeStamp(commonDir, adminDir, name) + ) + return [mainHead, mainExists, packedRefs, reftable, String(names.length), ...linked].join( + FIELD_SEPARATOR + ) + } catch { + return null + } +} + +async function readLinkedWorktreeStamp( + commonDir: string, + adminDir: string, + name: string +): Promise { + const entryDir = path.join(adminDir, name) + // `gitdir` holds "/.git"; its contents follow `git worktree move` and `git worktree repair`. + const gitdirTarget = await readTrimmedFile(path.join(entryDir, 'gitdir')) + const [head, locked, worktreeExists] = await Promise.all([ + readHeadStamp(commonDir, entryDir), + readExistenceStamp(path.join(entryDir, 'locked')), + // Deleting a worktree directory outside Orca flips its `prunable` row without touching the admin dir. + gitdirTarget ? readExistenceStamp(path.dirname(gitdirTarget)) : Promise.resolve(MISSING) + ]) + return [name, gitdirTarget ?? MISSING, head, locked, worktreeExists].join(FIELD_SEPARATOR) +} + +/** + * Identify the commit `git worktree list` would print for one checkout: the HEAD line itself plus, + * when HEAD is a symref, the tip it names. Reading the tip is what makes a plain commit visible — + * committing rewrites `refs/heads/` and leaves HEAD untouched. + */ +async function readHeadStamp(commonDir: string, headDir: string): Promise { + const head = await readTrimmedFile(path.join(headDir, 'HEAD')) + if (!head) { + return MISSING + } + const refName = head.match(/^ref:\s*(.+?)\s*$/)?.[1] + if (!refName || !isSafeRefName(refName)) { + // Detached HEAD already holds the object id, and an unrecognized HEAD is covered by its own text. + return head + } + // Per-worktree refs (`refs/bisect`, `refs/worktree`) live beside the checkout; branches are shared. + const tip = + (await readTrimmedFile(path.join(headDir, refName))) ?? + (await readTrimmedFile(path.join(commonDir, refName))) + return [head, tip ?? MISSING].join(FIELD_SEPARATOR) +} + +/** Keep a hand-edited HEAD from steering the probe outside the repo's ref store. */ +function isSafeRefName(refName: string): boolean { + const segments = refName.split(/[\\/]/) + return ( + segments[0] === 'refs' && + segments.length > 1 && + segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') && + !path.isAbsolute(refName) + ) +} + +async function readLinkedWorktreeNames(adminDir: string): Promise { + try { + const entries = await readdir(adminDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + } catch (err) { + // A repo with no linked worktrees has no admin dir at all; anything else is a real read failure. + if (isMissingEntryError(err)) { + return [] + } + throw err + } +} + +async function resolveGitCommonDir(repoPath: string): Promise { + const gitDir = await resolveGitDir(repoPath) + if (!gitDir) { + return null + } + // A linked worktree's gitdir points at the shared admin root through `commondir`. + const commonDir = await readTrimmedFile(path.join(gitDir, 'commondir')) + return commonDir ? path.resolve(gitDir, commonDir) : gitDir +} + +async function resolveGitDir(repoPath: string): Promise { + const dotGitPath = path.join(repoPath, '.git') + let dotGitStats: Awaited> | null = null + try { + dotGitStats = await stat(dotGitPath) + } catch (err) { + if (!isMissingEntryError(err)) { + throw err + } + } + if (!dotGitStats) { + // Bare repo, or a repo path that already is a gitdir. + return (await readExistenceStamp(path.join(repoPath, 'HEAD'))) === 'y' ? repoPath : null + } + if (dotGitStats.isDirectory()) { + return dotGitPath + } + if (!dotGitStats.isFile()) { + return null + } + const contents = await readTrimmedFile(dotGitPath) + const match = contents?.match(/^gitdir:\s*(.+?)\s*$/m) + return match ? path.resolve(repoPath, match[1]) : null +} + +async function readTrimmedFile(filePath: string): Promise { + try { + const trimmed = (await readFile(filePath, 'utf-8')).trim() + return trimmed.length > 0 ? trimmed : null + } catch (err) { + if (isMissingEntryError(err)) { + return null + } + throw err + } +} + +async function readFileStamp(filePath: string): Promise { + try { + const stats = await stat(filePath) + return `${stats.mtimeMs}:${stats.size}` + } catch (err) { + if (isMissingEntryError(err)) { + return MISSING + } + throw err + } +} + +async function readExistenceStamp(targetPath: string): Promise { + try { + await stat(targetPath) + return 'y' + } catch (err) { + if (isMissingEntryError(err)) { + return 'n' + } + throw err + } +} + +function isMissingEntryError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} 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/files-terminal-path-resolution.test.ts b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts index 2055222ff7f..d589b74116c 100644 --- a/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts +++ b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts @@ -40,7 +40,8 @@ describe('files.resolveTerminalPath RPC', () => { '/repo', undefined, 'term-1', - false + false, + null ) expect(response).toMatchObject({ ok: true, @@ -67,7 +68,30 @@ describe('files.resolveTerminalPath RPC', () => { null, undefined, null, - true + true, + null + ) + }) + + it('forwards optional native-chat provenance', async () => { + const { runtime, dispatcher } = createDispatcher() + + await dispatcher.dispatch( + makeRequest('files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '/outside/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + ) + + expect(runtime.resolveTerminalPath).toHaveBeenLastCalledWith( + 'id:wt-1', + '/outside/result.html', + null, + undefined, + null, + false, + { tabId: 'tab-1', sessionId: 'session-1' } ) }) }) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index ffaa48b510e..741fc65635f 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -78,6 +78,12 @@ const ResolveTerminalPath = WorktreeSelector.extend({ crossWorkspace: z .unknown() .transform((v) => v === true) + .optional(), + nativeChatContext: z + .object({ + tabId: z.string().min(1), + sessionId: z.string().min(1) + }) .optional() }) @@ -257,7 +263,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params.cwd ?? null, clientId, params.terminal ?? null, - params.crossWorkspace === true + params.crossWorkspace === true, + params.nativeChatContext ?? null ) }), defineMethod({ 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/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts new file mode 100644 index 00000000000..05c0fe9cd6f --- /dev/null +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -0,0 +1,326 @@ +// The fingerprint's own behaviour is covered against real Git in repo-worktree-admin-fingerprint.test.ts. +// This suite pins the cache wiring: when the probe may skip a `git worktree list`, and when it may not. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0), + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', + requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) + +const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) +vi.mock('../git/worktree', async (importOriginal) => ({ + ...(await importOriginal>()), + listWorktreesStrict: listWorktreesStrictMock +})) + +const readRepoWorktreeAdminFingerprintMock = vi.hoisted(() => vi.fn()) +vi.mock('./repo-worktree-admin-fingerprint', () => ({ + readRepoWorktreeAdminFingerprint: readRepoWorktreeAdminFingerprintMock +})) + +import { OrcaRuntimeService, WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS } from './orca-runtime' + +const REPO_ID = 'repo-local' +const REPO_PATH = '/Users/me/dev/app' +const WORKTREE_PATH = '/Users/me/dev/app-feature' +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}` +const SCAN_TTL_MS = 30_000 + +function makeMeta(overrides: Record = {}) { + return { + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeStore(options: { connectionId?: string; repoCount?: number; repoPath?: string } = {}) { + const metaById: Record> = { + [WORKTREE_ID]: makeMeta(), + [MAIN_WORKTREE_ID]: makeMeta({ displayName: 'main' }) + } + const basePath = options.repoPath ?? REPO_PATH + const repos = Array.from({ length: options.repoCount ?? 1 }, (_unused, index) => ({ + id: index === 0 ? REPO_ID : `${REPO_ID}-${index}`, + path: index === 0 ? basePath : `${basePath}-${index}`, + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + ...(options.connectionId === undefined ? {} : { connectionId: options.connectionId }) + })) + const store = { + getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), + getRepos: () => repos, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...(metaById[id] ?? makeMeta()), ...meta } as never + return metaById[id] + }, + removeWorktreeMeta: () => {}, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } + return store +} + +type RuntimeInternals = { listResolvedWorktrees: () => Promise } + +function makeRuntime( + options: { connectionId?: string; repoCount?: number; repoPath?: string } = {} +): { + runtime: OrcaRuntimeService + list: () => Promise +} { + const runtime = new OrcaRuntimeService(makeStore(options) as never) + return { + runtime, + list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees() + } +} + +function scanCount(): number { + return listWorktreesStrictMock.mock.calls.length +} + +describe('worktree scan admin-fingerprint gate', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() + listWorktreesStrictMock.mockResolvedValue([ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } + ]) + readRepoWorktreeAdminFingerprintMock.mockReset() + readRepoWorktreeAdminFingerprintMock.mockResolvedValue('fp-1') + }) + + it('skips the Git scan past the TTL while the admin fingerprint is unchanged', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + expect(scanCount()).toBe(1) + + // The suppressed read must still re-arm the TTL rather than probing on every poll. + vi.advanceTimersByTime(1_000) + await list() + expect(scanCount()).toBe(1) + expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('rescans at the TTL once the admin fingerprint changes', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + readRepoWorktreeAdminFingerprintMock.mockResolvedValue('fp-2') + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('reconciles with a real scan once the bounded interval elapses', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + // Keep polling past the TTL with an unchanged fingerprint; only the reconcile deadline rescans. + for ( + let elapsed = 31_000; + elapsed < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS; + elapsed += 31_000 + ) { + vi.advanceTimersByTime(31_000) + await list() + } + expect(scanCount()).toBe(1) + + vi.advanceTimersByTime(WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('still rescans immediately when an event invalidates the repo', async () => { + vi.useFakeTimers() + try { + const { runtime, list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + runtime.notifyBranchRenamed(REPO_ID) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('scans when the probe cannot describe the repo', async () => { + vi.useFakeTimers() + try { + readRepoWorktreeAdminFingerprintMock.mockResolvedValue(null) + const { list } = makeRuntime() + + await list() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('never probes a repo whose scan TTL already reaches the reconcile interval', async () => { + // Agent-scratch roots carry a 5-minute TTL, so a fingerprint could never be reused. Reading one + // would be pure work on a polling path. + vi.useFakeTimers() + try { + const { list } = makeRuntime({ repoPath: '/tmp/.codex-tmp/capsule-a' }) + + await list() + vi.advanceTimersByTime(WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS + 1_000) + await list() + + expect(scanCount()).toBe(2) + expect(readRepoWorktreeAdminFingerprintMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('never consults the probe for SSH repos', async () => { + vi.useFakeTimers() + try { + const listWorktrees = vi.fn(async () => [ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } + ]) + getSshGitProviderMock.mockReturnValue({ listWorktrees }) + const { list } = makeRuntime({ connectionId: 'ssh-remote-1' }) + + await list() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + + expect(listWorktrees).toHaveBeenCalledTimes(2) + expect(readRepoWorktreeAdminFingerprintMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not extend a failed scan on an unchanged fingerprint', async () => { + vi.useFakeTimers() + try { + listWorktreesStrictMock.mockRejectedValue(new Error('git unavailable')) + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('bounds Git fan-out under a sustained 1 Hz polling workload', async () => { + // Reproduces the reported steady state: 10 idle repos, a caller polling faster than the + // resolved-snapshot TTL. Before the gate this cost one `git worktree list` per repo per 30 s. + vi.useFakeTimers() + try { + const REPOS = 10 + const POLL_SECONDS = 30 * 60 + const { list } = makeRuntime({ repoCount: REPOS }) + + for (let second = 0; second < POLL_SECONDS; second += 1) { + await list() + vi.advanceTimersByTime(1_000) + } + + // One scan per repo per cache refresh: the TTL alone would refresh 60x per repo, the + // reconcile deadline refreshes 6x. 600 -> 60 `git worktree list` spawns per half hour. + const ttlOnlyScans = (REPOS * POLL_SECONDS * 1_000) / SCAN_TTL_MS + const reconcileScans = + (REPOS * POLL_SECONDS * 1_000) / WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS + expect(ttlOnlyScans).toBe(600) + expect(reconcileScans).toBe(60) + expect(scanCount()).toBe(reconcileScans) + } finally { + vi.useRealTimers() + } + }) + + it('shares one probe and one scan across concurrent callers', async () => { + const { list } = makeRuntime() + + await Promise.all([list(), list(), list()]) + + expect(scanCount()).toBe(1) + expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8c901236967..0f90182d053 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 @@ -3412,6 +3413,7 @@ export type PreloadApi = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string }) => Promise> subscribe: ( args: { @@ -3420,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 7a1d4030a32..58aec899f03 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 @@ -4427,6 +4428,7 @@ const api = { params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number + expectedRuntimeId?: string }): Promise> => ipcRenderer.invoke('runtimeEnvironments:call', args), subscribe: async ( @@ -4436,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/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/browser-pane/BrowserPane.remote-link-routing.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.remote-link-routing.test.ts new file mode 100644 index 00000000000..2ea6375f123 --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserPane.remote-link-routing.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +describe('remote browser link routing', () => { + it('pins context-menu opens to the runtime that owns the pane', () => { + const source = readFileSync( + fileURLToPath(new URL('./BrowserPane.tsx', import.meta.url)), + 'utf8' + ) + const paneStart = source.indexOf('function RemoteBrowserPagePane') + const actionStart = source.indexOf('void openWorkspaceBrowserTab({', paneStart) + const actionEnd = source.indexOf('}).catch((error) => {', actionStart) + const openRequest = source.slice(actionStart, actionEnd) + + expect(openRequest).toContain('workspaceId: worktreeId') + expect(openRequest).toContain('expectedRuntimeEnvironmentId: runtimeEnvironmentId') + }) +}) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 825b0ceb3e8..9158962d88c 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -2042,7 +2042,8 @@ function RemoteBrowserPagePane({ void openWorkspaceBrowserTab({ workspaceId: worktreeId, url: linkUrl, - intent: { kind: 'url' } + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: runtimeEnvironmentId }).catch((error) => { setPaneNotice({ kind: 'direct', 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