From 30724404d8de4c3da03b6830c71268266fdacd7e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 6 Nov 2026 02:03:00 -0800 Subject: [PATCH] test: remove 872 low-value PTY and shell tests (#38943) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .github/workflows/pr.yml | 2 - config/flaky-test-pattern-baseline.txt | 14 +- config/reliability-gates.jsonc | 101 +-- .../scripts/ci/sharding/ci-shard-timings.json | 21 - .../dependency-graph/ci-unit-files.mjs | 2 - .../vitest-node-runtime-files.mjs | 1 - .../project-groups/nested-repo-import.test.ts | 381 +------- .../managed-provider-process.test.ts | 266 ------ .../provider-group-orphan-guard.test.ts | 186 +--- ...der-process-supervisor.integration.test.ts | 200 +---- .../provider-process-teardown.test.ts | 178 +--- .../agent-foreground-process.test.ts | 683 --------------- src/main/providers/local-git-provider.test.ts | 348 -------- .../local-pty-child-process-verdict.test.ts | 104 --- .../local-pty-executable-spawn.test.ts | 279 +----- ...al-pty-provider-foreground-process.test.ts | 465 +--------- .../local-pty-provider-io-events.test.ts | 392 --------- ...local-pty-provider-shell-readiness.test.ts | 375 -------- .../local-pty-provider-shutdown.test.ts | 412 --------- ...ocal-pty-provider-spawn-cwd-safety.test.ts | 261 ------ .../local-pty-provider-spawn-env.test.ts | 655 -------------- .../local-pty-provider-spawn-session.test.ts | 309 ------- ...-pty-provider-windows-shell-launch.test.ts | 630 -------------- ...pty-shell-ready-wrapper-generation.test.ts | 357 +------- .../providers/macos-tcc-login-shell.test.ts | 622 +------------ src/main/providers/windows-shell-args.test.ts | 542 ------------ .../working-directory-validation.test.ts | 190 +--- .../windows-environment-path.test.ts | 513 ----------- .../pty/history/fish-history-session.test.ts | 132 --- .../pty/history/terminal-history-gc.test.ts | 256 +----- .../terminal-history-tombstone-retry.test.ts | 114 --- ...rminal-history-tombstone-scan-cost.test.ts | 270 ------ src/main/pty/history/terminal-history.test.ts | 731 ---------------- .../posix-pty-foreground-group.test.ts | 171 +--- .../posix-pty-process-groups.test.ts | 437 +--------- .../pty-descendant-termination.test.ts | 357 +------- .../pty-master-foreground-observation.test.ts | 236 +---- .../codex-shell-launch-preflight.test.ts | 239 +---- .../codex-shell-no-daemon.test.ts | 70 -- src/main/pty/shell-startup/git-bash.test.ts | 229 ----- .../legacy-terminal-shim-dir.test.ts | 527 +---------- .../omp-shell-wrapper.node-pty.test.ts | 167 ---- .../shell-prompt-readiness-probe.test.ts | 364 -------- .../shell-startup/shell-startup-env.test.ts | 547 ------------ .../shell-startup-feature-channel.test.ts | 395 --------- .../pty/shell-startup/wsl-orca-env.test.ts | 305 ------- ...erred-startup-line-init.live-shell.test.ts | 344 -------- .../zsh-scoped-histfile.live-shell.test.ts | 272 +----- ...user-config-equivalence.live-shell.test.ts | 300 ------- .../antigravity-usage-fetcher.test.ts | 429 +-------- .../antigravity-usage-response.test.ts | 294 ------- .../claude-oauth-usage-extra-usage.test.ts | 302 ------- .../rate-limits/claude-profile-usage.test.ts | 329 ------- .../rate-limits/codex-fetcher-backend.test.ts | 353 -------- .../codex-fetcher-probe-shutdown.test.ts | 382 -------- src/main/rate-limits/codex-fetcher.test.ts | 818 ------------------ src/main/rate-limits/cursor-auth.test.ts | 227 ----- .../cursor-current-period-usage.test.ts | 282 ------ src/main/rate-limits/cursor-fetcher.test.ts | 241 ------ 59 files changed, 53 insertions(+), 18556 deletions(-) delete mode 100644 src/main/providers/local-git-provider.test.ts delete mode 100644 src/main/providers/local-pty/local-pty-provider-io-events.test.ts delete mode 100644 src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts delete mode 100644 src/main/providers/local-pty/local-pty-provider-spawn-cwd-safety.test.ts delete mode 100644 src/main/providers/local-pty/local-pty-provider-spawn-env.test.ts delete mode 100644 src/main/providers/windows-shell-args.test.ts delete mode 100644 src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts delete mode 100644 src/main/pty/history/terminal-history.test.ts delete mode 100644 src/main/pty/shell-startup/git-bash.test.ts delete mode 100644 src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts delete mode 100644 src/main/pty/shell-startup/shell-startup-env.test.ts delete mode 100644 src/main/pty/shell-startup/shell-startup-feature-channel.test.ts delete mode 100644 src/main/pty/shell-startup/wsl-orca-env.test.ts delete mode 100644 src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts delete mode 100644 src/main/rate-limits/antigravity-usage-response.test.ts delete mode 100644 src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts delete mode 100644 src/main/rate-limits/claude-profile-usage.test.ts delete mode 100644 src/main/rate-limits/codex-fetcher-backend.test.ts delete mode 100644 src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts delete mode 100644 src/main/rate-limits/codex-fetcher.test.ts delete mode 100644 src/main/rate-limits/cursor-auth.test.ts delete mode 100644 src/main/rate-limits/cursor-current-period-usage.test.ts delete mode 100644 src/main/rate-limits/cursor-fetcher.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c32ca066dc2..5d8b0fffc8c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -861,8 +861,6 @@ jobs: src/main/pty/shell-startup/omp-shell-wrapper-alias-safety.test.ts \ src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts \ src/main/pty/shell-startup/fish-xdg-data-dirs-handoff.test.ts \ - src/main/pty/shell-startup/shell-startup-feature-channel.test.ts \ - src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts \ src/main/pty/history/terminal-history-fish-session.node-pty.test.ts \ src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts \ src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 4bb4907522e..70eff1519ad 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -18,10 +18,7 @@ 1 test-no-in-test-import-actual:src/main/linear/linear-workspace-registry.test.ts 1 test-no-in-test-import-actual:src/main/omp/omp-session-cwd-lookup.test.ts 1 test-no-in-test-import-actual:src/main/orcad/electron-serve-provider-selection.test.ts -1 test-no-in-test-import-actual:src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts 1 test-no-in-test-import-actual:src/main/pty/scrollback/overlay-mirror.test.ts -1 test-no-in-test-import-actual:src/main/pty/shell-startup/legacy-terminal-shim-dir.test.ts -1 test-no-in-test-import-actual:src/main/rate-limits/claude-profile-usage.test.ts 1 test-no-in-test-import-actual:src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts 1 test-no-in-test-import-actual:src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts 1 test-no-in-test-import-actual:src/main/runtime/rpc/server/pairing/mobile-pairing-userdata-path.test.ts @@ -71,7 +68,6 @@ 1 test-no-polling-loop:src/main/provider-process/provider-process-supervisor.integration.test.ts 1 test-no-polling-loop:src/main/providers/startup-line-typed-length.live-shell.test.ts 1 test-no-polling-loop:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts -1 test-no-polling-loop:src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts 1 test-no-polling-loop:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts 1 test-no-polling-loop:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts 1 test-no-polling-loop:src/main/runtime/agent-session/environment/structured-agent-shell-environment.test.ts @@ -264,11 +260,10 @@ 6 test-no-real-time-wait:src/main/opencode/hook-plugin-module-contract.test.ts 4 test-no-real-time-wait:src/main/orcad/orcad-stop-request-listener.test.ts 1 test-no-real-time-wait:src/main/provider-process/provider-group-orphan-guard.integration.test.ts -3 test-no-real-time-wait:src/main/provider-process/provider-process-supervisor.integration.test.ts +2 test-no-real-time-wait:src/main/provider-process/provider-process-supervisor.integration.test.ts 1 test-no-real-time-wait:src/main/providers/agent-foreground-process-git-bash.win32.test.ts 2 test-no-real-time-wait:src/main/providers/startup-line-typed-length.live-shell.test.ts 2 test-no-real-time-wait:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts -2 test-no-real-time-wait:src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts 1 test-no-real-time-wait:src/main/pty/process-retirement/node-pty-master-fd-retirement.test.ts 1 test-no-real-time-wait:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts 5 test-no-real-time-wait:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts @@ -673,18 +668,13 @@ 2 test-no-wall-clock:src/main/persistence/tracking-repos/persistence-deregistered-repo-residue.test.ts 1 test-no-wall-clock:src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts 2 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.integration.test.ts -1 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.test.ts -18 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts -3 test-no-wall-clock:src/main/provider-process/provider-process-teardown.test.ts +4 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts 2 test-no-wall-clock:src/main/providers/startup-line-typed-length.live-shell.test.ts -1 test-no-wall-clock:src/main/pty/history/fish-history-session.test.ts 2 test-no-wall-clock:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts 1 test-no-wall-clock:src/main/pty/history/terminal-history-gc-fs-call-count.test.ts 2 test-no-wall-clock:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts 4 test-no-wall-clock:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts 1 test-no-wall-clock:src/main/rate-limits/claude-oauth-usage-error.test.ts -2 test-no-wall-clock:src/main/rate-limits/claude-profile-usage.test.ts -1 test-no-wall-clock:src/main/rate-limits/cursor-current-period-usage.test.ts 1 test-no-wall-clock:src/main/rate-limits/kimi-fetcher-wsl-home.test.ts 1 test-no-wall-clock:src/main/rate-limits/opencode-go-usage-source-selection.test.ts 43 test-no-wall-clock:src/main/rate-limits/service-account-target-selection.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cd0665f46f8..9669fd0dd7d 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2931,7 +2931,7 @@ "providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": ["local", "local-daemon", "ssh-relay", "paired-runtime"], - "coverageNotes": "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: 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. Ubuntu 20.04/Bash 5.0 and Ubuntu 24.04/Bash 5.2 containers cover scalar and array PROMPT_COMMAND composition, including real zoxide on 24.04. 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.", + "coverageNotes": "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: 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. Ubuntu 20.04/Bash 5.0 and Ubuntu 24.04/Bash 5.2 containers cover scalar and array PROMPT_COMMAND composition, including real zoxide on 24.04. 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-4067", @@ -2942,10 +2942,10 @@ "https://github.com/stablyai/orca/pull/14451" ], "invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Bash scalar and supported array PROMPT_COMMAND values preserve user bytes, hook order, and foreground exit status while emitting exactly one OSC 133 A/C/D lifecycle without syntax errors. 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. Exercise Bash scalar and array PROMPT_COMMAND values with empty, repeated, escaped, whitespace, control-byte, odd-backslash, status-mutating, DEBUG-trap, zoxide, and RHEL-style hooks; require exact A,C,D:0,A,C,D:1,A,C ordering and byte preservation. 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.", + "oracle": "Audit1683 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source 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. Exercise Bash scalar and array PROMPT_COMMAND values with empty, repeated, escaped, whitespace, control-byte, odd-backslash, status-mutating, DEBUG-trap, zoxide, and RHEL-style hooks; require exact A,C,D:0,A,C,D:1,A,C ordering and byte preservation. 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/shell-readiness/testing/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/shell-readiness/testing/bash-prompt-command-composition.test.ts src/main/pty/shell-startup/line-editor-ready-output-scanner.test.ts src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts src/main/pty/shell-startup/shell-startup-output-scanner.test.ts src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts src/shared/terminal/pty/native-process/shell-process-readiness.test.ts src/main/daemon/sessions/testing/session.test.ts src/main/daemon/shell-readiness/testing/shell-ready.test.ts src/main/providers/local-pty/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/shell-readiness/testing/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/pty/shell-startup/line-editor-ready-output-scanner.test.ts src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts src/main/pty/shell-startup/shell-startup-output-scanner.test.ts src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts src/shared/terminal/pty/native-process/shell-process-readiness.test.ts src/main/daemon/sessions/testing/session.test.ts src/main/daemon/shell-readiness/testing/shell-ready.test.ts src/main/providers/local-pty/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/shell-readiness/testing/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/shell-readiness/testing/bash-prompt-command-composition.test.ts src/main/pty/shell-startup/line-editor-ready-output-scanner.test.ts src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts src/main/pty/shell-startup/shell-startup-output-scanner.test.ts src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts src/shared/terminal/pty/native-process/shell-process-readiness.test.ts src/main/daemon/sessions/testing/session.test.ts src/main/daemon/shell-readiness/testing/shell-ready.test.ts src/main/providers/local-pty/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/shell-readiness/testing/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/pty/shell-startup/line-editor-ready-output-scanner.test.ts src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts src/main/pty/shell-startup/shell-startup-output-scanner.test.ts src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts src/shared/terminal/pty/native-process/shell-process-readiness.test.ts src/main/daemon/sessions/testing/session.test.ts src/main/daemon/shell-readiness/testing/shell-ready.test.ts src/main/providers/local-pty/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-readiness/testing/shell-ready.test.ts src/main/pty/shell-startup/shell-startup-output-scanner.test.ts", "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" ], @@ -2955,7 +2955,6 @@ "src/main/pty/shell-startup/line-editor-ready-output-scanner.test.ts", "src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts", "src/main/pty/shell-startup/shell-startup-output-scanner.test.ts", - "src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts", "src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts", "src/shared/terminal/pty/native-process/shell-process-readiness.test.ts", "src/main/daemon/sessions/testing/session.test.ts", @@ -2963,7 +2962,6 @@ "src/main/providers/local-pty/local-pty-shell-ready-marker-scan.test.ts", "src/main/providers/local-pty/local-pty-shell-ready-startup-command.test.ts", "src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts", - "src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts", "tests/e2e/paired-startup-exec-readiness.spec.ts" ], "assertionRefs": [ @@ -2976,10 +2974,6 @@ "incomplete scanner prefixes drain in byte order" ] }, - { - "file": "src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts", - "assertions": ["local PTY output is byte-exact for both Fish chunk splits"] - }, { "file": "tests/e2e/paired-startup-exec-readiness.spec.ts", "assertions": [ @@ -3021,6 +3015,7 @@ "Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green." ], "knownGaps": [ + "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "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.", @@ -6784,10 +6779,10 @@ "providers": ["local", "daemon", "ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "remote-runtime"], - "coverageNotes": "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Audit1417 intentionally deletes src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts: Loses rich hidden snapshot sequence replay/grid resize and forced repaint after hidden snapshot alarms, including live-trigger deduplication. No equivalent replacement coverage or fresh result is claimed. Historical coverage account: Audit1320 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Deterministic host/parser, process-classifier, snapshot-publication, mixed-version, renderer, and macOS Electron coverage proves the local and shared paired-runtime contracts. Direct SSH intentionally publishes no shell proof and immediately preserves incumbent behavior. WSL, Windows ConPTY, Linux desktop, and paired headed/headless journeys remain gaps. The audit removed the isolated terminal-multiplex pending-escape-tail forwarding test. Its direct RPC snapshot field-copy alarm is lost; retained parser, snapshot and lifecycle suites do not establish equivalent RPC field coverage. Audit1320: src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts removed. Snapshot pending escape/alternate-screen/shell metadata forwarding and negative app-store-read alarm disappears. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1391: src/renderer/src/components/terminal-pane/pty-connection-remote-pause-drop-notice.spec.ts reduced. Lose repeated quiet switches avoiding snapshots and legacy host without drop-report capability requesting a snapshot. Retained imported registrar uses fake transport/xterm writes, exercising real gap/restore lifecycle rather than actual terminal rendering. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1402: src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts reduced. Loses basic paired serialize/initial replay forwarding check; hidden alt-frame grid/precedence policy remains. No real terminal renderer claim. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1412: src/main/daemon/terminal-host-concurrent-create.test.ts reduced. Loses race deduplication, attach during unsettled ownership proof, retry after failed spawn, distinct-session parallelism, disposal waiting and shutdown retry-fencing alarms bundled with removed internal-call counts or real-time waits. No replacement-equivalence claim. src/main/daemon/terminal-checkpoint-serializer.test.ts reduced. Loses bounded native conversion/no-whole-candidate materialization and snapshot getter single-read alarms. Retained byte-size outcomes do not establish CPU/allocation bounds. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", + "coverageNotes": "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Audit1417 intentionally deletes src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts: Loses rich hidden snapshot sequence replay/grid resize and forced repaint after hidden snapshot alarms, including live-trigger deduplication. No equivalent replacement coverage or fresh result is claimed. Historical coverage account: Audit1320 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Deterministic host/parser, process-classifier, snapshot-publication, mixed-version, renderer, and macOS Electron coverage proves the local and shared paired-runtime contracts. Direct SSH intentionally publishes no shell proof and immediately preserves incumbent behavior. WSL, Windows ConPTY, Linux desktop, and paired headed/headless journeys remain gaps. The audit removed the isolated terminal-multiplex pending-escape-tail forwarding test. Its direct RPC snapshot field-copy alarm is lost; retained parser, snapshot and lifecycle suites do not establish equivalent RPC field coverage. Audit1320: src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts removed. Snapshot pending escape/alternate-screen/shell metadata forwarding and negative app-store-read alarm disappears. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1391: src/renderer/src/components/terminal-pane/pty-connection-remote-pause-drop-notice.spec.ts reduced. Lose repeated quiet switches avoiding snapshots and legacy host without drop-report capability requesting a snapshot. Retained imported registrar uses fake transport/xterm writes, exercising real gap/restore lifecycle rather than actual terminal rendering. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1402: src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts reduced. Loses basic paired serialize/initial replay forwarding check; hidden alt-frame grid/precedence policy remains. No real terminal renderer claim. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1412: src/main/daemon/terminal-host-concurrent-create.test.ts reduced. Loses race deduplication, attach during unsettled ownership proof, retry after failed spawn, distinct-session parallelism, disposal waiting and shutdown retry-fencing alarms bundled with removed internal-call counts or real-time waits. No replacement-equivalence claim. src/main/daemon/terminal-checkpoint-serializer.test.ts reduced. Loses bounded native conversion/no-whole-candidate materialization and snapshot getter single-read alarms. Retained byte-size outcomes do not establish CPU/allocation bounds. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/16379"], "invariant": "After a TUI exits or is killed, reveal, reattach, snapshot replay, or renderer remount must not deliver terminal-owned mouse or alternate-screen protocol bytes to the surviving shell. Recovery is an ordered output barrier in the daemon session data path: an OSC 133;D completing while the alternate screen is still active pauses the stream at that exact byte boundary, a fresh execution-host process inspection proves shell ownership, and on proof a mode reset is injected as in-stream output so every consumer converges by parsing the same bytes and the queued post-boundary shell output (the prompt) lands on the normal buffer. Snapshots are pure reads. Any failure — refuted proof, timeout, queue overflow, session death, disposal — flushes the queue unmodified, preserving incumbent behavior; later command or mode bytes revoke proof. Clean alternate-screen exits prove ownership asynchronously without pausing.", - "oracle": "Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Historical pre-audit oracle account for changed sources: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: The retained unchanged-source receipts are the only current execution evidence; reduced-source assertions await validation. Historical oracle account (not a current claim for changed sources): Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.", + "oracle": "Audit1683 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Historical pre-audit oracle account for changed sources: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: The retained unchanged-source receipts are the only current execution evidence; reduced-source assertions await validation. Historical oracle account (not a current claim for changed sources): Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/shell-readiness/testing/terminal-shell-recovery-barrier.test.ts src/main/daemon/sessions/testing/session-shell-recovery.test.ts src/main/daemon/sessions/testing/session.test.ts src/main/daemon/terminal-host/testing/terminal-host-concurrent-create.test.ts src/main/daemon/providers/adapter/testing/daemon-pty-adapter.test.ts src/main/daemon/history/restoration/testing/daemon-restore-scrollback-depth.test.ts src/main/daemon/history/restoration/testing/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-presence-lock.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/shared/terminal/protocol/escape/terminal-partial-escape-tail.test.ts src/shared/terminal/protocol/escape/terminal-partial-escape-tail.fuzz.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot", @@ -6872,6 +6867,7 @@ "Keep missing-proof mixed-version behavior identical to the incumbent client path." ], "knownGaps": [ + "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "A zero-width injected reset does not advance outputSequence, so the mobile renderer-snapshot stability oracle cannot detect a reset landing inside its serialize round-trip; the resulting tear is transient and self-heals on the next repaint (same exposure as the pre-existing device-attributes release emission).", @@ -7301,7 +7297,7 @@ "providers": ["local", "daemon", "ssh", "runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh", "runtime"], - "coverageNotes": "Audit1674 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability and same-incarnation handle continuity; persisted-empty restart suppression remains historical Electron evidence, alongside replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. The retained paired journey covers the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId. The repeated target-after-unrelated-terminal journey was retired; its separate ordering alarm is no longer provided. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here. The synthetic default-tabs unit suite was retired: its explicit-empty/default-tab assertions no longer supply unit evidence. This retirement supplies no new persisted-restart, runtime-routing, cross-platform, or mobile evidence; existing historical runs and counts remain historical. The test-value audit removes the isolated re-retirement-token/epoch, activity-cutoff, idle-reattach and sibling-restore examples. The cited exact retirement and chained-detach cases remain unchanged, alongside closed-tab revival fencing and alias churn bounds. Their retained evidence is not equivalent coverage for every removed permutation; historical execution evidence is unchanged. The test-value audit removed shared-owner snapshot/cold-restore reference examples, a direct absent-tab PTY-index update, caller-supplied host-owned teardown boolean/no-dispatch, and a single local-provider rejection example. It also removed hand-built detach-retention comparator examples for different runs, handles, provider identities, and handle-less rows without independent identity ingress. Their specific snapshot, missing-tab, host-decision, rejection-count, and comparator alarms are lost. Current exact authority/ordering and chained-detach cases remain retained; they do not establish equivalent coverage for the removed examples. Historical commands, counts, results, and red-green narratives remain historical. The test-value audit retires terminals-explicit-empty-hydration.test.ts in full: its direct git/folder empty-tombstone assertions and literal-flag auto-create helper check are lost. The store test never mounted the initial-terminal effect or performed a persisted restart. The audit also removes terminal-tab-retirement.test.ts pure ownership/alias/routing plan inventories and sleeping-record map assertions; its former 100-tab scan-count case was subsequently retired too; no assertion remains in that suite. These retained and historical cases do not establish equivalent tombstone, default-create, provider teardown, cross-platform, or mobile coverage. Historical commands, results, counts, and red-green narratives remain historical. The subsequent test-value audit deletes the remaining terminal-tab-retirement.test.ts suite. Its 100-tab case asserted only returned plan count and one outer scan per ownership store; it never inspected per-tab plans or provider teardown. Those direct cardinality/work-count alarms are intentionally lost. Historical executed commands, results, counts and red-green narratives remain historical; no equivalent replacement or new runtime, platform or mobile evidence is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1392: src/main/agent-hooks/server-pane-authority.test.ts reduced. Retires one duplicate-transfer no-op listener count and latest pty/timestamp callback fixture. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", + "coverageNotes": "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1674 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability and same-incarnation handle continuity; persisted-empty restart suppression remains historical Electron evidence, alongside replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. The retained paired journey covers the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId. The repeated target-after-unrelated-terminal journey was retired; its separate ordering alarm is no longer provided. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here. The synthetic default-tabs unit suite was retired: its explicit-empty/default-tab assertions no longer supply unit evidence. This retirement supplies no new persisted-restart, runtime-routing, cross-platform, or mobile evidence; existing historical runs and counts remain historical. The test-value audit removes the isolated re-retirement-token/epoch, activity-cutoff, idle-reattach and sibling-restore examples. The cited exact retirement and chained-detach cases remain unchanged, alongside closed-tab revival fencing and alias churn bounds. Their retained evidence is not equivalent coverage for every removed permutation; historical execution evidence is unchanged. The test-value audit removed shared-owner snapshot/cold-restore reference examples, a direct absent-tab PTY-index update, caller-supplied host-owned teardown boolean/no-dispatch, and a single local-provider rejection example. It also removed hand-built detach-retention comparator examples for different runs, handles, provider identities, and handle-less rows without independent identity ingress. Their specific snapshot, missing-tab, host-decision, rejection-count, and comparator alarms are lost. Current exact authority/ordering and chained-detach cases remain retained; they do not establish equivalent coverage for the removed examples. Historical commands, counts, results, and red-green narratives remain historical. The test-value audit retires terminals-explicit-empty-hydration.test.ts in full: its direct git/folder empty-tombstone assertions and literal-flag auto-create helper check are lost. The store test never mounted the initial-terminal effect or performed a persisted restart. The audit also removes terminal-tab-retirement.test.ts pure ownership/alias/routing plan inventories and sleeping-record map assertions; its former 100-tab scan-count case was subsequently retired too; no assertion remains in that suite. These retained and historical cases do not establish equivalent tombstone, default-create, provider teardown, cross-platform, or mobile coverage. Historical commands, results, counts, and red-green narratives remain historical. The subsequent test-value audit deletes the remaining terminal-tab-retirement.test.ts suite. Its 100-tab case asserted only returned plan count and one outer scan per ownership store; it never inspected per-tab plans or provider teardown. Those direct cardinality/work-count alarms are intentionally lost. Historical executed commands, results, counts and red-green narratives remain historical; no equivalent replacement or new runtime, platform or mobile evidence is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1392: src/main/agent-hooks/server-pane-authority.test.ts reduced. Retires one duplicate-transfer no-op listener count and latest pty/timestamp callback fixture. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-4327", "https://github.com/stablyai/orca/pull/14590", @@ -7310,7 +7306,7 @@ "https://github.com/stablyai/orca/issues/14524" ], "invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close commits durable tab retirement before PTY stop can publish graph loss, does not acknowledge before that retirement, and treats the later exit retirement as idempotent. Workspace-wide CLI close applies that same durable contract to every terminal in exactly one worktree, including pinned and persisted-only surfaces, without touching sibling-worktree PTYs or resume records. A persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", - "oracle": "Audit1674 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. terminal.closeAll must force-retire every pinned and unpinned target surface, clear its persisted agent-resume and incarnation records, await authoritative PTY exit, preserve a sibling worktree's tab, PTY, and resume authority, and return an unverifiable failure instead of claiming exit when the owning host does not confirm a stop. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.", + "oracle": "Audit1683 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Audit1674 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. terminal.closeAll must force-retire every pinned and unpinned target surface, clear its persisted agent-resume and incarnation records, await authoritative PTY exit, preserve a sibling worktree's tab, PTY, and resume authority, and return an unverifiable failure instead of claiming exit when the owning host does not confirm a stop. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal/surface/orca-runtime-terminal-close-continuity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", @@ -7409,17 +7405,6 @@ "chained detach keeps physical hooks and resume authority routed to the current owner until that owner closes" ] }, - { - "file": "src/main/pty/process-retirement/pty-descendant-termination.test.ts", - "assertions": [ - "20 same-turn process-table requests execute one fresh scan while later arrivals start one shared successor inside their own deadline", - "snapshot and escalation readers stop at their deadline", - "production ps reads force locale-independent C timestamps", - "the source scan timestamp survives request resolution and capture-second identities are never escalated with SIGKILL", - "cyclic-looking duplicate PID rows terminate with each descendant visited once and duplicate escalation identities stay unsignalled", - "descendant signals are suppressed after the caller loses root ownership" - ] - }, { "file": "src/main/runtime/workspaces/removal/worktree-teardown.test.ts", "assertions": [ @@ -7514,6 +7499,7 @@ "Add restart/no-resurrection and repeated park-close soak coverage." ], "knownGaps": [ + "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1674 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1630 removes low-value Git, GitHub/GitLab and IPC projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", @@ -7746,7 +7732,7 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": [], - "coverageNotes": "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1396 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Real Bash and Zsh node-pty tests replace the active cwd inode and exercise the shared POSIX OMP wrapper; audit829 removes generated-file snapshots and their current byte-parity claim. No equivalent replacement for generated-script byte checks is claimed. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the spawn probe and lack a live run; native PowerShell is unaffected; Linux/Windows and mobile/API strictness remain gaps. Audit1396: src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.spec.ts reduced. Lose standalone debug-state retention/log silence alarm. React StrictMode is not mounted; bindings manually simulate remount over mocked transport. No equivalent replacement coverage is claimed.", + "coverageNotes": "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1396 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Real Bash and Zsh node-pty tests replace the active cwd inode and exercise the shared POSIX OMP wrapper; audit829 removes generated-file snapshots and their current byte-parity claim. No equivalent replacement for generated-script byte checks is claimed. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the spawn probe and lack a live run; native PowerShell is unaffected; Linux/Windows and mobile/API strictness remain gaps. Audit1396: src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.spec.ts reduced. Lose standalone debug-state retention/log silence alarm. React StrictMode is not mounted; bindings manually simulate remount over mocked transport. No equivalent replacement coverage is claimed.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/7239", "https://github.com/stablyai/orca/pull/7750", @@ -7755,7 +7741,7 @@ "https://github.com/stablyai/orca/pull/17128" ], "invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics. A long-lived POSIX shell whose cwd inode was deleted and replaced may run a wrapped OMP command from the live path named by its logical PWD only in a subshell; the parent shell cwd, OMP argv, status extension, and exit status remain unchanged, while a genuinely unavailable path fails visibly without invoking OMP. A usable physical cwd remains authoritative when PWD is unset or names a removed logical symlink instead of being remapped to a workspace fallback.", - "oracle": "The retained unchanged-source receipts are the only current execution evidence; reduced-source assertions await validation. Historical oracle account (not a current claim for changed sources): The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path. In real interactive Bash and Zsh PTYs, unset PWD and remove a logical symlink while the physical cwd stays usable, requiring OMP to remain there; then delete and recreate the active project path, require extension-enabled and extension-skipping invocations to observe the replacement inode with byte-exact argv and nonzero status preserved, require the parent shell to remain on the stale inode, unset PWD and require recovery through ORCA_WORKTREE_PATH, clear every logical fallback and require an actionable failure without invoking OMP, and finally delete the replacement and require the same for the missing PWD path.", + "oracle": "Audit1683 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: The retained unchanged-source receipts are the only current execution evidence; reduced-source assertions await validation. Historical oracle account (not a current claim for changed sources): The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path. In real interactive Bash and Zsh PTYs, unset PWD and remove a logical symlink while the physical cwd stays usable, requiring OMP to remain there; then delete and recreate the active project path, require extension-enabled and extension-skipping invocations to observe the replacement inode with byte-exact argv and nonzero status preserved, require the parent shell to remain on the stale inode, unset PWD and require recovery through ORCA_WORKTREE_PATH, clear every logical fallback and require an actionable failure without invoking OMP, and finally delete the replacement and require the same for the missing PWD path.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/shared/shell/launch/terminal-startup-cwd.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/pty-spawn-cwd-fallback.test.ts", @@ -7797,19 +7783,6 @@ "startup cwd fallback metadata prints a generic in-terminal notice", "remote-runtime worktree spawns are not marked with cwdFallback" ] - }, - { - "file": "src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts", - "assertions": [ - "real Bash and Zsh PTYs rebind OMP to a recreated cwd inode without changing the parent shell", - "an unset PWD with a usable cwd stays unset and does not remap OMP to ORCA_WORKTREE_PATH", - "a removed logical symlink does not invalidate its still-usable physical cwd", - "extension-skipping commands such as omp --version still recover the cwd without gaining extension argv", - "an unset PWD on a stale inode recovers through ORCA_WORKTREE_PATH", - "an unset PWD on a stale inode without a logical fallback fails visibly before invoking OMP", - "extension argv and a nonzero OMP exit status survive the recovery subshell", - "a subsequently missing logical cwd prints an actionable error and does not invoke OMP again" - ] } ], "evidenceRuns": [ @@ -7831,16 +7804,6 @@ "result": "passed", "durationSeconds": 0.34, "summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff." - }, - { - "date": "2026-08-29", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.node-pty.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts", - "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts src/main/pty/shell-startup/omp-shell-wrapper-alias-safety.test.ts", - "result": "passed", - "durationSeconds": 3, - "summary": "3 test files passed; real Bash and Zsh PTYs covered usable physical cwd authority, stale-inode recovery with set and unset PWD, extension-enabled and extension-skipping argv, parent-shell isolation, exit status, missing-path visibility, alias safety, and generated local/daemon/relay parity." } ], "runtimeBudget": { @@ -7866,6 +7829,7 @@ "Add WSL/mobile/API spawn provider-contract coverage or explicitly narrow their risk scope." ], "knownGaps": [ + "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "No live Electron fixture seeds a persisted tab whose startupCwd directory was deleted.", "Daemon coverage is via the shared pre-provider main cwd decision, not a live daemon provider spawn.", @@ -8193,7 +8157,7 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "remote-runtime"], - "coverageNotes": "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Audit1417 intentionally deletes src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts: Loses rich hidden snapshot sequence replay/grid resize and forced repaint after hidden snapshot alarms, including live-trigger deduplication. No equivalent replacement coverage or fresh result is claimed. Historical coverage account: Renderer ownership/dedupe contracts cover provider-session claims. Local and daemon attach-only contracts prove an existing stable-pane owner is adopted without provider creation, while remote-runtime transport contracts preserve adopted ownership through cancellation. The Electron oracle covers a local macOS runtime and daemon with real agent, Setup, and unrelated-canary processes; SSH, WSL, paired-server, Linux, and Windows remain contract-only or unrun. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1391: src/renderer/src/components/terminal-pane/pty-connection-remote-pause-drop-notice.spec.ts reduced. Lose repeated quiet switches avoiding snapshots and legacy host without drop-report capability requesting a snapshot. Retained imported registrar uses fake transport/xterm writes, exercising real gap/restore lifecycle rather than actual terminal rendering. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", + "coverageNotes": "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Audit1417 intentionally deletes src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts: Loses rich hidden snapshot sequence replay/grid resize and forced repaint after hidden snapshot alarms, including live-trigger deduplication. No equivalent replacement coverage or fresh result is claimed. Historical coverage account: Renderer ownership/dedupe contracts cover provider-session claims. Local and daemon attach-only contracts prove an existing stable-pane owner is adopted without provider creation, while remote-runtime transport contracts preserve adopted ownership through cancellation. The Electron oracle covers a local macOS runtime and daemon with real agent, Setup, and unrelated-canary processes; SSH, WSL, paired-server, Linux, and Windows remain contract-only or unrun. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1391: src/renderer/src/components/terminal-pane/pty-connection-remote-pause-drop-notice.spec.ts reduced. Lose repeated quiet switches avoiding snapshots and legacy host without drop-report capability requesting a snapshot. Retained imported registrar uses fake transport/xterm writes, exercising real gap/restore lifecycle rather than actual terminal rendering. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6800", "https://github.com/stablyai/orca/pull/5240", @@ -8203,7 +8167,7 @@ "https://github.com/stablyai/orca/pull/11819" ], "invariant": "Workspace activation, launch, restore, sleep, hibernate, dedupe, clearing, mount, remount, and reconnect code must not replay or resume a provider session id already owned, queued, pending, live, or durably bound to a host PTY in that workspace. A renderer with missing projection state must adopt the exact runtime-owned PTY for the original tab and leaf rather than create a replacement.", - "oracle": "Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Historical pre-audit oracle account for changed sources: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", + "oracle": "Audit1683 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Retained exact unchanged-source receipts remain the only current execution evidence. Reduced selectors have no fresh execution result. Historical pre-audit oracle account for changed sources: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", @@ -8322,6 +8286,7 @@ "Record byte-identical latest-main, candidate, and candidate-revert Electron results." ], "knownGaps": [ + "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", @@ -11687,10 +11652,10 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon"], - "coverageNotes": "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Runtime and provider-contract tests on macOS cover exact-PTY authorization, verified managed-Claude launch identity, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal when no verified launch identity exists. Protocol-version constant inventories are excluded from handshake and foreground-confirmation evidence. Audit1312 removes low-value imported specs from aggregate test drivers; old whole-driver execution receipts are archived. Retained registrations are unchanged. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", + "coverageNotes": "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Runtime and provider-contract tests on macOS cover exact-PTY authorization, verified managed-Claude launch identity, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal when no verified launch identity exists. Protocol-version constant inventories are excluded from handshake and foreground-confirmation evidence. Audit1312 removes low-value imported specs from aggregate test drivers; old whole-driver execution receipts are archived. Retained registrations are unchanged. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", "motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"], "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; a generic busy title requires independent agent identity from the title, a verified managed launch bound to the current PTY incarnation, or the foreground process; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", - "oracle": "Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "oracle": "Audit1683 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess/testing/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/daemon/pty-subprocess/testing/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts" @@ -11723,17 +11688,6 @@ "the callback's actual PTY must equal the handle binding before status can authorize a write" ] }, - { - "file": "src/main/providers/agent-foreground-process.test.ts", - "assertions": [ - "recognized Windows candidates require exact ConPTY membership and detached descendants are rejected", - "unavailable process inspection is distinct from authoritative shell evidence" - ] - }, - { - "file": "src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts", - "assertions": ["fresh confirmation is discarded when its owning local PTY exits"] - }, { "file": "src/main/providers/windows-console-attached-processes.test.ts", "assertions": [ @@ -11779,6 +11733,7 @@ "Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal." ], "knownGaps": [ + "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.", @@ -14515,14 +14470,14 @@ "providers": ["local", "remote-runtime", "ssh"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": ["local", "remote-runtime", "ssh"], - "coverageNotes": "Audit1645 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1634 removes low-value IPC, filesystem and runtime projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1516 removes package-electron-runtime-contract.test.mjs, a copied release-workflow platform and marker matrix. Its workflow wiring alarm is intentionally retired; no actual packaged watcher delivery was exercised and no equivalent replacement is claimed. Unaffected dated execution receipts retain their original scope; changed optional selectors have no fresh full-gate execution result. This audit removes the paired-web stopped-during-outage and late-ready direct-branch examples. The retained pre-ready cancellation, failed-unwatch retry and replay-continuity cases are not equivalent replacements for those ordering alarms. Current web-runtime-client.test.ts assertions below name only its retained pre-ready callback case; replay cases remain in their separate suite. Historical commands and results remain historical. Deterministic tests cover one shared healthy child per runtime process plus at most four bounded fault-quarantine children, native desktop, paired-runtime, and WSL snapshot processes sharing the same eight-physical-child reservation with typed serialized event-driven capacity recovery, including recursive re-wait when crash recovery reclaims an announced slot, one quarantine attempt per watch lifetime, generation-scoped cancellation, bounded termination with removable deadline waiters, replacement-crawl errors that cannot be blessed by a late readiness ack, pre-ready paired-web cancellation registered before an unbounded capacity wait, already-resolved and late physical-exit retry of a rejected paired-web teardown, physical desktop-install and local/SSH PTY teardown, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. Destructive local and SSH removal fences both ID-derived and resolved-cwd terminal roots, including sibling-root/cwd combinations with reverse admission rollback, closes descendant relay watches before parent deletion, enumerates authoritative provider/cwd ownership, falls back to daemon spawn cwd before OSC 7, and keeps daemon/relay immediate shutdown pending until native exit or a bounded fail-closed error; a dead relay PID discovered during attach also settles concurrent shutdown before its stale entry is reaped, while Windows runtime watcher deadlines retain one removable physical-close waiter, treat Node's error-close path as positive physical-exit proof, and clear root ownership on late close. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. Abortable capacity, quarantine, runtime-root, relay, SSH, and relay pre-install setup waits attach one reaction to each shared promise and explicitly remove 10,000 cancelled caller closures while one anchor remains. Built-entry macOS harnesses kill the desktop/runtime and SSH relay watcher children, then require automatic resubscription and later events while the host process and relay PTY survive. SSH bundles the child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. The test-value audit corrects the active renderer watch command/file reference to the existing runtime-file-client-watch.test.ts, whose three cases are retained unchanged. They observe synthetic subscription retry/fanout and unsubscribe/unwatch calls, not actual preload/main bookkeeping or physical watcher-process cleanup. The separate read/preview client suite loses forwarding, supplied-error and negative-only examples, retaining binary detection and Unicode SVG decoding. Historical evidence commands/results are unchanged. Test-value audit1293 removes src/main/providers/local-pty/local-pty-provider-session-inventory.test.ts (2 authored cases). Their isolated assertions no longer run; surviving tests are not claimed as equivalent coverage. Fresh execution against the candidate SHA is required. Audit1312 removes low-value imported specs from aggregate test drivers; old whole-driver execution receipts are archived. Retained registrations are unchanged. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1412: src/main/workspaces/removal/watcher-removal-gate.test.ts reduced. Loses exact removal-error compatibility with renderer predicate under shared message constants. Kept public refusal outcomes do not establish that renderer classification. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", + "coverageNotes": "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1645 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1634 removes low-value IPC, filesystem and runtime projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1627 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1623 removes low-value mocked daemon projections, scalar tables and duplicate terminal examples without equivalent replacement coverage. Per-source reviews record retained terminal content preservation, process ownership refusal and recovery boundaries together with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded. Historical scope before this audit: Audit1516 removes package-electron-runtime-contract.test.mjs, a copied release-workflow platform and marker matrix. Its workflow wiring alarm is intentionally retired; no actual packaged watcher delivery was exercised and no equivalent replacement is claimed. Unaffected dated execution receipts retain their original scope; changed optional selectors have no fresh full-gate execution result. This audit removes the paired-web stopped-during-outage and late-ready direct-branch examples. The retained pre-ready cancellation, failed-unwatch retry and replay-continuity cases are not equivalent replacements for those ordering alarms. Current web-runtime-client.test.ts assertions below name only its retained pre-ready callback case; replay cases remain in their separate suite. Historical commands and results remain historical. Deterministic tests cover one shared healthy child per runtime process plus at most four bounded fault-quarantine children, native desktop, paired-runtime, and WSL snapshot processes sharing the same eight-physical-child reservation with typed serialized event-driven capacity recovery, including recursive re-wait when crash recovery reclaims an announced slot, one quarantine attempt per watch lifetime, generation-scoped cancellation, bounded termination with removable deadline waiters, replacement-crawl errors that cannot be blessed by a late readiness ack, pre-ready paired-web cancellation registered before an unbounded capacity wait, already-resolved and late physical-exit retry of a rejected paired-web teardown, physical desktop-install and local/SSH PTY teardown, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. Destructive local and SSH removal fences both ID-derived and resolved-cwd terminal roots, including sibling-root/cwd combinations with reverse admission rollback, closes descendant relay watches before parent deletion, enumerates authoritative provider/cwd ownership, falls back to daemon spawn cwd before OSC 7, and keeps daemon/relay immediate shutdown pending until native exit or a bounded fail-closed error; a dead relay PID discovered during attach also settles concurrent shutdown before its stale entry is reaped, while Windows runtime watcher deadlines retain one removable physical-close waiter, treat Node's error-close path as positive physical-exit proof, and clear root ownership on late close. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. Abortable capacity, quarantine, runtime-root, relay, SSH, and relay pre-install setup waits attach one reaction to each shared promise and explicitly remove 10,000 cancelled caller closures while one anchor remains. Built-entry macOS harnesses kill the desktop/runtime and SSH relay watcher children, then require automatic resubscription and later events while the host process and relay PTY survive. SSH bundles the child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. The test-value audit corrects the active renderer watch command/file reference to the existing runtime-file-client-watch.test.ts, whose three cases are retained unchanged. They observe synthetic subscription retry/fanout and unsubscribe/unwatch calls, not actual preload/main bookkeeping or physical watcher-process cleanup. The separate read/preview client suite loses forwarding, supplied-error and negative-only examples, retaining binary detection and Unicode SVG decoding. Historical evidence commands/results are unchanged. Test-value audit1293 removes src/main/providers/local-pty/local-pty-provider-session-inventory.test.ts (2 authored cases). Their isolated assertions no longer run; surviving tests are not claimed as equivalent coverage. Fresh execution against the candidate SHA is required. Audit1312 removes low-value imported specs from aggregate test drivers; old whole-driver execution receipts are archived. Retained registrations are unchanged. No equivalent replacement coverage is claimed. Audit1383: src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts reduced. Lose command-list/receipt/default/background activation matrices whose provider spawn returns canned IDs. Retained test proves actual tab allocation separation, not terminal startup. No equivalent replacement coverage is claimed. Existing exact unaffected receipts and their platform/provider/budget claims remain scoped to unchanged selectors. Changed optional selectors have no new execution result. Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result. Audit1412: src/main/workspaces/removal/watcher-removal-gate.test.ts reduced. Loses exact removal-error compatibility with renderer predicate under shared message constants. Kept public refusal outcomes do not establish that renderer classification. No equivalent replacement coverage is claimed. Existing exact unaffected receipts remain unchanged and apply only to their unchanged selectors. Changed optional selectors have no new execution result.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/5308", "https://github.com/stablyai/orca/issues/7547", "https://github.com/stablyai/orca/issues/8212" ], "invariant": "Local runtime and SSH relay roots share one watcher child per host process while healthy and use at most four quarantine children after shard failure; native desktop, paired-runtime, and direct WSL snapshot children consume the same process-wide eight-child reservation and retry transient exhaustion without permanently disabling a root. Cancellation remains owned by its child generation and settles only after acknowledgement or bounded physical exit; paired-web retains cleanup ownership and can cancel before readiness even while capacity is exhausted, retries a failed unwatch after already-resolved or late physical exit without duplicating stream termination, replacements never overlap a still-retiring generation, and aborted or timed-out callers detach immediately even while queued behind same-root relay teardown. Destructive local and SSH cleanup holds one containment-aware watcher/terminal removal fence through descendant watcher and PTY physical exit plus parent Git/filesystem deletion; daemon and relay kill requests retain ownership until the actual subprocess exit callback or an equivalent positive dead-PID proof and propagate rejected signals or a bounded failure otherwise. A crash-fused shared shard moves affected roots into the bounded quarantine pool, a second fused quarantine ends those watch lifetimes, interrupted setup emits overflow after recovery, metadata work stays bounded, and the SSH relay, PTYs, Git, and other filesystem operations never load the watcher native addon or share its fault boundary.", - "oracle": "Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Fill the global watcher-child reservation, require native desktop, paired-runtime, and WSL setup to retain typed transient capacity and retry after one serialized release, then cancel pending desktop and paired-client watches and require no retry. Hold one live anchor while aborting 10,000 callers at the capacity, quarantine-assignment, runtime-root, relay-setup, and SSH-registration layers; require exactly one explicit waiter and one base-promise reaction to remain, then zero after settlement. Hold an old relay unsubscribe, queue one same-root setup leader and one anchor, then abort 10,000 joiners; require every cancellation to settle before teardown releases, exactly one anchor waiter, and only one later physical install. Advance the former paired-web callback deadline while capacity remains full, require cancellation ownership to stay live, publish the server token before ready, and require files.unwatch to abort setup before a later slot release can install a native root. Reject the first ready-watch unwatch with a physical-exit promise, resolve that exit later, and require a fresh physical unwatch attempt, registry release, and exactly one stream end. Fill one healthy shared child with multiple roots, map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor, then inject IPC disconnect-before-exit, queued and active cancellation, operations during termination, kill-without-exit, repeated unkillable generations, initial and recovery timeouts, and shard failure. During local and SSH deletion, attempt a late terminal spawn whose worktree identity and cwd name different roots, enumerate a non-prefix remote PTY by authoritative worktree ownership, withhold daemon/relay exit callbacks, make attach positively prove a concurrent-shutdown PID dead, and make the provider unavailable; require every acquired admission to roll back on rejection, dead-PID proof to settle physical exit before reaping, timeout to retain ownership, and Git/filesystem deletion never to start. Require bounded watcher process count, physical-exit settlement, healthy-root recovery, stale-generation rejection, overflow plus later events, and fail-closed destructive cleanup. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", + "oracle": "Audit1683 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Fill the global watcher-child reservation, require native desktop, paired-runtime, and WSL setup to retain typed transient capacity and retry after one serialized release, then cancel pending desktop and paired-client watches and require no retry. Hold one live anchor while aborting 10,000 callers at the capacity, quarantine-assignment, runtime-root, relay-setup, and SSH-registration layers; require exactly one explicit waiter and one base-promise reaction to remain, then zero after settlement. Hold an old relay unsubscribe, queue one same-root setup leader and one anchor, then abort 10,000 joiners; require every cancellation to settle before teardown releases, exactly one anchor waiter, and only one later physical install. Advance the former paired-web callback deadline while capacity remains full, require cancellation ownership to stay live, publish the server token before ready, and require files.unwatch to abort setup before a later slot release can install a native root. Reject the first ready-watch unwatch with a physical-exit promise, resolve that exit later, and require a fresh physical unwatch attempt, registry release, and exactly one stream end. Fill one healthy shared child with multiple roots, map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor, then inject IPC disconnect-before-exit, queued and active cancellation, operations during termination, kill-without-exit, repeated unkillable generations, initial and recovery timeouts, and shard failure. During local and SSH deletion, attempt a late terminal spawn whose worktree identity and cwd name different roots, enumerate a non-prefix remote PTY by authoritative worktree ownership, withhold daemon/relay exit callbacks, make attach positively prove a concurrent-shutdown PID dead, and make the provider unavailable; require every acquired admission to roll back on rejection, dead-PID proof to settle physical exit before reaping, timeout to retain ownership, and Git/filesystem deletion never to start. Require bounded watcher process count, physical-exit settlement, healthy-root recovery, stale-generation rejection, overflow plus later events, and fail-closed destructive cleanup. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem/watcher-process/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot", @@ -14690,17 +14645,6 @@ "failed local worktree removal restores every live renderer listener and emits overflow" ] }, - { - "file": "src/main/providers/local-pty/local-pty-provider-shutdown.test.ts", - "assertions": [ - "local shutdown remains pending and owned until node-pty reports physical exit", - "destructive cleanup escalates an existing graceful shutdown without losing its shared exit proof", - "POSIX graceful shutdown escalates to one bounded SIGKILL when the child ignores SIGTERM", - "Windows graceful-to-destructive cleanup treats the first ConPTY kill as final and never double-closes the handle", - "orphan cleanup racing immediate shutdown shares one kill and remains owned until the queued physical exit", - "timeout and kill failure preserve ownership for a later successful retry" - ] - }, { "file": "src/main/runtime/file-watcher-host.test.ts", "assertions": [ @@ -14940,6 +14884,7 @@ "Add a packaged orca serve pairing scenario that proves a connected client refreshes after child replacement." ], "knownGaps": [ + "Audit1683 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1645 removes low-value test projections, mock wiring, private-state assertions, inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained public behavior and refusal boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1640 removes low-value IPC, SSH, workspace and transcript-watcher tests without equivalent replacement coverage. Per-source reviews record retained public refusal, cancellation, execution-host and real-filesystem outcomes with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", "Audit1634 removes low-value IPC, filesystem and runtime projections, argument inventories and duplicate examples without equivalent replacement coverage. Per-source reviews record retained cancellation, ownership, authorization, filesystem and SQLite boundaries with injected/native/remote limits. Affected historical receipts are retired; gates without active current evidence are downgraded.", diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 1087d4426b4..1da9d9d80dc 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -2006,7 +2006,6 @@ "src/main/ghostty/parser.test.ts": 51, "src/main/ghostty/theme-import.test.ts": 100, "src/main/ghostty/theme-resolution.test.ts": 62, - "src/main/pty/shell-startup/git-bash.test.ts": 60, "src/main/git/base-ref-search-selector.test.ts": 257, "src/main/git/branch-rename.test.ts": 66, "src/main/git/canonical-repo-key.test.ts": 221, @@ -3632,17 +3631,12 @@ "src/main/providers/agent-foreground-process.test.ts": 227, "src/main/providers/filesystem-markdown-listing.test.ts": 93, "src/main/providers/local-filesystem-provider.test.ts": 344, - "src/main/providers/local-git-provider.test.ts": 95, "src/main/providers/local-pty/local-pty-child-process-verdict.test.ts": 676, "src/main/providers/local-pty/local-pty-default-shell.test.ts": 272, "src/main/providers/local-pty/local-pty-executable-spawn.test.ts": 96, "src/main/providers/local-pty/local-pty-foreground-inspection-cheap-tier.test.ts": 266, "src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts": 520, - "src/main/providers/local-pty/local-pty-provider-io-events.test.ts": 493, - "src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts": 551, "src/main/providers/local-pty/local-pty-provider-shutdown.test.ts": 731, - "src/main/providers/local-pty/local-pty-provider-spawn-cwd-safety.test.ts": 504, - "src/main/providers/local-pty/local-pty-provider-spawn-env.test.ts": 527, "src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts": 856, "src/main/providers/local-pty/local-pty-provider-windows-shell-launch.test.ts": 598, "src/main/providers/local-pty/local-pty-runtime-spawn-wsl-allowlist.test.ts": 85, @@ -3667,7 +3661,6 @@ "src/main/providers/windows-foreground-process-rows.test.ts": 97, "src/main/providers/windows-powershell-executable.test.ts": 65, "src/main/providers/windows-shell-alone-in-job.test.ts": 45, - "src/main/providers/windows-shell-args.test.ts": 220, "src/main/providers/windows-shell-preflight-runtime.windows.test.ts": 126, "src/main/providers/working-directory-validation-retention.test.ts": 132, "src/main/providers/working-directory-validation.test.ts": 163, @@ -3694,38 +3687,27 @@ "src/main/pty/shell-startup/powershell-profile-env.test.ts": 88, "src/main/pty/process-retirement/pty-master-foreground-native.test.ts": 112, "src/main/pty/process-retirement/pty-master-foreground-observation.test.ts": 120, - "src/main/pty/shell-startup/shell-startup-env.test.ts": 116, "src/main/pty/environment/windows-environment-path.test.ts": 116, - "src/main/pty/shell-startup/wsl-orca-env.test.ts": 104, "src/main/qoder/real-cli.test.ts": 602, "src/main/startup/quit/quit-path-durable-write-blocking.test.ts": 3722, "src/main/startup/quit/quit-teardown-deadline.test.ts": 48, "src/main/rate-limits/account-runtime-target-sync.test.ts": 262, "src/main/rate-limits/antigravity-usage-error.test.ts": 47, "src/main/rate-limits/antigravity-usage-fetcher.test.ts": 188, - "src/main/rate-limits/antigravity-usage-response.test.ts": 67, "src/main/rate-limits/auth-filesystem-operation-retention.test.ts": 131, "src/main/rate-limits/auth-filesystem-operation.test.ts": 48, "src/main/rate-limits/claude-oauth-usage-error.test.ts": 49, - "src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts": 119, - "src/main/rate-limits/claude-profile-usage.test.ts": 218, "src/main/rate-limits/claude-usage-error-classification.test.ts": 56, "src/main/rate-limits/codex-auth-presence.test.ts": 65, "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 214, - "src/main/rate-limits/codex-fetcher-backend.test.ts": 277, - "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 251, "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 454, "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 341, - "src/main/rate-limits/codex-fetcher.test.ts": 222, "src/main/rate-limits/codex-probe-termination.test.ts": 73, "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 53, "src/main/rate-limits/codex-rate-limit-wsl-home-gate.test.ts": 105, "src/main/rate-limits/codex-rpc-failure-kind.test.ts": 41, "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 119, "src/main/rate-limits/codex-rpc-worker-backpressure.test.ts": 615, - "src/main/rate-limits/cursor-auth.test.ts": 126, - "src/main/rate-limits/cursor-current-period-usage.test.ts": 211, - "src/main/rate-limits/cursor-fetcher.test.ts": 196, "src/main/rate-limits/cursor-session-token.test.ts": 96, "src/main/rate-limits/cursor-usage-mapping.test.ts": 141, "src/main/rate-limits/gemini-oauth-sources.test.ts": 97, @@ -5020,7 +5002,6 @@ "src/main/runtime/wsl-moved-project-chat-runtime.test.ts": 5094, "src/main/updater/serve-update-handoff.test.ts": 62, "src/main/server/serve-readiness.test.ts": 52, - "src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts": 87, "src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts": 39, "src/main/pty/shell-startup/shell-startup-output-scanner.test.ts": 58, "src/main/pty/shell-startup/shell-wrapper-content-address.test.ts": 49, @@ -5426,8 +5407,6 @@ "src/main/pty/history/terminal-history-gc-fs-call-count.test.ts": 157, "src/main/pty/history/terminal-history-gc.test.ts": 824, "src/main/pty/history/terminal-history-tombstone-retry.test.ts": 715, - "src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts": 339, - "src/main/pty/history/terminal-history.test.ts": 153, "src/main/text-generation/agent-failure-output.test.ts": 49, "src/main/text-generation/antigravity-generation-stream.test.ts": 479, "src/main/text-generation/command-environment-process.test.ts": 1044, diff --git a/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs b/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs index 2b660684891..14a875a8d23 100644 --- a/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs +++ b/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs @@ -31,8 +31,6 @@ export const UNIT_EXCLUDE = [ 'src/main/providers/local-pty/local-pty-shell-ready-zsh-launch-environment.test.ts', 'src/main/pty/shell-startup/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts', - 'src/main/pty/shell-startup/shell-startup-feature-channel.test.ts', - 'src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/pty/history/terminal-history-fish-session.node-pty.test.ts', 'src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts', 'src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', diff --git a/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs b/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs index b4d27de4274..f8d7d5c2c73 100644 --- a/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs +++ b/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs @@ -93,7 +93,6 @@ const NODE_RUNTIME_INCLUDE = [ 'src/**/node-pty-*.test.ts', 'src/**/*.live-shell.test.ts', // These drive live zsh through node-pty and rely on Node's native PTY stream delivery. - 'src/main/pty/shell-startup/shell-startup-feature-channel.test.ts', 'src/main/daemon/shell-readiness/testing/shell-ready.test.ts', 'src/main/daemon/shell-readiness/testing/repro-13767-shell-ready-marker-lost-to-exec.test.ts', 'src/**/*real-pty*.test.ts', diff --git a/src/main/project-groups/nested-repo-import.test.ts b/src/main/project-groups/nested-repo-import.test.ts index aa8a0502e1f..008592fd704 100644 --- a/src/main/project-groups/nested-repo-import.test.ts +++ b/src/main/project-groups/nested-repo-import.test.ts @@ -1,359 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - createNestedProjectGroupResolver, - resolveNestedRepoImportPaths, - resolveNestedRepoSelection -} from './nested-repo-import' -import type { ProjectGroup } from '../../shared/project-group-types' - -function createGroupRecorder(): { - groups: ProjectGroup[] - createGroup: (input: { - name: string - parentPath?: string | null - connectionId?: string | null - parentGroupId?: string | null - createdFrom: ProjectGroup['createdFrom'] - }) => ProjectGroup -} { - const groups: ProjectGroup[] = [] - return { - groups, - createGroup: (input) => { - const group: ProjectGroup = { - id: `group-${groups.length}`, - name: input.name, - parentPath: input.parentPath ?? null, - connectionId: input.connectionId ?? null, - parentGroupId: input.parentGroupId ?? null, - createdFrom: input.createdFrom, - tabOrder: groups.length, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - groups.push(group) - return group - } - } -} +import { resolveNestedRepoImportPaths } from './nested-repo-import' describe('createNestedProjectGroupResolver', () => { - it('creates sparse folder scopes for nested repos in grouped imports', () => { - const groups: ProjectGroup[] = [] - const resolver = createNestedProjectGroupResolver({ - parentPath: '/workspace', - groupName: 'workspace', - mode: 'group', - repoPaths: [ - '/workspace/gateway-api', - '/workspace/services/payments/api', - '/workspace/services/payments/worker' - ], - createGroup: (input) => { - const group: ProjectGroup = { - id: `group-${groups.length}`, - name: input.name, - parentPath: input.parentPath ?? null, - connectionId: input.connectionId ?? null, - parentGroupId: input.parentGroupId ?? null, - createdFrom: input.createdFrom, - tabOrder: groups.length, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - groups.push(group) - return group - } - }) - - const direct = resolver.getGroupForRepo('/workspace/gateway-api') - const nested = resolver.getGroupForRepo('/workspace/services/payments/api') - const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker') - - expect(direct?.name).toBe('workspace') - expect(nested?.name).toBe('services/payments') - expect(sibling?.id).toBe(nested?.id) - expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ - ['workspace', null, '/workspace'], - ['services/payments', 'group-0', '/workspace/services/payments'] - ]) - expect(resolver.getRootGroup()?.id).toBe('group-0') - }) - - it('skips intermediate folders that only lead to one meaningful child scope', () => { - const { groups, createGroup } = createGroupRecorder() - const resolver = createNestedProjectGroupResolver({ - parentPath: '/workspace/platform', - groupName: 'Platform', - mode: 'group', - repoPaths: [ - '/workspace/platform/api', - '/workspace/platform/web', - '/workspace/platform/packages/shared/repo1', - '/workspace/platform/packages/shared/repo2' - ], - createGroup - }) - - const api = resolver.getGroupForRepo('/workspace/platform/api') - const repo1 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1') - const repo2 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo2') - - expect(api?.name).toBe('Platform') - expect(repo1?.name).toBe('packages/shared') - expect(repo2?.id).toBe(repo1?.id) - expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ - ['Platform', null, '/workspace/platform'], - ['packages/shared', 'group-0', '/workspace/platform/packages/shared'] - ]) - }) - - it('creates a parent folder scope when it has direct repos and nested descendants', () => { - const { groups, createGroup } = createGroupRecorder() - const resolver = createNestedProjectGroupResolver({ - parentPath: '/workspace/platform', - groupName: 'Platform', - mode: 'group', - repoPaths: ['/workspace/platform/services/api', '/workspace/platform/services/jobs/worker'], - createGroup - }) - - const direct = resolver.getGroupForRepo('/workspace/platform/services/api') - const nested = resolver.getGroupForRepo('/workspace/platform/services/jobs/worker') - - expect(direct?.name).toBe('services') - expect(nested?.id).toBe(direct?.id) - expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ - ['Platform', null, '/workspace/platform'], - ['services', 'group-0', '/workspace/platform/services'] - ]) - }) - - it('does not create groups for separate imports', () => { - const resolver = createNestedProjectGroupResolver({ - parentPath: '/workspace', - groupName: 'workspace', - mode: 'separate', - get repoPaths(): readonly string[] { - throw new Error('separate imports must not build unused folder scopes') - }, - createGroup: () => { - throw new Error('should not create a group') - } - }) - - expect(resolver.getGroupForRepo('/workspace/services/api')).toBeUndefined() - expect(resolver.getCreatedGroups()).toEqual([]) - }) - - it('leaves every separate-import repo ungrouped even when repo paths are supplied', () => { - const { groups, createGroup } = createGroupRecorder() - const repoPaths = [ - '/workspace/services/api', - '/workspace/services/worker', - '/workspace/platform/packages/shared' - ] - const resolver = createNestedProjectGroupResolver({ - parentPath: '/workspace', - groupName: 'workspace', - mode: 'separate', - repoPaths, - createGroup - }) - - expect(repoPaths.map((repoPath) => resolver.getGroupForRepo(repoPath))).toEqual([ - undefined, - undefined, - undefined - ]) - expect(resolver.getRootGroup()).toBeUndefined() - expect(groups).toEqual([]) - }) - - it('preserves filesystem root parent paths when creating the root group', () => { - const groups: ProjectGroup[] = [] - const resolver = createNestedProjectGroupResolver({ - parentPath: '/', - groupName: 'root', - mode: 'group', - repoPaths: ['/api', '/services/api'], - createGroup: (input) => { - const group: ProjectGroup = { - id: `group-${groups.length}`, - name: input.name, - parentPath: input.parentPath ?? null, - parentGroupId: input.parentGroupId ?? null, - createdFrom: input.createdFrom, - tabOrder: groups.length, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - groups.push(group) - return group - } - }) - - resolver.getGroupForRepo('/api') - resolver.getGroupForRepo('/services/api') - - expect(groups.map((group) => group.parentPath)).toEqual(['/']) - }) - - it('preserves Windows drive roots when creating the root group', () => { - const groups: ProjectGroup[] = [] - const resolver = createNestedProjectGroupResolver({ - parentPath: 'C:\\', - groupName: 'C', - mode: 'group', - repoPaths: ['C:\\api', 'C:\\services\\api'], - createGroup: (input) => { - const group: ProjectGroup = { - id: `group-${groups.length}`, - name: input.name, - parentPath: input.parentPath ?? null, - parentGroupId: input.parentGroupId ?? null, - createdFrom: input.createdFrom, - tabOrder: groups.length, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - groups.push(group) - return group - } - }) - - resolver.getGroupForRepo('C:\\api') - resolver.getGroupForRepo('C:\\services\\api') - - expect(groups.map((group) => group.parentPath)).toEqual(['C:/']) - }) - - it('creates sparse folder scopes for Windows repo paths', () => { - const { groups, createGroup } = createGroupRecorder() - const resolver = createNestedProjectGroupResolver({ - parentPath: 'C:\\workspace\\platform', - groupName: 'Platform', - mode: 'group', - repoPaths: [ - 'C:\\workspace\\platform\\apps\\web', - 'C:\\workspace\\platform\\packages\\shared\\repo1', - 'C:\\workspace\\platform\\packages\\shared\\repo2' - ], - createGroup - }) - - const web = resolver.getGroupForRepo('C:\\workspace\\platform\\apps\\web') - const repo1 = resolver.getGroupForRepo('C:\\workspace\\platform\\packages\\shared\\repo1') - - expect(web?.name).toBe('Platform') - expect(repo1?.name).toBe('packages/shared') - expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ - ['Platform', null, 'C:/workspace/platform'], - ['packages/shared', 'group-0', 'C:/workspace/platform/packages/shared'] - ]) - }) - - it('preserves SSH provenance on grouped folder scopes', () => { - const { groups, createGroup } = createGroupRecorder() - const resolver = createNestedProjectGroupResolver({ - parentPath: '/workspace/platform', - groupName: 'Platform', - mode: 'group', - connectionId: 'ssh-1', - repoPaths: [ - '/workspace/platform/packages/shared/repo1', - '/workspace/platform/packages/shared/repo2' - ], - createGroup - }) - - resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1') - - expect(groups.map((group) => [group.name, group.connectionId])).toEqual([ - ['Platform', 'ssh-1'], - ['packages/shared', 'ssh-1'] - ]) - }) - - it('falls back to the selected parent folder basename for blank group names', () => { - const groups: ProjectGroup[] = [] - const resolver = createNestedProjectGroupResolver({ - parentPath: '/workspace/platform', - groupName: ' ', - mode: 'group', - createGroup: (input) => { - const group: ProjectGroup = { - id: `group-${groups.length}`, - name: input.name, - parentPath: input.parentPath ?? null, - parentGroupId: input.parentGroupId ?? null, - createdFrom: input.createdFrom, - tabOrder: groups.length, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - groups.push(group) - return group - } - }) - - resolver.getGroupForRepo('/workspace/platform/apps/web') - - expect(groups.map((group) => group.name)).toEqual(['platform']) - }) - - it('resolves Windows-style repo paths back to canonical scan output', () => { - const selection = resolveNestedRepoSelection({ - scan: { - selectedPath: 'C:\\workspace', - selectedPathKind: 'non_git_folder', - repos: [ - { path: 'C:\\workspace\\Services\\API', displayName: 'API', depth: 2 }, - { path: 'C:\\workspace\\tools', displayName: 'tools', depth: 1 } - ], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 1, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null - }, - projectPaths: ['c:/workspace/services/api', 'C:/workspace/services/api', 'D:/other/repo'] - }) - - expect(selection.selectedPaths).toEqual(['C:\\workspace\\Services\\API']) - expect(selection.rejectedPaths).toEqual(['D:/other/repo']) - }) - - it('accepts stopped-scan import paths inside the selected parent without rescanning', () => { - const selection = resolveNestedRepoImportPaths({ - parentPath: '/workspace/platform', - projectPaths: [ - '/workspace/platform/api', - '/workspace/platform/api', - '/workspace/platform/apps/web', - '/workspace/other/repo' - ] - }) - - expect(selection.selectedPaths).toEqual([ - '/workspace/platform/api', - '/workspace/platform/apps/web' - ]) - expect(selection.rejectedPaths).toEqual(['/workspace/other/repo']) - }) - it('rejects stopped-scan import paths that escape the selected parent with dot segments', () => { const selection = resolveNestedRepoImportPaths({ parentPath: '/workspace/platform', @@ -370,31 +18,4 @@ describe('createNestedProjectGroupResolver', () => { '/workspace/platform/apps/../../other-outside-repo' ]) }) - - it('rejects stopped-scan import requests with a relative parent path', () => { - const selection = resolveNestedRepoImportPaths({ - parentPath: 'workspace/platform', - projectPaths: ['workspace/platform/api', '/workspace/platform/api'] - }) - - expect(selection.selectedPaths).toEqual([]) - expect(selection.rejectedPaths).toEqual(['workspace/platform/api', '/workspace/platform/api']) - }) - - it('normalizes accepted stopped-scan import paths before importing', () => { - const selection = resolveNestedRepoImportPaths({ - parentPath: 'C:\\workspace\\platform', - projectPaths: [ - 'C:\\workspace\\platform\\api', - 'C:\\workspace\\platform\\apps\\..\\tools', - 'C:\\workspace\\outside' - ] - }) - - expect(selection.selectedPaths).toEqual([ - 'C:/workspace/platform/api', - 'C:/workspace/platform/tools' - ]) - expect(selection.rejectedPaths).toEqual(['C:\\workspace\\outside']) - }) }) diff --git a/src/main/provider-process/managed-provider-process.test.ts b/src/main/provider-process/managed-provider-process.test.ts index 453d5fb139b..9486c957a49 100644 --- a/src/main/provider-process/managed-provider-process.test.ts +++ b/src/main/provider-process/managed-provider-process.test.ts @@ -6,10 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor' -import { PROVIDER_SPAWN_FAILURE_MARKER } from './provider-spawn-failure-report' import { spawnManagedProviderProcess } from './managed-provider-process' -import type { DescendantTreeVerdict } from '../pty/process-retirement/pty-descendant-exit-verification' -import type { ProviderProcessTree } from './provider-process-close' const mocks = vi.hoisted(() => ({ capture: vi.fn(async () => null), @@ -54,88 +51,7 @@ function launch(fixture: ReturnType, platform: NodeJS.Platform ) } -function fakeTree(initial: DescendantTreeVerdict = 'unverifiable') { - let verdict = initial - const tree: ProviderProcessTree = { - capture: vi.fn(async () => {}), - refresh: vi.fn(async () => {}), - reap: vi.fn(async () => verdict), - get treeVerdict() { - return verdict - } - } - return { - tree, - setVerdict: (next: DescendantTreeVerdict) => { - verdict = next - } - } -} - describe('managed provider process', () => { - it('applies launch environment and uses the supervisor on its execution platform', () => { - const fixture = fakeChild() - const managed = spawnManagedProviderProcess( - { - command: 'fixture-provider', - args: [], - env: { AGENT_HOME: '/pinned' }, - envToDelete: ['SECRET'] - }, - { - spawnImpl: fixture.spawn, - platform: 'darwin', - inheritedEnv: { SECRET: 'inherited', PATH: '/bin' }, - site: 'fixture', - acceptClose: (result) => result.root === 'exited' && result.tree === 'exited', - policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 }) - } - ) - expect(managed.supervised).toBe(true) - expect(fixture.spawn).toHaveBeenCalledWith( - expect.objectContaining({ - detached: true, - env: expect.objectContaining({ AGENT_HOME: '/pinned', PATH: '/bin' }) - }) - ) - expect(fixture.spawn.mock.calls[0][0].env).not.toHaveProperty('SECRET') - expect(managed.rootVerdict).toBe('live') - }) - - it('observes exit once across exit and close, including a late subscriber', async () => { - const fixture = fakeChild() - const managed = launch(fixture) - const onExit = vi.fn() - managed.onExit(onExit) - fixture.child.emit('exit', 7, 'SIGTERM') - fixture.child.emit('close', 7, 'SIGTERM') - fixture.child.emit('exit', 8, 'SIGKILL') - await managed.exitPromise - expect(onExit).toHaveBeenCalledExactlyOnceWith({ - code: 7, - signal: 'SIGTERM', - processless: false - }) - const late = vi.fn() - managed.onExit(late) - expect(late).toHaveBeenCalledExactlyOnceWith({ code: 7, signal: 'SIGTERM', processless: false }) - await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) - expect(fixture.child.kill).not.toHaveBeenCalled() - }) - - it('proves stdin-close exit without forcing and clears the grace timer', async () => { - vi.useFakeTimers() - const fixture = fakeChild() - const managed = launch(fixture) - fixture.child.stdin.once('finish', () => fixture.child.emit('exit', 0, null)) - const closing = managed.close() - expect(fixture.child.stdin.writableEnded).toBe(true) - await vi.advanceTimersByTimeAsync(0) - await expect(closing).resolves.toMatchObject({ root: 'exited' }) - expect(fixture.child.kill).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - it('joins a close, keeps root live after the kill deadline, and retries on the same child', async () => { vi.useFakeTimers() const fixture = fakeChild() @@ -157,21 +73,6 @@ describe('managed provider process', () => { expect(vi.getTimerCount()).toBe(0) }) - it('still reports a root exit once after an unconfirmed close', async () => { - vi.useFakeTimers() - const fixture = fakeChild() - const managed = launch(fixture) - const report = vi.fn() - managed.onExit(report) - const close = managed.close() - await vi.advanceTimersByTimeAsync(150) - await expect(close).resolves.toMatchObject({ root: 'live' }) - fixture.child.emit('exit', 0, null) - fixture.child.emit('close', 0, null) - expect(report).toHaveBeenCalledOnce() - await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) - }) - it('requires error then close for a processless spawn to settle', async () => { const fixture = fakeChild(null) const managed = launch(fixture) @@ -186,46 +87,6 @@ describe('managed provider process', () => { await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) }) - it('names a missing provider executable from a direct spawn error or a supervisor report', async () => { - const direct = fakeChild(null) - const directManaged = launch(direct) - direct.child.emit( - 'error', - Object.assign(new Error('spawn fixture-provider ENOENT'), { - code: 'ENOENT', - path: 'fixture-provider' - }) - ) - expect(directManaged.executableMissing).toBe(true) - - const supervisedLaunch = (fixture: ReturnType) => - spawnManagedProviderProcess( - { command: 'fixture-provider', args: ['serve'], cwd: tmpdir() }, - { - spawnImpl: fixture.spawn, - platform: 'linux', - site: 'fixture-provider-teardown', - policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 }) - } - ) - const supervised = fakeChild() - const supervisedManaged = supervisedLaunch(supervised) - const report = (code: string): string => - `${PROVIDER_SPAWN_FAILURE_MARKER}${JSON.stringify({ thrown: false, code, message: `spawn fixture-provider ${code}` })}\n` - supervised.child.stderr.write(report('ENOENT')) - await new Promise((resolve) => setImmediate(resolve)) - expect(supervisedManaged.executableMissing).toBe(false) - supervised.child.emit('exit', 127, null) - expect(supervisedManaged.executableMissing).toBe(true) - - const denied = fakeChild() - const deniedManaged = supervisedLaunch(denied) - denied.child.stderr.write(report('EACCES')) - await new Promise((resolve) => setImmediate(resolve)) - denied.child.emit('exit', 127, null) - expect(deniedManaged.executableMissing).toBe(false) - }) - // Direct (Windows) and supervised (POSIX) spawns: Node names what it spawned, never the folder. it.each(['win32', 'linux'] as const)( 'names a deleted working folder, not the CLI, when a real %s spawn fails there', @@ -251,33 +112,6 @@ describe('managed provider process', () => { } ) - it("never blames the agent's CLI when the supervisor binary itself is gone", () => { - const execPath = process.execPath - // Orca's binary replaced mid-update: the supervisor spawn finds no such file. - const gone = join(tmpdir(), 'orca-updated-away', 'Orca') - Object.defineProperty(process, 'execPath', { configurable: true, value: gone }) - try { - const fixture = fakeChild(null) - const managed = spawnManagedProviderProcess( - { command: 'fixture-provider', args: ['serve'], cwd: tmpdir() }, - { - spawnImpl: fixture.spawn, - platform: 'linux', - site: 'fixture-provider-teardown', - policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 }) - } - ) - fixture.child.emit( - 'error', - Object.assign(new Error(`spawn ${gone} ENOENT`), { code: 'ENOENT', path: gone }) - ) - expect(managed.spawnAbsence).toBeNull() - expect(managed.executableMissing).toBe(false) - } finally { - Object.defineProperty(process, 'execPath', { configurable: true, value: execPath }) - } - }) - it('does not turn a transport close with an existing pid into Claude exit proof', () => { const fixture = fakeChild() const managed = launch(fixture) @@ -286,104 +120,4 @@ describe('managed provider process', () => { expect(managed.rootVerdict).toBe('live') expect(managed.processless).toBe(false) }) - - it('uses Windows tree teardown and still requires observed root exit', async () => { - vi.useFakeTimers() - const fixture = fakeChild() - const managed = launch(fixture, 'win32') - expect(managed.supervised).toBe(false) - const first = managed.close() - await vi.advanceTimersByTimeAsync(150) - await expect(first).resolves.toMatchObject({ root: 'live' }) - expect(mocks.windowsTree).toHaveBeenCalledWith(fixture.child.pid, { - site: 'fixture-provider-teardown' - }) - expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGTERM') - fixture.child.emit('exit', null, 'SIGKILL') - await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) - }) - - it('preserves a caller requiring tree proof, including live and unverifiable descendants', async () => { - const fixture = fakeChild() - const managed = spawnManagedProviderProcess( - { command: 'fixture-provider', args: [] }, - { - spawnImpl: fixture.spawn, - platform: 'darwin', - site: 'fixture', - acceptClose: (result) => result.root === 'exited' && result.tree === 'exited', - policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 }) - } - ) - const proof = fakeTree() - fixture.child.emit('exit', 0, null) - await expect(managed.close(proof.tree)).resolves.toEqual({ - root: 'exited', - tree: 'unverifiable' - }) - proof.setVerdict('live') - await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'live' }) - proof.setVerdict('exited') - await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' }) - await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' }) - expect(proof.tree.capture).toHaveBeenCalledTimes(3) - }) - - it('reports failed tree cleanup separately after observed root exit', async () => { - vi.useFakeTimers() - const fixture = fakeChild() - const managed = launch(fixture, 'win32') - mocks.windowsTree.mockImplementationOnce(async () => { - fixture.child.emit('exit', null, 'SIGKILL') - throw new Error('tree teardown unavailable') - }) - const close = managed.close() - await vi.advanceTimersByTimeAsync(150) - await expect(close).resolves.toMatchObject({ root: 'exited' }) - expect(managed.lastCloseResult).toEqual({ root: 'exited', tree: 'unverifiable' }) - }) - - it('keeps the cleanup diagnostic tied to the close that finished before a late root exit', async () => { - vi.useFakeTimers() - const fixture = fakeChild() - const managed = launch(fixture, 'win32') - mocks.windowsTree.mockRejectedValueOnce(new Error('tree teardown unavailable')) - const close = managed.close() - await vi.advanceTimersByTimeAsync(150) - await expect(close).resolves.toMatchObject({ root: 'live' }) - fixture.child.emit('exit', null, 'SIGKILL') - await expect(managed.close()).resolves.toMatchObject({ root: 'exited' }) - expect(managed.lastCloseResult).toEqual({ root: 'live', tree: 'unverifiable' }) - }) - - it('lets a supervised caller signal immediately and waits its configured grace before forcing', async () => { - vi.useFakeTimers() - const fixture = fakeChild() - const managed = spawnManagedProviderProcess( - { command: 'fixture-provider', args: [] }, - { - spawnImpl: fixture.spawn, - platform: 'darwin', - site: 'fixture', - acceptClose: (result) => result.root === 'exited' && result.tree === 'exited', - policy: () => ({ - gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS + 500, - forcedExitMs: 50, - signalSupervisorOnClose: true - }) - } - ) - fixture.child.kill.mockImplementation(() => { - setTimeout(() => fixture.child.emit('exit', 0, 'SIGTERM'), 550) - return true - }) - const close = managed.close() - await vi.advanceTimersByTimeAsync(549) - expect(fixture.child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM') - expect(managed.rootVerdict).toBe('live') - await vi.advanceTimersByTimeAsync(1) - await expect(close).resolves.toMatchObject({ root: 'exited' }) - expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGKILL') - expect(vi.getTimerCount()).toBe(0) - }) }) diff --git a/src/main/provider-process/provider-group-orphan-guard.test.ts b/src/main/provider-process/provider-group-orphan-guard.test.ts index 5fb0f13ec95..e502c2e17ca 100644 --- a/src/main/provider-process/provider-group-orphan-guard.test.ts +++ b/src/main/provider-process/provider-group-orphan-guard.test.ts @@ -2,18 +2,13 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { guardSupervisedProviderGroup, - sweepOrphanedProviderGroup, - type ProviderGroupSweepVerdict + sweepOrphanedProviderGroup } from './provider-group-orphan-guard' import type { ProcessRowRead } from './provider-process-row-read' const STARTED = 'Mon Oct 19 10:00:00 2026' const identity = { pgid: 4242, startedAt: STARTED } -function errno(code: string): Error { - return Object.assign(new Error(code), { code }) -} - function row(overrides: Partial> = {}) { return { pid: 4242, @@ -26,22 +21,6 @@ function row(overrides: Partial> } describe('sweepOrphanedProviderGroup', () => { - it('does nothing when the group is already gone', async () => { - const signalGroup = vi.fn(() => { - throw errno('ESRCH') - }) - const readProcessRow = vi.fn() - await expect( - sweepOrphanedProviderGroup(identity, { - site: 'test', - supervised: true, - signalGroup, - readProcessRow - }) - ).resolves.toBe('absent') - expect(readProcessRow).not.toHaveBeenCalled() - }) - it('kills a surviving group whose leader is the recorded process', async () => { const signalGroup = vi.fn() await expect( @@ -84,15 +63,6 @@ describe('sweepOrphanedProviderGroup', () => { ).resolves.toBe('refused') expect(signalGroup).not.toHaveBeenCalledWith(4242, 'SIGKILL') }) - - it('refuses a group this user cannot signal', async () => { - const signalGroup = vi.fn(() => { - throw errno('EPERM') - }) - await expect( - sweepOrphanedProviderGroup(identity, { site: 'test', supervised: true, signalGroup }) - ).resolves.toBe('refused') - }) }) class FakeSupervisor extends EventEmitter { @@ -105,67 +75,7 @@ class FakeSupervisor extends EventEmitter { } } -function guard( - child: FakeSupervisor, - readProcessRow: (pid: number) => Promise, - signalGroup = vi.fn() -): Promise { - return new Promise((resolve) => - guardSupervisedProviderGroup(child, { - site: 'test', - supervised: true, - readProcessRow, - signalGroup, - onSwept: resolve - }) - ) -} - describe('guardSupervisedProviderGroup', () => { - it('sweeps the reported group once the supervisor exits', async () => { - const child = new FakeSupervisor() - const signalGroup = vi.fn() - const swept = guard(child, async () => row({ ppid: 777 }), signalGroup) - child.report({ pgid: 4242 }) - child.emit('exit', null, 'SIGKILL') - await expect(swept).resolves.toBe('killed') - expect(signalGroup).toHaveBeenLastCalledWith(4242, 'SIGKILL') - }) - - it('never records a reported pid that its supervisor no longer parents', async () => { - const child = new FakeSupervisor() - const signalGroup = vi.fn() - const swept = guard(child, async () => row({ ppid: 1 }), signalGroup) - child.report({ pgid: 4242 }) - child.emit('exit', null, 'SIGKILL') - await expect(swept).resolves.toBe('unproven') - expect(signalGroup).not.toHaveBeenCalled() - }) - - it('re-reads a leader started in the read second, so a same-second reuse cannot match', async () => { - const child = new FakeSupervisor() - const startedAtMs = Date.parse(STARTED) - const clock = { now: startedAtMs + 300 } - const readProcessRow = vi.fn(async () => row({ ppid: 777 })) - const sleep = vi.fn(async (ms: number) => { - clock.now += ms - }) - const proven = new Promise((resolve) => - guardSupervisedProviderGroup(child, { - site: 'test', - supervised: true, - readProcessRow, - now: () => clock.now, - sleep, - onProven: resolve - }) - ) - child.report({ pgid: 4242 }) - await expect(proven).resolves.toEqual(identity) - expect(readProcessRow).toHaveBeenCalledTimes(2) - expect(clock.now).toBeGreaterThanOrEqual(startedAtMs + 1_000) - }) - it('arms nothing when a same-second replacement holds the pid once the second ends', async () => { const child = new FakeSupervisor() const clock = { now: Date.parse(STARTED) + 300 } @@ -194,98 +104,4 @@ describe('guardSupervisedProviderGroup', () => { await expect(swept).resolves.toBe('unproven') expect(signalGroup).not.toHaveBeenCalled() }) - - it('trusts a Linux clock-tick start without waiting out the second', async () => { - const child = new FakeSupervisor() - const startedAt = 'clock-tick:123456' - const readProcessRow = vi.fn(async () => - row({ ppid: 777, startedAt, startResolution: 'clock-tick' }) - ) - const sleep = vi.fn(async () => {}) - const proven = new Promise((resolve) => - guardSupervisedProviderGroup(child, { - site: 'test', - supervised: true, - readProcessRow, - now: () => Date.now(), - sleep, - onProven: resolve - }) - ) - child.report({ pgid: 4242 }) - await expect(proven).resolves.toEqual({ pgid: 4242, startedAt }) - expect(sleep).not.toHaveBeenCalled() - }) - - it('records nothing when every read stays inside the start second', async () => { - const child = new FakeSupervisor() - const now = Date.parse(STARTED) + 100 - const signalGroup = vi.fn() - const swept = new Promise((resolve) => - guardSupervisedProviderGroup(child, { - site: 'test', - supervised: true, - readProcessRow: async () => row({ ppid: 777 }), - signalGroup, - now: () => now, - sleep: async () => {}, - onSwept: resolve - }) - ) - child.report({ pgid: 4242 }) - child.emit('exit', null, 'SIGKILL') - await expect(swept).resolves.toBe('unproven') - expect(signalGroup).not.toHaveBeenCalled() - }) - - it('stops waiting out the start second as soon as the supervisor exits', async () => { - const child = new FakeSupervisor() - const readProcessRow = vi.fn(async () => row({ ppid: 777 })) - const swept = new Promise((resolve) => - guardSupervisedProviderGroup(child, { - site: 'test', - supervised: true, - readProcessRow, - now: () => Date.parse(STARTED) + 100, - onSwept: resolve - }) - ) - child.report({ pgid: 4242 }) - await vi.waitFor(() => expect(readProcessRow).toHaveBeenCalledTimes(1)) - const exitedAt = performance.now() - child.emit('exit', null, 'SIGKILL') - await expect(swept).resolves.toBe('unproven') - expect(performance.now() - exitedAt).toBeLessThan(500) - expect(readProcessRow).toHaveBeenCalledTimes(1) - }) - - it('warns once and watches nothing for a supervised spawn without the report channel', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const child = new FakeSupervisor() - child.connected = false - expect(guardSupervisedProviderGroup(child, { site: 'test', supervised: true })).toBe(false) - expect(guardSupervisedProviderGroup(child, { site: 'test', supervised: true })).toBe(false) - expect(child.listenerCount('exit')).toBe(0) - expect(warn).toHaveBeenCalledTimes(1) - warn.mockRestore() - }) - - it('stays quiet for an unsupervised spawn', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect( - guardSupervisedProviderGroup(new FakeSupervisor(), { site: 'test', supervised: false }) - ).toBe(false) - expect(warn).not.toHaveBeenCalled() - warn.mockRestore() - }) - - it('ignores a malformed report', async () => { - const child = new FakeSupervisor() - const readProcessRow = vi.fn(async () => row({ ppid: 777 })) - const swept = guard(child, readProcessRow) - child.report({ pgid: 1 }) - child.emit('exit', 0, null) - await expect(swept).resolves.toBe('unproven') - expect(readProcessRow).not.toHaveBeenCalled() - }) }) diff --git a/src/main/provider-process/provider-process-supervisor.integration.test.ts b/src/main/provider-process/provider-process-supervisor.integration.test.ts index 5a1b9bc4a19..e1b42641f63 100644 --- a/src/main/provider-process/provider-process-supervisor.integration.test.ts +++ b/src/main/provider-process/provider-process-supervisor.integration.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { - PROVIDER_SIGTERM_GRACE_MS, PROVIDER_STDIN_END_GRACE_MS, PROVIDER_SUPERVISOR_MAX_STOP_MS, supervisedPosixLaunch, @@ -48,9 +47,7 @@ const CLEANS_UP_AFTER_SIGTERM_PROVIDER = String.raw` ) setInterval(() => {}, 60000) ` - -// Exits the moment its stdin ends, as Codex does on a normal close. -const EXITS_ON_STDIN_END_PROVIDER = String.raw` +void String.raw` process.stdin.on('end', () => process.exit(0)).resume() process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n') ` @@ -68,9 +65,7 @@ const FLUSHES_ON_STDIN_END_PROVIDER = String.raw` }, 200)).resume() process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n') ` - -// Ignores stdin end and SIGTERM, recording when SIGTERM arrived, so only SIGKILL ends it. -const RECORDS_SIGTERM_PROVIDER = String.raw` +void String.raw` process.on('SIGTERM', () => { require('node:fs').writeFileSync(process.env.ORCA_TEST_PROVIDER_SIGNAL_FILE, String(Date.now())) }) @@ -272,20 +267,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(alive(grandchild)).toBe(false) }) - it('kills the rest of a one-shot group at once when the provider exits on a stop', async () => { - const { supervisor, exit } = launchSupervisor({ lifetime: 'one-shot' }) - const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild']) - - const signalledAt = Date.now() - supervisor.kill('SIGTERM') - - await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) - // One-shot helpers must not impose the session grace after provider exit. - expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS / 3) - expect(alive(provider)).toBe(false) - expect(alive(grandchild)).toBe(false) - }) - it('lets a session provider group finish its SIGTERM cleanup after the provider exits', async () => { const cleanupFile = join(tempDir(), 'grandchild-cleaned-up') const { supervisor, exit } = launchSupervisor( @@ -301,31 +282,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(existsSync(cleanupFile)).toBe(true) }) - it('escalates a SIGTERM-ignoring provider to SIGKILL after the grace from the spec', async () => { - const graceMs = 200 - const { supervisor, exit } = launchSupervisor( - { sigtermGraceMs: graceMs }, - { ORCA_TEST_PROVIDER_IGNORES_SIGTERM: '1' } - ) - const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild']) - - const signalledAt = Date.now() - supervisor.kill('SIGTERM') - let exitTimeout: ReturnType | undefined - const exited = await Promise.race([ - exit, - new Promise((resolve) => { - exitTimeout = setTimeout(resolve, 5_000) - }) - ]).finally(() => clearTimeout(exitTimeout)) - - expect(exited).toEqual({ code: null, signal: 'SIGTERM' }) - expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs) - expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS) - expect(alive(provider)).toBe(false) - expect(alive(grandchild)).toBe(false) - }) - it('reaps a provider spawned in the instant before a stop arrives', async () => { const dir = tempDir() const preload = join(dir, 'signal-after-spawn.js') @@ -387,101 +343,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(alive(pids.grandchild)).toBe(false) }) - it('closes a provider that exits on stdin end without waiting out any grace', async () => { - const { supervisor, exit } = launchSupervisor( - {}, - {}, - { - command: process.execPath, - args: ['-e', EXITS_ON_STDIN_END_PROVIDER] - } - ) - const { provider } = await readPids(supervisor, ['provider']) - - const endedAt = Date.now() - supervisor.stdin!.end() - - await expect(exit).resolves.toEqual({ code: 0, signal: null }) - expect(Date.now() - endedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS) - expect(alive(provider)).toBe(false) - }) - - it('gives a provider its stdin-end grace, then its SIGTERM grace before SIGKILL', async () => { - const signalFile = join(tempDir(), 'provider-sigterm-at') - const stdinEndGraceMs = 200 - const sigtermGraceMs = 300 - const { supervisor, exit } = launchSupervisor( - { stdinEndGraceMs, sigtermGraceMs }, - { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile }, - { command: process.execPath, args: ['-e', RECORDS_SIGTERM_PROVIDER] } - ) - const { provider } = await readPids(supervisor, ['provider']) - - const endedAt = Date.now() - supervisor.stdin!.end() - const exited = await exit - const exitedAt = Date.now() - const signalledAt = Number(readFileSync(signalFile, 'utf8')) - - expect(exited).toEqual({ code: 137, signal: null }) - // Timers may fire a tick early against another process's clock. - expect(signalledAt - endedAt).toBeGreaterThanOrEqual(stdinEndGraceMs - 20) - expect(exitedAt - signalledAt).toBeGreaterThanOrEqual(sigtermGraceMs - 20) - expect(exitedAt - endedAt).toBeLessThan(stdinEndGraceMs + sigtermGraceMs + 1_000) - expect(alive(provider)).toBe(false) - }) - - it('reaps the provider group and exits when its owner quits cleanly', async () => { - const { owner, pids } = await launchUnderOwner( - { sigtermGraceMs: 300 }, - { ORCA_TEST_OWNER_QUITS_CLEANLY: '1' } - ) - const ownerExit = new Promise((resolve) => - owner.once('exit', (code, signal) => resolve({ code, signal })) - ) - - owner.kill('SIGUSR2') - - await expect(ownerExit).resolves.toEqual({ code: 0, signal: null }) - expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) - expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true) - expect(alive(pids.grandchild)).toBe(false) - }) - - it('asks the provider to stop with SIGTERM when its owner dies', async () => { - const signalFile = join(tempDir(), 'provider-signal') - const { owner, pids } = await launchUnderOwner( - { sigtermGraceMs: 300 }, - { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile } - ) - - owner.kill('SIGKILL') - - expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) - expect(existsSync(signalFile) && readFileSync(signalFile, 'utf8')).toBe('SIGTERM') - }) - - it('stops a session closed with SIGTERM at once when its owner dies', async () => { - const signalFile = join(tempDir(), 'provider-sigterm-at') - const { owner, pids } = await launchUnderOwner( - { sigtermGraceMs: 200 }, - { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile }, - { script: RECORDS_SIGTERM_PROVIDER, pids: ['provider'] } - ) - - const killedAt = Date.now() - owner.kill('SIGKILL') - - // Wait for nonempty content: observing file creation alone could produce a false timing result. - const signalledAt = (): number => - existsSync(signalFile) ? Number(readFileSync(signalFile, 'utf8')) : 0 - expect(await waitFor(() => signalledAt() > 0, 3_000)).toBe(true) - // Owner death uses the 100ms poll, without the normal stdin-end grace. - expect(signalledAt() - killedAt).toBeGreaterThanOrEqual(0) - expect(signalledAt() - killedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS / 2) - expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) - }) - it('gives a session closed by its stdin end that EOF and grace when its owner dies', async () => { const dir = tempDir() const signalFile = join(dir, 'provider-signal') @@ -500,26 +361,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(existsSync(signalFile)).toBe(false) }) - it('stops a stdin-end session that ignores its EOF once the grace after owner death ends', async () => { - const signalFile = join(tempDir(), 'provider-sigterm-at') - const stdinEndGraceMs = 400 - const { owner, pids } = await launchUnderOwner( - { closeRequest: 'stdin-end', stdinEndGraceMs, sigtermGraceMs: 200 }, - { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile }, - { script: RECORDS_SIGTERM_PROVIDER, pids: ['provider'] } - ) - - const killedAt = Date.now() - owner.kill('SIGKILL') - - expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) - // Timers may fire a tick early against another process's clock. - expect(Number(readFileSync(signalFile, 'utf8')) - killedAt).toBeGreaterThanOrEqual( - stdinEndGraceMs - 20 - ) - expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true) - }) - describe('one-shot lifetime', () => { it('lets a one-shot answer after its stdin ends and relays its exit code', async () => { const signalFile = join(tempDir(), 'provider-signal') @@ -566,25 +407,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe } ) - it('escalates a SIGTERM-ignoring one-shot to SIGKILL after the grace from the spec', async () => { - const graceMs = 200 - const { supervisor, exit } = launchSupervisor( - { lifetime: 'one-shot', sigtermGraceMs: graceMs }, - { ORCA_TEST_PROVIDER_IGNORES_SIGTERM: '1' } - ) - const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild']) - supervisor.stdin!.end() - - const signalledAt = Date.now() - supervisor.kill('SIGTERM') - - await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) - expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs) - expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS) - expect(alive(provider)).toBe(false) - expect(alive(grandchild)).toBe(false) - }) - it('keeps the user Node options from the supervisor and hands them to the provider', async () => { const nodeOptions = `--require ${join(tempDir(), 'missing-preload.js')}` const { supervisor, exit } = launchSupervisor( @@ -621,24 +443,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe }) }) - it('hands the provider a near-cap argv prompt intact', async () => { - const prompt = 'x'.repeat(110 * 1024) - const { supervisor, exit } = launchSupervisor( - { lifetime: 'one-shot' }, - {}, - { - command: process.execPath, - args: ['-e', 'process.stdout.write(String(process.argv[1].length))', prompt] - } - ) - let stdout = '' - supervisor.stdout!.on('data', (chunk: Buffer) => (stdout += chunk.toString())) - supervisor.stdin!.end() - - await expect(exit).resolves.toEqual({ code: 0, signal: null }) - expect(stdout).toBe(String(prompt.length)) - }) - it('relays all of the provider output to a slow owner before exiting', async () => { const bytes = 4 * 1024 * 1024 const { supervisor, exit } = launchSupervisor( diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index cbbb78626c7..d4edc4fe151 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -1,9 +1,6 @@ import type { ChildProcess } from 'node:child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - findSelfInitiatedTreeKills, - resetSelfInitiatedTreeKillLogForTest -} from '../crash-reporting/self-initiated-tree-kill-log' +import { resetSelfInitiatedTreeKillLogForTest } from '../crash-reporting/self-initiated-tree-kill-log' import type { DescendantTreeVerdict } from '../pty/process-retirement/pty-descendant-exit-verification' import { terminateProviderProcessTree as terminateWithEvidence } from './provider-process-teardown' @@ -12,8 +9,6 @@ async function terminateProviderProcessTree(...args: Parameters { expect(target.kill).toHaveBeenCalledWith('SIGKILL') }) - it('reports an unproven Windows tree when taskkill does not exit cleanly', async () => { - const target = child() - - await expect( - terminateWithEvidence(target, { - site: 'codex-app-server-teardown', - platform: 'win32', - terminateWindowsTree: async () => false - }) - ).resolves.toBe('unverifiable') - expect(target.kill).toHaveBeenCalledWith('SIGKILL') - }) - - it('passes a non-Codex diagnostic label to Windows teardown', async () => { - const terminateWindowsTree = vi.fn(async () => true) - - await terminateProviderProcessTree(child(), { - site: 'provider-test-teardown', - platform: 'win32', - terminateWindowsTree - }) - - expect(terminateWindowsTree).toHaveBeenCalledWith(1234, { site: 'provider-test-teardown' }) - }) - it('waits for an owned POSIX snapshot before killing the wrapper', async () => { const target = child() const signalProcessGroup = vi.fn() @@ -91,97 +61,7 @@ describe('terminateProviderProcessTree', () => { expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') }) - it('signals a proven dedicated POSIX process group without scanning descendants', async () => { - const target = child() - const captureDescendants = vi.fn() - const signalProcessGroup = vi.fn() - - await expect( - terminateProviderProcessTree(target, { - site: 'provider-test-teardown', - platform: 'darwin', - dedicatedProcessGroup: true, - captureDescendants, - signalProcessGroup - }) - ).resolves.toBeNull() - - expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') - expect(captureDescendants).not.toHaveBeenCalled() - expect(target.kill).not.toHaveBeenCalled() - expect(findSelfInitiatedTreeKills(Date.now())).toEqual([ - expect.objectContaining({ site: 'provider-test-teardown', scope: 'posix-process-group' }) - ]) - }) - - it('keeps the dedicated-group wrapper reachable when signalling is unproven', async () => { - const target = child() - - await expect( - terminateProviderProcessTree(target, { - site: 'codex-app-server-teardown', - platform: 'linux', - dedicatedProcessGroup: true, - signalProcessGroup: () => { - throw Object.assign(new Error('denied'), { code: 'EPERM' }) - } - }) - ).resolves.toBe('unverifiable') - - expect(target.kill).not.toHaveBeenCalled() - }) - // An intercepted ESRCH must exercise the default signal path without creating a false kill breadcrumb. - it('does not claim a snapshot group that was already gone', async () => { - const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) } - const kill = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) - }) - try { - await expect( - terminateProviderProcessTree(target, { - site: 'codex-app-server-teardown', - platform: 'darwin', - captureDescendants: async () => ({ - rootPgid: UNREACHABLE_PGID, - descendants: [], - capturedAtMs: 1 - }), - terminateDescendants: async () => 'exited' - }) - ).resolves.toBe('exited') - - expect(kill).toHaveBeenCalledWith(-UNREACHABLE_PGID, 'SIGKILL') - expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') - expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) - } finally { - kill.mockRestore() - } - }) - - it('claims a snapshot group the signal actually reached', async () => { - const target = child() - const signalProcessGroup = vi.fn() - - await expect( - terminateProviderProcessTree(target, { - site: 'codex-app-server-teardown', - platform: 'darwin', - captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), - terminateDescendants: async () => 'exited', - signalProcessGroup - }) - ).resolves.toBe('exited') - - expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') - expect(findSelfInitiatedTreeKills(Date.now())).toEqual([ - expect.objectContaining({ - pid: 1234, - site: 'codex-app-server-teardown', - scope: 'posix-process-group' - }) - ]) - }) it.each([ ['live', 'live'], @@ -218,62 +98,6 @@ describe('terminateProviderProcessTree', () => { ) } ) - - it('claims no observation when the POSIX process table cannot be read', async () => { - const target = child() - await expect( - terminateProviderProcessTree(target, { - site: 'codex-app-server-teardown', - platform: 'darwin', - captureDescendants: async () => null - }) - ).resolves.toBeNull() - expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') - }) - - it('claims no observation from an ESRCH dedicated group and reports a spawnless child as unverifiable', async () => { - await expect( - terminateProviderProcessTree(child(), { - site: 'provider-test-teardown', - platform: 'linux', - dedicatedProcessGroup: true, - signalProcessGroup: () => { - throw Object.assign(new Error('gone'), { code: 'ESRCH' }) - } - }) - ).resolves.toBeNull() - const spawnless = { pid: undefined, kill: vi.fn(() => true) } - await expect( - terminateProviderProcessTree(spawnless, { site: 'provider-test-teardown', platform: 'linux' }) - ).resolves.toBe('unverifiable') - }) - - it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => { - const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) - const targets = killMocks.map((kill, index) => ({ - pid: 10_000 + index, - kill - })) - const captureDescendants = vi.fn() - const signalProcessGroup = vi.fn() - - const results = await Promise.all( - targets.map((target) => - terminateProviderProcessTree(target, { - site: 'codex-app-server-teardown', - platform: 'linux', - dedicatedProcessGroup: true, - captureDescendants, - signalProcessGroup - }) - ) - ) - - expect(results).toEqual(Array.from({ length: targets.length }, () => null)) - expect(signalProcessGroup.mock.calls).toEqual(targets.map((target) => [target.pid, 'SIGKILL'])) - expect(captureDescendants).not.toHaveBeenCalled() - expect(killMocks.every((kill) => kill.mock.calls.length === 0)).toBe(true) - }) }) it('counts the writer group kill, but never a supervisor-only kill, without an exit report', async () => { diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index b85075ed1aa..35e2292f656 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -14,7 +14,6 @@ import { resetProcessTableSnapshotForTests } from '../../shared/process-table-sn import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { confirmShellForegroundProcess, - resolveAgentForegroundProcess, resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' // Toolhelp denial may return an empty table; require the querying process to distinguish a readable snapshot. @@ -61,13 +60,6 @@ function mockWindowsRows(rows: NativeProcessRow[] = DEFAULT_WINDOWS_ROWS): void }) } -/** `undefined` rows are how the native snapshot reports a table it cannot read. */ -function mockUnreadableWindowsTable(): void { - getAllProcessesMock.mockImplementation((cb: (snapshot: undefined) => void) => { - cb(undefined) - }) -} - describe('resolveAgentForegroundProcess', () => { let platform: PropertyDescriptor | undefined @@ -91,155 +83,7 @@ describe('resolveAgentForegroundProcess', () => { } }) - it('does not report a suspended agent when a non-agent holds the foreground', async () => { - // vim owns foreground (+); Ctrl-Z left Codex as a stopped background descendant. - mockPs( - [ - '101 100 T node /Users/dev/.nvm/versions/node/bin/codex', - '102 100 S+ vim notes.txt' - ].join('\n') - ) - - await expect(resolveAgentForegroundProcess(100, 'vim')).resolves.toBe('vim') - }) - - it('still reports a foreground agent', async () => { - mockPs(['101 100 S+ node /Users/dev/.nvm/versions/node/bin/codex'].join('\n')) - - await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') - }) - // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). - it('reports the outer omp wrapper, not the wrapped pi child', async () => { - mockPs(['101 100 S+ omp', '102 101 S+ pi'].join('\n')) - - await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp') - }) - - it('reports omp even when the wrapped pi child holds the foreground alone', async () => { - // OMP identity must survive samples where only the wrapped Pi child owns foreground. - mockPs(['101 100 S omp', '102 101 S+ pi'].join('\n')) - - await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp') - }) - - it('reports bare pi when no omp wrapper is present', async () => { - mockPs(['101 100 S+ pi'].join('\n')) - - await expect(resolveAgentForegroundProcess(100, 'pi')).resolves.toBe('pi') - }) - - it('reports the outer omp wrapper on Windows', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'omp.exe', - commandLine: 'omp.exe' - }, - { - pid: 102, - ppid: 101, - name: 'pi.exe', - commandLine: 'pi.exe' - } - ]) - - await expect(resolveAgentForegroundProcess(100, 'pi.exe')).resolves.toBe('omp') - }) - - it('keeps the Windows omp ancestor when context selects one of multiple pi descendants', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'omp.exe', - commandLine: 'omp.exe' - }, - { - pid: 102, - ppid: 101, - name: 'pi.exe', - commandLine: 'pi.exe --cwd C:\\repo\\orca' - }, - { - pid: 103, - ppid: 100, - name: 'pi.exe', - commandLine: 'pi.exe --cwd C:\\repo\\other' - } - ]) - - await expect( - resolveAgentForegroundProcess(100, 'pi.exe', { contextPaths: ['C:\\repo\\orca'] }) - ).resolves.toBe('omp') - }) - - it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { - mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) - ).resolves.toEqual({ available: false, processName: 'zsh' }) - }) - - it('treats failed POSIX scans as unavailable', async () => { - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) - } - ) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) - ).resolves.toEqual({ available: false, processName: 'zsh' }) - await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({ - available: false, - processName: 'zsh' - }) - await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') - }) - - it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => { - mockPs(['100 99 100 100 Ss+ "/bin/zsh" -l', '101 100 101 100 S+ /bin/bash'].join('\n')) - - await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true) - }) - - it('uses spawned-shell identity instead of a lagging foreground child label', async () => { - mockPs(['100 99 100 100 Ss+ /bin/zsh -l'].join('\n')) - - await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) - }) - - it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => { - mockPs( - [ - '100 99 100 101 Ss /usr/bin/login -pfl developer /bin/zsh', - '101 100 101 101 S+ -zsh', - '102 101 101 101 S+ (zsh)', - '103 102 101 101 S+ (sed)', - '104 102 101 101 R+ (git)' - ].join('\n') - ) - - await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) - }) it('rejects a foreground nested shell while the spawned shell remains suspended', async () => { mockPs( @@ -254,271 +98,12 @@ describe('resolveAgentForegroundProcess', () => { await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false) }) - it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => { - mockPs( - [ - '100 99 100 101 Ss /bin/zsh -l', - '101 100 101 101 S+ /usr/local/bin/agent-tui', - '102 101 101 101 S+ /bin/bash -i' - ].join('\n') - ) - - await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) - }) - it('rejects shell ownership while a stopped TUI remains resumable', async () => { mockPs(['100 99 100 100 Ss+ /bin/zsh -l', '101 100 101 100 T agent-tui'].join('\n')) await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) }) - it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => { - // The WSL job holds only wsl.exe and cannot prove guest-side shell state. - await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false) - expect(execFileMock).not.toHaveBeenCalled() - }) - - it('fails open when fresh shell ownership inspection is unavailable', async () => { - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) - } - ) - - await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) - }) - - it('confirms a Windows shell from fresh root-only ConPTY membership', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100])) - - await expect( - confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds }) - ).resolves.toBe(true) - expect(getAllProcessesMock).not.toHaveBeenCalled() - }) - - it('rejects Windows shell ownership with a child or unavailable membership', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - - await expect( - confirmShellForegroundProcess(100, 'powershell.exe', { - readWindowsPtyJobProcessIds: async () => new Set([100, 101]) - }) - ).resolves.toBe(false) - await expect( - confirmShellForegroundProcess(100, 'powershell.exe', { - readWindowsPtyJobProcessIds: async () => null - }) - ).resolves.toBe(false) - }) - - it('does not report Claude print-mode hook descendants as foreground agents', async () => { - mockPs( - [ - '100 99 Ss bash -i', - '101 100 S+ claude --print --model haiku Analyze this conversation and determine next work' - ].join('\n') - ) - - await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') - }) - - it('does not report a stopped agent after the shell regains foreground', async () => { - mockPs( - ['100 99 Ss+ bash -i', '101 100 T node /Users/dev/.nvm/versions/node/bin/codex'].join( - '\n' - ) - ) - - await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') - }) - - it('falls back to recognized descendants when no process in the PTY tree holds foreground', async () => { - // Detached descendants may have no foreground marker; retain agent recognition as a fallback. - mockPs( - ['100 99 Ss bash -i', '101 100 S node /Users/dev/.nvm/versions/node/bin/codex'].join( - '\n' - ) - ) - - await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') - }) - - it('recognizes Windows wrapper-launched agents from descendant command lines', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows() - - await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex') - }) - - it('recognizes Windows shell-rooted agent launches from descendant command lines', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows() - - await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex') - }) - - it('recognizes the native Windows Cursor launcher process tree', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'cmd.exe', - commandLine: 'cmd.exe /c cursor-agent.cmd' - }, - { - pid: 102, - ppid: 101, - name: 'powershell.exe', - commandLine: - 'powershell.exe -File C:\\Users\\dev\\AppData\\Local\\cursor-agent\\cursor-agent.ps1' - }, - { - pid: 103, - ppid: 102, - name: 'node.exe', - commandLine: - 'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js' - }, - { - pid: 104, - ppid: 103, - name: 'node.exe', - commandLine: - 'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js worker-server' - }, - { - pid: 105, - ppid: 100, - name: 'agent.exe', - commandLine: 'C:\\Users\\dev\\.grok\\bin\\agent.exe' - } - ]) - - await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('cursor-agent') - }) - - it('recognizes Windows Git Bash shell-rooted agent launches', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'bash.exe', - commandLine: 'C:\\Program Files\\Git\\bin\\bash.exe --login -i' - }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd' - } - ]) - - await expect(resolveAgentForegroundProcess(100, 'bash.exe')).resolves.toBe('codex') - }) - - it('keeps a multiline Windows command line inside its own row', async () => { - // Prompt text impersonates another row; keep it within its actual process command line. - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: [ - 'node', - 'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js', - '--prompt', - '"line one\r\nName=gemini.exe\r\nProcessId=999"' - ].join(' ') - } - ]) - - await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex') - }) - - it('distinguishes unavailable Windows enumeration from a confirmed shell', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockUnreadableWindowsTable() - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') - ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) - await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( - 'powershell.exe' - ) - }) - - it('treats an observed Windows shell with no children as authoritative', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - } - ]) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') - ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) - }) - - it('does not restore a recognized fallback that disappeared before confirmation', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - } - ]) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'droid', { - fresh: true, - forceProcessScan: true - }) - ).resolves.toEqual({ available: true, processName: null }) - }) - - it('reports a foreign anchor when its pid now runs an unrecognized command', async () => { - // Job membership cannot preserve an agent identity contradicted by immutable command-line evidence. - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, - { pid: 999, ppid: 100, name: 'node.exe', commandLine: 'node server.js' } - ]) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { - anchorProcessId: 999 - }) - ).resolves.toEqual({ - available: true, - processName: 'powershell.exe', - anchorPidForeign: true - }) - }) - it('detects a foreign anchor even when the squatter is orphaned out of the descendant walk', async () => { // PID reuse can leave the squatter outside the shell ancestry while retaining job membership. Object.defineProperty(process, 'platform', { value: 'win32' }) @@ -557,21 +142,6 @@ describe('resolveAgentForegroundProcess', () => { }) }) - it('does not flag an anchor whose row still recognizes as the cached agent', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, - { pid: 999, ppid: 500, name: 'node.exe', commandLine: 'node C:\\npm\\claude' } - ]) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { - anchorProcessId: 999, - anchorProcessName: 'claude' - }) - ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) - }) - it('treats a query-denied anchor row as inconclusive, never foreign', async () => { // A denied query yields command === name; the agent may just be unreadable. Object.defineProperty(process, 'platform', { value: 'win32' }) @@ -587,81 +157,6 @@ describe('resolveAgentForegroundProcess', () => { ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) }) - it('treats a Windows snapshot missing the requested shell as unavailable', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 200, - ppid: 99, - name: 'unrelated.exe', - commandLine: 'unrelated.exe' - } - ]) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') - ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) - await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( - 'powershell.exe' - ) - }) - - it('does not use unrelated Windows agent descendants for wrapper fallbacks', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: 'node C:\\repo\\server.js' - }, - { - pid: 102, - ppid: 100, - name: 'codex.exe', - commandLine: 'codex' - } - ]) - - await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe') - }) - - it('fails closed for ambiguous Windows shell-rooted agent descendants', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: - 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js' - }, - { - pid: 102, - ppid: 100, - name: 'node.exe', - commandLine: - 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs' - } - ]) - - await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( - 'powershell.exe' - ) - }) - it('filters detached agents before resolving an otherwise ambiguous ConPTY tree', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) mockWindowsRows([ @@ -719,163 +214,6 @@ describe('resolveAgentForegroundProcess', () => { expect(resolution.processName).toBe('powershell.exe') }) - it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: - 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js --cwd C:\\repo\\orca' - }, - { - pid: 102, - ppid: 100, - name: 'node.exe', - commandLine: - 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs --cwd C:\\repo\\other' - } - ]) - - await expect( - resolveAgentForegroundProcess(100, 'powershell.exe', { - contextPaths: ['C:\\repo\\orca'] - }) - ).resolves.toBe('codex') - }) - - it('recognizes the deepest Windows shell-rooted agent when candidates share one lineage', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'codex.exe', - commandLine: 'codex --cwd C:\\repo\\orca' - }, - { - pid: 102, - ppid: 101, - name: 'gemini.exe', - commandLine: 'gemini --cwd C:\\repo\\orca' - } - ]) - - await expect( - resolveAgentForegroundProcess(100, 'powershell.exe', { - contextPaths: ['C:\\repo\\orca'] - }) - ).resolves.toBe('gemini') - }) - - it('fails closed for sibling Windows agents that both match the same worktree path', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'codex.exe', - commandLine: 'codex --cwd C:\\repo\\orca' - }, - { - pid: 102, - ppid: 100, - name: 'gemini.exe', - commandLine: 'gemini --cwd C:\\repo\\orca' - } - ]) - - await expect( - resolveAgentForegroundProcess(100, 'powershell.exe', { - contextPaths: ['C:\\repo\\orca'] - }) - ).resolves.toBe('powershell.exe') - }) - - it('fails closed when Windows has multiple matching wrapper descendants', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: 'node C:\\repo\\server.js' - }, - { - pid: 102, - ppid: 100, - name: 'node.exe', - commandLine: - 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js' - } - ]) - - await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe') - }) - - it('does not enrich Windows foregrounds that are not interpreter wrappers', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - - await expect(resolveAgentForegroundProcess(100, 'vim.exe')).resolves.toBe('vim.exe') - expect(getAllProcessesMock).not.toHaveBeenCalled() - }) - - it('authorizes a fresh Windows agent only when it still belongs to the ConPTY', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - }, - { - pid: 101, - ppid: 100, - name: 'droid.exe', - commandLine: 'droid' - } - ]) - const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 999])) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { - fresh: true, - readWindowsConsoleAttachedProcessIds - }) - ).resolves.toEqual({ - available: true, - processName: 'droid', - processId: 101 - }) - expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1) - }) - it('excludes a detached Windows Droid descendant from byte authority', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) mockWindowsRows([ @@ -900,25 +238,4 @@ describe('resolveAgentForegroundProcess', () => { }) ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) }) - - it('does not fork the ConPTY membership helper when no Windows agent is inferred', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - mockWindowsRows([ - { - pid: 100, - ppid: 99, - name: 'powershell.exe', - commandLine: 'powershell.exe' - } - ]) - const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 999])) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { - fresh: true, - readWindowsConsoleAttachedProcessIds - }) - ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) - expect(readWindowsConsoleAttachedProcessIds).not.toHaveBeenCalled() - }) }) diff --git a/src/main/providers/local-git-provider.test.ts b/src/main/providers/local-git-provider.test.ts deleted file mode 100644 index 42273bfdfd6..00000000000 --- a/src/main/providers/local-git-provider.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { git } = vi.hoisted(() => ({ - git: { - getStatus: vi.fn(), - getSubmoduleStatus: vi.fn(), - checkIgnoredPaths: vi.fn(), - getHistory: vi.fn(), - commitChanges: vi.fn(), - getStagedCommitContext: vi.fn(), - getDiff: vi.fn(), - stageFile: vi.fn(), - unstageFile: vi.fn(), - bulkStageFiles: vi.fn(), - stageWorktreeChanges: vi.fn(), - bulkUnstageFiles: vi.fn(), - discardChanges: vi.fn(), - bulkDiscardChanges: vi.fn(), - detectConflictOperation: vi.fn(), - abortMerge: vi.fn(), - abortRebase: vi.fn(), - checkoutBranch: vi.fn(), - listLocalBranches: vi.fn(), - getBranchCompare: vi.fn(), - getCommitCompare: vi.fn(), - getCommitDiff: vi.fn(), - getUpstreamStatus: vi.fn(), - gitPush: vi.fn(), - gitPull: vi.fn(), - gitFastForward: vi.fn(), - gitPullRebaseFromBase: vi.fn(), - gitFetch: vi.fn(), - gitSyncForkDefaultBranch: vi.fn(), - listWorktrees: vi.fn(), - isGitRepo: vi.fn(), - gitExecFileAsync: vi.fn(), - awaitWindowsHostGitEnvironmentReady: vi.fn(), - getRemoteFileUrl: vi.fn(), - getRemoteCommitUrl: vi.fn() - } -})) - -vi.mock('../git/source-control/status-read', async (importOriginal) => ({ - ...(await importOriginal>()), - getStatus: git.getStatus -})) - -vi.mock('../git/source-control/submodule-status', async (importOriginal) => ({ - ...(await importOriginal>()), - getSubmoduleStatus: git.getSubmoduleStatus -})) - -vi.mock('../git/source-control/commit-changes', async (importOriginal) => ({ - ...(await importOriginal>()), - commitChanges: git.commitChanges -})) - -vi.mock('../git/source-control/staged-commit-context', async (importOriginal) => ({ - ...(await importOriginal>()), - getStagedCommitContext: git.getStagedCommitContext -})) - -vi.mock('../git/source-control/file-diff', async (importOriginal) => ({ - ...(await importOriginal>()), - getDiff: git.getDiff -})) - -vi.mock('../git/source-control/staging', async (importOriginal) => ({ - ...(await importOriginal>()), - stageFile: git.stageFile, - unstageFile: git.unstageFile, - bulkStageFiles: git.bulkStageFiles, - stageWorktreeChanges: git.stageWorktreeChanges, - bulkUnstageFiles: git.bulkUnstageFiles -})) - -vi.mock('../git/source-control/discard-changes', async (importOriginal) => ({ - ...(await importOriginal>()), - discardChanges: git.discardChanges, - bulkDiscardChanges: git.bulkDiscardChanges -})) - -vi.mock('../git/source-control/git-conflict-operation', async (importOriginal) => ({ - ...(await importOriginal>()), - detectConflictOperation: git.detectConflictOperation, - abortMerge: git.abortMerge, - abortRebase: git.abortRebase -})) - -vi.mock('../git/source-control/branch-compare', async (importOriginal) => ({ - ...(await importOriginal>()), - getBranchCompare: git.getBranchCompare -})) - -vi.mock('../git/source-control/commit-compare', async (importOriginal) => ({ - ...(await importOriginal>()), - getCommitCompare: git.getCommitCompare -})) - -vi.mock('../git/source-control/commit-diff', async (importOriginal) => ({ - ...(await importOriginal>()), - getCommitDiff: git.getCommitDiff -})) -vi.mock('../git/source-control/check-ignored-paths', () => ({ - checkIgnoredPaths: git.checkIgnoredPaths -})) -vi.mock('../git/checkout', () => ({ - checkoutBranch: git.checkoutBranch, - listLocalBranches: git.listLocalBranches -})) -vi.mock('../git/history', () => ({ getHistory: git.getHistory })) -vi.mock('../git/remote', () => git) -vi.mock('../git/fork-sync', () => ({ gitSyncForkDefaultBranch: git.gitSyncForkDefaultBranch })) -vi.mock('../git/source-control/upstream', () => ({ getUpstreamStatus: git.getUpstreamStatus })) -vi.mock('../git/worktrees/catalog/worktree-scan-cache', () => ({ - listWorktrees: git.listWorktrees -})) -vi.mock('../git/repo', () => git) -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: git.gitExecFileAsync -})) - -vi.mock('../git/command-runner/windows-host-git-environment', async (importOriginal) => ({ - ...(await importOriginal>()), - awaitWindowsHostGitEnvironmentReady: git.awaitWindowsHostGitEnvironmentReady -})) - -import { createLocalGitProvider, type LocalGitProvider } from './local-git-provider' - -const WT = '/repo/wt' -const pushTarget = { remoteName: 'origin', branchName: 'feature' } -const interactive = { admissionTier: 'interactive' } - -// [provider call, free function, expected free-function args] — mirrors the runtime local arms. -type ParityCase = [string, (p: LocalGitProvider) => unknown, keyof typeof git, unknown[]] -const cases: ParityCase[] = [ - ['getStatus', (p) => p.getStatus(WT), 'getStatus', [WT, { admissionTier: 'status' }]], - [ - 'getStatus with options', - (p) => p.getStatus(WT, { includeIgnored: true, admissionTier: 'background' }), - 'getStatus', - [WT, { includeIgnored: true, admissionTier: 'background' }] - ], - [ - 'getSubmoduleStatus staged', - (p) => p.getSubmoduleStatus(WT, 'sub', 'staged'), - 'getSubmoduleStatus', - [WT, 'sub', { ...interactive, staged: true }] - ], - [ - 'getSubmoduleStatus default', - (p) => p.getSubmoduleStatus(WT, 'sub'), - 'getSubmoduleStatus', - [WT, 'sub', interactive] - ], - [ - 'checkIgnoredPaths', - (p) => p.checkIgnoredPaths(WT, ['a']), - 'checkIgnoredPaths', - [WT, ['a'], interactive] - ], - [ - 'getHistory', - (p) => p.getHistory(WT, { limit: 5 }), - 'getHistory', - [WT, { limit: 5, ...interactive }] - ], - ['commit', (p) => p.commit(WT, 'msg'), 'commitChanges', [WT, 'msg', interactive]], - [ - 'getStagedCommitContext', - (p) => p.getStagedCommitContext(WT), - 'getStagedCommitContext', - [WT, interactive] - ], - ['getDiff', (p) => p.getDiff(WT, 'a', true, true), 'getDiff', [WT, 'a', true, true, interactive]], - ['stageFile', (p) => p.stageFile(WT, 'a'), 'stageFile', [WT, 'a', interactive]], - ['unstageFile', (p) => p.unstageFile(WT, 'a'), 'unstageFile', [WT, 'a', interactive]], - [ - 'bulkStageFiles', - (p) => p.bulkStageFiles(WT, ['a']), - 'bulkStageFiles', - [WT, ['a'], interactive] - ], - [ - 'bulkStageFiles scoped', - (p) => p.bulkStageFiles(WT, [], 'all'), - 'stageWorktreeChanges', - [WT, 'all', { ...interactive, sharedLinkPaths: [] }] - ], - [ - 'bulkUnstageFiles', - (p) => p.bulkUnstageFiles(WT, ['a']), - 'bulkUnstageFiles', - [WT, ['a'], interactive] - ], - ['discardChanges', (p) => p.discardChanges(WT, 'a'), 'discardChanges', [WT, 'a', interactive]], - [ - 'bulkDiscardChanges', - (p) => p.bulkDiscardChanges(WT, ['a']), - 'bulkDiscardChanges', - [WT, ['a'], interactive] - ], - [ - 'detectConflictOperation', - (p) => p.detectConflictOperation(WT), - 'detectConflictOperation', - [WT, {}] - ], - ['abortMerge', (p) => p.abortMerge(WT), 'abortMerge', [WT, interactive]], - ['abortRebase', (p) => p.abortRebase(WT), 'abortRebase', [WT, interactive]], - ['checkoutBranch', (p) => p.checkoutBranch(WT, 'b'), 'checkoutBranch', [WT, 'b', interactive]], - ['listLocalBranches', (p) => p.listLocalBranches(WT), 'listLocalBranches', [WT, {}]], - [ - 'getBranchCompare default tier', - (p) => p.getBranchCompare(WT, 'main'), - 'getBranchCompare', - [WT, 'main', interactive] - ], - [ - 'getBranchCompare background', - (p) => p.getBranchCompare(WT, 'main', { admissionTier: 'background' }), - 'getBranchCompare', - [WT, 'main', { admissionTier: 'background' }] - ], - [ - 'getCommitCompare', - (p) => p.getCommitCompare(WT, 'abc'), - 'getCommitCompare', - [WT, 'abc', interactive] - ], - [ - 'getCommitDiff', - (p) => p.getCommitDiff(WT, { commitOid: 'abc', filePath: 'a' }), - 'getCommitDiff', - [WT, { commitOid: 'abc', filePath: 'a' }, interactive] - ], - [ - 'getUpstreamStatus', - (p) => p.getUpstreamStatus(WT, pushTarget), - 'getUpstreamStatus', - [WT, pushTarget, {}] - ], - [ - 'pushBranch', - (p) => p.pushBranch(WT, true, pushTarget, { forceWithLease: true }), - 'gitPush', - [WT, true, pushTarget, { forceWithLease: true, ...interactive }] - ], - [ - 'pushBranch defaults', - (p) => p.pushBranch(WT), - 'gitPush', - [WT, false, undefined, { forceWithLease: false, ...interactive }] - ], - ['pullBranch', (p) => p.pullBranch(WT, pushTarget), 'gitPull', [WT, pushTarget, interactive]], - [ - 'fastForwardBranch', - (p) => p.fastForwardBranch(WT, pushTarget), - 'gitFastForward', - [WT, pushTarget, interactive] - ], - [ - 'rebaseFromBase', - (p) => p.rebaseFromBase(WT, 'main'), - 'gitPullRebaseFromBase', - [WT, 'main', interactive] - ], - ['fetchRemote', (p) => p.fetchRemote(WT), 'gitFetch', [WT, undefined, interactive]], - [ - 'syncForkDefaultBranch', - (p) => p.syncForkDefaultBranch(WT, { owner: 'acme', repo: 'orca' }), - 'gitSyncForkDefaultBranch', - [WT, { owner: 'acme', repo: 'orca' }, interactive] - ], - ['listWorktrees', (p) => p.listWorktrees('/repo'), 'listWorktrees', ['/repo', {}]], - [ - 'exec', - (p) => p.exec(['status'], WT, { timeoutMs: 5 }), - 'gitExecFileAsync', - [['status'], { cwd: WT, timeout: 5 }] - ], - [ - 'getRemoteFileUrl', - (p) => p.getRemoteFileUrl(WT, 'a', 3, 8), - 'getRemoteFileUrl', - [WT, 'a', 3, 8, {}] - ], - ['getRemoteCommitUrl', (p) => p.getRemoteCommitUrl(WT, 'abc'), 'getRemoteCommitUrl', [WT, 'abc']] -] - -describe('createLocalGitProvider', () => { - beforeEach(() => { - for (const fn of Object.values(git)) { - fn.mockReset() - } - }) - - it.each(cases)('%s calls the local free function as the runtime local arm does', async (...c) => { - const [, call, fnName, expected] = c - await call(createLocalGitProvider()) - expect(git[fnName]).toHaveBeenCalledTimes(1) - expect(git[fnName]).toHaveBeenCalledWith(...expected) - }) - - it.each(cases)('%s keeps the WSL distro on every call', async (...c) => { - const [, call, fnName] = c - await call(createLocalGitProvider({ wslDistro: 'Ubuntu' })) - const args = git[fnName].mock.calls[0] - const options = args.at(-1) - if (fnName === 'getRemoteCommitUrl') { - // URL reads require Windows-hosted Git readiness, matching local execution. - expect(git.awaitWindowsHostGitEnvironmentReady).toHaveBeenCalledWith({ cwd: WT }) - return - } - expect(options).toMatchObject({ wslDistro: 'Ubuntu' }) - }) - - it('applies shared link paths to status and scoped stage-all only', async () => { - const getSharedLinkPaths = vi.fn(() => ['node_modules']) - const provider = createLocalGitProvider({ getSharedLinkPaths }) - await provider.getDiff(WT, 'a', false) - expect(getSharedLinkPaths).not.toHaveBeenCalled() - await provider.getStatus(WT) - await provider.bulkStageFiles(WT, [], 'all') - await provider.bulkStageFiles(WT, ['a']) - expect(git.getStatus).toHaveBeenCalledWith(WT, { - admissionTier: 'status', - sharedLinkPaths: ['node_modules'] - }) - expect(git.stageWorktreeChanges).toHaveBeenCalledWith(WT, 'all', { - ...interactive, - sharedLinkPaths: ['node_modules'] - }) - expect(git.bulkStageFiles).toHaveBeenCalledWith(WT, ['a'], interactive) - }) - - it('lets the per-call status tier win over a caller-wide default tier', async () => { - await createLocalGitProvider({ admissionTier: 'background' }).getStatus(WT) - expect(git.getStatus).toHaveBeenCalledWith(WT, { admissionTier: 'status' }) - }) - - it('passes errors from the free function through unchanged', async () => { - const error = new Error('fatal: not a git repository') - git.getStatus.mockRejectedValueOnce(error) - await expect(createLocalGitProvider().getStatus('/not-git')).rejects.toBe(error) - }) -}) diff --git a/src/main/providers/local-pty/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty/local-pty-child-process-verdict.test.ts index 352348f2f96..95d8ed7394d 100644 --- a/src/main/providers/local-pty/local-pty-child-process-verdict.test.ts +++ b/src/main/providers/local-pty/local-pty-child-process-verdict.test.ts @@ -31,8 +31,6 @@ import { isRetiredPtyMaster } from '../../pty/process-retirement/node-pty-master import { LocalPtyProvider } from './local-pty-provider' import { ptyIncarnations, ptyProcesses, ptyShellPath } from './local-pty-provider-state' import { inspectPtyProviderProcess } from '../pty-process-inspection' -import { readLaunchedAgentForeground } from '../../runtime/terminal/wait/launched-agent-foreground' -import type { TerminalForegroundSample } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation' // Match the recorded basename so only the retired descriptor distinguishes the pane from idle. const POSIX_SHELL = 'sh' @@ -106,67 +104,6 @@ const describeOnPosix = process.platform === 'win32' ? describe.skip : describe describe('inspectPtyProviderProcess child-process evidence', () => { const provider = new LocalPtyProvider() - it('uses the owned Windows job without a static-name marker, preserving uncertain and replaced panes', async () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - registerPane('pty-win', 'powershell.exe', 'powershell.exe') - const inspect = () => - inspectPtyProviderProcess(provider, 'pty-win', { - expectedIncarnationId: 'incarnation:pty-win' - }) - jobMembers.mockReturnValue(new Set([4242])) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'no-children', - hasChildProcesses: false - }) - jobMembers.mockReturnValue(new Set([4242, 4243])) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'children', - hasChildProcesses: true - }) - jobMembers.mockReturnValue(null) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'unverifiable', - hasChildProcesses: true - }) - jobMembers.mockImplementationOnce(() => { - registerPane('pty-win', 'powershell.exe', 'powershell.exe') - return new Set([4242]) - }) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'unverifiable', - hasChildProcesses: true - }) - }) - - it('carries unverifiable evidence when the child read fails after foreground inspection', async () => { - let reads = 0 - registerPane( - 'pty-closing', - () => { - reads += 1 - if (reads > 1) { - throw new Error('EBADF: bad file descriptor') - } - return '/bin/zsh' - }, - '/bin/zsh' - ) - await expect(inspectPtyProviderProcess(provider, 'pty-closing')).resolves.toEqual({ - foregroundProcess: '/bin/zsh', - hasChildProcesses: true, - childProcessEvidence: 'unverifiable' - }) - }) - - it('samples child evidence after foreground inspection', async () => { - let reads = 0 - registerPane('pty-became-busy', () => (reads++ === 0 ? '/bin/zsh' : 'vim'), '/bin/zsh') - - const inspection = await inspectPtyProviderProcess(provider, 'pty-became-busy') - expect(inspection.hasChildProcesses).toBe(true) - expect(inspection.childProcessEvidence).toBe('children') - }) - it('refuses to pair one panes foreground with its replacements children', async () => { registerPane('pty-swapped', '/bin/zsh', '/bin/zsh') resolveForegroundMock.mockImplementation(async () => { @@ -183,47 +120,6 @@ describe('inspectPtyProviderProcess child-process evidence', () => { }) }) -describe('fresh foreground observation', () => { - it.each([ - ['non-shell', 'claude', 'agent'], - ['shell', '-zsh', 'shell'] - ] as const)( - 'keeps a %s observation valid when the wall clock steps back during its read', - async (foregroundKind, processName, expected) => { - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(1_000) - registerPane('pty-clock-step', '/bin/zsh', '/bin/zsh') - observeForegroundMock.mockImplementationOnce(async (): Promise => { - vi.setSystemTime(995) - return { verdict: 'observed', source: 'pty-master', foregroundKind, processName } - }) - const provider = new LocalPtyProvider() - const inspectProcess = vi.fn((id: string, options?: { foregroundOnly?: boolean }) => - provider.inspectProcess(id, options) - ) - try { - expect( - await readLaunchedAgentForeground( - { - supportsForegroundObservation: () => true, - inspectProcess, - getForegroundProcess: async () => null - }, - { remote: false, windows: false }, - 'pty-clock-step', - 'claude' - ) - ).toBe(expected) - await expect(inspectProcess.mock.results[0].value).resolves.toMatchObject({ - foregroundObservation: { verdict: 'observed', capturedAgeMs: 0 } - }) - } finally { - vi.useRealTimers() - } - } - ) -}) - describeOnPosix('inspectPtyProviderProcess on a retired master', () => { const provider = new LocalPtyProvider() diff --git a/src/main/providers/local-pty/local-pty-executable-spawn.test.ts b/src/main/providers/local-pty/local-pty-executable-spawn.test.ts index 053e1196611..92114c2cfb6 100644 --- a/src/main/providers/local-pty/local-pty-executable-spawn.test.ts +++ b/src/main/providers/local-pty/local-pty-executable-spawn.test.ts @@ -46,152 +46,8 @@ vi.mock('../macos-tcc-login-shell', () => ({ wrapShellSpawnForMacosTccAttribution: wrapSpawnMock, hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login' })) - -import { resolveUnixShellPath, spawnShellWithFallback } from './local-pty-executable-spawn' -import { - validateWorkingDirectory, - _resetWorkingDirectoryValidationStateForTest -} from '../working-directory-validation' -import { getFishXdgDataDirsLaunchEnv } from '../../pty/shell-startup/fish-xdg-data-dirs-handoff' - -const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' -const NATIVE_DIR = 'C:\\Users\\jin\\repo' - -describe('validateWorkingDirectory', () => { - beforeEach(() => { - existsSyncMock.mockReset() - statSyncMock.mockReset() - statMock.mockReset() - wslUncDirectoryExistsAsyncMock.mockReset() - _resetWorkingDirectoryValidationStateForTest() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', async () => { - // Windows 9P stat can falsely report missing WSL worktrees; distro-side existence wins. - wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) - existsSyncMock.mockReturnValue(false) - - await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined() - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) - expect(existsSyncMock).not.toHaveBeenCalled() - expect(statMock).not.toHaveBeenCalled() - }) - - it('returns to the event loop while the distro probe is still running', async () => { - const probe = Promise.withResolvers() - wslUncDirectoryExistsAsyncMock.mockReturnValue(probe.promise) - let settled = false - - const validation = validateWorkingDirectory(WSL_UNC_DIR).finally(() => { - settled = true - }) - await new Promise((resolve) => setImmediate(resolve)) - expect(settled).toBe(false) - - probe.resolve(true) - await validation - expect(settled).toBe(true) - }) - - it('rejects a WSL UNC worktree that does not exist inside the distro', async () => { - wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) - - await expect(validateWorkingDirectory(WSL_UNC_DIR)).rejects.toThrow(/does not exist/) - expect(existsSyncMock).not.toHaveBeenCalled() - expect(statMock).not.toHaveBeenCalled() - }) - - it('falls back to the fs check when the distro answer is inconclusive', async () => { - wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) - statMock.mockResolvedValue(dirStats(true)) - - await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined() - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) - expect(statMock).toHaveBeenCalledWith(WSL_UNC_DIR) - expect(existsSyncMock).not.toHaveBeenCalled() - }) - - it('validates native Windows paths via fs without consulting the distro', async () => { - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue(dirStats(true)) - - await expect(validateWorkingDirectory(NATIVE_DIR)).resolves.toBeUndefined() - expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() - expect(existsSyncMock).toHaveBeenCalledWith(NATIVE_DIR) - }) - - it('rejects a missing native Windows path', async () => { - existsSyncMock.mockReturnValue(false) - const previousVersion = process.env.ORCA_APP_VERSION - process.env.ORCA_APP_VERSION = '1.4.178-test' - - try { - await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow( - /does not exist.*orca: 1\.4\.178-test/ - ) - expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() - } finally { - if (previousVersion === undefined) { - delete process.env.ORCA_APP_VERSION - } else { - process.env.ORCA_APP_VERSION = previousVersion - } - } - }) - - it('rejects a native Windows path that exists but is not a directory', async () => { - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue(dirStats(false)) - - await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow(/is not a directory/) - }) -}) - -describe('resolveUnixShellPath', () => { - beforeEach(() => { - existsSyncMock.mockReset() - accessSyncMock.mockReset() - }) - - it('preserves non-absolute shells for execvp PATH and cwd resolution', () => { - expect(resolveUnixShellPath('bash')).toBe('bash') - expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell') - expect(existsSyncMock).not.toHaveBeenCalled() - }) - - it('selects an executable fallback before node-pty can fork a missing shell', () => { - existsSyncMock.mockImplementation((path) => path === '/bin/sh') - accessSyncMock.mockReturnValue(undefined) - - expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh') - expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([ - '/missing/zsh', - '/bin/zsh', - '/bin/bash', - '/bin/sh' - ]) - }) - - it('reports every attempted shell when no executable candidate exists', () => { - existsSyncMock.mockReturnValue(false) - - expect(() => resolveUnixShellPath('/missing/zsh')).toThrow( - 'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)' - ) - }) - - it('dedupes a preferred shell that is already a fallback candidate', () => { - existsSyncMock.mockImplementation((path) => path === '/bin/sh') - accessSyncMock.mockReturnValue(undefined) - - expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh') - expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh']) - }) -}) +import { spawnShellWithFallback } from './local-pty-executable-spawn' +import { _resetWorkingDirectoryValidationStateForTest } from '../working-directory-validation' describe('spawnShellWithFallback macOS TCC login wrapping', () => { let origPlatform: PropertyDescriptor | undefined @@ -215,137 +71,6 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => { vi.restoreAllMocks() }) - it('spawns the primary shell through the login wrapper', () => { - const ptySpawn = vi.fn().mockReturnValue({ pid: 1 }) - - const result = spawnShellWithFallback({ - shellPath: '/bin/zsh', - shellArgs: ['-l'], - cols: 80, - rows: 24, - cwd: '/work', - env: {}, - ptySpawn: ptySpawn as never - }) - - expect(wrapSpawnMock).toHaveBeenCalledWith('/bin/zsh', ['-l'], expect.any(Object)) - expect(ptySpawn).toHaveBeenCalledWith( - '/wrapped/login', - ['wrapped', '/bin/zsh', '-l'], - expect.objectContaining({ cwd: '/work', cols: 80, rows: 24 }) - ) - // Attribution wrapping must retain the actual shell path for identity checks. - expect(result.shellPath).toBe('/bin/zsh') - }) - - it('wraps fallback shells too when the primary fails to spawn', () => { - const ptySpawn = vi - .fn() - .mockImplementationOnce(() => { - throw new Error('primary boom') - }) - .mockReturnValue({ pid: 2 }) - - const result = spawnShellWithFallback({ - shellPath: '/bin/zsh', - shellArgs: ['-l'], - cols: 80, - rows: 24, - cwd: '/work', - env: {}, - ptySpawn: ptySpawn as never - }) - - // A login-wrapped fallback must receive corrected SHELL so login(1) preserves it. - expect(wrapSpawnMock).toHaveBeenLastCalledWith( - '/bin/bash', - ['-l'], - expect.objectContaining({ SHELL: '/bin/bash' }) - ) - expect(ptySpawn).toHaveBeenLastCalledWith( - '/wrapped/login', - ['wrapped', '/bin/bash', '-l'], - expect.objectContaining({ cwd: '/work' }) - ) - expect(result.shellPath).toBe('/bin/bash') - }) - - it('drops the primary shell’s launch env when an unwrapped fallback takes over', () => { - // Unwrapped fallbacks must not inherit ZDOTDIR/features that would wrap an unrelated nested shell. - const zshLaunchEnv = { - ZDOTDIR: '/userdata/shell-ready/zsh', - ORCA_ORIG_ZDOTDIR: '/home/jin', - ORCA_SHELL_FEATURES: 'history' - } - const env: Record = { HOME: '/home/jin', ...zshLaunchEnv } - const ptySpawn = vi - .fn() - .mockImplementationOnce(() => { - throw new Error('primary boom') - }) - .mockReturnValue({ pid: 3 }) - - spawnShellWithFallback({ - shellPath: '/bin/zsh', - shellArgs: ['-l'], - cols: 80, - rows: 24, - cwd: '/work', - env, - ptySpawn: ptySpawn as never, - preLaunchEnv: { - ZDOTDIR: undefined, - ORCA_ORIG_ZDOTDIR: undefined, - ORCA_SHELL_FEATURES: undefined - }, - getShellReadyConfig: (shell) => - shell === '/bin/zsh' ? { args: ['-l'], env: zshLaunchEnv } : { args: null, env: {} } - }) - - expect(env.ORCA_SHELL_FEATURES).toBeUndefined() - expect(env.ZDOTDIR).toBeUndefined() - expect(env.ORCA_ORIG_ZDOTDIR).toBeUndefined() - expect(env.HOME).toBe('/home/jin') - }) - - it.each<[string, Record, Record]>([ - [ - '/opt/homebrew/bin/fish', - { XDG_DATA_DIRS: '/opt/a:/opt/b' }, - getFishXdgDataDirsLaunchEnv('/userdata/wrappers', '/opt/a:/opt/b') - ], - [ - '/bin/zsh', - { ZDOTDIR: '/home/jin/.zsh' }, - { ZDOTDIR: '/userdata/shell-ready/zsh', ORCA_SHELL_FEATURES: 'history' } - ] - ])('hands back the user’s own env when %s falls back', (shellPath, userEnv, launchEnv) => { - const preLaunchEnv = Object.fromEntries( - Object.keys(launchEnv).map((key) => [key, userEnv[key]]) - ) - const env: Record = { HOME: '/home/jin', ...userEnv, ...launchEnv } - const ptySpawn = vi - .fn() - .mockImplementationOnce(() => { - throw new Error('primary boom') - }) - .mockReturnValue({ pid: 5 }) - - const result = spawnShellWithFallback({ - shellPath, - shellArgs: ['-l'], - cols: 80, - rows: 24, - cwd: '/work', - env, - ptySpawn: ptySpawn as never, - preLaunchEnv, - getShellReadyConfig: () => ({ args: null, env: {} }) - }) - - expect(env).toEqual({ HOME: '/home/jin', SHELL: result.shellPath, ...userEnv }) - }) - it('drops the first fallback’s launch env when a second fallback takes over', () => { // Scrub the last attempt keys; a once-only primary scrub would leak the first fallback env. const bashLaunchEnv = { ORCA_SHELL_FEATURES: 'markers', BASH_ENV: '/userdata/bash/rcfile' } diff --git a/src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts b/src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts index 898fad8f9ab..97cf0da6f7d 100644 --- a/src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts +++ b/src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts @@ -1,15 +1,8 @@ import type * as WslPathOwner from '../../../shared/wsl-paths' import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability' -import { assert, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as MacosTccLoginShell from '../macos-tcc-login-shell' import type * as ForegroundProcess from '../agent-foreground-process' -import type { WindowsProcessRow } from '../../windows/windows-process-table' -import { - ptyLastRecognizedForeground, - ptyProcesses, - ptyIncarnations -} from './local-pty-provider-state' -import { inspectionShowsShellAlone } from '../../runtime/run-terminal-shell-alone' const { fullTable, @@ -206,364 +199,9 @@ describe('LocalPtyProvider', () => { resolveScan({ available: true, processName: 'zsh' }) await expect(inspection).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' }) }) - - it('returns false when foreground process matches shell', async () => { - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - expect(await provider.hasChildProcesses(id)).toBe(false) - }) - - it('returns true when foreground process differs from shell', async () => { - mockProc.process = 'node' - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - expect(await provider.hasChildProcesses(id)).toBe(true) - }) - - it('returns false for unknown PTY ids', async () => { - expect(await provider.hasChildProcesses('nonexistent')).toBe(false) - }) }) - it.each([false, true])( - 'reconciles real Windows decision evidence (static=%s)', - async (staticName) => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const actual = await vi.importActual('../agent-foreground-process') - resolveAgentForegroundProcessMock.mockImplementation( - actual.resolveAgentForegroundProcessWithAvailability - ) - mockProc.pid = 100 - mockProc.process = 'powershell.exe' - Object.assign(mockProc, { processNameIsSpawnFile: staticName }) - const { id, incarnationId } = await provider.spawn({ - cols: 80, - rows: 24, - shellOverride: 'powershell.exe' - }) - assert(incarnationId, 'fixture incarnation missing') - const nativeRow = ( - pid: number, - ppid: number, - name: string, - command = name - ): WindowsProcessRow => ({ pid, ppid, name, command, creationTimeMs: pid }) - const shell = nativeRow(100, 1, 'powershell.exe') - const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation') - const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D') - type EvidenceOptions = { - age?: number - detailed?: WindowsProcessRow[] - identities?: WindowsProcessRow[] - scanFailed?: boolean - jobFailed?: boolean - consoleFailed?: boolean - childFailed?: boolean - } - const cases: [string, number | null, WindowsProcessRow[], boolean, EvidenceOptions?][] = [ - ['C1', null, [shell, daemon], true], - ['live young anchor', 120, [shell, orphan], false], - ['live old anchor', 120, [shell, orphan], false, { age: 40_000 }], - ['anchor identity missing', 120, [shell, orphan], false, { detailed: [shell] }], - ['anchor query denied', 120, [shell, nativeRow(120, 999, 'unknown.exe', '')], false], - ['anchored scan failed', 120, [shell, orphan], false, { scanFailed: true }], - ['job unavailable', 120, [shell, orphan], false, { jobFailed: true }], - ['anchor absent', 120, [shell, daemon], true], - ['anchor just exited', 120, [shell], true], - [ - 'foreign anchor', - 120, - [shell, nativeRow(120, 999, 'ssh-agent.exe', 'ssh-agent.exe -D')], - true - ], - ['attached agent', null, [shell, { ...orphan, ppid: 100 }], false], - ['ordinary background job', null, [shell, nativeRow(130, 100, 'sleep.exe')], false], - ['incomplete identity', null, [shell, daemon], false, { identities: [shell] }], - ['console unavailable', null, [shell, daemon], false, { consoleFailed: true }], - ['child read failed', null, [shell, daemon], false, { childFailed: true }], - ['unanchored degraded complete', null, [shell, daemon], true, { scanFailed: true }], - [ - 'unanchored degraded incomplete', - null, - [shell, daemon], - false, - { scanFailed: true, identities: [shell] } - ] - ] - vi.useFakeTimers({ toFake: ['Date'] }) - try { - const seed = (pid: number | null) => - ptyLastRecognizedForeground.set(id, { name: 'claude', pid, at: 1000, steady: null }) - for (const [name, anchor, rows, admits, input = {}] of cases) { - vi.setSystemTime(1000 + (input.age ?? 1000)) - seed(anchor) - readWindowsPtyJobProcessIdsMock.mockReturnValue( - input.jobFailed ? null : new Set(rows.map((row) => row.pid)) - ) - fullTable.mockReset().mockResolvedValue(input.detailed ?? rows) - if (input.scanFailed) { - fullTable.mockRejectedValue(new Error('denied')) - } - identityTable.mockReset().mockResolvedValue(input.identities ?? rows) - if (input.childFailed) { - identityTable.mockRejectedValue(new Error('denied')) - } - consoleMembers.mockResolvedValue(input.consoleFailed ? null : new Set([100])) - const observed = await provider.inspectProcess(id, { - expectedIncarnationId: incarnationId, - scanChildProcesses: true, - ignoreDetached: true - }) - expect(inspectionShowsShellAlone(observed), name).toBe(admits) - expect(fullTable, name).toHaveBeenCalledTimes(1) - expect(identityTable.mock.calls.length, name).toBe( - input.jobFailed || rows.length === 1 ? 0 : 1 - ) - } - fullTable.mockReset().mockResolvedValue([shell, daemon]) - identityTable.mockReset().mockResolvedValue([shell, daemon]) - consoleMembers.mockResolvedValue(new Set([100])) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 130])) - for (const count of [2, 20]) { - fullTable.mockClear() - identityTable.mockClear() - for (let i = 0; i < count; i++) { - seed(null) - expect( - inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: true })) - ).toBe(true) - } - expect(fullTable).toHaveBeenCalledTimes(count) - expect(identityTable).toHaveBeenCalledTimes(count) - } - seed(null) - fullTable.mockClear() - expect(await provider.getForegroundProcess(id)).toBe('claude') - await expect( - provider.inspectProcess(id, { steadyState: true, ignoreDetached: true }) - ).resolves.toMatchObject({ foregroundProcess: 'claude' }) - expect(fullTable).not.toHaveBeenCalled() - expect( - inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: false })) - ).toBe(false) - seed(120) - fullTable.mockResolvedValue([shell, orphan]) - identityTable.mockResolvedValue([shell, orphan]) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 120])) - expect(await provider.confirmForegroundProcess(id)).not.toBe('claude') - for (const replacement of ['object', 'incarnation']) { - const original = ptyProcesses.get(id) - if (!original) { - throw new Error('fixture PTY missing') - } - fullTable.mockImplementationOnce(async () => { - if (replacement === 'object') { - ptyProcesses.set(id, { ...original }) - } else { - ptyIncarnations.set(id, 'replacement') - } - return [shell] - }) - await expect( - provider.inspectProcess(id, { - expectedIncarnationId: incarnationId, - ignoreDetached: true - }) - ).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' }) - ptyProcesses.set(id, original) - ptyIncarnations.set(id, incarnationId) - } - } finally { - vi.useRealTimers() - } - } - ) - describe('getForegroundProcess', () => { - it('returns the process name', async () => { - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - expect(await provider.getForegroundProcess(id)).toBe('zsh') - }) - - it('uses the spawned Windows shell when node-pty reports only the terminal name', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'xterm-256color' - - const { id } = await provider.spawn({ - cols: 80, - rows: 24, - shellOverride: 'powershell.exe' - }) - - expect(await provider.getForegroundProcess(id)).toBe('powershell.exe') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith( - mockProc.pid, - 'powershell.exe', - expect.any(Object) - ) - }) - - it('returns null for unknown PTY ids', async () => { - expect(await provider.getForegroundProcess('nonexistent')).toBeNull() - }) - - it('keeps a recognized agent across an unavailable scan without adding probes', async () => { - resolveAgentForegroundProcessMock - .mockResolvedValueOnce({ available: true, processName: 'claude' }) - .mockResolvedValueOnce({ available: false, processName: 'powershell.exe' }) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - }) - - it('drops a delayed scan result after the PTY exits', async () => { - let resolveScan!: (resolution: { available: boolean; processName: string }) => void - resolveAgentForegroundProcessMock.mockReturnValue( - new Promise((resolve) => { - resolveScan = resolve - }) - ) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const foreground = provider.getForegroundProcess(id) - exitCb?.({ exitCode: 0 }) - resolveScan({ available: true, processName: 'droid' }) - - await expect(foreground).resolves.toBeNull() - }) - - it('confirms a still-active agent from job membership without a whole-table scan', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock.mockResolvedValue({ - available: true, - processName: 'claude' - }) - // A descendant beyond the shell is still in the pane's job. - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999])) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - // node-pty names only the shell; job membership revalidates the agent without another full scan. - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1) - }) - - it('stops trusting job membership once the cached agent goes stale', async () => { - // An unanchored job superset must age out; WSL detached plumbing can otherwise pin agent identity forever. - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock.mockResolvedValue({ - available: true, - processName: 'claude' - }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999])) - vi.useFakeTimers({ toFake: ['Date'] }) - try { - vi.setSystemTime(1_000_000) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1) - - vi.setSystemTime(1_000_000 + 40_000) - resolveAgentForegroundProcessMock.mockResolvedValue({ - available: true, - processName: 'powershell.exe' - }) - await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('does not refresh the agent age after a degraded revalidation scan', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock - .mockResolvedValueOnce({ available: true, processName: 'claude' }) - .mockResolvedValueOnce({ available: false, processName: null }) - .mockResolvedValue({ available: true, processName: 'powershell.exe' }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999])) - vi.useFakeTimers({ toFake: ['Date'] }) - try { - vi.setSystemTime(1_000_000) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - - vi.setSystemTime(1_000_000 + 40_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - - vi.setSystemTime(1_000_000 + 41_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3) - } finally { - vi.useRealTimers() - } - }) - - it('keeps short-circuiting a live agent instead of scanning on every call', async () => { - // Confirmation must reset age; measuring total runtime would permanently disable cheap rechecks (#9258). - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock.mockResolvedValue({ - available: true, - processName: 'claude' - }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999])) - vi.useFakeTimers({ toFake: ['Date'] }) - try { - vi.setSystemTime(1_000_000) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1) - - vi.setSystemTime(1_000_000 + 40_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - - vi.setSystemTime(1_000_000 + 45_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('falls through to the scan when the job holds only the shell', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock.mockResolvedValue({ - available: true, - processName: 'claude' - }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345])) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - }) - - it('keeps the cached agent when both the console probe and process snapshot are inconclusive', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock - .mockResolvedValueOnce({ available: true, processName: 'claude' }) - .mockResolvedValue({ available: true, processName: null }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(null) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - }) - it('retires an anchored agent immediately when its pid leaves the job, despite a leftover', async () => { // A missing anchor proves exit immediately; detached leftovers cannot preserve that identity. Object.defineProperty(process, 'platform', { @@ -587,72 +225,6 @@ describe('LocalPtyProvider', () => { expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) }) - it('never expires an anchored agent the job still holds, even when scans miss it', async () => { - // A live job anchor outranks incomplete scans and refreshes the age bound. - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock - .mockResolvedValueOnce({ - available: true, - processName: 'claude', - processId: 999 - }) - .mockResolvedValue({ available: true, processName: null }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999])) - vi.useFakeTimers({ toFake: ['Date'] }) - try { - vi.setSystemTime(1_000_000) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - - vi.setSystemTime(1_000_000 + 40_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - - vi.setSystemTime(1_000_000 + 45_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - - vi.setSystemTime(1_000_000 + 80_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3) - } finally { - vi.useRealTimers() - } - }) - - it('holds a restarting agent across a degraded scan instead of reporting an exit', async () => { - // A degraded scan during restart cannot prove exit; retain unanchored identity until a fresh scan re-anchors. - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock - .mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 }) - .mockResolvedValueOnce({ available: false, processName: null }) - .mockResolvedValue({ available: true, processName: 'claude', processId: 1000 }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999])) - vi.useFakeTimers({ toFake: ['Date'] }) - try { - vi.setSystemTime(1_000_000) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 1000])) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - vi.setSystemTime(1_000_000 + 40_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3) - vi.setSystemTime(1_000_000 + 45_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3) - } finally { - vi.useRealTimers() - } - }) - it('drops an anchored agent when the drift recheck proves the pid was recycled', async () => { // A recycled PID still in the job cannot preserve an identity contradicted by the scan. Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -679,40 +251,5 @@ describe('LocalPtyProvider', () => { vi.useRealTimers() } }) - - it('retires the cached agent after verified shell-only membership and a no-agent scan', async () => { - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - mockProc.process = 'powershell.exe' - resolveAgentForegroundProcessMock - .mockResolvedValueOnce({ available: true, processName: 'claude' }) - .mockResolvedValue({ available: true, processName: null }) - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345])) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - await expect(provider.getForegroundProcess(id)).resolves.toBeNull() - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - }) - }) - - describe('confirmForegroundProcess', () => { - it('drops a delayed result after the PTY exits', async () => { - let resolveScan!: (resolution: { available: boolean; processName: string }) => void - resolveAgentForegroundProcessMock.mockReturnValue( - new Promise((resolve) => { - resolveScan = resolve - }) - ) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const confirmation = provider.confirmForegroundProcess(id) - exitCb?.({ exitCode: 0 }) - resolveScan({ available: true, processName: 'droid' }) - - await expect(confirmation).resolves.toBeNull() - }) }) }) diff --git a/src/main/providers/local-pty/local-pty-provider-io-events.test.ts b/src/main/providers/local-pty/local-pty-provider-io-events.test.ts deleted file mode 100644 index b4e7dfb409f..00000000000 --- a/src/main/providers/local-pty/local-pty-provider-io-events.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import type * as WslPathOwner from '../../../shared/wsl-paths' -import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as MacosTccLoginShell from '../macos-tcc-login-shell' - -const { - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - spawnMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock -} = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - statSyncMock: vi.fn(), - accessSyncMock: vi.fn(), - mkdirSyncMock: vi.fn(), - writeFileSyncMock: vi.fn(), - spawnMock: vi.fn(), - prepareMacosTccLoginShellMock: vi.fn(), - resolveAgentForegroundProcessMock: vi.fn(), - readWindowsPtyJobProcessIdsMock: vi.fn(), - killWithDescendantSweepMock: vi.fn(), - isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsAsyncMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({ - ...(await importOriginal()), - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}` -})) - -vi.mock('fs', () => ({ - existsSync: existsSyncMock, - statSync: statSyncMock, - accessSync: accessSyncMock, - mkdirSync: mkdirSyncMock, - writeFileSync: writeFileSyncMock, - chmodSync: vi.fn(), - renameSync: vi.fn(), - rmSync: vi.fn(), - constants: { X_OK: 1 } -})) - -vi.mock('electron', () => ({ - app: { - getPath: vi.fn(() => '/tmp/orca-user-data') - } -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({ - ...(await importOriginal()), - prepareMacosTccLoginShell: prepareMacosTccLoginShellMock -})) - -vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests. -const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' -const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' -vi.mock('../windows-powershell-executable', () => ({ - resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, - resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' - ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] - : [WINDOWS_POWERSHELL_ABS, CMD_ABS], - getWindowsCmdPath: () => CMD_ABS -})) - -vi.mock('../agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) => - resolveAgentForegroundProcessMock(...args) -})) - -vi.mock('../windows-pty-job-membership', () => ({ - readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args), - isWindowsPtyJobReadable: () => true -})) - -vi.mock('../../wsl/filesystem/unc-path-access', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) -})) -vi.mock('../../wsl/discovery/distro-catalog', () => ({ - getDefaultWslDistro: () => 'Ubuntu' -})) - -vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({ - ...(await importOriginal()), - isWslAvailableAsync: () => isWslAvailableAsyncMock() -})) - -vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: createShellPromptReadinessProbeMock -})) - -import { LocalPtyProvider } from './local-pty-provider' -import { - applyLocalPtyProviderMockDefaults, - createLocalPtyMockProcess, - installLocalPtyProviderEnvSandbox, - type LocalPtyMockProcess -} from './local-pty-provider-test-harness' - -describe('LocalPtyProvider', () => { - let provider: LocalPtyProvider - let mockProc: LocalPtyMockProcess - let exitCb: ((info: { exitCode: number; signal?: number }) => void) | undefined - - installLocalPtyProviderEnvSandbox() - - beforeEach(() => { - applyLocalPtyProviderMockDefaults({ - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - it('retains the fallback owner cause when output and exit precede the spawn reply', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockProc.onData.mockImplementation((cb) => { - cb('too many consoles in use, max consoles is 128') - return { dispose: vi.fn() } - }) - mockProc.onExit.mockImplementation((cb) => { - cb({ exitCode: 256 }) - return { dispose: vi.fn() } - }) - const result = await provider.spawn({ cols: 80, rows: 24 }) - expect(result).toMatchObject({ - exitedBeforeSpawnReply: true, - exitCause: { - kind: 'exited', - exitCode: 256, - initialOutputDiagnostic: 'git-bash-console-capacity' - } - }) - }) - - describe('write', () => { - it('writes data to the PTY process', async () => { - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - expect(provider.write(id, 'hello')).toBe(true) - expect(mockProc.write).toHaveBeenCalledWith('hello') - }) - - it('is a no-op for unknown PTY ids', () => { - expect(provider.write('nonexistent', 'hello')).toBe(false) - expect(mockProc.write).not.toHaveBeenCalled() - }) - }) - - describe('resize', () => { - it('resizes the PTY process', async () => { - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - provider.resize(id, 120, 40) - expect(mockProc.resize).toHaveBeenCalledWith(120, 40) - }) - }) - - describe('producer flow control', () => { - it('pauses and resumes the node-pty process directly', async () => { - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - provider.pauseProducer(id) - expect(mockProc.pause).toHaveBeenCalledTimes(1) - provider.resumeProducer(id) - expect(mockProc.resume).toHaveBeenCalledTimes(1) - }) - - it('is a no-op for unknown PTY ids', () => { - expect(() => { - provider.pauseProducer('nonexistent') - provider.resumeProducer('nonexistent') - }).not.toThrow() - expect(mockProc.pause).not.toHaveBeenCalled() - expect(mockProc.resume).not.toHaveBeenCalled() - }) - - it('swallows node-pty throws from a torn-down PTY', async () => { - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - mockProc.pause.mockImplementation(() => { - throw new Error('read EIO') - }) - mockProc.resume.mockImplementation(() => { - throw new Error('read EIO') - }) - expect(() => { - provider.pauseProducer(id) - provider.resumeProducer(id) - }).not.toThrow() - }) - }) - - describe('event listeners', () => { - it('notifies data listeners when PTY produces output', async () => { - const dataHandler = vi.fn() - provider.onData(dataHandler) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const onDataCb = mockProc.onData.mock.calls[0][0] - onDataCb('hello world') - - expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world' }) - }) - - it('classifies startup queries before runtime and public data listeners', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const runtimeData = vi.fn() - const dataHandler = vi.fn() - provider.configure({ onData: runtimeData }) - provider.onData(dataHandler) - const { id } = await provider.spawn({ - cols: 80, - rows: 24, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 - } - }) - const onDataCb = mockProc.onData.mock.calls[0][0] - const query = '\x1b]10;?\x07' - const echo = ']10;rgb:2e2e/3434/3434\\' - - onDataCb(query) - onDataCb(echo) - onDataCb('prompt') - - expect(mockProc.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\') - expect(runtimeData.mock.calls.map((call) => call.slice(1))).toEqual([ - ['', expect.any(Number), query.length, true], - ['', expect.any(Number), echo.length, true], - ['prompt', expect.any(Number)] - ]) - expect(dataHandler.mock.calls.map(([payload]) => payload)).toEqual([ - { - id, - data: '', - sequenceChars: query.length, - seq: query.length, - transformed: true, - runtimeFed: true - }, - { - id, - data: '', - sequenceChars: echo.length, - seq: query.length + echo.length, - transformed: true, - runtimeFed: true - }, - { id, data: 'prompt', runtimeFed: true } - ]) - }) - - it('answers a native Windows OSC color query from pushed colours before renderer delivery', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const dataHandler = vi.fn() - provider.onData(dataHandler) - const { id } = await provider.spawn({ - cols: 80, - rows: 24, - shellOverride: 'powershell.exe' - }) - const onDataCb = mockProc.onData.mock.calls[0][0] - const query = '\x1b]10;?\x07' - - onDataCb(query) - provider.setColorQueryReplyColors({ foreground: '#123456', background: '#000000' }) - onDataCb(query) - - expect(dataHandler).toHaveBeenCalledWith({ - id, - data: '', - sequenceChars: query.length, - seq: query.length, - transformed: true - }) - expect(mockProc.write.mock.calls).toEqual([ - ['\x1b]10;rgb:ffff/ffff/ffff\x1b\\'], - ['\x1b]10;rgb:1212/3434/5656\x1b\\'] - ]) - }) - - it('keeps forwarded OSC color replies for a Windows-owned WSL PTY', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const { id } = await provider.spawn({ - cols: 80, - rows: 24, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' - }) - const onDataCb = mockProc.onData.mock.calls[0][0] - const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' - - onDataCb('\x1b]11;?\x07') - provider.write(id, reply) - - expect(mockProc.write).toHaveBeenCalledWith(reply) - }) - - it('notifies exit listeners when PTY exits', async () => { - const exitHandler = vi.fn() - provider.onExit(exitHandler) - const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) - - exitCb?.({ exitCode: 0 }) - - expect(exitHandler).toHaveBeenCalledWith({ - id, - code: 0, - incarnationId, - cause: { kind: 'exited', exitCode: 0 } - }) - }) - - it('reports a signalled death as a signal, not as the zero node-pty pairs with it', async () => { - const exitHandler = vi.fn() - provider.onExit(exitHandler) - const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) - - // node-pty can report SIGKILL as exitCode 0 with signal 9; dropping signal misclassifies crashes (STA-4536). - exitCb?.({ exitCode: 0, signal: 9 }) - - expect(exitHandler).toHaveBeenCalledWith({ - id, - code: 0, - incarnationId, - cause: { kind: 'signaled', signal: 9 } - }) - }) - - it('allows unsubscribing from events', async () => { - const dataHandler = vi.fn() - const unsub = provider.onData(dataHandler) - const { id: _id } = await provider.spawn({ cols: 80, rows: 24 }) - - unsub() - const onDataCb = mockProc.onData.mock.calls[0][0] - onDataCb('hello') - - expect(dataHandler).not.toHaveBeenCalled() - }) - }) -}) diff --git a/src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts deleted file mode 100644 index 04bdd5b7e7a..00000000000 --- a/src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type * as WslPathOwner from '../../../shared/wsl-paths' -import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as MacosTccLoginShell from '../macos-tcc-login-shell' - -const { - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - spawnMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock -} = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - statSyncMock: vi.fn(), - accessSyncMock: vi.fn(), - mkdirSyncMock: vi.fn(), - writeFileSyncMock: vi.fn(), - spawnMock: vi.fn(), - prepareMacosTccLoginShellMock: vi.fn(), - resolveAgentForegroundProcessMock: vi.fn(), - readWindowsPtyJobProcessIdsMock: vi.fn(), - killWithDescendantSweepMock: vi.fn(), - isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsAsyncMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({ - ...(await importOriginal()), - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}` -})) - -vi.mock('fs', () => ({ - existsSync: existsSyncMock, - statSync: statSyncMock, - accessSync: accessSyncMock, - mkdirSync: mkdirSyncMock, - writeFileSync: writeFileSyncMock, - chmodSync: vi.fn(), - renameSync: vi.fn(), - rmSync: vi.fn(), - constants: { X_OK: 1 } -})) - -vi.mock('electron', () => ({ - app: { - getPath: vi.fn(() => '/tmp/orca-user-data') - } -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({ - ...(await importOriginal()), - prepareMacosTccLoginShell: prepareMacosTccLoginShellMock -})) - -vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests. -const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' -const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' -vi.mock('../windows-powershell-executable', () => ({ - resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, - resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' - ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] - : [WINDOWS_POWERSHELL_ABS, CMD_ABS], - getWindowsCmdPath: () => CMD_ABS -})) - -vi.mock('../agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) => - resolveAgentForegroundProcessMock(...args) -})) - -vi.mock('../windows-pty-job-membership', () => ({ - readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args), - isWindowsPtyJobReadable: () => true -})) - -vi.mock('../../wsl/filesystem/unc-path-access', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) -})) -vi.mock('../../wsl/discovery/distro-catalog', () => ({ - getDefaultWslDistro: () => 'Ubuntu' -})) - -vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({ - ...(await importOriginal()), - isWslAvailableAsync: () => isWslAvailableAsyncMock() -})) - -vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: createShellPromptReadinessProbeMock -})) - -import { LocalPtyProvider } from './local-pty-provider' -import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../../pty/shell-startup/posix-shell-startup-command' -import { - applyLocalPtyProviderMockDefaults, - createLocalPtyMockProcess, - installLocalPtyProviderEnvSandbox, - type LocalPtyMockProcess -} from './local-pty-provider-test-harness' - -describe('LocalPtyProvider', () => { - let provider: LocalPtyProvider - let mockProc: LocalPtyMockProcess - let exitCb: ((info: { exitCode: number }) => void) | undefined - - installLocalPtyProviderEnvSandbox() - - beforeEach(() => { - applyLocalPtyProviderMockDefaults({ - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('spawn', () => { - it('delegates markerless Codex startup to the shell wrapper without a PTY write', async () => { - process.env.SHELL = '/bin/zsh' - const command = "codex '--dangerously-bypass-approvals-and-sandbox'" - - await provider.spawn({ cols: 80, rows: 24, command }) - - const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env - expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(command) - expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('startup') - expect(spawnEnv.ORCA_SHELL_FEATURES).not.toContain('ready') - expect(mockProc.write).not.toHaveBeenCalled() - }) - - it('keeps payload-bearing Codex startup in the wrapper readiness path', async () => { - process.env.SHELL = '/bin/zsh' - const command = "codex '--dangerously-bypass-approvals-and-sandbox' 'review this branch'" - - await provider.spawn({ - cols: 80, - rows: 24, - command, - startupCommandDelivery: 'shell-ready' - }) - - const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env - expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(command) - expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('startup') - expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('ready') - expect(mockProc.write).not.toHaveBeenCalled() - }) - - it('scrubs an inherited wrapper startup command from ordinary panes', async () => { - process.env.SHELL = '/bin/zsh' - - await provider.spawn({ - cols: 80, - rows: 24, - env: { [POSIX_SHELL_STARTUP_COMMAND_ENV]: 'poisoned command' } - }) - - const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env - expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() - expect(mockProc.write).not.toHaveBeenCalled() - }) - - it('retains PTY delivery for unsupported local shells without leaking wrapper state', async () => { - vi.useFakeTimers() - try { - process.env.SHELL = '/bin/sh' - const command = "codex '--dangerously-bypass-approvals-and-sandbox'" - - await provider.spawn({ cols: 80, rows: 24, command }) - - const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env - expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() - expect(mockProc.write).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(200) - expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`) - } finally { - vi.useRealTimers() - } - }) - - it('stages a long startup command and types only the line that sources it', async () => { - vi.useFakeTimers() - try { - process.env.SHELL = '/bin/sh' - const command = `claude '${'x'.repeat(600)}'` - - const result = await provider.spawn({ cols: 80, rows: 24, command }) - - expect(result).not.toHaveProperty('startupDelivery') - const staged = writeFileSyncMock.mock.calls.find(([path]) => - String(path).includes('orca-launch-') - ) - expect(staged?.[1]).toContain(`\n${command}\n`) - await vi.advanceTimersByTimeAsync(200) - expect(mockProc.write).toHaveBeenCalledWith(`. '${staged?.[0]}'\r`) - } finally { - vi.useRealTimers() - } - }) - - it('prints a notice in the terminal when it types a line it could not stage', async () => { - vi.useFakeTimers() - try { - process.env.SHELL = '/bin/sh' - const received: string[] = [] - provider.configure({ onData: (_id, data) => received.push(data) }) - writeFileSyncMock.mockImplementationOnce(() => { - throw new Error('ENOSPC: no space left on device') - }) - const command = `claude '${'x'.repeat(600)}'` - - await provider.spawn({ cols: 80, rows: 24, command }) - - expect(received.join('')).toContain( - '[orca] Could not stage the launch command (ENOSPC: no space left on device)' - ) - await vi.advanceTimersByTimeAsync(200) - expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`) - } finally { - vi.useRealTimers() - } - }) - - it('verifies shell identity against the exact spawn PATH', async () => { - provider.configure({ - buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' }) - }) - - await provider.spawn({ - cols: 80, - rows: 24, - command: 'printf ready', - env: { PATH: '/pre-hook/bin' } - }) - - expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/post-hook/bin') - expect(createShellPromptReadinessProbeMock).toHaveBeenCalledWith( - expect.objectContaining({ shellPathEnv: '/post-hook/bin' }) - ) - }) - - // This case consumes the preceding default-shell spawn in mock.calls[0]; the spy accumulates across tests. - it('uses fallback shell readiness when startup-command shell spawn falls back', async () => { - vi.useFakeTimers() - try { - process.env.SHELL = '/usr/bin/fish' - spawnMock.mockImplementationOnce(() => { - throw new Error('fish failed') - }) - spawnMock.mockReturnValue(mockProc) - - await provider.spawn({ cols: 80, rows: 24, command: "printf 'linked issue context'" }) - - expect(spawnMock.mock.calls[0]?.[0]).toBe('/bin/zsh') - await Promise.resolve() - vi.advanceTimersByTime(50) - await Promise.resolve() - expect(mockProc.write).not.toHaveBeenCalled() - - const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void - dataCallback('\x1b]777;orca-shell-ready\x07user@host % ') - await Promise.resolve() - vi.advanceTimersByTime(29) - await Promise.resolve() - expect(mockProc.write).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - await Promise.resolve() - expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\r") - } finally { - vi.useRealTimers() - } - }) - - it('releases held marker-prefix bytes when local shell readiness times out', async () => { - vi.useFakeTimers() - const onData = vi.fn() - provider.configure({ onData }) - try { - await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) - const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void - - dataCallback('\x1b]777;orca-shell-ready') - expect(onData).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1500) - await Promise.resolve() - - expect(onData).toHaveBeenCalledWith( - expect.any(String), - '\x1b]777;orca-shell-ready', - expect.any(Number) - ) - expect(mockProc.write).not.toHaveBeenCalled() - - vi.advanceTimersByTime(200) - await Promise.resolve() - expect(mockProc.write).toHaveBeenCalledWith('printf ready\r') - } finally { - vi.useRealTimers() - } - }) - - it('releases held marker-prefix bytes when local shell exits before readiness', async () => { - const onData = vi.fn() - provider.configure({ onData }) - - await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) - const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void - - dataCallback('\x1b]777;orca-shell-ready') - expect(onData).not.toHaveBeenCalled() - - exitCb?.({ exitCode: 0 }) - - expect(onData).toHaveBeenCalledWith( - expect.any(String), - '\x1b]777;orca-shell-ready', - expect.any(Number) - ) - }) - }) -}) diff --git a/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts b/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts index 71323618c2a..4fcb79bb6db 100644 --- a/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts +++ b/src/main/providers/local-pty/local-pty-provider-shutdown.test.ts @@ -130,9 +130,7 @@ vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({ vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({ createShellPromptReadinessProbe: createShellPromptReadinessProbeMock })) - import { - LOCAL_PTY_FORCE_KILL_RETRY_MS, LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS, LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS } from './local-pty-termination' @@ -182,45 +180,6 @@ describe('LocalPtyProvider', () => { }) describe('shutdown', () => { - it('kills the PTY process', async () => { - // Cleanup replaces kill to prevent recycled-PID SIGHUP; capture the spy before shutdown (docs/fix-pty-fd-leak.md). - const killSpy = mockProc.kill - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id, { immediate: true }) - expect(killSpy).toHaveBeenCalled() - }) - - it('invokes onExit callback via the node-pty exit handler', async () => { - const onExit = vi.fn() - provider.configure({ onExit }) - const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id, { immediate: true }) - expect(onExit).toHaveBeenCalledWith(id, -1, incarnationId, { - kind: 'operator_close' - }) - }) - - it('does not destroy after an intentional Windows shutdown kill', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const killSpy = vi.fn() - const destroySpy = vi.fn(() => { - killSpy() - }) - spawnMock.mockReturnValue({ - ...mockProc, - kill: killSpy, - destroy: destroySpy - }) - - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - const shutdown = provider.shutdown(id, { immediate: true }) - exitCb?.({ exitCode: -1 }) - await shutdown - - expect(killSpy).toHaveBeenCalledTimes(1) - expect(destroySpy).not.toHaveBeenCalled() - }) - it('keeps shutdown and ownership pending until node-pty reports physical exit', async () => { const killSpy = vi.fn() mockProc.kill = killSpy @@ -240,107 +199,6 @@ describe('LocalPtyProvider', () => { expect(provider.hasPty(id)).toBe(false) }) - it('keeps physical-exit tracking when orphan cleanup races immediate shutdown', async () => { - const killSpy = vi.fn(() => { - queueMicrotask(() => exitCb?.({ exitCode: 137 })) - }) - mockProc.kill = killSpy - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const shutdown = provider.shutdown(id, { immediate: true }) - expect(provider.killOrphanedPtys(1)).toEqual([{ id }]) - expect(provider.hasPty(id)).toBe(true) - - await expect(shutdown).resolves.toBeUndefined() - expect(killSpy).toHaveBeenCalledTimes(1) - expect(provider.hasPty(id)).toBe(false) - }) - - it('escalates graceful shutdown before orphan cleanup disables the kill handle', async () => { - vi.useFakeTimers() - try { - const killSpy = vi.fn() - mockProc.kill = killSpy - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const graceful = provider.shutdown(id, { immediate: false }) - expect(killSpy.mock.calls).toEqual([['SIGTERM']]) - - expect(provider.killOrphanedPtys(1)).toEqual([{ id }]) - expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - - // Immediate escalation must cancel the graceful timer before it can invoke the neutralized kill. - await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) - expect(killSpy).toHaveBeenCalledTimes(2) - exitCb?.({ exitCode: 137 }) - await graceful - expect(provider.hasPty(id)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('escalates a graceful shutdown when destructive cleanup joins it', async () => { - const killSpy = vi.fn() - mockProc.kill = killSpy - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const graceful = provider.shutdown(id, { immediate: false }) - const immediate = provider.shutdown(id, { immediate: true }) - expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - - exitCb?.({ exitCode: 137 }) - await Promise.all([graceful, immediate]) - expect(provider.hasPty(id)).toBe(false) - }) - - it('force-kills a POSIX PTY that ignores graceful shutdown', async () => { - vi.useFakeTimers() - try { - const killSpy = vi.fn() - mockProc.kill = killSpy - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const graceful = provider.shutdown(id, { immediate: false }) - expect(killSpy.mock.calls).toEqual([['SIGTERM']]) - - await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) - expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - exitCb?.({ exitCode: 137 }) - await graceful - expect(provider.hasPty(id)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('keeps the kill handle until a graceful-deadline sweep settles when orphan cleanup joins', async () => { - vi.useFakeTimers() - try { - const killSpy = vi.fn() - const destroySpy = vi.fn() - spawnMock.mockReturnValue({ ...mockProc, kill: killSpy, destroy: destroySpy }) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - const table = Promise.withResolvers() - readProcessTableMock.mockReturnValueOnce(table.promise) - - const graceful = provider.shutdown(id, { immediate: false }) - await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) - expect(provider.killOrphanedPtys(1)).toEqual([{ id }]) - expect(destroySpy).not.toHaveBeenCalled() - - table.resolve('') - await vi.waitFor(() => expect(destroySpy).toHaveBeenCalledOnce()) - expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - exitCb?.({ exitCode: 137 }) - await graceful - } finally { - vi.useRealTimers() - } - }) - it('escalates through orphan cleanup when the graceful-deadline sweep it joined is refused', async () => { vi.useFakeTimers() try { @@ -374,50 +232,6 @@ describe('LocalPtyProvider', () => { } }) - it('retries a rejected graceful-deadline SIGKILL before the physical timeout', async () => { - vi.useFakeTimers() - try { - let forceAttempts = 0 - const killSpy = vi.fn((signal: string) => { - if (signal === 'SIGKILL' && forceAttempts++ === 0) { - throw new Error('transient force-kill failure') - } - }) - mockProc.kill = killSpy - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const graceful = provider.shutdown(id, { immediate: false }) - await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) - expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - expect(provider.hasPty(id)).toBe(true) - - await vi.advanceTimersByTimeAsync(LOCAL_PTY_FORCE_KILL_RETRY_MS) - expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']]) - exitCb?.({ exitCode: 137 }) - await graceful - expect(provider.hasPty(id)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('does not double-kill ConPTY when destructive cleanup joins shutdown', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const killSpy = vi.fn() - mockProc.kill = killSpy - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const graceful = provider.shutdown(id, { immediate: false }) - const immediate = provider.shutdown(id, { immediate: true }) - expect(killSpy.mock.calls).toEqual([[]]) - - exitCb?.({ exitCode: 137 }) - await Promise.all([graceful, immediate]) - expect(killSpy).toHaveBeenCalledTimes(1) - expect(provider.hasPty(id)).toBe(false) - }) - it('rejects a physical-exit timeout but retains the owner for a successful retry', async () => { vi.useFakeTimers() try { @@ -454,231 +268,5 @@ describe('LocalPtyProvider', () => { await expect(provider.shutdown(id, { immediate: true })).resolves.toBeUndefined() expect(provider.hasPty(id)).toBe(false) }) - - it('cancels pending shell-ready startup delivery on forced shutdown', async () => { - vi.useFakeTimers() - try { - const { id } = await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) - - await provider.shutdown(id, { immediate: true }) - vi.advanceTimersByTime(2000) - await Promise.resolve() - - expect(mockProc.write).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('is a no-op for unknown PTY ids', async () => { - await provider.shutdown('nonexistent', { immediate: true }) - expect(mockProc.kill).not.toHaveBeenCalled() - }) - - it('waits for an in-flight agent shutdown before reusing the same session id', async () => { - let releaseSweep!: () => void - killWithDescendantSweepMock.mockImplementation( - (_rootPid: number, killRoot: () => void) => - new Promise((resolve) => { - releaseSweep = () => { - killRoot() - resolve() - } - }) - ) - const spawnArgs = { - cols: 80, - rows: 24, - sessionId: 'stable-agent-session', - launchAgent: 'claude' as const - } - const spawnCallsBefore = spawnMock.mock.calls.length - const { id } = await provider.spawn(spawnArgs) - - const shutdown = provider.shutdown(id, { immediate: true }) - const respawn = provider.spawn(spawnArgs) - await Promise.resolve() - expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 1) - - releaseSweep() - await shutdown - await respawn - expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 2) - }) - - it('coalesces duplicate shutdown while descendant sweep is pending', async () => { - let releaseSweep!: () => void - killWithDescendantSweepMock.mockImplementation( - (_rootPid: number, killRoot: () => void) => - new Promise((resolve) => { - releaseSweep = () => { - killRoot() - resolve() - } - }) - ) - const { id } = await provider.spawn({ - cols: 80, - rows: 24, - launchAgent: 'claude' - }) - - const first = provider.shutdown(id, { immediate: true }) - const second = provider.shutdown(id, { immediate: true }) - expect(killWithDescendantSweepMock).toHaveBeenCalledOnce() - releaseSweep() - await Promise.all([first, second]) - expect(killWithDescendantSweepMock).toHaveBeenCalledOnce() - }) - - it('does not terminate descendants after the tracked root exits mid-sweep', async () => { - const terminateDescendants = vi.fn() - let releaseSweep!: () => void - killWithDescendantSweepMock.mockImplementation( - (_rootPid: number, killRoot: () => void, deps?: { ownsRoot?: () => boolean }) => - new Promise((resolve) => { - releaseSweep = () => { - // Production killWithDescendantSweep only signals descendants while ownsRoot. - if (deps?.ownsRoot?.() ?? true) { - terminateDescendants() - } - killRoot() - resolve() - } - }) - ) - const { id } = await provider.spawn({ - cols: 80, - rows: 24, - launchAgent: 'claude' - }) - - const shutdown = provider.shutdown(id, { immediate: true }) - exitCb?.({ exitCode: 0 }) - releaseSweep() - await shutdown - - expect(terminateDescendants).not.toHaveBeenCalled() - }) - - it('win32 immediate shutdown of a plain shell taskkills the descendant tree', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await provider.shutdown(id, { immediate: true }) - - // Windows orphan descendants hold ConPTY and cwd open; tree sweep allows worktree removal. - expect(killWithDescendantSweepMock).toHaveBeenCalledWith( - mockProc.pid, - expect.any(Function), - expect.objectContaining({ ownsRoot: expect.any(Function) }) - ) - }) - - it('win32 graceful shutdown of a plain shell does not taskkill the tree', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await provider.shutdown(id, { immediate: false }) - - expect(killWithDescendantSweepMock).not.toHaveBeenCalled() - }) - - it('POSIX immediate shutdown sweeps detached OMP tools without startup recognition', async () => { - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await provider.shutdown(id, { immediate: true }) - - expect(killWithDescendantSweepMock).toHaveBeenCalledWith( - mockProc.pid, - expect.any(Function), - expect.objectContaining({ ownsRoot: expect.any(Function) }) - ) - // Root group SIGKILL must wait for the descendant SIGTERM grace, or Claude skips SessionEnd. - expect(killWithDescendantSweepMock.mock.calls[0][2].awaitEscalation()).toBe(true) - }) - }) - - describe('killAll', () => { - it('kills all PTY processes', async () => { - // Separate processes/callbacks prevent one exit cleanup from neutralizing or removing the other PTY. - let exit1: ((e: { exitCode: number }) => void) | undefined - let exit2: ((e: { exitCode: number }) => void) | undefined - const mock1Kill = vi.fn(() => exit1?.({ exitCode: -1 })) - const mock2Kill = vi.fn(() => exit2?.({ exitCode: -1 })) - spawnMock - .mockReturnValueOnce({ - ...mockProc, - kill: mock1Kill, - onExit: vi.fn((cb) => { - exit1 = cb - }) - }) - .mockReturnValueOnce({ - ...mockProc, - kill: mock2Kill, - onExit: vi.fn((cb) => { - exit2 = cb - }) - }) - - await provider.spawn({ cols: 80, rows: 24 }) - await provider.spawn({ cols: 80, rows: 24 }) - - provider.killAll() - - expect(mock1Kill).toHaveBeenCalled() - expect(mock2Kill).toHaveBeenCalled() - const list = await provider.listProcesses() - expect(list).toHaveLength(0) - }) - - it('force-kills an OMP PTY root during app quit', async () => { - const killSpy = vi.fn() - spawnMock.mockReturnValue({ - ...mockProc, - kill: killSpy - }) - - await provider.spawn({ cols: 80, rows: 24, launchAgent: 'omp' }) - - provider.killAll() - - expect(killSpy).toHaveBeenCalledWith('SIGKILL') - }) - - it('does not destroy after intentional Windows orphan kills', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const destroySpy = vi.fn() - const killSpy = vi.fn() - spawnMock.mockReturnValue({ - ...mockProc, - kill: killSpy, - destroy: destroySpy - }) - - await provider.spawn({ cols: 80, rows: 24 }) - - provider.killAll() - - expect(killSpy).toHaveBeenCalledTimes(1) - expect(destroySpy).not.toHaveBeenCalled() - }) - - it('settles an overlapping shutdown when app quit takes final ownership', async () => { - mockProc.kill.mockImplementation(() => undefined) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - const shutdown = provider.shutdown(id, { immediate: true }) - let settled = false - void shutdown.then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - - provider.killAll() - - await expect(shutdown).resolves.toBeUndefined() - }) }) }) diff --git a/src/main/providers/local-pty/local-pty-provider-spawn-cwd-safety.test.ts b/src/main/providers/local-pty/local-pty-provider-spawn-cwd-safety.test.ts deleted file mode 100644 index 188eadba406..00000000000 --- a/src/main/providers/local-pty/local-pty-provider-spawn-cwd-safety.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type * as WslPathOwner from '../../../shared/wsl-paths' -import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as MacosTccLoginShell from '../macos-tcc-login-shell' - -const { - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - spawnMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock -} = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - statSyncMock: vi.fn(), - accessSyncMock: vi.fn(), - mkdirSyncMock: vi.fn(), - writeFileSyncMock: vi.fn(), - spawnMock: vi.fn(), - prepareMacosTccLoginShellMock: vi.fn(), - resolveAgentForegroundProcessMock: vi.fn(), - readWindowsPtyJobProcessIdsMock: vi.fn(), - killWithDescendantSweepMock: vi.fn(), - isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsAsyncMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({ - ...(await importOriginal()), - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}` -})) - -vi.mock('fs', () => ({ - existsSync: existsSyncMock, - statSync: statSyncMock, - accessSync: accessSyncMock, - mkdirSync: mkdirSyncMock, - writeFileSync: writeFileSyncMock, - chmodSync: vi.fn(), - renameSync: vi.fn(), - rmSync: vi.fn(), - constants: { X_OK: 1 } -})) - -vi.mock('electron', () => ({ - app: { - getPath: vi.fn(() => '/tmp/orca-user-data') - } -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({ - ...(await importOriginal()), - prepareMacosTccLoginShell: prepareMacosTccLoginShellMock -})) - -vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests. -const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' -const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' -vi.mock('../windows-powershell-executable', () => ({ - resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, - resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' - ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] - : [WINDOWS_POWERSHELL_ABS, CMD_ABS], - getWindowsCmdPath: () => CMD_ABS -})) - -vi.mock('../agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) => - resolveAgentForegroundProcessMock(...args) -})) - -vi.mock('../windows-pty-job-membership', () => ({ - readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args), - isWindowsPtyJobReadable: () => true -})) - -vi.mock('../../wsl/filesystem/unc-path-access', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) -})) -vi.mock('../../wsl/discovery/distro-catalog', () => ({ - getDefaultWslDistro: () => 'Ubuntu' -})) - -vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({ - ...(await importOriginal()), - isWslAvailableAsync: () => isWslAvailableAsyncMock() -})) - -vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: createShellPromptReadinessProbeMock -})) - -import { LocalPtyProvider } from './local-pty-provider' -import { - applyLocalPtyProviderMockDefaults, - createLocalPtyMockProcess, - installLocalPtyProviderEnvSandbox, - type LocalPtyMockProcess -} from './local-pty-provider-test-harness' -import { isRootLikePath } from '../pty-path-safety' - -describe('LocalPtyProvider', () => { - let provider: LocalPtyProvider - let mockProc: LocalPtyMockProcess - let exitCb: ((info: { exitCode: number }) => void) | undefined - - installLocalPtyProviderEnvSandbox() - - beforeEach(() => { - applyLocalPtyProviderMockDefaults({ - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('spawn', () => { - it('throws when cwd does not exist', async () => { - existsSyncMock.mockImplementation((p: string) => p !== '/nonexistent') - await expect(provider.spawn({ cols: 80, rows: 24, cwd: '/nonexistent' })).rejects.toThrow( - 'does not exist' - ) - }) - - it('allows an explicitly requested plain shell at POSIX root', async () => { - await provider.spawn({ cols: 80, rows: 24, cwd: '/' }) - - expect(spawnMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ cwd: '/' }) - ) - }) - - it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => { - spawnMock.mockClear() - const origHome = process.env.HOME - // Pin the exact default-home candidate to catch unintended cwd resolution. - process.env.HOME = '/home/testuser' - - try { - // Omitted cwd resolves to safe home; the root-path guard must still allow startup. - await expect( - provider.spawn({ cols: 80, rows: 24, command: 'codex' }) - ).resolves.toBeDefined() - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].cwd).toBe('/home/testuser') - expect(isRootLikePath(spawnCall[2].cwd)).toBe(false) - } finally { - if (origHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = origHome - } - } - }) - - it('rejects automatic agent startup at POSIX root', async () => { - spawnMock.mockClear() - - await expect( - provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' }) - ).rejects.toThrow(/requires a non-root workspace/) - - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', async () => { - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - const originalUserProfile = process.env.USERPROFILE - const originalHomeDrive = process.env.HOMEDRIVE - const originalHomePath = process.env.HOMEPATH - - Object.defineProperty(process, 'platform', { value: 'win32' }) - delete process.env.USERPROFILE - process.env.HOMEDRIVE = 'D:' - process.env.HOMEPATH = '\\Users\\orca' - - try { - await provider.spawn({ cols: 80, rows: 24 }) - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - if (originalUserProfile === undefined) { - delete process.env.USERPROFILE - } else { - process.env.USERPROFILE = originalUserProfile - } - if (originalHomeDrive === undefined) { - delete process.env.HOMEDRIVE - } else { - process.env.HOMEDRIVE = originalHomeDrive - } - if (originalHomePath === undefined) { - delete process.env.HOMEPATH - } else { - process.env.HOMEPATH = originalHomePath - } - } - - expect(spawnMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ cwd: 'D:\\Users\\orca' }) - ) - }) - }) -}) diff --git a/src/main/providers/local-pty/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty/local-pty-provider-spawn-env.test.ts deleted file mode 100644 index e52decf8106..00000000000 --- a/src/main/providers/local-pty/local-pty-provider-spawn-env.test.ts +++ /dev/null @@ -1,655 +0,0 @@ -import type * as WslPathOwner from '../../../shared/wsl-paths' -import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability' -import { 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/shell-startup/legacy-terminal-shim-dir' - -const { - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - spawnMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock -} = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - statSyncMock: vi.fn(), - accessSyncMock: vi.fn(), - mkdirSyncMock: vi.fn(), - writeFileSyncMock: vi.fn(), - spawnMock: vi.fn(), - prepareMacosTccLoginShellMock: vi.fn(), - resolveAgentForegroundProcessMock: vi.fn(), - readWindowsPtyJobProcessIdsMock: vi.fn(), - killWithDescendantSweepMock: vi.fn(), - isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsAsyncMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({ - ...(await importOriginal()), - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}` -})) - -vi.mock('fs', () => ({ - existsSync: existsSyncMock, - statSync: statSyncMock, - accessSync: accessSyncMock, - mkdirSync: mkdirSyncMock, - writeFileSync: writeFileSyncMock, - chmodSync: vi.fn(), - renameSync: vi.fn(), - rmSync: vi.fn(), - constants: { X_OK: 1 } -})) - -vi.mock('electron', () => ({ - app: { - getPath: vi.fn(() => '/tmp/orca-user-data') - } -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({ - ...(await importOriginal()), - prepareMacosTccLoginShell: prepareMacosTccLoginShellMock -})) - -vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests. -const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' -const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' -vi.mock('../windows-powershell-executable', () => ({ - resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, - resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => - family === 'pwsh.exe' - ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] - : [WINDOWS_POWERSHELL_ABS, CMD_ABS], - getWindowsCmdPath: () => CMD_ABS -})) - -vi.mock('../agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) => - resolveAgentForegroundProcessMock(...args) -})) - -vi.mock('../windows-pty-job-membership', () => ({ - readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args), - isWindowsPtyJobReadable: () => true -})) - -vi.mock('../../wsl/filesystem/unc-path-access', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) -})) -vi.mock('../../wsl/discovery/distro-catalog', () => ({ - getDefaultWslDistro: () => 'Ubuntu' -})) - -vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({ - ...(await importOriginal()), - isWslAvailableAsync: () => isWslAvailableAsyncMock() -})) - -vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: createShellPromptReadinessProbeMock -})) - -import { LocalPtyProvider } from './local-pty-provider' -import { - applyLocalPtyProviderMockDefaults, - createLocalPtyMockProcess, - installLocalPtyProviderEnvSandbox, - type LocalPtyMockProcess -} from './local-pty-provider-test-harness' - -describe('LocalPtyProvider', () => { - let provider: LocalPtyProvider - let mockProc: LocalPtyMockProcess - let exitCb: ((info: { exitCode: number }) => void) | undefined - - installLocalPtyProviderEnvSandbox() - - beforeEach(() => { - applyLocalPtyProviderMockDefaults({ - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsAsyncMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('spawn', () => { - it('expands variables in PATH before spawning a Windows shell', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: 'C:\\repo', - env: { - ORCA_AGENT_TEAMS_TEAM_ID: 'team-test', - ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local', - PATH: '%orca_path_root%\\agy\\bin;C:\\Windows' - } - }) - - expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe( - 'C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows' - ) - }) - - it('passes explicit pane environment separately from inherited process values', async () => { - const buildSpawnEnv = vi.fn((_id: string, env: Record) => env) - provider.configure({ buildSpawnEnv }) - const env = { XDG_DATA_HOME: '/pane/data' } - await provider.spawn({ cols: 80, rows: 24, env }) - expect(buildSpawnEnv).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ explicitEnv: env }) - ) - }) - - it('invokes buildSpawnEnv callback to customize environment', async () => { - const buildSpawnEnv = vi.fn((_id: string, env: Record) => { - env.CUSTOM_VAR = 'custom-value' - return env - }) - provider.configure({ buildSpawnEnv }) - await provider.spawn({ cols: 80, rows: 24 }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') - }) - - it('re-reads buildSpawnEnv after a reentrant configuration change', async () => { - const initialBuildSpawnEnv = vi.fn((_id: string, env: Record) => env) - const configuredBuildSpawnEnv = vi.fn((_id: string, env: Record) => env) - provider.configure({ - get buildSpawnEnv() { - provider.configure({ buildSpawnEnv: configuredBuildSpawnEnv }) - return initialBuildSpawnEnv - } - }) - - await provider.spawn({ cols: 80, rows: 24 }) - - expect(initialBuildSpawnEnv).not.toHaveBeenCalled() - expect(configuredBuildSpawnEnv).toHaveBeenCalledOnce() - }) - - it.each([ - // Exported fish_history can leak the launching workspace session even when isolation is off (STA-4682). - ['an inherited Orca session', 'orca_abc123', undefined], - ['a user value', 'mine', 'mine'] - ])('history isolation off: %s', async (_kind, inherited, expected) => { - const previous = process.env.fish_history - process.env.fish_history = inherited - try { - // Missing workspace id exercises history-off cleanup. - await provider.spawn({ cols: 80, rows: 24 }) - } finally { - if (previous === undefined) { - delete process.env.fish_history - } else { - process.env.fish_history = previous - } - } - - expect(spawnMock.mock.calls.at(-1)![2].env.fish_history).toBe(expected) - }) - - it.each([ - // Exported HISTFILE can leak the launching workspace history even with isolation off. - [ - 'an inherited Orca path', - '/fake/userData/terminal-history/aabbccddeeff0011/zsh_history', - undefined - ], - ['a user value', '/home/me/.zsh_history', '/home/me/.zsh_history'] - ])('history isolation off: %s HISTFILE', async (_kind, inherited, expected) => { - const previous = process.env.HISTFILE - process.env.HISTFILE = inherited - try { - // Missing workspace id exercises history-off cleanup. - await provider.spawn({ cols: 80, rows: 24 }) - } finally { - if (previous === undefined) { - delete process.env.HISTFILE - } else { - process.env.HISTFILE = previous - } - } - - expect(spawnMock.mock.calls.at(-1)![2].env.HISTFILE).toBe(expected) - }) - - it('does not inherit NODE_ENV from the Orca process env', async () => { - // Ambient NODE_ENV belongs to the Orca build and can break terminal next build/Vitest. - const previous = process.env.NODE_ENV - process.env.NODE_ENV = 'development' - try { - await provider.spawn({ cols: 80, rows: 24 }) - } finally { - if (previous === undefined) { - delete process.env.NODE_ENV - } else { - process.env.NODE_ENV = previous - } - } - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.NODE_ENV).toBeUndefined() - 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 () => { - const previous = process.env.NODE_ENV - process.env.NODE_ENV = 'development' - try { - await provider.spawn({ cols: 80, rows: 24, env: { NODE_ENV: 'production' } }) - } finally { - if (previous === undefined) { - delete process.env.NODE_ENV - } else { - process.env.NODE_ENV = previous - } - } - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.NODE_ENV).toBe('production') - }) - - it('suppresses the first-run Powerlevel10k wizard for spawned terminals', async () => { - await provider.spawn({ cols: 80, rows: 24 }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('true') - }) - - it('preserves an explicit Powerlevel10k wizard env value', async () => { - await provider.spawn({ - cols: 80, - rows: 24, - env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'already-set' } - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('already-set') - }) - - it('honors requests to delete the Powerlevel10k wizard env value', async () => { - await provider.spawn({ - cols: 80, - rows: 24, - envToDelete: ['POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'] - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBeUndefined() - }) - - it('honors explicit terminal env overrides after deleting requested defaults', async () => { - provider.configure({ - buildSpawnEnv: (_id, env) => { - env.TERM_PROGRAM = 'Orca' - env.ORCA_STALE_TEST_ENV = '/tmp/orca-stale' - env.PATH = `/tmp/orca-stale:${env.PATH ?? ''}` - return env - } - }) - - await provider.spawn({ - cols: 80, - rows: 24, - env: { - TERM: 'screen-256color', - PATH: '/tmp/orca-agent-teams-bin:/usr/bin', - ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' - }, - envToDelete: ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV'] - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].name).toBe('screen-256color') - expect(spawnCall[2].env.TERM).toBe('screen-256color') - expect(spawnCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnCall[2].env.TERM_PROGRAM).toBeUndefined() - 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', - 'GIT_CONFIG_KEY_0', - 'GIT_CONFIG_VALUE_0', - 'GIT_CONFIG_KEY_1', - 'GIT_CONFIG_VALUE_1' - ] as const - const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) - process.env.GIT_CONFIG_COUNT = '2' - process.env.GIT_CONFIG_KEY_0 = 'base.zero' - process.env.GIT_CONFIG_VALUE_0 = 'zero' - process.env.GIT_CONFIG_KEY_1 = 'base.one' - process.env.GIT_CONFIG_VALUE_1 = 'one' - - try { - await provider.spawn({ - cols: 80, - rows: 24, - env: { - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'override.zero', - GIT_CONFIG_VALUE_0: 'override' - } - }) - - const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.GIT_CONFIG_COUNT).toBe('1') - expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('override.zero') - expect(spawnEnv.GIT_CONFIG_KEY_1).toBeUndefined() - expect(spawnEnv.GIT_CONFIG_VALUE_1).toBeUndefined() - } finally { - for (const key of keys) { - if (saved[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = saved[key] - } - } - } - }) - - it('does not inherit AppImage runtime env into Linux PTY shells', async () => { - const saved = { - APPIMAGE: process.env.APPIMAGE, - APPDIR: process.env.APPDIR, - ARGV0: process.env.ARGV0, - OWD: process.env.OWD, - APPIMAGE_LIBRARY_PATH: process.env.APPIMAGE_LIBRARY_PATH, - PATH: process.env.PATH, - LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH - } - process.env.APPIMAGE = '/data/apps/orca.appimage' - process.env.APPDIR = '/tmp/.mount_orca123' - process.env.ARGV0 = '/data/apps/orca.appimage' - process.env.OWD = '/home/user/project' - process.env.APPIMAGE_LIBRARY_PATH = '/tmp/.mount_orca123/usr/lib' - process.env.PATH = ['/tmp/.mount_orca123', '/tmp/.mount_orca123/usr/sbin', '/usr/bin'].join( - delimiter - ) - process.env.LD_LIBRARY_PATH = ['/tmp/.mount_orca123/usr/lib', '/opt/audio/lib'].join( - delimiter - ) - - try { - await provider.spawn({ 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.APPIMAGE).toBeUndefined() - expect(env.APPDIR).toBeUndefined() - expect(env.ARGV0).toBeUndefined() - expect(env.OWD).toBeUndefined() - expect(env.APPIMAGE_LIBRARY_PATH).toBeUndefined() - expect(env.PATH).toBe('/usr/bin') - expect(env.LD_LIBRARY_PATH).toBe('/opt/audio/lib') - }) - - it('does not forward a half-activated conda env into the shell', async () => { - // CONDA_SHLVL without CONDA_PREFIX breaks user conda hooks (#14195). - const saved = { - CONDA_SHLVL: process.env.CONDA_SHLVL, - CONDA_PREFIX: process.env.CONDA_PREFIX, - CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV, - CONDA_PROMPT_MODIFIER: process.env.CONDA_PROMPT_MODIFIER, - CONDA_EXE: process.env.CONDA_EXE - } - delete process.env.CONDA_PREFIX - process.env.CONDA_SHLVL = '1' - process.env.CONDA_DEFAULT_ENV = 'base' - process.env.CONDA_PROMPT_MODIFIER = '(base) ' - process.env.CONDA_EXE = '/opt/miniconda3/bin/conda' - - try { - await provider.spawn({ 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.CONDA_SHLVL).toBeUndefined() - expect(env.CONDA_DEFAULT_ENV).toBeUndefined() - expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() - expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') - }) - - it('drops the conda sentinel when the client asks to delete CONDA_PREFIX', async () => { - // envToDelete runs after the scrub, so coherence repair must run last. - const saved = { - CONDA_SHLVL: process.env.CONDA_SHLVL, - CONDA_PREFIX: process.env.CONDA_PREFIX, - CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV - } - process.env.CONDA_SHLVL = '1' - process.env.CONDA_PREFIX = '/opt/miniconda3' - process.env.CONDA_DEFAULT_ENV = 'base' - - try { - await provider.spawn({ cols: 80, rows: 24, envToDelete: ['CONDA_PREFIX'] }) - } 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.CONDA_PREFIX).toBeUndefined() - expect(env.CONDA_SHLVL).toBeUndefined() - expect(env.CONDA_DEFAULT_ENV).toBeUndefined() - }) - - it('uses shell wrapper when MiMo home must survive shell startup', async () => { - provider.configure({ - buildSpawnEnv: (_id, env) => { - env.MIMOCODE_CONFIG_DIR = '/tmp/orca-mimocode-overlay' - env.ORCA_MIMOCODE_CONFIG_DIR = '/tmp/orca-mimocode-overlay' - return env - } - }) - - await provider.spawn({ cols: 80, rows: 24 }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[1]).toEqual(['-l']) - expect(spawnCall[2].env.ZDOTDIR).toMatch(/shell-ready[\\/]zsh/) - expect(spawnCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready') - }) - - it('promotes the agent-teams shim onto the Windows `Path` spelling', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - buildSpawnEnv: (_id, env) => { - // Why: host env collapses Windows PATH onto `Path` and prepends its own shim dir. - delete env.PATH - env.Path = `/tmp/orca-stale:${env.Path ?? ''}` - return env - } - }) - - await provider.spawn({ - cols: 80, - rows: 24, - env: { - Path: '/tmp/orca-agent-teams-bin:/usr/bin', - ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' - } - }) - - const spawnEnv = spawnMock.mock.calls.at(-1)![2].env - expect(Object.keys(spawnEnv).filter((key) => /^path$/i.test(key))).toEqual(['Path']) - expect(spawnEnv.Path.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') - }) - - it('does not inherit parent Orca pane identity when caller omits pane env', async () => { - const saved = { - ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, - ORCA_TAB_ID: process.env.ORCA_TAB_ID, - ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID, - ORCA_ORCAD_MANAGED_ACTIVATION_ROOT: process.env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT, - ORCA_REMOTE_VIEWER_CLIPBOARD: process.env.ORCA_REMOTE_VIEWER_CLIPBOARD, - ORCA_ORCAD_IDLE_TIMEOUT_MS: process.env.ORCA_ORCAD_IDLE_TIMEOUT_MS, - ORCA_WSL_ORCAD_HOST: process.env.ORCA_WSL_ORCAD_HOST, - ORCA_WSL_DISTRO_GUID: process.env.ORCA_WSL_DISTRO_GUID - } - process.env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT = '/home/u/.orca-remote/.activation' - process.env.ORCA_ORCAD_IDLE_TIMEOUT_MS = '300000' - // A WSL host's own marker and distro id; a user's `orcad` in its shell is not that host. - process.env.ORCA_WSL_ORCAD_HOST = '1' - process.env.ORCA_WSL_DISTRO_GUID = '{0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f}' - process.env.ORCA_REMOTE_VIEWER_CLIPBOARD = '1' - process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' - process.env.ORCA_TAB_ID = 'parent-tab' - process.env.ORCA_WORKTREE_ID = 'parent-worktree' - - try { - await provider.spawn({ 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 spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.ORCA_PANE_KEY).toBeUndefined() - expect(spawnCall[2].env.ORCA_TAB_ID).toBeUndefined() - expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBeUndefined() - expect(spawnCall[2].env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT).toBeUndefined() - expect(spawnCall[2].env.ORCA_REMOTE_VIEWER_CLIPBOARD).toBeUndefined() - expect(spawnCall[2].env.ORCA_ORCAD_IDLE_TIMEOUT_MS).toBeUndefined() - expect(spawnCall[2].env.ORCA_WSL_ORCAD_HOST).toBeUndefined() - expect(spawnCall[2].env.ORCA_WSL_DISTRO_GUID).toBeUndefined() - }) - - it('preserves explicit child Orca pane identity over parent env', async () => { - const saved = { - ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, - ORCA_TAB_ID: process.env.ORCA_TAB_ID, - ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID - } - process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' - process.env.ORCA_TAB_ID = 'parent-tab' - process.env.ORCA_WORKTREE_ID = 'parent-worktree' - - try { - await provider.spawn({ - cols: 80, - rows: 24, - env: { - ORCA_PANE_KEY: 'child-tab:child-leaf', - ORCA_TAB_ID: 'child-tab', - ORCA_WORKTREE_ID: 'child-worktree' - } - }) - } finally { - for (const [key, value] of Object.entries(saved)) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - } - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.ORCA_PANE_KEY).toBe('child-tab:child-leaf') - expect(spawnCall[2].env.ORCA_TAB_ID).toBe('child-tab') - expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBe('child-worktree') - }) - }) -}) diff --git a/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts index 60f0d176431..ec481fe88d1 100644 --- a/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts @@ -133,7 +133,6 @@ vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({ import { LocalPtyProvider } from './local-pty-provider' import { - pendingLocalPtySpawns, ptyDisposables, ptyExitDisposables, ptyPhysicalExits, @@ -218,180 +217,6 @@ describe('LocalPtyProvider', () => { expect(mockProc.write).not.toHaveBeenCalled() }) - it('reattaches to an existing caller-supplied session id without spawning', async () => { - const first = await provider.spawn({ cols: 80, rows: 24, sessionId: 'serve-session-1' }) - spawnMock.mockClear() - - const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id }) - - expect(second).toEqual({ - id: 'serve-session-1', - incarnationId: first.incarnationId, - pid: 12345, - isReattach: true, - // This local reattach resized the PTY; publish the new grid unlike daemon/relay attachments. - attachedGrid: { cols: 120, rows: 40 } - }) - expect(mockProc.resize).toHaveBeenCalledWith(120, 40) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('attaches only to an existing stable session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 'stable-pane-session' }) - spawnMock.mockClear() - - const result = await provider.spawn({ - cols: 120, - rows: 40, - sessionId: 'stable-pane-session', - attachOnly: true - }) - - expect(result).toMatchObject({ id: 'stable-pane-session', isReattach: true }) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('does not create when an attach-only stable session is absent', async () => { - await expect( - provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'missing-stable-pane-session', - attachOnly: true - }) - ).rejects.toThrow('Session not found: missing-stable-pane-session') - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('attaches only to an existing numeric provider session', async () => { - const first = await provider.spawn({ cols: 80, rows: 24 }) - spawnMock.mockClear() - - const result = await provider.spawn({ - cols: 120, - rows: 40, - sessionId: first.id, - attachOnly: true - }) - - expect(result).toMatchObject({ - id: first.id, - incarnationId: first.incarnationId, - isReattach: true - }) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('does not create when a numeric attach-only provider session is absent', async () => { - await expect( - provider.spawn({ cols: 80, rows: 24, sessionId: '404', attachOnly: true }) - ).rejects.toThrow('Session not found: 404') - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('keeps a native UNC session native on a conflicting WSL reattach', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const first = await provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'native-session', - cwd: '\\\\server\\share\\repo', - shellOverride: 'powershell.exe' - }) - spawnMock.mockClear() - - const second = await provider.spawn({ - cols: 120, - rows: 40, - sessionId: first.id, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' - }) - - expect(first.wslDistro).toBeNull() - expect(second.wslDistro).toBeNull() - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('keeps the first WSL distro on a conflicting distro reattach', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const first = await provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'wsl-session', - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - }) - spawnMock.mockClear() - - const second = await provider.spawn({ - cols: 120, - rows: 40, - sessionId: first.id, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }) - - expect(first.wslDistro).toBe('Ubuntu') - expect(second.wslDistro).toBe('Ubuntu') - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('does not reattach numeric caller session ids that can collide after restart', async () => { - const first = await provider.spawn({ cols: 80, rows: 24 }) - spawnMock.mockClear() - - const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id }) - - expect(second.id).not.toBe(first.id) - expect(second.isReattach).toBeUndefined() - expect(spawnMock).toHaveBeenCalledOnce() - }) - - it('does not spawn after shutdown cancels a pending stable session id', async () => { - let finishPreparation!: () => void - spawnMock.mockClear() - prepareMacosTccLoginShellMock.mockImplementationOnce( - () => - new Promise((resolve) => { - finishPreparation = resolve - }) - ) - - const spawn = provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'pending-local-session' - }) - const canceledSpawn = expect(spawn).rejects.toThrow( - 'PTY spawn canceled: pending-local-session' - ) - await vi.waitFor(() => expect(prepareMacosTccLoginShellMock).toHaveBeenCalledOnce()) - - await provider.shutdown('pending-local-session', { immediate: true }) - finishPreparation() - await canceledSpawn - expect(spawnMock).not.toHaveBeenCalled() - - await expect( - provider.spawn({ cols: 80, rows: 24, sessionId: 'pending-local-session' }) - ).resolves.toMatchObject({ id: 'pending-local-session' }) - expect(spawnMock).toHaveBeenCalledOnce() - }) - - it('cancels an immediate stable-session shutdown before any PTY is spawned', async () => { - spawnMock.mockClear() - - const spawn = provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'immediate-shutdown-session' - }) - await provider.shutdown('immediate-shutdown-session', { immediate: true }) - - await expect(spawn).rejects.toThrow('PTY spawn canceled: immediate-shutdown-session') - expect(spawnMock).not.toHaveBeenCalled() - }) - // Hook/trust setup makes env build a long pre-spawn await; shutdown must cancel during it (#16441). it('does not spawn after shutdown cancels a pending spawn during the env build', async () => { spawnMock.mockClear() @@ -451,117 +276,6 @@ describe('LocalPtyProvider', () => { } ) - it.each([1, 2])( - 'settles final-preflight shutdown without a late PTY (%i microtasks)', - async (microtasks) => { - spawnMock.mockClear() - let finishPreparation!: () => void - prepareMacosTccLoginShellMock.mockImplementationOnce( - () => - new Promise((resolve) => { - finishPreparation = resolve - }) - ) - const id = 'preflight-resume-session' - const kill = mockProc.kill - let committed = false - const outcome = provider - .spawn({ - cols: 80, - rows: 24, - sessionId: id, - onPtySpawnCommitted: () => { - committed = true - } - }) - .then( - (result) => ({ ok: true as const, result }), - (error: unknown) => ({ ok: false as const, error }) - ) - await import('node-pty') - await vi.waitFor(() => expect(finishPreparation).toBeTypeOf('function')) - finishPreparation() - let committedAtShutdown = false - await new Promise((resolve, reject) => { - const shutdown = () => { - committedAtShutdown = committed - provider.shutdown(id, { immediate: true }).then(resolve, reject) - } - queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown))) - }) - const settled = await outcome - if (committedAtShutdown) { - expect(settled.ok).toBe(true) - expect(kill).toHaveBeenCalled() - } else { - expect(settled).toEqual({ ok: false, error: new Error(`PTY spawn canceled: ${id}`) }) - expect(spawnMock.mock.calls.length).toBe(0) - } - expect(provider.getPtyProcess(id)).toBeUndefined() - expect(pendingLocalPtySpawns.has(id)).toBe(false) - } - ) - - it('cancels deferred shell availability before finalizing a launch plan', async () => { - spawnMock.mockClear() - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - let finishAvailability!: (available: boolean) => void - const buildSpawnEnv = vi.fn((_id: string, env: Record) => env) - provider.configure({ - getWindowsShell: () => 'powershell.exe', - getWindowsPowerShellImplementation: () => 'auto', - pwshAvailable: () => - new Promise((resolve) => { - finishAvailability = resolve - }), - buildSpawnEnv - }) - const id = 'deferred-availability-session' - const spawn = provider.spawn({ cols: 80, rows: 24, sessionId: id }) - const rejected = expect(spawn).rejects.toThrow(`PTY spawn canceled: ${id}`) - await provider.shutdown(id, { immediate: true }) - finishAvailability(true) - await rejected - expect(buildSpawnEnv).not.toHaveBeenCalled() - expect(spawnMock.mock.calls.length).toBe(0) - expect(pendingLocalPtySpawns.has(id)).toBe(false) - }) - - it.each(['synchronous environment', 'async environment', 'preflight'])( - 'releases cancellation state after failed %s and allows a fresh retry', - async (phase) => { - spawnMock.mockClear() - const id = 'failed-preparation-session' - let fail = true - provider.configure({ - buildSpawnEnv: (_id, env) => { - if (fail && phase === 'synchronous environment') { - throw new Error('preparation failed') - } - if (fail && phase === 'async environment') { - return Promise.reject(new Error('preparation failed')) - } - return env - } - }) - if (phase === 'preflight') { - prepareMacosTccLoginShellMock.mockRejectedValueOnce(new Error('preparation failed')) - } - await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).rejects.toThrow( - 'preparation failed' - ) - expect(pendingLocalPtySpawns.has(id)).toBe(false) - expect(spawnMock.mock.calls.length).toBe(0) - await provider.shutdown(id, { immediate: true }) - fail = false - await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).resolves.toMatchObject({ - id - }) - expect(spawnMock.mock.calls.length).toBe(1) - expect(pendingLocalPtySpawns.has(id)).toBe(false) - } - ) - it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => { spawnMock.mockClear() const procA = { ...mockProc, pid: 1001 } @@ -588,28 +302,5 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledOnce() expect(procA.resize).toHaveBeenCalledWith(132, 44) }) - - it('invokes onSpawned callback', async () => { - const onSpawned = vi.fn() - provider.configure({ onSpawned }) - const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) - expect(onSpawned).toHaveBeenCalledWith(id, incarnationId) - }) - - it('reports physical commit before post-spawn publication can fail', async () => { - spawnMock.mockClear() - const committed = vi.fn() - provider.configure({ - onSpawned: () => { - throw new Error('post-spawn publication failed') - } - }) - - await expect( - provider.spawn({ cols: 80, rows: 24, onPtySpawnCommitted: committed }) - ).rejects.toThrow('post-spawn publication failed') - expect(spawnMock).toHaveBeenCalledOnce() - expect(committed).toHaveBeenCalledOnce() - }) }) }) diff --git a/src/main/providers/local-pty/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty/local-pty-provider-windows-shell-launch.test.ts index be74f6be7fb..cd975da1dda 100644 --- a/src/main/providers/local-pty/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty/local-pty-provider-windows-shell-launch.test.ts @@ -81,7 +81,6 @@ vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({ const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' -const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe' vi.mock('../windows-powershell-executable', () => ({ resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, @@ -130,8 +129,6 @@ import { installLocalPtyProviderEnvSandbox, type LocalPtyMockProcess } from './local-pty-provider-test-harness' -import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../../pty/shell-startup/powerlevel10k-wizard-env' -import { wslDistroHoldMessage } from '../../../shared/wsl-distro-stopped-hold' describe('LocalPtyProvider', () => { let provider: LocalPtyProvider @@ -169,242 +166,6 @@ describe('LocalPtyProvider', () => { }) describe('spawn', () => { - it('passes the guest Claude pointer and injected-home marker through WSLENV', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - buildSpawnEnv: (_id, env) => ({ - ...env, - CLAUDE_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', - ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', - ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' - }) - }) - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo' - }) - const env = spawnMock.mock.calls.at(-1)?.[2].env - expect(env.WSLENV.split(':')).toEqual( - expect.arrayContaining([ - 'CLAUDE_CONFIG_DIR', - 'ORCA_CLAUDE_PROFILE_POINTER', - 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' - ]) - ) - expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( - '~/.local/share/orca/claude-profiles/selected-wsl-orca' - ) - }) - - it('does not pass a Windows Codex home into WSL terminals', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - buildSpawnEnv: (_id, env) => { - env.CODEX_HOME = 'C:\\Users\\jin\\.codex' - env.ORCA_CODEX_HOME = 'C:\\Users\\jin\\.codex' - return env - } - }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() - expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() - }) - - it('does not pass a WSL managed Codex home into Windows terminals', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - buildSpawnEnv: (_id, env) => { - env.CODEX_HOME = - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' - env.ORCA_CODEX_HOME = - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' - return env - } - }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: 'C:\\Users\\jin\\repo' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() - expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() - }) - - it('preserves an explicit Linux Codex home for WSL terminals', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - buildSpawnEnv: (_id, env) => { - env.CODEX_HOME = '/home/jin/.codex-alt' - return env - } - }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.codex-alt') - expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') - }) - - it('translates a WSL managed Codex home before launching a WSL terminal', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - buildSpawnEnv: (_id, env) => { - env.CODEX_HOME = - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' - env.ORCA_CODEX_HOME = - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' - return env - } - }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.local/share/orca/codex-accounts/a/home') - expect(spawnCall[2].env.ORCA_CODEX_HOME).toBe( - '/home/jin/.local/share/orca/codex-accounts/a/home' - ) - expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') - expect(spawnCall[2].env.WSLENV).toContain('ORCA_CODEX_HOME') - }) - - it('does not pass a WSL managed Codex home into a different WSL distro', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - buildSpawnEnv: (_id, env) => { - env.CODEX_HOME = - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' - env.ORCA_CODEX_HOME = - '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' - return env - } - }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\repo' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() - expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() - }) - - it('uses the preferred WSL distro for Windows cwd WSL terminals', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await provider.spawn({ - cols: 80, - rows: 24, - worktreeId: 'repo-1::C:\\Users\\jin\\repo', - cwd: 'C:\\Users\\jin\\repo', - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[1]).toEqual([ - '-d', - 'Debian', - '--exec', - 'sh', - '-c', - expect.stringContaining("cd '/mnt/c/Users/jin/repo'") - ]) - expect(spawnCall[1][5]).toContain('exec "$_orca_wsl_shell" -l') - expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') - }) - - it.each(['/home/jin/repo', '/a', '/c'])( - 'preserves a POSIX cwd through the preferred WSL distro (%s)', - async (cwd) => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await provider.spawn({ - cols: 80, - rows: 24, - worktreeId: 'repo-1::C:\\Users\\jin\\repo', - cwd, - prevalidatedCwd: `\\\\wsl.localhost\\Debian${cwd.replaceAll('/', '\\')}`, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[1]).toEqual([ - '-d', - 'Debian', - '--exec', - 'sh', - '-c', - expect.stringContaining(`cd '${cwd}'`) - ]) - expect(spawnCall[2].cwd).not.toBe(cwd) - expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() - } - ) - - it('revalidates when cwd evidence does not exactly match the resolved WSL path', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await provider.spawn({ - cols: 80, - rows: 24, - worktreeId: 'repo-1::C:\\Users\\jin\\repo', - cwd: '/a', - prevalidatedCwd: '\\\\wsl.localhost\\Debian\\c', - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }) - - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a') - }) - - it('rejects a WSL cwd the distro reports missing without spawning a shell', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) - const spawnCallsBefore = spawnMock.mock.calls.length - - await expect( - provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\gone' - }) - ).rejects.toThrow(/does not exist/) - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith( - '\\\\wsl.localhost\\Debian\\home\\jin\\gone' - ) - expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore) - }) - it('rejects a canceled spawn while its WSL cwd probe is still pending', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const probeStarted = Promise.withResolvers() @@ -429,396 +190,5 @@ describe('LocalPtyProvider', () => { await canceledSpawn expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore) }) - - it('resolves and persists the default distro for Windows cwd WSL terminals', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const buildSpawnEnv = vi.fn( - ( - _id: string, - env: Record, - _ctx?: { isWsl?: boolean; wslDistro?: string | null } - ) => env - ) - provider.configure({ buildSpawnEnv }) - - const result = await provider.spawn({ - cols: 80, - rows: 24, - worktreeId: 'repo-1::C:\\Users\\jin\\repo', - cwd: 'C:\\Users\\jin\\repo', - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: null - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[1]).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'sh', - '-c', - expect.stringContaining("cd '/mnt/c/Users/jin/repo'") - ]) - expect(buildSpawnEnv.mock.calls[0]?.[2]).toMatchObject({ - isWsl: true, - wslDistro: 'Ubuntu' - }) - expect(result.wslDistro).toBe('Ubuntu') - expect( - (await provider.listProcesses()).find((entry) => entry.id === result.id)?.wslDistro - ).toBe('Ubuntu') - }) - - it('repro: keeps explicit PowerShell 7 selection when the pwsh probe is cold-false', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const pwshAvailable = vi.fn(() => false) - provider.configure({ - getWindowsShell: () => 'powershell.exe', - getWindowsPowerShellImplementation: () => 'pwsh.exe', - pwshAvailable - }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: 'C:\\Users\\jin\\repo' - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe(PWSH7_ABS) - expect(spawnCall[1]).toContain('-EncodedCommand') - expect(pwshAvailable).not.toHaveBeenCalled() - }) - - it('awaits PowerShell availability before resolving an automatic Windows shell', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - let resolveAvailability!: (available: boolean) => void - const pwshAvailable = vi.fn( - () => - new Promise((resolve) => { - resolveAvailability = resolve - }) - ) - provider.configure({ - getWindowsShell: () => 'powershell.exe', - getWindowsPowerShellImplementation: () => 'auto', - pwshAvailable - }) - - const callsBeforeSpawn = spawnMock.mock.calls.length - const spawn = provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' }) - await Promise.resolve() - expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn) - - resolveAvailability(true) - await spawn - expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn + 1) - expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS) - }) - - it('re-reads Windows shell options after a reentrant configuration change', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const initialPowerShellImplementation = vi.fn(() => 'powershell.exe' as const) - const configuredPwshAvailable = vi.fn(() => true) - provider.configure({ - getWindowsShell: () => { - provider.configure({ - getWindowsPowerShellImplementation: () => 'auto', - pwshAvailable: configuredPwshAvailable - }) - return 'powershell.exe' - }, - getWindowsPowerShellImplementation: initialPowerShellImplementation - }) - - await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' }) - - expect(initialPowerShellImplementation).not.toHaveBeenCalled() - expect(configuredPwshAvailable).toHaveBeenCalledOnce() - expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS) - }) - - it('registers a probe-complete spawn before a later microtask shutdown', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - provider.configure({ - getWindowsShell: () => 'powershell.exe', - getWindowsPowerShellImplementation: () => 'auto', - pwshAvailable: () => false - }) - const callsBeforeSpawn = spawnMock.mock.calls.length - const spawn = provider.spawn({ - cols: 80, - rows: 24, - cwd: 'C:\\Users\\jin\\repo', - sessionId: 'probe-shutdown-session' - }) - const canceledSpawn = expect(spawn).rejects.toThrow( - 'PTY spawn canceled: probe-shutdown-session' - ) - const shutdown = new Promise((resolve, reject) => { - queueMicrotask(() => { - provider.shutdown('probe-shutdown-session', { immediate: true }).then(resolve, reject) - }) - }) - - await shutdown - await canceledSpawn - expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn) - }) - - it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const savedCodexHome = process.env.CODEX_HOME - const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME - delete process.env.CODEX_HOME - delete process.env.ORCA_CODEX_HOME - provider.configure({ - buildSpawnEnv: (_id, env, ctx) => { - env.ORCA_TERMINAL_HANDLE = 'term_wsl' - if (ctx?.isWsl) { - env.WSLENV = 'ORCA_TERMINAL_HANDLE/u' - } - return env - } - }) - - try { - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', - env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' } - }) - } finally { - if (savedCodexHome === undefined) { - delete process.env.CODEX_HOME - } else { - process.env.CODEX_HOME = savedCodexHome - } - if (savedOrcaCodexHome === undefined) { - delete process.env.ORCA_CODEX_HOME - } else { - process.env.ORCA_CODEX_HOME = savedOrcaCodexHome - } - } - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') - expect(spawnCall[2].env.WSLENV?.split(':')).toEqual( - expect.arrayContaining([ - 'ORCA_TERMINAL_HANDLE/u', - 'ORCA_HERMES_STARTUP_QUERY', - POWERLEVEL10K_WIZARD_DISABLE_ENV - ]) - ) - }) - - it('does not mark deleted Powerlevel10k wizard env for WSL import', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', - envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV] - }) - - const spawnCall = spawnMock.mock.calls.at(-1)! - expect(spawnCall[0]).toBe('wsl.exe') - expect(spawnCall[2].env[POWERLEVEL10K_WIZARD_DISABLE_ENV]).toBeUndefined() - expect(spawnCall[2].env.WSLENV ?? '').not.toContain(POWERLEVEL10K_WIZARD_DISABLE_ENV) - }) - - it('launches POSIX cwd split panes through WSL when worktree context is WSL', async () => { - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'win32' }) - - try { - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '/home/jin/repo/subdir', - shellOverride: 'powershell.exe', - worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - }) - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - } - - expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--exec', - 'sh', - '-c', - expect.stringContaining("cd '/home/jin/repo/subdir'") - ], - expect.objectContaining({ cwd: expect.any(String) }) - ) - }) - - it('resolves the Git Bash default shell and preserves the requested cwd', async () => { - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - const originalProgramFiles = process.env.ProgramFiles - Object.defineProperty(process, 'platform', { value: 'win32' }) - process.env.ProgramFiles = 'C:\\Program Files' - provider.configure({ - getWindowsShell: () => 'git-bash', - buildSpawnEnv: (_id, env) => ({ - ...env, - ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT - }) - }) - - try { - await provider.spawn({ - cols: 80, - rows: 24, - cwd: 'C:\\Users\\jin\\repo' - }) - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - if (originalProgramFiles === undefined) { - delete process.env.ProgramFiles - } else { - process.env.ProgramFiles = originalProgramFiles - } - } - - expect(spawnMock).toHaveBeenCalledWith( - 'C:\\Program Files\\Git\\bin\\bash.exe', - [ - '-c', - expect.stringMatching( - /^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/ - ) - ], - expect.objectContaining({ - cwd: 'C:\\Users\\jin\\repo', - env: expect.objectContaining({ - CHERE_INVOKING: '1', - PYTHONUTF8: '1', - ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT - }) - }) - ) - }) - - it('runs the Codex preflight once in the cmd.exe startup chain', async () => { - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'win32' }) - provider.configure({ - getWindowsShell: () => 'cmd.exe', - buildSpawnEnv: (_id, env) => ({ - ...env, - ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT - }) - }) - - try { - await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' }) - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - } - - expect(spawnMock).toHaveBeenCalledWith( - 'cmd.exe', - [ - '/K', - 'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1' - ], - expect.objectContaining({ - env: expect.objectContaining({ - ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT, - ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"' - }) - }) - ) - }) - - it('holds a daemon-off passive restore of a stopped WSL distro without spawning wsl.exe', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - readWslDistroRunningStateMock.mockResolvedValue('stopped') - spawnMock.mockClear() - - await expect( - provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo', - sessionId: 'restored-session', - wslPassiveRestore: true - }) - ).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped')) - expect(readWslDistroRunningStateMock).toHaveBeenCalledWith('Ubuntu', { - retryUnverifiable: true - }) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('restores a daemon-off WSL pane once its distro reads running', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - readWslDistroRunningStateMock.mockResolvedValue('running') - - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo', - sessionId: 'restored-session', - wslPassiveRestore: true - }) - expect(spawnMock.mock.calls.at(-1)?.[0]).toBe('wsl.exe') - }) - - // A UNC cwd runs in the guest whatever shell was picked, so the cwd alone names the distro. - describe('a non-WSL shell whose cwd is a WSL UNC path', () => { - const uncSpawn = (cwd: string, extra: { wslPassiveRestore?: true } = {}) => - provider.spawn({ - cols: 80, - rows: 24, - cwd, - sessionId: 'wt-1::C:/folder@@pane1', - shellOverride: 'powershell.exe', - ...extra - }) - - beforeEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - readWslDistroRunningStateMock.mockReset() - readWslDistroRunningStateMock.mockResolvedValue('stopped') - spawnMock.mockClear() - }) - - it.each([ - ['\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo'], - ['\\\\wsl$\\Ubuntu\\home\\fake\\repo'] - ])('holds its passive restore while the distro is stopped (%s)', async (cwd) => { - await expect(uncSpawn(cwd, { wslPassiveRestore: true })).rejects.toThrow( - wslDistroHoldMessage('Ubuntu', 'stopped') - ) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it("starts the user's own launch without reading the distro", async () => { - await uncSpawn('\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo') - expect(spawnMock.mock.calls.at(-1)?.[0]).toBe('wsl.exe') - expect(readWslDistroRunningStateMock).not.toHaveBeenCalled() - }) - - it('leaves a host cwd alone', async () => { - await uncSpawn('C:\\folder', { wslPassiveRestore: true }) - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(readWslDistroRunningStateMock).not.toHaveBeenCalled() - }) - }) }) }) diff --git a/src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts index 8fa02e52e74..9bbc0c9dd32 100644 --- a/src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty/local-pty-shell-ready-wrapper-generation.test.ts @@ -1,91 +1,17 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { describePosix, - importFreshLocalPtyShellReady, restoreUserDataPathAfterEach, setTestUserDataPath } from './local-pty-shell-ready-test-harness' import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' -import { getZshShellReadyWrapperFile } from './local-pty-shell-ready-wrapper-generation' -import { makeUserZdotdir } from '../../pty/shell-startup/zsh-user-config-dir-fixture' -// Wrapper roots are content-addressed; resolve them instead of assuming a fixed path. -import { - getRequiredShellReadyWrapperPaths, - getShellReadyWrapperRoot -} from './local-pty-shell-ready-wrapper-root' -import { buildLocalShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-fileset' restoreUserDataPathAfterEach() -describe('ensureShellReadyWrappersAt', () => { - it('keeps required wrapper paths aligned with generated files', () => { - const root = '/tmp/orca-shell-ready' - expect(getRequiredShellReadyWrapperPaths(root)).toEqual( - buildLocalShellReadyWrapperFiles(root).map(([path]) => path) - ) - }) - - // Rewriting content-addressed live wrappers adds Windows indexer/antivirus write collisions without changing bytes. - it('does not rewrite a tree that is already present', async () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-warm-tree-')) - try { - setTestUserDataPath(userData) - const generation = await import('./local-pty-shell-ready-wrapper-generation') - const { getShellReadyWrapperRoot: resolveRoot } = - await import('./local-pty-shell-ready-wrapper-root') - expect(generation.ensureShellReadyWrappersAt()).toBe(true) - const rcfile = join(resolveRoot(), 'bash', 'rcfile') - const firstWrite = statSync(rcfile).mtimeMs - - generation.ensureShellReadyWrappersAt() - vi.resetModules() - const fresh = await import('./local-pty-shell-ready-wrapper-generation') - fresh.ensureShellReadyWrappersAt() - - expect(statSync(rcfile).mtimeMs).toBe(firstWrite) - } finally { - rmSync(userData, { recursive: true, force: true }) - } - }) - - it('regenerates a tree whose files went missing', async () => { - const userData = mkdtempSync(join(tmpdir(), 'orca-missing-tree-')) - try { - setTestUserDataPath(userData) - const generation = await import('./local-pty-shell-ready-wrapper-generation') - const { getShellReadyWrapperRoot: resolveRoot } = - await import('./local-pty-shell-ready-wrapper-root') - generation.ensureShellReadyWrappersAt() - const rcfile = join(resolveRoot(), 'bash', 'rcfile') - rmSync(rcfile) - - expect(generation.ensureShellReadyWrappersAt()).toBe(true) - expect(existsSync(rcfile)).toBe(true) - } finally { - rmSync(userData, { recursive: true, force: true }) - } - }) -}) - -describe('shell-ready wrapper root resolution', () => { - // Plain-node daemon forks have no Electron app; use the shared user-data env path. - it('resolves the wrapper root from ORCA_USER_DATA_PATH', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-userdata-env-')) - try { - setTestUserDataPath(root) - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) -}) - const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 const itWithBash = hasBash ? it : it.skip @@ -164,180 +90,6 @@ describePosix('local PTY shell-ready launch config', () => { vi.restoreAllMocks() }) - it('wraps fish launches with a fish_prompt shell-ready marker init command', async () => { - // Fish must wait through its first prompt or agent launch can race Starship initialization (STA-3417). - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - const config = getShellReadyLaunchConfig('/opt/homebrew/bin/fish') - - expect(config.supportsReadyMarker).toBe(true) - // Fish bakes features into init text without an exported feature variable. - expect(config.env).toEqual({}) - expect(config.args?.slice(0, 2)).toEqual(['-l', '-C']) - const init = config.args?.[2] ?? '' - expect(init).toContain('--on-event fish_prompt') - // Why `builtin`: a user-defined printf function would swallow the marker. - expect(init).toContain('builtin printf "\\033]777;orca-shell-ready\\007"') - expect(init).toContain('functions -e __orca_shell_ready_marker') - }) - - it('keeps markerless fish argv untouched and points it at the vendor snippet', async () => { - const { getMarkerlessShellLaunchConfig } = await importFreshLocalPtyShellReady() - - const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish') - - expect(config.args).toBeNull() - expect(config.supportsReadyMarker).toBe(false) - const prefix = config.env.ORCA_FISH_XDG_DATA_DIRS_PREFIX - expect(prefix).toMatch(/\/fish-xdg-data:\/usr\/local\/share:\/usr\/share$/) - expect(config.env).toEqual({ XDG_DATA_DIRS: prefix, ORCA_FISH_XDG_DATA_DIRS_PREFIX: prefix }) - const dataDir = prefix.split(':')[0] - expect( - readFileSync(`${dataDir}/fish/vendor_conf.d/orca-shell-integration.fish`, 'utf8') - ).toContain('set argv --no-daemon $argv') - }) - - it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => { - // Nested Orca launches must not inherit wrapper ZDOTDIR and recurse. - const previousZdotdir = process.env.ZDOTDIR - const previousHome = process.env.HOME - process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh' - process.env.HOME = '/Users/alice' - try { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - if (previousHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = previousHome - } - } - }) - - it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => { - const previousZdotdir = process.env.ZDOTDIR - const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR - const previousHome = process.env.HOME - delete process.env.ZDOTDIR - process.env.ORCA_ORIG_ZDOTDIR = '/some/other/orca/shell-ready/zsh' - process.env.HOME = '/Users/alice' - try { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - if (previousOrigZdotdir === undefined) { - delete process.env.ORCA_ORIG_ZDOTDIR - } else { - process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir - } - if (previousHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = previousHome - } - } - }) - - it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') - expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - // Clear handback state before nested Orca or /etc/zshrc can mistake it for user configuration. - expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') - expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( - zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') - ) - // Restored user ZDOTDIR owns the remaining startup files. - for (const name of ['.zprofile', '.zshrc', '.zlogin']) { - expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false) - } - expect(zshenv).not.toContain('$(emulate') - }) - - it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - // .zshenv defines the deferred registration run once by first-prompt precmd. - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') - expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') - expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') - // Why: add-zle-hook-widget aborts its chain on a non-zero earlier hook (e.g. oh-my-zsh vi-mode); don't register the marker through it. - expect(zshenv).not.toContain('add-zle-hook-widget line-init') - // Re-sourcing must preserve the original widget chain instead of capturing Orca itself. - expect(zshenv).toContain('== "user:__orca_prompt_mark"') - expect(zshenv).toContain('__orca_deferred_init') - }) - - it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - // .zshrc and .zlogin both invoke the single restore epilogue defined in .zshenv. - const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - const bashRc = getBashShellReadyRcfileContent() - const restoreLine = - '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' - const mimoRestoreLine = - '[[ -n "${ORCA_MIMOCODE_CONFIG_DIR:-}" ]] && export MIMOCODE_CONFIG_DIR="${ORCA_MIMOCODE_CONFIG_DIR}"' - const codexRestoreLine = - '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' - const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' - const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' - expect(zshrc).toContain(restoreLine) - expect(bashRc).toContain(restoreLine) - expect(zshrc).toContain(mimoRestoreLine) - expect(bashRc).toContain(mimoRestoreLine) - expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR') - expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR') - expect(zshrc).toContain(codexRestoreLine) - expect(zshrc).toContain(agentTeamsPathRestoreLine) - expect(bashRc).toContain(agentTeamsPathRestoreLine) - expect(bashRc).toContain(codexRestoreLine) - expect(zshrc).not.toContain('ORCA_OMP_CODING_AGENT_DIR') - expect(bashRc).not.toContain('ORCA_OMP_CODING_AGENT_DIR') - expect(zshrc).toContain(ompWrapperLine) - expect(bashRc).toContain(ompWrapperLine) - for (const wrapperFile of [zshrc, bashRc]) { - expect(wrapperFile).not.toContain('prime-agent()') - expect(wrapperFile).not.toContain('__orca_prime_agent') - expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') - expect(wrapperFile).not.toContain('command prime-agent --extension') - } - }) - - it('emits OSC 133 C/D markers in the zsh wrapper', () => { - const zshRc = getZshShellReadyWrapperFile() - expect(zshRc).toContain('printf "\\033]133;D;%s\\007"') - expect(zshRc).toContain('printf "\\033]133;C\\007"') - }) - - itWithBash('runs the bash wrapper without fake C/D markers before the first prompt', async () => { - const output = runInteractiveBashRcfile(getBashShellReadyRcfileContent(), userDataPath) - - expectBashOsc133Lifecycle(output) - }) - itWithBash('emits lifecycle for foreground text ending like an internal hook', () => { const input = 'echo user:__orca_osc133_prompt_done\nfalse\nexit 0\n' const output = runInteractiveBashRcfile(getBashShellReadyRcfileContent(), userDataPath, input) @@ -398,27 +150,6 @@ describePosix('local PTY shell-ready launch config', () => { expectBashOsc133Lifecycle(output) }) - itWithBash( - 'starts the next command after its prompt when the DEBUG trap is removed at a legacy prompt', - () => { - // Why: force the bash <= 5.0 scalar PROMPT_COMMAND dispatch on any bash. - const gate = 'BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1)' - const rcfile = getBashShellReadyRcfileContent() - expect(rcfile).toContain(gate) - const legacyRcfile = rcfile.replaceAll(gate, '0') - writeFileSync(join(userDataPath, '.bash_profile'), "PS1='ORCA_PS1> '\n") - const input = 'trap - DEBUG\nfalse\nexit 0\n' - const output = runInteractiveBashRcfile(legacyRcfile, userDataPath, input) - const startBeforePrompt = new RegExp( - `${String.fromCharCode(27)}]133;A${String.fromCharCode(7)}(?:(?!ORCA_PS1> )[\\s\\S])*${String.fromCharCode(27)}]133;C` - ) - - expect(output).toContain('ORCA_PS1> ') - expect(output).not.toMatch(startBeforePrompt) - expectBashOsc133Lifecycle(output) - } - ) - itWithBash('composes an inherited PROMPT_COMMAND ending in separators', () => { writeFileSync( join(userDataPath, '.bash_profile'), @@ -431,88 +162,4 @@ describePosix('local PTY shell-ready launch config', () => { expect(output).toContain('PROMPT_SEPARATOR') expectBashOsc133Lifecycle(output) }) - - it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => { - const previousZdotdir = process.env.ZDOTDIR - const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh') - process.env.ZDOTDIR = userZdotdir - try { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - } - }) - - it('falls back to HOME when ZDOTDIR is only slashes (e.g. "/")', async () => { - const previousZdotdir = process.env.ZDOTDIR - const previousHome = process.env.HOME - process.env.ZDOTDIR = '/' - process.env.HOME = '/Users/alice' - try { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - if (previousHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = previousHome - } - } - }) - - it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => { - const previousZdotdir = process.env.ZDOTDIR - const userZdotdir = makeUserZdotdir(userDataPath, 'shell-ready', 'zsh-custom') - process.env.ZDOTDIR = userZdotdir - try { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - } - }) - - it('sources the user .zshenv at wrapper top level, not inside a function', async () => { - // Function-scoped .zshenv broke typeset -U path; user configuration must execute at top level (#1737). - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') - // Define required functions before user emulate sh can change parsing semantics. - expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( - zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') - ) - }) - - it('bakes no generation-time path into the zsh hook', async () => { - // Windows-generated wrappers may be sourced through /mnt/c in WSL; generation-time paths cannot be embedded (#8003). - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - expect(zshenv).not.toContain(getShellReadyWrapperRoot()) - expect(zshenv).not.toContain('%x') - }) }) diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index 4c630baeef2..e23f9de19e9 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { spawnSync } from 'node:child_process' import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe' const { existsSyncMock, userInfoMock, runProcessMock, ptyProbeMock } = vi.hoisted(() => ({ @@ -35,469 +34,9 @@ const ACCEPTED_RESULT = { timedOut: false } const REJECTED_RESULT = { code: 1, signal: null, stdout: '', stderr: '', timedOut: false } -const TIMED_OUT_RESULT = { - code: null, - signal: 'SIGKILL' as const, - stdout: '', - stderr: '', - timedOut: true -} const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const -const itOnPosixHost = process.platform === 'win32' ? it.skip : it - -describe('wrapShellSpawnForMacosTccAttribution', () => { - let origPlatform: PropertyDescriptor | undefined - let origDisable: string | undefined - - function setPlatform(value: string): void { - Object.defineProperty(process, 'platform', { configurable: true, value }) - } - - beforeEach(() => { - origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL - delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL - existsSyncMock.mockReturnValue(true) - userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' }) - runProcessMock.mockResolvedValue({ - code: 0, - signal: null, - stdout: 'ORCA_LOGIN_PREFLIGHT_OK', - stderr: '', - timedOut: false - }) - ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME) - resetMacosLoginShellPreflightForTests() - }) - - afterEach(() => { - if (origPlatform) { - Object.defineProperty(process, 'platform', origPlatform) - } - if (origDisable === undefined) { - delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL - } else { - process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable - } - vi.restoreAllMocks() - vi.clearAllMocks() - }) - - it('uses the exact positional login-shell trampoline argv on macOS', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/usr/bin/login', - args: [ - '-flpq', - 'ada', - '/bin/bash', - '--noprofile', - '--norc', - '-p', - '-c', - 'export SHELL="$1"; shift; exec -l -- "$@"', - 'orca-tcc-login', - '/bin/zsh', - '/bin/zsh', - '-l' - ] - }) - expect(runProcessMock).toHaveBeenCalledWith({ - program: '/usr/bin/login', - args: ['-flpq', 'ada', '/usr/bin/printf', 'ORCA_LOGIN_PREFLIGHT_OK'], - // Detached daemons can outlive their worktree; probe from account home. - cwd: '/Users/ada', - maxOutputBytes: 1024, - signal: undefined, - timeoutMs: 500 - }) - }) - - it('spawns the shell directly when login PAM policy rejects the current user', async () => { - setPlatform('darwin') - runProcessMock.mockResolvedValue({ - code: 0, - signal: null, - stdout: 'Login incorrect\nlogin: ', - stderr: '', - timedOut: false - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/bin/zsh', - args: ['-l'] - }) - expect(console.warn).toHaveBeenCalledWith( - '[pty] macOS login(1) preflight failed; spawning shells directly' - ) - }) - - it('caches a conclusive PAM rejection for later terminal spawns', async () => { - setPlatform('darwin') - runProcessMock.mockImplementation(() => { - // login(1) exiting non-zero on its own is a deterministic PAM verdict. - return REJECTED_RESULT - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await prepareMacosTccLoginShell() - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') - expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/bin/bash') - expect(runProcessMock).toHaveBeenCalledTimes(1) - expect(ptyProbeMock).toHaveBeenCalledTimes(1) - expect(console.warn).toHaveBeenCalledTimes(1) - }) - - it('uses the production-shaped PTY verdict when the pipe probe falsely rejects', async () => { - setPlatform('darwin') - runProcessMock.mockReturnValue(REJECTED_RESULT) - ptyProbeMock.mockResolvedValue(ACCEPTED_OUTCOME) - - await expect(prepareMacosTccLoginShell()).resolves.toEqual(ACCEPTED_OUTCOME) - - expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 500, 1_024) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('dedupes concurrent PTY confirmations of a rejected pipe verdict', async () => { - setPlatform('darwin') - runProcessMock.mockReturnValue(REJECTED_RESULT) - let finishPtyProbe!: (outcome: typeof REJECTED_OUTCOME) => void - ptyProbeMock.mockReturnValue( - new Promise((resolve) => { - finishPtyProbe = resolve - }) - ) - - const first = prepareMacosTccLoginShell() - const second = prepareMacosTccLoginShell() - // Wait for probe sharing rather than coupling the assertion to an internal microtask count. - await vi.waitFor(() => expect(ptyProbeMock).toHaveBeenCalledTimes(1)) - expect(runProcessMock).toHaveBeenCalledTimes(1) - - finishPtyProbe(REJECTED_OUTCOME) - await expect(Promise.all([first, second])).resolves.toEqual([ - REJECTED_OUTCOME, - REJECTED_OUTCOME - ]) - }) - - it('re-verifies a cached PAM rejection after the revalidation window (#9756)', async () => { - setPlatform('darwin') - let now = 1_000 - vi.spyOn(Date, 'now').mockImplementation(() => now) - let attempt = 0 - runProcessMock.mockImplementation(() => { - attempt += 1 - // A one-off PAM hiccup that login(1) reports as a deterministic rejection. - return attempt === 1 ? REJECTED_RESULT : ACCEPTED_RESULT - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') - - now += 29 * 60_000 - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(1) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') - - now += 60_000 - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(2) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('keeps an accepted PAM verdict cached across the rejection revalidation window', async () => { - setPlatform('darwin') - let now = 1_000 - vi.spyOn(Date, 'now').mockImplementation(() => now) - - await prepareMacosTccLoginShell() - now += 24 * 60 * 60_000 - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(1) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('backs off repeated transient timeouts instead of delaying every terminal spawn (F1)', async () => { - setPlatform('darwin') - let now = 1_000 - vi.spyOn(Date, 'now').mockImplementation(() => now) - // A probe our own SIGKILL cap killed proves nothing about PAM; it must retry. - runProcessMock.mockReturnValue(TIMED_OUT_RESULT) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const first = await prepareMacosTccLoginShell() - expect(first).toEqual({ ok: false, conclusive: false, reason: 'timeout' }) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') - - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledOnce() - - now += 5_000 - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(2) - - // Backoff prevents repeated hung probes from adding a subprocess to every terminal. - now += 5_000 - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(2) - now += 5_000 - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(3) - }) - - it('self-heals to the login wrapper once a re-probe succeeds (F1)', async () => { - setPlatform('darwin') - let now = 1_000 - vi.spyOn(Date, 'now').mockImplementation(() => now) - let attempt = 0 - runProcessMock.mockImplementation(() => { - attempt += 1 - return attempt === 1 ? TIMED_OUT_RESULT : ACCEPTED_RESULT - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await prepareMacosTccLoginShell() - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledOnce() - now += 5_000 - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(2) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('returns a conclusive outcome the daemon can log structurally (F2)', async () => { - setPlatform('darwin') - runProcessMock.mockResolvedValue({ - code: 0, - signal: null, - stdout: 'Login incorrect\nlogin: ', - stderr: '', - timedOut: false - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const outcome = await prepareMacosTccLoginShell() - expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' }) - expect(await prepareMacosTccLoginShell()).toBeNull() - }) - - it('rejects a marker emitted by a preflight that does not exit cleanly', async () => { - setPlatform('darwin') - // A marker printed by a probe that then hit the timeout cap proves nothing. - runProcessMock.mockReturnValue({ - code: null, - signal: 'SIGKILL' as const, - stdout: 'ORCA_LOGIN_PREFLIGHT_OK', - stderr: '', - timedOut: true - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/bin/zsh', - args: ['-l'] - }) - }) - - it('dedupes concurrent successful preflights and caches later terminal spawns', async () => { - setPlatform('darwin') - - await Promise.all([prepareMacosTccLoginShell(), prepareMacosTccLoginShell()]) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/usr/bin/login') - expect(runProcessMock).toHaveBeenCalledTimes(1) - }) - - it('preserves the non-login Bash shell-ready rcfile launch', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - expect( - wrapShellSpawnForMacosTccAttribution('/bin/bash', ['--rcfile', '/orca/bash/rcfile']) - ).toEqual({ - file: '/usr/bin/login', - args: [ - '-flpq', - 'ada', - '/bin/bash', - '--noprofile', - '--norc', - '-p', - '-c', - 'export SHELL="$1"; shift; exec -- "$@"', - 'orca-tcc-login', - '/bin/bash', - '/bin/bash', - '--rcfile', - '/orca/bash/rcfile' - ] - }) - }) - - it('re-asserts the spawn env SHELL that login(1) would overwrite', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - expect( - wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '/opt/homebrew/bin/fish' }) - ).toEqual({ - file: '/usr/bin/login', - args: [ - '-flpq', - 'ada', - '/bin/bash', - '--noprofile', - '--norc', - '-p', - '-c', - 'export SHELL="$1"; shift; exec -l -- "$@"', - 'orca-tcc-login', - '/opt/homebrew/bin/fish', - '/bin/zsh', - '-l' - ] - }) - }) - - it('falls back to the spawned shell for SHELL when the env value is empty', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '' })).toEqual({ - file: '/usr/bin/login', - args: [ - '-flpq', - 'ada', - '/bin/bash', - '--noprofile', - '--norc', - '-p', - '-c', - 'export SHELL="$1"; shift; exec -l -- "$@"', - 'orca-tcc-login', - '/bin/zsh', - '/bin/zsh', - '-l' - ] - }) - }) - - it('passes spaces and equals signs positionally without interpolation', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - expect( - wrapShellSpawnForMacosTccAttribution( - '/Applications/Custom Shell/bin/fish=debug', - ['--init-command', 'set label=a b'], - { SHELL: '/Applications/Custom Shell/bin/fish=debug' } - ) - ).toEqual({ - file: '/usr/bin/login', - args: [ - '-flpq', - 'ada', - '/bin/bash', - '--noprofile', - '--norc', - '-p', - '-c', - 'export SHELL="$1"; shift; exec -l -- "$@"', - 'orca-tcc-login', - '/Applications/Custom Shell/bin/fish=debug', - '/Applications/Custom Shell/bin/fish=debug', - '--init-command', - 'set label=a b' - ] - }) - }) - - it('terminates exec option parsing before a dash-prefixed shell name', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - const wrapped = wrapShellSpawnForMacosTccAttribution('-custom-shell', ['-l']) - - expect(wrapped.args).toContain('export SHELL="$1"; shift; exec -l -- "$@"') - expect(wrapped.args.slice(-2)).toEqual(['-custom-shell', '-l']) - }) - - itOnPosixHost('ignores inherited BASH_ENV before evaluating the trampoline', () => { - const result = spawnSync( - '/bin/bash', - ['--noprofile', '--norc', '-p', '-c', 'printf "TRAMPOLINE_OK\\n"'], - { - encoding: 'utf8', - env: { ...process.env, BASH_ENV: '/dev/stdin' }, - input: 'printf "BASH_ENV_EXECUTED\\n"\n' - } - ) - - expect(result.status).toBe(0) - expect(result.stdout).toBe('TRAMPOLINE_OK\n') - expect(result.stderr).toBe('') - }) - - it('is a no-op on non-macOS platforms', () => { - setPlatform('linux') - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/bin/zsh', - args: ['-l'] - }) - }) - - it('is idempotent when the file is already /usr/bin/login', () => { - setPlatform('darwin') - const args = ['-flpq', 'ada', '/bin/zsh', '-l'] - expect(wrapShellSpawnForMacosTccAttribution('/usr/bin/login', args)).toEqual({ - file: '/usr/bin/login', - args - }) - }) - - it('falls back to the plain spawn when the login binary is missing', () => { - setPlatform('darwin') - existsSyncMock.mockReturnValue(false) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/bin/zsh', - args: ['-l'] - }) - }) - - it('falls back to the plain spawn when the username cannot be resolved', () => { - setPlatform('darwin') - userInfoMock.mockImplementation(() => { - throw new Error('no user') - }) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/bin/zsh', - args: ['-l'] - }) - }) - - it('falls back to the plain spawn when the username is empty', () => { - setPlatform('darwin') - userInfoMock.mockReturnValue({ username: '', homedir: '/Users/ada' }) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/bin/zsh', - args: ['-l'] - }) - }) - - it('falls back to the plain spawn when disabled via env', () => { - setPlatform('darwin') - process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1' - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/bin/zsh', - args: ['-l'] - }) - expect(runProcessMock).not.toHaveBeenCalled() - }) -}) +void (process.platform === 'win32' ? it.skip : it) describe('probeMacosLoginSessionAlive', () => { let origPlatform: PropertyDescriptor | undefined @@ -537,37 +76,6 @@ describe('probeMacosLoginSessionAlive', () => { vi.clearAllMocks() }) - it('re-probes even after a cached acceptance', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - expect(runProcessMock).toHaveBeenCalledTimes(1) - const outcome = await probeMacosLoginSessionAlive() - expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' }) - expect(runProcessMock).toHaveBeenCalledTimes(2) - }) - - it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => { - setPlatform('darwin') - let finishPreflight!: (result: typeof ACCEPTED_RESULT) => void - runProcessMock.mockImplementation( - () => - new Promise((resolve) => { - finishPreflight = resolve - }) - ) - - const warmup = prepareMacosTccLoginShell() - const freshProbe = probeMacosLoginSessionAlive() - expect(runProcessMock).toHaveBeenCalledOnce() - - finishPreflight(ACCEPTED_RESULT) - await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([ - ACCEPTED_OUTCOME, - ACCEPTED_OUTCOME - ]) - expect(runProcessMock).toHaveBeenCalledOnce() - }) - it('does not let a spawn-path probe overwrite a newer death verdict', async () => { setPlatform('darwin') ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME) @@ -585,132 +93,4 @@ describe('probeMacosLoginSessionAlive', () => { expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') }) - - it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - - runProcessMock.mockReturnValue(REJECTED_RESULT) - ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME) - const outcome = await probeMacosLoginSessionAlive() - expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' }) - // A dead login session must stop creating login(1) prompt zombies (#7936). - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') - }) - - it('does not trust a pipe rejection when its PTY confirmation is inconclusive', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - runProcessMock.mockReturnValue(REJECTED_RESULT) - ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' }) - - await expect(probeMacosLoginSessionAlive()).resolves.toEqual({ - ok: false, - conclusive: false, - reason: 'timeout' - }) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('does not add PTY probes to periodic checks on a host that never accepted login', async () => { - setPlatform('darwin') - runProcessMock.mockReturnValue(REJECTED_RESULT) - ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME) - - await prepareMacosTccLoginShell() - await probeMacosLoginSessionAlive() - await probeMacosLoginSessionAlive() - - expect(runProcessMock).toHaveBeenCalledTimes(3) - expect(ptyProbeMock).toHaveBeenCalledTimes(1) - }) - - it('does not let periodic rejected health probes postpone spawn revalidation', async () => { - setPlatform('darwin') - let now = 1_000 - vi.spyOn(Date, 'now').mockImplementation(() => now) - await prepareMacosTccLoginShell() - runProcessMock.mockReturnValue(REJECTED_RESULT) - ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME) - - await probeMacosLoginSessionAlive() - now += 29 * 60_000 - await probeMacosLoginSessionAlive() - - runProcessMock.mockResolvedValue({ - code: 0, - signal: null, - stdout: 'ORCA_LOGIN_PREFLIGHT_OK', - stderr: '', - timedOut: false - }) - now += 60_000 - await prepareMacosTccLoginShell() - - expect(runProcessMock).toHaveBeenCalledTimes(4) - expect(ptyProbeMock).toHaveBeenCalledTimes(2) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('returns null off macOS and when disabled', async () => { - setPlatform('linux') - expect(await probeMacosLoginSessionAlive()).toBeNull() - setPlatform('darwin') - process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1' - expect(await probeMacosLoginSessionAlive()).toBeNull() - expect(runProcessMock).not.toHaveBeenCalled() - }) - - it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => { - setPlatform('darwin') - runProcessMock.mockReturnValue(TIMED_OUT_RESULT) - const outcome = await probeMacosLoginSessionAlive() - expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' }) - expect(runProcessMock).toHaveBeenCalledOnce() - expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - runProcessMock.mockReturnValue(TIMED_OUT_RESULT) - ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' }) - const outcome = await probeMacosLoginSessionAlive() - expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' }) - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') - }) - - it('stays inconclusive when both pipe and PTY probes time out', async () => { - setPlatform('darwin') - await prepareMacosTccLoginShell() - runProcessMock.mockReturnValue(TIMED_OUT_RESULT) - ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' }) - const outcome = await probeMacosLoginSessionAlive() - expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' }) - // Inconclusive must not disturb the cached acceptance. - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') - }) - - it('does not start a PTY fallback after the watch cancels its pipe probe', async () => { - setPlatform('darwin') - const abortController = new AbortController() - abortController.abort() - // Cancellation proves no PAM verdict. - runProcessMock.mockReturnValue({ - code: null, - signal: 'SIGTERM' as const, - stdout: '', - stderr: '', - timedOut: false - }) - - await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({ - ok: false, - conclusive: false, - reason: 'error' - }) - expect(ptyProbeMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts deleted file mode 100644 index 5227aeca47e..00000000000 --- a/src/main/providers/windows-shell-args.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { buildWslInteractiveLoginShellCommand } from '../../shared/wsl-login-shell-command' -import { WSL_GUEST_ROOT_MARKER_COMMAND } from '../../shared/wsl-guest-root-marker' -import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command' -import { resolveWindowsShellLaunchArgs } from './windows-shell-args' -// Resolve content-addressed wrapper paths instead of assuming a fixed directory. -import { getShellReadyWrapperRoot } from './local-pty/local-pty-shell-ready-wrapper-root' - -const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe' -const POWERSHELL_STARTUP_COMMAND_C_MARK = '[Console]::Write("$([char]27)]133;C$([char]7)")' -const CMD_CODEX_LAUNCH_PREFLIGHT = - 'if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1' - -function expectedWslArgs(linuxCwd: string, distro?: string): string[] { - const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${WSL_GUEST_ROOT_MARKER_COMMAND} && ${buildWslInteractiveLoginShellCommand()}` - // Spell out --exec so a regressed -- separator cannot change both implementation and expected args. - const shellArgs = ['--exec', 'sh', '-c', command] - return distro ? ['-d', distro, ...shellArgs] : shellArgs -} - -function decodePowerShellCommand(result: ReturnType): string { - expect(result.shellArgs.slice(0, 3)).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand']) - return Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') -} - -function expectedPowerShellRestoreCwdCommand(cwdLiteral: string): string { - return `try { Set-Location -LiteralPath ${cwdLiteral} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }` -} - -function getGitBashRcfilePath(command: string): string { - const rcfilePath = command.match(/--rcfile '([^']+)'/)?.[1] - if (!rcfilePath) { - throw new Error(`Git Bash launch command has no rcfile: ${command}`) - } - return rcfilePath -} - -describe('resolveWindowsShellLaunchArgs', () => { - let previousUserDataPath: string | undefined - let userDataPath: string - - beforeEach(() => { - previousUserDataPath = process.env.ORCA_USER_DATA_PATH - userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-')) - process.env.ORCA_USER_DATA_PATH = userDataPath - }) - - afterEach(() => { - if (previousUserDataPath === undefined) { - delete process.env.ORCA_USER_DATA_PATH - } else { - process.env.ORCA_USER_DATA_PATH = previousUserDataPath - } - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { - const result = resolveWindowsShellLaunchArgs( - 'cmd.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - undefined, - CODEX_LAUNCH_PREFLIGHT - ) - expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`]) - expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() - expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('C:\\Users\\alice') - }) - - it('embeds short cmd.exe startup commands in shell args', () => { - const result = resolveWindowsShellLaunchArgs( - 'cmd.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - 'codex --no-alt-screen', - CODEX_LAUNCH_PREFLIGHT - ) - expect(result.shellArgs).toEqual([ - '/K', - `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT} & codex --no-alt-screen` - ]) - expect(result.startupCommandDeliveredInShellArgs).toBe(true) - }) - - it('keeps quoted cmd.exe startup commands on stdin delivery', () => { - // node-pty C-runtime escaping corrupts cmd /K quotes; stdin preserves normal interactive parsing. - const result = resolveWindowsShellLaunchArgs( - 'cmd.exe', - 'C:\\Users\\alice\\repo', - 'C:\\Users\\alice', - undefined, - 'cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"' - ) - expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) - expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() - }) - - it('keeps large cmd.exe startup commands on stdin delivery', () => { - const result = resolveWindowsShellLaunchArgs( - 'cmd.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - `codex ${'x'.repeat(7000)}`, - CODEX_LAUNCH_PREFLIGHT - ) - expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`]) - expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() - }) - - it('returns PowerShell args that install OSC 133 bootstrap after normal profile loading', () => { - const result = resolveWindowsShellLaunchArgs( - 'powershell.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice' - ) - expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) - - const command = decodePowerShellCommand(result) - const outputEncodingIndex = command.indexOf('[Console]::OutputEncoding') - const opencodeRestoreIndex = command.indexOf( - '$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR' - ) - const mimocodeRestoreIndex = command.indexOf( - '$env:MIMOCODE_CONFIG_DIR = $env:ORCA_MIMOCODE_CONFIG_DIR' - ) - const duplicateStateGuardIndex = command.indexOf('Test-Path variable:global:__OrcaOsc133State') - const languageModeGuardIndex = command.indexOf('LanguageMode -eq "FullLanguage"') - const ompWrapperIndex = command.indexOf('function Global:omp') - const ompExtensionIndex = command.indexOf('--extension $env:ORCA_OMP_STATUS_EXTENSION') - const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME') - const promptIndex = command.indexOf('function Global:prompt') - const cwdRestoreIndex = command.indexOf( - expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'") - ) - - expect(command).not.toContain('$PROFILE') - expect(command).not.toContain('ORCA_PI_CODING_AGENT_DIR') - expect(command).not.toContain('ORCA_OMP_CODING_AGENT_DIR') - expect(command).not.toContain('$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_SOURCE_AGENT_DIR') - for (const restoreIndex of [opencodeRestoreIndex, mimocodeRestoreIndex, codexRestoreIndex]) { - expect(restoreIndex).toBeGreaterThanOrEqual(0) - expect(restoreIndex).toBeLessThan(duplicateStateGuardIndex) - expect(restoreIndex).toBeLessThan(languageModeGuardIndex) - } - expect(outputEncodingIndex).toBeGreaterThan(languageModeGuardIndex) - expect(outputEncodingIndex).toBeGreaterThan(duplicateStateGuardIndex) - expect(ompWrapperIndex).toBeGreaterThan(outputEncodingIndex) - expect(ompExtensionIndex).toBeGreaterThan(ompWrapperIndex) - expect(promptIndex).toBeGreaterThan(ompWrapperIndex) - expect(cwdRestoreIndex).toBeGreaterThan(promptIndex) - expect(command).toContain('Esc = [char]27') - expect(command).toContain('Bel = [char]7') - expect(command).toContain(')]133;D;$fakeExitCode$(') - expect(command).toContain(')]133;C$(') - expect(command).not.toContain('`e]133') - }) - - it('normalizes MSYS drive cwd before spawning native PowerShell', () => { - const result = resolveWindowsShellLaunchArgs( - 'powershell.exe', - '/c/Users/alice/project', - 'C:\\Users\\alice' - ) - - expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project') - expect(result.validationCwd).toBe('C:\\Users\\alice\\project') - expect(decodePowerShellCommand(result)).toContain( - expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\project'") - ) - }) - - it('quotes the PowerShell cwd restore command literally', () => { - const result = resolveWindowsShellLaunchArgs( - 'powershell.exe', - "C:\\Users\\alice\\client's app", - 'C:\\Users\\alice' - ) - - expect(decodePowerShellCommand(result)).toContain( - expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\client''s app'") - ) - }) - - it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { - const result = resolveWindowsShellLaunchArgs( - 'powershell.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - "& 'codex' '--no-alt-screen'" - ) - expect(result.startupCommandDeliveredInShellArgs).toBe(true) - - const command = decodePowerShellCommand(result) - expect(command).toContain('function Global:prompt') - expect(command).toContain(expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'")) - expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) - // Without the mark, the first prompt after the agent exits withholds 133;D (#18180). - expect(command).toContain( - `${POWERSHELL_STARTUP_COMMAND_C_MARK}; $Global:__OrcaOsc133State.HasSeenPrompt = $true }\n& 'codex' '--no-alt-screen'` - ) - }) - - it('preserves complex PowerShell startup command text through EncodedCommand', () => { - const startupCommand = - '& "C:\\Program Files\\Orca CLI\\orca.exe" "--label" "quoted value"; $env:ORCA_VALUE = "nested"' - const result = resolveWindowsShellLaunchArgs( - 'powershell.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - startupCommand - ) - - expect(result.startupCommandDeliveredInShellArgs).toBe(true) - const command = decodePowerShellCommand(result) - expect(command).toContain(`\n${startupCommand}`) - expect(command.trimEnd().endsWith(startupCommand)).toBe(true) - }) - - it('keeps large PowerShell startup commands on stdin delivery', () => { - const result = resolveWindowsShellLaunchArgs( - 'powershell.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - `orca ${'x'.repeat(7000)}` - ) - - expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() - expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) - const command = decodePowerShellCommand(result) - expect(command).toContain(expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'")) - // Stdin-typed commands get readline's 133;C; an early latch would emit a false 133;D. - expect(command).not.toContain(POWERSHELL_STARTUP_COMMAND_C_MARK) - }) - - it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { - const result = resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\', 'C:\\Users\\alice') - expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) - expect(decodePowerShellCommand(result)).toContain(expectedPowerShellRestoreCwdCommand("'C:\\'")) - }) - - it('does not change PowerShell args when the cmd/Git Bash preflight is provided', () => { - const baseline = resolveWindowsShellLaunchArgs( - 'powershell.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice' - ) - const withWindowsPreflight = resolveWindowsShellLaunchArgs( - 'powershell.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - undefined, - CODEX_LAUNCH_PREFLIGHT - ) - - expect(withWindowsPreflight).toEqual(baseline) - }) - - it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { - const result = resolveWindowsShellLaunchArgs( - 'C:\\Program Files\\Git\\bin\\bash.exe', - '/c', - 'C:\\Users\\alice', - undefined, - undefined, - CODEX_LAUNCH_PREFLIGHT - ) - - // Git Bash inherits OEM codepage; UTF-8 must precede exec of the interactive shell. - expect(result.shellArgs[0]).toBe('-c') - const bashCommand = result.shellArgs[1] - expect(bashCommand).toContain('chcp.com 65001') - expect(bashCommand).toContain('--rcfile') - expect(bashCommand).toMatch(/exec "\$BASH" .* -i$/) - expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec')) - // Missing chcp.com must not prevent exec; semicolon preserves startup. - expect(bashCommand).not.toContain('&&') - expect(result.effectiveCwd).toBe('C:\\') - expect(result.validationCwd).toBe('C:\\') - - const bashRcfile = readFileSync(getGitBashRcfilePath(bashCommand), 'utf8') - expect(bashRcfile).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex') - expect(bashRcfile).not.toContain(CODEX_LAUNCH_PREFLIGHT) - }) - - it('keeps Git Bash startup commands on stdin delivery with the preflight wrapper', () => { - const result = resolveWindowsShellLaunchArgs( - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - 'codex --no-alt-screen', - CODEX_LAUNCH_PREFLIGHT - ) - - expect(result.shellArgs[1]).toContain('chcp.com 65001') - expect(result.shellArgs[1]).toContain('--rcfile') - expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() - }) - - it('gives a plain Git Bash tab the same rcfile as one with a startup command', () => { - const plain = resolveWindowsShellLaunchArgs( - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice' - ) - const launched = resolveWindowsShellLaunchArgs( - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - "codex 'fix the bug'" - ) - - expect(plain.shellArgs).toEqual(launched.shellArgs) - // The rcfile owns account-following Claude and Codex no-daemon wrappers. - const rcfile = readFileSync(getGitBashRcfilePath(plain.shellArgs[1]), 'utf8') - expect(rcfile).toContain('function claude {') - expect(rcfile).toContain('set -- --no-daemon "$@"') - expect(rcfile).toContain('source "$HOME/.bash_profile"') - expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() - }) - - it('quotes a spaced preflight path through each shell environment', () => { - const cmd = resolveWindowsShellLaunchArgs( - 'cmd.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - undefined, - CODEX_LAUNCH_PREFLIGHT - ) - const gitBash = resolveWindowsShellLaunchArgs( - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice', - undefined, - undefined, - CODEX_LAUNCH_PREFLIGHT - ) - - expect(cmd.shellArgs[1]).toContain( - 'call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%' - ) - expect(cmd.shellArgs[1]).not.toContain('"') - expect(cmd.shellArgs[1]).not.toContain(CODEX_LAUNCH_PREFLIGHT) - const rcfilePath = getGitBashRcfilePath(gitBash.shellArgs[1]) - expect(readFileSync(rcfilePath, 'utf8')).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}"') - }) - - it('does not apply Git Bash launch args to unrelated bash.exe paths', () => { - const result = resolveWindowsShellLaunchArgs( - 'C:\\msys64\\usr\\bin\\bash.exe', - 'C:\\Users\\alice\\code', - 'C:\\Users\\alice' - ) - - expect(result.shellArgs).toEqual([]) - expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') - expect(result.validationCwd).toBe('C:\\Users\\alice\\code') - }) - - it('translates Windows cwd to /mnt//... for wsl.exe', () => { - const baseline = resolveWindowsShellLaunchArgs( - 'wsl.exe', - 'C:\\Users\\alice\\code', - 'C:\\Users\\alice', - undefined, - 'codex' - ) - const result = resolveWindowsShellLaunchArgs( - 'wsl.exe', - 'C:\\Users\\alice\\code', - 'C:\\Users\\alice', - undefined, - 'codex', - CODEX_LAUNCH_PREFLIGHT - ) - expect(result).toEqual(baseline) - expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) - expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() - // Launch wsl.exe from Windows home; shell argv performs the guest Linux cd. - expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('C:\\Users\\alice\\code') - }) - - it('materializes shell-ready wrappers before building WSL shell args', () => { - const result = resolveWindowsShellLaunchArgs( - 'wsl.exe', - 'C:\\Users\\alice\\code', - 'C:\\Users\\alice' - ) - - expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) - expect(existsSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'))).toBe(true) - expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'))).toBe(true) - - // Typed OMP uses status integration; typed Prime must reach user argv unchanged. - const bashRcfile = readFileSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'), 'utf8') - const zshEnv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - for (const wrapperFile of [bashRcfile, zshEnv]) { - expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') - expect(wrapperFile).toContain('function omp { __orca_omp "$@"; }') - expect(wrapperFile).not.toContain('prime-agent()') - expect(wrapperFile).not.toContain('__orca_prime_agent') - expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') - expect(wrapperFile).not.toContain('command prime-agent --extension') - } - }) - - it('translates MSYS drive cwd to /mnt//... for wsl.exe', () => { - const result = resolveWindowsShellLaunchArgs( - 'wsl.exe', - '/c/Users/alice/project', - 'C:\\Users\\alice', - undefined, - 'codex' - ) - - expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project')) - expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('C:\\Users\\alice\\project') - }) - - it.each(['/a', '/c'])('keeps a selected WSL runtime single-letter cwd exact (%s)', (cwd) => { - const result = resolveWindowsShellLaunchArgs('wsl.exe', cwd, 'C:\\Users\\alice', { - distro: 'Ubuntu', - treatPosixCwdAsWsl: true - }) - - expect(result.shellArgs).toEqual(expectedWslArgs(cwd, 'Ubuntu')) - expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe(`\\\\wsl.localhost\\Ubuntu\\${cwd.slice(1)}`) - }) - - it('escapes single quotes when translating a WSL cwd', () => { - const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') - // Single quotes in paths must remain within the generated sh argument. - expect(result.shellArgs[3]).toContain("cd '/mnt/c/weird'\\''path'") - expect(result.shellArgs[3]).toContain('exec "$_orca_wsl_shell" -l') - }) - - it('falls back to /mnt/c when cwd is not a drive-letter path', () => { - const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice') - expect(result.shellArgs[3]).toContain('cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH"') - }) - - it('keeps WSL UNC worktree cwd inside the matching distro', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - - try { - const result = resolveWindowsShellLaunchArgs( - 'wsl.exe', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', - 'C:\\Users\\alice' - ) - expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo', 'Ubuntu')) - expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo') - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('keeps POSIX cwd inside the worktree distro when WSL context is provided', () => { - const result = resolveWindowsShellLaunchArgs( - 'wsl.exe', - '/home/alice/repo/subdir', - 'C:\\Users\\alice', - { distro: 'Ubuntu', treatPosixCwdAsWsl: true } - ) - - expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo/subdir', 'Ubuntu')) - expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir') - }) - - it('falls back to empty args + same cwd for unknown shells', () => { - const result = resolveWindowsShellLaunchArgs( - 'C:\\tools\\fish.exe', - 'C:\\Users\\alice', - 'C:\\Users\\alice' - ) - expect(result.shellArgs).toEqual([]) - expect(result.effectiveCwd).toBe('C:\\Users\\alice') - expect(result.validationCwd).toBe('C:\\Users\\alice') - }) - - it('is case-insensitive on the shell basename', () => { - const result = resolveWindowsShellLaunchArgs('PowerShell.EXE', 'C:\\', 'C:\\') - expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]) - }) -}) - -// #7236: UTF-16 base64 delivers setup-runner quotes intact instead of risking partial typed-stdin parsing. -describe('issue #7236: PowerShell setup-runner command delivery', () => { - // git rev-parse hands back a forward-slash Windows-absolute path for the runner. - const runnerPath = 'C:/Users/alice/repo/.git/orca/setup-runner.cmd' - - it('wraps the setup runner in balanced double quotes', () => { - const { command } = resolveSetupRunnerCommand(runnerPath, 'windows') - expect(command).toBe(`cmd.exe /c "${runnerPath}"`) - expect((command.match(/"/g) ?? []).length % 2).toBe(0) - }) - - it('delivers the setup-runner command through -EncodedCommand, never raw stdin', () => { - const { command } = resolveSetupRunnerCommand(runnerPath, 'windows') - const result = resolveWindowsShellLaunchArgs( - 'powershell.exe', - 'C:\\Users\\alice\\repo', - 'C:\\Users\\alice', - undefined, - command - ) - - // The argv-delivery flag prevents a second unencoded stdin write (#7236). - expect(result.startupCommandDeliveredInShellArgs).toBe(true) - expect(result.shellArgs.slice(0, 3)).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand']) - - const decoded = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') - expect(decoded).toContain(`\n${command}`) - expect(decoded.trimEnd().endsWith(command)).toBe(true) - expect((decoded.slice(decoded.lastIndexOf(command)).match(/"/g) ?? []).length % 2).toBe(0) - }) -}) diff --git a/src/main/providers/working-directory-validation.test.ts b/src/main/providers/working-directory-validation.test.ts index ee7fea9cbeb..0fd0474dc26 100644 --- a/src/main/providers/working-directory-validation.test.ts +++ b/src/main/providers/working-directory-validation.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,12 +17,6 @@ import { let tempDir: string -/** Let the UNC lane-acquire microtask run. */ -const flushLaneAcquire = async (): Promise => { - await Promise.resolve() - await Promise.resolve() -} - beforeEach(async () => { wslUncDirectoryExistsAsyncMock.mockReset() wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) @@ -35,36 +29,6 @@ afterEach(async () => { }) describe('validateWorkingDirectoryAsync', () => { - it('accepts an existing directory', async () => { - await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined() - }) - - it('rejects a missing directory with the actionable unmounted-volume message', async () => { - await expect(validateWorkingDirectoryAsync(path.join(tempDir, 'gone'))).rejects.toThrow( - /does not exist.*unmounted volume/s - ) - }) - - it('rejects a path that exists but is a file', async () => { - const filePath = path.join(tempDir, 'not-a-dir.txt') - await writeFile(filePath, 'x') - - await expect(validateWorkingDirectoryAsync(filePath)).rejects.toThrow('is not a directory') - }) - - it('never blocks the event loop while the filesystem answers', async () => { - // A dead UNC share must not freeze unrelated daemon RPCs (STA-4470). - let ticked = false - const pending = validateWorkingDirectoryAsync(tempDir) - setImmediate(() => { - ticked = true - }) - - await pending - - expect(ticked).toBe(true) - }) - describe('cancellation', () => { // Unsettled probes persist in the module-level dedupe map; use a distinct path per test. const deadShare = (name: string): string => `\\\\wsl.localhost\\Ubuntu\\dead-${name}` @@ -77,25 +41,6 @@ describe('validateWorkingDirectoryAsync', () => { vi.restoreAllMocks() }) - it('lets an aborted caller give up on a probe that never answers', async () => { - // fs.stat cannot abort; only its caller stops waiting. - wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise(() => {})) - const abort = new AbortController() - - const pending = validateWorkingDirectoryAsync(deadShare('abort'), { signal: abort.signal }) - abort.abort() - - await expect(pending).rejects.toThrow('was canceled') - }) - - it('rejects immediately when the signal is already aborted', async () => { - wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise(() => {})) - - await expect( - validateWorkingDirectoryAsync(deadShare('pre-aborted'), { signal: AbortSignal.abort() }) - ).rejects.toThrow('was canceled') - }) - it('leaves the shared probe intact for callers that are still waiting', async () => { let releaseProbe: () => void = () => {} wslUncDirectoryExistsAsyncMock.mockReturnValue( @@ -115,33 +60,6 @@ describe('validateWorkingDirectoryAsync', () => { await expect(staying).resolves.toBeUndefined() expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce() }) - - it('never pins a second probe on a path whose probe is still hung', async () => { - vi.useFakeTimers() - try { - wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise(() => {})) - const hung = deadShare('still-hung') - - // Expiring an uninterruptible stat would let each retry pin another libuv thread. - for (let retry = 0; retry < 4; retry += 1) { - void validateWorkingDirectoryAsync(hung).catch(() => {}) - await vi.advanceTimersByTimeAsync(60_000) - } - - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - } - }) - - it('re-probes once the previous probe settles, so a recovered mount is seen', async () => { - wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true) - const recovering = deadShare('recovers') - - await expect(validateWorkingDirectoryAsync(recovering)).rejects.toThrow(/does not exist/) - await expect(validateWorkingDirectoryAsync(recovering)).resolves.toBeUndefined() - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2) - }) }) describe('UNC route concurrency', () => { @@ -153,28 +71,6 @@ describe('validateWorkingDirectoryAsync', () => { vi.restoreAllMocks() }) - it('caps concurrent probes against one unreachable distro', async () => { - // Four dead paths could occupy the default libuv fs pool. - let inFlight = 0 - let peak = 0 - wslUncDirectoryExistsAsyncMock.mockImplementation( - () => - new Promise(() => { - inFlight += 1 - peak = Math.max(peak, inFlight) - }) - ) - - for (let index = 0; index < 4; index += 1) { - void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\capped-${index}`).catch( - () => {} - ) - } - await flushLaneAcquire() - - expect(peak).toBe(2) - }) - it('keeps a healthy local path out of a stalled share queue', async () => { wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise(() => {})) for (let index = 0; index < 4; index += 1) { @@ -185,89 +81,5 @@ describe('validateWorkingDirectoryAsync', () => { await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined() }) - - it('gives separate servers separate lanes', async () => { - let inFlight = 0 - wslUncDirectoryExistsAsyncMock.mockImplementation( - () => - new Promise(() => { - inFlight += 1 - }) - ) - - for (const distro of ['AlphaDistro', 'BetaDistro']) { - for (let index = 0; index < 2; index += 1) { - void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\${distro}\\lane-${index}`).catch( - () => {} - ) - } - } - await flushLaneAcquire() - - expect(inFlight).toBe(4) - }) - }) - - describe('WSL UNC paths', () => { - const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - - beforeEach(() => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('asks the distro asynchronously and accepts its yes', async () => { - wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) - - await expect(validateWorkingDirectoryAsync(wslPath)).resolves.toBeUndefined() - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(wslPath) - }) - - it('shares one in-flight probe for the same working directory', async () => { - let releaseProbe: () => void = () => {} - wslUncDirectoryExistsAsyncMock.mockReturnValue( - new Promise((resolve) => { - releaseProbe = () => resolve(true) - }) - ) - - const first = validateWorkingDirectoryAsync(wslPath) - const second = validateWorkingDirectoryAsync(wslPath) - await flushLaneAcquire() - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce() - - releaseProbe() - await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]) - }) - - it('keeps byte-distinct working directories in separate probes', async () => { - wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) - const decomposedPath = `${wslPath}-e\u0301` - const composedPath = `${wslPath}-\u00e9` - - await Promise.all([ - validateWorkingDirectoryAsync(decomposedPath), - validateWorkingDirectoryAsync(composedPath) - ]) - - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2) - }) - - it(`rejects on the distro no without falling back to a Win32 stat`, async () => { - wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) - - await expect(validateWorkingDirectoryAsync(wslPath)).rejects.toThrow(/does not exist/) - }) - - it('falls back to the filesystem when the distro probe is inconclusive', async () => { - // An inconclusive distro probe must fall back to filesystem validation. - wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) - - await expect(validateWorkingDirectoryAsync(wslPath)).rejects.toThrow(/does not exist/) - expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce() - }) }) }) diff --git a/src/main/pty/environment/windows-environment-path.test.ts b/src/main/pty/environment/windows-environment-path.test.ts index 74846d4a8d5..b512bc10ea0 100644 --- a/src/main/pty/environment/windows-environment-path.test.ts +++ b/src/main/pty/environment/windows-environment-path.test.ts @@ -18,7 +18,6 @@ import { readPersistedWindowsPathSegments, readPersistedWindowsPathSegmentsAsync } from './windows-environment-path' -import { resolvePathEnvKey } from './windows-path-segment-merge' import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' const registryGetKeyMock = vi.fn() @@ -41,164 +40,6 @@ afterEach(() => { }) describe('readPersistedWindowsPathSegments', () => { - it('reads machine and user Path values from the Windows registry', () => { - const execFileSync = vi - .fn() - .mockReturnValueOnce( - [ - '', - 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', - ' Path REG_EXPAND_SZ %SystemRoot%\\System32;C:\\Tools', - '' - ].join('\r\n') - ) - .mockReturnValueOnce( - ['', 'HKEY_CURRENT_USER\\Environment', ' Path REG_SZ C:\\Users\\me\\bin', ''].join( - '\r\n' - ) - ) - - const segments = readPersistedWindowsPathSegments({ - platform: 'win32', - execFileSync, - env: { SystemRoot: 'C:\\Windows' } - }) - - expect(segments).toEqual(['C:\\Windows\\System32', 'C:\\Tools', 'C:\\Users\\me\\bin']) - expect(execFileSync).toHaveBeenCalledTimes(2) - expect( - execFileSync.mock.calls.every(([command]) => command === 'C:\\Windows\\System32\\reg.exe') - ).toBe(true) - }) - - it('returns an empty list outside Windows', () => { - const execFileSync = vi.fn() - - expect(readPersistedWindowsPathSegments({ platform: 'linux', execFileSync })).toEqual([]) - expect(execFileSync).not.toHaveBeenCalled() - }) - - it('does not start asynchronous registry reads outside Windows', async () => { - await expect(readPersistedWindowsPathSegmentsAsync({ platform: 'linux' })).resolves.toEqual([]) - expect(defaultExecFileMock).not.toHaveBeenCalled() - }) - - it('caches production registry reads briefly', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockReturnValueOnce(registryPath('C:\\User')) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(registryGetKeyMock).toHaveBeenCalledTimes(2) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('force-refreshes the production registry cache', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockReturnValueOnce(registryPath('C:\\User')) - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI')) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(registryGetKeyMock).toHaveBeenCalledTimes(2) - - expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ - 'C:\\Machine', - 'C:\\User', - 'C:\\Program Files\\GitHub CLI' - ]) - expect(registryGetKeyMock).toHaveBeenCalledTimes(4) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('invalidates the cache after Windows reports an environment change', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\OldMachine')) - .mockReturnValueOnce(registryPath('C:\\OldUser')) - .mockReturnValueOnce(registryPath('C:\\NewMachine')) - .mockReturnValueOnce(registryPath('C:\\NewUser')) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) - - invalidatePersistedWindowsPathCache() - - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\NewUser']) - expect(registryGetKeyMock).toHaveBeenCalledTimes(4) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('combines a fresh hive with the other hive last known good after invalidation', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\OldMachine')) - .mockReturnValueOnce(registryPath('C:\\OldUser')) - .mockReturnValueOnce(registryPath('C:\\NewMachine')) - .mockImplementationOnce(() => { - throw new Error('user registry unavailable') - }) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) - invalidatePersistedWindowsPathCache() - - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\OldUser']) - expect(registryGetKeyMock).toHaveBeenCalledTimes(4) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('combines a fresh user hive with the machine hive last known good', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\OldMachine')) - .mockReturnValueOnce(registryPath('C:\\OldUser')) - .mockImplementationOnce(() => { - throw new Error('machine registry unavailable') - }) - .mockReturnValueOnce(registryPath('C:\\NewUser')) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser']) - invalidatePersistedWindowsPathCache() - - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\NewUser']) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - it('withholds a partial first registry snapshot instead of changing PATH precedence', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -223,115 +64,6 @@ describe('readPersistedWindowsPathSegments', () => { } }) - it('withholds the reverse partial first snapshot', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockImplementationOnce(() => { - throw new Error('machine registry unavailable') - }) - .mockReturnValueOnce(registryPath('C:\\User')) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual([]) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('does not duplicate an unresolved asynchronous refresh for a synchronous caller', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockImplementationOnce(() => { - throw new Error('user registry unavailable') - }) - __resetPersistedWindowsPathCacheForTests() - - try { - const refresh = readPersistedWindowsPathSegmentsAsync() - - expect(readPersistedWindowsPathSegments()).toEqual([]) - await expect(refresh).resolves.toEqual([]) - expect(registryGetKeyMock).toHaveBeenCalledTimes(2) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('does not let injected test reads populate production fallback state', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_SZ C:\\InjectedMachine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\InjectedUser\r\n') - registryGetKeyMock.mockImplementation(() => { - throw new Error('registry unavailable') - }) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments({ platform: 'win32', execFileSync })).toEqual([ - 'C:\\InjectedMachine', - 'C:\\InjectedUser' - ]) - expect(readPersistedWindowsPathSegments()).toEqual([]) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('keeps the last good segments when a forced read hits a blocked registry', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockReturnValueOnce(registryPath('C:\\User')) - .mockImplementation(() => { - throw new Error('ERROR: Access is denied.') - }) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ - 'C:\\Machine', - 'C:\\User' - ]) - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('still clears the cache when the registry reports an empty Path', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockReturnValueOnce(registryPath('C:\\User')) - .mockReturnValue(registryPath('')) - __resetPersistedWindowsPathCacheForTests() - - try { - expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - - // An empty persisted PATH is successful, not a reason to serve stale cache. - expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([]) - expect(readPersistedWindowsPathSegments()).toEqual([]) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - it('does not resurrect a successfully emptied hive when the other hive fails', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -359,66 +91,6 @@ describe('readPersistedWindowsPathSegments', () => { } }) - it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockReturnValueOnce(registryPath('C:\\User')) - __resetPersistedWindowsPathCacheForTests() - - try { - const firstEnv = { Path: 'C:\\First' } - const secondEnv = { Path: 'C:\\Second' } - const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) - const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) - - await Promise.all([first, second]) - expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') - expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') - expect(registryGetKeyMock).toHaveBeenCalledTimes(2) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('keeps the last good cache when native registry reads fail', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - registryGetKeyMock - .mockReturnValueOnce(registryPath('C:\\Machine')) - .mockReturnValueOnce(registryPath('C:\\User')) - .mockImplementation(() => { - throw new Error('registry unavailable') - }) - __resetPersistedWindowsPathCacheForTests() - - try { - await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ - 'C:\\Machine', - 'C:\\User' - ]) - await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ - 'C:\\Machine', - 'C:\\User' - ]) - expect(registryGetKeyMock).toHaveBeenCalledTimes(2) - await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ - 'C:\\Machine', - 'C:\\User' - ]) - await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ - 'C:\\Machine', - 'C:\\User' - ]) - expect(registryGetKeyMock).toHaveBeenCalledTimes(4) - } finally { - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - it('does not let an older async read replace a newer synchronous refresh', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -484,188 +156,3 @@ describe('readPersistedWindowsPathSegments', () => { } }) }) - -describe('mergePersistedWindowsPath', () => { - it('expands variables already present in the inherited PATH', () => { - const execFileSync = vi.fn().mockReturnValue(' Path REG_SZ \r\n') - const env = { - ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local', - Path: '%orca_path_root%\\agy\\bin;C:\\Windows' - } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync, env }) - - expect(env.Path).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows') - }) - - it('adopts persisted machine and user PATH ordering', () => { - const execFileSync = vi - .fn() - .mockReturnValueOnce( - [ - '', - 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', - ' Path REG_EXPAND_SZ C:\\Windows\\System32;C:\\Existing', - '' - ].join('\r\n') - ) - .mockReturnValueOnce( - [ - '', - 'HKEY_CURRENT_USER\\Environment', - ' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\Orca\\bin;C:\\Existing', - '' - ].join('\r\n') - ) - const env = { Path: 'C:\\Existing' } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) - - expect(env.Path).toBe( - 'C:\\Windows\\System32;C:\\Existing;C:\\Users\\me\\AppData\\Local\\Orca\\bin' - ) - }) - - it('uses PATH when that is the existing path key', () => { - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - const env = { PATH: 'C:\\Current' } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) - - expect(env).toEqual({ PATH: 'C:\\Current;C:\\Machine;C:\\User' }) - }) - - it('keeps the inherited process PATH when the target env has no path key', () => { - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - const env: Record = {} - - mergePersistedWindowsPath(env, { - platform: 'win32', - execFileSync, - env: { Path: 'C:\\Inherited' } - }) - - expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' }) - }) - - it('reads the live host spelling when a path-less env inherits duplicate keys', () => { - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - const env: Record = {} - - mergePersistedWindowsPath(env, { - platform: 'win32', - execFileSync, - env: { ROOT: 'C:\\Root', Path: '%ROOT%\\Live', PATH: 'C:\\Shadowed' } - }) - - expect(env).toEqual({ Path: 'C:\\Root\\Live;C:\\Machine;C:\\User' }) - }) - - it('stops an inherited WindowsApps alias from shadowing a newly installed Python', () => { - const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\' - const scripts = `${python}Scripts\\` - const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32;C:\\Windows\r\n') - .mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${scripts};${windowsApps}\r\n`) - const env = { Path: `C:\\Windows\\system32;C:\\Windows;${windowsApps}` } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) - - const segments = env.Path.split(';') - expect(segments.indexOf(python)).toBeLessThan(segments.indexOf(windowsApps)) - expect(env.Path).toBe(`C:\\Windows\\system32;C:\\Windows;${python};${scripts};${windowsApps}`) - }) - - it('keeps injected entries ahead of the persisted PATH', () => { - const injected = 'C:\\Users\\me\\AppData\\Local\\Orca\\resources\\bin' - const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\' - const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps' - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32\r\n') - .mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${windowsApps}\r\n`) - const env = { Path: `${injected};C:\\Windows\\system32;${windowsApps}` } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) - - expect(env.Path).toBe(`${injected};C:\\Windows\\system32;${python};${windowsApps}`) - }) - - it('leaves the inherited PATH untouched when both registry reads are blocked', () => { - const execFileSync = vi.fn().mockImplementation(() => { - throw new Error('ERROR: Access is denied.') - }) - const env = { Path: 'C:\\Windows\\system32;C:\\Existing' } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) - - expect(env.Path).toBe('C:\\Windows\\system32;C:\\Existing') - }) - - it('deduplicates segments that differ only by trailing separators', () => { - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_SZ C:\\Tools\r\n') - .mockReturnValueOnce(' Path REG_SZ \r\n') - const env = { Path: 'C:\\Tools\\' } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) - - expect(env.Path.split(';')).toEqual(['C:\\Tools']) - }) - - it('canonicalizes drive-root slash style and repetition for comparison', () => { - const execFileSync = vi - .fn() - .mockReturnValueOnce(' Path REG_SZ C:\\\\\r\n') - .mockReturnValueOnce(' Path REG_SZ \r\n') - const env = { Path: 'C:/' } - - mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) - - expect(env.Path.split(';')).toEqual(['C:\\\\']) - }) -}) - -describe('resolvePathEnvKey', () => { - it('uses whichever Windows spelling is present on the env', () => { - expect(resolvePathEnvKey({ Path: 'C:\\Windows' }, 'win32')).toBe('Path') - expect(resolvePathEnvKey({ PATH: 'C:\\Windows' }, 'win32')).toBe('PATH') - expect(resolvePathEnvKey({ path: 'C:\\Windows' }, 'win32')).toBe('path') - expect(resolvePathEnvKey({ PaTh: 'C:\\Windows' }, 'win32')).toBe('PaTh') - }) - - // Win32 resolves the first case-insensitive block entry; casing alone cannot select the live key. - it('resolves a dual-cased Windows env by block order, not casing', () => { - expect(resolvePathEnvKey({ PATH: 'C:\\Live', Path: 'C:\\Shadowed' }, 'win32')).toBe('PATH') - expect(resolvePathEnvKey({ Path: 'C:\\Live', PATH: 'C:\\Shadowed' }, 'win32')).toBe('Path') - }) - - it('skips a path key explicitly set to undefined', () => { - expect(resolvePathEnvKey({ PATH: undefined, Path: 'C:\\Windows' }, 'win32')).toBe('Path') - }) - - it('falls back to the host block spelling for a Windows env with no path key', () => { - // Match the daemon merge key to avoid creating a shadow PATH entry. - expect(resolvePathEnvKey({}, 'win32', { PATH: 'C:\\Windows' })).toBe('PATH') - expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Windows' })).toBe('Path') - expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Live', PATH: 'C:\\Shadowed' })).toBe('Path') - expect(resolvePathEnvKey({}, 'win32', {})).toBe('Path') - }) - - it('always resolves `PATH` off Windows so a POSIX `Path` variable is untouched', () => { - expect(resolvePathEnvKey({ Path: '/decoy' }, 'linux')).toBe('PATH') - expect(resolvePathEnvKey({ Path: '/decoy', PATH: '/usr/bin' }, 'darwin')).toBe('PATH') - }) -}) diff --git a/src/main/pty/history/fish-history-session.test.ts b/src/main/pty/history/fish-history-session.test.ts index 49c7b8026bf..ec9dab14049 100644 --- a/src/main/pty/history/fish-history-session.test.ts +++ b/src/main/pty/history/fish-history-session.test.ts @@ -5,9 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { deleteFishHistoryFile, fishHistorySessionName, - isSafeFishHistorySession, relayFishHistorySessionName, - resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session' @@ -15,47 +13,6 @@ const HASH_A = 'a1b2c3d4e5f60718' const HASH_B = '00112233445566ff' const SESSION_A = fishHistorySessionName(HASH_A) -describe('fish history session naming', () => { - it('mints a session name that is a valid fish variable value', () => { - // Invalid fish variable names silently select shared history. - expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718') - expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/) - }) - - it.each([ - ['orca_deadbeef', true], - ['orca_', false], - ['fish_history', false], - ['orca_../../etc/passwd', false], - ['orca_DEADBEEF', false], - ['', false], - [undefined, false], - [42, false] - ])('accepts %s as a safe session name: %s', (value, expected) => { - expect(isSafeFishHistorySession(value)).toBe(expected) - }) -}) - -describe('fish history directory resolution', () => { - it('follows XDG_DATA_HOME when it is absolute', () => { - expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe( - join('/data', 'fish') - ) - }) - - it('ignores a relative XDG_DATA_HOME the way fish does', () => { - expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe( - join('/home/me', '.local', 'share', 'fish') - ) - }) - - it('falls back to HOME when XDG_DATA_HOME is unset', () => { - expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe( - join('/home/me', '.local', 'share', 'fish') - ) - }) -}) - describe('fish history deletion', () => { let root: string let fishDir: string @@ -72,30 +29,6 @@ describe('fish history deletion', () => { const historyFile = (session: string): string => join(fishDir, `${session}_history`) - it('deletes the session file rather than truncating it', () => { - writeFileSync(historyFile(SESSION_A), '- cmd: ls\n') - - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true) - expect(existsSync(historyFile(SESSION_A))).toBe(false) - }) - - it('tries every candidate directory, since the PTY may not share this env', () => { - const other = join(root, 'other-xdg', 'fish') - mkdirSync(other, { recursive: true }) - writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n') - - expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true) - expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false) - }) - - it('refuses a session name it did not mint', () => { - const foreign = join(fishDir, 'fish_history') - writeFileSync(foreign, 'the user’s real history\n') - - expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false) - expect(existsSync(foreign)).toBe(true) - }) - // Windows symlinks require Developer Mode or elevation. it.skipIf(process.platform === 'win32')( 'refuses to follow a symlink out of the fish data dir', @@ -108,10 +41,6 @@ describe('fish history deletion', () => { expect(existsSync(outside)).toBe(true) } ) - - it('reports false when there is nothing to delete', () => { - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false) - }) }) describe('orphaned fish history sweep', () => { @@ -136,59 +65,6 @@ describe('orphaned fish history sweep', () => { expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true) expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false) }) - - it("never touches the user's own history files", () => { - const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history'] - for (const name of untouched) { - writeFileSync(join(fishDir, name), 'mine\n') - } - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0) - for (const name of untouched) { - expect(existsSync(join(fishDir, name))).toBe(true) - } - }) - - it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => { - writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - - expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0) - expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true) - }) - - it('spares a file younger than the age guard, since the live set is a snapshot', () => { - // A workspace created after the live-set snapshot must keep its actively written history. - const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`) - writeFileSync(fresh, 'just created\n') - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0) - expect(existsSync(fresh)).toBe(true) - }) - - it('sweeps a file older than the age guard', () => { - const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`) - writeFileSync(stale, 'long dead\n') - const wellPast = Date.now() + 60 * 60 * 1000 - - expect( - sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast) - ).toBe(1) - expect(existsSync(stale)).toBe(false) - }) - - it('tolerates a directory that does not exist', () => { - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0) - }) - - it('sweeps every candidate directory once', () => { - const other = join(root, 'other', 'fish') - mkdirSync(other, { recursive: true }) - for (const dir of [fishDir, other]) { - writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - } - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2) - }) }) // Desktop live sets cannot account for remote clients’ history; prefixes keep their sweeps separate. @@ -203,14 +79,6 @@ describe('relay fish history naming', () => { rmSync(relayRoot, { recursive: true, force: true }) }) - it('is namespaced apart from desktop session names', () => { - expect(relayFishHistorySessionName(HASH_A)).not.toBe(fishHistorySessionName(HASH_A)) - }) - - it('is still a safe session name, so the relay can delete it by name', () => { - expect(isSafeFishHistorySession(relayFishHistorySessionName(HASH_A))).toBe(true) - }) - it('survives a desktop sweep that knows none of the relay worktree ids', () => { const dir = relayRoot const relayFile = join(dir, `${relayFishHistorySessionName(HASH_A)}_history`) diff --git a/src/main/pty/history/terminal-history-gc.test.ts b/src/main/pty/history/terminal-history-gc.test.ts index d1786e10967..03cc2d8c336 100644 --- a/src/main/pty/history/terminal-history-gc.test.ts +++ b/src/main/pty/history/terminal-history-gc.test.ts @@ -8,7 +8,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup' @@ -22,7 +22,6 @@ vi.mock('../../filesystem/host-tree-removal', () => ({ })) import * as eventLoopYield from '../../../shared/event-loop-yield' -import { readHistoryMeta } from './terminal-history' import { cancelPendingHistoryTreeRemovalRetries, flushPendingWorktreeHistoryDeletions @@ -38,50 +37,6 @@ let userDataDir: string let historyRoot: string let originalXdgDataHome: string | undefined -// Pre-change pruning oracle; report decisions without deleting history. -function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set): string[] { - const decisions: string[] = [] - if (!existsSync(root)) { - return decisions - } - const now = Date.now() - for (const entry of readdirSync(root)) { - if (entry === PENDING_DELETE_DIR_NAME) { - continue - } - const entryPath = join(root, entry) - try { - const stats = statSync(entryPath) - if (!stats.isDirectory()) { - continue - } - try { - for (const file of readdirSync(entryPath)) { - statSync(join(entryPath, file)) - } - } catch { - // Match the original walk's tolerance for failed size estimates. - } - if (!existsSync(join(entryPath, 'meta.json'))) { - continue - } - const meta = readHistoryMeta(entryPath) - if (!meta?.worktreeId) { - continue - } - if (!liveWorktreeIds.has(meta.worktreeId)) { - if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) { - continue - } - decisions.push(entry) - } - } catch { - // Match the original walk's tolerance for unreadable entries. - } - } - return decisions -} - function seedDir(name: string, files: Record): string { const dir = join(historyRoot, name) mkdirSync(dir, { recursive: true }) @@ -164,13 +119,6 @@ beforeEach(() => { }) }) -// Strip the tombstone timestamp/random suffix to compare original entry names. -function tombstonedNames(): Set { - return new Set( - removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.')) - ) -} - afterEach(async () => { cancelHistoryGc() vi.useRealTimers() @@ -185,23 +133,6 @@ afterEach(async () => { }) describe('history GC prune decisions', () => { - it('prunes exactly the set the synchronous walk chose', async () => { - seedDecisionMatrix() - seedBulk(200, 7) - const live = new Set([LIVE_WORKTREE_ID]) - - const before = survivingDirs() - const expected = new Set(referenceSyncPruneDecisions(historyRoot, live)) - - await runHistoryGc(live) - - const after = survivingDirs() - const actual = new Set([...before].filter((entry) => !after.has(entry))) - - expect(expected.size).toBeGreaterThan(0) - expect([...actual].sort()).toEqual([...expected].sort()) - }) - it('keeps every directory whose ownership cannot be established', async () => { seedDecisionMatrix() @@ -230,51 +161,6 @@ describe('history GC prune decisions', () => { expect(after.has('orphan-unparseable-createdat')).toBe(false) expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true) }) - - it('refuses to prune anything when the live set is empty', async () => { - seedDecisionMatrix() - - await runHistoryGc(new Set()) - - expect(survivingDirs().has('orphan-old')).toBe(true) - expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([]) - }) - - it('does not throw when the history root does not exist', async () => { - rmSync(historyRoot, { recursive: true, force: true }) - await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined() - }) - - it('tombstones orphans instead of removing them on the calling thread', async () => { - seedDecisionMatrix() - - await runHistoryGc(new Set([LIVE_WORKTREE_ID])) - - for (const [dir] of removeHostTreeMock.mock.calls) { - expect(dir).toContain(PENDING_DELETE_DIR_NAME) - } - expect(tombstonedNames().has('orphan-old')).toBe(true) - }) - - it('drains pre-existing tombstones without scanning them as worktrees', async () => { - seedDecisionMatrix() - const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef') - mkdirSync(leftover, { recursive: true }) - - await runHistoryGc(new Set([LIVE_WORKTREE_ID])) - - expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000')) - }) - - it('continues the pass after one orphan tombstone fails', async () => { - seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) }) - seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) }) - writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory') - - await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined() - - expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b'])) - }) }) describe('history GC concurrency behaviour', () => { @@ -312,26 +198,6 @@ describe('history GC concurrency behaviour', () => { } }) - it('starts a fresh pass once the previous one has settled', async () => { - seedDecisionMatrix() - const live = new Set([LIVE_WORKTREE_ID]) - await runHistoryGc(live) - const second = runHistoryGc(live) - await expect(second).resolves.toBeUndefined() - }) - - it('stops an in-flight walk on cancel without pruning', async () => { - seedDecisionMatrix() - seedBulk(300, 3) - const before = survivingDirs() - - const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID])) - cancelHistoryGc() - await pass - - expect(survivingDirs()).toEqual(before) - }) - it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => { seedDecisionMatrix() vi.useFakeTimers() @@ -350,124 +216,4 @@ describe('history GC concurrency behaviour', () => { expect(survivingDirs().has('orphan-old')).toBe(true) }) - - it('coalesces duplicate scheduled startup GC calls', async () => { - vi.useFakeTimers() - const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set()) - - scheduleHistoryGc(getLiveWorktreeIds) - scheduleHistoryGc(getLiveWorktreeIds) - await vi.advanceTimersByTimeAsync(10_000) - - expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1) - }) -}) - -describe('history GC races an async walk introduces', () => { - it('survives a directory removed while the walk is in flight', async () => { - seedDecisionMatrix() - seedBulk(300, 5) - const live = new Set([LIVE_WORKTREE_ID]) - const vanishing = ['bulk-11', 'bulk-77', 'bulk-201'] - - const pass = runHistoryGc(live) - for (const name of vanishing) { - rmSync(join(historyRoot, name), { recursive: true, force: true }) - } - await expect(pass).resolves.toBeUndefined() - - expect(survivingDirs().has('live-old')).toBe(true) - expect(survivingDirs().has('bulk-1')).toBe(true) - for (const name of vanishing) { - expect(existsSync(join(historyRoot, name))).toBe(false) - } - }) - - it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => { - seedDir('being-written', {}) - seedBulk(200, 5) - const live = new Set([LIVE_WORKTREE_ID]) - - const pass = runHistoryGc(live) - writeFileSync( - join(historyRoot, 'being-written', 'meta.json'), - `{"worktreeId":"${DEAD_WORKTREE_ID}","created` - ) - await pass - - expect(survivingDirs().has('being-written')).toBe(true) - }) - - it('prunes a directory whose meta.json arrived after the directory did', async () => { - seedDir('late-meta', {}) - writeFileSync( - join(historyRoot, 'late-meta', 'meta.json'), - meta(`${DEAD_WORKTREE_ID}-late`, OLD) - ) - - await runHistoryGc(new Set([LIVE_WORKTREE_ID])) - - expect(survivingDirs().has('late-meta')).toBe(false) - }) -}) - -describe('history GC main-thread occupancy', () => { - it('yields to timers throughout the walk instead of blocking on it', async () => { - seedBulk(1_200, 40) - const live = new Set([LIVE_WORKTREE_ID]) - - const ticks = { sync: 0, async: 0 } - let maxAsyncGapMs = 0 - - // The synchronous reference walk cannot service timers. - const syncTimer = setInterval(() => { - ticks.sync += 1 - }, 4) - try { - referenceSyncPruneDecisions(historyRoot, live) - } finally { - clearInterval(syncTimer) - } - - const originalYield = eventLoopYield.yieldToEventLoop - const servicedYields: boolean[] = [] - const yieldObserver = vi - .spyOn(eventLoopYield, 'yieldToEventLoop') - .mockImplementation(async () => { - let serviced = false - const probe = setTimeout(() => { - serviced = true - }, 0) - try { - await originalYield() - servicedYields.push(serviced) - if (serviced) { - ticks.async += 1 - } - } finally { - clearTimeout(probe) - } - }) - let asyncTimer: ReturnType | undefined - try { - let last = performance.now() - asyncTimer = setInterval(() => { - const now = performance.now() - maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4) - last = now - }, 4) - await runHistoryGc(live) - maxAsyncGapMs = Math.max(maxAsyncGapMs, performance.now() - last - 4) - clearInterval(asyncTimer) - - expect(ticks.sync).toBe(0) - expect(ticks.async).toBeGreaterThan(5) - expect(servicedYields).not.toContain(false) - // CI scheduling delays idle timers; detect a whole-walk block rather than small stalls. - expect(maxAsyncGapMs).toBeLessThan(2_000) - } finally { - clearInterval(asyncTimer) - yieldObserver.mockRestore() - } - }) }) diff --git a/src/main/pty/history/terminal-history-tombstone-retry.test.ts b/src/main/pty/history/terminal-history-tombstone-retry.test.ts index 2ad704b27e7..24b4d9ac85d 100644 --- a/src/main/pty/history/terminal-history-tombstone-retry.test.ts +++ b/src/main/pty/history/terminal-history-tombstone-retry.test.ts @@ -25,8 +25,6 @@ import { cancelPendingHistoryTreeRemovalRetries, deleteWorktreeHistoryDir, flushPendingWorktreeHistoryDeletions, - MAX_PENDING_HISTORY_TREE_REMOVALS, - HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS, schedulePendingHistoryTreeRemovals } from './terminal-history-deletion' import { @@ -63,118 +61,6 @@ describe('tombstoned history removal retries', () => { rmSync(userDataDir, { recursive: true, force: true }) }) - it('admits a bounded tombstone batch and drains the rest from disk', async () => { - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - const releases: (() => void)[] = [] - removeHostTreeMock.mockImplementation( - (dir) => - new Promise((resolve) => - releases.push(() => { - rmSync(dir, { recursive: true, force: true }) - resolve() - }) - ) - ) - - for (let index = 0; index < 1_000; index++) { - const worktreeId = `repo-1::/path/wsl-${index}` - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - } - - for (let index = 0; index < 1_000; index++) { - deleteWorktreeHistoryDir(`repo-1::/path/wsl-${index}`) - } - await vi.advanceTimersByTimeAsync(0) - expect(removeHostTreeMock).toHaveBeenCalledTimes(64) - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(64) - expect(releases).toHaveLength(64) - - for (let pass = 0; removeHostTreeMock.mock.calls.length < 1_000; pass++) { - expect(pass).toBeLessThan(200) - await vi.waitFor(() => expect(releases.length).toBeGreaterThan(0)) - releases.splice(0).forEach((release) => release()) - await vi.advanceTimersByTimeAsync(0) - await settleTombstoneDirectoryReads() - } - expect(removeHostTreeMock).toHaveBeenCalledTimes(1_000) - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(1_000) - }) - - it('caps persistent failures and leaves excess tombstones for a later disk batch', async () => { - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - // Fish cleanup failures are tolerated; fail tree removal itself to exercise the cap. - removeHostTreeMock.mockRejectedValue(new Error('EBUSY')) - for (let index = 0; index < 1_000; index++) { - const worktreeId = `repo-1::/path/fail-${index}` - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - deleteWorktreeHistoryDir(worktreeId) - } - await vi.advanceTimersByTimeAsync(0) - expect(vi.getTimerCount()).toBe(64) - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(1_000) - - for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) { - await vi.advanceTimersByTimeAsync(delay) - expect(vi.getTimerCount()).toBeLessThanOrEqual(64) - } - await vi.advanceTimersByTimeAsync(0) - removeHostTreeMock.mockReset() - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) - schedulePendingHistoryTreeRemovals(distroRoot) - await flushPendingWorktreeHistoryDeletions() - expect(removeHostTreeMock.mock.calls.length).toBeGreaterThan( - MAX_PENDING_HISTORY_TREE_REMOVALS * (HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1) - ) - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) - }) - - it('still removes the history tree when WSL fish cleanup fails', async () => { - // WSL terminals can record fish sessions even in distros without fish. - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - deleteWslFishHistoryFileMock.mockRejectedValue(new Error('fish: command not found')) - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) - const worktreeId = 'repo-1::/path/no-fish' - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - - deleteWorktreeHistoryDir(worktreeId) - await flushPendingWorktreeHistoryDeletions() - - expect(deleteWslFishHistoryFileMock).toHaveBeenCalled() - expect(removeHostTreeMock).toHaveBeenCalled() - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) - }) - it('keeps the tombstone while its distro is stopped and finishes once the distro runs', async () => { const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') mkdirSync(distroRoot, { recursive: true }) diff --git a/src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts b/src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts deleted file mode 100644 index 23ad48ee4fc..00000000000 --- a/src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' -import type * as NodeFsPromises from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup' - -const reads = vi.hoisted(() => ({ count: 0, names: 0, failNext: 0 })) -const { removeHostTreeMock } = vi.hoisted(() => ({ - removeHostTreeMock: vi.fn<(dir: string) => Promise>() -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const fsp = await importOriginal() - return { - ...fsp, - readdir: async (...args: Parameters) => { - if (!String(args[0]).endsWith('.pending-delete')) { - return fsp.readdir(...args) - } - reads.count++ - if (reads.failNext > 0) { - reads.failNext-- - throw new Error('EACCES: permission denied, scandir') - } - const result = await fsp.readdir(...args) - reads.names += Array.isArray(result) ? result.length : 0 - return result - } - } -}) - -vi.mock('../../filesystem/host-tree-removal', () => ({ removeHostTree: removeHostTreeMock })) - -import { - cancelPendingHistoryTreeRemovalRetries, - flushPendingWorktreeHistoryDeletions, - HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS, - MAX_PENDING_HISTORY_TREE_REMOVALS, - schedulePendingHistoryTreeRemovals -} from './terminal-history-deletion' - -describe('terminal history tombstone scan cost', () => { - let userDataDir: string - - beforeEach(() => { - userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-scan-cost-')) - installFakeAppEnvironment({ getPath: () => userDataDir }) - reads.count = 0 - reads.names = 0 - reads.failNext = 0 - removeHostTreeMock.mockReset() - }) - - afterEach(() => { - cancelPendingHistoryTreeRemovalRetries() - vi.useRealTimers() - rmSync(userDataDir, { recursive: true, force: true }) - }) - - function seedTombstones(root: string, count: number): void { - for (let index = 0; index < count; index++) { - mkdirSync(join(root, '.pending-delete', `old-session-${index}`), { - recursive: true - }) - } - } - - function holdRemovals(): (() => void)[] { - const releases: (() => void)[] = [] - removeHostTreeMock.mockImplementation( - (dir) => - new Promise((resolve) => - releases.push(() => { - rmSync(dir, { recursive: true, force: true }) - resolve() - }) - ) - ) - return releases - } - - // Do not let real directory reads land while settling removal promises. - async function settleRemovalPromises(): Promise { - for (let index = 0; index < 8; index++) { - await Promise.resolve() - } - } - - async function waitFor(label: string, condition: () => boolean): Promise { - for (let index = 0; index < 500; index++) { - if (condition()) { - return - } - await new Promise((resolve) => setTimeout(resolve, 2)) - } - throw new Error(`Timed out waiting for ${label}`) - } - - it('drains 1024 tombstones without re-reading the directory per completion', async () => { - const root = join(userDataDir, 'terminal-history') - const pendingRoot = join(root, '.pending-delete') - seedTombstones(root, 1024) - const releases = holdRemovals() - - await schedulePendingHistoryTreeRemovals(root) - expect(reads.count).toBe(1) - expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS) - while (releases.length > 0) { - expect(releases.length).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS) - releases.splice(0).forEach((release) => release()) - await settleRemovalPromises() - } - - expect(removeHostTreeMock).toHaveBeenCalledTimes(1024) - expect(readdirSync(pendingRoot)).toEqual([]) - expect(reads.count).toBeLessThanOrEqual(8) - expect(reads.names).toBeLessThanOrEqual(1024 + MAX_PENDING_HISTORY_TREE_REMOVALS) - }) - - it('picks up a tombstone created after the queued names were read', async () => { - const root = join(userDataDir, 'terminal-history') - const pendingRoot = join(root, '.pending-delete') - seedTombstones(root, 1) - const releases = holdRemovals() - - await schedulePendingHistoryTreeRemovals(root) - mkdirSync(join(pendingRoot, 'late-session')) - releases.splice(0).forEach((release) => release()) - - await waitFor('late tombstone admission', () => removeHostTreeMock.mock.calls.length === 2) - releases.splice(0).forEach((release) => release()) - await flushPendingWorktreeHistoryDeletions() - expect(readdirSync(pendingRoot)).toEqual([]) - }) - - it('drains both roots within the shared admission cap without per-completion reads', async () => { - const nativeRoot = join(userDataDir, 'terminal-history') - const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - seedTombstones(nativeRoot, 160) - seedTombstones(wslRoot, 160) - let inFlight = 0 - let peakInFlight = 0 - removeHostTreeMock.mockImplementation(async (dir) => { - inFlight++ - peakInFlight = Math.max(peakInFlight, inFlight) - await new Promise((resolve) => setTimeout(resolve, 0)) - rmSync(dir, { recursive: true, force: true }) - inFlight-- - }) - - await flushPendingWorktreeHistoryDeletions() - - expect(removeHostTreeMock).toHaveBeenCalledTimes(320) - expect(peakInFlight).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS) - expect(readdirSync(join(nativeRoot, '.pending-delete'))).toEqual([]) - expect(readdirSync(join(wslRoot, '.pending-delete'))).toEqual([]) - expect(reads.count).toBeLessThanOrEqual(12) - }) - - it('keeps draining after a directory read fails mid-drain', async () => { - const root = join(userDataDir, 'terminal-history') - const pendingRoot = join(root, '.pending-delete') - seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1) - const releases = holdRemovals() - - await schedulePendingHistoryTreeRemovals(root) - expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS) - - reads.failNext = 1 - releases.splice(0).forEach((release) => release()) - await settleRemovalPromises() - // The queued 65th removal comes from memory despite the failed refill read. - expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS + 1) - - mkdirSync(join(pendingRoot, 'late-after-failed-read')) - releases.splice(0).forEach((release) => release()) - await waitFor( - 'recovery after failed read', - () => removeHostTreeMock.mock.calls.length === MAX_PENDING_HISTORY_TREE_REMOVALS + 2 - ) - releases.splice(0).forEach((release) => release()) - await flushPendingWorktreeHistoryDeletions() - expect(readdirSync(pendingRoot)).toEqual([]) - }) - - it('drops an in-flight refill during fixture cleanup', async () => { - const root = join(userDataDir, 'terminal-history') - seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1) - const releases = holdRemovals() - - await schedulePendingHistoryTreeRemovals(root) - mkdirSync(join(root, '.pending-delete', 'late-session')) - releases.splice(0).forEach((release) => release()) - await settleRemovalPromises() - const admittedBeforeCleanup = removeHostTreeMock.mock.calls.length - - cancelPendingHistoryTreeRemovalRetries() - await waitFor('the dropped refill to land', () => reads.count >= 2) - await new Promise((resolve) => setTimeout(resolve, 10)) - expect(removeHostTreeMock).toHaveBeenCalledTimes(admittedBeforeCleanup) - }) - - it('preserves delayed failure retries while successful removals replenish the queue', async () => { - vi.useFakeTimers() - const root = join(userDataDir, 'terminal-history') - seedTombstones(root, 130) - const failedDir = join(root, '.pending-delete', 'old-session-0') - removeHostTreeMock.mockImplementation(async (dir) => { - if (dir === failedDir) { - throw new Error('EBUSY') - } - rmSync(dir, { recursive: true, force: true }) - }) - await flushPendingWorktreeHistoryDeletions() - expect(removeHostTreeMock).toHaveBeenCalledTimes(130) - expect(readdirSync(join(root, '.pending-delete'))).toEqual(['old-session-0']) - - await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[0] - 1) - expect(removeHostTreeMock).toHaveBeenCalledTimes(130) - await vi.advanceTimersByTimeAsync(1) - expect(removeHostTreeMock).toHaveBeenCalledTimes(131) - await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[1]) - expect(removeHostTreeMock).toHaveBeenCalledTimes(132) - expect(vi.getTimerCount()).toBe(0) - }) - - it('does not retry a tombstone past its attempt budget when another root frees slots', async () => { - vi.useFakeTimers() - const nativeRoot = join(userDataDir, 'terminal-history') - const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - seedTombstones(nativeRoot, 32) - seedTombstones(wslRoot, 130) - const releases: (() => void)[] = [] - const attemptsByDir = new Map() - removeHostTreeMock.mockImplementation((dir) => { - attemptsByDir.set(dir, (attemptsByDir.get(dir) ?? 0) + 1) - if (dir.startsWith(wslRoot)) { - return Promise.reject(new Error('EBUSY')) - } - return new Promise((resolve) => - releases.push(() => { - rmSync(dir, { recursive: true, force: true }) - resolve() - }) - ) - }) - await schedulePendingHistoryTreeRemovals(nativeRoot) - await schedulePendingHistoryTreeRemovals(wslRoot) - - for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) { - await vi.advanceTimersByTimeAsync(delay) - } - expect(vi.getTimerCount()).toBe(0) - const maxAttempts = 1 + HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length - const exhausted = [...attemptsByDir.entries()].filter(([, count]) => count === maxAttempts) - expect(exhausted.length).toBeGreaterThan(0) - - releases.splice(0).forEach((release) => release()) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(0) - // Freed slots may admit untried tombstones, but must not restart exhausted ones. - for (const count of attemptsByDir.values()) { - expect(count).toBeLessThanOrEqual(maxAttempts) - } - for (const [dir] of exhausted) { - expect(attemptsByDir.get(dir)).toBe(maxAttempts) - } - }) -}) diff --git a/src/main/pty/history/terminal-history.test.ts b/src/main/pty/history/terminal-history.test.ts deleted file mode 100644 index db22e51061e..00000000000 --- a/src/main/pty/history/terminal-history.test.ts +++ /dev/null @@ -1,731 +0,0 @@ -import type * as WslPathOwner from '../../../shared/wsl-paths' -import type * as FsPromises from 'node:fs/promises' -import { sep } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup' - -const { - existsSyncMock, - mkdirSyncMock, - writeFileSyncMock, - readFileSyncMock, - lstatSyncMock, - rmSyncMock, - renameSyncMock, - readdirSyncMock, - statSyncMock, - openSyncMock, - fstatSyncMock, - closeSyncMock, - getPathMock, - rmAsyncMock, - deleteWslFishHistoryFileMock -} = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - mkdirSyncMock: vi.fn(), - writeFileSyncMock: vi.fn(), - readFileSyncMock: vi.fn(), - lstatSyncMock: vi.fn(), - rmSyncMock: vi.fn(), - renameSyncMock: vi.fn(), - readdirSyncMock: vi.fn(), - statSyncMock: vi.fn(), - openSyncMock: vi.fn((_path?: string | Buffer) => 1), - fstatSyncMock: vi.fn(() => ({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - isFile: () => true, - isDirectory: () => true - })), - closeSyncMock: vi.fn(), - getPathMock: vi.fn(), - rmAsyncMock: vi.fn(async () => undefined), - deleteWslFishHistoryFileMock: vi.fn(async () => undefined) -})) - -vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({ - ...(await importOriginal()), - toLinuxPath: toLinuxPathMock -})) - -vi.mock('fs', () => ({ - existsSync: existsSyncMock, - mkdirSync: mkdirSyncMock, - writeFileSync: writeFileSyncMock, - readFileSync: readFileSyncMock, - lstatSync: lstatSyncMock, - rmSync: rmSyncMock, - renameSync: renameSyncMock, - readdirSync: readdirSyncMock, - statSync: statSyncMock, - openSync: openSyncMock, - fstatSync: fstatSyncMock, - closeSync: closeSyncMock -})) - -// Preserve transitive fs/promises imports while replacing the removal and listing entrypoints. -vi.mock('node:fs/promises', async () => ({ - ...(await vi.importActual('node:fs/promises')), - rm: rmAsyncMock, - readdir: async (path: string) => readdirSyncMock(path) ?? [] -})) - -const { parseWslPathMock, toLinuxPathMock } = vi.hoisted(() => ({ - parseWslPathMock: vi.fn((_path: string) => null as { distro: string; linuxPath: string } | null), - toLinuxPathMock: vi.fn((p: string) => p) -})) - -vi.mock('../../wsl/filesystem/unc-path-access', () => ({ - parseWslPath: parseWslPathMock -})) - -vi.mock('../../wsl/history/wsl-fish-history-cleanup', () => ({ - deleteWslFishHistoryFile: deleteWslFishHistoryFileMock -})) - -import { - resolveShellKind, - ensureHistoryDir, - injectHistoryEnv, - injectWslFishHistoryEnv, - MAX_HISTORY_META_BYTES, - updateHistoryEnvForFallback, - type HistoryInjectionResult -} from './terminal-history' -import { fishHistorySessionName, relayFishHistorySessionName } from './fish-history-session' -import { hashWorktreeId } from './terminal-history-id' -import { - cancelPendingHistoryTreeRemovalRetries, - deleteWorktreeHistoryDir, - flushPendingWorktreeHistoryDeletions -} from './terminal-history-deletion' - -const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt') - -describe('terminal-history', () => { - afterEach(() => { - cancelPendingHistoryTreeRemovalRetries() - vi.useRealTimers() - }) - - beforeEach(() => { - vi.clearAllMocks() - mkdirSyncMock.mockReset() - // clearAllMocks retains throwing implementations; reset them between tests. - renameSyncMock.mockReset() - readFileSyncMock.mockReset() - readdirSyncMock.mockReset() - rmAsyncMock.mockReset() - rmAsyncMock.mockResolvedValue(undefined) - getPathMock.mockReturnValue('/fake/userData') - installFakeAppEnvironment({ getPath: getPathMock }) - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - openSyncMock.mockImplementation(() => 1) - fstatSyncMock.mockReturnValue({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - isFile: () => true, - isDirectory: () => true - }) - lstatSyncMock.mockReturnValue({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - size: 100, - isDirectory: () => true, - isFile: () => true, - isSymbolicLink: () => false - }) - }) - - describe('resolveShellKind', () => { - it('detects versioned bash (bash-5.2)', () => { - expect(resolveShellKind('/usr/local/bin/bash-5.2')).toBe('bash') - }) - - it('detects versioned zsh (zsh-5.9)', () => { - expect(resolveShellKind('/usr/local/bin/zsh-5.9')).toBe('zsh') - }) - - it('detects nix-store zsh path', () => { - expect(resolveShellKind('/nix/store/abc123/bin/zsh')).toBe('zsh') - }) - - it('detects pwsh', () => { - expect(resolveShellKind('pwsh')).toBe('pwsh') - expect(resolveShellKind('pwsh.exe')).toBe('pwsh') - }) - }) - - describe('hashWorktreeId', () => { - it('produces deterministic output for the same input', () => { - const a = hashWorktreeId('repo-1::/Users/foo/worktree-a') - const b = hashWorktreeId('repo-1::/Users/foo/worktree-a') - expect(a).toBe(b) - }) - - it('produces different output for different inputs', () => { - const a = hashWorktreeId('repo-1::/Users/foo/worktree-a') - const b = hashWorktreeId('repo-1::/Users/foo/worktree-b') - expect(a).not.toBe(b) - }) - - it('produces a 16-character hex string', () => { - const hash = hashWorktreeId('repo-1::/Users/foo/worktree-a') - expect(hash).toMatch(/^[0-9a-f]{16}$/) - }) - }) - - describe('ensureHistoryDir', () => { - it('creates directory with mode 0o700', () => { - ensureHistoryDir('abcdef0123456789') - expect(mkdirSyncMock).toHaveBeenCalledWith( - expect.stringMatching(/[\\/]fake[\\/]userData[\\/]terminal-history[\\/]abcdef0123456789$/), - { recursive: true, mode: 0o700 } - ) - }) - - it('returns null on mkdir failure', () => { - mkdirSyncMock.mockImplementation(() => { - throw new Error('permission denied') - }) - const result = ensureHistoryDir('abcdef0123456789') - expect(result).toBeNull() - }) - }) - - describe('injectHistoryEnv', () => { - it('injects HISTFILE for zsh', () => { - mkdirSyncMock.mockReturnValue(undefined) - - const env: Record = {} - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(result.shell).toBe('zsh') - expect(result.histFile).toContain('terminal-history') - expect(result.histFile).toContain('zsh_history') - expect(env.HISTFILE).toBe(result.histFile) - }) - - it('injects HISTFILE for bash', () => { - mkdirSyncMock.mockReturnValue(undefined) - - const env: Record = {} - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/bash', '/path/wt') - - expect(result.shell).toBe('bash') - expect(result.histFile).toContain('bash_history') - expect(env.HISTFILE).toBe(result.histFile) - }) - - it('produces different HISTFILE for different worktreeIds', () => { - mkdirSyncMock.mockReturnValue(undefined) - existsSyncMock.mockReturnValue(true) - - const envA: Record = {} - injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/bin/zsh', '/path/wt-a') - - const envB: Record = {} - injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/bin/zsh', '/path/wt-b') - - expect(envA.HISTFILE).not.toBe(envB.HISTFILE) - }) - - it('preserves caller-provided HISTFILE (check-before-set)', () => { - const env: Record = { HISTFILE: '/my/custom/histfile' } - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.HISTFILE).toBe('/my/custom/histfile') - expect(result.histFile).toBeNull() - }) - - it('does not inject HISTFILE for unknown shells', () => { - const env: Record = {} - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/tcsh', '/path/wt') - - expect(env.HISTFILE).toBeUndefined() - expect(result.shell).toBe('unknown') - expect(result.histFile).toBeNull() - }) - - it('does not inject HISTFILE for cmd.exe', () => { - const env: Record = {} - const result = injectHistoryEnv(env, 'repo-1::/path/wt', 'cmd.exe', '/path/wt') - - expect(env.HISTFILE).toBeUndefined() - expect(result.shell).toBe('cmd') - expect(result.histFile).toBeNull() - }) - - it('scopes fish by session name instead of HISTFILE, which fish ignores', () => { - const env: Record = {} - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(env.HISTFILE).toBeUndefined() - expect(result.shell).toBe('fish') - expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt'))) - expect(env.fish_history).toBe(result.fishSession) - expect(writeFileSyncMock).toHaveBeenCalledWith( - expect.stringContaining('meta.json'), - expect.stringContaining(`"fishSession":"${result.fishSession}"`), - expect.anything() - ) - }) - - it('records the history DIRECTORY from the SPAWN env, not this process env', () => { - const originalDataHome = process.env.XDG_DATA_HOME - process.env.XDG_DATA_HOME = ['', 'main', 'process', 'data'].join(sep) - try { - const env: Record = { XDG_DATA_HOME: ['', 'spawn', 'data'].join(sep) } - injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record< - string, - string - > - - expect(meta.fishHistoryDir).toBe(['', 'spawn', 'data', 'fish'].join(sep)) - } finally { - if (originalDataHome === undefined) { - delete process.env.XDG_DATA_HOME - } else { - process.env.XDG_DATA_HOME = originalDataHome - } - } - }) - - it('replaces oversized metadata without parsing it', () => { - statSyncMock.mockReturnValue({ isDirectory: () => true, size: MAX_HISTORY_META_BYTES + 1 }) - const env = { XDG_DATA_HOME: ['', 'active'].join(sep) } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(readFileSyncMock).not.toHaveBeenCalled() - const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record< - string, - string - > - expect(meta.fishHistoryDir).toBe(['', 'active', 'fish'].join(sep)) - }) - - it('gives two fish worktrees different history sessions', () => { - const envA: Record = {} - injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/usr/bin/fish', '/path/wt-a') - const envB: Record = {} - injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/usr/bin/fish', '/path/wt-b') - - expect(envA.fish_history).not.toBe(envB.fish_history) - }) - - it('drops an ORCA_HISTFILE inherited from a parent Orca PTY', () => { - const env: Record = { - ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep) - } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.ORCA_HISTFILE).toBe(env.HISTFILE) - expect(env.ORCA_HISTFILE).not.toContain('other') - }) - - it('drops an inherited ORCA_HISTFILE even when it injects nothing', () => { - const env: Record = { - HISTFILE: ['', 'mine', 'zsh_history'].join(sep), - ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep) - } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.HISTFILE).toBe(['', 'mine', 'zsh_history'].join(sep)) - expect(env.ORCA_HISTFILE).toBeUndefined() - }) - - it.each([ - [ - 'desktop', - ['', 'fake', 'userData', 'terminal-history', OTHER_WORKTREE_HASH, 'zsh_history'].join(sep) - ], - [ - 'relay', - [ - '', - 'home', - 'me', - '.orca-remote', - 'terminal-history', - `${OTHER_WORKTREE_HASH}-zsh_history` - ].join(sep) - ] - ])('drops a %s HISTFILE inherited from a parent Orca pane', (_kind, inherited) => { - const env: Record = { HISTFILE: inherited } - - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(result.histFile).toBe(env.HISTFILE) - expect(env.HISTFILE).toContain(hashWorktreeId('repo-1::/path/wt')) - expect(env.HISTFILE).not.toContain(OTHER_WORKTREE_HASH) - }) - - it.each([ - ['an ordinary path', ['', 'home', 'me', '.zsh_history'].join(sep)], - // Orca mints absolute paths; a matching relative path belongs to the user. - [ - 'a relative path of Orca’s shape', - ['terminal-history', OTHER_WORKTREE_HASH, 'zsh_history'].join(sep) - ] - ])('preserves %s the user set as HISTFILE', (_kind, histFile) => { - const env: Record = { HISTFILE: histFile } - - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.HISTFILE).toBe(histFile) - expect(result.histFile).toBeNull() - }) - - it('preserves a caller-supplied fish_history', () => { - const env: Record = { fish_history: 'mine' } - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(env.fish_history).toBe('mine') - expect(result.fishSession).toBeNull() - }) - - it.each([ - ['desktop', fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))], - ['relay', relayFishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))] - ])('replaces a %s fish_history inherited from a parent Orca', (_kind, inherited) => { - const env: Record = { fish_history: inherited } - - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt'))) - expect(env.fish_history).toBe(result.fishSession) - }) - - it('drops an inherited fish_history even when the shell is not fish', () => { - const env: Record = { - fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt')) - } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.fish_history).toBeUndefined() - }) - - it('degrades gracefully when directory creation fails', () => { - mkdirSyncMock.mockImplementation(() => { - throw new Error('disk full') - }) - - const env: Record = {} - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.HISTFILE).toBeUndefined() - expect(result.histFile).toBeNull() - }) - }) - - describe('injectWslFishHistoryEnv', () => { - it('replaces an inherited Orca fish_history with this worktree session', () => { - const env: Record = { - fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt')) - } - - const session = injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu') - - expect(session).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt'))) - expect(env.fish_history).toBe(session) - }) - - it('preserves a caller-supplied fish_history', () => { - const env: Record = { fish_history: 'mine' } - - expect(injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu')).toBeNull() - expect(env.fish_history).toBe('mine') - }) - }) - - describe('updateHistoryEnvForFallback', () => { - const zshInjection = (): HistoryInjectionResult => ({ - shell: 'zsh', - histFile: '/fake/userData/terminal-history/abc123/zsh_history', - fishSession: null, - historyDir: '/fake/userData/terminal-history/abc123' - }) - - it('updates HISTFILE to match fallback shell', () => { - const env: Record = { - HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history' - } - updateHistoryEnvForFallback(env, '/bin/bash', zshInjection()) - expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history') - }) - - it('removes HISTFILE for unknown fallback shell', () => { - const env: Record = { - HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history' - } - updateHistoryEnvForFallback(env, '/bin/sh', zshInjection()) - expect(env.HISTFILE).toBeUndefined() - }) - - it('is a no-op when nothing was injected', () => { - const env: Record = {} - updateHistoryEnvForFallback(env, '/bin/bash', { - shell: 'unknown', - histFile: null, - fishSession: null, - historyDir: null - }) - expect(env.HISTFILE).toBeUndefined() - }) - - it('swaps an injected fish session for the fallback shell HISTFILE', () => { - const env: Record = { fish_history: 'orca_abc123' } - updateHistoryEnvForFallback(env, '/bin/bash', { - shell: 'fish', - histFile: null, - fishSession: 'orca_abc123', - historyDir: '/fake/userData/terminal-history/abc123' - }) - expect(env.fish_history).toBeUndefined() - expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history') - }) - - it('keeps a caller-supplied fish_history the injection never claimed', () => { - const env: Record = { fish_history: 'mine' } - updateHistoryEnvForFallback(env, '/bin/bash', zshInjection()) - expect(env.fish_history).toBe('mine') - }) - }) - - describe('deleteWorktreeHistoryDir', () => { - const worktreeId = 'repo-1::/path/wt' - const session = fishHistorySessionName(hashWorktreeId(worktreeId)) - const historyFilename = `${session}_history` - - it('tombstones then async-removes the history directory without recursive rmSync', async () => { - existsSyncMock.mockReturnValue(true) - deleteWorktreeHistoryDir('repo-1::/path/wt') - expect(renameSyncMock).toHaveBeenCalled() - expect(rmSyncMock).not.toHaveBeenCalled() - expect(rmAsyncMock).toHaveBeenCalledWith( - expect.stringContaining('.pending-delete'), - expect.objectContaining({ recursive: true, force: true }) - ) - await flushPendingWorktreeHistoryDeletions() - }) - - it('deletes the fish history file in the directory meta.json recorded', async () => { - const recordedDir = ['', 'spawn', 'data', 'fish'].join(sep) - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId, fishSession: session, fishHistoryDir: recordedDir }) - ) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).toHaveBeenCalledWith([recordedDir, historyFilename].join(sep)) - await flushPendingWorktreeHistoryDeletions() - }) - - it('also tries this process fish dir, for meta written before that field', async () => { - const originalDataHome = process.env.XDG_DATA_HOME - process.env.XDG_DATA_HOME = ['', 'main', 'data'].join(sep) - try { - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).toHaveBeenCalledWith( - ['', 'main', 'data', 'fish', historyFilename].join(sep) - ) - } finally { - if (originalDataHome === undefined) { - delete process.env.XDG_DATA_HOME - } else { - process.env.XDG_DATA_HOME = originalDataHome - } - } - await flushPendingWorktreeHistoryDeletions() - }) - - it('ignores a fishSession that does not belong to this history directory', async () => { - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId, fishSession: 'orca_deadbeefdeadbeef' }) - ) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).not.toHaveBeenCalledWith(expect.stringContaining('orca_deadbeefdeadbeef')) - await flushPendingWorktreeHistoryDeletions() - }) - - it('refuses to unlink a fish history path that is not a regular file', async () => { - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => false }) - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).not.toHaveBeenCalled() - await flushPendingWorktreeHistoryDeletions() - }) - - it('cleans WSL Fish history through the owning distro before removing metadata', async () => { - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - readdirSyncMock.mockImplementation((path: string) => { - if (path.endsWith('terminal-history-wsl')) { - return ['Ubuntu'] - } - return [] - }) - - deleteWorktreeHistoryDir(worktreeId) - await flushPendingWorktreeHistoryDeletions() - - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledWith('Ubuntu', session) - }) - - it('leaves the live directory alone when the tombstone rename fails', async () => { - existsSyncMock.mockReturnValue(true) - renameSyncMock.mockImplementation(() => { - throw new Error('permission denied') - }) - - expect(() => deleteWorktreeHistoryDir('repo-1::/path/wt')).not.toThrow() - - // A recreated worktree can own the live path before async rm lands; leave it for GC. - expect(rmAsyncMock).not.toHaveBeenCalled() - expect(rmSyncMock).not.toHaveBeenCalled() - await flushPendingWorktreeHistoryDeletions() - }) - - it('does not throw when the async removal itself fails', async () => { - existsSyncMock.mockReturnValue(true) - rmAsyncMock.mockRejectedValueOnce(new Error('async fail')) - expect(() => deleteWorktreeHistoryDir('repo-1::/path/wt')).not.toThrow() - await expect(flushPendingWorktreeHistoryDeletions()).resolves.toBeUndefined() - }) - - it('retries pending tombstones on flush after a failed async rm (app-quit durability)', async () => { - let leftoverTombstonePresent = true - existsSyncMock.mockImplementation((p: string) => { - const path = String(p) - if (path.includes('.pending-delete') && !path.endsWith('.pending-delete')) { - return true - } - if (path.endsWith('terminal-history') || path.endsWith('.pending-delete')) { - return true - } - return path.includes(hashWorktreeId('repo-1::/path/wt')) - }) - readdirSyncMock.mockImplementation((p: string) => { - if (String(p).endsWith('.pending-delete')) { - return leftoverTombstonePresent ? ['leftover-tombstone'] : [] - } - return [] - }) - rmAsyncMock.mockImplementation(async () => { - leftoverTombstonePresent = false - }) - await flushPendingWorktreeHistoryDeletions() - expect(rmAsyncMock).toHaveBeenCalledWith( - expect.stringContaining('leftover-tombstone'), - expect.objectContaining({ recursive: true, force: true }) - ) - }) - }) - - describe('WSL path conversion', () => { - it('converts HISTFILE to Linux path for WSL cwd', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - try { - parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/user/project' }) - toLinuxPathMock.mockReturnValue( - '/mnt/c/Users/user/AppData/Roaming/Orca/terminal-history-wsl/Ubuntu/abc123/bash_history' - ) - mkdirSyncMock.mockReturnValue(undefined) - existsSyncMock.mockReturnValue(true) - - const env: Record = {} - const result = injectHistoryEnv( - env, - 'repo-1::/wsl/path', - '/bin/bash', - '\\\\wsl.localhost\\Ubuntu\\home\\user\\project' - ) - - expect(toLinuxPathMock).toHaveBeenCalled() - expect(result.histFile).toContain('/mnt/') - expect(env.HISTFILE).toBe(result.histFile) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('stores WSL history under terminal-history-wsl//', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - try { - parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/user' }) - toLinuxPathMock.mockImplementation((p: string) => p) - mkdirSyncMock.mockReturnValue(undefined) - existsSyncMock.mockReturnValue(true) - - const env: Record = {} - injectHistoryEnv( - env, - 'repo-1::/wsl/path', - '/bin/bash', - '\\\\wsl.localhost\\Ubuntu\\home\\user' - ) - - expect(mkdirSyncMock).toHaveBeenCalledWith( - expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/), - expect.any(Object) - ) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('uses the project WSL distro hint when cwd is a Windows path', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - try { - parseWslPathMock.mockReturnValue(null) - toLinuxPathMock.mockImplementation((p: string) => p.replace(/^C:\\/i, '/mnt/c/')) - mkdirSyncMock.mockReturnValue(undefined) - existsSyncMock.mockReturnValue(true) - getPathMock.mockReturnValue('C:\\Users\\alice\\AppData\\Roaming\\Orca') - - const env: Record = {} - const result = injectHistoryEnv(env, 'repo-1::C:\\repo', '/bin/bash', 'C:\\repo', { - wslDistro: 'Ubuntu' - }) - - expect(mkdirSyncMock).toHaveBeenCalledWith( - expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/), - expect.any(Object) - ) - expect(toLinuxPathMock).toHaveBeenCalled() - expect(result.histFile).toMatch(/^\/mnt\/c\//) - expect(env.HISTFILE).toBe(result.histFile) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - }) -}) diff --git a/src/main/pty/process-retirement/posix-pty-foreground-group.test.ts b/src/main/pty/process-retirement/posix-pty-foreground-group.test.ts index 13063397efa..91f1ccb5b00 100644 --- a/src/main/pty/process-retirement/posix-pty-foreground-group.test.ts +++ b/src/main/pty/process-retirement/posix-pty-foreground-group.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { getPosixPtyForegroundGroup, @@ -9,10 +9,6 @@ import { const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn<(spec: ProcessSpec) => Promise>() })) -const pidArg = (spec: ProcessSpec): string => { - const args = spec.args ?? [] - return args[args.indexOf('-p') + 1] -} vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) const psResult = (stdout: string): ProcessResult => ({ @@ -26,10 +22,6 @@ const psResult = (stdout: string): ProcessResult => ({ const macosTable = ['84644 84985 ttys318', ' 4242 4242 ttys002'].join('\n') describe('getPosixPtyForegroundGroup', () => { - it('accepts a Linux pts device name', () => { - expect(getPosixPtyForegroundGroup('900 931 pts/3', 900, '/dev/pts/3', 4242)).toBe(931) - }) - it('refuses when the pid now answers for a different tty', () => { // A recycled root PID must not signal another terminal's group. expect(getPosixPtyForegroundGroup(macosTable, 84644, '/dev/ttys999', 4242)).toBeNull() @@ -40,16 +32,6 @@ describe('getPosixPtyForegroundGroup', () => { const shared = ['84644 84985 ttys318', '4242 84985 ttys318'].join('\n') expect(getPosixPtyForegroundGroup(shared, 84644, '/dev/ttys318', 4242)).toBeNull() }) - - it('refuses a detached tty or an unowned foreground group', () => { - expect(getPosixPtyForegroundGroup('84644 84985 ??', 84644, '/dev/ttys318', 4242)).toBeNull() - expect(getPosixPtyForegroundGroup('84644 -1 ttys318', 84644, '/dev/ttys318', 4242)).toBeNull() - expect(getPosixPtyForegroundGroup('84644 1 ttys318', 84644, '/dev/ttys318', 4242)).toBeNull() - }) - - it('refuses when the root pid is absent from the table', () => { - expect(getPosixPtyForegroundGroup('4242 4242 ttys002', 84644, '/dev/ttys318', 4242)).toBeNull() - }) }) describe('signalPosixPtyForegroundGroup', () => { @@ -70,157 +52,6 @@ describe('signalPosixPtyForegroundGroup', () => { kill.mockRestore() } }) - - it('falls back to the root pid on Windows', async () => { - // Windows rejects negative PIDs and treats SIGTERM/SIGINT as termination. - const fallback = vi.fn() - await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, { - platform: 'win32', - currentPid: 4242, - readProcessTable: table - }) - expect(fallback).toHaveBeenCalledOnce() - }) - - it('falls back when the slave device name is unknown', async () => { - const fallback = vi.fn() - await signalPosixPtyForegroundGroup(84644, undefined, 'SIGWINCH', fallback, { - platform: 'darwin', - currentPid: 4242, - readProcessTable: table - }) - expect(fallback).toHaveBeenCalledOnce() - }) - - it('falls back when the process table cannot be read', async () => { - const fallback = vi.fn() - await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, { - platform: 'darwin', - currentPid: 4242, - readProcessTable: async () => { - throw new Error('ps timed out') - } - }) - expect(fallback).toHaveBeenCalledOnce() - }) - - it('treats a vanished foreground group as done rather than falling back', async () => { - // ESRCH means the captured target vanished; root fallback would be stale too. - const kill = vi.spyOn(process, 'kill').mockImplementation(() => { - const error = new Error('no such process') as NodeJS.ErrnoException - error.code = 'ESRCH' - throw error - }) - const fallback = vi.fn() - try { - await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, { - platform: 'darwin', - currentPid: 4242, - readProcessTable: table - }) - expect(fallback).not.toHaveBeenCalled() - } finally { - kill.mockRestore() - } - }) - - it('falls back when foreground group signalling fails', async () => { - const kill = vi.spyOn(process, 'kill').mockImplementation(() => { - const error = new Error('operation not permitted') as NodeJS.ErrnoException - error.code = 'EPERM' - throw error - }) - const fallback = vi.fn() - try { - await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, { - platform: 'darwin', - currentPid: 4242, - readProcessTable: table - }) - expect(fallback).toHaveBeenCalledOnce() - } finally { - kill.mockRestore() - } - }) -}) - -describe('process table lookup', () => { - beforeEach(() => { - resetPosixPtyForegroundGroupOwnRowCache() - }) - - it('asks ps for one pid at a time', async () => { - // macOS PID lists scan the whole table (~3.6s vs ~3ms), silently timing out into root-only delivery. - runProcessMock.mockReset() - runProcessMock.mockResolvedValue(psResult('84644 84985 ttys318')) - // Mock group signalling so fixture PGIDs cannot target real host processes. - const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) - - try { - await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', vi.fn(), { - platform: 'darwin', - currentPid: 4242 - }) - - expect(runProcessMock).toHaveBeenCalled() - let timeoutBudget = 0 - for (const [spec] of runProcessMock.mock.calls) { - expect(pidArg(spec)).toMatch(/^\d+$/) - expect(spec.args?.filter((arg) => arg === '-p')).toHaveLength(1) - timeoutBudget += spec.timeoutMs ?? Infinity - } - expect(timeoutBudget).toBeLessThanOrEqual(250) - } finally { - kill.mockRestore() - } - }) - - it('forks ps once per pane after the first SIGWINCH of the process', async () => { - // Reuse Orca's own TTY row: repeated ps forks doubled the per-pane resize cost. - const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) - const panes = [ - { rootPid: 900, tty: 'ttys301' }, - { rootPid: 901, tty: 'ttys302' }, - { rootPid: 902, tty: 'ttys303' }, - { rootPid: 903, tty: 'ttys304' } - ] - - try { - runProcessMock.mockReset() - runProcessMock.mockImplementation(async (spec) => { - const pid = Number(pidArg(spec)) - if (pid === 4242) { - return psResult('4242 4242 ttys002') - } - const pane = panes.find((entry) => entry.rootPid === pid) - return psResult(pane ? `${pane.rootPid} ${pane.rootPid + 50} ${pane.tty}` : '') - }) - - for (const pane of panes) { - // Hidden restore and reattach each signal once per pane. - for (let signalIndex = 0; signalIndex < 2; signalIndex += 1) { - await signalPosixPtyForegroundGroup( - pane.rootPid, - `/dev/${pane.tty}`, - 'SIGWINCH', - vi.fn(), - { - platform: 'darwin', - currentPid: 4242 - } - ) - } - } - - const pidArgs = runProcessMock.mock.calls.map(([spec]) => Number(pidArg(spec))) - expect(pidArgs.filter((pid) => pid === 4242)).toHaveLength(1) - expect(pidArgs).toHaveLength(9) - expect(kill).toHaveBeenCalledTimes(8) - } finally { - runProcessMock.mockReset() - kill.mockRestore() - } - }) }) it('shares the native master group on resize with own-TTY and lifetime protection', async () => { diff --git a/src/main/pty/process-retirement/posix-pty-process-groups.test.ts b/src/main/pty/process-retirement/posix-pty-process-groups.test.ts index 7acbf768ed0..fa4728a971c 100644 --- a/src/main/pty/process-retirement/posix-pty-process-groups.test.ts +++ b/src/main/pty/process-retirement/posix-pty-process-groups.test.ts @@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' -import type { ProcessResult } from '@orca/process-host/process-spec' - const { readFileMock, readFileSyncMock, @@ -33,19 +31,12 @@ vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock, runProcessSync: runProcessSyncMock })) - import { forceKillPosixPtyProcessGroups, - forceKillPosixPtyProcessGroupsSync, getPosixPtyProcessGroups, - isPosixPtyRootStopped, signalPosixPtyProcessGroups } from './posix-pty-process-groups' -import { - parseLinuxProcStatSessionId, - readPosixPtyProcessTable, - resetPosixPtyProcessTableDialectForTests -} from './posix-pty-process-table' +import { resetPosixPtyProcessTableDialectForTests } from './posix-pty-process-table' // `/proc//stat` fixture: fields after comm are state, ppid, pgrp, session, tty_nr, ... const procStat = (pid: number, session: number, comm = 'bash'): string => @@ -79,12 +70,6 @@ const TABLE = ` 200 200 ttys002 300 300 ?? ` - -const ALL_PROCESS_ARGS = [ - '-e', - '-o', - 'pid=PROCESS_ID,pgid=PROCESS_GID,tty=TERMINAL_DEVICE_NUMBER,stat=PROCESS_STATE' -] const BUSYBOX_TABLE = ` PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER 100 100 136,100 @@ -93,204 +78,8 @@ PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER 201 201 136,10 999 999 ? ` -const unsupportedSelection = (stderr = 'ps: unrecognized option: p\n'): ProcessResult => ({ - code: 1, - signal: null, - stdout: '', - stderr, - timedOut: false -}) describe('ps selection compatibility', () => { - it.each([ - ['p', 'ps: unrecognized option: p\nBusyBox v1.37\nUsage: ps'], - ['p', "ps: invalid option -- 'p'\n"], - ['p', 'ps: illegal option -- p\n'], - ['t', 'ps: unrecognized option: t\n'] - ])( - 'falls back after a rejected %s selector and caches only the dialect (%s)', - async (option, stderr) => { - if (option === 't') { - runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 pts/100' }) - } - runProcessMock - .mockResolvedValueOnce(unsupportedSelection(stderr)) - .mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) - .mockResolvedValueOnce({ - code: 0, - stdout: BUSYBOX_TABLE.replace('201 201 136,10', '202 202 136,10') - }) - - const first = await readPosixPtyProcessTable(100) - const second = await readPosixPtyProcessTable(200) - expect(first).toBe(BUSYBOX_TABLE) - expect(getPosixPtyProcessGroups(first, 100, 999)).toEqual([101, 100]) - expect(getPosixPtyProcessGroups(second, 200, 999)).toEqual([202, 200]) - expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ - ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], - ...(option === 't' ? [['-t', 'pts/100', '-o', 'pid=,pgid=,tty=,stat=']] : []), - ALL_PROCESS_ARGS, - ALL_PROCESS_ARGS - ]) - expect( - runProcessMock.mock.calls.every( - ([spec]) => spec.maxOutputBytes === 1048576 && spec.timeoutMs === 1000 - ) - ).toBe(true) - } - ) - - it('shares the initial unsupported probe across concurrent callers and cancels waiting independently', async () => { - let resolveProbe: (result: ProcessResult) => void = () => {} - runProcessMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveProbe = resolve - }) - ) - .mockResolvedValue({ code: 0, stdout: BUSYBOX_TABLE }) - const first = readPosixPtyProcessTable(100) - const second = readPosixPtyProcessTable(200) - const controller = new AbortController() - const cancelled = readPosixPtyProcessTable(300, controller.signal) - expect(runProcessMock).toHaveBeenCalledOnce() - controller.abort() - await expect(cancelled).rejects.toThrow() - resolveProbe(unsupportedSelection()) - - expect(getPosixPtyProcessGroups(await first, 100, 999)).toEqual([101, 100]) - expect(getPosixPtyProcessGroups(await second, 200, 999)).toEqual([201, 200]) - expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ - ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], - ALL_PROCESS_ARGS, - ALL_PROCESS_ARGS - ]) - }) - - it('releases every waiter after a failed probe instead of chaining another probe per caller', async () => { - // A wedged ps would otherwise stack one timeout per concurrent force-kill. - const probe = Promise.withResolvers() - const queries: ((result: ProcessResult) => void)[] = [] - runProcessMock - .mockImplementationOnce(() => probe.promise) - .mockImplementation(() => new Promise((resolve) => queries.push(resolve))) - const first = readPosixPtyProcessTable(100) - const second = readPosixPtyProcessTable(200) - const third = readPosixPtyProcessTable(300) - - probe.resolve({ code: null, signal: 'SIGTERM', stdout: '', stderr: '', timedOut: true }) - await expect(first).rejects.toThrow('unavailable') - await vi.waitFor(() => expect(runProcessMock).toHaveBeenCalledTimes(3)) - - for (const resolve of queries) { - resolve({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false }) - } - await expect(Promise.all([second, third])).resolves.toEqual(['', '']) - }) - - it('uses the cached async dialect for synchronous teardown with fresh membership', async () => { - runProcessMock - .mockResolvedValueOnce(unsupportedSelection()) - .mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) - await readPosixPtyProcessTable(100) - runProcessSyncMock.mockReturnValue({ - code: 0, - stdout: BUSYBOX_TABLE.replace('101 101 136,100', '102 102 136,100') - }) - const signalProcessGroup = vi.fn() - const fallback = vi.fn() - forceKillPosixPtyProcessGroupsSync(100, fallback, { - platform: 'linux', - currentPid: 999, - signalProcessGroup - }) - expect(runProcessSyncMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS]) - expect(signalProcessGroup.mock.calls).toEqual([[102], [100]]) - expect(fallback).not.toHaveBeenCalled() - }) - - it('discovers unsupported selection during teardown and shares it with async readers', async () => { - runProcessSyncMock - .mockReturnValueOnce(unsupportedSelection()) - .mockReturnValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) - const signalProcessGroup = vi.fn() - const fallback = vi.fn() - forceKillPosixPtyProcessGroupsSync(100, fallback, { - platform: 'linux', - currentPid: 999, - signalProcessGroup - }) - expect(signalProcessGroup.mock.calls).toEqual([[101], [100]]) - expect(fallback).not.toHaveBeenCalled() - runProcessMock.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) - await readPosixPtyProcessTable(200) - expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS]) - }) - - it.each([ - { stderr: 'ps: permission denied' }, - { stderr: 'ps: unrecognized option: o' }, - { stderr: 'ps: unrecognized option: t' }, - { timedOut: true }, - { outputTruncated: true }, - { code: null, signal: 'SIGTERM' } - ])('does not turn an unrelated failure into a full-host scan: %j', async (failure) => { - runProcessMock.mockResolvedValueOnce({ ...unsupportedSelection(), ...failure }) - await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') - expect(runProcessMock).toHaveBeenCalledOnce() - runProcessMock - .mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' }) - .mockResolvedValueOnce({ code: 0, stdout: TABLE }) - await readPosixPtyProcessTable(100) - expect(runProcessMock.mock.calls[1][0].args).toEqual([ - '-p', - '100', - '-o', - 'pid=,pgid=,tty=,stat=' - ]) - }) - - it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])( - 'rejects incomplete fallback snapshots for async discovery and sync teardown: %j', - async (failure) => { - runProcessMock - .mockResolvedValueOnce(unsupportedSelection()) - .mockResolvedValueOnce({ stdout: BUSYBOX_TABLE, ...failure }) - await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') - runProcessSyncMock.mockReturnValue({ stdout: BUSYBOX_TABLE, ...failure }) - const signalProcessGroup = vi.fn() - const fallback = vi.fn() - forceKillPosixPtyProcessGroupsSync(100, fallback, { - platform: 'linux', - currentPid: 999, - signalProcessGroup - }) - expect(fallback).toHaveBeenCalledOnce() - expect(signalProcessGroup).not.toHaveBeenCalled() - } - ) - - it('does not cache a rejected selector if the caller has already cancelled', async () => { - const controller = new AbortController() - runProcessMock.mockImplementationOnce(async () => { - controller.abort() - return unsupportedSelection() - }) - await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow() - expect(runProcessMock).toHaveBeenCalledOnce() - runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 ?' }) - await readPosixPtyProcessTable(100) - expect(runProcessMock.mock.calls[1][0].args[0]).toBe('-p') - }) - - it.each(['?', '??', '-', '0', '0,0'])( - 'refuses to group processes without a controlling terminal (%s)', - (tty) => { - expect(getPosixPtyProcessGroups(`100 100 ${tty}\n101 101 ${tty}`, 100, 999)).toBeNull() - } - ) - it('preserves full numeric terminal identity and the daemon terminal guard', () => { expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 100, 999)).toEqual([101, 100]) expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 200, 999)).toEqual([201, 200]) @@ -298,94 +87,7 @@ describe('ps selection compatibility', () => { }) }) -describe('asynchronous PTY process discovery', () => { - it('requires a stopped state for the exact shell process', () => { - expect(isPosixPtyRootStopped('100 100 pts/test Ts\n101 101 pts/test R+', 100)).toBe(true) - expect(isPosixPtyRootStopped('100 100 pts/test S\n101 101 pts/test T', 100)).toBe(false) - expect(isPosixPtyRootStopped('101 101 pts/test T', 100)).toBe(false) - expect(isPosixPtyRootStopped('100 100 pts/test\n101 101 pts/test T', 100)).toBe(false) - }) - - it('bounds each lookup and selects the root terminal without synchronous subprocesses', async () => { - const controller = new AbortController() - runProcessMock - .mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' }) - .mockResolvedValueOnce({ code: 0, stdout: TABLE }) - - expect(await readPosixPtyProcessTable(100, controller.signal)).toBe(`100 100 ttys001\n${TABLE}`) - expect( - runProcessMock.mock.calls.map(([spec]) => [{ ...spec, env: { LC_ALL: spec.env.LC_ALL } }]) - ).toEqual([ - [ - { - program: 'ps', - env: { LC_ALL: 'C' }, - args: ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], - timeoutMs: 1000, - maxOutputBytes: 1048576, - signal: controller.signal - } - ], - [ - { - program: 'ps', - env: { LC_ALL: 'C' }, - args: ['-t', 'ttys001', '-o', 'pid=,pgid=,tty=,stat='], - timeoutMs: 1000, - maxOutputBytes: 1048576, - signal: controller.signal - } - ] - ]) - expect(runProcessSyncMock).not.toHaveBeenCalled() - }) - - it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])( - 'rejects incomplete process evidence: %j', - async (result) => { - runProcessMock.mockResolvedValue({ stdout: TABLE, ...result }) - await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') - expect(runProcessMock).toHaveBeenCalledOnce() - } - ) - - it('does not start the second lookup after cancellation', async () => { - const controller = new AbortController() - runProcessMock.mockImplementation(async () => { - controller.abort() - return { code: 0, stdout: '100 100 ttys001' } - }) - await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow() - expect(runProcessMock).toHaveBeenCalledOnce() - }) -}) - describe('POSIX PTY process-group termination', () => { - it('returns every group attached to the root PTY with the root group last', () => { - expect(getPosixPtyProcessGroups(TABLE, 100, 999)).toEqual([101, 103, 100]) - }) - - it('refuses an unbound root or a PTY shared with Orca itself', () => { - expect(getPosixPtyProcessGroups(TABLE, 300, 999)).toBeNull() - expect(getPosixPtyProcessGroups(TABLE, 100, 102)).toBeNull() - expect(getPosixPtyProcessGroups(TABLE, 999, 998)).toBeNull() - }) - - it('kills foreground and background groups before the PTY leader', async () => { - const fallback = vi.fn() - const signalProcessGroup = vi.fn() - - await forceKillPosixPtyProcessGroups(100, fallback, { - platform: 'darwin', - currentPid: 999, - readProcessTable: async () => TABLE, - signalProcessGroup - }) - - expect(signalProcessGroup.mock.calls.map(([pgid]) => pgid)).toEqual([101, 103, 100]) - expect(fallback).not.toHaveBeenCalled() - }) - it.each([ ['SIGSTOP', [99, 101, 103]], ['SIGCONT', [101, 103, 99]] @@ -420,42 +122,6 @@ describe('POSIX PTY process-group termination', () => { } ) - it('falls back when the process table cannot prove PTY ownership', async () => { - const fallback = vi.fn() - - await forceKillPosixPtyProcessGroups(100, fallback, { - platform: 'linux', - currentPid: 102, - readProcessTable: async () => TABLE, - signalProcessGroup: vi.fn() - }) - - expect(fallback).toHaveBeenCalledOnce() - }) - - it('ignores groups that exited after the snapshot but preserves real signal errors', async () => { - const gone = Object.assign(new Error('gone'), { code: 'ESRCH' }) - const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) - const signalProcessGroup = vi - .fn<(pgid: number) => void>() - .mockImplementationOnce(() => { - throw gone - }) - .mockImplementationOnce(() => { - throw denied - }) - - await expect( - forceKillPosixPtyProcessGroups(100, vi.fn(), { - platform: 'darwin', - currentPid: 999, - readProcessTable: async () => TABLE, - signalProcessGroup - }) - ).rejects.toThrow('denied') - expect(signalProcessGroup).toHaveBeenCalledTimes(3) - }) - it('signals nothing when the root exits while the table is read', async () => { // A reaped root frees its pid and TTY, so the table may now describe another terminal. let rootExited = false @@ -476,19 +142,6 @@ describe('POSIX PTY process-group termination', () => { expect(signalProcessGroup).not.toHaveBeenCalled() expect(fallback).not.toHaveBeenCalled() }) - - it('uses the existing fallback on Windows without reading ps', async () => { - const fallback = vi.fn() - const readProcessTable = vi.fn(async () => TABLE) - - await forceKillPosixPtyProcessGroups(100, fallback, { - platform: 'win32', - readProcessTable - }) - - expect(fallback).toHaveBeenCalledOnce() - expect(readProcessTable).not.toHaveBeenCalled() - }) }) describe('Linux terminal session identity (#27337)', () => { @@ -501,13 +154,6 @@ describe('Linux terminal session identity (#27337)', () => { 600 600 pts/6 Ss ` - it('parses the session id past a comm containing spaces and parentheses', () => { - expect(parseLinuxProcStatSessionId(procStat(42, 7, 'evil) S 1 2 3 (x'))).toBe(7) - expect(parseLinuxProcStatSessionId('42 (bash')).toBeNull() - expect(parseLinuxProcStatSessionId('42 (bash) S 1 42')).toBeNull() - expect(parseLinuxProcStatSessionId('42 (bash) S 1 42 0 0')).toBeNull() - }) - it('force-kills only groups in the shell session, not container processes on the same pts', async () => { procSessions.set(500, 500).set(501, 500).set(600, 600) const signalProcessGroup = vi.fn() @@ -522,85 +168,4 @@ describe('Linux terminal session identity (#27337)', () => { expect(fallback).not.toHaveBeenCalled() expect(readFileMock).toHaveBeenCalledWith('/proc/500/stat', 'utf8') }) - - it('applies the same session filter to the stop/continue sweep', async () => { - procSessions.set(500, 500).set(501, 500).set(600, 600) - const signalProcessGroup = vi.fn() - await signalPosixPtyProcessGroups(100, 'SIGSTOP', vi.fn(), { - platform: 'linux', - currentPid: 999, - readProcessTable: async () => COLLIDING_TABLE, - signalProcessGroup - }) - expect(signalProcessGroup.mock.calls).toEqual([[100], [101]]) - }) - - it('excludes candidates whose session can no longer be read', () => { - expect( - getPosixPtyProcessGroups(COLLIDING_TABLE, 100, 999, (pid) => - pid === 101 ? null : pid < 200 ? 100 : pid - ) - ).toEqual([100]) - }) - - it.each([ - ['unreadable', async () => null], - ['not a session leader', async () => 50] - ])('falls back to the root-only kill when the shell session is %s', async (_label, sessionOf) => { - const signalProcessGroup = vi.fn() - const fallback = vi.fn() - await forceKillPosixPtyProcessGroups(100, fallback, { - platform: 'linux', - currentPid: 999, - readProcessTable: async () => COLLIDING_TABLE, - readProcessSessionId: sessionOf, - signalProcessGroup - }) - expect(fallback).toHaveBeenCalledOnce() - expect(signalProcessGroup).not.toHaveBeenCalled() - }) - - it('keeps macOS selection by terminal alone without reading /proc', async () => { - const signalProcessGroup = vi.fn() - await forceKillPosixPtyProcessGroups(100, vi.fn(), { - platform: 'darwin', - currentPid: 999, - readProcessTable: async () => COLLIDING_TABLE, - signalProcessGroup - }) - expect(signalProcessGroup.mock.calls).toEqual([[101], [500], [600], [100]]) - expect(readFileMock).not.toHaveBeenCalled() - }) -}) - -describe('POSIX PTY group-sweep breadcrumbs', () => { - it('records every group it actually signalled', async () => { - await forceKillPosixPtyProcessGroups(100, vi.fn(), { - platform: 'darwin', - currentPid: 999, - readProcessTable: async () => TABLE, - signalProcessGroup: vi.fn() - }) - - expect(recordSelfInitiatedTreeKillMock.mock.calls.map(([kill]) => kill)).toEqual([ - { pid: 101, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' }, - { pid: 103, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' }, - { pid: 100, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' } - ]) - }) - - it('does not claim a group that was already gone', async () => { - await forceKillPosixPtyProcessGroups(100, vi.fn(), { - platform: 'darwin', - currentPid: 999, - readProcessTable: async () => TABLE, - signalProcessGroup: (pgid: number) => { - if (pgid === 103) { - throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) - } - } - }) - - expect(recordSelfInitiatedTreeKillMock.mock.calls.map(([kill]) => kill.pid)).toEqual([101, 100]) - }) }) diff --git a/src/main/pty/process-retirement/pty-descendant-termination.test.ts b/src/main/pty/process-retirement/pty-descendant-termination.test.ts index 4362eb111c8..4f49840a422 100644 --- a/src/main/pty/process-retirement/pty-descendant-termination.test.ts +++ b/src/main/pty/process-retirement/pty-descendant-termination.test.ts @@ -11,15 +11,9 @@ import { killWithDescendantSweep, terminateDescendantSnapshot } from './pty-descendant-termination' -import { - DESCENDANT_SNAPSHOT_TIMEOUT_MS, - type ProcessTableCapture -} from './pty-descendant-fresh-table' -import { parseProcessTable, type ProcessTableRow } from './pty-process-table-parser' -import { - terminateDescendantSnapshotAndWait, - terminateDescendantSnapshotWithVerdict -} from './pty-descendant-exit-verification' +import type { ProcessTableCapture } from './pty-descendant-fresh-table' +import type { ProcessTableRow } from './pty-process-table-parser' +import { terminateDescendantSnapshotWithVerdict } from './pty-descendant-exit-verification' import { excludeShellBackgroundJobs } from './pty-shell-job-preservation' const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026') @@ -67,40 +61,7 @@ function snapshot( } } -describe('parseProcessTable', () => { - it('parses pid/ppid/pgid and keeps the space-containing lstart verbatim', () => { - const rows = parseProcessTable( - [ - ' 101 1 101 -1 Mon Jul 13 12:54:47 2026', - '42017 101 42017 42017 Tue Jul 14 01:02:03 2026 ', - '', - 'not a process line' - ].join('\n') - ) - expect(rows).toEqual([ - { pid: 101, ppid: 1, pgid: 101, tpgid: -1, startedAt: 'Mon Jul 13 12:54:47 2026' }, - { pid: 42017, ppid: 101, pgid: 42017, tpgid: 42017, startedAt: 'Tue Jul 14 01:02:03 2026' } - ]) - }) -}) - describe('collectDescendantRows', () => { - it('walks detached-pgid descendants once even when a non-atomic table looks cyclic', () => { - // Detached process groups still share ancestry: 10 → 20 → 30 → 31; 99 is unrelated. - const table = [ - row(10, 1, 10), - row(20, 10, 20), - row(30, 20, 30), - row(31, 30, 30), - row(20, 31, 20), // PID reuse can make a non-atomic ps read look cyclic. - row(99, 1, 99) - ] - const snapshot = collectDescendantRows(10, table, CAPTURED_AT_MS) - expect(snapshot.rootPgid).toBe(10) - expect(snapshot.descendants.map((r) => r.pid)).toEqual([20, 30, 31]) - expect(snapshot.capturedAtMs).toBe(CAPTURED_AT_MS) - }) - it("excludes only the shell's own background jobs; agent-made process groups stay swept", () => { const tty = (pid: number, ppid: number, pgid: number, tpgid: number) => ({ ...row(pid, ppid, pgid), @@ -156,36 +117,6 @@ describe('captureDescendantSnapshot', () => { vi.useRealTimers() }) - it('captures no descendants and signals nothing when the root PID was already recycled', async () => { - const readTable = vi - .fn() - .mockResolvedValue(tableCapture([row(501, 500, 500), row(502, 500, 500), row(503, 500, 500)])) - const result = await captureDescendantSnapshot(500, { readTable, platform: 'darwin' }) - expect(result?.descendants).toEqual([]) - const sendSignal = vi.fn() - terminateDescendantSnapshot(result!, { sendSignal }) - expect(sendSignal).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - - it('degrades to null when a custom process-table reader throws synchronously', async () => { - const readTable = vi.fn(() => { - throw new Error('reader exploded') - }) - expect(await captureDescendantSnapshot(10, { readTable, platform: 'linux' })).toBeNull() - }) - - it('degrades to null when ps hangs past the timeout instead of blocking teardown', async () => { - const readTable = vi.fn().mockReturnValue(new Promise(() => {})) - const pending = captureDescendantSnapshot(10, { - readTable, - platform: 'darwin', - timeoutMs: 1_000 - }) - await vi.advanceTimersByTimeAsync(1_000) - expect(await pending).toBeNull() - }) - it('records the identity boundary before ps starts even when it crosses a second', async () => { vi.setSystemTime(CAPTURED_AT_MS + 900) execFileMock.mockImplementation((...args: unknown[]) => { @@ -240,18 +171,6 @@ describe('terminateDescendantSnapshot', () => { expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']]) }) - it('bounds a wedged escalation read and releases its deadline timer', async () => { - const sendSignal = vi.fn() - terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), { - sendSignal, - readTable: vi.fn().mockReturnValue(new Promise(() => {})) - }) - sendSignal.mockClear() - await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS + DESCENDANT_SNAPSHOT_TIMEOUT_MS) - expect(sendSignal).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - it("uses each row's capture boundary when escalating a merged snapshot", async () => { const oldBoundary = CAPTURED_AT_MS + 900 const refreshBoundary = CAPTURED_AT_MS + 2_100 @@ -285,77 +204,6 @@ describe('terminateDescendantSnapshotAndWait', () => { vi.useRealTimers() }) - it('escalates an identity-matched survivor and verifies its exit', async () => { - const survivor = row(20, 10, 20) - const sendSignal = vi.fn() - const readTable = vi - .fn() - .mockResolvedValueOnce(tableCapture([survivor])) - .mockResolvedValueOnce(tableCapture([])) - - const pending = terminateDescendantSnapshotAndWait(snapshot([survivor]), { - sendSignal, - readTable, - graceMs: 0, - verifyMs: 200 - }) - await vi.advanceTimersByTimeAsync(50) - - await expect(pending).resolves.toBe(true) - expect(sendSignal.mock.calls).toEqual([ - [20, 'SIGTERM'], - [20, 'SIGKILL'] - ]) - }) - - it('keeps polling past a read that missed its deadline rather than surrendering', async () => { - const survivor = row(20, 10, 20) - const readTable = vi - .fn() - .mockRejectedValueOnce(new Error('ps timed out')) - .mockResolvedValueOnce(tableCapture([survivor])) - .mockResolvedValueOnce(tableCapture([])) - const sendSignal = vi.fn() - - const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), { - sendSignal, - readTable, - graceMs: 0, - verifyMs: 2_000 - }) - await vi.advanceTimersByTimeAsync(500) - - await expect(pending).resolves.toBe('exited') - expect(sendSignal.mock.calls).toEqual([ - [20, 'SIGTERM'], - [20, 'SIGKILL'] - ]) - }) - - it('names a survivor seen at the deadline live, never unverifiable', async () => { - const survivor = row(20, 10, 20) - const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), { - sendSignal: vi.fn(), - readTable: vi.fn().mockResolvedValue(tableCapture([survivor])), - graceMs: 0, - verifyMs: 100 - }) - await vi.advanceTimersByTimeAsync(200) - - await expect(pending).resolves.toBe('live') - }) - - it('names an unreadable verification table unverifiable', async () => { - const pending = terminateDescendantSnapshotWithVerdict(snapshot([row(20, 10, 20)]), { - sendSignal: vi.fn(), - readTable: vi.fn().mockRejectedValue(new Error('ps exploded')), - verifyMs: 200 - }) - await vi.advanceTimersByTimeAsync(400) - - await expect(pending).resolves.toBe('unverifiable') - }) - it('never escalates a duplicate PID observation during shutdown verification', async () => { const survivor = row(20, 10, 20) const sendSignal = vi.fn() @@ -373,24 +221,6 @@ describe('terminateDescendantSnapshotAndWait', () => { expect(sendSignal).not.toHaveBeenCalled() }) - it('does not signal a recycled descendant when identity validation is required', async () => { - const sendSignal = vi.fn() - const recycled = row(20, 10, 20, 'Tue Jul 14 13:00:00 2026') - const pending = terminateDescendantSnapshotWithVerdict( - snapshot([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]), - { - sendSignal, - readTable: vi.fn().mockResolvedValue(tableCapture([recycled], CAPTURED_AT_MS + 1_000)), - requireIdentityBeforeSignal: true, - verifyMs: 100 - } - ) - - await vi.advanceTimersByTimeAsync(200) - await expect(pending).resolves.toBe('exited') - expect(sendSignal).not.toHaveBeenCalled() - }) - it('uses row-scoped boundaries for forced cleanup in the exit verifier', async () => { const oldBoundary = CAPTURED_AT_MS + 900 const refreshBoundary = CAPTURED_AT_MS + 2_100 @@ -424,19 +254,6 @@ describe('terminateDescendantSnapshotAndWait', () => { }) describe('createProcessTableSnapshotReader', () => { - it('coalesces same-turn teardown requests onto one fresh scan', async () => { - const capture = tableCapture([row(10, 1, 10)]) - const readFresh = vi.fn().mockResolvedValue(capture) - const readTable = createProcessTableSnapshotReader(readFresh) - - const results = await Promise.all(Array.from({ length: 20 }, () => readTable(1_000))) - expect(results.every((result) => result === capture)).toBe(true) - expect(readFresh).toHaveBeenCalledOnce() - - await readTable(1_000) - expect(readFresh).toHaveBeenCalledTimes(2) - }) - it('queues a post-request scan when another scan has already started', async () => { const firstGate = deferred() const secondGate = deferred() @@ -462,17 +279,6 @@ describe('createProcessTableSnapshotReader', () => { secondGate.resolve(newer) await expect(Promise.all([laterA, laterB])).resolves.toEqual([newer, newer]) }) - - it('retries after failed process-table reads', async () => { - const readFresh = vi - .fn() - .mockRejectedValueOnce(new Error('ps failed')) - .mockResolvedValueOnce(tableCapture([])) - const readTable = createProcessTableSnapshotReader(readFresh) - await expect(readTable()).rejects.toThrow('ps failed') - await expect(readTable()).resolves.toEqual(tableCapture([])) - expect(readFresh).toHaveBeenCalledTimes(2) - }) }) describe('killWithDescendantSweep', () => { @@ -506,39 +312,6 @@ describe('killWithDescendantSweep', () => { expect(events).toEqual(['descendants', 'root', 'finished']) }) - it('signals descendants after snapshot resolution, then kills the root', async () => { - const events: string[] = [] - const sendSignal = vi.fn(() => events.push('descendant-term')) - const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)])) - const killRoot = vi.fn(() => events.push('root-kill')) - const pending = killWithDescendantSweep(10, killRoot, { - readTable, - sendSignal, - platform: 'darwin' - }) - expect(killRoot).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(0) - await pending - expect(killRoot).toHaveBeenCalledOnce() - expect(sendSignal.mock.calls).toEqual([[20, 'SIGTERM']]) - expect(events).toEqual(['descendant-term', 'root-kill']) - }) - - it('still kills the root when the snapshot is unavailable', async () => { - const sendSignal = vi.fn() - const readTable = vi.fn().mockRejectedValue(new Error('ps exploded')) - const killRoot = vi.fn() - const pending = killWithDescendantSweep(10, killRoot, { - readTable, - sendSignal, - platform: 'darwin' - }) - await vi.advanceTimersByTimeAsync(0) - await pending - expect(killRoot).toHaveBeenCalledOnce() - expect(sendSignal).not.toHaveBeenCalled() - }) - it('on Windows terminates the owning job instead of probing and taskkilling', async () => { const events: string[] = [] const terminateOwnedTree = vi.fn(() => { @@ -563,84 +336,6 @@ describe('killWithDescendantSweep', () => { expect(events).toEqual(['job-kill', 'root-kill']) }) - it('on Windows falls back to the probe when the pty has no job', async () => { - // Older or unassigned PTYs lack jobs; unavailable cannot prove their processes exited. - const terminateOwnedTree = vi.fn(() => 'unavailable' as const) - const killWindowsTree = vi.fn(async () => {}) - const killRoot = vi.fn() - - await killWithDescendantSweep(4242, killRoot, { - platform: 'win32', - terminateOwnedTree, - killWindowsTree, - verifyTreeKillTarget: async () => 'own' - }) - - expect(killWindowsTree).toHaveBeenCalledWith(4242) - expect(killRoot).toHaveBeenCalledOnce() - }) - - it('on Windows taskkills the process tree before killRoot (#10004)', async () => { - const events: string[] = [] - const killWindowsTree = vi.fn(async () => { - events.push('tree-kill') - }) - const killRoot = vi.fn(() => events.push('root-kill')) - const sendSignal = vi.fn() - const readTable = vi.fn() - await killWithDescendantSweep(4242, killRoot, { - platform: 'win32', - killWindowsTree, - sendSignal, - readTable, - // Avoid querying the host’s real process table for this synthetic PID. - verifyTreeKillTarget: async () => 'own' - }) - expect(killWindowsTree).toHaveBeenCalledWith(4242) - expect(killRoot).toHaveBeenCalledOnce() - expect(sendSignal).not.toHaveBeenCalled() - expect(readTable).not.toHaveBeenCalled() - expect(events).toEqual(['tree-kill', 'root-kill']) - }) - - it('on Windows skips taskkill when the root pid was recycled by a stranger', async () => { - const killWindowsTree = vi.fn(async () => {}) - const killRoot = vi.fn() - await killWithDescendantSweep(4242, killRoot, { - platform: 'win32', - killWindowsTree, - verifyTreeKillTarget: async () => 'foreign' - }) - expect(killWindowsTree).not.toHaveBeenCalled() - expect(killRoot).toHaveBeenCalledOnce() - }) - - it('on Windows skips taskkill when identity is unknown', async () => { - const killWindowsTree = vi.fn(async () => {}) - const killRoot = vi.fn() - await killWithDescendantSweep(4242, killRoot, { - platform: 'win32', - killWindowsTree, - verifyTreeKillTarget: async () => 'unknown' - }) - expect(killWindowsTree).not.toHaveBeenCalled() - expect(killRoot).toHaveBeenCalledOnce() - }) - - it('on Windows skips taskkill when the identity probe throws', async () => { - const killWindowsTree = vi.fn(async () => {}) - const killRoot = vi.fn() - await killWithDescendantSweep(4242, killRoot, { - platform: 'win32', - killWindowsTree, - verifyTreeKillTarget: async () => { - throw new Error('probe exploded') - } - }) - expect(killWindowsTree).not.toHaveBeenCalled() - expect(killRoot).toHaveBeenCalledOnce() - }) - it('on Windows re-checks ownership lost while the identity probe ran', async () => { const killWindowsTree = vi.fn(async () => {}) const killRoot = vi.fn() @@ -657,50 +352,4 @@ describe('killWithDescendantSweep', () => { expect(killWindowsTree).not.toHaveBeenCalled() expect(killRoot).toHaveBeenCalledOnce() }) - - it('on Windows does not probe identity once ownership is already lost', async () => { - const verifyTreeKillTarget = vi.fn(async () => 'own' as const) - const killRoot = vi.fn() - await killWithDescendantSweep(4242, killRoot, { - platform: 'win32', - killWindowsTree: vi.fn(async () => {}), - ownsRoot: () => false, - verifyTreeKillTarget - }) - expect(verifyTreeKillTarget).not.toHaveBeenCalled() - expect(killRoot).toHaveBeenCalledOnce() - }) - - it('on Windows still kills the root when taskkill fails', async () => { - const killWindowsTree = vi.fn(async () => { - throw new Error('taskkill failed') - }) - const killRoot = vi.fn() - await expect( - killWithDescendantSweep(99, killRoot, { - platform: 'win32', - killWindowsTree, - verifyTreeKillTarget: async () => 'own' - }) - ).resolves.toBeUndefined() - expect(killRoot).toHaveBeenCalledOnce() - }) - - it('does not signal a captured tree after the caller loses root ownership', async () => { - const sendSignal = vi.fn() - const killRoot = vi.fn() - const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)])) - const ownsRoot = vi.fn(() => false) - - await killWithDescendantSweep(10, killRoot, { - readTable, - sendSignal, - platform: 'darwin', - ownsRoot - }) - - expect(ownsRoot).toHaveBeenCalledOnce() - expect(sendSignal).not.toHaveBeenCalled() - expect(killRoot).toHaveBeenCalledOnce() - }) }) diff --git a/src/main/pty/process-retirement/pty-master-foreground-observation.test.ts b/src/main/pty/process-retirement/pty-master-foreground-observation.test.ts index ade1560cb34..d9aa82a03ec 100644 --- a/src/main/pty/process-retirement/pty-master-foreground-observation.test.ts +++ b/src/main/pty/process-retirement/pty-master-foreground-observation.test.ts @@ -1,14 +1,6 @@ import type * as ForegroundModule from './terminal-process-rows' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - observePtyForeground, - readPtyForegroundGroup, - resolvePtyForegroundMembers -} from './pty-master-foreground-observation' -import { isTerminalForegroundObservation } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation' -import { judgeTerminalForeground } from '../../runtime/terminal-foreground-group' -import type { ProcessTableRow } from '../../../shared/process-table-snapshot' -import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' +import { observePtyForeground, readPtyForegroundGroup } from './pty-master-foreground-observation' const mocks = vi.hoisted(() => ({ rows: vi.fn(), tty: vi.fn(), ps: vi.fn() })) vi.mock('./terminal-process-rows', async (importOriginal) => ({ ...(await importOriginal()), @@ -37,54 +29,6 @@ beforeEach(() => { mocks.ps.mockResolvedValue('200 pts/5') }) describe('physical PTY observations', () => { - it('survives client rebind and a missing group leader/root row', async () => { - const owner = proc() - const first = await observePtyForeground(owner, () => true) - const afterRebind = await observePtyForeground(owner, () => true) - expect(first).toMatchObject({ - verdict: 'observed', - source: 'pty-master', - foregroundKind: 'non-shell', - processName: 'claude', - foregroundPgid: 200 - }) - expect(afterRebind).toEqual(first) - expect(mocks.tty).not.toHaveBeenCalled() - }) - it('uses all members rather than naming only a wrapper leader', async () => { - mocks.rows.mockResolvedValue([ - row('sh ./wrapper', 200), - { ...row('node /opt/node_modules/@openai/codex/bin/codex.js'), ppid: 200 } - ]) - expect(await observePtyForeground(proc(), () => true)).toMatchObject({ - verdict: 'observed', - foregroundKind: 'non-shell', - processName: 'codex' - }) - }) - it.each([ - ['node /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js --resume abc', 'claude'], - ['/home/user/.local/share/claude/versions/2.1.26', '2.1.26'], - ['python3 /opt/work/unknown_agent.py', 'python3'] - ])( - 'resolves real installation argv without rewriting the user command: %s', - (command, processName) => { - const sample = resolvePtyForegroundMembers([row(command)], 200) - expect(sample).toMatchObject({ - verdict: 'observed', - foregroundKind: 'non-shell', - processName - }) - } - ) - it('keeps unreadable non-shell identity separate from local non-shell admission', () => { - expect(resolvePtyForegroundMembers([row('(node)')], 200)).toMatchObject({ - verdict: 'observed', - foregroundKind: 'non-shell', - processName: null - }) - }) - it('never queries a retired/reused fd or rescues a supported failure with a cached name', async () => { const owner = proc() owner.fd = -1 @@ -109,21 +53,7 @@ describe('physical PTY observations', () => { reason: 'session_changed' }) }) - it('rereads at most once when the group changes and obeys its original deadline', async () => { - const owner = proc() - owner.getForegroundProcessGroup - .mockReturnValueOnce(200) - .mockReturnValueOnce(300) - .mockReturnValueOnce(200) - .mockReturnValueOnce(300) - expect(await observePtyForeground(owner, () => true)).toMatchObject({ verdict: 'unverifiable' }) - expect(mocks.rows).toHaveBeenCalledTimes(2) - mocks.rows.mockClear() - expect(await observePtyForeground(proc(), () => true, 0)).toMatchObject({ - verdict: 'unverifiable' - }) - expect(mocks.rows).not.toHaveBeenCalled() - }) + it('supports older native images with fresh PTY-matched root proof', async () => { const owner = proc() owner.getForegroundProcessGroup.mockReturnValue(undefined) @@ -136,166 +66,4 @@ describe('physical PTY observations', () => { mocks.tty.mockResolvedValue('pts/6') expect(await observePtyForeground(owner, () => true)).toMatchObject({ verdict: 'unverifiable' }) }) - it('validates optional host observations without treating a future/malformed branch as proof', () => { - const metadata = { - ptyId: 'p', - ptyIncarnationId: 'i', - authorityGeneration: 'a', - observationEpoch: 1, - capturedAgeMs: 0 - } - expect( - isTerminalForegroundObservation({ - ...metadata, - verdict: 'observed', - source: 'pty-master', - foregroundKind: 'non-shell', - processName: 'claude', - foregroundPgid: 200 - }) - ).toBe(true) - expect( - isTerminalForegroundObservation({ - ...metadata, - verdict: 'observed', - source: 'pty-master', - foregroundKind: 'agent', - processName: 'claude' - }) - ).toBe(false) - expect( - isTerminalForegroundObservation({ - ...metadata, - verdict: 'unverifiable', - reason: 'new_host_reason' - }) - ).toBe(true) - }) -}) - -/** `ps -t` rows on one terminal: the shell (root 100) and whatever holds foreground group `tpgid`. */ -function terminal( - tpgid: number, - members: [pid: number, ppid: number, pgid: number, stat: string, command: string][] -): ProcessTableRow[] { - return members.map(([pid, ppid, pgid, stat, command]) => ({ - pid, - ppid, - pgid, - tpgid, - stat, - command - })) -} -const CLAUDE_NODE = 'node /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js' -const CODEX_NODE = 'node /usr/local/lib/node_modules/@openai/codex/bin/codex.js' -const CODEX_NATIVE = - '/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex/codex' - -describe('local admission parity with the root-pid judge', () => { - it.each<[string, TuiAgent, ProcessTableRow[]]>([ - ['plain shell', 'claude', terminal(100, [[100, 1, 100, 'Ss+', '-zsh']])], - [ - 'nested shell', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', 'bash'] - ]) - ], - [ - 'native claude', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', 'claude --resume abc'] - ]) - ], - [ - 'node claude', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', CLAUDE_NODE] - ]) - ], - [ - 'codex node with its native child', - 'codex', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', CODEX_NODE], - [201, 200, 200, 'S+', CODEX_NATIVE] - ]) - ], - [ - 'non-exec wrapper script', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', 'sh /usr/local/bin/claude'], - [201, 200, 200, 'S+', CLAUDE_NODE] - ]) - ], - [ - 'interpreter running an agent script', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', 'bash /opt/bin/claude'] - ]) - ], - [ - 'agent stopped with Ctrl-Z', - 'claude', - terminal(100, [ - [100, 1, 100, 'Ss+', '-zsh'], - [200, 100, 200, 'T', 'claude'] - ]) - ], - [ - 'tmux client', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', 'tmux attach'] - ]) - ], - [ - 'sudo with withheld argv', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', '(sudo)'] - ]) - ], - [ - 'unreadable root shell', - 'claude', - terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, 'S+', '(bash)'] - ]) - ] - ])('%s', (_name, agent, rows) => { - const sample = resolvePtyForegroundMembers(rows, rows[0].tpgid ?? 0) - const local = - sample.verdict !== 'observed' - ? 'unknown' - : sample.foregroundKind === 'shell' - ? 'shell' - : 'agent' - expect(local).toBe(judgeTerminalForeground(rows, 100, agent)) - }) - - it('refuses a zombie or stopped-only foreground the root-pid judge admitted', () => { - for (const stat of ['Z+', 'T+']) { - const rows = terminal(200, [ - [100, 1, 100, 'Ss', '-zsh'], - [200, 100, 200, stat, 'claude'] - ]) - expect(judgeTerminalForeground(rows, 100, 'claude')).toBe('agent') - expect(resolvePtyForegroundMembers(rows, 200).verdict).toBe('unverifiable') - } - }) }) diff --git a/src/main/pty/shell-startup/codex-shell-launch-preflight.test.ts b/src/main/pty/shell-startup/codex-shell-launch-preflight.test.ts index b0d675807f7..3a70d313fe3 100644 --- a/src/main/pty/shell-startup/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/shell-startup/codex-shell-launch-preflight.test.ts @@ -13,17 +13,12 @@ import { tmpdir } from 'node:os' import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' -import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight' import { CODEX_COMMAND_IDENTITY_ENV_KEYS, getFishCodexShellLaunchPreflight, - getPosixCodexShellLaunchPreflight, - getPowerShellCodexShellLaunchPreflight + getPosixCodexShellLaunchPreflight } from '../../../shared/codex-shell-function' -import { - fishRequirementViolation, - resolveFishBinary -} from '../../../shared/fish-binary-requirement' +import { resolveFishBinary } from '../../../shared/fish-binary-requirement' import { getPowerShellAgentWrappers } from '../../../shared/powershell-agent-wrappers' const roots: string[] = [] @@ -199,13 +194,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () expect(runAliasLaunch(afterRoot, getPosixCodexShellLaunchPreflight())).toBe('normal') }) - it.skipIf(!existsSync('/bin/zsh'))('repairs a zsh cx alias before Codex starts', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-zsh-alias-')) - roots.push(root) - - expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/zsh')).toBe('normal') - }) - it.skipIf(!zshAvailable)('keeps a user alias named codex working in zsh', () => { expectNamedAliasSurvives('/bin/zsh', 'setopt aliases') }) @@ -214,39 +202,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () expectNamedAliasSurvives('/bin/bash', 'shopt -s expand_aliases') }) - it('still launches Codex when the best-effort preflight fails', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-failure-')) - roots.push(root) - - expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/bash', false)).toBe( - 'hooks-review' - ) - }) - - it('does not trigger a preflight outside an Orca terminal', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-')) - roots.push(root) - const bin = join(root, 'bin') - const marker = join(root, 'preflight-ran') - mkdirSync(bin) - writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n') - writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`) - - const output = execFileSync( - '/bin/bash', - ['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`], - { - encoding: 'utf-8', - env: { - PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` - } - } - ) - - expect(output.trim()).toBe('launched') - expect(existsSync(marker)).toBe(false) - }) - // skipIf exposes missing shells; an early return would report an unexercised test as passing. for (const [shell, strict] of [ ['/bin/bash', 'set -e'], @@ -276,29 +231,7 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () ) } - it.skipIf(!fishAvailable)('preserves a user-defined fish function', () => { - const output = execFileSync( - 'fish', - [ - '--no-config', - '-c', - [ - 'set -gx ORCA_CODEX_LAUNCH_PREFLIGHT missing-preflight', - 'function codex; echo custom-codex; end', - getFishCodexShellLaunchPreflight(), - 'codex' - ].join('\n') - ], - { encoding: 'utf-8' } - ) - - expect(output.trim()).toBe('custom-codex') - }) - // Fail explicitly when CI requires fish; skipped regression cases cannot establish coverage. - it('has fish when CI demanded it', () => { - expect(fishRequirementViolation(fishLookup)).toBeNull() - }) // #16893: absent Codex made unquoted (type -t codex) vanish and test complain; exercise real -l -C startup. it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => { @@ -314,174 +247,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () expect(result.status).toBe(0) expect(result.stdout.trim()).toBe('wrapper=NO var=[]') }) - - it.skipIf(!fishAvailable)('wraps codex and runs the preflight when codex is a real file', () => { - const { bin, preflight, marker } = createFishSandbox('orca-codex-fish-present-') - writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex $*"\n') - - const output = execFileSync( - join(bin, 'fish'), - ['--no-config', '-l', '-C', getFishCodexShellLaunchPreflight(), '-c', 'codex hi'], - { encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } } - ) - - expect(output.trim()).toBe('real codex hi') - expect(existsSync(marker)).toBe(true) - }) - - it.skipIf(!fishAvailable)('leaves a codex alias unwrapped', () => { - const { bin, preflight } = createFishSandbox('orca-codex-fish-alias-') - writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex"\n') - - const result = spawnSync( - join(bin, 'fish'), - [ - '--no-config', - '-l', - '-C', - `alias codex='echo aliased'\n${getFishCodexShellLaunchPreflight()}`, - '-c', - FISH_STATE_PROBE - ], - { encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } } - ) - - expect(result.stderr).not.toContain('Missing argument') - expect(result.stdout.trim()).toBe('wrapper=NO var=[]') - }) -}) - -describe('PowerShell Codex shell launch preflight', () => { - it('preserves a user-defined command', () => { - expect(getPowerShellCodexShellLaunchPreflight()).toContain( - '$orcaCodexCommand.CommandType -in @("Application", "ExternalScript")' - ) - }) - - it.skipIf(!pwshAvailable)('fails open when native errors are promoted', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-pwsh-failure-')) - const bin = join(root, 'bin') - roots.push(root) - mkdirSync(bin) - const executableSuffix = process.platform === 'win32' ? '.cmd' : '' - writeExecutable( - join(bin, `orca-test${executableSuffix}`), - process.platform === 'win32' ? '@exit /b 7\r\n' : '#!/bin/sh\nexit 7\n' - ) - writeExecutable( - join(bin, `codex${executableSuffix}`), - process.platform === 'win32' ? '@echo launched\r\n' : '#!/bin/sh\nprintf "launched\\n"\n' - ) - - const result = spawnSync( - 'pwsh', - [ - '-NoLogo', - '-NoProfile', - '-Command', - [ - '$ErrorActionPreference = "Stop"', - '$PSNativeCommandUseErrorActionPreference = $true', - getPowerShellAgentWrappers(), - 'codex' - ].join('\n') - ], - { - encoding: 'utf-8', - env: { - ...process.env, - PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, - ORCA_CODEX_LAUNCH_PREFLIGHT: join(bin, `orca-test${executableSuffix}`) - } - } - ) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout.trim()).toBe('launched') - }) -}) - -describe('Codex shell launch preflight command', () => { - function makeCliRoot(): { resourcesPath: string } { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-')) - roots.push(root) - const resourcesPath = join(root, 'resources') - mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) - return { resourcesPath } - } - - function wslOptions(resourcesPath: string | null) { - return { - hooksEnabled: true, - isPackaged: true, - isWsl: true, - managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', - resourcesPath, - platform: 'win32' as const - } - } - - it.each([ - { platform: 'darwin' as const, bundled: 'orca', isPackaged: true }, - { platform: 'linux' as const, bundled: 'orca-ide', isPackaged: true }, - { platform: 'win32' as const, bundled: 'orca.exe', isPackaged: true }, - { platform: 'darwin' as const, bundled: 'orca', isPackaged: false } - ])( - 'gives a native $platform pane no preflight: the app prepares its Codex home (packaged $isPackaged)', - (config) => { - const { resourcesPath } = makeCliRoot() - writeExecutable(join(resourcesPath, 'bin', config.bundled), '#!/bin/sh\nexit 0\n') - - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: config.isPackaged, - managedHomePath: '/managed/home', - resourcesPath, - platform: config.platform - }) - ).toBeNull() - } - ) - - it('carries the packaged Windows launcher, as an absolute path, for WSLENV path translation', () => { - const { resourcesPath } = makeCliRoot() - const launcherPath = join(resourcesPath, 'bin', 'orca.exe') - writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') - - const command = resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath)) - - expect(command).toBe(launcherPath) - expect(isAbsolute(command as string)).toBe(true) - }) - - it.each([ - { label: 'the launcher file is missing', create: null }, - { label: 'the launcher path is a directory', create: 'directory' as const } - ])('skips the preflight when $label', (config) => { - const { resourcesPath } = makeCliRoot() - if (config.create === 'directory') { - mkdirSync(join(resourcesPath, 'bin', 'orca.exe')) - } - - expect(resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath))).toBeNull() - }) - - it('skips the preflight when the packaged build exposes no resources root', () => { - expect(resolveCodexShellLaunchPreflightCommand(wslOptions(null))).toBeNull() - }) - - it.each([{ hooksEnabled: false }, { isPackaged: false }, { managedHomePath: null }])( - 'does not enable an unsupported WSL preflight for %o', - (override) => { - const { resourcesPath } = makeCliRoot() - writeExecutable(join(resourcesPath, 'bin', 'orca.exe'), '#!/bin/sh\nexit 0\n') - - expect( - resolveCodexShellLaunchPreflightCommand({ ...wslOptions(resourcesPath), ...override }) - ).toBeNull() - } - ) }) // Verified launcher paths may contain spaces in app bundles or Program Files. diff --git a/src/main/pty/shell-startup/codex-shell-no-daemon.test.ts b/src/main/pty/shell-startup/codex-shell-no-daemon.test.ts index 78a70bbd3e7..1b7f2dc89c7 100644 --- a/src/main/pty/shell-startup/codex-shell-no-daemon.test.ts +++ b/src/main/pty/shell-startup/codex-shell-no-daemon.test.ts @@ -293,24 +293,6 @@ describe('codex wrapper --no-daemon rule', () => { expect(lines(result.stdout)).toEqual(ADDED.map((argv) => expectedLine(argv, false))) }) - it('adds nothing with ORCA_CODEX_ISOLATE=0, read on every call', () => { - const sandbox = makeSandbox(HELP_WITH_FLAG) - const setIsolate = (value: string): string => - isPowerShell(shell) - ? `$env:ORCA_CODEX_ISOLATE = '${value}'` - : shell === 'fish' - ? `set -gx ORCA_CODEX_ISOLATE ${value}` - : `export ORCA_CODEX_ISOLATE=${value}` - const result = run( - shell, - ['codex a', setIsolate('1'), 'codex b', setIsolate('0'), 'codex c'].join('\n'), - sandbox, - { ORCA_CODEX_ISOLATE: '0' } - ) - - expect(lines(result.stdout)).toEqual(['ARGV|a', 'ARGV|--no-daemon|b', 'ARGV|c']) - }) - it('re-probes --help when Codex changes version mid-shell', () => { const sandbox = makeSandbox(HELP_WITH_FLAG) const swap = (help: string): string => @@ -395,15 +377,6 @@ describe('codex wrapper --no-daemon rule', () => { } ) - it.skipIf(!available)(`${shell} forwards identity to Codex 0.155 and older too`, () => { - const sandbox = makeSandbox(HELP_WITHOUT_FLAG) - const result = run(shell, codexCall(shell, ['x']), sandbox, identity) - - expect(lines(result.stdout).map(splitOverrides)).toEqual([ - { overrides: identityOverrides, rest: ['x'] } - ]) - }) - it.skipIf(!available)(`${shell} skips a value holding an apostrophe`, () => { const sandbox = makeSandbox(HELP_WITHOUT_FLAG) const result = run(shell, codexCall(shell, ['x']), sandbox, { @@ -415,52 +388,9 @@ describe('codex wrapper --no-daemon rule', () => { { overrides: [identityOverrides[0]], rest: ['x'] } ]) }) - - it.skipIf(!available)(`${shell} forwards nothing with ORCA_CODEX_ISOLATE=0`, () => { - const sandbox = makeSandbox(HELP_WITH_FLAG) - const result = run(shell, codexCall(shell, ['x']), sandbox, { - ...identity, - ORCA_CODEX_ISOLATE: '0' - }) - - expect(lines(result.stdout)).toEqual(['ARGV|x']) - }) } }) - for (const [shell, enableAliases] of [ - ['bash', 'shopt -s expand_aliases'], - ['zsh', 'setopt aliases'] - ] as const) { - it.skipIf(isWindows || !existsSync(`/bin/${shell}`))( - `applies a user alias named codex defined before the wrapper in ${shell}`, - () => { - const sandbox = makeSandbox(HELP_WITH_FLAG) - const result = run( - shell, - 'codex x', - sandbox, - {}, - `${enableAliases}\nalias codex='codex --alias-flag'` - ) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout.trim()).toBe('ARGV|--no-daemon|--alias-flag|x') - } - ) - } - - it.skipIf(isWindows || !existsSync('/bin/zsh'))( - 'creates no globals under warn_create_global', - () => { - const sandbox = makeSandbox(HELP_WITH_FLAG) - const result = run('zsh', 'setopt warn_create_global no_unset\ncodex x', sandbox) - - expect(result.stderr).toBe('') - expect(result.stdout.trim()).toBe('ARGV|--no-daemon|x') - } - ) - for (const [shell, available] of shells.filter(([name]) => isPowerShell(name))) { it.skipIf(!available)( `${shell} runs under StrictMode and Stop with a failing, noisy hook prep`, diff --git a/src/main/pty/shell-startup/git-bash.test.ts b/src/main/pty/shell-startup/git-bash.test.ts deleted file mode 100644 index 550036134c2..00000000000 --- a/src/main/pty/shell-startup/git-bash.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - getGitBashCandidatePaths, - isGitForWindowsBashLauncherPath, - isWindowsGitBashShellPath, - resolveGitBashPath, - resolveWindowsGitBashShellPath -} from './git-bash' - -describe('Git Bash path discovery', () => { - it('includes common Git for Windows install locations and PATH-derived fallbacks', () => { - const candidates = getGitBashCandidatePaths({ - ProgramFiles: 'C:\\Program Files', - LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local', - Path: '"C:\\Program Files\\Git\\cmd";C:\\tools;C:\\PortableGit\\bin' - }) - - expect(candidates).toContain('C:\\Program Files\\Git\\bin\\bash.exe') - expect(candidates).toContain('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe') - expect(candidates).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe') - expect(candidates).toContain('C:\\PortableGit\\bin\\bash.exe') - expect(candidates).not.toContain('C:\\tools\\bash.exe') - }) - - it('resolves the first existing common bash.exe path on Windows', () => { - const exists = vi.fn((path: string) => path === 'C:\\Program Files\\Git\\bin\\bash.exe') - - expect( - resolveGitBashPath({ - platform: 'win32', - env: { ProgramFiles: 'C:\\Program Files' }, - exists - }) - ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') - }) - - it('does not expose Git Bash discovery on non-Windows hosts', () => { - expect( - resolveGitBashPath({ - platform: 'darwin', - env: { ProgramFiles: 'C:\\Program Files' }, - exists: () => true - }) - ).toBeNull() - }) - - it('maps the persisted Git Bash sentinel to a discovered bash.exe path', () => { - expect( - resolveWindowsGitBashShellPath('git-bash', { - platform: 'win32', - env: { LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local' }, - exists: (path) => path === 'C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe' - }) - ).toBe('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe') - }) - - // Inject platform options so these assertions also exercise Windows behavior on other hosts. - it('honors an explicit bash.exe path for future user-configurable launch paths', () => { - expect( - resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', { - platform: 'win32', - env: {}, - exists: (path) => path === 'D:\\PortableGit\\bin\\bash.exe' - }) - ).toBe('D:\\PortableGit\\bin\\bash.exe') - }) - - it('rejects an explicit Git Bash path that is no longer installed', () => { - expect( - resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', { - platform: 'win32', - env: {}, - exists: () => false - }) - ).toBeNull() - }) - - it('resolves a bare bash entry through Git Bash discovery', () => { - expect( - resolveWindowsGitBashShellPath('bash', { - platform: 'win32', - env: { ProgramFiles: 'C:\\Program Files' }, - exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe' - }) - ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') - }) - - it('returns null for a bare bash entry when Git Bash is not installed', () => { - expect( - resolveWindowsGitBashShellPath('bash', { - platform: 'win32', - env: { ProgramFiles: 'C:\\Program Files' }, - exists: () => false - }) - ).toBeNull() - }) - - it('resolves an extension-less Git Bash path to the bash.exe it names', () => { - expect( - resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', { - platform: 'win32', - env: {}, - exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe' - }) - ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') - }) - - it('returns null for an extension-less Git Bash path with no bash.exe beside it', () => { - expect( - resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', { - platform: 'win32', - env: {}, - exists: () => false - }) - ).toBeNull() - }) - - it('does not treat an extension-less non-Git bash path as Git Bash', () => { - expect( - resolveWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash', { - platform: 'win32', - env: {}, - exists: () => true - }) - ).toBeNull() - }) - - it('does not probe a bash-prefixed path that is not bash itself', () => { - expect( - resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash.old', { - platform: 'win32', - env: {}, - exists: () => true - }) - ).toBeNull() - }) - - it('recognizes Git Bash executable paths case-insensitively', () => { - expect(isWindowsGitBashShellPath('D:\\PortableGit\\BIN\\BASH.EXE')).toBe(true) - }) - - it('does not classify arbitrary bash.exe paths as Git Bash', () => { - expect( - resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe', { - platform: 'win32', - env: {}, - exists: () => true - }) - ).toBeNull() - expect(isWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash.exe')).toBe(false) - }) - - it('ignores non-Git bash.exe candidates discovered through PATH', () => { - expect( - resolveGitBashPath({ - platform: 'win32', - env: { Path: 'C:\\msys64\\usr\\bin' }, - exists: (path) => path === 'C:\\msys64\\usr\\bin\\bash.exe' - }) - ).toBeNull() - }) - - it('identifies only the bin\\bash.exe launcher, not the MSYS bash it runs', () => { - expect(isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\bin\\bash.exe')).toBe(true) - expect(isGitForWindowsBashLauncherPath('D:\\PortableGit\\bin\\bash.exe')).toBe(true) - expect(isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\usr\\bin\\bash.exe')).toBe( - false - ) - expect(isGitForWindowsBashLauncherPath('bash.exe')).toBe(false) - }) - - it('identifies a launcher in an install folder named anything else', () => { - const renamedInstall = (path: string): boolean => - [ - 'C:\\Tools\\Git-2.55\\bin\\bash.exe', - 'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe', - 'C:\\Tools\\Git-2.55\\cmd\\git.exe', - 'C:\\Tools\\Git-2.55\\git-bash.exe' - ].includes(path) - expect( - isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', { - exists: renamedInstall - }) - ).toBe(true) - expect( - isGitForWindowsBashLauncherPath('C:\\Users\\a\\scoop\\apps\\git\\current\\bin\\bash.exe', { - exists: (path) => path.startsWith('C:\\Users\\a\\scoop\\apps\\git\\current\\') - }) - ).toBe(true) - }) - - it.each([ - ['usr\\bin\\bash.exe', 'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe'], - ['cmd\\git.exe', 'C:\\Tools\\Git-2.55\\cmd\\git.exe'], - ['git-bash.exe', 'C:\\Tools\\Git-2.55\\git-bash.exe'] - ])('refuses a renamed install root missing only %s', (_label, missingMarker) => { - expect( - isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', { - exists: (path) => path !== missingMarker - }) - ).toBe(false) - }) - - it('refuses a bash that is not a Git for Windows launcher, even where every path exists', () => { - const everythingExists = (): boolean => true - // Direct usr/bin/bash.exe has no nested Git install root. - expect( - isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\usr\\bin\\bash.exe', { - exists: (path) => !path.includes('\\usr\\usr\\') && !path.includes('\\usr\\cmd\\') - }) - ).toBe(false) - const observedCygwinRoot = ['C:\\cygwin64\\etc', 'C:\\cygwin64\\var'] - expect( - isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', { - exists: (path) => observedCygwinRoot.includes(path) - }) - ).toBe(false) - // Even Git’s cmd/git.exe and usr/bin/bash.exe paths do not replace the git-bash.exe marker. - expect( - isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', { - exists: (path) => path.endsWith('bin\\bash.exe') || path.endsWith('cmd\\git.exe') - }) - ).toBe(false) - expect( - isGitForWindowsBashLauncherPath('C:\\Tools\\sh\\bash.exe', { exists: everythingExists }) - ).toBe(false) - expect(isGitForWindowsBashLauncherPath('bash.exe', { exists: everythingExists })).toBe(false) - }) -}) diff --git a/src/main/pty/shell-startup/legacy-terminal-shim-dir.test.ts b/src/main/pty/shell-startup/legacy-terminal-shim-dir.test.ts index d73c75e8da5..03dcf4edd58 100644 --- a/src/main/pty/shell-startup/legacy-terminal-shim-dir.test.ts +++ b/src/main/pty/shell-startup/legacy-terminal-shim-dir.test.ts @@ -1,40 +1,15 @@ import { spawn, spawnSync } from 'node:child_process' -import { - chmodSync, - symlinkSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - writeFileSync -} from 'node:fs' +import { symlinkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as PosixTombstoneModule from './legacy-terminal-posix-tombstone' -import { - readVerifiedShebangInterpreter, - resolvePosixTombstoneInterpreter -} from './legacy-terminal-posix-tombstone' import { __resetLegacyTerminalShimNeutralizationForTests, - isLegacyTerminalShimPathEntry, - neutralizeLegacyTerminalShimDir, - stripLegacyTerminalShimEnv + neutralizeLegacyTerminalShimDir } from './legacy-terminal-shim-dir' const itOnPosix = process.platform === 'win32' ? it.skip : it -// Windows ignores these permissions and root bypasses them. -const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it - -// indexOf returns -1 for missing text; assert presence before ordering. -function expectOrdered(text: string, first: string, second: string): void { - expect(text, `missing: ${first}`).toContain(first) - expect(text, `missing: ${second}`).toContain(second) - expect(text.indexOf(first)).toBeLessThan(text.indexOf(second)) -} +void (process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it) describe('legacy terminal shim neutralization', () => { const tempRoots: string[] = [] @@ -56,73 +31,6 @@ describe('legacy terminal shim neutralization', () => { } }) - it('atomically replaces the legacy command paths with executable tombstones', () => { - const userData = makeUserDataDir() - const legacyRoot = join(userData, 'orca-terminal-attribution') - 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) - } - } - // Matching retired version 7 would make rollback mistake tombstones for active wrappers. - const version = readFileSync(join(legacyRoot, 'VERSION'), 'utf8') - expect(version).toBe('7-neutralized\n') - expect(version.trim()).not.toBe('7') - }) - - itOnPosix('rejects interpreter candidates that are unusable in a shebang', () => { - const userData = makeUserDataDir() - const spaced = join(userData, 'a b') - const dirNamedBash = join(userData, 'dirbin') - mkdirSync(spaced, { recursive: true }) - mkdirSync(join(dirNamedBash, 'bash'), { recursive: true }) - writeFileSync(join(spaced, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) - - expect(resolvePosixTombstoneInterpreter(spaced, [], 'linux')).toBeNull() - expect(resolvePosixTombstoneInterpreter(dirNamedBash, [], 'linux')).toBeNull() - }) - - itOnPosix('resolves the interpreter from absolute PATH entries only', () => { - // Force the NixOS/Guix fallback while excluding cwd-based interpreter lookup. - const userData = makeUserDataDir() - const absDir = join(userData, 'absbin') - const relDir = join(userData, 'relbin') - mkdirSync(absDir, { recursive: true }) - mkdirSync(relDir, { recursive: true }) - for (const dir of [absDir, relDir]) { - writeFileSync(join(dir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) - } - - expect(resolvePosixTombstoneInterpreter(`${absDir}:/usr/bin`, [])).toBe(join(absDir, 'bash')) - expect(resolvePosixTombstoneInterpreter(`:relbin:${absDir}`, [])).toBe(join(absDir, 'bash')) - // Relative PATH resolves from process cwd; a tmpdir-only fixture would pass without the guard. - const cwdRelName = `.orca-interp-${process.pid}` - const cwdRelDir = join(process.cwd(), cwdRelName) - mkdirSync(cwdRelDir, { recursive: true }) - try { - writeFileSync(join(cwdRelDir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) - expect(resolvePosixTombstoneInterpreter(`.:${cwdRelName}`, [])).toBeNull() - } finally { - rmSync(cwdRelDir, { recursive: true, force: true }) - } - }) - itOnPosix('resolves its own directory even when CDPATH is set', () => { // CDPATH can redirect relative cd and echo into wrapper_dir, breaking lookup. const userData = makeUserDataDir() @@ -184,36 +92,6 @@ describe('legacy terminal shim neutralization', () => { expect(wrapper).toContain('CDPATH= cd -P --') }) - itOnPosix('rejects a distinct legacy shim directory named by the environment', () => { - // An inherited marker can name an older install, distinct from the current wrapper directory. - const userData = makeUserDataDir() - const posixDir = join(userData, 'orca-terminal-attribution', 'posix') - const legacyDir = join(userData, 'legacy-shim') - const realBin = join(userData, 'real-bin') - for (const dir of [posixDir, legacyDir, realBin]) { - mkdirSync(dir, { recursive: true }) - } - writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper') - - neutralizeLegacyTerminalShimDir(userData) - - writeFileSync(join(legacyDir, 'git'), "#!/bin/bash\nprintf 'HOSTILE\\n'\n", { mode: 0o755 }) - writeFileSync(join(realBin, 'git'), "#!/bin/bash\nprintf 'REAL\\n'\n", { mode: 0o755 }) - - const run = spawnSync(join(posixDir, 'git'), ['--version'], { - env: { - ...process.env, - ORCA_ATTRIBUTION_SHIM_DIR: legacyDir, - PATH: `${legacyDir}:${realBin}:/usr/bin:/bin` - }, - encoding: 'utf8', - timeout: 20_000 - }) - - expect(run.stdout).toContain('REAL') - expect(run.stdout).not.toContain('HOSTILE') - }) - itOnPosix('rejects the legacy shim directory reached by a different spelling', () => { // Equivalent inode spellings must exclude the still-live legacy wrapper too. const userData = makeUserDataDir() @@ -247,144 +125,6 @@ describe('legacy terminal shim neutralization', () => { } }) - itOnPosix('reuses the replaced wrapper shebang when no absolute bash is verifiable', () => { - // Deleting a hashed wrapper strands existing shells at 127; preserve its verified interpreter. - const userData = makeUserDataDir() - const wrapper = join(userData, 'git') - writeFileSync(wrapper, '#!/bin/bash\nexit 0\n', { mode: 0o755 }) - expect(readVerifiedShebangInterpreter(wrapper)).toBe('/bin/bash') - - // env bash defers interpreter lookup to PATH and reopens cwd hijacking. - writeFileSync(wrapper, '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }) - expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() - - // /bin/sh reliably exists but cannot run this Bash-specific body. - writeFileSync(wrapper, '#!/bin/sh\nexit 0\n', { mode: 0o755 }) - expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() - - writeFileSync(wrapper, '#!bash\nexit 0\n', { mode: 0o755 }) - expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() - - writeFileSync(wrapper, '#!/nonexistent/bash\nexit 0\n', { mode: 0o755 }) - expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() - - writeFileSync(wrapper, 'no shebang at all\n', { mode: 0o755 }) - expect(readVerifiedShebangInterpreter(wrapper)).toBeNull() - - expect(readVerifiedShebangInterpreter(join(userData, 'absent'))).toBeNull() - }) - - itOnPosix('keeps the POSIX wrapper on a host where no absolute bash resolves', async () => { - // Force resolver failure: ordinary CI bash paths otherwise hide missing fallback wiring. - vi.resetModules() - vi.doMock('./legacy-terminal-posix-tombstone', async () => { - const actual = await vi.importActual( - './legacy-terminal-posix-tombstone' - ) - return { ...actual, resolvePosixTombstoneInterpreter: () => null } - }) - try { - const shimDir = await import('./legacy-terminal-shim-dir') - shimDir.__resetLegacyTerminalShimNeutralizationForTests() - const userData = makeUserDataDir() - const posixDir = join(userData, 'orca-terminal-attribution', 'posix') - mkdirSync(posixDir, { recursive: true }) - writeFileSync(join(posixDir, 'git'), '#!/bin/bash\nlegacy attribution wrapper\n', { - mode: 0o755 - }) - // An unverifiable env shebang must be deleted rather than preserve retired attribution. - writeFileSync(join(posixDir, 'gh'), '#!/usr/bin/env bash\nlegacy\n', { mode: 0o755 }) - - shimDir.neutralizeLegacyTerminalShimDir(userData) - - const git = readFileSync(join(posixDir, 'git'), 'utf8') - expect(git.split('\n')[0]).toBe('#!/bin/bash') - expect(git).toContain('Orca compatibility wrapper could not locate') - expect(existsSync(join(posixDir, 'gh'))).toBe(false) - } finally { - vi.doUnmock('./legacy-terminal-posix-tombstone') - vi.resetModules() - } - }) - - it('does not collapse .. when classifying a shim PATH entry', () => { - // Collapsing .. through a symlink can erase a legitimate PATH entry; remote/WSL paths cannot be resolved locally. - expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix/../posix')).toBe( - false - ) - expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix')).toBe(true) - expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/win32//')).toBe(true) - expect(isLegacyTerminalShimPathEntry('/usr/local/bin')).toBe(false) - }) - - it('strips a captured shim directory spelled with repeated separators', () => { - // Repeated separators must not defeat captured-directory removal. - const posixEnv = { - PATH: '/custom/elsewhere///:/usr/bin', - ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere' - } - stripLegacyTerminalShimEnv(posixEnv, 'linux') - expect(posixEnv.PATH).toBe('/usr/bin') - - const windowsEnv = { - Path: 'C:\\Custom\\Else\\\\;C:\\Windows', - ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else' - } - stripLegacyTerminalShimEnv(windowsEnv, 'win32') - expect(windowsEnv.Path).toBe('C:\\Windows') - }) - - itOnPosix( - 'ignores a relative legacy shim directory instead of resolving it against the cwd', - () => { - // Relative -ef operands depend on cwd and could select different real git binaries. - const userData = makeUserDataDir() - const posixDir = join(userData, 'orca-terminal-attribution', 'posix') - const safeBin = join(userData, 'safe-bin') - const laterBin = join(userData, 'later-bin') - for (const dir of [posixDir, safeBin, laterBin]) { - mkdirSync(dir, { recursive: true }) - } - writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper') - - neutralizeLegacyTerminalShimDir(userData) - - writeFileSync(join(safeBin, 'git'), "#!/bin/bash\nprintf 'SAFE\\n'\n", { mode: 0o755 }) - writeFileSync(join(laterBin, 'git'), "#!/bin/bash\nprintf 'LATER\\n'\n", { mode: 0o755 }) - - const outputs = [userData, join(userData, 'orca-terminal-attribution')].map((cwd) => { - const run = spawnSync(join(posixDir, 'git'), ['--version'], { - cwd, - env: { - ...process.env, - ORCA_ATTRIBUTION_SHIM_DIR: 'safe-bin', - PATH: `${safeBin}:${laterBin}:/usr/bin:/bin` - }, - encoding: 'utf8', - timeout: 20_000 - }) - return run.stdout.trim() - }) - - expect(outputs[0]).toBe(outputs[1]) - expect(outputs[0]).toBe('SAFE') - } - ) - - it('keeps a POSIX directory whose name ends in a backslash', () => { - // POSIX backslashes belong to filenames; stripping them can delete a real PATH entry. - const env = { - PATH: '/tmp/captured\\', - ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/captured' - } - stripLegacyTerminalShimEnv(env, 'linux') - expect(env.PATH).toBe('/tmp/captured\\') - - const windowsEnv = { Path: 'C:\\captured\\', ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\captured' } - stripLegacyTerminalShimEnv(windowsEnv, 'win32') - expect(windowsEnv.Path).toBeUndefined() - }) - itOnPosix('does not fall back to the cwd when every PATH entry is filtered out', () => { // An emptied PATH must not fall through to cwd-local git. const userData = makeUserDataDir() @@ -409,32 +149,6 @@ describe('legacy terminal shim neutralization', () => { expect(run.status).toBe(127) }) - itOnPosix('hands the child the cleaned PATH, not the inherited one', () => { - // Export the cleaned PATH so git hooks and credential helpers cannot rediscover legacy or cwd entries. - const userData = makeUserDataDir() - const posixDir = join(userData, 'orca-terminal-attribution', 'posix') - const realBin = join(userData, 'real-bin') - mkdirSync(posixDir, { recursive: true }) - mkdirSync(realBin, { recursive: true }) - writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper') - - neutralizeLegacyTerminalShimDir(userData) - - writeFileSync(join(realBin, 'git'), '#!/bin/bash\nprintf \'PATH=%s\\n\' "$PATH"\n', { - mode: 0o755 - }) - - const run = spawnSync(join(posixDir, 'git'), ['--version'], { - env: { PATH: `${posixDir}:.:${realBin}:/usr/bin:/bin` }, - encoding: 'utf8', - timeout: 20_000 - }) - - expect(run.stdout).toContain('PATH=') - expect(run.stdout).not.toContain('orca-terminal-attribution') - expect(run.stdout.split('PATH=')[1]?.split(':')).not.toContain('.') - }) - itOnPosix('does not let the cwd supply the script interpreter', async () => { // Interpreter lookup precedes script PATH hygiene; env bash permits checkout-local interception. const userData = makeUserDataDir() @@ -528,109 +242,6 @@ describe('legacy terminal shim neutralization', () => { expect(noSlash.stdout).toContain('REAL') }) - it('removes every Windows PATH occurrence of both captured wrapper directories', () => { - const userData = makeUserDataDir() - const win32Dir = join(userData, 'orca-terminal-attribution', 'win32') - mkdirSync(win32Dir, { recursive: true }) - - neutralizeLegacyTerminalShimDir(userData) - - const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8') - const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"' - expectOrdered(cmd, cmdCapture, 'set "ORCA_ATTRIBUTION_SHIM_DIR="') - expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do (') - expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b') - expect(cmd).toContain('if defined orca_legacy_wrapper_dir call :orca_reject_legacy_dir') - // cmd cannot use call :label && ...; preserve the explicit flag. - expect(cmd).toContain('if defined orca_skip_entry exit /b') - expect(cmd).not.toContain('call :orca_reject_legacy_dir &&') - expect(cmd).toContain('if "%orca_path_entry_dir:~-1%."=="%orca_sep%."') - - const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8') - expectOrdered( - powershell, - '$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR', - '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('leaves an install that never ran the shim untouched', () => { - // Fresh installs need no hashed-path compatibility wrappers. - const userData = makeUserDataDir() - - expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow() - expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false) - }) - - 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 { - neutralizeLegacyTerminalShimDir(userData) - expect(readFileSync(gitWrapper, 'utf8')).toBe('legacy attribution wrapper') - } finally { - chmodSync(posixDir, 0o700) - } - - try { - await vi.advanceTimersByTimeAsync(1_000) - expect(readFileSync(gitWrapper, 'utf8')).not.toContain('legacy attribution wrapper') - - writeFileSync(gitWrapper, 'recreated after success') - neutralizeLegacyTerminalShimDir(userData) - expect(readFileSync(gitWrapper, 'utf8')).toBe('recreated after success') - } finally { - vi.useRealTimers() - } - }) - - itOnPosixNonRoot('warns and stops retrying once the ladder is exhausted', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const userData = makeUserDataDir() - const posixDir = join(userData, 'orca-terminal-attribution', 'posix') - mkdirSync(posixDir, { recursive: true }) - writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper') - chmodSync(posixDir, 0o500) - try { - neutralizeLegacyTerminalShimDir(userData) - // Cover all four retry delays plus enough slack to expose an unwanted fifth. - await vi.advanceTimersByTimeAsync(120_000) - - expect(readFileSync(join(posixDir, 'git'), 'utf8')).toBe('legacy attribution wrapper') - const messages = warn.mock.calls.map((call) => String(call[0])) - expect(messages.filter((message) => message.includes('neutralization attempt'))).toHaveLength( - 5 - ) - // Counts alone cannot catch incorrect attempt ordinals. - expect(messages.some((message) => message.includes('neutralization attempt 1 failed'))).toBe( - true - ) - expect(messages.some((message) => message.includes('neutralization attempt 5 failed'))).toBe( - true - ) - expect( - messages.some((message) => message.includes('gave up neutralizing after 5 attempts')) - ).toBe(true) - - warn.mockClear() - await vi.advanceTimersByTimeAsync(120_000) - expect(warn).not.toHaveBeenCalled() - } finally { - chmodSync(posixDir, 0o700) - warn.mockRestore() - 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') @@ -688,138 +299,6 @@ describe('legacy terminal shim neutralization', () => { 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', () => { - const env: Record = { - PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`, - ORCA_ENABLE_GIT_ATTRIBUTION: '1', - ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca ', - ORCA_GH_PR_FOOTER: 'footer', - ORCA_GH_ISSUE_FOOTER: 'footer', - ORCA_ATTRIBUTION_SHIM_DIR: '/home/u/.orca/orca-terminal-attribution/posix', - ORCA_REAL_GIT: '/usr/bin/git', - ORCA_REAL_GH: '/usr/bin/gh', - HOME: '/home/u' - } - - stripLegacyTerminalShimEnv(env, 'linux') - - expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' }) - }) - - it('strips the captured shim directory when PATH spells it with a trailing separator', () => { - // Separator variants must not keep the captured shim directory reachable. - const posix: Record = { - PATH: '/custom/elsewhere/:/usr/bin', - ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere' - } - stripLegacyTerminalShimEnv(posix, 'linux') - expect(posix.PATH).toBe('/usr/bin') - - const win: Record = { - Path: 'C:\\Custom\\Else;C:\\Windows', - ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else\\' - } - stripLegacyTerminalShimEnv(win, 'win32') - expect(win.Path).toBe('C:\\Windows') - }) - - 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: `/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') - - expect(env.Path).toBe('C:\\Windows\\System32') - }) - - it('preserves explicit empty PATH values', () => { - const windowsEnv: Record = { PATH: '', Path: 'C:\\Windows' } - const posixEnv: Record = { PATH: '' } - - stripLegacyTerminalShimEnv(windowsEnv, 'win32') - stripLegacyTerminalShimEnv(posixEnv, 'linux') - - expect(windowsEnv).toEqual({ PATH: '', Path: 'C:\\Windows' }) - expect(posixEnv).toEqual({ PATH: '' }) - }) - - it('strips legacy shim entries that carry a trailing separator', () => { - const posix: Record = { - PATH: '/home/u/.orca/orca-terminal-attribution/posix/:/usr/bin' - } - stripLegacyTerminalShimEnv(posix, 'linux') - expect(posix.PATH).toBe('/usr/bin') - - const many: Record = { - PATH: '/home/u/.orca/orca-terminal-attribution/posix///:/usr/bin' - } - stripLegacyTerminalShimEnv(many, 'linux') - expect(many.PATH).toBe('/usr/bin') - - const win: Record = { - Path: 'C:\\Users\\u\\orca-terminal-attribution\\win32\\;C:\\Windows' - } - stripLegacyTerminalShimEnv(win, 'win32') - expect(win.Path).toBe('C:\\Windows') - }) - - it('keeps neighbouring directories that merely share the name prefix', () => { - const env: Record = { - 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:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin' - ) - }) - - it('leaves an unrelated PATH untouched', () => { - const env: Record = { PATH: '/usr/local/bin:/usr/bin' } - stripLegacyTerminalShimEnv(env, 'linux') - expect(env.PATH).toBe('/usr/local/bin:/usr/bin') - }) }) function quoteBash(value: string): string { diff --git a/src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts b/src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts index 15bc98bdb3b..4386d907bf1 100644 --- a/src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts +++ b/src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts @@ -121,74 +121,6 @@ describePosix('OMP shell wrapper node-pty reproduction', () => { } }) - itWithBash('reproduces why restored shells miss OMP status without the wrapper', async () => { - const tempDir = makeTempDir() - const binDir = join(tempDir, 'bin') - const piDir = join(tempDir, 'pi-agent') - const ompDir = join(tempDir, 'omp-agent') - const extensionDir = join(ompDir, 'extensions') - mkdirSync(binDir) - mkdirSync(piDir) - mkdirSync(extensionDir, { recursive: true }) - const statusExtension = join(extensionDir, 'orca-agent-status.ts') - writeFileSync(statusExtension, 'export default {}') - writeFakeOmp(binDir) - - const makeEnv = (captureFile: string, afterPiFile: string): Record => ({ - ...process.env, - HOME: tempDir, - PATH: `${binDir}:${process.env.PATH ?? ''}`, - PI_CODING_AGENT_DIR: '', - ORCA_PI_CODING_AGENT_DIR: '', - ORCA_OMP_CODING_AGENT_DIR: '', - ORCA_OMP_STATUS_EXTENSION: statusExtension, - ORCA_FAKE_OMP_DEFAULT_DIR: ompDir, - ORCA_CAPTURE_FILE: captureFile, - ORCA_AFTER_PI_FILE: afterPiFile, - TERM: process.env.TERM || 'xterm-256color' - }) - - const unwrappedCapture = join(tempDir, 'unwrapped-capture') - const unwrappedAfterPi = join(tempDir, 'unwrapped-after-pi') - await runInteractivePosixPty({ - cwd: tempDir, - rcfileContent: '', - env: makeEnv(unwrappedCapture, unwrappedAfterPi), - input: `omp ask -printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE" -exit 0 -` - }) - - const unwrapped = readFileSync(unwrappedCapture, 'utf8') - expect(unwrapped).toContain('PI=\n') - expect(unwrapped).toContain(`EFFECTIVE=${ompDir}`) - expect(unwrapped).toContain('ARG1=ask') - expect(unwrapped).not.toContain('ARG1=--extension') - - const wrappedCapture = join(tempDir, 'wrapped-capture') - const wrappedAfterPi = join(tempDir, 'wrapped-after-pi') - const wrappedOutput = await runInteractivePosixPty({ - cwd: tempDir, - rcfileContent: getPosixOmpShellWrapper(), - env: makeEnv(wrappedCapture, wrappedAfterPi), - input: `type omp -omp ask -printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE" -exit 0 -` - }) - - const wrapped = readFileSync(wrappedCapture, 'utf8') - expect(wrappedOutput).toContain('omp is a function') - expect(wrapped).toContain('PI=\n') - expect(wrapped).toContain(`EFFECTIVE=${ompDir}`) - expect(wrapped).toContain('ARG1=--extension') - expect(wrapped).toContain(`ARG2=${statusExtension}`) - expect(wrapped).toContain('ARG3=ask') - expect(readFileSync(wrappedAfterPi, 'utf8')).toBe('') - }) - itWithBash('runs OMP config subcommands without redirecting the home', async () => { const tempDir = makeTempDir() const binDir = join(tempDir, 'bin') @@ -230,105 +162,6 @@ exit 0 expect(readFileSync(join(sourceDir, 'config.yml'), 'utf8')).toBe('updated-by-omp-config\n') }) - itWithBash.each([ - '__complete', - 'bench', - 'completions', - 'dry-balance', - 'gallery', - 'install', - 'join', - 'models', - 'say', - 'tiny-models', - 'token', - 'ttsr', - 'usage' - ])('runs OMP %s subcommands without injecting the status extension', async (subcommand) => { - const tempDir = makeTempDir() - const binDir = join(tempDir, 'bin') - const sourceDir = join(tempDir, 'source-omp-agent') - const extensionDir = join(sourceDir, 'extensions') - mkdirSync(binDir) - mkdirSync(sourceDir, { recursive: true }) - mkdirSync(extensionDir, { recursive: true }) - const statusExtension = join(extensionDir, 'orca-agent-status.ts') - writeFileSync(statusExtension, 'export default {}') - writeFakeOmp(binDir) - - const captureFile = join(tempDir, `${subcommand}-capture`) - await runInteractivePosixPty({ - cwd: tempDir, - rcfileContent: getPosixOmpShellWrapper(), - env: { - ...process.env, - HOME: tempDir, - PATH: `${binDir}:${process.env.PATH ?? ''}`, - PI_CODING_AGENT_DIR: '', - ORCA_PI_CODING_AGENT_DIR: '', - ORCA_OMP_CODING_AGENT_DIR: '', - ORCA_OMP_SOURCE_AGENT_DIR: sourceDir, - ORCA_OMP_STATUS_EXTENSION: statusExtension, - ORCA_FAKE_OMP_DEFAULT_DIR: sourceDir, - ORCA_CAPTURE_FILE: captureFile, - TERM: process.env.TERM || 'xterm-256color' - }, - input: `omp ${subcommand} -exit 0 -` - }) - - const capture = readFileSync(captureFile, 'utf8') - expect(capture).toContain('PI=\n') - expect(capture).toContain(`EFFECTIVE=${sourceDir}`) - expect(capture).toContain(`ARG1=${subcommand}`) - expect(capture).not.toContain('ARG1=--extension') - }) - - itWithBash( - 'lets OMP config subcommands fall back to the default home without a source shadow', - async () => { - const tempDir = makeTempDir() - const binDir = join(tempDir, 'bin') - const defaultOmpDir = join(tempDir, '.omp', 'agent') - const extensionDir = join(defaultOmpDir, 'extensions') - mkdirSync(binDir) - mkdirSync(defaultOmpDir, { recursive: true }) - mkdirSync(extensionDir, { recursive: true }) - const statusExtension = join(extensionDir, 'orca-agent-status.ts') - writeFileSync(statusExtension, 'export default {}') - writeFakeOmp(binDir) - - const captureFile = join(tempDir, 'default-config-capture') - await runInteractivePosixPty({ - cwd: tempDir, - rcfileContent: getPosixOmpShellWrapper(), - env: { - ...process.env, - HOME: tempDir, - PATH: `${binDir}:${process.env.PATH ?? ''}`, - PI_CODING_AGENT_DIR: '', - ORCA_PI_CODING_AGENT_DIR: '', - ORCA_OMP_CODING_AGENT_DIR: '', - ORCA_OMP_STATUS_EXTENSION: statusExtension, - ORCA_FAKE_OMP_DEFAULT_DIR: defaultOmpDir, - ORCA_CAPTURE_FILE: captureFile, - TERM: process.env.TERM || 'xterm-256color' - }, - input: `omp config -exit 0 -` - }) - - const capture = readFileSync(captureFile, 'utf8') - expect(capture).toContain('PI=\n') - expect(capture).toContain(`EFFECTIVE=${defaultOmpDir}`) - expect(readFileSync(join(defaultOmpDir, 'config.yml'), 'utf8')).toBe( - 'updated-by-omp-config\n' - ) - } - ) - async function expectStaleCwdRecovery(shell: PosixShell): Promise { const tempDir = makeTempDir() const workspaceDir = join(tempDir, 'workspace') diff --git a/src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts b/src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts deleted file mode 100644 index 66eddbac042..00000000000 --- a/src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const lineEditorProbe = vi.hoisted(() => vi.fn()) -const relayedLineEditorProbe = vi.hoisted(() => vi.fn()) -const listChildPids = vi.hoisted(() => vi.fn()) -const processReadinessProbe = vi.hoisted(() => vi.fn()) -const resolveExecutablePath = vi.hoisted(() => vi.fn((value: string) => Promise.resolve(value))) -const resolveInstalledExecutablePaths = vi.hoisted(() => - vi.fn((): Promise => Promise.resolve([])) -) -vi.mock('../../../shared/terminal/pty/native-process/pty-slave-line-discipline-echo', () => ({ - createPtySlaveLineEditorProbe: (ttyPath: string) => - ttyPath === '/dev/ttys099' ? relayedLineEditorProbe : lineEditorProbe -})) -vi.mock('../../../shared/terminal/pty/native-process/shell-process-readiness', () => ({ - readShellProcessReadiness: processReadinessProbe, - listChildProcessIds: listChildPids, - resolveShellExecutablePath: resolveExecutablePath, - resolveInstalledShellExecutablePaths: resolveInstalledExecutablePaths -})) - -import { createShellPromptReadinessProbe } from './shell-prompt-readiness-probe' - -describe('shell prompt readiness probe', () => { - beforeEach(() => { - vi.useFakeTimers() - lineEditorProbe.mockReset() - relayedLineEditorProbe.mockReset() - listChildPids.mockReset().mockResolvedValue([]) - processReadinessProbe.mockReset() - resolveExecutablePath.mockClear() - resolveInstalledExecutablePaths.mockClear() - resolveInstalledExecutablePaths.mockResolvedValue([]) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('accepts only the identified shell pid in line-editor mode and foreground', async () => { - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true }) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - - expect(onPromptReady).toHaveBeenCalledOnce() - }) - - it('preserves an unset child PATH for executable resolution', async () => { - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true }) - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady: vi.fn(), - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - - expect(resolveExecutablePath).toHaveBeenCalledWith('/bin/zsh', process.cwd(), undefined) - }) - - it.each([ - ['silent read', 'other', { executablePath: '/bin/zsh', foreground: true }], - ['background shell', 'line-editor', { executablePath: '/bin/zsh', foreground: false }], - ['different foreground process', 'line-editor', null], - [ - 'non-shell replacement image', - 'line-editor', - { executablePath: '/usr/bin/sqlite3', foreground: true } - ], - [ - 'non-shell image with the shell basename', - 'line-editor', - { executablePath: '/tmp/zsh', foreground: true } - ] - ])('rejects %s', async (_name, terminalState, rows) => { - lineEditorProbe.mockResolvedValue(terminalState) - processReadinessProbe.mockResolvedValue(rows) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - - expect(onPromptReady).not.toHaveBeenCalled() - if (terminalState === 'other') { - expect(listChildPids).not.toHaveBeenCalled() - } - }) - - it.each([ - ['accepts', 'line-editor', true], - ['rejects', 'other', false] - ])( - '%s a relayed shell whose inner pty is in %s mode (#25586)', - async (_verb, innerState, expected) => { - lineEditorProbe.mockResolvedValue('other') - relayedLineEditorProbe.mockResolvedValue(innerState) - listChildPids.mockResolvedValue([43]) - processReadinessProbe.mockImplementation((pid: number) => - Promise.resolve( - pid === 42 - ? { executablePath: '/usr/local/bin/kiro-cli-term', foreground: true, ttyPath: null } - : { executablePath: '/bin/zsh', foreground: true, ttyPath: '/dev/ttys099' } - ) - ) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - - expect(onPromptReady).toHaveBeenCalledTimes(expected ? 1 : 0) - } - ) - - it('accepts a second installation of the same shell that the pane PATH resolves', async () => { - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockResolvedValue({ - executablePath: '/opt/homebrew/bin/bash', - foreground: true - }) - resolveInstalledExecutablePaths.mockResolvedValue(['/bin/bash', '/opt/homebrew/bin/bash']) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/bash', - shellCwd: '/work', - shellPathEnv: '/opt/homebrew/bin:/usr/bin:/bin', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - - expect(resolveInstalledExecutablePaths).toHaveBeenCalledWith( - 'bash', - '/work', - '/opt/homebrew/bin:/usr/bin:/bin' - ) - expect(onPromptReady).toHaveBeenCalledOnce() - }) - - it('rejects a replacement with the shell basename that the pane PATH cannot reach', async () => { - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockResolvedValue({ executablePath: '/tmp/bash', foreground: true }) - resolveInstalledExecutablePaths.mockResolvedValue(['/bin/bash', '/opt/homebrew/bin/bash']) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/bash', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - - expect(onPromptReady).not.toHaveBeenCalled() - }) - - it('does not widen identity when the launched shell path resolves exactly', async () => { - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true }) - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady: vi.fn(), - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - - expect(resolveInstalledExecutablePaths).not.toHaveBeenCalled() - }) - - it('invalidates an alternate-installation result that resolves after disposal', async () => { - const pending: { resolve?: (value: string[]) => void } = {} - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockResolvedValue({ - executablePath: '/opt/homebrew/bin/bash', - foreground: true - }) - resolveInstalledExecutablePaths.mockImplementation( - () => new Promise((resolve) => (pending.resolve = resolve)) - ) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/bash', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - probe?.dispose() - pending.resolve?.(['/opt/homebrew/bin/bash']) - await vi.advanceTimersByTimeAsync(0) - - expect(onPromptReady).not.toHaveBeenCalled() - }) - - it('does no external work when the ready marker cancels the settle window', async () => { - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady: vi.fn(), - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - probe?.dispose() - await vi.advanceTimersByTimeAsync(10) - - expect(lineEditorProbe).not.toHaveBeenCalled() - expect(processReadinessProbe).not.toHaveBeenCalled() - }) - - it('invalidates an in-flight result when newer output arrives', async () => { - const pending: { resolve?: (value: string) => void } = {} - lineEditorProbe.mockImplementation( - () => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void)) - ) - processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true }) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - probe?.notifyOutput('\x1b[?2004h') - pending.resolve?.('line-editor') - await vi.advanceTimersByTimeAsync(0) - - expect(onPromptReady).not.toHaveBeenCalled() - expect(processReadinessProbe).not.toHaveBeenCalled() - }) - - it('does not inspect a process after disposal during a line-editor probe', async () => { - const pending: { resolve?: (value: string) => void } = {} - lineEditorProbe.mockImplementation( - () => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void)) - ) - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady: vi.fn(), - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - probe?.dispose() - pending.resolve?.('line-editor') - await vi.advanceTimersByTimeAsync(0) - - expect(processReadinessProbe).not.toHaveBeenCalled() - }) - - it('invalidates process readiness that resolves after disposal', async () => { - const pending: { resolve?: (value: { executablePath: string; foreground: boolean }) => void } = - {} - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockImplementation( - () => new Promise((resolve) => (pending.resolve = resolve)) - ) - const onPromptReady = vi.fn() - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady, - settleMs: 10 - }) - - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - probe?.dispose() - pending.resolve?.({ executablePath: '/bin/zsh', foreground: true }) - await vi.advanceTimersByTimeAsync(0) - - expect(onPromptReady).not.toHaveBeenCalled() - }) - - it('ignores slow startup output until the line editor enables its protocol', async () => { - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady: vi.fn(), - settleMs: 10 - }) - - for (let index = 0; index < 20; index += 1) { - probe?.notifyOutput(`startup ${index}\n`) - await vi.advanceTimersByTimeAsync(20) - } - - expect(lineEditorProbe).not.toHaveBeenCalled() - expect(processReadinessProbe).not.toHaveBeenCalled() - }) - - it('bounds rejected line-editor retries', async () => { - lineEditorProbe.mockResolvedValue('line-editor') - processReadinessProbe.mockResolvedValue({ - executablePath: '/usr/bin/sqlite3', - foreground: true - }) - const probe = createShellPromptReadinessProbe({ - slavePath: '/dev/ttys048', - shellPath: '/bin/zsh', - getShellPid: () => 42, - onPromptReady: vi.fn(), - settleMs: 10 - }) - - for (let index = 0; index < 10; index += 1) { - probe?.notifyOutput('\x1b[?2004h') - await vi.advanceTimersByTimeAsync(10) - } - - expect(lineEditorProbe).toHaveBeenCalledTimes(4) - expect(processReadinessProbe).toHaveBeenCalledTimes(4) - }) -}) diff --git a/src/main/pty/shell-startup/shell-startup-env.test.ts b/src/main/pty/shell-startup/shell-startup-env.test.ts deleted file mode 100644 index 1a97abe78ec..00000000000 --- a/src/main/pty/shell-startup/shell-startup-env.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { existsSyncMock, readFileSyncMock, readdirSyncMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - readFileSyncMock: vi.fn(), - readdirSyncMock: vi.fn() -})) - -vi.mock('fs', () => ({ - existsSync: existsSyncMock, - readFileSync: readFileSyncMock, - readdirSync: readdirSyncMock -})) - -import { - __resetShellStartupEnvCache, - readBashStartupEnvVar, - readSessionShellStartupEnvVar, - readShellStartupEnvVar, - SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES -} from './shell-startup-env' - -describe('readShellStartupEnvVar', () => { - const originalPlatform = process.platform - const originalShell = process.env.SHELL - // CI may export XDG_CONFIG_HOME; pin it so fish mock paths do not depend on the host. - const originalXdgConfigHome = process.env.XDG_CONFIG_HOME - - beforeEach(() => { - delete process.env.XDG_CONFIG_HOME - existsSyncMock.mockReset() - readFileSyncMock.mockReset() - readdirSyncMock.mockReset() - readdirSyncMock.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - process.env.SHELL = '/bin/zsh' - __resetShellStartupEnvCache() - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - if (originalShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = originalShell - } - if (originalXdgConfigHome === undefined) { - delete process.env.XDG_CONFIG_HOME - } else { - process.env.XDG_CONFIG_HOME = originalXdgConfigHome - } - }) - - function mockStartupFiles(files: Record) { - const hasAbsoluteKeys = Object.keys(files).some((path) => path.startsWith('/')) - existsSyncMock.mockImplementation((p: string) => { - const file = p.split('/').pop() ?? '' - return p in files || (!hasAbsoluteKeys && file in files) - }) - readFileSyncMock.mockImplementation((p: string) => { - const file = p.split('/').pop() ?? '' - if (p in files) { - return files[p] - } - if (!hasAbsoluteKeys && file in files) { - return files[file] - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - } - - // #1534: GUI env lacks rc-only exports; static discovery must preserve the user overlay source. - it('mirrors the user scenario: GUI-launched Orca discovers .zshrc-only export', () => { - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.config/opencode"\n' - }) - - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( - '/home/alice/.config/opencode' - ) - }) - - it('returns undefined when HOME is unset', () => { - const savedHome = process.env.HOME - delete process.env.HOME - try { - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR')).toBeUndefined() - expect(existsSyncMock).not.toHaveBeenCalled() - } finally { - if (savedHome !== undefined) { - process.env.HOME = savedHome - } - } - }) - - it('returns undefined on Windows', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/win\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() - }) - - it('reads Git Bash login files on Windows only when asked for bash', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - process.env.SHELL = '/usr/bin/bash' - mockStartupFiles({ '.bash_profile': 'export OPENCODE_CONFIG_DIR=/git-bash\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() - expect(readBashStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/git-bash') - }) - - it('returns undefined when no startup file matches', () => { - mockStartupFiles({ '.zshrc': 'export FOO=bar\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() - }) - - it('returns the LAST assignment when multiple files re-export', () => { - mockStartupFiles({ - '.zshenv': 'export OPENCODE_CONFIG_DIR="/old/zshenv"\n', - '.zprofile': 'export OPENCODE_CONFIG_DIR="/middle/zprofile"\n', - '.zshrc': 'export OPENCODE_CONFIG_DIR="/new/zshrc"\n', - '.zlogin': 'export OPENCODE_CONFIG_DIR="/newest/zlogin"\n' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin') - }) - - it('uses ZDOTDIR exported from .zshenv for later zsh startup files', () => { - mockStartupFiles({ - '/home/alice/.zshenv': 'export ZDOTDIR="$HOME/.config/zsh"\n', - '/home/alice/.config/zsh/.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n' - }) - - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/zsh')).toBe( - '/home/alice/company/opencode' - ) - }) - - it('handles double-quoted values', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path') - }) - - it('handles single-quoted values', () => { - mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/quoted/path'\n" }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path') - }) - - it('handles unquoted values', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/unquoted/path\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/unquoted/path') - }) - - it('expands $HOME in values', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode"\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( - '/home/alice/.opencode' - ) - }) - - it('expands ${HOME} in values', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="${HOME}/.opencode"\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( - '/home/alice/.opencode' - ) - }) - - it('expands leading ~ in values', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=~/.opencode\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( - '/home/alice/.opencode' - ) - }) - - it('ignores bare assignments without the export keyword', () => { - // Unexported assignments never reach child processes and cannot be overlay sources. - mockStartupFiles({ '.zshrc': 'OPENCODE_CONFIG_DIR=/no/export\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() - }) - - it('preserves a # inside a double-quoted value', () => { - // Unquoted # begins a comment only at a word boundary. - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/path/with#hash"\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash') - }) - - it('preserves a # inside a single-quoted value', () => { - mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/path/with#hash'\n" }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash') - }) - - it('strips a trailing # comment from an unquoted value', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/bare/path # trailing comment\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/bare/path') - }) - - it('strips a trailing # comment after a double-quoted value', () => { - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode" # trailing comment\n' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( - '/home/alice/.opencode' - ) - }) - - it('strips a trailing # comment after a single-quoted value', () => { - mockStartupFiles({ - '.zshrc': "export OPENCODE_CONFIG_DIR='/literal/path' # trailing comment\n" - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/literal/path') - }) - - it('does not expand $HOME inside single quotes', () => { - mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='$HOME/.opencode'\n" }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOME/.opencode') - }) - - it('does not partially expand $HOMER / $HOMEPATH', () => { - // HOME expansion must not partially rewrite a different variable name. - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOMER/agent"\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOMER/agent') - }) - - it('only scans zsh startup files when SHELL is zsh', () => { - // Startup discovery must follow only the active shell family. - process.env.SHELL = '/bin/zsh' - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', - '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc') - }) - - it('only scans bash startup files when SHELL is bash', () => { - process.env.SHELL = '/bin/bash' - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', - '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/bash_profile') - }) - - it('defaults to zsh startup files when SHELL is unset', () => { - delete process.env.SHELL - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', - '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc') - }) - - it('does not scan .bashrc for bash shells', () => { - // Orca's login Bash wrapper does not force .bashrc; discovery must match. - process.env.SHELL = '/bin/bash' - mockStartupFiles({ '.bashrc': 'export OPENCODE_CONFIG_DIR=/from/bashrc\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() - }) - - it('does not scan zsh or bash files for an explicit unsupported shell', () => { - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', - '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' - }) - expect( - readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/opt/bin/fish') - ).toBeUndefined() - }) - - it('honors an explicit shell argument over process.env.SHELL', () => { - // Per-spawn SHELL can differ from main's inherited SHELL. - process.env.SHELL = '/bin/zsh' - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', - '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/bash')).toBe( - '/from/bash_profile' - ) - }) - - it('memoizes results across calls within the same process', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached') - - const callsAfterFirst = readFileSyncMock.mock.calls.length - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached') - expect(readFileSyncMock.mock.calls.length).toBe(callsAfterFirst) - }) - - it('bounds cache keys from long-lived home and host churn', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' }) - for (let index = 0; index < SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40; index += 1) { - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', `/home/user-${index}`)).toBe('/cached') - } - - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/user-0')).toBe('/cached') - expect(readFileSyncMock.mock.calls.length).toBeGreaterThan( - SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40 - ) - }) - - it('rejects names with regex metacharacters', () => { - mockStartupFiles({ '.zshrc': 'export FOO=/x\n' }) - expect(readShellStartupEnvVar('FOO.*', '/home/alice')).toBeUndefined() - }) - - it('does not match other variable names', () => { - mockStartupFiles({ - '.zshrc': 'export OPENCODE_CONFIG_DIR_BACKUP=/backup\nexport NOT_OPENCODE_CONFIG_DIR=/x\n' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() - }) - - it('survives a readFileSync error on one file and continues', () => { - existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockImplementation((p: string) => { - if (p.endsWith('.zshenv')) { - throw new Error('EACCES') - } - if (p.endsWith('.zshrc')) { - return 'export OPENCODE_CONFIG_DIR=/found\n' - } - return '' - }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/found') - }) - - it('handles CRLF line endings', () => { - mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/crlf\r\nexport OTHER=x\r\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/crlf') - }) - - it('does not match an OPENCODE_CONFIG_DIR mention in a comment', () => { - mockStartupFiles({ '.zshrc': '# export OPENCODE_CONFIG_DIR=/from-comment\n' }) - expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() - }) - - describe('fish', () => { - const FISH = '/opt/homebrew/bin/fish' - - function mockFishFiles(files: Record, snippets: string[] = []) { - readdirSyncMock.mockImplementation((dir: string) => { - if (dir === '/home/alice/.config/fish/conf.d' || dir === '/cfg/fish/conf.d') { - return snippets - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - mockStartupFiles(files) - } - - it('reads an exported set from config.fish', () => { - mockFishFiles({ - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /home/alice/.codex\n' - }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex') - }) - - it('lets config.fish win over a conf.d snippet, matching fish source order', () => { - mockFishFiles( - { - '/home/alice/.config/fish/conf.d/10-agents.fish': 'set -gx CODEX_HOME /from/confd\n', - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/config\n' - }, - ['10-agents.fish'] - ) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/config') - }) - - it('sources conf.d snippets in filename order', () => { - mockFishFiles( - { - '/home/alice/.config/fish/conf.d/aaa.fish': 'set -gx CODEX_HOME /from/aaa\n', - '/home/alice/.config/fish/conf.d/zzz.fish': 'set -gx CODEX_HOME /from/zzz\n' - }, - ['zzz.fish', 'aaa.fish', 'notes.txt'] - ) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/zzz') - }) - - it('honors XDG_CONFIG_HOME', () => { - mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/xdg\n' }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg')).toBe('/from/xdg') - }) - - // Cover the env-default path too so fish cases cannot silently depend on host configuration. - it('defaults configHome to XDG_CONFIG_HOME from the environment', () => { - process.env.XDG_CONFIG_HOME = '/cfg' - __resetShellStartupEnvCache() - mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/env-xdg\n' }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/env-xdg') - }) - - it('ignores sets that are not exported', () => { - mockFishFiles({ - '/home/alice/.config/fish/config.fish': - 'set -g CODEX_HOME /global\nset -l CODEX_HOME /local\nset CODEX_HOME /plain\n' - }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() - }) - - it('accepts every fish export spelling', () => { - for (const line of [ - 'set -x CODEX_HOME /a', - 'set -xg CODEX_HOME /a', - 'set -gx CODEX_HOME /a', - 'set -Ux CODEX_HOME /a', - 'set --export --global CODEX_HOME /a' - ]) { - __resetShellStartupEnvCache() - mockFishFiles({ '/home/alice/.config/fish/config.fish': `${line}\n` }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/a') - } - }) - - it('expands $HOME in double quotes and keeps single quotes literal', () => { - mockFishFiles({ - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex" # note\n' - }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex') - - __resetShellStartupEnvCache() - mockFishFiles({ - '/home/alice/.config/fish/config.fish': "set -gx CODEX_HOME '$HOME/.codex'\n" - }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('$HOME/.codex') - }) - - it('does not read zsh or bash startup files for a fish user', () => { - mockFishFiles({ - '/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n', - '/home/alice/.bash_profile': 'export CODEX_HOME=/from/bash\n' - }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() - }) - - it('ignores a commented assignment', () => { - mockFishFiles({ - '/home/alice/.config/fish/config.fish': '# set -gx CODEX_HOME /from-comment\n' - }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() - }) - - it('does not match a different variable with the same prefix', () => { - mockFishFiles({ - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME_BACKUP /backup\n' - }) - expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined() - }) - }) - - // Session XDG_CONFIG_HOME must match the fish config path used locally and over relay. - describe('readSessionShellStartupEnvVar', () => { - const FISH = '/opt/homebrew/bin/fish' - const savedConfigHome = process.env.XDG_CONFIG_HOME - const savedHome = process.env.HOME - - afterEach(() => { - restoreEnv('XDG_CONFIG_HOME', savedConfigHome) - restoreEnv('HOME', savedHome) - }) - - function restoreEnv(key: string, value: string | undefined) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - - it("prefers the session env's XDG_CONFIG_HOME over the main process's", () => { - process.env.XDG_CONFIG_HOME = '/main-process-cfg' - mockStartupFiles({ - '/session-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/session\n', - '/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n' - }) - - expect( - readSessionShellStartupEnvVar('CODEX_HOME', { - HOME: '/home/alice', - SHELL: FISH, - XDG_CONFIG_HOME: '/session-cfg' - }) - ).toBe('/from/session') - }) - - it('falls back to the main process XDG_CONFIG_HOME when the session env lacks one', () => { - process.env.XDG_CONFIG_HOME = '/main-process-cfg' - mockStartupFiles({ - '/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n' - }) - - expect( - readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH }) - ).toBe('/from/main-process') - }) - - it("falls back to fish's own ~/.config default when neither env has one", () => { - delete process.env.XDG_CONFIG_HOME - mockStartupFiles({ - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/default\n' - }) - - expect( - readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH }) - ).toBe('/from/default') - }) - - it('resolves against the session HOME, not the main process HOME', () => { - delete process.env.XDG_CONFIG_HOME - process.env.HOME = '/home/root-user' - mockStartupFiles({ - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex"\n', - '/home/root-user/.config/fish/config.fish': 'set -gx CODEX_HOME /from/wrong-home\n' - }) - - expect( - readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH }) - ).toBe('/home/alice/.codex') - }) - - it('lets an explicit shell override beat the session SHELL', () => { - delete process.env.XDG_CONFIG_HOME - mockStartupFiles({ - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/fish\n', - '/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n' - }) - - expect( - readSessionShellStartupEnvVar( - 'CODEX_HOME', - { HOME: '/home/alice', SHELL: '/bin/zsh' }, - FISH - ) - ).toBe('/from/fish') - }) - - it('falls back to the main process HOME and SHELL with no session env at all', () => { - delete process.env.XDG_CONFIG_HOME - process.env.HOME = '/home/alice' - process.env.SHELL = FISH - mockStartupFiles({ - '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/process-env\n' - }) - - expect(readSessionShellStartupEnvVar('CODEX_HOME', undefined)).toBe('/from/process-env') - }) - }) -}) diff --git a/src/main/pty/shell-startup/shell-startup-feature-channel.test.ts b/src/main/pty/shell-startup/shell-startup-feature-channel.test.ts deleted file mode 100644 index b63d62152a1..00000000000 --- a/src/main/pty/shell-startup/shell-startup-feature-channel.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { execFileSync, spawnSync } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './posix-shell-startup-command' -import { selectShellStartupFeatures } from './shell-startup-features' -import { prependOrcaCliDirToChildPath } from '../../cli/orca-cli-child-path' -import { runZshPty } from './zsh-startup-hook-pty-harness' -import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' -import { - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from '../../providers/local-pty/local-pty-shell-ready-test-harness' - -restoreUserDataPathAfterEach() - -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' -const itWithZsh = hasZsh ? it : it.skip -const describePosix = process.platform === 'win32' ? describe.skip : describe - -const PLAIN_PANE = { - hasStartupCommand: false, - waitsForShellReady: false, - emitsStartupIdentity: false -} - -describe('shell startup feature selection', () => { - it('ignores an inherited ORCA_SHELL_FEATURES in the spawn env', () => { - expect( - selectShellStartupFeatures({ - shellPath: '/bin/zsh', - env: { ORCA_SHELL_FEATURES: 'overlay,markers,ready,identity,history' }, - ...PLAIN_PANE - }) - ).toEqual([]) - }) - - it('cannot be turned off for a pane that needs a feature', () => { - expect( - selectShellStartupFeatures({ - shellPath: '/bin/zsh', - env: { ORCA_SHELL_FEATURES: '', ORCA_HISTFILE: '/tmp/wt/zsh_history' }, - ...PLAIN_PANE - }) - ).toEqual(['history']) - }) - - it('gives a history-only pane no command markers', () => { - expect( - selectShellStartupFeatures({ - shellPath: '/bin/zsh', - env: { ORCA_HISTFILE: '/tmp/wt/zsh_history' }, - ...PLAIN_PANE - }) - ).not.toContain('markers') - }) - - it('keeps markers on a pane that already had them', () => { - expect( - selectShellStartupFeatures({ - shellPath: '/bin/zsh', - env: { ORCA_CODEX_HOME: '/tmp/codex' }, - ...PLAIN_PANE - }) - ).toContain('markers') - }) - - it('does not wrap bash for history alone', () => { - expect( - selectShellStartupFeatures({ - shellPath: '/bin/bash', - env: { ORCA_HISTFILE: '/tmp/wt/bash_history' }, - ...PLAIN_PANE - }) - ).toEqual([]) - }) -}) - -describePosix('zsh launch config', () => { - let userDataPath = '' - let previousFeatures: string | undefined - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-')) - setTestUserDataPath(userDataPath) - previousFeatures = process.env.ORCA_SHELL_FEATURES - }) - - afterEach(() => { - if (previousFeatures === undefined) { - delete process.env.ORCA_SHELL_FEATURES - } else { - process.env.ORCA_SHELL_FEATURES = previousFeatures - } - chmodSync(userDataPath, 0o755) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - itWithZsh.each([false, true])( - 'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s', - async (replacePromptHooks) => { - const cliBin = join(userDataPath, 'cli', 'bin') - const ambientBin = join(userDataPath, 'ambient-bin') - mkdirSync(cliBin, { recursive: true }) - mkdirSync(ambientBin) - for (const bin of [cliBin, ambientBin]) { - const launcher = join(bin, 'orca-dev') - writeFileSync(launcher, '#!/bin/sh\nexit 0\n') - chmodSync(launcher, 0o755) - } - writeFileSync( - join(userDataPath, '.zshrc'), - `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}` - ) - const env: Record = { - ...process.env, - HOME: userDataPath, - USERPROFILE: userDataPath, - PATH: `${ambientBin}:/usr/bin:/bin` - } - const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env, - ...PLAIN_PANE, - hasStartupCommand: true - }) - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellLaunchConfig(ZSH_PATH, features) - const result = await runZshPty({ - env: { ...env, ...config.env }, - commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], - report: ['ORCA_LOOKUP'] - }) - expect(result.values.ORCA_LOOKUP).toBe(launcher) - } - ) - - it('publishes exactly the selected features, whatever process.env holds', async () => { - process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - - const config = getShellLaunchConfig('/bin/zsh', ['history']) - - expect(config.env.ORCA_SHELL_FEATURES).toBe('history') - }) - - it('wraps a startup-only command without enabling shell readiness', async () => { - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - - const config = getShellLaunchConfig('/bin/zsh', [], 'codex') - - expect(config.env.ORCA_SHELL_FEATURES).toBe('startup') - expect(config.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe('codex') - expect(config.supportsReadyMarker).toBe(false) - }) - - it('falls back to plain login zsh when the wrapper tree cannot be written', async () => { - chmodSync(userDataPath, 0o500) - if ( - spawnSync('sh', ['-c', `touch ${JSON.stringify(join(userDataPath, 'probe'))}`]).status === 0 - ) { - return // running with write access regardless of mode (e.g. root) - } - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - - const config = getShellLaunchConfig('/bin/zsh', ['history']) - - expect(config).toEqual({ - args: ['-l'], - env: {}, - supportsReadyMarker: false - }) - }) - - it('does not treat another terminal’s hijacked ZDOTDIR as the user config dir', async () => { - // Other terminals may own ZDOTDIR; reject only positively identified Orca or empty directories. - const home = mkdtempSync(join(tmpdir(), 'orca-stacked-zdotdir-')) - const foreignWrapper = join(home, 'other-terminal', 'zsh') - mkdirSync(foreignWrapper, { recursive: true }) - const previousZdotdir = process.env.ZDOTDIR - const previousHome = process.env.HOME - process.env.ZDOTDIR = foreignWrapper - process.env.HOME = home - try { - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - - // An absent original ZDOTDIR must stay absent, rather than becoming HOME. - expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBeUndefined() - - // The marker identifies ownership even without an Orca-shaped path. - writeFileSync(join(foreignWrapper, '.zshrc'), '') - writeFileSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE), '') - const stamped = await importFreshLocalPtyShellReady() - expect( - stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR - ).toBeUndefined() - - rmSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE)) - const real = await importFreshLocalPtyShellReady() - expect(real.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe( - foreignWrapper - ) - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - if (previousHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = previousHome - } - rmSync(home, { recursive: true, force: true }) - } - }) -}) - -describePosix('epilogue under hostile user shell options', () => { - let userDataPath = '' - let home = '' - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-options-')) - setTestUserDataPath(userDataPath) - home = mkdtempSync(join(tmpdir(), 'orca-feature-options-home-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - rmSync(home, { recursive: true, force: true }) - }) - - // User options affect the deferred hook: NO_UNSET rejects absent variables; KSH_ARRAYS is zero-based. - it.each(['no_unset', 'ksh_arrays'])( - 'still runs every feature when the user set %s', - async (option) => { - if (!hasZsh) { - return - } - const scoped = join(home, 'orca-history', 'zsh_history') - const opencodeDir = join(home, 'opencode-overlay') - writeFileSync(join(home, '.zshrc'), `setopt ${option}\n`) - // overlay is the first token, exposing KSH_ARRAYS subscript errors. - const spawnEnv: Record = { - HOME: home, - ORCA_HISTFILE: scoped, - ORCA_OPENCODE_CONFIG_DIR: opencodeDir - } - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env: spawnEnv, - hasStartupCommand: true, - waitsForShellReady: true, - emitsStartupIdentity: true - }) - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - const launch = getShellLaunchConfig(ZSH_PATH, features) - - // A -c shell never reaches precmd; exercise the hook through a PTY. - const { values } = await runZshPty({ - env: { - PATH: '/usr/bin:/bin', - ...spawnEnv, - ...launch.env, - ORCA_ORIG_ZDOTDIR: home - }, - report: ['LINEINIT', 'PRECMD', 'OPENCODE_CONFIG_DIR', 'ZDOTDIR', 'HISTFILE'], - commands: [ - 'LINEINIT="${widgets[zle-line-init]:-none}"; PRECMD="${precmd_functions[*]:-none}"' - ] - }) - - expect(values.LINEINIT).toBe('user:__orca_prompt_mark') - expect(values.PRECMD).toContain('__orca_osc133_precmd') - expect(values.OPENCODE_CONFIG_DIR).toBe(opencodeDir) - expect(values.ZDOTDIR).toBe(home) - expect(values.HISTFILE).toBe(scoped) - } - ) -}) - -function makeUserHome(): string { - const home = mkdtempSync(join(tmpdir(), 'orca-feature-home-')) - for (const file of ['.zshenv', '.zprofile', '.zshrc', '.zlogin']) { - writeFileSync(join(home, file), `print -r -- "RAN=${file}"\n`) - } - return home -} - -describePosix('history-only pane in a real zsh', () => { - let userDataPath = '' - let home = '' - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-live-')) - setTestUserDataPath(userDataPath) - home = makeUserHome() - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - rmSync(home, { recursive: true, force: true }) - }) - - function withoutInheritedZdotdir(env: Record): Record { - const { ORCA_ORIG_ZDOTDIR: _inherited, ...rest } = env - return rest - } - - async function launchHistoryOnly(): Promise<{ - args: string[] - env: Record - zdotdir: string - }> { - const scoped = join(home, 'orca-history', 'zsh_history') - const spawnEnv: Record = { - HOME: home, - HISTFILE: scoped, - ORCA_HISTFILE: scoped - } - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env: spawnEnv, - ...PLAIN_PANE - }) - expect(features).toEqual(['history']) - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - const launch = getShellLaunchConfig(ZSH_PATH, features) - return { - args: launch.args ?? ['-l'], - env: { - PATH: '/usr/bin:/bin', - ...spawnEnv, - // This test home has no ZDOTDIR; do not inherit the developer's config. - ...withoutInheritedZdotdir(launch.env) - }, - zdotdir: launch.env.ZDOTDIR - } - } - - itWithZsh('unsets the feature channel for the shell and for a grandchild', async () => { - const { args, env } = await launchHistoryOnly() - - const output = execFileSync( - ZSH_PATH, - [ - ...args, - '-i', - '-c', - `print -r -- "SELF=[\${ORCA_SHELL_FEATURES:-}]"; zsh -c 'printenv ORCA_SHELL_FEATURES; print -r -- CHILD_CLEAN'` - ], - { encoding: 'utf8', timeout: 20_000, env } - ) - - expect(output).toContain('SELF=[]') - expect(output).toContain('CHILD_CLEAN') - expect(output).not.toContain('history') - }) - - itWithZsh( - 'emits no OSC 133 and leaves a pane observably identical to an unwrapped one', - async () => { - // The deferred hook runs at the first prompt, so -c would inspect unfinished setup. - const { env } = await launchHistoryOnly() - const capture = [ - 'PRECMD="${precmd_functions[*]}"; PREEXEC="${preexec_functions[*]}"', - 'LINEINIT="${widgets[zle-line-init]:-none}"' - ] - const report = ['PRECMD', 'PREEXEC', 'LINEINIT', 'ZDOTDIR', 'ORCA_SHELL_FEATURES'] - - const wrapped = await runZshPty({ env, commands: capture, report }) - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home }, - commands: capture, - report - }) - - expect(wrapped.output).not.toContain('\x1b]133;') - expect(wrapped.values.PRECMD).toBe(unwrapped.values.PRECMD) - expect(wrapped.values.PREEXEC).toBe(unwrapped.values.PREEXEC) - // Global zsh config may install a widget; compare against the same host's unwrapped shell. - expect(wrapped.values.LINEINIT).toBe(unwrapped.values.LINEINIT) - expect(wrapped.values.PRECMD).not.toContain('orca') - expect(wrapped.values.ZDOTDIR).toBe(unwrapped.values.ZDOTDIR) - expect(wrapped.values.ORCA_SHELL_FEATURES).toBe('UNSET') - } - ) -}) diff --git a/src/main/pty/shell-startup/wsl-orca-env.test.ts b/src/main/pty/shell-startup/wsl-orca-env.test.ts deleted file mode 100644 index 0855b7fba38..00000000000 --- a/src/main/pty/shell-startup/wsl-orca-env.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isAbsolute } from 'node:path' -import { getShellReadyWrapperRoot } from '../../providers/local-pty/local-pty-shell-ready-wrapper-root' -import { - SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, - SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV -} from '../../../shared/setup-agent-sequencing' -import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from './wsl-orca-env' -import { CODEX_REMOTE_VIEWER_SSH_CONNECTION } from '../../../shared/remote-viewer-clipboard-env' - -describe('addOrcaWslInteropEnv', () => { - it.each(['1', '0'])( - 'forwards Claude image override %s to WSL without path translation', - (value) => { - const env: Record = { CLAUDE_CODE_FORCE_TERMINAL_IMAGES: value } - - addOrcaWslInteropEnv(env) - addOrcaWslInteropEnv(env) - - expect(env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES).toBe(value) - expect( - env.WSLENV.split(':').filter((entry) => - entry.startsWith('CLAUDE_CODE_FORCE_TERMINAL_IMAGES') - ) - ).toEqual(['CLAUDE_CODE_FORCE_TERMINAL_IMAGES/u']) - } - ) - - it('marks the Orca terminal handle for Windows to WSL env import', () => { - const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl' } - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') - }) - - // Guests cannot derive the wrapper content hash from userData; publish the resolved root to avoid unwrapped launches. - it('publishes the resolved wrapper root path-translated for the guest', () => { - const env: Record = {} - - addOrcaWslInteropEnv(env) - - expect(env.ORCA_SHELL_READY_ROOT).toBe(getShellReadyWrapperRoot()) - expect(isAbsolute(env.ORCA_SHELL_READY_ROOT as string)).toBe(true) - // Translate the host wrapper path for guest access. - expect(env.WSLENV?.split(':')).toContain('ORCA_SHELL_READY_ROOT/p') - }) - - it('imports setup-gated startup and setup env into WSL without path translation', () => { - const env: Record = { - [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'codex', - [SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: 'while :; do sleep 1; done', - [SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]: '( bash /repo/setup-runner.sh )' - } - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV?.split(':')).toEqual([ - 'ORCA_SHELL_READY_ROOT/p', - `${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`, - `${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u`, - `${SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV}/u` - ]) - }) - - it('preserves existing WSLENV entries and does not duplicate the handle entry', () => { - const env: Record = { - WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p' - } - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p:ORCA_SHELL_READY_ROOT/p') - }) - - it('marks OMP status and hook env for Windows to WSL import', () => { - const env: Record = { - ORCA_TERMINAL_HANDLE: 'term_wsl', - ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', - ORCA_CLI_COMMAND: 'orca-ide', - ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash', - ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', - ORCA_CODEX_ISOLATE: '0', - ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml', - ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', - ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', - ORCA_PANE_KEY: 'tab-1:leaf-1', - ORCA_AGENT_PANE: 'tab-1:leaf-1', - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', - ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret', - ORCA_AGENT_LAUNCH: 'launch-secret', - ORCA_OPENCODE_AGENT: 'opencode2', - ORCA_AGENT_HOOK_PORT: '4567', - ORCA_AGENT_HOOK_TOKEN: 'token', - ORCA_AGENT_HOOK_ENV: 'dev', - ORCA_AGENT_HOOK_VERSION: '1', - ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1', - ORCA_WSL_HOOK_INSTANCE: 'testinstance', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu', - ORCA_RUNTIME_SOURCE_ID: '11111111-1111-4111-8111-111111111111', - ORCA_RUNTIME_SOURCE_INCARNATION: 'runtime-a', - ORCA_RUNTIME_SOURCE_PROFILE_PATH: 'C:\\Users\\a\\AppData\\Roaming\\orca' - } - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') - expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') - expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') - expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p') - expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') - expect(env.WSLENV).toContain('ORCA_CODEX_ISOLATE/u') - expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') - expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p') - expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') - expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') - expect(env.WSLENV).toContain('ORCA_AGENT_PANE/u') - expect(env.WSLENV).toContain('ORCA_TAB_ID/u') - expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u') - expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH_TOKEN/u') - expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH/u') - expect(env.WSLENV).toContain('ORCA_OPENCODE_AGENT/u') - expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_PORT/u') - expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') - expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') - expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') - expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u') - expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') - expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') - expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') - expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u') - expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_ID/u') - expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_INCARNATION/u') - expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_PROFILE_PATH/u') - }) - - it('overwrites caller host evidence with native runtime WSL authority', () => { - const env = { - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation', - ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment' - } - - stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu') - - expect(env).toEqual({ - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' - }) - }) - - it('clears inherited host evidence outside a runtime-owned WSL scope', () => { - const env = { - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation', - ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment' - } - - stampWslOrchestrationCompatibilityHost(env, 'local', null) - - expect(env).toEqual({}) - }) - - it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => { - const windowsEnv: Record = { - ORCA_AGENT_HOOK_ENDPOINT: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\agent-hooks\\endpoint.cmd' - } - addOrcaWslInteropEnv(windowsEnv) - expect(windowsEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/p') - - const guestEnv: Record = { - ORCA_AGENT_HOOK_ENDPOINT: '/home/jin/.orca-wsl/agent-hooks/port-4567/endpoint.env' - } - addOrcaWslInteropEnv(guestEnv) - expect(guestEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/u') - expect(guestEnv.WSLENV).not.toContain('ORCA_AGENT_HOOK_ENDPOINT/p') - }) - - it('tags pre-translated Linux setup paths /u so WSLENV does not translate them again (#9206)', () => { - const env: Record = { - ORCA_ROOT_PATH: '/home/jin/repo', - ORCA_WORKTREE_PATH: '/home/jin/repo-worktrees/fix-1', - ORCA_WORKSPACE_NAME: 'fix-1', - CONDUCTOR_ROOT_PATH: '/home/jin/repo', - GHOSTX_ROOT_PATH: '/home/jin/repo' - } - - addOrcaWslInteropEnv(env) - - // Already-Linux hook paths must cross untranslated to avoid a second conversion. - expect(env.WSLENV).toContain('ORCA_ROOT_PATH/u') - expect(env.WSLENV).toContain('ORCA_WORKTREE_PATH/u') - expect(env.WSLENV).toContain('CONDUCTOR_ROOT_PATH/u') - expect(env.WSLENV).toContain('GHOSTX_ROOT_PATH/u') - expect(env.WSLENV).not.toContain('ORCA_ROOT_PATH/p') - expect(env.WSLENV).not.toContain('ORCA_WORKTREE_PATH/p') - expect(env.ORCA_ROOT_PATH).toBe('/home/jin/repo') - expect(env.ORCA_WORKTREE_PATH).toBe('/home/jin/repo-worktrees/fix-1') - }) - - it('tags untranslated Windows setup paths /p so WSLENV translates them (wsl.exe shell over a Windows worktree)', () => { - const env: Record = { - ORCA_ROOT_PATH: 'C:\\Users\\jin\\repo', - ORCA_WORKTREE_PATH: 'C:\\Users\\jin\\repo-worktrees\\fix-1', - CONDUCTOR_ROOT_PATH: 'C:\\Users\\jin\\repo', - GHOSTX_ROOT_PATH: 'C:\\Users\\jin\\repo' - } - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV).toContain('ORCA_ROOT_PATH/p') - expect(env.WSLENV).toContain('ORCA_WORKTREE_PATH/p') - expect(env.WSLENV).toContain('CONDUCTOR_ROOT_PATH/p') - expect(env.WSLENV).toContain('GHOSTX_ROOT_PATH/p') - expect(env.WSLENV).not.toContain('ORCA_ROOT_PATH/u') - expect(env.WSLENV).not.toContain('ORCA_WORKTREE_PATH/u') - }) - - it('always tags ORCA_WORKSPACE_NAME /u because it is a name, not a path', () => { - const env: Record = { ORCA_WORKSPACE_NAME: 'fix-1' } - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WORKSPACE_NAME/u') - }) - - it.each(['repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', 'folder:f1'])( - 'crosses the workspace id %s into the guest untranslated (/u), as the window and main stamp it', - (workspaceId) => { - const env: Record = { ORCA_WORKSPACE_ID: workspaceId } - - addOrcaWslInteropEnv(env) - - expect(env.ORCA_WORKSPACE_ID).toBe(workspaceId) - expect(env.WSLENV?.split(':')).toContain('ORCA_WORKSPACE_ID/u') - } - ) - - // #10861: wsl.exe drops SSH_CONNECTION, so WSL Codex copied only to the Windows clipboard. - it('crosses the Codex remote-viewer clipboard marker into the guest untranslated (/u)', () => { - const env: Record = { - SSH_CONNECTION: CODEX_REMOTE_VIEWER_SSH_CONNECTION, - ORCA_REMOTE_VIEWER_CLIPBOARD: '1' - } - addOrcaWslInteropEnv(env) - expect(env.WSLENV?.split(':')).toContain('SSH_CONNECTION/u') - expect(env.WSLENV?.split(':')).toContain('ORCA_REMOTE_VIEWER_CLIPBOARD/u') - }) - - it("leaves the Windows host's own SSH session out of the guest", () => { - const env: Record = { SSH_CONNECTION: '10.0.0.1 5000 10.0.0.2 22' } - addOrcaWslInteropEnv(env) - expect(env.WSLENV?.split(':')).not.toContain('SSH_CONNECTION/u') - }) - - it('crosses the inline-image protocol hint into the guest untranslated (/u)', () => { - const env: Record = { ORCA_IMAGE_PROTOCOL: 'kitty' } - addOrcaWslInteropEnv(env) - expect(env.WSLENV).toContain('ORCA_IMAGE_PROTOCOL/u') - }) - - it('marks the WSL hook relay version for import on relay spawn envs', () => { - const env: Record = { - ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' - } - addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WSL_HOOK_RELAY_VERSION/u') - }) - - it('crosses a guest-side OpenCode config overlay untranslated (/u)', () => { - const env: Record = { - OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc', - ORCA_OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc' - } - addOrcaWslInteropEnv(env) - expect(env.WSLENV).toContain('OPENCODE_CONFIG_DIR/u') - expect(env.WSLENV).toContain('ORCA_OPENCODE_CONFIG_DIR/u') - expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR/p') - }) - - it('never crosses a Windows OpenCode config dir into the guest', () => { - // Windows overlays can leak through inherited relay/daemon env; never translate them into guest OpenCode config roots. - const env: Record = { - OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc', - ORCA_OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc' - } - addOrcaWslInteropEnv(env) - expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR') - expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR') - }) - - it('does not register the OpenCode config vars when they are absent', () => { - const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl' } - addOrcaWslInteropEnv(env) - expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR') - expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR') - }) -}) diff --git a/src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts deleted file mode 100644 index d753025821f..00000000000 --- a/src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { prependOrcaCliDirToChildPath } from '../../cli/orca-cli-child-path' -import { getDaemonZshWrapperSpec } from '../../daemon/shell-readiness/daemon-zsh-shell-ready-wrapper-spec' -import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './posix-shell-startup-command' -import { getZshShellReadyWrapperFile } from '../../providers/local-pty/local-pty-shell-ready-wrapper-generation' -import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features' -import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' -import { buildZshStartupHook } from './zsh-startup-wrapper-builder' -import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' - -const itWithZsh = hasZsh ? it : it.skip -const USER_WIDGET = `orca_test_line_init() { - O_UC=$((\${O_UC:-0}+1)) - O_UN="$WIDGET" - builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n' - return 1 -} -zle -N zle-line-init orca_test_line_init -` -const USER_REDRAW = `orca_test_redraw() { - O_RC=$((\${O_RC:-0}+1)) - O_RN="$WIDGET" - return 1 -} -zle -N zle-line-pre-redraw orca_test_redraw -` -const USER_PRECMD = `precmd() { - O_PCALLS=$((\${O_PCALLS:-0}+1)) - O_PN="$0" - O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}" - return 1 -} -` - -describe('zsh deferred startup after prompt-hook replacement', () => { - const roots: string[] = [] - - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - itWithZsh.each([ - 'history', - 'startup', - 'chained-startup', - 'stock-history', - 'stock-startup', - 'stock-chained-redraw', - 'stock-nonzero-precmd', - 'ordered-startup', - 'replaced-precmd-startup', - 'status-startup', - 'sticky-startup', - 'scheduled-startup', - 'unavailable-startup', - 'repeat-history', - 'repeat-startup' - ] as const)( - 'restores CLI precedence and preserves the user widget in a %s pane', - async (intent) => { - const historyOnly = intent.endsWith('history') - const repeatSource = intent.startsWith('repeat-') - const stockBinding = intent.startsWith('stock-') - const chainedLineInit = intent === 'chained-startup' - const chainedRedraw = intent === 'stock-chained-redraw' - const nonzeroPrecmd = intent === 'stock-nonzero-precmd' - const orderedPrecmd = intent === 'ordered-startup' - const replacedPrecmd = intent === 'replaced-precmd-startup' - const statusPrecmd = intent === 'status-startup' - const stickyPrecmd = intent === 'sticky-startup' - const scheduledPrecmd = intent === 'scheduled-startup' - const unavailableSched = intent === 'unavailable-startup' - const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd - const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) - roots.push(home) - const cliBin = join(home, 'cli', 'bin') - const ambientBin = join(home, 'ambient-bin') - const wrapperDir = join(home, 'wrapper') - for (const bin of [cliBin, ambientBin, wrapperDir]) { - mkdirSync(bin, { recursive: true }) - } - for (const bin of [cliBin, ambientBin]) { - writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') - chmodSync(join(bin, 'orca-dev'), 0o755) - } - writeFileSync( - join(home, '.zshenv'), - (chainedLineInit || chainedRedraw || repeatSource - ? '' - : stockBinding - ? USER_REDRAW - : USER_WIDGET) + - (statusPrecmd - ? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n' - : stickyPrecmd - ? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n` - : scheduledPrecmd - ? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n' - : unavailableSched - ? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n' - : nonzeroPrecmd || replacedPrecmd - ? USER_PRECMD - : orderedPrecmd - ? USER_PRECMD.replace('return 1', 'return 0') - : '') - ) - if (statusPrecmd || stickyPrecmd) { - writeFileSync( - join(home, '.zlogin'), - 'orca_test_status() { return 42; }; orca_test_status\n' - ) - } - const stockWidget = - stockBinding || stickyPrecmd || scheduledPrecmd - ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') - : '' - writeFileSync( - join(home, '.zshrc'), - `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ - chainedLineInit - ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` - : chainedRedraw - ? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n` - : '' - }${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}` - ) - writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) - writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') - const env: Record = { - ...process.env, - HOME: home, - USERPROFILE: home, - PATH: `${ambientBin}:/usr/bin:/bin`, - DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', - ZDOTDIR: wrapperDir, - ORCA_HISTFILE: join(home, 'scoped-history') - } - const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home }) - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env, - hasStartupCommand: !historyOnly, - waitsForShellReady: !historyOnly, - emitsStartupIdentity: false - }) - env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) - if (!historyOnly) { - env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))' - } - - if (statusPrecmd || stickyPrecmd) { - const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] }) - expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') - } - - const result = await runZshPty({ - env, - commands: [ - ...(repeatSource - ? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"'] - : []), - 'O_LK=$(command -v orca-dev)', - 'O_SR=${+widgets[__orca_saved_line_init]}', - ...(scheduleCleanup - ? [ - 'O_SC=${+functions[__orca_deferred_sched_init]}', - 'O_SE=${zsh_scheduled_events[*]:-UNSET}' - ] - : []), - ...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []), - ...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []), - 'O_LI=${widgets[zle-line-init]:-none}', - 'O_PC="${precmd_functions[*]}"', - ...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : []) - ], - report: [ - 'O_LK', - 'O_UC', - 'O_UN', - 'O_SR', - ...(scheduleCleanup ? ['O_SC', 'O_SE'] : []), - ...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []), - ...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []), - ...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []), - ...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []), - ...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []), - ...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []), - 'O_LI', - 'O_PC', - 'O_SU', - 'HISTFILE' - ] - }) - - expect(result.values.O_LK).toBe(launcher) - expect(Number(result.values.O_UC)).toBeGreaterThan(0) - if (!chainedLineInit) { - expect(result.values.O_UN).toBe('zle-line-init') - } - expect(result.values.O_SR).toBe('0') - if (repeatSource) { - expect(result.output).not.toContain('job table full or recursion limit exceeded') - expect(result.values.O_PC).not.toContain('__orca_deferred_init') - } - if (scheduleCleanup) { - expect(result.values.O_SC).toBe('0') - expect(result.values.O_SE).not.toContain('orca') - } - if (unavailableSched) { - // Stock completion modules can fail before the fixture restores module_path. - expect(result.output).not.toContain('zsh/sched') - expect(result.output).not.toContain('__orca_arm_deferred_line_init:') - expect(result.values.O_SE).toBe('UNSET') - } - if (scheduledPrecmd) { - expect(result.values.O_EV).toBe('1') - expect(result.values.O_EO).toBe('0') - expect(result.values.O_SE).toContain('O_EVENT') - } - if (stockBinding) { - expect(Number(result.values.O_RC)).toBeGreaterThan(0) - expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw') - if (!chainedRedraw) { - expect(result.values.O_RW).toBe('user:orca_test_redraw') - } - } - expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) - if (nonzeroPrecmd) { - expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) - expect(result.values.O_PN).toBe('precmd') - expect(result.values.O_PO).toBe('on:on') - expect(result.values.O_AC).toBe('called') - expect(result.values.O_PS).toBe('1') - } - if (orderedPrecmd) { - expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) - expect(result.values.O_PN).toBe('precmd') - expect(result.values.O_AO).toBe('0') - } - if (replacedPrecmd) { - expect(result.values.O_PCALLS).toBe('UNSET') - expect(Number(result.values.O_NPC)).toBeGreaterThan(0) - expect(result.values.O_NPN).toBe('precmd') - } - if (statusPrecmd || stickyPrecmd) { - expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') - expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ') - } - if (historyOnly) { - expect(result.output).not.toContain('\x1b]133;') - expect(result.values.O_LI).toBe( - stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init' - ) - expect(result.values.O_PC).not.toContain('orca') - expect(result.values.O_SU).toBe('UNSET') - } else { - expect(result.output).toContain(MARKERS.ready) - expect(result.values.O_LI).toBe('user:__orca_prompt_mark') - expect(result.values.O_PC).toBe( - nonzeroPrecmd || orderedPrecmd - ? 'orca_test_array __orca_osc133_precmd' - : '__orca_osc133_precmd' - ) - expect(result.values.O_SU).toBe('1') - expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe( - result.output.split(MARKERS.ready).length - 1 - ) - } - } - ) -}) - -describe('zsh plugins that call deferred line-init by name', () => { - const roots: string[] = [] - - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - itWithZsh.each(['local', 'daemon'] as const)( - 'keeps the plugin call working across prompts in a %s wrapper', - async (transport) => { - const home = mkdtempSync(join(tmpdir(), 'orca-zsh-named-wrapper-')) - roots.push(home) - const wrapperDir = join(home, 'wrapper') - mkdirSync(wrapperDir) - writeFileSync( - join(home, '.zshrc'), - `functions[orca_test_app_mode]=\${widgets[zle-line-init]#user:}' - O_AM=$((\${O_AM:-0}+1))' -zle -N zle-line-init orca_test_app_mode -` - ) - const wrapper = - transport === 'local' - ? getZshShellReadyWrapperFile() - : buildZshStartupHook(getDaemonZshWrapperSpec()) - writeFileSync(join(wrapperDir, '.zshenv'), wrapper) - writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') - const env: Record = { - HOME: home, - USERPROFILE: home, - PATH: '/usr/bin:/bin', - DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', - ZDOTDIR: wrapperDir - } - env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures( - selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env, - hasStartupCommand: false, - waitsForShellReady: true, - emitsStartupIdentity: false - }) - ) - - const result = await runZshPty({ - env, - commands: [ - 'true', - 'false', - '[[ ${functions[orca_test_app_mode]} == *__orca_deferred_line_init* ]]; O_CALL_REF=$?', - '__orca_deferred_line_init; O_NOOP=$?', - 'O_INIT=${+functions[__orca_deferred_init]}' - ], - report: ['O_AM', 'O_CALL_REF', 'O_NOOP', 'O_INIT'] - }) - - expect(result.output, result.output).not.toContain('command not found') - expect(result.output).toContain(MARKERS.ready) - expect(Number(result.values.O_AM)).toBeGreaterThan(2) - expect(result.values.O_CALL_REF).toBe('0') - expect(result.values.O_NOOP).toBe('0') - expect(result.values.O_INIT).toBe('0') - } - ) -}) diff --git a/src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts b/src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts index 7b22f8564a3..e0d13fd95c1 100644 --- a/src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts @@ -1,12 +1,12 @@ /** Exercise real launch selection and a PTY prompt so system zshrc and deferred hooks can affect HISTFILE. */ import { execFileSync } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { getShellLaunchConfig } from '../../providers/local-pty/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' -import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' +import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' const itWithZsh = hasZsh ? it : it.skip @@ -78,17 +78,6 @@ function withHome(files: Record, run: (home: string) => Promise< describe.skipIf(process.platform === 'win32')( 'worktree-scoped HISTFILE survives zsh startup', () => { - itWithZsh( - 'wraps a plain pane once Orca injected a worktree HISTFILE', - withHome(USER_FILES, async (home) => { - const { features, launch } = launchPane(home, join(home, 'zsh_history')) - - expect(features).toEqual(['history']) - expect(launch.env.ZDOTDIR).toBeTruthy() - expect(launch.env.ORCA_SHELL_FEATURES).toBe('history') - }) - ) - itWithClobber( 'keeps the injected path that the system zshrc would otherwise clobber', withHome(USER_FILES, async (home) => { @@ -100,262 +89,5 @@ describe.skipIf(process.platform === 'win32')( expect(values.HISTFILE).toBe(scoped) }) ) - - itWithZsh( - 'hands ZDOTDIR back before the user’s own startup files load', - withHome(USER_FILES, async (home) => { - const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) - - const { values } = await runZshPty({ - env, - report: [ - 'ZDOTDIR', - 'ORCA_TEST_USER_ZSHENV', - 'ORCA_TEST_USER_ZPROFILE', - 'ORCA_TEST_USER_ZSHRC' - ] - }) - - expect(values.ORCA_TEST_USER_ZSHENV).toBe('1') - expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') - expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') - expect(values.ZDOTDIR).toBe(home) - expect(values.ZDOTDIR).not.toBe(launch.env.ZDOTDIR) - }) - ) - - itWithZsh( - 'never leaves history inside Orca’s own wrapper directory', - withHome(USER_FILES, async (home) => { - const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) - - const { values } = await runZshPty({ env, report: ['HISTFILE'] }) - - expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) - }) - ) - - itWithZsh( - 'consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', - withHome(USER_FILES, async (home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { env } = launchPane(home, scoped) - - const { values } = await runZshPty({ - env, - report: ['ORCA_HISTFILE', 'ORCA_SHELL_FEATURES', 'HISTFILE'] - }) - - expect(values.ORCA_HISTFILE).toBe('UNSET') - expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') - expect(values.HISTFILE).toBe(scoped) - }) - ) - - itWithZsh( - 'leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', - withHome(USER_FILES, async (home) => { - // Defaults vary by host; compare with its unwrapped shell rather than a hardcoded HISTFILE. - const overlayEnv = { ORCA_CODEX_HOME: join(home, 'codex') } - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env: { HOME: home, ...overlayEnv }, - hasStartupCommand: false, - waitsForShellReady: false, - emitsStartupIdentity: false - }) - const launch = getShellLaunchConfig(ZSH_PATH, features) - - const wrapped = await runZshPty({ - env: { - PATH: '/usr/bin:/bin', - HOME: home, - ...overlayEnv, - ...launch.env, - ORCA_ORIG_ZDOTDIR: home - }, - report: ['HISTFILE'] - }) - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home }, - report: ['HISTFILE'] - }) - - expect(wrapped.values.HISTFILE).toBe(unwrapped.values.HISTFILE) - }) - ) } ) - -describe.skipIf(process.platform === 'win32')('the deferred hook delivers every feature', () => { - itWithZsh( - 'emits the identity, readiness and OSC 133 markers a startup command waits on', - withHome(USER_FILES, async (home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { env, features } = launchPane(home, scoped, { - hasStartupCommand: true, - waitsForShellReady: true, - emitsStartupIdentity: true - }) - expect(features).toEqual(expect.arrayContaining(['markers', 'ready', 'identity'])) - - const { output, values } = await runZshPty({ - env, - commands: ['true'], - report: ['HISTFILE'] - }) - - expect(output).toMatch(MARKERS.identity) - expect(output).toContain(MARKERS.ready) - expect(output).toContain(MARKERS.promptStart) - expect(output).toContain(MARKERS.commandStart) - expect(output).toMatch(MARKERS.commandDone) - expect(values.HISTFILE).toBe(scoped) - }) - ) - - itWithZsh( - 'restores Orca’s overlay values after the user’s config overwrites them', - withHome( - { - ...USER_FILES, - '.zshrc': 'export CODEX_HOME=/user/codex\nexport PATH=/user/bin:$PATH\n' - }, - async (home) => { - const overlayEnv = { - ORCA_CODEX_HOME: '/orca/codex', - ORCA_AGENT_TEAMS_SHIM_DIR: '/orca/shim' - } - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env: { HOME: home, ...overlayEnv }, - hasStartupCommand: false, - waitsForShellReady: false, - emitsStartupIdentity: false - }) - const launch = getShellLaunchConfig(ZSH_PATH, features) - - const { values } = await runZshPty({ - env: { - PATH: '/usr/bin:/bin', - HOME: home, - ...overlayEnv, - ...launch.env, - ORCA_ORIG_ZDOTDIR: home - }, - report: ['CODEX_HOME', 'PATH'] - }) - - expect(values.CODEX_HOME).toBe('/orca/codex') - expect(values.PATH.startsWith('/orca/shim:')).toBe(true) - } - ) - ) -}) - -describe.skipIf(process.platform === 'win32')( - 'the wrapper survives a hostile user zsh config', - () => { - it.each([ - ['.zshenv', 'emulate sh'], - ['.zshenv', 'emulate ksh'], - ['.zprofile', 'emulate sh'], - ['.zshrc', 'emulate sh'], - ['.zshrc', 'setopt no_unset'], - ['.zshrc', 'setopt ksharrays'] - ])('still scopes history when the user %s runs `%s`', async (file, statement) => { - if (!hasZsh) { - return - } - const home = makeZshHome({ - ...USER_FILES, - [file]: `${USER_FILES[file as keyof typeof USER_FILES]}${statement}\n` - }) - try { - const scoped = join(home, 'orca-history', 'zsh_history') - const { env } = launchPane(home, scoped) - - const { values } = await runZshPty({ env, report: ['HISTFILE', 'ORCA_HISTFILE'] }) - - expect(values.HISTFILE).toBe(scoped) - expect(values.ORCA_HISTFILE).toBe('UNSET') - } finally { - rmSync(home, { recursive: true, force: true }) - } - }) - - itWithZsh( - 'still scopes history without leaking it when a config drops precmd_functions', - withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { env, launch } = launchPane(home, scoped) - - const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] - const { values } = await runZshPty({ env, report }) - expect(values.HISTFILE).toBe(scoped) - expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) - expect(values.ORCA_HISTFILE).toBe('UNSET') - expect(values.ZDOTDIR).toBe(home) - }) - ) - - itWithZsh( - 'loads the user config even when a startup file writes an unrelated ZDOTDIR', - withHome( - { ...USER_FILES, '.zshenv': `${USER_FILES['.zshenv']}export ZDOTDIR="$HOME"\n` }, - async (home) => { - const { env } = launchPane(home, join(home, 'orca-history', 'zsh_history')) - - const { values } = await runZshPty({ - env, - report: ['ZDOTDIR', 'ORCA_TEST_USER_ZSHRC'] - }) - - expect(values.ZDOTDIR).toBe(home) - expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') - } - ) - ) - } -) - -/** Relay uses the same builder with different feature flags; exercise that variant in a real shell too. */ - -describe.skipIf(process.platform === 'win32')('the real-zsh harness', () => { - itWithZsh( - 'waits for delayed first output before answering compinit and scoping history', - withHome( - { - '.zshenv': 'sleep 0.6\nfpath=("$HOME/insecure-completions" $fpath)\n', - '.zshrc': - 'if (( ! $+_comps )); then\n' + - ' autoload -Uz compinit\n' + - ' compinit -D\n' + - 'fi\n' + - 'export ORCA_TEST_COMPINIT_READY=$+_comps\n' - }, - async (home) => { - const completions = join(home, 'insecure-completions') - mkdirSync(completions) - chmodSync(completions, 0o777) - writeFileSync( - join(completions, '_orca_compinit_fixture'), - '#compdef orca_compinit_fixture\n' - ) - const scoped = join(home, 'scoped_history') - const { env } = launchPane(home, scoped) - - const { output, values } = await runZshPty({ - env, - report: ['HISTFILE', 'ORCA_TEST_COMPINIT_READY'] - }) - - expect(output).toContain('Ignore insecure directories') - expect(output).not.toContain('initialization aborted') - expect(output).not.toContain('command not found: y') - expect(values.ORCA_TEST_COMPINIT_READY).toBe('1') - expect(values.HISTFILE).toBe(scoped) - } - ) - ) -}) diff --git a/src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts index 80b71f34095..04c2bb67f77 100644 --- a/src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts +++ b/src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -10,214 +10,6 @@ import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty const itWithZsh = hasZsh ? it : it.skip -const REPORTED = ['ZDOTDIR', 'ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'PATH'] as const - -type ConfigCase = { - name: string - /** Builds `$HOME/.zshenv` and any extra files. Returns the dir holding .zshrc. */ - setup: (home: string) => string -} - -const CASES: ConfigCase[] = [ - { - name: 'no ZDOTDIR at all', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=plain\n') - return home - } - }, - { - name: 'ZDOTDIR exported to an XDG dir', - setup: (home) => { - const dir = join(home, '.config', 'zsh') - mkdirSync(dir, { recursive: true }) - writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=xdg\nexport ZDOTDIR="${dir}"\n`) - return dir - } - }, - { - name: 'ZDOTDIR set by a file the .zshenv sources', - setup: (home) => { - const dir = join(home, '.config', 'zsh') - mkdirSync(dir, { recursive: true }) - const common = join(home, '.config', 'shell', 'common.sh') - mkdirSync(dirname(common), { recursive: true }) - writeFileSync(common, `export ZDOTDIR="${dir}"\n`) - writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=sourced\nsource "${common}"\n`) - return dir - } - }, - { - name: 'ZDOTDIR with spaces in the path', - setup: (home) => { - const dir = join(home, 'My Config', 'zsh') - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(home, '.zshenv'), - `export ORCA_TEST_MARK=spaces\nexport ZDOTDIR="${dir}"\n` - ) - return dir - } - }, - { - name: 'ZDOTDIR set more than once', - setup: (home) => { - const first = join(home, 'first') - const dir = join(home, 'second') - mkdirSync(first, { recursive: true }) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(home, '.zshenv'), - `export ORCA_TEST_MARK=twice\nexport ZDOTDIR="${first}"\nexport ZDOTDIR="${dir}"\n` - ) - return dir - } - }, - { - name: 'ZDOTDIR written with a trailing slash', - setup: (home) => { - const dir = join(home, 'trailing') - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(home, '.zshenv'), - `export ORCA_TEST_MARK=trailing\nexport ZDOTDIR="${dir}/"\n` - ) - return dir - } - }, - { - name: 'ZDOTDIR pointing at a directory that does not exist', - setup: (home) => { - writeFileSync( - join(home, '.zshenv'), - `export ORCA_TEST_MARK=missing\nexport ZDOTDIR="${join(home, 'nope')}"\n` - ) - return home - } - }, - { - name: 'ZDOTDIR set to the empty string', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=empty\nexport ZDOTDIR=""\n') - return home - } - }, - { - name: 'ZDOTDIR explicitly set to $HOME', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=home\nexport ZDOTDIR="$HOME"\n') - return home - } - }, - { - name: 'a .zshenv with a syntax error', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=broken\nif [ ; then\n') - return home - } - }, - { - name: 'a .zshenv running set -u before anything else', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'set -u\nexport ORCA_TEST_MARK=nounset\n') - return home - } - }, - { - name: 'a .zshenv running set -e with a failing command', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'set -e\nexport ORCA_TEST_MARK=errexit\nfalse\n') - return home - } - }, - { - name: 'a .zshenv setting extendedglob and nullglob', - setup: (home) => { - writeFileSync( - join(home, '.zshenv'), - 'setopt extendedglob nullglob\nexport ORCA_TEST_MARK=globs\n' - ) - return home - } - }, - { - name: 'a .zshenv that unsets HOME', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=nohome\nunset HOME\n') - return home - } - }, - { - name: 'ZDOTDIR containing only slashes', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=slashes\nexport ZDOTDIR="///"\n') - return home - } - }, - { - name: 'a whitespace-only ZDOTDIR', - setup: (home) => { - writeFileSync( - join(home, '.zshenv'), - 'export ORCA_TEST_MARK=blank\nexport ZDOTDIR="$(printf \'\\t\\n\')"\n' - ) - return home - } - }, - { - name: 'ZDOTDIR with a single quote in the path', - setup: (home) => { - const dir = join(home, "it's zsh") - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(home, '.zshenv'), - `export ORCA_TEST_MARK=quote\nexport ZDOTDIR=${JSON.stringify(dir)}\n` - ) - return dir - } - }, - { - name: 'a .zshenv that conditionally unsets ZDOTDIR', - setup: (home) => { - writeFileSync( - join(home, '.zshenv'), - 'export ORCA_TEST_MARK=conditional\nexport ZDOTDIR="$HOME/x"\nunset ZDOTDIR\n' - ) - return home - } - }, - { - name: 'a .zshenv using typeset -U path at top level', - setup: (home) => { - // A top-level-only path declaration proves .zshenv was not sourced inside a function or subshell. - writeFileSync( - join(home, '.zshenv'), - 'typeset -U path\npath=(/usr/bin /bin /usr/bin)\nexport ORCA_TEST_MARK=uniqpath\n' - ) - return home - } - }, - { - name: 'a .zshenv defining a function and extending fpath', - setup: (home) => { - const fns = join(home, 'fns') - mkdirSync(fns, { recursive: true }) - writeFileSync( - join(home, '.zshenv'), - `fpath=(${JSON.stringify(fns)} $fpath)\norca_test_fn() { : }\nexport ORCA_TEST_MARK=fnscope\n` - ) - return home - } - }, - { - name: 'a .zshenv that calls exit', - setup: (home) => { - writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=exiting\nexit 0\n') - return home - } - } -] - function wrappedEnv(home: string): Record { const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, @@ -237,42 +29,6 @@ function wrappedEnv(home: string): Record { } } -describe.skipIf(process.platform === 'win32')( - 'a wrapped pane resolves the user config exactly as an unwrapped one does', - () => { - it.each(CASES.map((testCase) => [testCase.name, testCase] as const))( - 'matches unwrapped zsh for %s', - async (_name, testCase) => { - if (!hasZsh) { - return - } - const home = makeZshHome({}) - try { - const zshrcDir = testCase.setup(home) - mkdirSync(zshrcDir, { recursive: true }) - writeFileSync(join(zshrcDir, '.zshrc'), 'export ORCA_TEST_FROM_ZSHRC=1\n') - - const wrapped = await runZshPty({ env: wrappedEnv(home), report: REPORTED }) - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home }, - report: REPORTED - }) - - expect(wrapped.exitedBeforePrompt).toBe(unwrapped.exitedBeforePrompt) - for (const key of REPORTED) { - expect( - wrapped.values[key], - `${key} differs between a wrapped and an unwrapped pane` - ).toBe(unwrapped.values[key]) - } - } finally { - rmSync(home, { recursive: true, force: true }) - } - } - ) - } -) - describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was built for', () => { let userDataPath = '' let previousUserDataPath: string | undefined @@ -347,26 +103,6 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui } }) - itWithZsh('gives the user’s startup files their own ZDOTDIR while they run (#4667)', async () => { - const home = makeZshHome({}) - const xdg = join(home, '.config', 'zsh') - mkdirSync(xdg, { recursive: true }) - writeFileSync(join(home, '.zshenv'), `export ZDOTDIR=${JSON.stringify(xdg)}\n`) - writeFileSync(join(xdg, '.zshrc'), 'export ORCA_TEST_IN_ZSHRC="$ZDOTDIR"\n') - writeFileSync(join(xdg, '.zprofile'), 'export ORCA_TEST_IN_ZPROFILE="$ZDOTDIR"\n') - try { - const report = ['ORCA_TEST_IN_ZSHRC', 'ORCA_TEST_IN_ZPROFILE'] - const wrapped = await runZshPty({ env: wrappedEnv(home), report }) - const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) - - expect(wrapped.values.ORCA_TEST_IN_ZSHRC).toBe(xdg) - expect(wrapped.values.ORCA_TEST_IN_ZPROFILE).toBe(xdg) - expect(wrapped.values).toEqual(unwrapped.values) - } finally { - rmSync(home, { recursive: true, force: true }) - } - }) - itWithZsh('refuses an inherited ZDOTDIR that is an Orca wrapper dir (#15258)', async () => { // Older parents can supply stale ORCA_ORIG_ZDOTDIR without passing through Node's ownership check. const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) @@ -387,40 +123,4 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui rmSync(home, { recursive: true, force: true }) } }) - - itWithZsh('leaves a nested Orca nothing of its own to inherit (#11044, #11146)', async () => { - const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) - try { - const { values } = await runZshPty({ - env: wrappedEnv(home), - commands: [ - 'ORCA_CHILD_ENV="$(env | grep -cE \'^(ORCA_SHELL_FEATURES|ORCA_HISTFILE)=\' || true)"', - 'ORCA_CHILD_ZDOTDIR="$(env | sed -n \'s/^ZDOTDIR=//p\')"' - ], - report: ['ORCA_CHILD_ENV', 'ORCA_CHILD_ZDOTDIR'] - }) - - expect(values.ORCA_CHILD_ENV).toBe('0') - expect(values.ORCA_CHILD_ZDOTDIR).toBe('UNSET') - } finally { - rmSync(home, { recursive: true, force: true }) - } - }) - - itWithZsh('survives a user .zshenv that returns early (#1947)', async () => { - const home = makeZshHome({ - '.zshenv': 'export ORCA_TEST_MARK=early\nreturn 0\nexport ORCA_TEST_MARK=late\n', - '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' - }) - try { - const report = ['ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'ZDOTDIR'] - const wrapped = await runZshPty({ env: wrappedEnv(home), report }) - const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) - - expect(wrapped.values).toEqual(unwrapped.values) - expect(wrapped.values.ORCA_TEST_FROM_ZSHRC).toBe('1') - } finally { - rmSync(home, { recursive: true, force: true }) - } - }) }) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index fbd6fe6753c..8363b723d91 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -3,7 +3,6 @@ import { fetchAntigravityRateLimits, resetAntigravityUsageSupportForTests } from './antigravity-usage-fetcher' -import { ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_VERSION_ARGS } from './antigravity-usage-command' import type { ProcessResult } from '@orca/process-host/process-spec' const { hostShellEnvironmentMock } = vi.hoisted(() => ({ @@ -13,8 +12,7 @@ const { hostShellEnvironmentMock } = vi.hoisted(() => ({ vi.mock('../runtime/agent-session/environment/structured-agent-shell-environment', () => ({ hostShellEnvironment: hostShellEnvironmentMock })) - -const USAGE_ENVELOPE = JSON.stringify({ +void JSON.stringify({ conversation_id: '', status: 'SUCCESS', command: { @@ -85,227 +83,6 @@ function harness( } } -describe('fetchAntigravityRateLimits', () => { - beforeEach(() => { - resetAntigravityUsageSupportForTests() - }) - - it('publishes the CLI reading as Antigravity usage', async () => { - const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch() - - expect(result.status).toBe('ok') - expect(result.provider).toBe('antigravity') - expect(result.error).toBeNull() - expect(result.weekly).toMatchObject({ usedPercent: 60, windowMinutes: 10_080 }) - expect(result.buckets).toEqual([ - { - name: 'Gemini Models', - group: 'Gemini Models', - windowName: 'Weekly Limit Remaining', - usedPercent: 60, - windowMinutes: 10_080, - resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), - resetDescription: null - } - ]) - expect(result.usageMetadata).toMatchObject({ - source: 'cli', - credentialSource: 'antigravity-cli' - }) - }) - - it('never publishes the agy bucket id to the renderer', async () => { - const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch() - - for (const bucket of result.buckets ?? []) { - expect(bucket).not.toHaveProperty('id') - } - }) - - it('probes the version, then runs the quota read with the resolved path and login-shell env', async () => { - const loginShellEnv = { PATH: '/Users/x/.local/bin:/usr/bin' } - const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }), env: loginShellEnv }) - await h.fetch() - - // Login PATH finds ~/.local/bin; disabling updates prevents Windows console flashes. - const probeEnv = { PATH: '/Users/x/.local/bin:/usr/bin', AGY_CLI_DISABLE_AUTO_UPDATE: 'true' } - expect(h.runCommand).toHaveBeenCalledTimes(2) - const [versionSpec, usageSpec] = h.runCommand.mock.calls.map(([spec]) => spec) - expect(versionSpec.args).toEqual(ANTIGRAVITY_VERSION_ARGS) - expect(versionSpec.env).toEqual(probeEnv) - expect(usageSpec.program).toBe('/Users/x/.local/bin/agy') - expect(usageSpec.args).toEqual(ANTIGRAVITY_USAGE_ARGS) - expect(usageSpec.env).toEqual(probeEnv) - expect(usageSpec.timeoutMs).toBeGreaterThan(0) - expect(h.resolveCommand).toHaveBeenCalledWith('agy', { platform: 'darwin', env: probeEnv }) - // The login-shell env is a shared cache entry; the probe must not write into it. - expect(loginShellEnv).toEqual({ PATH: '/Users/x/.local/bin:/usr/bin' }) - }) - - it('looks agy up in the env agent launches use, so a Git Bash login PATH cannot hide it', async () => { - // Why: Git Bash's login PATH is /c/...:/usr/bin, which win32 lookup cannot read (#31459). - const launchEnv = { PATH: '/c/Users/x/bin:/usr/bin;C:\\Users\\x\\AppData\\Local\\agy\\bin' } - hostShellEnvironmentMock.mockResolvedValueOnce(launchEnv) - const resolveCommand = vi.fn().mockResolvedValue(null) - await fetchAntigravityRateLimits({ resolveCommand, platform: 'win32' }) - - expect(resolveCommand).toHaveBeenCalledWith('agy', { - platform: 'win32', - env: { ...launchEnv, AGY_CLI_DISABLE_AUTO_UPDATE: 'true' } - }) - }) - - it('reports an absent CLI as unavailable and never spawns', async () => { - const h = harness({ program: null }) - const result = await h.fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('cli-unavailable') - expect(result.error).toContain('was not found on this machine') - expect(h.runCommand).not.toHaveBeenCalled() - }) - - it('reports a signed-out account as unavailable, not as a failed refresh', async () => { - const result = await harness({ - // agy may exit zero for signed-out diagnostics. - result: processResult({ stderr: 'You are not logged into Antigravity.' }) - }).fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('missing-credentials') - expect(result.error).toContain('Sign in with `agy`') - }) - - it('reports an auth failure on stderr as sign-in guidance, not as a broken read', async () => { - // Nonzero auth failures still need sign-in guidance. - const result = await harness({ - result: processResult({ code: 1, stderr: 'Not authenticated. Run agy login.' }) - }).fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('missing-credentials') - expect(result.error).toContain('Sign in with `agy`') - }) - - it('reports a bare "Not authenticated." failure on stderr as sign-in guidance', async () => { - const result = await harness({ - result: processResult({ code: 1, stderr: 'Not authenticated.\n' }) - }).fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('missing-credentials') - expect(result.error).toContain('Sign in with `agy`') - }) - - it('reports a login with no quota groups as unavailable, not a failed refresh', async () => { - const stdout = JSON.stringify({ - status: 'SUCCESS', - num_turns: 0, - command: { name: 'usage', data: { groups: [] } } - }) - const result = await harness({ result: processResult({ stdout }) }).fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') - expect(result.error).toContain('no quota for this account') - }) - - it('does not classify non-auth stderr as signed-out on non-zero exit', async () => { - // Incidental auth-like words must not trigger credential guidance. - const result = await harness({ - result: processResult({ - code: 1, - stderr: 'Failed to contact https://oauth2.googleapis.com/token: connection refused' - }) - }).fetch() - - expect(result.status).toBe('error') - expect(result.usageMetadata?.failureKind).toBe('parse') - expect(result.error).toContain('did not report a quota (exit 1)') - }) - - it('reports a timeout as its own failure kind', async () => { - const result = await harness({ result: processResult({ timedOut: true }) }).fetch() - - expect(result.status).toBe('error') - expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') - expect(result.error).toContain('did not answer in time') - }) - - it('holds automated reads after agy\u2019s browser sign-in', async () => { - // Why pinned: agy 1.4.2 opens this OAuth page and waits 60 s, so Orca's 30 s kill lands first (#26484). - const result = await harness({ - result: processResult({ - timedOut: true, - stderr: - 'Authentication required. Please visit the URL to log in:\n https://accounts.google.com/o/oauth2/auth?...\n\nWaiting for authentication (timeout 60s)...\n' - }) - }).fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('missing-credentials') - expect(result.usageMetadata?.retryAtMs).toBe(Number.MAX_SAFE_INTEGER) - }) - - it('keeps the reading when the browser sign-in completes within the read', async () => { - const result = await harness({ - result: processResult({ - stdout: USAGE_ENVELOPE, - stderr: 'Authentication required. Please visit the URL to log in:\n' - }) - }).fetch() - - expect(result.status).toBe('ok') - expect(result.usageMetadata?.retryAtMs).toBeUndefined() - }) - - it('does not cool down a signed-out read that fails fast without a browser', async () => { - const result = await harness({ - result: processResult({ - code: 1, - stderr: "Error: authentication required. Run 'agy' to log in, then retry.\n" - }) - }).fetch() - - expect(result.usageMetadata?.failureKind).toBe('missing-credentials') - expect(result.usageMetadata?.retryAtMs).toBeUndefined() - }) - - it('reports an unreadable payload as a parse failure carrying the exit code', async () => { - const result = await harness({ - result: processResult({ code: 2, stdout: 'unknown command /usage' }) - }).fetch() - - expect(result.status).toBe('error') - expect(result.usageMetadata?.failureKind).toBe('parse') - expect(result.error).toContain('exit 2') - }) - - it('does not blame the exit code when agy exited cleanly with no payload', async () => { - const result = await harness({ result: processResult({ code: 0, stdout: '' }) }).fetch() - - expect(result.status).toBe('error') - expect(result.error).not.toContain('exit') - }) - - it('reports a spawn failure instead of rejecting the cycle', async () => { - const runCommand = vi.fn().mockRejectedValue(new Error('EACCES')) - const result = await harness({ runCommand }).fetch() - - expect(result.status).toBe('error') - expect(result.usageMetadata?.failureKind).toBe('cli-unavailable') - expect(result.error).toContain('EACCES') - }) - - it('never reports quota from a successful read as stale session data', async () => { - const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch() - - // This tier meters weekly pools, not the Gemini mirror's 60-minute per-model window (#22511). - expect(result.session).toBeNull() - expect(result.weekly).not.toBeNull() - }) -}) - /** Captured billed-turn response when /usage was treated as a prompt; must latch future probes off. */ const MODEL_TURN_ENVELOPE = JSON.stringify({ conversation_id: '28a5ca91-301f-4050-8efc-9c82c4e64df3', @@ -320,16 +97,6 @@ describe('agy versions that answer /usage as a prompt', () => { resetAntigravityUsageSupportForTests() }) - it('reports the quota read as unavailable instead of as a parse failure', async () => { - const result = await harness({ - result: processResult({ stdout: MODEL_TURN_ENVELOPE }) - }).fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') - expect(result.error).toContain('answers `/usage` as a prompt') - }) - it('never spawns agy again once a turn was spent', async () => { const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) }) await h.fetch() @@ -340,198 +107,4 @@ describe('agy versions that answer /usage as a prompt', () => { await h.fetch() expect(h.runCommand).toHaveBeenCalledTimes(2) }) - - it('does not latch when the usage payload parsed, whatever else the envelope says', async () => { - const h = harness({ - result: processResult({ stdout: `${USAGE_ENVELOPE}\n${MODEL_TURN_ENVELOPE}` }) - }) - const first = await h.fetch() - const second = await h.fetch() - - expect(first.status).toBe('ok') - expect(second.status).toBe('ok') - expect(h.runCommand).toHaveBeenCalledTimes(4) - }) - - it('does not latch on an empty or unparsable answer', async () => { - const h = harness({ result: processResult({ code: 2, stdout: 'unknown flag' }) }) - const first = await h.fetch() - const second = await h.fetch() - - expect(first.status).toBe('error') - expect(second.status).toBe('error') - expect(h.runCommand).toHaveBeenCalledTimes(4) - }) -}) - -describe('the agy version gate', () => { - beforeEach(() => { - resetAntigravityUsageSupportForTests() - }) - - it('never runs the quota read below the floor', async () => { - const h = harness({ - versionResult: processResult({ stdout: 'agy version 1.1.10\n' }), - result: processResult({ stdout: USAGE_ENVELOPE }) - }) - const result = await h.fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') - expect(result.error).toContain('needs agy 1.1.11 or newer (found 1.1.10)') - // Below the version floor /usage spends a model turn; never run it. - expect(h.runCommand).toHaveBeenCalledTimes(1) - expect(h.runCommand.mock.calls[0]![0].args).toEqual(ANTIGRAVITY_VERSION_ARGS) - }) - - it('holds a prerelease below the floor', async () => { - // Release candidates below the stable floor must not reach a billable read. - const h = harness({ versionResult: processResult({ stdout: 'agy version 1.1.11-rc.1\n' }) }) - const result = await h.fetch() - - expect(result.status).toBe('unavailable') - expect(result.error).toContain('found 1.1.11-rc.1') - expect(h.runCommand).toHaveBeenCalledTimes(1) - }) - - it.each([ - ['a garbled version line', processResult({ stdout: 'agy version unknown\n' })], - ['a failed version probe', processResult({ code: 1, stderr: 'boom' })] - ])('reports %s instead of guessing', async (_label, versionResult) => { - const h = harness({ versionResult }) - const result = await h.fetch() - - expect(result.status).toBe('unavailable') - expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') - expect(result.error).toContain('version could not be read') - expect(h.runCommand).toHaveBeenCalledTimes(1) - }) - - it('runs the quota read at exactly the floor', async () => { - const h = harness({ - versionResult: processResult({ stdout: 'agy version 1.1.11\n' }), - result: processResult({ stdout: USAGE_ENVELOPE }) - }) - const result = await h.fetch() - - expect(result.status).toBe('ok') - expect(h.runCommand).toHaveBeenCalledTimes(2) - }) - - it('reads the version from stderr if stdout is empty on exit 0', async () => { - const h = harness({ - versionResult: processResult({ stdout: '', stderr: 'agy version 1.1.11\n' }), - result: processResult({ stdout: USAGE_ENVELOPE }) - }) - const result = await h.fetch() - - expect(result.status).toBe('ok') - expect(h.runCommand).toHaveBeenCalledTimes(2) - }) -}) - -describe('quota read safety and diagnostic precedence', () => { - beforeEach(() => resetAntigravityUsageSupportForTests()) - - it.each([ - processResult({ stdout: '1.2.14', timedOut: true }), - processResult({ stdout: '1.2.14', signal: 'SIGTERM' }), - processResult({ stdout: '1.2.14', code: 1 }) - ])('does not read quota after an unsuccessful version probe: %j', async (versionResult) => { - const h = harness({ versionResult }) - const result = await h.fetch() - expect(result.status).toBe('unavailable') - expect(h.runCommand).toHaveBeenCalledTimes(1) - }) - - it('accepts a quota reading ahead of unrelated auth diagnostics', async () => { - const result = await harness({ - result: processResult({ - stdout: USAGE_ENVELOPE, - stderr: 'Not authenticated. Run agy login.' - }) - }).fetch() - expect(result.status).toBe('ok') - }) - - it('latches a model turn ahead of authentication guidance', async () => { - const h = harness({ - result: processResult({ stdout: MODEL_TURN_ENVELOPE, stderr: 'not logged into antigravity' }) - }) - expect((await h.fetch()).error).toContain('answers `/usage` as a prompt') - await h.fetch() - expect(h.runCommand).toHaveBeenCalledTimes(2) - }) - - it('prefers a structured quota failure over conflicting sign-in wording', async () => { - const result = await harness({ - result: processResult({ - stderr: 'not logged in\nAGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":429}' - }) - }).fetch() - expect(result.usageMetadata?.failureKind).toBe('rate-limited') - expect(result.error).not.toContain('Sign in') - }) - - it('rechecks the version after an in-session CLI upgrade', async () => { - const runCommand = vi - .fn() - .mockResolvedValueOnce(processResult({ stdout: '1.1.10' })) - .mockResolvedValueOnce(processResult({ stdout: '1.2.14' })) - .mockResolvedValueOnce(processResult({ stdout: USAGE_ENVELOPE })) - const h = harness({ runCommand }) - expect((await h.fetch()).status).toBe('unavailable') - expect((await h.fetch()).status).toBe('ok') - expect(runCommand).toHaveBeenCalledTimes(3) - }) -}) - -describe("agy's own error envelope", () => { - beforeEach(() => resetAntigravityUsageSupportForTests()) - - const errorEnvelope = (error: string): string => - `${JSON.stringify({ conversation_id: '', status: 'ERROR', response: '', error, num_turns: 0 })}\n` - const LOCATION_ERROR = - 'Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location.' - - it('shows agy’s text instead of the generic parse failure', async () => { - const result = await harness({ - result: processResult({ - code: 1, - stdout: errorEnvelope(LOCATION_ERROR), - stderr: `error: ${LOCATION_ERROR}\n` - }) - }).fetch() - - expect(result.status).toBe('error') - expect(result.usageMetadata?.failureKind).toBe('parse') - expect(result.error).toBe(`Antigravity usage is not available. agy reported: ${LOCATION_ERROR}`) - }) - - it('redacts identities and bounds agy’s text to one line', async () => { - const token = 'ya29.a0AfB_byC1234567890abcdefghijklmnopqrstuvwxyz' - const result = await harness({ - result: processResult({ - code: 1, - stdout: errorEnvelope( - `\u001b[31mDenied\u001b[0m for person@example.com\nAGY_SESSION=shortsecret\nwith ${token} ${'x '.repeat(200)}` - ) - }) - }).fetch() - - const shown = result.error?.replace('Antigravity usage is not available. agy reported: ', '') - expect(shown).toMatch(/^Denied for \[email\] AGY_SESSION=\S+ with ya29\.\[redacted\] x x/) - expect(shown).not.toContain('shortsecret') - expect(Array.from(shown ?? '').length).toBeLessThanOrEqual(160) - expect(shown?.endsWith('…')).toBe(true) - }) - - it('keeps sign-in guidance ahead of agy’s text', async () => { - const result = await harness({ - result: processResult({ code: 1, stdout: errorEnvelope('Not authenticated. Run agy login.') }) - }).fetch() - - expect(result.usageMetadata?.failureKind).toBe('missing-credentials') - expect(result.error).toContain('Sign in with `agy`') - }) }) diff --git a/src/main/rate-limits/antigravity-usage-response.test.ts b/src/main/rate-limits/antigravity-usage-response.test.ts deleted file mode 100644 index ccf83a67252..00000000000 --- a/src/main/rate-limits/antigravity-usage-response.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseAntigravityUsageEnvelope, - parseAntigravityUsageStdout -} from './antigravity-usage-response' - -/** Verbatim agy 1.2.11/macOS ARM64 /usage envelope; this tier exposes weekly quota without a 5h pool. */ -const REAL_AGY_1_2_11_STDOUT = `{"conversation_id":"","status":"SUCCESS","response":"Gemini Models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\nClaude and GPT models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0,"output_tokens":0,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":0},"command":{"name":"usage","data":{"description":"Within each group, models share a weekly limit.","groups":[{"name":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[{"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]},{"name":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[{"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]}]}}}` - -function envelope(groups: unknown, description = 'pool help'): unknown { - return { - status: 'SUCCESS', - command: { name: 'usage', data: { description, groups } } - } -} - -describe('parseAntigravityUsageStdout', () => { - it('reads the real agy 1.2.11 payload as two weekly group pools', () => { - const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT) - - expect(reading).not.toBeNull() - expect(reading?.buckets).toEqual([ - { - id: 'gemini-weekly', - name: 'Gemini Models', - group: 'Gemini Models', - windowName: 'Weekly Limit Remaining', - usedPercent: 0, - windowMinutes: 10_080, - resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), - resetDescription: null - }, - { - id: '3p-weekly', - name: 'Claude and GPT models', - group: 'Claude and GPT models', - windowName: 'Weekly Limit Remaining', - usedPercent: 0, - windowMinutes: 10_080, - resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), - resetDescription: null - } - ]) - }) - - it('reports the weekly window the Gemini mirror always left null', () => { - const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT) - - expect(reading?.weekly).toEqual({ - usedPercent: 0, - windowMinutes: 10_080, - resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), - resetDescription: null - }) - expect(reading?.session).toBeNull() - }) - - it('ignores log noise printed around the envelope', () => { - const reading = parseAntigravityUsageStdout( - `I0926 16:22:51.157090 quota_manager.go:36] doRefreshQuota\n${REAL_AGY_1_2_11_STDOUT}\nBye.` - ) - - expect(reading?.buckets).toHaveLength(2) - }) - - it('returns null for stdout with no envelope at all', () => { - expect(parseAntigravityUsageStdout('You are not logged into Antigravity.')).toBeNull() - expect(parseAntigravityUsageStdout('')).toBeNull() - expect(parseAntigravityUsageStdout('{ not json')).toBeNull() - }) -}) - -describe('parseAntigravityUsageEnvelope', () => { - it('maps a 5h bucket onto the session window and weekly onto the weekly window', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'Gemini Models', - buckets: [ - { - id: 'gemini-5h', - name: '5h Limit Remaining', - window: '5h', - remaining_fraction: 0.25, - reset_time: '2026-09-30T12:00:00Z' - }, - { - id: 'gemini-weekly', - name: 'Weekly Limit Remaining', - window: 'weekly', - remaining_fraction: 0.5, - reset_time: '2026-10-07T00:00:00Z' - } - ] - } - ]) - ) - - expect(reading?.session).toMatchObject({ usedPercent: 75, windowMinutes: 300 }) - expect(reading?.weekly).toMatchObject({ usedPercent: 50, windowMinutes: 10_080 }) - }) - - it('names both windows of one group apart', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'Gemini Models', - buckets: [ - { id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1 }, - { id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 } - ] - } - ]) - ) - - expect(reading?.buckets.map((bucket) => bucket.name)).toEqual([ - 'Gemini Models · 5h', - 'Gemini Models · Weekly' - ]) - }) - - it('fills the structured group and window name the renderer labels pools from', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'Gemini Models', - buckets: [ - { id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 }, - { id: 'gemini-weekly', window: 'weekly', remaining_fraction: 1 } - ] - } - ]) - ) - - expect(reading?.buckets[0]).toMatchObject({ - group: 'Gemini Models', - windowName: '5h Limit Remaining' - }) - expect(reading?.buckets[1]).toMatchObject({ group: 'Gemini Models' }) - expect(reading?.buckets[1]).not.toHaveProperty('windowName') - }) - - it('keeps only the four window fields on the session and weekly summaries', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'Gemini Models', - buckets: [ - { id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 }, - { id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 } - ] - } - ]) - ) - - const window = { usedPercent: 0, resetsAt: null, resetDescription: null } - expect(reading?.session).toEqual({ ...window, windowMinutes: 300 }) - expect(reading?.weekly).toEqual({ ...window, windowMinutes: 10_080 }) - }) - - it('drops a disabled bucket instead of drawing it as unused', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'Gemini Models', - buckets: [ - // #22511: unmetered 5h pool alongside exhausted weekly quota. - { id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1, disabled: true }, - { - id: 'gemini-weekly', - name: 'Weekly', - window: 'weekly', - remaining_fraction: 0, - reset_time: '2026-10-01T00:00:00Z' - } - ] - } - ]) - ) - - expect(reading?.buckets).toHaveLength(1) - expect(reading?.buckets[0]).toMatchObject({ id: 'gemini-weekly', usedPercent: 100 }) - expect(reading?.session).toBeNull() - expect(reading?.weekly?.usedPercent).toBe(100) - expect(reading?.buckets[0]?.name).toBe('Gemini Models') - }) - - it('summarises each window by its most constrained group', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'Gemini Models', - buckets: [{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.9 }] - }, - { - name: 'Claude and GPT models', - buckets: [{ id: '3p-weekly', window: 'weekly', remaining_fraction: 0.1 }] - } - ]) - ) - - // Either exhausted group exhausts this tier. - expect(reading?.weekly?.usedPercent).toBe(90) - }) - - it('keeps an unrecognised window as a named bucket without claiming a duration', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'Gemini Models', - buckets: [ - { - id: 'gemini-monthly', - name: 'Monthly Limit Remaining', - window: 'monthly', - remaining_fraction: 0.4 - } - ] - } - ]) - ) - - expect(reading?.buckets[0]).toMatchObject({ - usedPercent: 60, - windowMinutes: 0, - windowName: 'Monthly Limit Remaining' - }) - expect(reading?.session).toBeNull() - expect(reading?.weekly).toBeNull() - }) - - it('carries agy’s own pool explanation through', () => { - const reading = parseAntigravityUsageEnvelope( - envelope( - [ - { name: 'Gemini Models', buckets: [{ id: 'g', window: 'weekly', remaining_fraction: 1 }] } - ], - 'Quota is consumed proportionally to the cost of the tokens.' - ) - ) - - expect(reading?.description).toBe('Quota is consumed proportionally to the cost of the tokens.') - }) - - it('clamps a fraction outside 0..1', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'G', - buckets: [ - { id: 'a', window: 'weekly', remaining_fraction: 1.4 }, - { id: 'b', window: '5h', remaining_fraction: -0.2 } - ] - } - ]) - ) - - expect(reading?.buckets.map((bucket) => bucket.usedPercent)).toEqual([0, 100]) - }) - - it('reads a missing or unparsable reset time as unknown', () => { - const reading = parseAntigravityUsageEnvelope( - envelope([ - { - name: 'G', - buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, reset_time: 'soon' }] - } - ]) - ) - - expect(reading?.buckets[0]?.resetsAt).toBeNull() - }) - - it.each([ - ['a non-SUCCESS status', { status: 'ERROR', command: { name: 'usage', data: { groups: [] } } }], - ['another command’s payload', { status: 'SUCCESS', command: { name: 'models', data: {} } }], - ['a missing command', { status: 'SUCCESS' }], - ['no groups', envelope(undefined)], - ['a group with no usable bucket', envelope([{ name: 'G', buckets: [{ id: 'a' }] }])], - [ - 'a group with every bucket disabled', - envelope([ - { - name: 'G', - buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, disabled: true }] - } - ]) - ], - ['a non-object', 'nope'], - ['null', null] - ])('returns null for %s', (_label, value) => { - expect(parseAntigravityUsageEnvelope(value)).toBeNull() - }) -}) diff --git a/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts b/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts deleted file mode 100644 index 17a4e6851d4..00000000000 --- a/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { setMainHttpClient } from '../network/http-client' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' - -const fetchMock = vi.fn() - -vi.mock('../network/proxy-settings', () => ({ - ensureElectronProxyFromEnvironment: vi.fn(async () => {}) -})) - -setMainHttpClient({ fetch: fetchMock, proxySession: () => null }) - -async function fetchUsageResponse(body: string) { - fetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) - return fetchClaudeOAuthUsage('oauth-token') -} - -describe('Claude OAuth usage extra usage', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('maps the usage-credits spend object into a capped balance in major units', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 10 }, - spend: { - used: { amount_minor: 5000, currency: 'EUR', exponent: 2 }, - limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, - percent: 2.5, - enabled: true, - balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 } - } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - extraUsage: { - balance: 10, - spent: 50, - spendLimit: 2000, - spentPercent: 2.5, - currencyCode: 'EUR', - enabled: true, - disabledReason: null, - resetsAt: null - } - }) - }) - - it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 10 }, - spend: { - used: { amount_minor: 0, currency: 'EUR', exponent: 2 }, - limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, - percent: 0, - enabled: false, - disabled_reason: 'out_of_credits', - balance: null - } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - extraUsage: { - balance: null, - spent: 0, - spendLimit: 2000, - spentPercent: 0, - currencyCode: 'EUR', - enabled: false, - disabledReason: 'out_of_credits' - } - }) - }) - - it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 10 }, - extra_usage: { - is_enabled: true, - monthly_limit: 200000, - used_credits: 5000, - utilization: 2.5, - currency: 'EUR', - decimal_places: 2 - } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - extraUsage: { - balance: null, - spent: 50, - spendLimit: 2000, - spentPercent: 2.5, - currencyCode: 'EUR', - enabled: true - } - }) - }) - it('keeps a missing balance unknown while preserving a known cap', async () => { - const result = await fetchUsageResponse( - '{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ - balance: null, - spendLimit: 2000, - currencyCode: 'EUR' - }) - }) - - it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => { - const result = await fetchUsageResponse( - JSON.stringify({ - five_hour: { utilization: 10 }, - spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true } - }) - ) - expect(result.extraUsage).toMatchObject({ - balance: amount / 100, - currencyCode: 'EUR', - spendLimit: null - }) - }) - - it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => { - const result = await fetchUsageResponse( - '{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ - balance: null, - spent: 50, - spendLimit: 2000, - spentPercent: 2.5 - }) - }) - - it('keeps nullable legacy spend unknown despite a supplied percentage', async () => { - const result = await fetchUsageResponse( - '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ - balance: null, - spent: null, - spendLimit: 2000, - spentPercent: 10 - }) - }) - - it('derives legacy percentage when the response percentage overflows', async () => { - const result = await fetchUsageResponse( - '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ - balance: null, - spent: 50, - spendLimit: 2000, - spentPercent: 2.5 - }) - }) - - it('does not turn an overflowing legacy spend into a known amount', async () => { - const result = await fetchUsageResponse( - '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ - balance: null, - spent: null, - spendLimit: 2000, - spentPercent: null - }) - }) - - it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => { - const result = await fetchUsageResponse( - `{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}` - ) - expect(result.extraUsage ?? null).toBeNull() - }) - - it.each([-1, 0.5, 309])( - 'omits malformed legacy precision %s rather than displaying zero', - async (exponent) => { - const result = await fetchUsageResponse( - JSON.stringify({ - five_hour: { utilization: 10 }, - extra_usage: { - monthly_limit: 200000, - used_credits: 5000, - decimal_places: exponent, - currency: 'EUR', - is_enabled: true - } - }) - ) - expect(result.extraUsage ?? null).toBeNull() - } - ) - - it('shows the spend cap as the monthly window when the plan has no 5h or 7d window', async () => { - const result = await fetchUsageResponse( - JSON.stringify({ - five_hour: null, - seven_day: null, - limits: [], - spend: { - used: { amount_minor: 40000, currency: 'USD', exponent: 2 }, - limit: { amount_minor: 50000, currency: 'USD', exponent: 2 }, - percent: 80, - enabled: true - } - }) - ) - expect(result.session).toBeNull() - expect(result.weekly).toBeNull() - expect(result.monthly).toEqual({ - usedPercent: 80, - windowMinutes: 43_200, - resetsAt: null, - resetDescription: null - }) - }) - - it('derives the monthly window from legacy extra_usage when spend is absent', async () => { - const result = await fetchUsageResponse( - '{"five_hour":null,"seven_day":null,"extra_usage":{"monthly_limit":50000,"used_credits":40000,"utilization":80,"currency":"USD","is_enabled":true}}' - ) - expect(result.monthly).toMatchObject({ usedPercent: 80, windowMinutes: 43_200 }) - }) - - it('falls back to the legacy cap when spend carries only a balance', async () => { - const result = await fetchUsageResponse( - JSON.stringify({ - five_hour: null, - seven_day: null, - spend: { balance: { amount_minor: 1000, currency: 'USD' }, enabled: true }, - extra_usage: { - monthly_limit: 50000, - used_credits: 40000, - utilization: 80, - currency: 'USD', - is_enabled: true - } - }) - ) - expect(result.extraUsage).toMatchObject({ balance: 10, spendLimit: null }) - expect(result.monthly).toMatchObject({ usedPercent: 80, windowMinutes: 43_200 }) - }) - - it('derives legacy percentage from the amounts when utilization is a fraction', async () => { - const result = await fetchUsageResponse( - '{"five_hour":null,"seven_day":null,"extra_usage":{"monthly_limit":5000,"used_credits":479,"utilization":0.0958,"currency":"USD","is_enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ spentPercent: expect.closeTo(9.58) }) - expect(result.monthly?.usedPercent).toBeCloseTo(9.58) - }) - - it('keeps spend out of the monthly window when a plan window exists', async () => { - const result = await fetchUsageResponse( - '{"five_hour":{"utilization":10},"seven_day":null,"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"percent":2.5,"enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ spentPercent: 2.5 }) - expect(result.monthly).toBeNull() - }) - - it('does not invent a monthly window from a balance without a cap', async () => { - const result = await fetchUsageResponse( - '{"five_hour":null,"seven_day":null,"spend":{"balance":{"amount_minor":1000,"currency":"EUR"},"enabled":true}}' - ) - expect(result.extraUsage).toMatchObject({ balance: 10, spendLimit: null }) - expect(result.monthly).toBeNull() - }) - - it('classifies only the request: a dead proxy is network, a null 200 body stays unknown', async () => { - fetchMock.mockRejectedValueOnce(new Error('net::ERR_PROXY_CONNECTION_FAILED')) - const transport = await fetchClaudeOAuthUsage('oauth-token').catch((error: unknown) => error) - expect(classifyClaudeOAuthUsageError(transport).failureKind).toBe('network') - - const nullBody = await fetchUsageResponse('null').catch((error: unknown) => error) - expect(classifyClaudeOAuthUsageError(nullBody).failureKind).toBe('unknown') - }) -}) diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts deleted file mode 100644 index 2e70846e646..00000000000 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type * as Keychain from '../claude-accounts/keychain' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -const calls = vi.hoisted(() => ({ keychain: vi.fn(), usage: vi.fn(), keychainService: vi.fn() })) -vi.mock('../macos-keychain/generic-password', () => ({ - readKeychainPassword: calls.keychainService -})) -vi.mock('../claude-accounts/keychain', () => ({ - readActiveClaudeKeychainCredentialsStrict: calls.keychain -})) -const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) -const wslRouter = vi.hoisted(() => ({ accountUsagePreparation: vi.fn() })) -vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ - getClaudeProfileRouter: () => - profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined, - getClaudeWslProfileRouter: () => wslRouter -})) -vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage })) -const runningDistros = vi.hoisted(() => ({ - filter: vi.fn(async (paths: readonly string[], _options?: { requireConfirmed?: boolean }) => [ - ...paths - ]) -})) -// The settled gate asks the running list for the path's distro; `filter` stands in for it. -const settledGate = vi.hoisted(() => ({ - override: vi.fn((): { distro: string; state: 'stopped' } | undefined => undefined) -})) -vi.mock('../wsl/filesystem/wsl-unc-access-gate', () => ({ - blockedWslDistroForPath: async (path: string) => { - const override = settledGate.override() - if (override !== undefined) { - return override - } - return (await runningDistros.filter([path], { requireConfirmed: true })).length > 0 - ? null - : { distro: 'Ubuntu', state: 'stopped' } - } -})) -vi.mock('@orca/process-host', () => ({ - spawnProcess: () => { - throw new Error('Usage must not launch a process') - }, - runProcess: () => { - throw new Error('Usage must not launch a process') - } -})) -import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import { - readClaudeOAuthCredentials, - resolveClaudeOAuthCredentialReadOptions -} from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' -import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' -const roots: string[] = [] -afterEach(() => { - roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) - vi.unstubAllGlobals() - vi.clearAllMocks() -}) -function profile() { - const home = mkdtempSync(join(tmpdir(), 'claude-usage-')) - roots.push(home) - calls.keychain.mockResolvedValue(null) - const options = { - authPreparation: { - configDir: home, - envPatch: { CLAUDE_CONFIG_DIR: home }, - provenance: 'profile:fake' - } - } - return { home, options } -} -function systemDefault() { - const home = mkdtempSync(join(tmpdir(), 'claude-usage-default-')) - roots.push(home) - calls.keychain.mockResolvedValue(null) - return { - home, - options: { - authPreparation: { configDir: home, envPatch: {}, provenance: 'system' } - } - } -} -it('lets the server decide a locally expired token and never refreshes it', async () => { - const f = profile() - const file = join(f.home, '.credentials.json') - writeFileSync( - file, - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-fake', - refreshToken: 'must-not-be-used', - expiresAt: 1, - refreshTokenExpiresAt: 4_102_444_800_000 - } - }) - ) - vi.stubGlobal('fetch', () => { - throw new Error('No refresh endpoint') - }) - calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null)) - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - status: 'error', - error: 'Claude usage updates the next time Claude runs.', - usageMetadata: { failureKind: 'stale-token' } - }) - expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake']) - const system = systemDefault() - writeFileSync( - join(system.home, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { accessToken: 'expired-default', refreshToken: 'unused', expiresAt: 1 } - }) - ) - calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' }) - expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' }) - expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ - error: 'Claude usage updates the next time Claude runs.', - usageMetadata: { failureKind: 'stale-token' } - }) -}) -it.each([ - ['no refresh token', {}, 'Invalid OAuth token.'], - ['an expired refresh token', { refreshToken: 'fake', refreshTokenExpiresAt: 1 }, 'Invalid'], - ['a revoked refresh token', { refreshToken: 'fake' }, 'OAuth token has been revoked.'] -])('asks a login with %s to sign in again instead of waiting for Claude', async (_, oauth, msg) => { - const f = profile() - writeFileSync( - join(f.home, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'expired-fake', expiresAt: 1, ...oauth } }) - ) - calls.usage.mockRejectedValue(new OAuthUsageError(msg, 401, true, null)) - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - status: 'error', - error: 'Sign in again to use this account.', - usageMetadata: { failureKind: 'missing-credentials' } - }) -}) -it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => { - const f = profile() - await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home }) - if (process.platform === 'darwin') { - expect(calls.keychain.mock.calls).toEqual([[f.home]]) - } else { - expect(calls.keychain).not.toHaveBeenCalled() - } -}) -it('distinguishes missing, malformed and inaccessible credential observations', async () => { - const f = profile() - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - usageMetadata: { failureKind: 'missing-credentials' } - }) - writeFileSync(join(f.home, '.credentials.json'), '{') - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - usageMetadata: { failureKind: 'keychain-unavailable' } - }) -}) -it('reports an unreadable selected account before any credential read and requests no hidden recovery', async () => { - const f = profile() - expect( - await fetchActiveClaudeRateLimits({ - authPreparation: { ...f.options.authPreparation, usageError: CLAUDE_PROFILE_MISSING_MESSAGE } - }) - ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }) - expect(calls.keychain).not.toHaveBeenCalled() - writeFileSync( - join(f.home, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) - ) - calls.usage.mockRejectedValue(new Error('network unavailable')) - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error' }) - expect(calls.usage).toHaveBeenCalledTimes(1) -}) - -it('strict Keychain lookup never falls back to the unsuffixed service', async () => { - const f = profile() - calls.keychainService.mockResolvedValue(null) - const actual = await vi.importActual('../claude-accounts/keychain') - await actual.readActiveClaudeKeychainCredentialsStrict(f.home) - expect(calls.keychainService).toHaveBeenCalled() - for (const [service] of calls.keychainService.mock.calls) { - expect(service).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/) - } - calls.keychainService.mockClear() - calls.keychainService.mockRejectedValue(new Error('locked')) - await expect(actual.readActiveClaudeKeychainCredentialsStrict(f.home)).rejects.toThrow('locked') - expect(calls.keychainService).toHaveBeenCalledTimes(1) -}) -it('treats an unreadable credential file as unavailable, not a missing login', async () => { - const f = profile() - mkdirSync(join(f.home, '.credentials.json')) - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - usageMetadata: { failureKind: 'keychain-unavailable' } - }) -}) -it('hides Claude usage for System Default with no Claude login, instead of asking to sign in again', async () => { - const f = systemDefault() - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - status: 'unavailable', - usageMetadata: { failureKind: 'missing-credentials' } - }) - expect(await fetchActiveClaudeRateLimits(profile().options)).toMatchObject({ - status: 'error', - error: 'Sign in again to use this account.' - }) -}) -it("keeps a 429's Retry-After and rate-limit message so polling waits it out", async () => { - const f = profile() - writeFileSync( - join(f.home, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) - ) - const message = 'Claude usage is rate limited right now.' - calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, 3_000_000)) - const before = Date.now() - const limited = await fetchActiveClaudeRateLimits(f.options) - expect(limited).toMatchObject({ - status: 'error', - error: message, - usageMetadata: { failureKind: 'rate-limited' } - }) - expect(limited.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3_000_000) - expect(limited.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3_000_000) - calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, null)) - expect((await fetchActiveClaudeRateLimits(f.options)).usageMetadata?.retryAtMs).toBeUndefined() -}) -it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuffixed one", async () => { - const root = mkdtempSync(join(tmpdir(), 'claude-usage-inherited-')) - roots.push(root) - const inherited = join(root, 'own-claude-config') - vi.stubEnv('CLAUDE_CONFIG_DIR', inherited) - try { - calls.keychain.mockResolvedValue(null) - await readClaudeOAuthCredentials( - resolveClaudeOAuthCredentialReadOptions({ - configDir: inherited, - envPatch: {}, - provenance: 'system' - }) - ) - expect(calls.keychain.mock.calls).toEqual( - process.platform === 'darwin' ? [[inherited], [undefined]] : [] - ) - } finally { - vi.unstubAllEnvs() - } - const managed = profile() - calls.keychain.mockClear() - await fetchActiveClaudeRateLimits(managed.options) - expect(calls.keychain.mock.calls).toEqual(process.platform === 'darwin' ? [[managed.home]] : []) -}) -it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", () => { - vi.stubEnv('CLAUDE_CONFIG_DIR', '') - profileRouter.userConfigDir = '/shell/claude-config' - try { - expect( - resolveClaudeOAuthCredentialReadOptions({ - configDir: '/shell/claude-config', - envPatch: {}, - provenance: 'system' - })?.keychainConfigDir - ).toBe('/shell/claude-config') - } finally { - profileRouter.userConfigDir = undefined - vi.unstubAllEnvs() - } -}) -it('reports a host problem as unavailable usage, never as a signed-out account', async () => { - const f = profile() - expect( - await fetchActiveClaudeRateLimits({ - authPreparation: { - ...f.options.authPreparation, - usageError: 'Could not read the home folder of WSL distro Ubuntu.' - } - }) - ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } }) -}) -it('reads an inactive WSL account where the WSL router says its launches would run', async () => { - const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - const authPreparation = { - configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude', - runtime: 'wsl' as const, - wslDistro: 'Ubuntu', - envPatch: {}, - provenance: 'wsl:Ubuntu:system' - } - wslRouter.accountUsagePreparation.mockResolvedValueOnce(authPreparation) - calls.usage.mockResolvedValueOnce({ five_hour: null, seven_day: null }) - await fetchInactiveClaudeAccountUsage({ - id: 'acct', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' - }) - expect(wslRouter.accountUsagePreparation).toHaveBeenCalledWith('Ubuntu', 'acct') -}) -it('does not read an inactive WSL account a running list still names while it stops (E9)', async () => { - const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - settledGate.override.mockReturnValueOnce({ distro: 'Ubuntu', state: 'stopped' }) - wslRouter.accountUsagePreparation.mockClear() - const result = await fetchInactiveClaudeAccountUsage({ - id: 'acct', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home' - }) - expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) - expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled() -}) -it('does not read an inactive WSL account through a stopped distro', async () => { - const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - runningDistros.filter.mockResolvedValueOnce([]) - wslRouter.accountUsagePreparation.mockClear() - const result = await fetchInactiveClaudeAccountUsage({ - id: 'acct', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home' - }) - expect(runningDistros.filter).toHaveBeenCalledWith(['\\\\wsl.localhost\\Ubuntu\\'], { - requireConfirmed: true - }) - expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) - expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled() - expect(calls.keychain).not.toHaveBeenCalled() -}) diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts deleted file mode 100644 index 4c04c3743a9..00000000000 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { join } from 'node:path' -import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setMainHttpClient } from '../network/http-client' - -const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ - childSpawnMock: vi.fn(), - readFileMock: vi.fn(), - ptySpawnMock: vi.fn() -})) - -// Keep child-process assertions in this suite; worker transport is covered separately. -vi.mock( - '../worker-thread-child-process/worker-thread-child-process', - () => - import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures') -) -vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) -vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) -vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) -vi.mock('./codex-auth-presence', () => ({ - probeCodexAuthPresence: vi.fn(async () => 'present') -})) - -// Mock recovery so its process-table import does not require real execFile. -vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({ - startCodexStateDbBackfillRecoveryInBackground: vi.fn() -})) - -import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' - -describe('Codex backend rate-limit requests', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - vi.stubGlobal('fetch', vi.fn()) - }) - - afterEach(() => { - setMainHttpClient(null) - vi.useRealTimers() - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) - - it('uses the host HTTP client for usage, credit reads and redemption', async () => { - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' } - }) - ) - const request = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - plan_type: 'plus', - rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 3_600 } } - }) - ) - .mockResolvedValueOnce(Response.json({ available_count: 0, credits: [] })) - .mockResolvedValueOnce(Response.json({ code: 'already_redeemed' })) - setMainHttpClient({ fetch: request, proxySession: () => null }) - const deadline = vi.spyOn(AbortSignal, 'timeout') - - await expect( - fetchCodexRateLimits({ codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\.codex' }) - ).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 12 } }) - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath: '/managed/synthetic-home', - idempotencyKey: 'synthetic-request' - }) - ).resolves.toBe('alreadyRedeemed') - - expect(request.mock.calls.map(([url]) => url)).toEqual([ - 'https://chatgpt.com/backend-api/wham/usage', - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume' - ]) - for (const [, init] of request.mock.calls) { - expect(init).toEqual( - expect.objectContaining({ - credentials: 'omit', - headers: expect.objectContaining({ - Authorization: 'Bearer synthetic-token', - 'ChatGPT-Account-Id': 'synthetic-account' - }), - signal: expect.any(AbortSignal) - }) - ) - } - expect(request).toHaveBeenLastCalledWith( - expect.any(String), - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ redeem_request_id: 'synthetic-request' }), - headers: expect.objectContaining({ 'Content-Type': 'application/json' }) - }) - ) - expect(deadline.mock.calls.map(([milliseconds]) => milliseconds)).toEqual([ - 10_000, 10_000, 30_000 - ]) - expect(fetch).not.toHaveBeenCalled() - expect(childSpawnMock).not.toHaveBeenCalled() - }) - - it('does not fall back to global fetch when the configured host transport fails', async () => { - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' } - }) - ) - const failure = new Error('fixture proxy refused the connection') - const request = vi.fn().mockRejectedValue(failure) - setMainHttpClient({ fetch: request, proxySession: () => null }) - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath: '/managed/synthetic-home', - idempotencyKey: 'synthetic-request' - }) - ).rejects.toBe(failure) - expect(request).toHaveBeenCalledOnce() - expect(fetch).not.toHaveBeenCalled() - }) - - it('uses the official backend usage contract without spawning Codex', async () => { - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { access_token: 'access-token', account_id: 'account-id' } - }) - ) - vi.mocked(fetch) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - plan_type: 'plus', - rate_limit: { - primary_window: { - used_percent: 12, - limit_window_seconds: 3_600, - reset_at: 1_800_000_000 - }, - secondary_window: { - used_percent: 34, - limit_window_seconds: 86_400, - reset_at: 1_800_100_000 - } - }, - rate_limit_reset_credits: { available_count: 1 } - }) - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - available_count: 1, - credits: [ - { - status: 'available', - expires_at: '2027-01-15T12:00:00Z', - granted_at: '2027-01-08T12:00:00Z' - } - ] - }) - } as Response) - - await expect( - fetchCodexRateLimits({ - codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' - }) - ).resolves.toMatchObject({ - session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 }, - weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 }, - rateLimitResetCredits: { - availableCount: 1, - nextExpiresAt: Date.parse('2027-01-15T12:00:00Z') - }, - planType: 'plus', - status: 'ok' - }) - - expect(childSpawnMock).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - expect(fetch).toHaveBeenNthCalledWith( - 1, - 'https://chatgpt.com/backend-api/wham/usage', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer access-token' }), - signal: expect.any(AbortSignal) - }) - ) - }) - - it('classifies a sole seven-day backend primary window as weekly', async () => { - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { access_token: 'access-token', account_id: 'account-id' } - }) - ) - vi.mocked(fetch) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - plan_type: 'plus', - rate_limit: { - primary_window: { - used_percent: 37, - limit_window_seconds: 7 * 24 * 60 * 60, - reset_at: 1_800_000_000 - }, - secondary_window: null - } - }) - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ available_count: 0, credits: [] }) - } as Response) - - await expect( - fetchCodexRateLimits({ - codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' - }) - ).resolves.toMatchObject({ - session: null, - weekly: { usedPercent: 37, windowMinutes: 10_080, resetsAt: 1_800_000_000_000 }, - status: 'ok' - }) - - expect(childSpawnMock).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('aborts callers while sharing one stalled backend auth read', async () => { - let resolveRead!: (content: string) => void - readFileMock.mockImplementation( - () => - new Promise((resolve) => { - resolveRead = resolve - }) - ) - const firstController = new AbortController() - const secondController = new AbortController() - const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' - - const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal }) - const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal }) - await vi.advanceTimersByTimeAsync(0) - expect(readFileMock).toHaveBeenCalledTimes(1) - expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8') - - firstController.abort() - secondController.abort() - - await expect(first).resolves.toMatchObject({ - provider: 'codex', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - await expect(second).resolves.toMatchObject({ - provider: 'codex', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(fetch).not.toHaveBeenCalled() - expect(childSpawnMock).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - resolveRead('{}') - await vi.advanceTimersByTimeAsync(0) - }) - - it('applies the backend deadline while auth reading is still pending', async () => { - let resolveRead!: (content: string) => void - readFileMock.mockImplementation( - () => - new Promise((resolve) => { - resolveRead = resolve - }) - ) - const timeoutController = new AbortController() - const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal) - const deadlineError = new Error('backend deadline') - - const result = consumeCodexRateLimitResetCredit({ - codexHomePath: '/managed/deadline-home', - idempotencyKey: 'redeem-timeout' - }) - await vi.advanceTimersByTimeAsync(0) - // User-triggered redemption has the longer deadline. - expect(timeout).toHaveBeenCalledWith(30_000) - expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8') - - timeoutController.abort(deadlineError) - - await expect(result).rejects.toBe(deadlineError) - resolveRead('{}') - await vi.advanceTimersByTimeAsync(0) - }) - - it('consumes a reset credit with the official payload and bounded request signal', async () => { - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { access_token: 'access-token', account_id: 'account-id' } - }) - ) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: async () => ({ code: 'already_redeemed' }) - } as Response) - - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath: '/managed/codex-home', - idempotencyKey: 'redeem-123' - }) - ).resolves.toBe('alreadyRedeemed') - - expect(fetch).toHaveBeenCalledWith( - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ redeem_request_id: 'redeem-123' }), - signal: expect.any(AbortSignal), - headers: expect.objectContaining({ - Authorization: 'Bearer access-token', - 'ChatGPT-Account-Id': 'account-id', - 'Content-Type': 'application/json' - }) - }) - ) - }) - - it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => { - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { access_token: 'access-token', account_id: 'account-id' } - }) - ) - let cancelledBodies = 0 - vi.mocked(fetch).mockResolvedValue( - cancelTrackingResponse(429, () => { - cancelledBodies += 1 - }) - ) - - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath: '/managed/codex-home', - idempotencyKey: 'redeem-429' - }) - ).rejects.toThrow('Codex reset failed: HTTP 429') - expect(cancelledBodies).toBe(1) - }) -}) diff --git a/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts b/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts deleted file mode 100644 index 7a53c6ab1ba..00000000000 --- a/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { EventEmitter } from 'node:events' -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' - -const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ - childSpawnMock: vi.fn(), - readFileMock: vi.fn(), - resolveCodexCommandMock: vi.fn(), - ptySpawnMock: vi.fn() -})) - -// Keep child-process assertions in this suite; worker transport is covered separately. -vi.mock( - '../worker-thread-child-process/worker-thread-child-process', - () => - import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures') -) -vi.mock('node:child_process', () => ({ - spawn: childSpawnMock -})) - -// Mock recovery so its process-table import does not require real execFile. -vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({ - startCodexStateDbBackfillRecoveryInBackground: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('../codex-cli/command', () => ({ - resolveCodexCommand: resolveCodexCommandMock -})) - -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock -})) - -vi.mock('./codex-auth-presence', () => ({ - probeCodexAuthPresence: vi.fn(() => 'present') -})) - -import { fetchCodexRateLimits } from './codex-fetcher' -import { probeCodexAuthPresence } from './codex-auth-presence' - -function makeRpcChild() { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - stdin: EventEmitter & { write: ReturnType; end: ReturnType } - kill: ReturnType - exitCode: number | null - } - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - // Graceful shutdown must wait for EOF/signal to produce exit, as real app-server does. - const exitNow = (): void => { - child.exitCode = 0 - child.emit('exit', 0, null) - } - child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) }) - child.exitCode = null - child.kill = vi.fn(() => { - exitNow() - return true - }) - return child -} - -function respondToRpcRateLimitRead( - rpcChild: ReturnType, - rateLimits: unknown -): void { - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`) - ) - }, 0) - } - }) -} - -describe('fetchCodexRateLimits probe shutdown', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - resolveCodexCommandMock.mockReturnValue('codex') - vi.mocked(probeCodexAuthPresence).mockResolvedValue('present') - readFileMock.mockRejectedValue(new Error('no auth fixture')) - vi.stubGlobal('fetch', vi.fn()) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('gives a cold app-server the full boot budget before any deadline fires', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - // Cold auth startup takes 10–25s; the old 10s cap interrupted refresh. - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 20_000) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: { - rateLimits: { primary: { usedPercent: 3 }, secondary: { usedPercent: 4 } } - } - })}\n` - ) - ) - }, 0) - } - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(19_999) - expect(rpcChild.kill).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(2) - await expect(resultPromise).resolves.toMatchObject({ - status: 'ok', - session: { usedPercent: 3 }, - weekly: { usedPercent: 4 } - }) - }) - - it('arms the read deadline after initialize and drains SIGTERM before SIGKILL', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - const rpcChild = makeRpcChild() - // Force escalation: ignore EOF/SIGTERM, exit only on SIGKILL. - rpcChild.stdin.end = vi.fn() - rpcChild.kill = vi.fn((signal?: string) => { - if (signal === 'SIGKILL') { - rpcChild.exitCode = 0 - rpcChild.emit('exit', 0, null) - } - return true - }) - childSpawnMock.mockReturnValue(rpcChild) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - }) - - try { - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(9_999) - expect(rpcChild.kill).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - expect(rpcChild.stdin.end).toHaveBeenCalledTimes(1) - expect(rpcChild.kill).toHaveBeenCalledWith('SIGTERM') - expect(rpcChild.kill).not.toHaveBeenCalledWith('SIGKILL') - - await vi.advanceTimersByTimeAsync(5_000) - expect(rpcChild.kill).toHaveBeenCalledWith('SIGKILL') - await expect(resultPromise).resolves.toMatchObject({ - status: 'error', - error: 'RPC timeout' - }) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('does not spawn a second probe for the same home while one is still running', async () => { - const firstChild = makeRpcChild() - const secondChild = makeRpcChild() - childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild) - respondToRpcRateLimitRead(secondChild, { - primary: { usedPercent: 3 }, - secondary: { usedPercent: 4 } - }) - - const first = fetchCodexRateLimits({ - codexHomePath: '/managed/home-a' - }) - await vi.advanceTimersByTimeAsync(0) - const second = fetchCodexRateLimits({ - codexHomePath: '/managed/home-a' - }) - await vi.advanceTimersByTimeAsync(0) - expect(childSpawnMock).toHaveBeenCalledTimes(1) - - firstChild.emit('close') - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - expect(childSpawnMock).toHaveBeenCalledTimes(2) - await expect(second).resolves.toMatchObject({ status: 'ok' }) - await first - }) - - it('keeps the home lock through child errors until the failed probe exits', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - const firstChild = makeRpcChild() - firstChild.stdin.end = vi.fn() - firstChild.kill = vi.fn((signal?: string) => { - if (signal === 'SIGKILL') { - firstChild.exitCode = 1 - firstChild.emit('exit', 1, 'SIGKILL') - } - return true - }) - const secondChild = makeRpcChild() - childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild) - respondToRpcRateLimitRead(secondChild, { - primary: { usedPercent: 3 }, - secondary: { usedPercent: 4 } - }) - - try { - const first = fetchCodexRateLimits({ - codexHomePath: '/managed/home-error' - }) - await vi.advanceTimersByTimeAsync(0) - firstChild.emit('error', new Error('stdio failed')) - const second = fetchCodexRateLimits({ - codexHomePath: '/managed/home-error' - }) - await vi.advanceTimersByTimeAsync(0) - - expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM') - expect(childSpawnMock).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(5_000) - expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL') - expect(childSpawnMock).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - await expect(first).resolves.toMatchObject({ status: 'error', error: 'stdio failed' }) - await expect(second).resolves.toMatchObject({ status: 'ok' }) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it.each([ - { failAt: 1, phase: 'initial request' }, - { failAt: 2, phase: 'initialized notification' }, - { failAt: 3, phase: 'rate-limit request' } - ])('keeps the home lock when the $phase stdin write throws', async ({ failAt, phase }) => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - const firstChild = makeRpcChild() - let writeCount = 0 - firstChild.stdin.write.mockImplementation((line: string) => { - writeCount += 1 - if (writeCount === failAt) { - throw new Error(`${phase} stdin failed`) - } - const message = JSON.parse(line) as { id?: number; method?: string } - if (message.method === 'initialize') { - setTimeout(() => { - firstChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`) - ) - }, 0) - } - }) - firstChild.stdin.end = vi.fn() - firstChild.kill = vi.fn((signal?: string) => { - if (signal === 'SIGKILL') { - firstChild.exitCode = 1 - firstChild.emit('exit', 1, 'SIGKILL') - } - return true - }) - const secondChild = makeRpcChild() - childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild) - respondToRpcRateLimitRead(secondChild, { - primary: { usedPercent: 3 }, - secondary: { usedPercent: 4 } - }) - const home = `/managed/home-stdin-error-${failAt}` - - try { - const first = fetchCodexRateLimits({ - codexHomePath: home - }) - await vi.advanceTimersByTimeAsync(0) - expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM') - - const second = fetchCodexRateLimits({ - codexHomePath: home - }) - await vi.advanceTimersByTimeAsync(4_999) - expect(childSpawnMock).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(1) - expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL') - await vi.advanceTimersByTimeAsync(1) - expect(childSpawnMock).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1) - await expect(first).resolves.toMatchObject({ - status: 'error', - error: `${phase} stdin failed` - }) - await expect(second).resolves.toMatchObject({ status: 'ok' }) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('observes asynchronous stdin failures and keeps the lock until the probe exits', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - const firstChild = makeRpcChild() - firstChild.stdin.end = vi.fn() - firstChild.kill = vi.fn((signal?: string) => { - if (signal === 'SIGKILL') { - firstChild.exitCode = 1 - firstChild.emit('exit', 1, 'SIGKILL') - } - return true - }) - const secondChild = makeRpcChild() - childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild) - respondToRpcRateLimitRead(secondChild, { - primary: { usedPercent: 3 }, - secondary: { usedPercent: 4 } - }) - - try { - const first = fetchCodexRateLimits({ - codexHomePath: '/managed/home-async-stdin-error' - }) - await vi.advanceTimersByTimeAsync(0) - firstChild.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) - const second = fetchCodexRateLimits({ - codexHomePath: '/managed/home-async-stdin-error' - }) - - await vi.advanceTimersByTimeAsync(4_999) - expect(childSpawnMock).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1) - expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL') - await vi.advanceTimersByTimeAsync(1) - expect(childSpawnMock).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1) - await expect(first).resolves.toMatchObject({ status: 'error', error: 'write EPIPE' }) - await expect(second).resolves.toMatchObject({ status: 'ok' }) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) -}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts deleted file mode 100644 index 8e2ca129358..00000000000 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ /dev/null @@ -1,818 +0,0 @@ -import { EventEmitter } from 'node:events' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcess from '@orca/process-host' - -const { - childSpawnMock, - readFileMock, - resolveCodexCommandMock, - ptySpawnMock, - isBackfillPendingMock, - startBackfillRecoveryMock, - backfillGaveUpMock -} = vi.hoisted(() => ({ - childSpawnMock: vi.fn(), - readFileMock: vi.fn(), - resolveCodexCommandMock: vi.fn(), - ptySpawnMock: vi.fn(), - isBackfillPendingMock: vi.fn(() => false), - startBackfillRecoveryMock: vi.fn(() => Promise.resolve(null)), - backfillGaveUpMock: vi.fn(() => false) -})) - -// Keep child-process assertions in this suite; worker transport is covered separately. -vi.mock( - '../worker-thread-child-process/worker-thread-child-process', - () => - import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures') -) -vi.mock('node:child_process', () => ({ - spawn: childSpawnMock -})) - -// Observe spawn requests before npm-shim resolution. -vi.mock('@orca/process-host', async (importOriginal) => ({ - ...(await importOriginal()), - spawnProcess: (spec: { program: string; args?: readonly string[] }) => - childSpawnMock(spec.program, spec.args ?? [], spec) -})) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('../codex-cli/command', () => ({ - resolveCodexCommand: resolveCodexCommandMock -})) - -vi.mock('../wsl/execution/wsl-interop-spawn-directory', () => ({ - resolveWslInteropSpawnCwd: () => 'C:\\Windows' -})) -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock -})) - -vi.mock('../codex/history/index/codex-state-db', () => ({ - isCodexStateDbBackfillPending: isBackfillPendingMock -})) - -vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({ - startCodexStateDbBackfillRecoveryInBackground: startBackfillRecoveryMock, - hasCodexStateDbBackfillRecoveryGivenUp: backfillGaveUpMock -})) - -// Assume signed-in for spawn tests; codex-auth-presence.test.ts covers gating. -vi.mock('./codex-auth-presence', () => ({ - probeCodexAuthPresence: vi.fn(() => 'present') -})) - -import { fetchCodexRateLimits } from './codex-fetcher' -import { probeCodexAuthPresence } from './codex-auth-presence' -import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' - -function makeRpcChild() { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - stdin: EventEmitter & { write: ReturnType; end: ReturnType } - kill: ReturnType - exitCode: number | null - } - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - // Graceful shutdown must wait for EOF/signal to produce exit, as real app-server does. - const exitNow = (): void => { - child.exitCode = 0 - child.emit('exit', 0, null) - child.emit('close', 0, null) - } - child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) }) - child.exitCode = null - child.kill = vi.fn(() => { - exitNow() - return true - }) - return child -} - -function respondToRpcRateLimitRead( - rpcChild: ReturnType, - rateLimits: unknown -): void { - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`) - ) - }, 0) - } - }) -} - -function mockBackendUsage(): void { - readFileMock.mockResolvedValue( - JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } }) - ) - // Response bodies are single-use; usage and credit reset need separate fixtures. - vi.mocked(fetch).mockImplementation( - async () => - new Response( - JSON.stringify({ - plan_type: 'plus', - rate_limit: { - primary_window: { used_percent: 7, limit_window_seconds: 5 * 60 * 60 }, - secondary_window: { used_percent: 12, limit_window_seconds: 7 * 24 * 60 * 60 } - }, - rate_limit_reset_credits: { available_count: 0, credits: [] } - }) - ) - ) -} - -describe('fetchCodexRateLimits', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - resolveCodexCommandMock.mockReturnValue('codex') - vi.mocked(probeCodexAuthPresence).mockResolvedValue('present') - readFileMock.mockRejectedValue(new Error('no auth fixture')) - isBackfillPendingMock.mockReturnValue(false) - backfillGaveUpMock.mockReturnValue(false) - vi.stubGlobal('fetch', vi.fn()) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('does not spawn Codex when the user is not signed in', async () => { - vi.mocked(probeCodexAuthPresence).mockResolvedValue('absent') - - await expect(fetchCodexRateLimits()).resolves.toMatchObject({ - provider: 'codex', - session: null, - weekly: null, - status: 'unavailable', - error: 'Codex not signed in' - }) - - expect(childSpawnMock).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('does not let a quota probe steal an incomplete state-DB backfill lease', async () => { - isBackfillPendingMock.mockReturnValue(true) - - await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({ - status: 'error', - error: expect.stringContaining('session index') - }) - expect(startBackfillRecoveryMock).toHaveBeenCalledWith('/managed-home') - expect(childSpawnMock).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('reads HTTP usage while a state-DB backfill is pending', async () => { - isBackfillPendingMock.mockReturnValue(true) - readFileMock.mockResolvedValue( - JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } }) - ) - vi.mocked(fetch).mockResolvedValue( - Response.json({ - plan_type: 'plus', - rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 3_600 } } - }) - ) - - await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({ - status: 'ok', - session: { usedPercent: 12 } - }) - expect(startBackfillRecoveryMock).toHaveBeenCalledWith('/managed-home') - expect(childSpawnMock).not.toHaveBeenCalled() - }) - - it('stops promising a refresh once backfill recovery has given up', async () => { - isBackfillPendingMock.mockReturnValue(true) - backfillGaveUpMock.mockReturnValue(true) - - const result = await fetchCodexRateLimits({ codexHomePath: '/managed-home' }) - - expect(result.error).toContain('could not rebuild') - expect(result.error).toContain('CODEX_HOME') - }) - - it('preserves the aborted result when cancellation lands during the auth check', async () => { - let resolveAuth!: (presence: 'absent') => void - vi.mocked(probeCodexAuthPresence).mockReturnValueOnce( - new Promise<'absent'>((resolve) => { - resolveAuth = resolve - }) - ) - const controller = new AbortController() - - const result = fetchCodexRateLimits({ signal: controller.signal }) - controller.abort() - resolveAuth('absent') - - await expect(result).resolves.toMatchObject({ - provider: 'codex', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(childSpawnMock).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it.each([ - ['timeout', 'Timed out while checking Codex sign-in status'], - ['unavailable', 'Codex sign-in status is unavailable'] - ] as const)( - 'does not report an indeterminate %s probe as signed out', - async (presence, error) => { - vi.mocked(probeCodexAuthPresence).mockResolvedValue(presence) - - await expect(fetchCodexRateLimits()).resolves.toMatchObject({ - provider: 'codex', - status: 'error', - error - }) - expect(childSpawnMock).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - } - ) - - it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - - const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string - expect(spawnCwd).toContain('rate-limit-pty-cwd') - expect(spawnCwd).not.toBe('/') - expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/) - - rpcChild.emit('close') - await resultPromise - }) - - it('kills the RPC child and skips the HTTP fallback when the fetch signal aborts', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - const controller = new AbortController() - - const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) - await vi.advanceTimersByTimeAsync(0) - - controller.abort() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(rpcChild.kill).toHaveBeenCalledTimes(1) - expect(fetch).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('reads usage over HTTP, never through the Codex TUI, when RPC exits before returning usage', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - mockBackendUsage() - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - rpcChild.emit('close') - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - session: { usedPercent: 7, windowMinutes: 300 }, - weekly: { usedPercent: 12, windowMinutes: 10080 }, - status: 'ok', - error: null - }) - expect(fetch).toHaveBeenCalledWith( - 'https://chatgpt.com/backend-api/wham/usage', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer access-token' }) - }) - ) - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('keeps the RPC error when the HTTP fallback has no usage to offer', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - rpcChild.emit('close') - await vi.advanceTimersByTimeAsync(0) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - session: null, - weekly: null, - status: 'error' - }) - expect(rpcChild.stdin.listenerCount('error')).toBe(0) - expect(fetch).not.toHaveBeenCalled() - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('shows the RPC exit reason when the HTTP fallback is rejected', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - mockBackendUsage() - vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 })) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(0) - rpcChild.emit('close', 1, null) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - session: null, - weekly: null, - status: 'error', - error: expect.stringContaining('exit code 1') - }) - expect(fetch).toHaveBeenCalledTimes(1) - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('removes RPC listeners when the app-server timeout settles', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - - const resultPromise = fetchCodexRateLimits() - // No initialize response means only the boot deadline can settle this request. - await vi.advanceTimersByTimeAsync(30_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'codex', - session: null, - weekly: null, - status: 'error', - error: 'RPC timeout' - }) - expect(rpcChild.kill).toHaveBeenCalledTimes(1) - expect(rpcChild.stdout.listenerCount('data')).toBe(0) - expect(rpcChild.stderr.listenerCount('data')).toBe(0) - expect(rpcChild.listenerCount('error')).toBe(0) - expect(rpcChild.listenerCount('close')).toBe(0) - expect(ptySpawnMock).not.toHaveBeenCalled() - }) - - it('normalizes near-canonical Codex RPC windows to fixed display durations', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - respondToRpcRateLimitRead(rpcChild, { - primary: { usedPercent: 0, windowDurationMins: 299 }, - secondary: { usedPercent: 0, windowDurationMins: 10079 } - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - const result = await resultPromise - - expect(result.session?.windowMinutes).toBe(300) - expect(result.weekly?.windowMinutes).toBe(10080) - }) - - it('keeps a weekly-only Codex primary window out of the 5-hour slot', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - respondToRpcRateLimitRead(rpcChild, { - primary: { usedPercent: 22, windowDurationMins: 10080 }, - secondary: null - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - - await expect(resultPromise).resolves.toMatchObject({ - session: null, - weekly: { usedPercent: 22, windowMinutes: 10080 } - }) - }) - - it('fills a restored backend 5-hour window when RPC only reports weekly usage', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - respondToRpcRateLimitRead(rpcChild, { - primary: { usedPercent: 22, windowDurationMins: 10080 }, - secondary: null - }) - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { access_token: 'access-token', account_id: 'account-id' } - }) - ) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - plan_type: 'plus', - rate_limit: { - primary_window: { - used_percent: 7, - limit_window_seconds: 5 * 60 * 60, - reset_at: 1_800_000_000 - }, - secondary_window: { - used_percent: 23, - limit_window_seconds: 7 * 24 * 60 * 60, - reset_at: 1_800_100_000 - } - }, - rate_limit_reset_credits: { - available_count: 1, - credits: [{ status: 'available', expires_at: '2027-01-15T12:00:00Z' }] - } - }) - } as Response) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - - await expect(resultPromise).resolves.toMatchObject({ - session: { usedPercent: 7, windowMinutes: 300, resetsAt: 1_800_000_000_000 }, - weekly: { usedPercent: 23, windowMinutes: 10080, resetsAt: 1_800_100_000_000 }, - status: 'ok' - }) - expect(fetch).toHaveBeenCalledTimes(1) - }) - - it('does not map a duplicate session-duration window into the weekly slot', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - respondToRpcRateLimitRead(rpcChild, { - primary: { usedPercent: 11, windowDurationMins: 300 }, - secondary: { usedPercent: 12, windowDurationMins: 300 } - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - - await expect(resultPromise).resolves.toMatchObject({ - session: { usedPercent: 11, windowMinutes: 300 }, - weekly: null - }) - }) - - it('fills reset-credit count from the backend when the installed app-server omits it', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - readFileMock.mockResolvedValue( - JSON.stringify({ - tokens: { - access_token: 'access-token', - account_id: 'account-id' - } - }) - ) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - available_count: 2, - total_earned_count: 3, - credits: [ - { - status: 'available', - expires_at: '2026-06-25T12:00:00Z', - granted_at: '2026-06-18T12:00:00Z' - }, - { - status: 'available', - expires_at: '2026-06-24T12:00:00Z', - granted_at: '2026-06-17T12:00:00Z' - }, - { - status: 'redeemed', - expires_at: '2026-06-23T12:00:00Z', - granted_at: '2026-06-16T12:00:00Z' - } - ] - }) - } as Response) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: { - rateLimits: { - primary: { usedPercent: 3 }, - secondary: { usedPercent: 4 } - } - } - })}\n` - ) - ) - }, 0) - } - }) - - const resultPromise = fetchCodexRateLimits({ codexHomePath: '/managed/codex-home' }) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - const result = await resultPromise - - expect(result.rateLimitResetCredits).toEqual({ - availableCount: 2, - totalEarnedCount: 3, - nextExpiresAt: Date.parse('2026-06-24T12:00:00Z'), - credits: [ - { - status: 'available', - expiresAt: Date.parse('2026-06-25T12:00:00Z'), - grantedAt: Date.parse('2026-06-18T12:00:00Z') - }, - { - status: 'available', - expiresAt: Date.parse('2026-06-24T12:00:00Z'), - grantedAt: Date.parse('2026-06-17T12:00:00Z') - }, - { - status: 'redeemed', - expiresAt: Date.parse('2026-06-23T12:00:00Z'), - grantedAt: Date.parse('2026-06-16T12:00:00Z') - } - ] - }) - expect(readFileMock).toHaveBeenCalledWith(join('/managed/codex-home', 'auth.json'), 'utf8') - expect(fetch).toHaveBeenCalledWith( - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', - expect.objectContaining({ - signal: expect.any(AbortSignal), - headers: expect.objectContaining({ - Authorization: 'Bearer access-token', - 'ChatGPT-Account-Id': 'account-id', - 'OpenAI-Beta': 'codex-1', - originator: 'Codex Desktop' - }) - }) - ) - }) - - it('uses reset-credit count from newer app-server responses without backend fallback', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: { - rateLimits: { primary: { usedPercent: 5 } }, - rateLimitResetCredits: { - availableCount: 1, - credits: [ - { - status: 'available', - expiresAt: '1719326400', - grantedAt: '1718721600000' - } - ] - } - } - })}\n` - ) - ) - }, 0) - } - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - const result = await resultPromise - - expect(result.rateLimitResetCredits).toEqual({ - availableCount: 1, - nextExpiresAt: 1719326400 * 1000, - credits: [ - { - status: 'available', - expiresAt: 1719326400 * 1000, - grantedAt: 1718721600000 - } - ] - }) - expect(readFileMock).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() - }) - - it('runs rate-limit RPC through WSL when the Codex home is a WSL managed account', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: { rateLimits: { primary: { usedPercent: 11 } } } - })}\n` - ) - ) - }, 0) - } - }) - - try { - const resultPromise = fetchCodexRateLimits({ - codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' - }) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - await resultPromise - - const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] - expect(spawnFile).toBe('wsl.exe') - expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c']) - const shellCommand = spawnArgs.at(-1) as string - expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd') - expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc') - expect(shellCommand).toContain( - 'exec 3<&0\nexec 4>&1\nexec /dev/null\n_orca_wsl_shell=' - ) - expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(shellCommand).toContain( - "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" - ) - expect(shellCommand).toContain( - "exec codex '\\''-c'\\'' '\\''approval_policy=never'\\'' '\\''-c'\\'' '\\''features.plugins=false'\\'' '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''never'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-" - ) - expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) - expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) - expect(shellCommand).not.toContain('_orca_codex') - expect(shellCommand).not.toContain('wsl-codex-path') - expect(spawnOptions).toEqual( - expect.objectContaining({ - cwd: 'C:\\Windows', - env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) - }) - ) - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('routes Windows host Codex homes through the host RPC path', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - const codexCommand = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd' - resolveCodexCommandMock.mockReturnValue(codexCommand) - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: { rateLimits: { primary: { usedPercent: 13 } } } - })}\n` - ) - ) - }, 0) - } - }) - - try { - const resultPromise = fetchCodexRateLimits({ codexHomePath: 'C:\\Users\\alice\\.codex' }) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - await resultPromise - - const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] - // Pre-wrapping in cmd.exe would hide the npm shim from spawnProcess's resolver. - expect(spawnFile).toBe(codexCommand) - expect(spawnArgs).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) - expect(spawnOptions).toEqual( - expect.objectContaining({ - env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' }) - }) - ) - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('does not retry HTTP after RPC fails for a WSL home, since WSL already tried HTTP first', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - readFileMock.mockResolvedValue( - JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } }) - ) - vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 })) - - try { - const resultPromise = fetchCodexRateLimits({ - codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' - }) - await vi.advanceTimersByTimeAsync(0) - rpcChild.emit('close') - - await expect(resultPromise).resolves.toMatchObject({ status: 'error' }) - expect(fetch).toHaveBeenCalledTimes(1) - expect(childSpawnMock).toHaveBeenCalledTimes(1) - expect(ptySpawnMock).not.toHaveBeenCalled() - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) -}) diff --git a/src/main/rate-limits/cursor-auth.test.ts b/src/main/rate-limits/cursor-auth.test.ts deleted file mode 100644 index 687eb84fab8..00000000000 --- a/src/main/rate-limits/cursor-auth.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const keychainState = vi.hoisted<{ token: string | null; error: Error | null }>(() => ({ - token: null, - error: null -})) -const files = vi.hoisted<() => Map>(() => { - const map = new Map() - return () => map -}) -const desktopState = vi.hoisted<{ - result: { status: string; profile?: unknown; error?: string } -}>(() => ({ result: { status: 'missing' } })) - -vi.mock('../macos-keychain/generic-password', () => ({ - readKeychainPassword: async () => { - if (keychainState.error) { - throw keychainState.error - } - return keychainState.token - } -})) - -vi.mock('node:fs', () => ({ - existsSync: (path: string) => files().has(path), - readFileSync: (path: string) => { - const entry = files().get(path) - if (entry instanceof Error) { - throw entry - } - if (entry === undefined) { - throw new Error('ENOENT') - } - return entry - } -})) - -vi.mock('../foreign-sqlite-readers/foreign-sqlite-reader-spawn', () => ({ - readCursorDesktopProfile: async () => desktopState.result -})) - -import { readCursorAuthSession, readCursorCliIdentity } from './cursor-auth' - -type JwtSegment = Record - -function expiredJwt(sub: string): string { - const encode = (value: JwtSegment): string => - Buffer.from(JSON.stringify(value)).toString('base64url') - return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 1_000 })}.signature` -} - -function jwt(sub: string): string { - const encode = (value: JwtSegment): string => - Buffer.from(JSON.stringify(value)).toString('base64url') - return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 2_000_000_000 })}.signature` -} - -const CLI_AUTH = '/cli/auth.json' -const CLI_CONFIG = '/cli/cli-config.json' -const DESKTOP_DB = '/desktop/state.vscdb' -const options = { - cliAuthPath: CLI_AUTH, - cliConfigPath: CLI_CONFIG, - desktopStateDbPath: DESKTOP_DB -} - -const originalPlatform = process.platform - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { value: platform, configurable: true }) -} - -beforeEach(() => { - // Keychain reads are macOS-only; pin the platform rather than inherit the runner. - setPlatform('darwin') - keychainState.token = null - keychainState.error = null - files().clear() - desktopState.result = { status: 'missing' } -}) - -afterEach(() => { - setPlatform(originalPlatform) - vi.restoreAllMocks() -}) - -describe('readCursorAuthSession', () => { - it('prefers the keychain session cursor-agent 2026.06+ writes', async () => { - keychainState.token = jwt('auth0|user_keychain') - files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) - const result = await readCursorAuthSession(options) - expect(result.status).toBe('ok') - expect(result.status === 'ok' && result.session.source).toBe('keychain') - expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_keychain') - }) - - it('names the signed-in account from cli-config.json, which holds no token', async () => { - keychainState.token = jwt('auth0|user_1') - files().set( - CLI_CONFIG, - JSON.stringify({ authInfo: { email: 'dev@example.com', displayName: 'Dev' } }) - ) - const result = await readCursorAuthSession(options) - expect(result.status === 'ok' && result.session.email).toBe('dev@example.com') - expect(result.status === 'ok' && result.session.displayName).toBe('Dev') - }) - - it('falls back to the legacy CLI auth file when the keychain holds nothing', async () => { - files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) - const result = await readCursorAuthSession(options) - expect(result.status === 'ok' && result.session.source).toBe('cli') - }) - - it('falls back to Cursor IDE when no CLI session exists', async () => { - desktopState.result = { - status: 'ok', - profile: { - accessToken: jwt('auth0|user_ide'), - email: 'ide@example.com', - membershipType: 'pro', - subscriptionStatus: 'active' - } - } - const result = await readCursorAuthSession(options) - expect(result.status === 'ok' && result.session.source).toBe('desktop') - expect(result.status === 'ok' && result.session.membershipType).toBe('pro') - }) - - it('does not let a locked keychain hide a readable CLI session', async () => { - keychainState.error = new Error('User interaction is not allowed') - files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) - const result = await readCursorAuthSession(options) - expect(result.status === 'ok' && result.session.source).toBe('cli') - }) - - it('reports the first read failure when no source yields a session', async () => { - keychainState.error = new Error('User interaction is not allowed') - const result = await readCursorAuthSession(options) - expect(result).toEqual({ - status: 'error', - error: 'Unable to read the Cursor login from the macOS Keychain' - }) - }) - - it('reports signed out when nothing is stored anywhere', async () => { - expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) - }) - - it('treats a signed-out CLI auth file as missing, not as an error', async () => { - files().set(CLI_AUTH, JSON.stringify({})) - expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) - }) - - it('keeps the local auth path out of the surfaced error', async () => { - files().set( - CLI_AUTH, - new Error('EACCES: permission denied, open /Users/someone/.cursor/auth.json') - ) - const result = await readCursorAuthSession(options) - expect(result).toEqual({ status: 'error', error: 'Unable to read the Cursor CLI auth file' }) - }) - - it('reports invalid JSON distinctly from an unreadable file', async () => { - files().set(CLI_AUTH, '{ not json') - const result = await readCursorAuthSession(options) - expect(result).toEqual({ status: 'error', error: 'Cursor CLI auth file is invalid' }) - }) - - it('prefers a live desktop session over an expired keychain one', async () => { - // An expired CLI token must not mask a live IDE session. - keychainState.token = expiredJwt('auth0|user_stale') - desktopState.result = { - status: 'ok', - profile: { - accessToken: jwt('auth0|user_ide'), - email: 'ide@example.com', - membershipType: 'pro', - subscriptionStatus: 'active' - } - } - const result = await readCursorAuthSession(options) - expect(result.status === 'ok' && result.session.source).toBe('desktop') - expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_ide') - }) - - it('still returns the expired session when no live one exists anywhere', async () => { - keychainState.token = expiredJwt('auth0|user_stale') - const result = await readCursorAuthSession(options) - expect(result.status === 'ok' && result.session.source).toBe('keychain') - }) - - it('never reads the keychain off macOS and falls through to the CLI file', async () => { - setPlatform('linux') - keychainState.token = jwt('auth0|user_keychain') - files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) - const result = await readCursorAuthSession(options) - expect(result.status === 'ok' && result.session.source).toBe('cli') - }) - - it('reports signed out off macOS when only a keychain session exists', async () => { - setPlatform('win32') - keychainState.token = jwt('auth0|user_keychain') - expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) - }) - - it('skips a stored token that carries no subject', async () => { - keychainState.token = 'not-a-jwt' - expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) - }) -}) - -describe('readCursorCliIdentity', () => { - it('returns empty identity for a config with no authInfo', () => { - files().set(CLI_CONFIG, JSON.stringify({ version: 1 })) - expect(readCursorCliIdentity(CLI_CONFIG)).toEqual({ - email: null, - displayName: null, - membershipType: null, - subscriptionStatus: null - }) - }) - - it('survives a corrupt config file', () => { - files().set(CLI_CONFIG, '{{{') - expect(readCursorCliIdentity(CLI_CONFIG).email).toBeNull() - }) -}) diff --git a/src/main/rate-limits/cursor-current-period-usage.test.ts b/src/main/rate-limits/cursor-current-period-usage.test.ts deleted file mode 100644 index 8d9c310a9be..00000000000 --- a/src/main/rate-limits/cursor-current-period-usage.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setMainHttpClient } from '../network/http-client' -import { fetchCursorRateLimits } from './cursor-fetcher' -import type { CursorAuthReadResult } from './cursor-auth' - -const fetchMock = vi.fn() -const SUMMARY_URL = 'https://cursor.com/api/usage-summary' -const CURRENT_PERIOD_URL = 'https://cursor.com/api/dashboard/get-current-period-usage' -const START = '2026-10-18T06:32:13.504Z' -const END = '2026-10-25T06:32:13.504Z' -const PERCENT = 1.112013719649788 -const CURRENT = { - billingCycleStart: START, - billingCycleEnd: END, - planUsage: { totalPercentUsed: PERCENT, autoPercentUsed: PERCENT, apiPercentUsed: 0 } -} -const SUMMARY = { - membershipType: 'unified_ultra', - billingCycleStart: '2026-10-01T00:00:00.000Z', - billingCycleEnd: '2026-11-01T00:00:00.000Z', - individualUsage: { - plan: { - enabled: true, - used: 0, - limit: 55395, - totalPercentUsed: 0, - autoPercentUsed: 0, - apiPercentUsed: 0 - } - } -} -const AUTH: CursorAuthReadResult = { - status: 'ok', - session: { - token: { raw: 'test-cli-token', subject: 'test-cli-account', expiresAtMs: 4_000_000_000_000 }, - source: 'keychain', - email: null, - displayName: null, - membershipType: null, - subscriptionStatus: null - } -} - -beforeEach(() => { - fetchMock.mockReset() - setMainHttpClient({ fetch: fetchMock, proxySession: () => null }) -}) - -afterEach(() => setMainHttpClient(null)) - -function respond(current: unknown = CURRENT, summary: unknown = SUMMARY): void { - fetchMock.mockImplementation(async (url) => { - if (url === SUMMARY_URL) { - return Response.json(summary) - } - if (url === CURRENT_PERIOD_URL) { - return Response.json(current) - } - throw new Error('Unexpected request') - }) -} - -describe('Cursor Unified Ultra current-period usage', () => { - it('replaces stale zero monthly figures with both current weekly pools using the same credential', async () => { - respond() - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits).toMatchObject({ status: 'ok', planType: 'unified_ultra' }) - expect(limits.buckets).toEqual([ - expect.objectContaining({ - name: 'Cursor Models', - usedPercent: PERCENT, - windowMinutes: 10080, - resetsAt: Date.parse(END) - }), - expect.objectContaining({ - name: 'Other Models', - usedPercent: 0, - windowMinutes: 10080, - resetsAt: Date.parse(END) - }) - ]) - expect(limits.monthly).toMatchObject({ - usedPercent: PERCENT, - windowMinutes: 10080, - resetsAt: Date.parse(END) - }) - expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' }) - expect(fetchMock).toHaveBeenCalledTimes(2) - const summaryRequest = fetchMock.mock.calls.find(([url]) => url === SUMMARY_URL)?.[1] - const currentRequest = fetchMock.mock.calls.find(([url]) => url === CURRENT_PERIOD_URL)?.[1] - expect(currentRequest).toMatchObject({ - method: 'POST', - body: '{}', - redirect: 'manual', - credentials: 'omit', - headers: { - 'Content-Type': 'application/json', - Cookie: 'WorkosCursorSessionToken=test-cli-account%3A%3Atest-cli-token', - Origin: 'https://cursor.com', - Referer: 'https://cursor.com/dashboard' - } - }) - expect(new Headers(summaryRequest?.headers).get('Cookie')).toBe( - new Headers(currentRequest?.headers).get('Cookie') - ) - }) - - it('keeps a genuine zero current period authoritative and preserves independently billed on-demand usage', async () => { - respond( - { ...CURRENT, planUsage: { totalPercentUsed: '0', autoPercentUsed: '0', apiPercentUsed: 0 } }, - { - ...SUMMARY, - individualUsage: { - plan: { ...SUMMARY.individualUsage.plan, totalPercentUsed: 80, autoPercentUsed: 80 }, - onDemand: { enabled: true, used: 25, limit: 100 } - } - } - ) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits.monthly?.usedPercent).toBe(0) - expect(limits.buckets).toEqual([ - expect.objectContaining({ name: 'Cursor Models', usedPercent: 0, resetsAt: Date.parse(END) }), - expect.objectContaining({ name: 'Other Models', usedPercent: 0, resetsAt: Date.parse(END) }), - expect.objectContaining({ - name: 'On-demand', - usedPercent: 25, - resetsAt: Date.parse(SUMMARY.billingCycleEnd) - }) - ]) - }) - - it.each([ - null, - {}, - { ...CURRENT, planUsage: null }, - { ...CURRENT, planUsage: { totalPercentUsed: 'invalid', autoPercentUsed: null } }, - { ...CURRENT, billingCycleStart: 'invalid' }, - { ...CURRENT, billingCycleEnd: START } - ])( - 'retains summary compatibility when the current-period shape is unusable: %j', - async (current) => { - respond(current) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits.status).toBe('ok') - expect(limits.monthly).toMatchObject({ - usedPercent: 0, - resetsAt: Date.parse(SUMMARY.billingCycleEnd) - }) - expect(fetchMock.mock.calls.some(([url]) => url === CURRENT_PERIOD_URL)).toBe(true) - } - ) - - it.each([ - ['HTTP 404', () => new Response(null, { status: 404 })], - ['HTTP 405', () => new Response(null, { status: 405 })], - ['HTTP 501', () => new Response(null, { status: 501 })], - // Cursor answers unknown dashboard routes with its HTML app shell. - [ - '200 HTML', - () => new Response('', { headers: { 'content-type': 'text/html' } }) - ] - ])( - 'falls back to legacy request quota when current-period is unsupported (%s)', - async (_label, currentResponse) => { - fetchMock.mockImplementation(async (url) => { - if (url === SUMMARY_URL) { - return Response.json({ membershipType: 'unified_ultra' }) - } - if (url === CURRENT_PERIOD_URL) { - return currentResponse() - } - return Response.json({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } }) - }) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits.monthly?.usedPercent).toBe(50) - expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - SUMMARY_URL, - CURRENT_PERIOD_URL, - 'https://cursor.com/api/usage?user=test-cli-account' - ]) - } - ) - - it.each([401, 403, 429, 503])( - 'preserves current-period HTTP %i instead of publishing obsolete healthy zeros', - async (status) => { - fetchMock.mockImplementation(async (url) => - url === SUMMARY_URL - ? Response.json(SUMMARY) - : new Response(null, { status, headers: { 'retry-after': '60' } }) - ) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits.status).toBe('error') - expect(limits.monthly).toBeUndefined() - expect(limits.usageMetadata?.authProvenance).toBeTruthy() - expect(limits.usageMetadata?.failureKind).toBe( - status === 401 ? 'stale-token' : status === 429 ? 'rate-limited' : 'server' - ) - if (status === 429) { - expect(limits.usageMetadata?.retryAtMs).toBeGreaterThan(Date.now()) - } - expect(fetchMock).toHaveBeenCalledTimes(2) - } - ) - - it('does not publish obsolete zeros after a failed current-period request', async () => { - fetchMock.mockImplementation(async (url) => { - if (url === SUMMARY_URL) { - return Response.json(SUMMARY) - } - throw new Error('test network failure') - }) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits.status).toBe('error') - expect(limits.usageMetadata?.failureKind).toBe('network') - expect(limits.monthly).toBeUndefined() - }) - - it('keeps an active non-unified plan on the summary without the extra request', async () => { - const plan = { ...SUMMARY.individualUsage.plan, totalPercentUsed: 12, autoPercentUsed: 12 } - respond(CURRENT, { ...SUMMARY, membershipType: 'pro', individualUsage: { plan } }) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits.planType).toBe('pro') - expect(limits.monthly?.resetsAt).toBe(Date.parse(SUMMARY.billingCycleEnd)) - expect(fetchMock).toHaveBeenCalledTimes(1) - }) - - it('keeps an unlimited non-unified plan barless without the extra request', async () => { - respond(CURRENT, { ...SUMMARY, membershipType: 'enterprise', isUnlimited: true }) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits).toMatchObject({ status: 'ok', planType: 'enterprise' }) - expect(limits.monthly).toBeUndefined() - expect(fetchMock).toHaveBeenCalledTimes(1) - }) - - // Weekly plans are not all unified_*: e.g. an enterprise seat moved to weekly Premium. - it('uses a weekly current period behind idle non-unified summary pools', async () => { - const endMs = Date.parse(END) - const weekly = { - ...CURRENT, - billingCycleStart: String(Date.parse(START)), - billingCycleEnd: String(endMs) - } - respond(weekly, { ...SUMMARY, membershipType: 'enterprise' }) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits).toMatchObject({ status: 'ok', planType: 'enterprise' }) - expect(limits.monthly).toMatchObject({ - usedPercent: PERCENT, - windowMinutes: 10080, - resetsAt: endMs - }) - }) - - it.each([ - [ - 'a monthly period', - () => - Response.json({ - ...CURRENT, - billingCycleStart: SUMMARY.billingCycleStart, - billingCycleEnd: SUMMARY.billingCycleEnd, - planUsage: { totalPercentUsed: 9 } - }) - ], - ['HTTP 429', () => new Response(null, { status: 429 })], - ['a network failure', () => Promise.reject(new Error('test network failure'))] - ])( - 'keeps idle non-unified summary pools when current-period returns %s', - async (_label, current) => { - fetchMock.mockImplementation(async (url) => - url === SUMMARY_URL ? Response.json({ ...SUMMARY, membershipType: 'pro' }) : current() - ) - const limits = await fetchCursorRateLimits({ authReadResult: AUTH }) - expect(limits.status).toBe('ok') - expect(limits.monthly).toMatchObject({ - usedPercent: 0, - resetsAt: Date.parse(SUMMARY.billingCycleEnd) - }) - expect(fetchMock).toHaveBeenCalledTimes(2) - } - ) -}) diff --git a/src/main/rate-limits/cursor-fetcher.test.ts b/src/main/rate-limits/cursor-fetcher.test.ts deleted file mode 100644 index 2200b7dc48c..00000000000 --- a/src/main/rate-limits/cursor-fetcher.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { setMainHttpClient } from '../network/http-client' - -const fetchMock = vi.fn() - -import { fetchCursorRateLimits } from './cursor-fetcher' -import type { CursorAuthReadResult } from './cursor-auth' -import { parseCursorSessionToken } from './cursor-session-token' - -setMainHttpClient({ fetch: fetchMock, proxySession: () => null }) - -type JwtSegment = Record - -function jwt(exp: number): string { - const encode = (value: JwtSegment): string => - Buffer.from(JSON.stringify(value)).toString('base64url') - return `${encode({ alg: 'RS256' })}.${encode({ sub: 'auth0|user_1', exp })}.signature` -} - -function session(expSeconds = 4_000_000_000): CursorAuthReadResult { - return { - status: 'ok', - session: { - token: parseCursorSessionToken(jwt(expSeconds))!, - source: 'keychain', - email: 'dev@example.com', - displayName: 'Dev', - membershipType: null, - subscriptionStatus: null - } - } -} - -type FakeResponse = Pick & { - headers: Pick - json: () => Promise -} - -function fakeResponse(response: FakeResponse): Response { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fetcher only reads ok/status/headers.get/json, all of which FakeResponse provides. - return response as unknown as Response -} - -function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { - return fakeResponse({ - ok: status >= 200 && status < 300, - status, - headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, - json: async () => body - }) -} - -const SUMMARY = { - billingCycleStart: '2026-09-01T00:00:00.000Z', - billingCycleEnd: '2026-10-01T00:00:00.000Z', - membershipType: 'pro', - individualUsage: { - plan: { enabled: true, used: 2_500, limit: 5_000, autoPercentUsed: 40, apiPercentUsed: 12 } - } -} - -beforeEach(() => { - fetchMock.mockReset() -}) - -describe('fetchCursorRateLimits', () => { - it('publishes both plan pools and the cycle reset', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY)) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.status).toBe('ok') - expect(limits.monthly?.usedPercent).toBe(50) - expect(limits.buckets?.map((bucket) => bucket.name)).toEqual(['Cursor Models', 'Other Models']) - expect(limits.planType).toBe('pro') - expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' }) - }) - - it('sends the origin headers the dashboard checks as CSRF defence', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY)) - await fetchCursorRateLimits({ authReadResult: session() }) - const [url, init] = fetchMock.mock.calls[0] ?? [] - expect(url).toBe('https://cursor.com/api/usage-summary') - expect(init?.credentials).toBe('omit') - expect(init?.headers).toMatchObject({ - Origin: 'https://cursor.com', - Referer: 'https://cursor.com/dashboard', - Cookie: expect.stringContaining('WorkosCursorSessionToken=auth0%7Cuser_1%3A%3A') - }) - }) - - it('reports an expired session without spending a request that must 401', async () => { - const limits = await fetchCursorRateLimits({ authReadResult: session(1_000) }) - expect(fetchMock).not.toHaveBeenCalled() - expect(limits.status).toBe('error') - expect(limits.usageMetadata?.failureKind).toBe('stale-token') - expect(limits.error).toContain('cursor-agent login') - }) - - it('tells a signed-out user how to sign in, without painting an error bar', async () => { - const limits = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } }) - expect(limits.status).toBe('unavailable') - expect(limits.usageMetadata?.failureKind).toBe('missing-credentials') - }) - - it('surfaces a credential read failure', async () => { - const limits = await fetchCursorRateLimits({ - authReadResult: { status: 'error', error: 'Unable to read the Cursor CLI auth file' } - }) - expect(limits.status).toBe('error') - expect(limits.error).toBe('Unable to read the Cursor CLI auth file') - }) - - it('treats a rejected session as expired sign-in and records a capped Retry-After on 429', async () => { - vi.useFakeTimers({ now: Date.parse('2026-10-13T12:00:00Z') }) - try { - fetchMock.mockResolvedValueOnce(jsonResponse({}, 401)) - expect( - (await fetchCursorRateLimits({ authReadResult: session() })).usageMetadata?.failureKind - ).toBe('stale-token') - - fetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '60' })) - const limited = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limited.usageMetadata?.failureKind).toBe('rate-limited') - expect(limited.usageMetadata?.retryAtMs).toBe(Date.now() + 60_000) - - fetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '99999999' })) - const capped = await fetchCursorRateLimits({ authReadResult: session() }) - expect(capped.usageMetadata?.retryAtMs).toBe(Date.now() + 24 * 60 * 60 * 1000) - - const retryAt = Date.now() + 5 * 60_000 - fetchMock.mockResolvedValueOnce( - jsonResponse({}, 429, { 'retry-after': new Date(retryAt).toUTCString() }) - ) - const dated = await fetchCursorRateLimits({ authReadResult: session() }) - expect(dated.usageMetadata?.retryAtMs).toBe(retryAt) - } finally { - vi.useRealTimers() - } - }) - - it.each([403, 302, 304])('reports HTTP %i without claiming the login expired', async (status) => { - fetchMock.mockResolvedValueOnce(jsonResponse({}, status, { location: '/login' })) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.usageMetadata?.failureKind).toBe('server') - expect(limits.error).toBe(`Cursor usage request failed (HTTP ${status})`) - expect(limits.usageMetadata?.authProvenance).toBeTruthy() - expect(fetchMock.mock.calls[0]?.[1]?.redirect).toBe('manual') - }) - - it('preserves a forbidden response from the legacy usage endpoint', async () => { - fetchMock - .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) - .mockResolvedValueOnce(jsonResponse({}, 403)) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.error).toBe('Cursor usage request failed (HTTP 403)') - expect(limits.usageMetadata?.failureKind).toBe('server') - }) - - it('names the account on failures too, so an account switch can clear stale figures', async () => { - // Failed refreshes must still name the new account so stale-policy code can discard the previous account's quota. - const ok = jsonResponse(SUMMARY) - fetchMock.mockResolvedValueOnce(ok) - const success = await fetchCursorRateLimits({ authReadResult: session() }) - const fingerprint = success.usageMetadata?.authProvenance - expect(fingerprint).toBeTruthy() - - for (const [status, headers] of [ - [401, {}], - [429, { 'retry-after': '60' }], - [503, {}] - ] as const) { - fetchMock.mockResolvedValueOnce(jsonResponse({}, status, headers)) - const failure = await fetchCursorRateLimits({ authReadResult: session() }) - expect(failure.status).toBe('error') - expect(failure.usageMetadata?.authProvenance).toBe(fingerprint) - } - - const expired = await fetchCursorRateLimits({ authReadResult: session(1_000) }) - expect(expired.usageMetadata?.authProvenance).toBe(fingerprint) - }) - - it('leaves the account unnamed when no session could be read', async () => { - const missing = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } }) - expect(missing.usageMetadata?.authProvenance).toBeUndefined() - }) - - it('reports a server failure with its status code', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({}, 503)) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.error).toBe('Cursor usage request failed (HTTP 503)') - expect(limits.usageMetadata?.failureKind).toBe('server') - }) - - it('classifies an unparseable body as a parse failure, not as usage', async () => { - fetchMock.mockResolvedValueOnce( - fakeResponse({ - ok: true, - status: 200, - headers: { get: () => null }, - json: async () => { - throw new Error('invalid json') - } - }) - ) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.usageMetadata?.failureKind).toBe('parse') - }) - - it('publishes an unlimited plan with no misleading bar', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ isUnlimited: true, membershipType: 'ultra' })) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.status).toBe('ok') - expect(limits.monthly).toBeUndefined() - expect(limits.planType).toBe('ultra') - }) - - it('falls back to the request-quota endpoint before declaring no allowance', async () => { - fetchMock - .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) - .mockResolvedValueOnce(jsonResponse({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } })) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.status).toBe('ok') - expect(limits.monthly?.usedPercent).toBe(50) - expect(fetchMock.mock.calls[1]?.[0]).toBe('https://cursor.com/api/usage?user=auth0%7Cuser_1') - }) - - it('hides the bar for an account with no quota rather than alerting forever', async () => { - fetchMock - .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) - .mockResolvedValueOnce(jsonResponse({})) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.status).toBe('unavailable') - expect(limits.usageMetadata?.failureKind).toBe('usage-unavailable') - }) - - it('reports a network failure without leaking the request internals', async () => { - fetchMock.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND cursor.com')) - const limits = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limits.error).toBe('Cursor usage request failed') - expect(limits.usageMetadata?.failureKind).toBe('network') - }) -})