From 553443f80e3165099e715fbe561a86e81fbd96ac Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 04:40:15 -0700 Subject: [PATCH 01/53] Consolidate desktop and WSL terminals on bundled Bun --- .../install-node-dependencies/action.yml | 9 +- .github/workflows/ci-cache-warmup.yml | 1 - .github/workflows/computer-e2e.yml | 23 +- .github/workflows/e2e.yml | 4 +- .github/workflows/pr.yml | 41 +- .github/workflows/release-cut.yml | 28 +- .github/workflows/unit-tests.yml | 15 +- .../workflows/windows-signing-rehearsal.yml | 8 +- config/bundled-cli-runtime.cjs | 48 +- .../daemon-shutdown-descendants/Dockerfile | 10 +- .../daemon-shutdown-descendants/README.md | 2 +- .../bundle-entry.ts | 2 + .../daemon-shutdown-descendants/fixture.cjs | 14 +- .../daemon-shutdown-descendants/run-case.sh | 13 +- config/electron-builder.config.cjs | 57 +- config/licenses/windows-conpty-LICENSE.txt | 21 + config/nsis/orca-bun-terminal-uninstall.nsh | 12 + config/nsis/orca-installer-hooks.nsh | 2 + config/packaged-runtime-node-modules.cjs | 166 +- config/patches/node-pty@1.1.0.patch | 1442 ----------------- ...e-pty-1.1.0-windows-pty-teardown-patch.cjs | 6 +- config/reliability-gates.jsonc | 201 +-- config/scripts/build-cli-runtime.mjs | 4 + config/scripts/build-cli-runtime.test.mjs | 44 + config/scripts/build-orcad-bun.mjs | 7 +- config/scripts/build-orcad.mjs | 76 +- config/scripts/build-terminal-daemon.mjs | 21 + config/scripts/build-windows-conpty.mjs | 148 ++ config/scripts/build-windows-conpty.test.mjs | 147 ++ .../scripts/bun-profile-change-scope.test.mjs | 16 +- config/scripts/bun-profile-test-paths.mjs | 11 +- .../scripts/bundled-cli-runtime-fixture.mjs | 55 + config/scripts/bundled-cli-runtime.test.mjs | 29 +- .../scripts/capture-agent-pty-transcript.mjs | 109 +- .../check-runtime-electron-ratchet.mjs | 1 - config/scripts/ci-shard-timings.json | 36 +- config/scripts/daemon-boot-smoke.mjs | 89 +- .../daemon-endpoint-handover-smoke.mjs | 37 +- config/scripts/daemon-smoke-launch.mjs | 54 + config/scripts/daemon-smoke-launch.test.mjs | 102 ++ .../scripts/electron-builder-config.test.mjs | 52 +- ...lectron-builder-runtime-resources.test.mjs | 92 +- .../electron-vite-output-contract.test.ts | 2 +- ...sure-native-runtime-job-ownership.test.mjs | 299 ---- config/scripts/ensure-native-runtime.mjs | 150 +- config/scripts/ensure-native-runtime.test.mjs | 430 ++--- .../scripts/finalize-signed-cli-runtime.mjs | 128 ++ .../finalize-signed-cli-runtime.test.mjs | 141 ++ .../scripts/fixtures/node-pty-1.1.0/LICENSE | 69 + .../scripts/fixtures/node-pty-1.1.0/README.md | 9 + .../node-pty-1.1.0/windowsPtyAgent.js.txt | 320 ++++ .../node-pty-1.1.0/windowsTerminal.js.txt | 199 +++ .../linux-packaged-node-pty-floor-child.cjs | 47 - config/scripts/node-pty-job-ownership.cjs | 227 --- ...de-pty-windows-pty-teardown-patch.test.mjs | 141 +- .../nsis-bun-terminal-uninstall.test.mjs | 100 ++ .../scripts/orcad-artifact-version.test.mjs | 19 + config/scripts/orcad-entry-build.mjs | 21 +- config/scripts/orcad-launcher-build.test.mjs | 5 +- .../packaged-node-pty-prebuild-prune.test.mjs | 138 -- config/scripts/pr-code-change-scope.mjs | 33 +- config/scripts/pr-code-change-scope.test.mjs | 29 +- .../scripts/pr-workflow-parallelism.test.mjs | 29 +- config/scripts/pty-transcript-bun-runtime.mjs | 90 + config/scripts/pty-transcript-recording.mjs | 98 ++ .../scripts/pty-transcript-recording.test.mjs | 182 +++ .../real-terminal-runtime-workflows.test.ts | 72 + ...uild-native-deps-addon-validation.test.mjs | 208 +++ .../rebuild-native-deps-node-pty.test.mjs | 606 ------- .../rebuild-native-deps-test-fixtures.mjs | 132 +- ...-native-deps-windows-process-tree.test.mjs | 2 - config/scripts/rebuild-native-deps.mjs | 248 +-- config/scripts/rebuild-native-deps.test.mjs | 37 + config/scripts/run-bundled-runtime-tests.mjs | 3 + ...run-daemon-shutdown-descendants-docker.mjs | 55 +- ...aemon-shutdown-descendants-docker.test.mjs | 92 ++ config/scripts/run-electron-vite-dev.mjs | 6 + ...un-linux-packaged-node-pty-floor-smoke.mjs | 94 -- ...nux-packaged-node-pty-floor-smoke.test.mjs | 36 - ...un-linux-packaged-terminal-floor-smoke.mjs | 120 ++ ...nux-packaged-terminal-floor-smoke.test.mjs | 55 + .../script-module-dependencies.test.mjs | 15 - .../skill-sharing-release-workflow.test.mjs | 4 +- config/scripts/verify-linux-glibc-floor.cjs | 5 +- .../scripts/verify-packaged-daemon-entry.cjs | 51 +- .../verify-packaged-daemon-entry.test.mjs | 29 +- ...verify-packaged-node-pty-job-ownership.cjs | 199 --- ...y-packaged-node-pty-job-ownership.test.mjs | 430 ----- .../win32-test-lane-registration.test.mjs | 1 - .../windows-daemon-workspace-close-repro.mjs | 23 +- .../windows-process-tree-creation-time.cjs | 5 +- ...ws-pty-native-capability-workflow.test.mjs | 9 +- ...tress.cjs => windows-pty-table-stress.mjs} | 78 +- ...windows-signing-workflow-contract.test.mjs | 43 +- .../windows-ssh-attach-console-repro.mjs | 436 ----- .../reference/agent-pty-transcript-capture.md | 14 +- docs/reference/linux-glibc-compatibility.md | 43 +- .../windows-daemon-host-relocation.md | 24 +- docs/reference/windows-msys-job-breakaway.md | 9 +- docs/reference/windows-process-enumeration.md | 17 +- docs/reference/wsl-runner-verification.md | 2 +- docs/site/content/docs/ssh.mdx | 18 +- package.json | 13 +- pnpm-lock.yaml | 11 - pnpm-workspace.yaml | 2 - .../cli-bun-environment.integration.test.ts | 53 + .../restored-subagent-liveness-sweep.test.ts | 28 + .../restored-subagent-liveness-sweep.ts | 6 +- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 9 +- src/main/agent-hooks/wsl-hook-relay-deps.ts | 5 +- .../wsl-hook-relay-guest-install.ts | 2 + .../agent-hooks/wsl-hook-relay-launch.test.ts | 20 + src/main/agent-hooks/wsl-hook-relay-launch.ts | 29 +- .../wsl-hook-relay-manager.test.ts | 67 +- .../agent-hooks/wsl-hook-relay-manager.ts | 62 +- .../wsl-hook-relay-reattach.test.ts | 5 + .../agent-hooks/wsl-hook-relay-reattach.ts | 11 +- .../wsl-hook-relay-recovery.test.ts | 33 +- .../agent-hooks/wsl-hook-relay-recovery.ts | 5 +- .../agent-hooks/wsl-hook-relay-state-key.ts | 8 +- .../wsl-hook-relay-state-machine.ts | 10 +- src/main/agent-hooks/wsl-hook-relay-state.ts | 1 + ...on-scanner-opencode-sqlite-process.test.ts | 10 +- src/main/bundled-bun-test-execution.test.ts | 48 + src/main/bundled-bun-test-execution.ts | 50 + .../runtime-auth-captured-wsl-owner.test.ts | 107 ++ .../claude-accounts/runtime-auth-service.ts | 28 +- .../runtime-auth-managed-credentials.ts | 52 +- .../runtime-auth/runtime-auth-preparation.ts | 20 +- .../runtime-auth/runtime-auth-sync.ts | 22 +- .../runtime-auth/runtime-auth-types.ts | 2 + src/main/cli/packaged-cli-assets.test.ts | 1 - .../captured-wsl-account-process.test.ts | 144 ++ .../captured-wsl-account-process.ts | 19 + .../legacy-wsl-runtime-auth-drain-owner.ts | 40 + .../legacy-wsl-runtime-auth-drain.ts | 133 +- .../runtime-home-captured-wsl-launch.test.ts | 116 ++ .../runtime-home-service-home-routing.ts | 28 +- .../runtime-home-service-launch.ts | 28 +- .../runtime-home-service-managed-home.ts | 9 +- .../runtime-home-service-state.ts | 30 +- .../runtime-home-service-wsl.ts | 59 +- .../wsl-codex-auth-batch-reader.ts | 25 +- src/main/codex/codex-pane-account-registry.ts | 4 +- .../codex/codex-stale-pane-accounts.test.ts | 20 + .../codex/codex-wsl-resume-file-probe.test.ts | 97 ++ src/main/codex/codex-wsl-resume-file-probe.ts | 70 + .../codex/codex-wsl-session-resume.test.ts | 102 ++ src/main/codex/codex-wsl-session-resume.ts | 110 ++ src/main/codex/wsl-codex-session-bridge.ts | 46 +- .../agent-startup-prompt-bun-fixture.ts | 95 ++ .../agent-startup-prompt-latency.bun.test.ts | 70 + ...nt-startup-prompt-latency.node-pty.test.ts | 123 -- .../bun-pty-fd-lifecycle.integration.test.ts | 89 + .../daemon/bun-pty-retired-handle-fixture.ts | 71 + ...bun-pty-retired-handle.integration.test.ts | 39 + .../bun-pty-windows-io-failure-fixture.ts | 175 ++ .../bun-pty-windows-io-failure.win32.test.ts | 30 + src/main/daemon/client.ts | 93 +- ...emon-bun-pty-artifact.integration.test.ts} | 43 +- .../daemon-bun-runtime-retention.test.ts | 322 ++++ .../daemon/daemon-bun-runtime-retention.ts | 177 ++ src/main/daemon/daemon-bun-runtime.test.ts | 291 ++++ src/main/daemon/daemon-bun-runtime.ts | 247 +++ .../daemon-client-connection-attempt.ts | 67 + src/main/daemon/daemon-client-duplex.test.ts | 244 +++ .../daemon/daemon-client-hello-handshake.ts | 32 +- .../daemon/daemon-client-ndjson-readers.ts | 8 +- .../daemon/daemon-client-notify-settlement.ts | 4 +- src/main/daemon/daemon-client-rpc-request.ts | 4 +- .../daemon/daemon-client-socket-connect.ts | 5 +- src/main/daemon/daemon-client-transport.ts | 89 + src/main/daemon/daemon-entry.ts | 9 +- .../daemon/daemon-folder-access-probe.test.ts | 142 +- src/main/daemon/daemon-folder-access-probe.ts | 59 +- .../daemon-fresh-spawn-admission.test.ts | 28 + .../daemon/daemon-fresh-spawn-admission.ts | 37 + src/main/daemon/daemon-guest-spawn-options.ts | 24 + src/main/daemon/daemon-hello-protocol.ts | 5 + src/main/daemon/daemon-host-manifest.ts | 162 -- .../daemon/daemon-host-relocation.test.ts | 336 +--- src/main/daemon/daemon-host-relocation.ts | 201 +-- .../daemon-init-endpoint-adoption.test.ts | 6 +- ...mon-init-live-session-preservation.test.ts | 13 +- src/main/daemon/daemon-init-mock-types.ts | 4 +- .../daemon-init-provider-installation.test.ts | 89 +- .../daemon-init-restart-sequence.test.ts | 13 +- src/main/daemon/daemon-init.ts | 2 +- src/main/daemon/daemon-launch-paths.ts | 4 + .../daemon-launched-child-spawn.test.ts | 189 ++- .../daemon/daemon-launched-child-spawn.ts | 44 +- .../daemon/daemon-out-of-process-launcher.ts | 33 +- src/main/daemon/daemon-provider-init.ts | 72 +- src/main/daemon/daemon-provider-restart.ts | 16 +- src/main/daemon/daemon-provider-routing.ts | 11 +- src/main/daemon/daemon-provider-state.ts | 18 +- .../daemon-pty-adapter-subscription-fanout.ts | 7 + .../daemon/daemon-pty-connection-lifecycle.ts | 11 +- src/main/daemon/daemon-pty-daemon-recovery.ts | 10 +- .../daemon/daemon-pty-guest-execution.test.ts | 210 +++ .../daemon/daemon-pty-router-test-adapter.ts | 164 ++ src/main/daemon/daemon-pty-router.test.ts | 210 +-- src/main/daemon/daemon-pty-router.ts | 31 +- src/main/daemon/daemon-pty-runtime-state.ts | 51 +- src/main/daemon/daemon-pty-session-control.ts | 4 +- .../daemon/daemon-pty-session-inventory.ts | 6 + src/main/daemon/daemon-pty-session-spawn.ts | 32 +- src/main/daemon/daemon-pty-spawn-result.ts | 9 +- .../daemon-pty-startup-delivery.test.ts | 2 +- .../daemon/daemon-replacement-preflight.ts | 10 +- .../daemon-request-router-transient.test.ts | 25 + src/main/daemon/daemon-request-router.ts | 14 +- .../daemon/daemon-runtime-launch-pin.test.ts | 86 + src/main/daemon/daemon-server.ts | 20 + ...shared-owner-incarnation-retention.test.ts | 38 +- src/main/daemon/daemon-spawner.ts | 2 +- .../daemon/daemon-transient-pty-protocol.ts | 9 + src/main/daemon/daemon-transient-pty.test.ts | 190 +++ src/main/daemon/daemon-transient-pty.ts | 239 +++ .../degraded-daemon-fallback-shutdown.ts | 25 - .../degraded-daemon-fresh-spawn-routing.ts | 77 - .../daemon/degraded-daemon-owner-recovery.ts | 82 - .../degraded-daemon-pty-provider.test.ts | 686 -------- .../daemon/degraded-daemon-pty-provider.ts | 360 ---- .../daemon/degraded-daemon-session-routing.ts | 54 - ...eadless-emulator-wide-char-repaint.test.ts | 2 +- .../linux-packaged-terminal-floor-fixture.ts | 154 ++ src/main/daemon/node-pty-fd-leak.test.ts | 195 --- ...node-pty-windows-input-error.win32.test.ts | 155 -- ...pty-subprocess-cwd-cancel-identity.test.ts | 18 +- .../pty-subprocess-env-inheritance.test.ts | 16 +- .../pty-subprocess-env-retention.test.ts | 5 +- ...ubprocess-foreground-degraded-scan.test.ts | 12 +- ...pty-subprocess-foreground-identity.test.ts | 16 +- ...subprocess-foreground-scan-cadence.test.ts | 16 +- ...ty-subprocess-git-credential-guard.test.ts | 16 +- .../pty-subprocess-handle-lifecycle.test.ts | 30 +- .../pty-subprocess-io-failure-bun-fixture.ts | 123 ++ .../pty-subprocess-io-failure-cleanup.test.ts | 5 +- .../pty-subprocess-io-failure-native.test.ts | 94 +- .../pty-subprocess-managed-agent-env.test.ts | 16 +- ...y-subprocess-spawn-file-foreground.test.ts | 5 +- .../daemon/pty-subprocess-test-harness.ts | 2 +- ...ty-subprocess-windows-shell-launch.test.ts | 16 +- ...pty-subprocess-windows-shell-probe.test.ts | 165 ++ .../daemon/pty-subprocess-wsl-launch.test.ts | 16 +- src/main/daemon/pty-subprocess.test.ts | 38 +- src/main/daemon/pty-subprocess.ts | 7 +- .../bun-pty-process-contract.ts | 13 +- .../bun-pty-process-flow-control.ts | 2 +- .../pty-subprocess/bun-pty-process-runtime.ts | 46 +- .../bun-pty-process.integration.test.ts | 8 +- .../pty-subprocess/bun-pty-process.test.ts | 327 ++-- .../pty-subprocess/bun-pty-terminal-io.ts | 71 + .../bun-tests/bash-reply-echo-bun-fixture.ts | 61 + .../bun-tests/bundled-bun-pty-fixture.ts | 59 + .../bun-tests/bundled-bun-pty-host-fixture.ts | 39 + ...fish-color-scheme-child-stdin.bun.test.ts} | 66 +- ...ish-query-reply-child-stdin-bun-fixture.ts | 166 ++ .../fish-query-reply-child-stdin.bun.test.ts | 83 + .../pty-reply-echo-shapes.bun.test.ts} | 65 +- .../foreground-process-tracker.ts | 4 +- .../native-pty-spawn-bun.test.ts | 24 +- .../daemon/pty-subprocess/native-pty-spawn.ts | 69 +- .../pty-shell-foreground-confirmation.ts | 4 +- .../pty-subprocess/shell-launch-plan.ts | 10 +- .../shell-launch-profile.test.ts | 55 + ...spawn-environment-client-overrides.test.ts | 65 + .../spawn-environment-pi-owner.test.ts | 43 + .../spawn-environment-preflight.test.ts | 23 + .../pty-subprocess/spawn-environment.ts | 6 +- .../spawn-file-child-processes.ts | 6 +- .../spawn-file-foreground-process.ts | 8 +- ...wn-file-foreground-rejected-agents.test.ts | 5 +- .../spawn-preflight-bun.test.ts | 134 +- .../daemon/pty-subprocess/spawn-preflight.ts | 113 +- .../subprocess-handle-subscriptions.test.ts | 91 ++ .../pty-subprocess/subprocess-handle.ts | 49 +- .../subprocess-windows-shell-identity.test.ts | 99 ++ .../windows-bun-pty-gate.integration.test.ts | 11 +- .../windows-bun-pty-job.test.ts | 9 + .../pty-subprocess/windows-bun-pty-job.ts | 8 +- .../windows-bun-pty-launch.test.ts | 1 + .../session-output-snapshot-fidelity.test.ts | 50 + src/main/daemon/session-subprocess-handle.ts | 1 + .../daemon/session-termination-controller.ts | 9 + src/main/daemon/session.ts | 12 +- src/main/daemon/shell-ready-bun-fixture.ts | 196 +++ src/main/daemon/shell-ready.test.ts | 204 +-- ...terminal-host-bun-posix-inspection.test.ts | 178 ++ .../terminal-host-process-inspection.ts | 15 +- src/main/daemon/terminal-host-session-cwd.ts | 6 +- src/main/daemon/tui-idle-agent-bun-fixture.ts | 142 ++ src/main/daemon/types.ts | 10 +- src/main/daemon/windows-conpty-warmup.test.ts | 59 +- src/main/daemon/windows-conpty-warmup.ts | 78 +- .../windows-shell-preflight-bun-fixture.ts | 53 + src/main/daemon/wsl-cold-restore-cwd.test.ts | 21 + src/main/daemon/wsl-cold-restore-cwd.ts | 10 + .../pty-activation-inventory-scope.test.ts | 41 + .../ipc/pty-buffer-snapshot-dispatch.test.ts | 16 +- .../pty-controller-ownership-routing.test.ts | 17 +- .../ipc/pty-cumulative-ack-accounting.test.ts | 14 +- src/main/ipc/pty-delivery-health-heal.test.ts | 4 +- .../ipc/pty-global-renderer-credit.test.ts | 2 +- src/main/ipc/pty-hidden-at-spawn-mark.test.ts | 11 +- src/main/ipc/pty-ipc-daemon-test-provider.ts | 136 ++ src/main/ipc/pty-ipc-mock-registry.ts | 1 + src/main/ipc/pty-ipc-native-runtime-mock.ts | 43 + src/main/ipc/pty-ipc-process-env-scope.ts | 7 + src/main/ipc/pty-ipc-suite-environment.ts | 22 +- .../pty-listener-teardown-and-orphans.test.ts | 53 +- .../pty-login-shell-startup-commands.test.ts | 44 +- src/main/ipc/pty-management.test.ts | 72 +- src/main/ipc/pty-management.ts | 30 +- src/main/ipc/pty-output-drain-rounds.test.ts | 3 +- .../ipc/pty-pane-claim-arbitration.test.ts | 17 +- .../ipc/pty-renderer-inflight-credit.test.ts | 4 +- ...-renderer-lifecycle-delivery-reset.test.ts | 3 +- .../pty-serializer-settlement-mapping.test.ts | 21 +- ...-spawn-env-codex-resume-provenance.test.ts | 14 +- .../ipc/pty-spawn-env-terminal-basics.test.ts | 19 +- .../pty-spawn-runtime-handle-binding.test.ts | 6 +- .../ipc/pty-windows-shell-selection.test.ts | 38 +- src/main/ipc/pty-wsl-cwd-validation.test.ts | 4 +- src/main/ipc/pty-wsl-stable-adoption.test.ts | 90 + src/main/ipc/pty/delivery/ssh-intake.ts | 12 +- src/main/ipc/pty/host-env/assembly.ts | 30 +- src/main/ipc/pty/host-env/codex-resume.ts | 5 +- src/main/ipc/pty/host-env/types.ts | 8 +- src/main/ipc/pty/ipc/inspect.ts | 43 +- src/main/ipc/pty/ipc/spawn-env-codex.ts | 50 +- src/main/ipc/pty/ipc/spawn-env.ts | 6 +- src/main/ipc/pty/ipc/spawn-execute.ts | 20 +- src/main/ipc/pty/ipc/spawn-options.ts | 6 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 92 +- src/main/ipc/pty/ipc/spawn-state.ts | 6 + src/main/ipc/pty/ipc/spawn-types.ts | 4 + src/main/ipc/pty/ipc/spawn-wsl-options.ts | 27 + src/main/ipc/pty/ipc/spawn-wsl-route.ts | 39 + .../ipc/pty/ipc/spawn-wsl-routing.test.ts | 286 ++++ src/main/ipc/pty/kill-all.ts | 13 +- .../ipc/pty/pane/adopt-stable-wsl.test.ts | 37 + src/main/ipc/pty/pane/adopt-stable.ts | 6 +- .../pty/pane/stable-owner-wsl-routing.test.ts | 70 + src/main/ipc/pty/pane/stable-owner.ts | 59 +- .../pty/pane/stable-pane-attach-provider.ts | 16 + .../pane/stable-pane-binding-persistence.ts | 47 + .../pty/provider/bind-listeners-wsl.test.ts | 69 + src/main/ipc/pty/provider/bind-listeners.ts | 172 +- src/main/ipc/pty/provider/local-configure.ts | 134 -- .../pty/provider/local-inventory-authority.ts | 86 + src/main/ipc/pty/provider/registry.ts | 56 +- .../ipc/pty/provider/wsl-registry.test.ts | 36 + src/main/ipc/pty/register-handlers.ts | 53 +- src/main/ipc/pty/register-headless-runtime.ts | 2 + src/main/ipc/pty/runtime/controller-deps.ts | 4 + .../foreground-process-evidence-capability.ts | 19 +- .../ipc/pty/runtime/inventory-operations.ts | 47 +- .../pty/runtime/local-guest-inventory.test.ts | 171 ++ src/main/ipc/pty/runtime/operations.ts | 8 +- src/main/ipc/pty/runtime/spawn-early.ts | 12 +- src/main/ipc/pty/runtime/spawn-execute.ts | 21 +- .../pty/runtime/spawn-guest-environment.ts | 44 + .../ipc/pty/runtime/spawn-host-environment.ts | 71 + src/main/ipc/pty/runtime/spawn-options.ts | 18 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 159 +- src/main/ipc/pty/runtime/spawn-state.ts | 3 + .../ipc/pty/runtime/spawn-wsl-routing.test.ts | 297 ++++ src/main/ipc/pty/session.ts | 2 + .../local-builds/local-build-compatibility.ts | 19 +- src/main/memory/hydrate-local-pty-registry.ts | 3 +- src/main/memory/pty-registry.test.ts | 19 + src/main/memory/pty-registry.ts | 8 +- src/main/orcad/main-preflight-order.test.ts | 27 +- src/main/orcad/orcad-app.ts | 19 +- src/main/orcad/orcad-bundled-runtime.test.ts | 27 +- src/main/orcad/orcad-bundled-runtime.ts | 14 + .../orcad/orcad-daemon-supervision.test.ts | 9 +- src/main/orcad/orcad-daemon-supervision.ts | 9 +- .../orcad/orcad-profile-preflight.test.ts | 47 +- src/main/orcad/orcad-profile-preflight.ts | 32 +- .../loading-store/loaded-state-parsing.ts | 7 + ...ion.ts => relay-lease-durable-mutation.ts} | 8 +- .../ssh-lease-recovery-operations.ts | 12 +- .../state-serialization-secret-handling.ts | 27 + .../loading-store/store-domain-composition.ts | 12 +- .../wsl-consumer-recovery-operations.test.ts | 111 ++ .../wsl-consumer-recovery-operations.ts | 164 ++ .../wsl-consumer-recovery-secrets.ts | 39 + .../loading-store/wsl-daemon-recovery.test.ts | 124 ++ .../profile-state-live-store-factory.test.ts | 39 + src/main/protected-secret-persistence.ts | 6 + ...-foreground-process-git-bash.win32.test.ts | 2 +- .../providers/bun-pty-transcript-fixture.ts | 87 + .../local-pty-bun-posix-inspection.test.ts | 153 -- .../local-pty-bun-windows-identity.test.ts | 80 - .../local-pty-child-process-verdict.test.ts | 235 --- .../providers/local-pty-default-shell.test.ts | 82 - .../local-pty-finalize-environment.ts | 138 -- ...y-foreground-inspection-cheap-tier.test.ts | 138 -- .../local-pty-foreground-inspection.ts | 300 ---- .../providers/local-pty-launch-helpers.ts | 98 -- src/main/providers/local-pty-launch-plan.ts | 260 --- .../local-pty-pending-native-spawn.test.ts | 147 -- .../providers/local-pty-pi-owner-env.test.ts | 78 - ...al-pty-provider-foreground-process.test.ts | 545 ------- .../local-pty-provider-io-events.test.ts | 354 ---- ...cal-pty-provider-session-inventory.test.ts | 241 --- ...local-pty-provider-shell-readiness.test.ts | 343 ---- .../local-pty-provider-shutdown.test.ts | 617 ------- ...ocal-pty-provider-spawn-cwd-safety.test.ts | 269 --- .../local-pty-provider-spawn-env.test.ts | 638 -------- .../local-pty-provider-spawn-session.test.ts | 613 ------- .../providers/local-pty-provider-state.ts | 170 -- .../local-pty-provider-test-harness.ts | 134 -- .../providers/local-pty-provider-types.ts | 39 - ...-pty-provider-windows-shell-launch.test.ts | 668 -------- src/main/providers/local-pty-provider.ts | 225 --- src/main/providers/local-pty-runtime-spawn.ts | 44 - .../providers/local-pty-session-activation.ts | 204 --- .../providers/local-pty-session-operations.ts | 176 -- .../local-pty-shell-readiness-session.ts | 149 -- ...al-pty-shell-ready-startup-command.test.ts | 186 --- .../local-pty-shell-ready-startup-command.ts | 89 - ...pty-shell-startup-command.node-pty.test.ts | 121 -- .../providers/local-pty-spawn-environment.ts | 84 - src/main/providers/local-pty-spawn-state.ts | 83 - src/main/providers/local-pty-spawn.ts | 148 -- src/main/providers/local-pty-termination.ts | 263 --- .../local-pty-utils-windows-fallback.test.ts | 160 -- src/main/providers/local-pty-utils.test.ts | 312 ---- src/main/providers/local-pty-utils.ts | 313 ---- .../local-pty-windows-spawn-environment.ts | 91 -- src/main/providers/provider-dispatch.test.ts | 32 +- src/main/providers/pty-provider-contract.ts | 1 + ... pty-repaint-wide-char-buffer.bun.test.ts} | 83 +- .../providers/pty-spawn-validation.test.ts | 153 ++ src/main/providers/pty-spawn-validation.ts | 45 + .../relay-pty-generation-registry.ts | 20 + src/main/providers/relay-pty-id-mapping.ts | 13 + src/main/providers/relay-pty-provider.ts | 317 ++++ ...lay-pty-source-credit-registration.test.ts | 72 + .../relay-pty-source-credit-registration.ts | 53 + .../providers/relay-pty-source-recovery.ts | 97 ++ .../settled-pty-writer-census.test.ts | 44 +- .../shell-startup-command-bun-fixture.ts | 116 ++ ...ll-startup-command-bun.integration.test.ts | 45 + src/main/providers/ssh-pty-applied-size.ts | 14 +- src/main/providers/ssh-pty-provider.ts | 317 +--- .../providers/ssh-pty-session-reattach.ts | 44 +- .../unavailable-pty-provider.test.ts | 18 + .../providers/unavailable-pty-provider.ts | 41 + ...-conpty-wide-char-duplication.bun.test.ts} | 87 +- ...windows-console-attached-processes.test.ts | 130 +- .../windows-console-attached-processes.ts | 75 +- .../providers/windows-console-runtime.test.ts | 89 + src/main/providers/windows-console-runtime.ts | 98 ++ .../windows-pty-job-membership.test.ts | 17 +- .../providers/windows-pty-job-membership.ts | 6 +- ...ws-shell-preflight-runtime.windows.test.ts | 58 +- .../working-directory-validation.test.ts | 2 +- .../providers/working-directory-validation.ts | 2 +- ...escendant-termination-job-coverage.test.ts | 4 +- .../pty/node-pty-master-fd-retirement.test.ts | 194 --- src/main/pty/node-pty-master-fd-retirement.ts | 15 - src/main/pty/node-pty-pts-name.ts | 10 - ...-pty-self-exit-pseudoconsole-close.test.ts | 180 -- src/main/pty/omp-shell-wrapper-bun-fixture.ts | 64 + ....test.ts => omp-shell-wrapper.bun.test.ts} | 60 +- .../pty/posix-pty-process-groups-fixture.ts | 107 ++ ...six-pty-process-groups.integration.test.ts | 97 +- src/main/pty/terminal-slave-device.ts | 7 + src/main/rate-limits/claude-pty.test.ts | 25 +- src/main/rate-limits/claude-pty.ts | 158 +- .../codex-fetcher-auth-errors.test.ts | 6 +- .../rate-limits/codex-fetcher-backend.test.ts | 2 +- .../codex-fetcher-probe-shutdown.test.ts | 4 +- .../codex-fetcher-process-contract.test.ts | 4 +- .../codex-fetcher-pty-settle.test.ts | 10 +- ...codex-fetcher-rpc-exit-diagnostics.test.ts | 4 +- .../codex-fetcher-runtime-pairing.test.ts | 2 +- .../codex-fetcher-session-supplement.test.ts | 2 +- src/main/rate-limits/codex-fetcher.test.ts | 8 +- .../codex-pty-rate-limit-probe.test.ts | 59 +- .../rate-limits/codex-pty-rate-limit-probe.ts | 52 +- .../rate-limits/hidden-daemon-pty.test.ts | 200 +++ src/main/rate-limits/hidden-daemon-pty.ts | 198 +++ .../rate-limits/hidden-pty-cleanup.test.ts | 52 +- src/main/rate-limits/hidden-pty-cleanup.ts | 23 +- ...-runtime-serialize-main-terminal-buffer.ts | 2 +- .../orca-runtime-wait-for-leaf-pty-id.ts | 5 +- ...tration-codex-real-pty.integration.test.ts | 51 +- ...y-exit-agent-status-reconciliation.test.ts | 3 +- .../runtime/runtime-terminal-state-records.ts | 1 + ...dle-name-only-real-pty.integration.test.ts | 143 +- src/main/ssh/orcad-activation-gate.ts | 2 +- src/main/ssh/ssh-relay-deploy-helpers.ts | 20 +- ...-relay-offline-node-headers.docker.test.ts | 204 --- src/main/ssh/ssh-relay-session.ts | 130 +- .../startup/codex-launch-preparation.test.ts | 121 ++ src/main/startup/codex-launch-preparation.ts | 17 +- .../codex-session-resume-launch.test.ts | 73 + .../startup/codex-session-resume-launch.ts | 17 + .../first-window-startup-services.test.ts | 20 +- .../startup/first-window-startup-services.ts | 23 +- .../legacy-worker-renderer-recovery.test.ts | 2 +- src/main/startup/main-process-pty-startup.ts | 7 +- src/main/startup/main-process-quit.ts | 4 + ...-process-runtime-launch-activation.test.ts | 1 - .../startup/main-process-runtime-launch.ts | 14 +- .../startup/main-process-runtime-service.ts | 13 +- src/main/startup/main-process-state.ts | 6 + .../startup/main-window-core-services.test.ts | 21 + src/main/startup/main-window-core-services.ts | 3 +- ...rminal-history-fish-session-bun-fixture.ts | 88 + ...terminal-history-fish-session.bun.test.ts} | 77 +- .../window/attach-main-window-services.ts | 10 +- .../windows/windows-conpty-provider.test.ts | 51 + src/main/windows/windows-conpty-provider.ts | 21 + .../windows/windows-host-job.win32.test.ts | 65 +- .../windows/windows-msys-job.win32.test.ts | 26 +- .../windows-process-tree-runtime-manifest.ts | 15 + src/main/windows/windows-pty-job.test.ts | 250 +-- src/main/windows/windows-pty-job.ts | 229 +-- .../windows/windows-pty-job.win32.test.ts | 68 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 - .../wsl-probe-failure-swallow-allowlist.txt | 7 + src/main/wsl/wsl-account-execution-context.ts | 23 + src/main/wsl/wsl-bun-runtime.ts | 4 +- .../wsl-bun-runtime.win32.integration.test.ts | 7 +- .../wsl/wsl-daemon-connector-protocol.test.ts | 75 + src/main/wsl/wsl-daemon-connector-script.ts | 36 + .../wsl/wsl-daemon-connector-stream.test.ts | 109 ++ src/main/wsl/wsl-daemon-connector-stream.ts | 61 + src/main/wsl/wsl-daemon-endpoint.test.ts | 114 ++ src/main/wsl/wsl-daemon-endpoint.ts | 149 ++ src/main/wsl/wsl-daemon-pty-provider.ts | 138 ++ .../wsl/wsl-daemon-reattach-hooks.test.ts | 52 + src/main/wsl/wsl-daemon-reattach-hooks.ts | 38 + src/main/wsl/wsl-daemon-sessions.test.ts | 317 ++++ src/main/wsl/wsl-daemon-sessions.ts | 271 ++++ src/main/wsl/wsl-daemon-spawn-route.ts | 57 + src/main/wsl/wsl-daemon-start-script.test.ts | 86 + src/main/wsl/wsl-daemon-start-script.ts | 35 + src/main/wsl/wsl-daemon-transport.test.ts | 100 ++ src/main/wsl/wsl-daemon-transport.ts | 111 ++ .../wsl/wsl-distribution-identity.test.ts | 24 + src/main/wsl/wsl-distribution-identity.ts | 47 + .../wsl/wsl-guest-artifact-install-script.ts | 57 + src/main/wsl/wsl-guest-environment.ts | 29 +- src/main/wsl/wsl-guest-owner-preparation.ts | 48 + src/main/wsl/wsl-guest-spawn-options.test.ts | 275 ++++ src/main/wsl/wsl-guest-spawn-options.ts | 204 +++ .../wsl/wsl-guest-terminal-history-script.ts | 36 + .../wsl/wsl-guest-terminal-history.test.ts | 189 +++ src/main/wsl/wsl-guest-terminal-history.ts | 88 + .../wsl-guest-terminal-preparation.test.ts | 266 +++ .../wsl/wsl-guest-terminal-preparation.ts | 111 ++ src/main/wsl/wsl-invocation-boundary.test.ts | 2 +- src/main/wsl/wsl-runner.ts | 11 +- src/main/zsh-startup-hook-pty-harness.ts | 32 +- src/main/zsh-startup-wrapper-builder.ts | 2 +- .../pty-bridge-stream-and-serialization.ts | 1 + src/preload/api/pty-management-api.ts | 9 +- src/relay/pty-handler.ts | 18 +- src/relay/pty-shell-overlay-wrappers.ts | 4 +- src/relay/pty-shell-wsl-cli.test.ts | 55 + src/relay/relay-daemon-fatal-reap.test.ts | 26 +- src/relay/relay-pty-runtime.ts | 4 +- .../settings/ManageSessionsSection.tsx | 4 + .../TerminalServiceRetryNotice.test.tsx | 45 + .../settings/TerminalServiceRetryNotice.tsx | 57 + .../src/web/preload-api/web-terminal-api.ts | 1 + ...h-query-reply-child-stdin.node-pty.test.ts | 225 --- src/shared/node-pty-spawn-helper.test.ts | 14 - src/shared/node-pty-spawn-helper.ts | 11 - src/shared/orcad-artifacts.test.ts | 22 + src/shared/orcad-artifacts.ts | 9 +- src/shared/orcad-bun-runtime.ts | 11 + src/shared/orcad-profile-preflight.ts | 1 + .../pane-agent-identity-inventory.test.ts | 2 +- src/shared/persisted-state-types.ts | 2 + src/shared/terminal-process.ts | 17 + src/shared/terminal-unicode-provider.test.ts | 65 + src/shared/terminal-unicode-provider.ts | 17 +- src/shared/tui-agent-config.ts | 2 +- src/shared/windows-conpty-release.ts | 18 + src/shared/wsl-daemon-recovery.ts | 49 + src/shared/wsl-login-shell-command.ts | 10 +- src/shared/wsl-pty-consumer-recovery.ts | 67 + src/shared/wsl-pty-id.test.ts | 45 + src/shared/wsl-pty-id.ts | 64 + tests/e2e/capture-opencode-tui-repro.mjs | 42 +- ...daemon-generation-bun-runtime.unit.test.ts | 49 + tests/e2e/fixtures/daemon-generation-entry.ts | 4 + tests/e2e/global-setup.ts | 33 +- .../daemon-generation-runtime-fixture.ts | 32 +- .../daemon-generation-safety-fixtures.ts | 30 +- tests/e2e/pty-input-write-queue-ssh.spec.ts | 11 +- ...minal-foreground-confirmation.unit.test.ts | 10 +- tests/tools/daemon-relocation-spike/README.md | 6 + .../capture-fish-mode2031-trace.mjs | 6 +- .../tools/omp-close-lifecycle-bun-fixture.mjs | 181 +++ tests/tools/omp-close-lifecycle.md | 23 +- tests/tools/omp-close-lifecycle.test.mjs | 228 +-- .../README.md | 13 +- ...mjs => packaged-bun-capability-oracle.mjs} | 28 +- ...> packaged-bun-capability-oracle.test.mjs} | 24 +- ....cjs => packaged-bun-capability-probe.cjs} | 99 +- ...=> packaged-bun-capability-probe.test.mjs} | 4 +- .../run.mjs | 86 +- .../run.test.mjs | 41 +- 613 files changed, 22530 insertions(+), 22593 deletions(-) create mode 100644 config/licenses/windows-conpty-LICENSE.txt create mode 100644 config/nsis/orca-bun-terminal-uninstall.nsh delete mode 100644 config/patches/node-pty@1.1.0.patch create mode 100644 config/scripts/build-cli-runtime.test.mjs create mode 100644 config/scripts/build-terminal-daemon.mjs create mode 100644 config/scripts/build-windows-conpty.mjs create mode 100644 config/scripts/build-windows-conpty.test.mjs create mode 100644 config/scripts/daemon-smoke-launch.mjs create mode 100644 config/scripts/daemon-smoke-launch.test.mjs delete mode 100644 config/scripts/ensure-native-runtime-job-ownership.test.mjs create mode 100644 config/scripts/finalize-signed-cli-runtime.mjs create mode 100644 config/scripts/finalize-signed-cli-runtime.test.mjs create mode 100644 config/scripts/fixtures/node-pty-1.1.0/LICENSE create mode 100644 config/scripts/fixtures/node-pty-1.1.0/README.md create mode 100644 config/scripts/fixtures/node-pty-1.1.0/windowsPtyAgent.js.txt create mode 100644 config/scripts/fixtures/node-pty-1.1.0/windowsTerminal.js.txt delete mode 100644 config/scripts/linux-packaged-node-pty-floor-child.cjs delete mode 100644 config/scripts/node-pty-job-ownership.cjs create mode 100644 config/scripts/nsis-bun-terminal-uninstall.test.mjs delete mode 100644 config/scripts/packaged-node-pty-prebuild-prune.test.mjs create mode 100644 config/scripts/pty-transcript-bun-runtime.mjs create mode 100644 config/scripts/pty-transcript-recording.mjs create mode 100644 config/scripts/pty-transcript-recording.test.mjs create mode 100644 config/scripts/real-terminal-runtime-workflows.test.ts create mode 100644 config/scripts/rebuild-native-deps-addon-validation.test.mjs delete mode 100644 config/scripts/rebuild-native-deps-node-pty.test.mjs create mode 100644 config/scripts/run-daemon-shutdown-descendants-docker.test.mjs delete mode 100644 config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs delete mode 100644 config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs create mode 100644 config/scripts/run-linux-packaged-terminal-floor-smoke.mjs create mode 100644 config/scripts/run-linux-packaged-terminal-floor-smoke.test.mjs delete mode 100644 config/scripts/verify-packaged-node-pty-job-ownership.cjs delete mode 100644 config/scripts/verify-packaged-node-pty-job-ownership.test.mjs rename config/scripts/{windows-pty-table-stress.cjs => windows-pty-table-stress.mjs} (68%) delete mode 100644 config/scripts/windows-ssh-attach-console-repro.mjs create mode 100644 src/cli/runtime/cli-bun-environment.integration.test.ts create mode 100644 src/main/bundled-bun-test-execution.test.ts create mode 100644 src/main/bundled-bun-test-execution.ts create mode 100644 src/main/claude-accounts/runtime-auth-captured-wsl-owner.test.ts create mode 100644 src/main/codex-accounts/captured-wsl-account-process.test.ts create mode 100644 src/main/codex-accounts/captured-wsl-account-process.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-owner.ts create mode 100644 src/main/codex-accounts/runtime-home-captured-wsl-launch.test.ts create mode 100644 src/main/codex/codex-wsl-resume-file-probe.test.ts create mode 100644 src/main/codex/codex-wsl-resume-file-probe.ts create mode 100644 src/main/codex/codex-wsl-session-resume.test.ts create mode 100644 src/main/codex/codex-wsl-session-resume.ts create mode 100644 src/main/daemon/agent-startup-prompt-bun-fixture.ts create mode 100644 src/main/daemon/agent-startup-prompt-latency.bun.test.ts delete mode 100644 src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts create mode 100644 src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts create mode 100644 src/main/daemon/bun-pty-retired-handle-fixture.ts create mode 100644 src/main/daemon/bun-pty-retired-handle.integration.test.ts create mode 100644 src/main/daemon/bun-pty-windows-io-failure-fixture.ts create mode 100644 src/main/daemon/bun-pty-windows-io-failure.win32.test.ts rename src/main/{providers/local-pty-bun-artifact.integration.test.ts => daemon/daemon-bun-pty-artifact.integration.test.ts} (67%) create mode 100644 src/main/daemon/daemon-bun-runtime-retention.test.ts create mode 100644 src/main/daemon/daemon-bun-runtime-retention.ts create mode 100644 src/main/daemon/daemon-bun-runtime.test.ts create mode 100644 src/main/daemon/daemon-bun-runtime.ts create mode 100644 src/main/daemon/daemon-client-connection-attempt.ts create mode 100644 src/main/daemon/daemon-client-duplex.test.ts create mode 100644 src/main/daemon/daemon-client-transport.ts create mode 100644 src/main/daemon/daemon-fresh-spawn-admission.test.ts create mode 100644 src/main/daemon/daemon-fresh-spawn-admission.ts create mode 100644 src/main/daemon/daemon-guest-spawn-options.ts delete mode 100644 src/main/daemon/daemon-host-manifest.ts create mode 100644 src/main/daemon/daemon-pty-guest-execution.test.ts create mode 100644 src/main/daemon/daemon-pty-router-test-adapter.ts create mode 100644 src/main/daemon/daemon-request-router-transient.test.ts create mode 100644 src/main/daemon/daemon-runtime-launch-pin.test.ts create mode 100644 src/main/daemon/daemon-transient-pty-protocol.ts create mode 100644 src/main/daemon/daemon-transient-pty.test.ts create mode 100644 src/main/daemon/daemon-transient-pty.ts delete mode 100644 src/main/daemon/degraded-daemon-fallback-shutdown.ts delete mode 100644 src/main/daemon/degraded-daemon-fresh-spawn-routing.ts delete mode 100644 src/main/daemon/degraded-daemon-owner-recovery.ts delete mode 100644 src/main/daemon/degraded-daemon-pty-provider.test.ts delete mode 100644 src/main/daemon/degraded-daemon-pty-provider.ts delete mode 100644 src/main/daemon/degraded-daemon-session-routing.ts create mode 100644 src/main/daemon/linux-packaged-terminal-floor-fixture.ts delete mode 100644 src/main/daemon/node-pty-fd-leak.test.ts delete mode 100644 src/main/daemon/node-pty-windows-input-error.win32.test.ts create mode 100644 src/main/daemon/pty-subprocess-io-failure-bun-fixture.ts create mode 100644 src/main/daemon/pty-subprocess-windows-shell-probe.test.ts create mode 100644 src/main/daemon/pty-subprocess/bun-pty-terminal-io.ts create mode 100644 src/main/daemon/pty-subprocess/bun-tests/bash-reply-echo-bun-fixture.ts create mode 100644 src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-fixture.ts create mode 100644 src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-host-fixture.ts rename src/{renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts => main/daemon/pty-subprocess/bun-tests/fish-color-scheme-child-stdin.bun.test.ts} (93%) create mode 100644 src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin-bun-fixture.ts create mode 100644 src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin.bun.test.ts rename src/{shared/pty-reply-echo-shapes.node-pty.test.ts => main/daemon/pty-subprocess/bun-tests/pty-reply-echo-shapes.bun.test.ts} (65%) create mode 100644 src/main/daemon/pty-subprocess/shell-launch-profile.test.ts create mode 100644 src/main/daemon/pty-subprocess/spawn-environment-client-overrides.test.ts create mode 100644 src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts create mode 100644 src/main/daemon/pty-subprocess/spawn-environment-preflight.test.ts create mode 100644 src/main/daemon/pty-subprocess/subprocess-handle-subscriptions.test.ts create mode 100644 src/main/daemon/pty-subprocess/subprocess-windows-shell-identity.test.ts create mode 100644 src/main/daemon/session-output-snapshot-fidelity.test.ts create mode 100644 src/main/daemon/shell-ready-bun-fixture.ts create mode 100644 src/main/daemon/terminal-host-bun-posix-inspection.test.ts create mode 100644 src/main/daemon/tui-idle-agent-bun-fixture.ts create mode 100644 src/main/daemon/windows-shell-preflight-bun-fixture.ts create mode 100644 src/main/ipc/pty-ipc-daemon-test-provider.ts create mode 100644 src/main/ipc/pty-ipc-native-runtime-mock.ts create mode 100644 src/main/ipc/pty-wsl-stable-adoption.test.ts create mode 100644 src/main/ipc/pty/ipc/spawn-wsl-options.ts create mode 100644 src/main/ipc/pty/ipc/spawn-wsl-route.ts create mode 100644 src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts create mode 100644 src/main/ipc/pty/pane/adopt-stable-wsl.test.ts create mode 100644 src/main/ipc/pty/pane/stable-owner-wsl-routing.test.ts create mode 100644 src/main/ipc/pty/pane/stable-pane-attach-provider.ts create mode 100644 src/main/ipc/pty/pane/stable-pane-binding-persistence.ts create mode 100644 src/main/ipc/pty/provider/bind-listeners-wsl.test.ts delete mode 100644 src/main/ipc/pty/provider/local-configure.ts create mode 100644 src/main/ipc/pty/provider/local-inventory-authority.ts create mode 100644 src/main/ipc/pty/provider/wsl-registry.test.ts create mode 100644 src/main/ipc/pty/runtime/local-guest-inventory.test.ts create mode 100644 src/main/ipc/pty/runtime/spawn-guest-environment.ts create mode 100644 src/main/ipc/pty/runtime/spawn-host-environment.ts create mode 100644 src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts create mode 100644 src/main/memory/pty-registry.test.ts rename src/main/persistence/loading-store/{ssh-lease-durable-mutation.ts => relay-lease-durable-mutation.ts} (83%) create mode 100644 src/main/persistence/loading-store/wsl-consumer-recovery-operations.test.ts create mode 100644 src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts create mode 100644 src/main/persistence/loading-store/wsl-consumer-recovery-secrets.ts create mode 100644 src/main/persistence/loading-store/wsl-daemon-recovery.test.ts create mode 100644 src/main/providers/bun-pty-transcript-fixture.ts delete mode 100644 src/main/providers/local-pty-bun-posix-inspection.test.ts delete mode 100644 src/main/providers/local-pty-bun-windows-identity.test.ts delete mode 100644 src/main/providers/local-pty-child-process-verdict.test.ts delete mode 100644 src/main/providers/local-pty-default-shell.test.ts delete mode 100644 src/main/providers/local-pty-finalize-environment.ts delete mode 100644 src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts delete mode 100644 src/main/providers/local-pty-foreground-inspection.ts delete mode 100644 src/main/providers/local-pty-launch-helpers.ts delete mode 100644 src/main/providers/local-pty-launch-plan.ts delete mode 100644 src/main/providers/local-pty-pending-native-spawn.test.ts delete mode 100644 src/main/providers/local-pty-pi-owner-env.test.ts delete mode 100644 src/main/providers/local-pty-provider-foreground-process.test.ts delete mode 100644 src/main/providers/local-pty-provider-io-events.test.ts delete mode 100644 src/main/providers/local-pty-provider-session-inventory.test.ts delete mode 100644 src/main/providers/local-pty-provider-shell-readiness.test.ts delete mode 100644 src/main/providers/local-pty-provider-shutdown.test.ts delete mode 100644 src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts delete mode 100644 src/main/providers/local-pty-provider-spawn-env.test.ts delete mode 100644 src/main/providers/local-pty-provider-spawn-session.test.ts delete mode 100644 src/main/providers/local-pty-provider-state.ts delete mode 100644 src/main/providers/local-pty-provider-test-harness.ts delete mode 100644 src/main/providers/local-pty-provider-types.ts delete mode 100644 src/main/providers/local-pty-provider-windows-shell-launch.test.ts delete mode 100644 src/main/providers/local-pty-provider.ts delete mode 100644 src/main/providers/local-pty-runtime-spawn.ts delete mode 100644 src/main/providers/local-pty-session-activation.ts delete mode 100644 src/main/providers/local-pty-session-operations.ts delete mode 100644 src/main/providers/local-pty-shell-readiness-session.ts delete mode 100644 src/main/providers/local-pty-shell-ready-startup-command.test.ts delete mode 100644 src/main/providers/local-pty-shell-ready-startup-command.ts delete mode 100644 src/main/providers/local-pty-shell-startup-command.node-pty.test.ts delete mode 100644 src/main/providers/local-pty-spawn-environment.ts delete mode 100644 src/main/providers/local-pty-spawn-state.ts delete mode 100644 src/main/providers/local-pty-spawn.ts delete mode 100644 src/main/providers/local-pty-termination.ts delete mode 100644 src/main/providers/local-pty-utils-windows-fallback.test.ts delete mode 100644 src/main/providers/local-pty-utils.test.ts delete mode 100644 src/main/providers/local-pty-utils.ts delete mode 100644 src/main/providers/local-pty-windows-spawn-environment.ts rename src/main/providers/{pty-repaint-wide-char-buffer.node-pty.test.ts => pty-repaint-wide-char-buffer.bun.test.ts} (62%) create mode 100644 src/main/providers/pty-spawn-validation.test.ts create mode 100644 src/main/providers/pty-spawn-validation.ts create mode 100644 src/main/providers/relay-pty-generation-registry.ts create mode 100644 src/main/providers/relay-pty-id-mapping.ts create mode 100644 src/main/providers/relay-pty-provider.ts create mode 100644 src/main/providers/relay-pty-source-credit-registration.test.ts create mode 100644 src/main/providers/relay-pty-source-credit-registration.ts create mode 100644 src/main/providers/relay-pty-source-recovery.ts create mode 100644 src/main/providers/shell-startup-command-bun-fixture.ts create mode 100644 src/main/providers/shell-startup-command-bun.integration.test.ts create mode 100644 src/main/providers/unavailable-pty-provider.test.ts create mode 100644 src/main/providers/unavailable-pty-provider.ts rename src/main/providers/{windows-conpty-wide-char-duplication.node-pty.test.ts => windows-conpty-wide-char-duplication.bun.test.ts} (50%) create mode 100644 src/main/providers/windows-console-runtime.test.ts create mode 100644 src/main/providers/windows-console-runtime.ts delete mode 100644 src/main/pty/node-pty-master-fd-retirement.test.ts delete mode 100644 src/main/pty/node-pty-master-fd-retirement.ts delete mode 100644 src/main/pty/node-pty-pts-name.ts delete mode 100644 src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts create mode 100644 src/main/pty/omp-shell-wrapper-bun-fixture.ts rename src/main/pty/{omp-shell-wrapper.node-pty.test.ts => omp-shell-wrapper.bun.test.ts} (90%) create mode 100644 src/main/pty/posix-pty-process-groups-fixture.ts create mode 100644 src/main/pty/terminal-slave-device.ts create mode 100644 src/main/rate-limits/hidden-daemon-pty.test.ts create mode 100644 src/main/rate-limits/hidden-daemon-pty.ts delete mode 100644 src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts create mode 100644 src/main/startup/codex-launch-preparation.test.ts create mode 100644 src/main/startup/codex-session-resume-launch.test.ts create mode 100644 src/main/terminal-history-fish-session-bun-fixture.ts rename src/main/{terminal-history-fish-session.node-pty.test.ts => terminal-history-fish-session.bun.test.ts} (58%) create mode 100644 src/main/windows/windows-conpty-provider.test.ts create mode 100644 src/main/windows/windows-conpty-provider.ts create mode 100644 src/main/windows/windows-process-tree-runtime-manifest.ts create mode 100644 src/main/wsl/wsl-account-execution-context.ts create mode 100644 src/main/wsl/wsl-daemon-connector-protocol.test.ts create mode 100644 src/main/wsl/wsl-daemon-connector-script.ts create mode 100644 src/main/wsl/wsl-daemon-connector-stream.test.ts create mode 100644 src/main/wsl/wsl-daemon-connector-stream.ts create mode 100644 src/main/wsl/wsl-daemon-endpoint.test.ts create mode 100644 src/main/wsl/wsl-daemon-endpoint.ts create mode 100644 src/main/wsl/wsl-daemon-pty-provider.ts create mode 100644 src/main/wsl/wsl-daemon-reattach-hooks.test.ts create mode 100644 src/main/wsl/wsl-daemon-reattach-hooks.ts create mode 100644 src/main/wsl/wsl-daemon-sessions.test.ts create mode 100644 src/main/wsl/wsl-daemon-sessions.ts create mode 100644 src/main/wsl/wsl-daemon-spawn-route.ts create mode 100644 src/main/wsl/wsl-daemon-start-script.test.ts create mode 100644 src/main/wsl/wsl-daemon-start-script.ts create mode 100644 src/main/wsl/wsl-daemon-transport.test.ts create mode 100644 src/main/wsl/wsl-daemon-transport.ts create mode 100644 src/main/wsl/wsl-distribution-identity.test.ts create mode 100644 src/main/wsl/wsl-distribution-identity.ts create mode 100644 src/main/wsl/wsl-guest-artifact-install-script.ts create mode 100644 src/main/wsl/wsl-guest-owner-preparation.ts create mode 100644 src/main/wsl/wsl-guest-spawn-options.test.ts create mode 100644 src/main/wsl/wsl-guest-spawn-options.ts create mode 100644 src/main/wsl/wsl-guest-terminal-history-script.ts create mode 100644 src/main/wsl/wsl-guest-terminal-history.test.ts create mode 100644 src/main/wsl/wsl-guest-terminal-history.ts create mode 100644 src/main/wsl/wsl-guest-terminal-preparation.test.ts create mode 100644 src/main/wsl/wsl-guest-terminal-preparation.ts create mode 100644 src/relay/pty-shell-wsl-cli.test.ts create mode 100644 src/renderer/src/components/settings/TerminalServiceRetryNotice.test.tsx create mode 100644 src/renderer/src/components/settings/TerminalServiceRetryNotice.tsx delete mode 100644 src/shared/fish-query-reply-child-stdin.node-pty.test.ts delete mode 100644 src/shared/node-pty-spawn-helper.test.ts delete mode 100644 src/shared/node-pty-spawn-helper.ts create mode 100644 src/shared/terminal-process.ts create mode 100644 src/shared/terminal-unicode-provider.test.ts create mode 100644 src/shared/windows-conpty-release.ts create mode 100644 src/shared/wsl-daemon-recovery.ts create mode 100644 src/shared/wsl-pty-consumer-recovery.ts create mode 100644 src/shared/wsl-pty-id.test.ts create mode 100644 src/shared/wsl-pty-id.ts create mode 100644 tests/e2e/daemon-generation-bun-runtime.unit.test.ts create mode 100644 tests/tools/omp-close-lifecycle-bun-fixture.mjs rename tests/tools/windows-pty-native-capability-smoke/{packaged-node-pty-capability-oracle.mjs => packaged-bun-capability-oracle.mjs} (82%) rename tests/tools/windows-pty-native-capability-smoke/{packaged-node-pty-capability-oracle.test.mjs => packaged-bun-capability-oracle.test.mjs} (79%) rename tests/tools/windows-pty-native-capability-smoke/{packaged-node-pty-capability-probe.cjs => packaged-bun-capability-probe.cjs} (83%) rename tests/tools/windows-pty-native-capability-smoke/{packaged-node-pty-capability-probe.test.mjs => packaged-bun-capability-probe.test.mjs} (95%) diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 6aecef6e1524..14517bfd55e6 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -146,8 +146,7 @@ runs: path: ${{ steps.electron-package-cache.outputs.cache-root }} key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} - # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so - # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. + # Cache Windows registry and process-tree addons built by ensure-native-runtime. # The artifacts are ABI-bound, so the key carries the target runtime, the resolved # Node version, and the patch whose contents the build has to match. # Windows extra globs are empty on Linux. No restore-keys: a partial-match key is @@ -173,10 +172,9 @@ runs: uses: actions/cache@v5 with: path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build native/windows-registry/build node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - name: Restore compiled native modules without saving id: native-cache-restore-only @@ -184,10 +182,9 @@ runs: uses: actions/cache/restore@v5 with: path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build native/windows-registry/build node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} # pnpm's bundled gyp_main.py is not executable on fresh Linux runners. - name: Use external node-gyp diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 3bd497e22359..87a61a2d3104 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -19,7 +19,6 @@ on: - 'config/tsconfig*.json' - 'config/scripts/ensure-native-runtime.mjs' - 'config/scripts/rebuild-native-deps.mjs' - - 'config/patches/node-pty@1.1.0.patch' - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' - 'native/windows-registry/**' pull_request: diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 325ce6cb61cd..d34c016682f1 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -26,13 +26,21 @@ on: - 'config/scripts/daemon-endpoint-handover-smoke.mjs' - 'config/scripts/windows-daemon-workspace-close-repro.mjs' - 'config/scripts/verify-computer-native.mjs' - # Why: the native-smoke job boots the built terminal daemon under plain - # Node, so any change to the daemon bundle graph or the main build must - # re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI). + # Runtime and standalone bundle changes must exercise actual daemon startup. + - 'config/scripts/daemon-smoke-launch*' + - 'config/scripts/build-terminal-daemon.mjs' + - 'config/scripts/orcad-entry-build.mjs' + - 'config/scripts/build-cli-runtime.mjs' + - 'config/scripts/build-orcad-bun.mjs' + - 'config/scripts/build-windows-conpty.mjs' + - 'config/licenses/windows-conpty-LICENSE.txt' + - 'resources/licenses/bun/**' + - 'config/bundled-cli-runtime.cjs' + - 'src/shared/orcad-bun-runtime.ts' + - 'src/shared/windows-conpty-release.ts' - 'electron.vite.config.ts' - 'config/build-plugins/**' - 'src/main/daemon/**' - - 'src/main/providers/local-pty-provider.ts' - 'src/main/providers/windows-shell-args.ts' - 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts' - 'native/computer-use-macos/**' @@ -126,10 +134,9 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm run build:electron-vite:parallel - # Why: boot the BUILT daemon-entry under plain Node the way production - # forks it. v1.4.129-rc.1 shipped a daemon that exited at module load - # (leaked electron require) while every other check passed; this fails - # the PR when the built daemon cannot start on ubuntu-22.04 / windows. + - run: pnpm run build:terminal-daemon + - run: pnpm exec vitest run --config config/vitest.config.ts config/scripts/daemon-smoke-launch.test.mjs + # Exercise the shipped Bun runtime and standalone daemon bundle together. - name: Daemon boot smoke run: node config/scripts/daemon-boot-smoke.mjs # Why: a daemon that lost its endpoint name used to delete the replacement's diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 72edb50b0af0..6f5bfbce752e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -57,7 +57,7 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} - # Why: the build's plain-Node daemon smoke load resolves node-pty. + # Build checks require the Electron runtime and Windows native addons. - uses: ./.github/actions/install-node-dependencies with: native-runtime: node @@ -77,6 +77,8 @@ jobs: VITE_EXPOSE_STORE: 'true' run: | status=0 + pnpm run build:cli || status=1 + pnpm run build:terminal-daemon || status=1 pnpm run build:relay & relay_pid=$! npx electron-vite build --mode e2e || status=1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 833b08995913..ed3d2d06e245 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -490,7 +490,7 @@ jobs: # present, so without this the fish barrier is only covered by config-shape # assertions and never actually exercised. # Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and - # fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships + # fish-color-scheme-child-stdin.bun.test.ts (#9993) needs it. Noble ships # 3.7, so the PPA is what makes that lane real. - name: Install zsh and fish run: | @@ -560,25 +560,28 @@ jobs: with: native-runtime: node + - name: Prepare pinned Bun for real terminal tests + run: node config/scripts/build-orcad-bun.mjs --runtime-only --out-dir out/orcad + - name: Test real shell contracts run: | node config/scripts/run-bun-profile-tests.mjs --maxWorkers=1 src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \ src/main/daemon/shell-ready.test.ts \ - src/main/daemon/node-pty-fd-leak.test.ts \ + src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts \ src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/codex-shell-launch-preflight.test.ts \ src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ - src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + src/main/pty/omp-shell-wrapper.bun.test.ts \ src/main/shell-startup-feature-channel.test.ts \ - src/main/terminal-history-fish-session.node-pty.test.ts \ + src/main/terminal-history-fish-session.bun.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - src/shared/pty-reply-echo-shapes.node-pty.test.ts \ + src/main/daemon/pty-subprocess/bun-tests/fish-color-scheme-child-stdin.bun.test.ts \ + src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin.bun.test.ts \ + src/main/daemon/pty-subprocess/bun-tests/pty-reply-echo-shapes.bun.test.ts \ src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts @@ -629,7 +632,7 @@ jobs: persist-credentials: false # Why no native-runtime: the provider drives the prebuilt agent-browser binary - # shipped in node_modules and never touches node-pty. + # shipped in node_modules without loading native terminal code. - uses: ./.github/actions/install-node-dependencies # Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an @@ -668,7 +671,7 @@ jobs: persist-credentials: false # Why no native-runtime: the builder is esbuild and the render check is a browser. Nothing - # in this job loads node-pty. + # in this job loads native terminal code. - uses: ./.github/actions/install-node-dependencies with: native-runtime: node @@ -843,7 +846,7 @@ jobs: status=0 pnpm run build:cli || status=1 - scripts=(build:relay build:electron-vite:parallel) + scripts=(build:relay build:terminal-daemon build:electron-vite:parallel) pids=() for script in "${scripts[@]}"; do pnpm run "$script" & @@ -967,16 +970,16 @@ jobs: uses: actions/cache/save@v5 with: path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build native/windows-registry/build node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} # Keep cold PowerShell probes out of concurrent native and renderer tests. - name: Test installer PowerShell capability run: >- pnpm exec vitest run --config config/vitest.config.ts config/scripts/nsis-process-check.test.mjs + config/scripts/nsis-bun-terminal-uninstall.test.mjs - name: Prepare pinned Bun for CLI boundary tests run: | @@ -985,14 +988,14 @@ jobs: # vitest runs here directly rather than through `pnpm test`, so the addon # assertions only hold once install-node-dependencies has rebuilt natives. + - name: Prepare pinned Bun for real terminal tests + run: node config/scripts/build-orcad-bun.mjs --runtime-only --out-dir out/orcad + - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts config/scripts/rebuild-native-deps.test.mjs config/scripts/rebuild-native-deps-windows-process-tree.test.mjs - config/scripts/rebuild-native-deps-node-pty.test.mjs - config/scripts/ensure-native-runtime-job-ownership.test.mjs - config/scripts/verify-packaged-node-pty-job-ownership.test.mjs config/scripts/windows-pe-machine.test.mjs config/scripts/script-module-dependencies.test.mjs src/main/windows-registry-addon.test.ts @@ -1005,8 +1008,8 @@ jobs: src/main/browser/browser-route-webrtc-egress.electron.test.ts src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts - src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts - src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts + src/main/providers/windows-conpty-wide-char-duplication.bun.test.ts + src/main/providers/pty-repaint-wide-char-buffer.bun.test.ts src/shared/child-process/windows-command-line.win32.test.ts src/shared/child-process/windows-cmd-shim-resolution.test.ts src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts @@ -1015,6 +1018,7 @@ jobs: src/main/codex/windows-hook-command.test.ts src/main/codex/windows-hook-upgrade.test.ts src/main/windows/windows-pty-job.win32.test.ts + src/main/daemon/bun-pty-windows-io-failure.win32.test.ts src/main/windows/windows-msys-job.win32.test.ts src/main/providers/agent-foreground-process-git-bash.win32.test.ts src/main/windows/windows-host-job.win32.test.ts @@ -1060,10 +1064,9 @@ jobs: uses: actions/cache@v5 with: path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build native/windows-registry/build node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - name: Prepare Electron native runtime run: node config/scripts/ensure-native-runtime.mjs --runtime=electron diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 2bd95518e09c..e080112ad3f9 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1506,7 +1506,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Load packaged node-pty on the Linux floor + - name: Qualify packaged Bun terminal on the Linux floor if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64' uses: nick-fields/retry@v4 with: @@ -1514,7 +1514,7 @@ jobs: max_attempts: 3 retry_wait_seconds: 30 command: >- - node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs + node config/scripts/run-linux-packaged-terminal-floor-smoke.mjs --app-dir ${{ matrix.unpacked_dir }} # Why: SignPath signs GitHub workflow artifacts, so Windows builds must @@ -1541,22 +1541,12 @@ jobs: # app.asar - the exact defect this chain exists to remove. ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe - - name: Verify Windows node-pty ConPTY runtime + - name: Verify Windows Bun and ConPTY runtime if: matrix.platform == 'win' && github.run_attempt == 1 shell: pwsh run: | - $runtimeDir = 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' - $requiredFiles = @( - "$runtimeDir/conpty.node", - "$runtimeDir/conpty/conpty.dll", - "$runtimeDir/conpty/OpenConsole.exe" - ) - foreach ($file in $requiredFiles) { - if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { - throw "Missing Windows node-pty runtime file: $file" - } - Get-Item -LiteralPath $file - } + node -e "require('./config/bundled-cli-runtime.cjs').verifyCliRuntimeDirectory('dist/win-unpacked/resources/cli-runtime', 'win32', 'x64')" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Install SignPath PowerShell module if: matrix.platform == 'win' && github.run_attempt == 1 @@ -1564,7 +1554,7 @@ jobs: # ── Windows inner-binary signing (issue #7785) ───────────────────── # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE - # files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip + # files (Orca.exe, Bun, native addons, DLLs) are signed via a separate zip # request, then the installer is rebuilt from the signed tree before the # existing installer signing request below. The NSIS uninstaller rides # this same request (it is the MDE update cluster: old-uninstaller.exe / @@ -1606,8 +1596,8 @@ jobs: if (-not ($list -contains 'Orca.exe')) { throw 'Orca.exe was not staged for signing; unpacked layout changed?' } - if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) { - throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.' + if (-not ($list | Where-Object { $_ -like '*windows_process_tree.node' })) { + throw 'Windows process inspection addon was not staged for signing.' } Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n") @@ -1789,6 +1779,8 @@ jobs: $failures | ForEach-Object { Write-Host "::error::$_" } throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } + node config/scripts/finalize-signed-cli-runtime.mjs $root 'signing-stage' + if ($LASTEXITCODE -ne 0) { throw 'Signed runtime identity finalization failed.' } # Why gated separately from the inner restore above: if SignPath's # windows-inner-binaries-zip artifact configuration does not (yet) cover the diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ceb1485e237e..a209ca6ea860 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -52,6 +52,9 @@ jobs: - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + - name: Prepare pinned Bun for real terminal tests + run: node config/scripts/build-orcad-bun.mjs --runtime-only --out-dir out/orcad + - name: Test shard env: ORCA_BALANCE_UNIT_SHARDS: '1' @@ -61,19 +64,19 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ --exclude=src/main/daemon/shell-ready.test.ts \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ + --exclude=src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper.bun.test.ts \ --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ + --exclude=src/main/terminal-history-fish-session.bun.test.ts \ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ + --exclude=src/main/daemon/pty-subprocess/bun-tests/fish-color-scheme-child-stdin.bun.test.ts \ + --exclude=src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin.bun.test.ts \ + --exclude=src/main/daemon/pty-subprocess/bun-tests/pty-reply-echo-shapes.bun.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/relay-region-compatibility.unit.test.ts \ diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index a5d194c1113c..7ad43f376f5f 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -1,7 +1,7 @@ # Windows inner-binary signing rehearsal. # # Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed -# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires +# inner binaries (Orca.exe, Bun, native addons, DLLs — see issue #7785) requires # a two-request flow: sign the unpacked PE files first, then build the NSIS # installer from the signed tree, then sign the installer. The NSIS uninstaller # rides that same first request — it is captured through electron-builder's sign @@ -164,8 +164,8 @@ jobs: if (-not ($list -contains 'Orca.exe')) { throw 'Orca.exe was not staged for signing; unpacked layout changed?' } - if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) { - throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.' + if (-not ($list | Where-Object { $_ -like '*windows_process_tree.node' })) { + throw 'Windows process inspection addon was not staged for signing.' } Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n") @@ -252,6 +252,8 @@ jobs: $failures | ForEach-Object { Write-Host "::error::$_" } throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } + node config/scripts/finalize-signed-cli-runtime.mjs $root 'signing-stage' + if ($LASTEXITCODE -ne 0) { throw 'Signed runtime identity finalization failed.' } - name: Restore signed uninstaller for the installer rebuild shell: pwsh diff --git a/config/bundled-cli-runtime.cjs b/config/bundled-cli-runtime.cjs index 2c31878d8523..04bfea963317 100644 --- a/config/bundled-cli-runtime.cjs +++ b/config/bundled-cli-runtime.cjs @@ -18,7 +18,12 @@ function cliRuntimeExtraResource(platform) { return { from: `out/cli-runtime/${platform}-\${arch}`, to: 'cli-runtime', - filter: [cliRuntimeFilename(platform), 'runtime.json', 'LICENSE.md'] + filter: [ + cliRuntimeFilename(platform), + 'runtime.json', + 'LICENSE.md', + ...(platform === 'win32' ? ['conpty/**'] : []) + ] } } @@ -40,12 +45,52 @@ function verifyCliRuntimeDirectory(directory, platform, arch) { if (!license.equals(expectedLicense)) { throw new Error(`CLI runtime license notice mismatch for ${expectedTarget}`) } + if (platform === 'win32') { + verifyWindowsConptyDirectory(join(directory, 'conpty'), arch) + } const binary = readFileSync(join(directory, cliRuntimeFilename(platform))) if (createHash('sha256').update(binary).digest('hex') !== manifest.sha256) { throw new Error(`CLI runtime checksum mismatch for ${expectedTarget}`) } } +function verifyWindowsConptyDirectory(directory, arch) { + const { + WINDOWS_CONPTY_VERSION, + WINDOWS_CONPTY_ARCHIVE, + WINDOWS_CONPTY_FILES + } = require('../src/shared/windows-conpty-release.ts') + const files = WINDOWS_CONPTY_FILES[arch] + if (!files) { + throw new Error(`Unsupported ConPTY architecture: ${arch}`) + } + const manifest = JSON.parse(readFileSync(join(directory, 'conpty.json'), 'utf8')) + if ( + manifest.version !== WINDOWS_CONPTY_VERSION || + manifest.arch !== arch || + manifest.source !== WINDOWS_CONPTY_ARCHIVE.url || + manifest.archiveSha256 !== WINDOWS_CONPTY_ARCHIVE.sha256 + ) { + throw new Error(`Invalid ConPTY manifest for ${arch}`) + } + for (const [filename, expected] of Object.entries(files)) { + const actual = createHash('sha256') + .update(readFileSync(join(directory, filename))) + .digest('hex') + if (manifest.files?.[filename] !== expected || actual !== expected) { + throw new Error(`ConPTY checksum mismatch: ${arch}/${filename}`) + } + } + const license = readFileSync(join(directory, 'LICENSE.txt')) + if (!license.equals(readFileSync(join(__dirname, 'licenses', 'windows-conpty-LICENSE.txt')))) { + throw new Error('ConPTY license notice mismatch') + } + const nuspec = readFileSync(join(directory, 'Microsoft.Windows.Console.ConPTY.nuspec')) + if (createHash('sha256').update(nuspec).digest('hex') !== WINDOWS_CONPTY_ARCHIVE.nuspecSha256) { + throw new Error('ConPTY package metadata checksum mismatch') + } +} + function assertBundledCliRuntimeBuilt(platform, archEnum, projectDir = join(__dirname, '..')) { const arch = { 1: 'x64', 3: 'arm64' }[archEnum] const target = cliRuntimeTarget(platform, arch) @@ -68,5 +113,6 @@ module.exports = { cliRuntimeFilename, cliRuntimeExtraResource, verifyCliRuntimeDirectory, + verifyWindowsConptyDirectory, assertBundledCliRuntimeBuilt } diff --git a/config/docker/daemon-shutdown-descendants/Dockerfile b/config/docker/daemon-shutdown-descendants/Dockerfile index b012d5738e3d..ecedf97749d3 100644 --- a/config/docker/daemon-shutdown-descendants/Dockerfile +++ b/config/docker/daemon-shutdown-descendants/Dockerfile @@ -1,13 +1,13 @@ -FROM node:22-bookworm-slim +FROM debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive -ENV NODE_PATH=/usr/local/lib/node_modules +ENV ORCA_BACKGROUND_LAUNCH=1 RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential python3 procps util-linux \ - && rm -rf /var/lib/apt/lists/* \ - && npm install --global --no-fund --no-audit node-pty@1.1.0 + && apt-get install -y --no-install-recommends libstdc++6 procps util-linux \ + && rm -rf /var/lib/apt/lists/* +COPY --chmod=755 bun-runtime /usr/local/bin/bun-runtime COPY run-case.sh /usr/local/bin/run-daemon-shutdown-descendants COPY fixture.cjs /opt/daemon-shutdown-descendants/fixture.cjs diff --git a/config/docker/daemon-shutdown-descendants/README.md b/config/docker/daemon-shutdown-descendants/README.md index 80a24a8f64e2..29761520a70e 100644 --- a/config/docker/daemon-shutdown-descendants/README.md +++ b/config/docker/daemon-shutdown-descendants/README.md @@ -6,7 +6,7 @@ Run from the repository root with Docker running: ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-daemon-shutdown-descendants-docker.mjs ``` -The runner bundles the production `TerminalHost` and native PTY wrapper from the working tree. By default it runs the candidate only. Pass `--baseline ` to add a red/green run; the baseline loads the five teardown modules from that ref without changing the checkout. Both run in Linux with `node-pty` 1.1.0. +The runner bundles the production `TerminalHost` and native PTY wrapper from the working tree. By default it runs the candidate only. Pass `--baseline ` to add a red/green run; the baseline loads the five teardown modules from that ref without changing the checkout. Both run in Linux with the pinned bundled Bun runtime and the current Bun PTY backend. The baseline changes teardown logic only; this is not a comparison between terminal engines. A live PTY shell owns a child that called `setsid` and ignores `SIGTERM`. The fixture verifies those relationships, calls `host.dispose()`, and immediately exits its process. A separate supervisor checks that the child has stopped, an unrelated canary is still running, and shutdown finished within the daemon's five-second limit. The baseline must leave the child running; the candidate must stop it. diff --git a/config/docker/daemon-shutdown-descendants/bundle-entry.ts b/config/docker/daemon-shutdown-descendants/bundle-entry.ts index 14a9d7026078..a132632148ea 100644 --- a/config/docker/daemon-shutdown-descendants/bundle-entry.ts +++ b/config/docker/daemon-shutdown-descendants/bundle-entry.ts @@ -1,2 +1,4 @@ export { TerminalHost } from '../../../src/main/daemon/terminal-host' export { createDaemonPtySubprocessHandle } from '../../../src/main/daemon/pty-subprocess/subprocess-handle' +export { spawnBunPty } from '../../../src/main/daemon/pty-subprocess/bun-pty-process' +export { spawnProcess } from '../../../src/shared/child-process/run-process' diff --git a/config/docker/daemon-shutdown-descendants/fixture.cjs b/config/docker/daemon-shutdown-descendants/fixture.cjs index 409d21d20e55..0c9752a3d766 100644 --- a/config/docker/daemon-shutdown-descendants/fixture.cjs +++ b/config/docker/daemon-shutdown-descendants/fixture.cjs @@ -1,20 +1,24 @@ const fs = require('node:fs') -const { spawn } = require('node:child_process') -const pty = require('node-pty') const bundle = process.argv[2] const resultPath = process.argv[3] async function main() { - const { TerminalHost, createDaemonPtySubprocessHandle } = require(bundle) + const { TerminalHost, createDaemonPtySubprocessHandle, spawnBunPty, spawnProcess } = require( + bundle + ) let output = '' - const canary = spawn('/bin/sh', ['-c', 'while :; do sleep 1; done'], { + const canary = spawnProcess({ + program: '/bin/sh', + args: ['-c', 'while :; do sleep 1; done'], stdio: 'ignore' }) canary.unref() const childScript = 'setsid /bin/sh -c \'trap "" TERM; while :; do sleep 1; done\' & echo ORCA_DESCENDANT:$!; while :; do sleep 1; done' - const native = pty.spawn('/bin/sh', ['-c', childScript], { + const native = spawnBunPty({ + file: '/bin/sh', + args: ['-c', childScript], cols: 80, rows: 24, cwd: '/tmp', diff --git a/config/docker/daemon-shutdown-descendants/run-case.sh b/config/docker/daemon-shutdown-descendants/run-case.sh index a8ce8414d745..0278d1d90dea 100755 --- a/config/docker/daemon-shutdown-descendants/run-case.sh +++ b/config/docker/daemon-shutdown-descendants/run-case.sh @@ -3,16 +3,17 @@ set -eu bundle=${1:?bundle path is required} mode=${2:?mode is required} -result_dir=/tmp/daemon-shutdown-descendants -mkdir -p "$result_dir" +fixture=${3:-/opt/daemon-shutdown-descendants/fixture.cjs} +result_dir=$(mktemp -d /tmp/daemon-shutdown-descendants.XXXXXX) +trap 'rm -rf "$result_dir"' EXIT # The fixture exits like daemon-entry after dispose(). This shell remains as a # supervisor so a detached descendant can be inspected after the daemon exits. -timeout --kill-after=2s 20s node /opt/daemon-shutdown-descendants/fixture.cjs "$bundle" "$result_dir/fixture.json" +timeout --kill-after=2s 20s bun-runtime --no-env-file "$fixture" "$bundle" "$result_dir/fixture.json" -child_pid=$(node -e "const r=require(process.argv[1]); process.stdout.write(String(r.childPid))" "$result_dir/fixture.json") -canary_pid=$(node -e "const r=require(process.argv[1]); process.stdout.write(String(r.canaryPid))" "$result_dir/fixture.json") -shutdown_ms=$(node -e "const r=require(process.argv[1]); process.stdout.write(String(r.shutdownMs))" "$result_dir/fixture.json") +child_pid=$(bun-runtime --no-env-file -e "const r=require(process.argv[1]); process.stdout.write(String(r.childPid))" "$result_dir/fixture.json") +canary_pid=$(bun-runtime --no-env-file -e "const r=require(process.argv[1]); process.stdout.write(String(r.canaryPid))" "$result_dir/fixture.json") +shutdown_ms=$(bun-runtime --no-env-file -e "const r=require(process.argv[1]); process.stdout.write(String(r.shutdownMs))" "$result_dir/fixture.json") process_state() { awk '{ print $3 }' "/proc/$1/stat" 2>/dev/null || true diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 68c4bc9a99f9..c44b1cf6e215 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -30,9 +30,6 @@ const { bundledRipgrepMacSignIgnore, finalizePackagedRipgrep } = require('./bundled-ripgrep-resources.cjs') -const { - verifyPackagedWindowsNodePty -} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs') const { signWindowsUninstallerViaSignPath } = require('./scripts/windows-uninstaller-signing.cjs') @@ -116,6 +113,11 @@ const emojiShortcodeDatasetResource = { } const commonExtraResources = [ relayExtraResource, + { + from: 'out/terminal-daemon', + to: 'terminal-daemon', + filter: ['daemon-entry.js', 'windows-bun-pty-gate-entry.js'] + }, ...bundledRipgrepExtraResources, bundledPluginResources, skillFreshnessResources, @@ -192,10 +194,12 @@ module.exports = { '!**/.vscode/*', // Why: these repo-only inputs are either bundled into out/ or copied via // extraResources. Shipping them in app.asar bloats the desktop bundle. + '!.build{,/**/*}', '!src{,/**/*}', '!out/orcad{,/**/*}', '!out/orcad-template{,/**/*}', '!out/cli-runtime{,/**/*}', + '!out/terminal-daemon{,/**/*}', '!out/.orcad-*{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', @@ -318,12 +322,32 @@ module.exports = { assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) assertBundledRipgrepInstalled() assertMobileWebBundleBuilt(mobileWebBundleDir) + assertPackagedDaemonEntryExists( + join(context.packager?.projectDir ?? join(__dirname, '..'), 'out'), + context.electronPlatformName + ) assertBundledCliRuntimeBuilt( context.electronPlatformName, context.arch, context.packager?.projectDir ) }, + afterSign: (context) => { + if (context.electronPlatformName !== 'darwin') { + return + } + verifyCliRuntimeDirectory( + join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.app`, + 'Contents', + 'Resources', + 'cli-runtime' + ), + 'darwin', + { 1: 'x64', 3: 'arm64' }[context.arch] + ) + }, afterPack: async (context) => { const resourcesDir = context.electronPlatformName === 'darwin' @@ -381,17 +405,10 @@ module.exports = { }) } verifyPackagedMainRuntimeDeps(resourcesDir) - // Why: boot the packaged daemon-entry under plain Node, but only for the - // slice matching the packaging host's arch — daemon-entry.js is JS, yet it - // require()s the native (N-API) node-pty for the TARGET arch, which the host - // Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2, - // arm64=3, universal=4 (universal contains the host slice, so run it). + // The bundled runtime and native dependencies can only boot on the matching host architecture. const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 } const hostArchEnum = archEnumByNodeArch[process.arch] const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4 - if (context.electronPlatformName === 'win32') { - verifyPackagedWindowsNodePty(resourcesDir, context.arch, { canExecuteTargetArch }) - } verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, { executeCommands: canExecuteTargetArch }) @@ -401,12 +418,10 @@ module.exports = { ) } if (canExecuteTargetArch) { - verifyPackagedDaemonEntryBoots(resourcesDir) + verifyPackagedDaemonEntryBoots(resourcesDir, { platform: context.electronPlatformName }) } else { - // Why: a cross-arch slice can't be booted by the host Node, but the - // unpacked entry must still exist — its absence is a layout regression - // regardless of arch, so only the boot is skipped, not the check. - assertPackagedDaemonEntryExists(resourcesDir) + // Cross-architecture packages still require the complete daemon entry layout. + assertPackagedDaemonEntryExists(resourcesDir, context.electronPlatformName) console.log( `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})` ) @@ -514,7 +529,8 @@ module.exports = { icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', - signIgnore: bundledRipgrepMacSignIgnore, + // Keep Bun's upstream Developer ID signature and pinned executable bytes. + signIgnore: [...bundledRipgrepMacSignIgnore, '/cli-runtime/bun-runtime$'], extendInfo: { NSAppleEventsUsageDescription: 'Orca allows terminal-launched developer tools to automate local apps when you request it.', @@ -686,12 +702,7 @@ module.exports = { afterRemove: 'resources/linux/packaging/after-remove.sh' }, beforeBuild: electronBuilderNativeRebuild, - // Why: must be true so that electron-builder rebuilds native modules - // (node-pty) for each target architecture when producing dual-arch macOS - // builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner - // packages arm64 binaries into the x64 DMG, causing "posix_spawnp failed" - // on Intel Macs. The beforeBuild hook performs Orca's targeted rebuild and - // returns false so electron-builder does not rebuild optional cpu-features. + // beforeBuild performs the target-architecture rebuild and skips optional cpu-features. npmRebuild: true, publish: { provider: 'github', diff --git a/config/licenses/windows-conpty-LICENSE.txt b/config/licenses/windows-conpty-LICENSE.txt new file mode 100644 index 000000000000..017b9885a46a --- /dev/null +++ b/config/licenses/windows-conpty-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/config/nsis/orca-bun-terminal-uninstall.nsh b/config/nsis/orca-bun-terminal-uninstall.nsh new file mode 100644 index 000000000000..933efe3d3e27 --- /dev/null +++ b/config/nsis/orca-bun-terminal-uninstall.nsh @@ -0,0 +1,12 @@ +; This root is outside legacy profile-scoped pruning; runtime copies retain their shipped name. +!macro ORCA_UNINSTALL_BUN_TERMINAL_HOST + ; Query and pin each process handle before termination; uncertainty retains the runtime files. + StrCpy $0 "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + IfFileExists "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" 0 +2 + StrCpy $0 "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" + nsExec::Exec /TIMEOUT=15000 `"$0" -NoProfile -NonInteractive -Command "$$ErrorActionPreference='Stop';try{if(!$$env:LOCALAPPDATA){throw 1};$$r=[IO.Path]::Combine($$env:LOCALAPPDATA,'Orca','terminal-daemon-host')+'\';$$s=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;$$owned=@(Get-CimInstance Win32_Process|? Name -eq 'bun-runtime.exe'|%{if(!$$_.ExecutablePath){throw 1};if($$_.ExecutablePath.StartsWith($$r,[StringComparison]::OrdinalIgnoreCase)){if((Invoke-CimMethod -InputObject $$_ -MethodName GetOwnerSid).Sid -ne $$s){throw 1};$$p=Get-Process -Id $$_.ProcessId;$$null=$$p.Handle;if($$p.StartTime.ToUniversalTime().ToString('yyyyMMddHHmmssffffff') -ne $$_.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssffffff') -or !$$p.Path.StartsWith($$r,[StringComparison]::OrdinalIgnoreCase)){throw 1};$$p}});foreach($$p in $$owned){if(!$$p.HasExited){$$p.Kill();if(!$$p.WaitForExit(5000)){throw 1}};$$p.Dispose()};exit 0}catch{exit 1}"` + Pop $0 + ${if} $0 == 0 + RMDir /r "$LOCALAPPDATA\Orca\terminal-daemon-host" + ${endIf} +!macroend diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index a6fb31f9aec9..ab4c1660253a 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -4,6 +4,7 @@ ; customUnInstall hook Orca needs lives here. !include "${__FILEDIR__}\orca-process-check.nsh" +!include "${__FILEDIR__}\orca-bun-terminal-uninstall.nsh" ; --------------------------------------------------------------------------- ; Markdown "Open with Orca" (issue #10138) @@ -90,6 +91,7 @@ Pop $0 nsExec::Exec 'taskkill /F /IM "orca-terminal-daemon.exe" $2' Pop $0 + !insertmacro ORCA_UNINSTALL_BUN_TERMINAL_HOST Pop $2 Pop $1 Pop $0 diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index c88d70350eb0..e569bd04a752 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -1,15 +1,6 @@ -const { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - rmSync -} = require('node:fs') +const { existsSync, readFileSync, readdirSync, realpathSync, rmSync } = require('node:fs') const { dirname, join, resolve } = require('node:path') const { builtinModules, createRequire } = require('node:module') -const { PE_MACHINE, readPeMachine } = require('./scripts/windows-pe-machine.cjs') const projectDir = resolve(__dirname, '..') const requireFromProject = createRequire(join(projectDir, 'package.json')) @@ -22,7 +13,6 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'electron-updater', 'i18next', 'jsonc-parser', - 'node-pty', 'posthog-node', 'proper-lockfile', // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. @@ -39,12 +29,6 @@ const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [ '@orca/windows-registry' ] -const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = { - darwin: 'darwin-', - linux: 'linux-', - win32: 'win32-' -} -const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = { darwin: 'watcher-darwin', linux: 'watcher-linux', @@ -282,14 +266,6 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a } } -function normalizeNodePtyWindowsArch(electronArch) { - const architecture = normalizeElectronArchitecture(electronArch) - if (architecture !== 'x64' && architecture !== 'arm64') { - throw new Error(`Unsupported packaged node-pty Windows architecture: ${architecture}`) - } - return architecture -} - function normalizeElectronArchitecture(electronArch) { const architecture = typeof electronArch === 'number' @@ -303,143 +279,6 @@ function normalizeElectronArchitecture(electronArch) { return architecture } -function pruneNodePtyNativeDirectories(directory, platformPrefix, electronArch, allowsSuffix) { - if (!existsSync(directory)) { - return - } - const architecture = normalizeElectronArchitecture(electronArch) - const targetPrefix = `${platformPrefix}${architecture}` - const platformPrefixes = Object.values(NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM) - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (!entry.isDirectory() || !platformPrefixes.some((prefix) => entry.name.startsWith(prefix))) { - continue - } - const matchesTarget = - entry.name.startsWith(platformPrefix) && - (entry.name === targetPrefix || (allowsSuffix && entry.name.startsWith(`${targetPrefix}-`))) - if (!matchesTarget) { - rmSync(join(directory, entry.name), { recursive: true, force: true }) - } - } -} - -function findNodePtyConptySourceDir(nodePtyDir, windowsArch) { - const conptyRoot = join(nodePtyDir, 'third_party', 'conpty') - if (!existsSync(conptyRoot)) { - throw new Error(`Packaged node-pty is missing ${conptyRoot}`) - } - for (const entry of readdirSync(conptyRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue - } - const sourceDir = join(conptyRoot, entry.name, `win10-${windowsArch}`) - if (existsSync(sourceDir)) { - return sourceDir - } - } - throw new Error(`Packaged node-pty has no ConPTY payload for win10-${windowsArch}`) -} - -function ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch) { - const releaseDir = join(nodePtyDir, 'build', 'Release') - if (!existsSync(join(releaseDir, 'conpty.node'))) { - return - } - - const runtimeDir = join(releaseDir, 'conpty') - const missingRuntimeFiles = NODE_PTY_CONPTY_RUNTIME_FILES.filter( - (filename) => !existsSync(join(runtimeDir, filename)) - ) - if (missingRuntimeFiles.length === 0) { - return - } - - const windowsArch = normalizeNodePtyWindowsArch(electronArch) - const sourceDir = findNodePtyConptySourceDir(nodePtyDir, windowsArch) - mkdirSync(runtimeDir, { recursive: true }) - for (const filename of missingRuntimeFiles) { - const sourceFile = join(sourceDir, filename) - if (!existsSync(sourceFile)) { - throw new Error(`Packaged node-pty is missing ${sourceFile}`) - } - // Why: node-pty's Windows addon loads conpty.dll relative to conpty.node, - // but its install script can run before electron-builder gathers resources. - copyFileSync(sourceFile, join(runtimeDir, filename)) - } -} - -/** Whether node-pty's source build holds a conpty.node the `electronArch` slice could load. */ -function conptyTargetsArch(nodePtyDir, electronArch) { - const releaseAddon = join(nodePtyDir, 'build', 'Release', 'conpty.node') - if (!existsSync(releaseAddon)) { - return false - } - // Null (not a PE) counts as unloadable, so a truncated or quarantined build keeps the fallback. - return readPeMachine(releaseAddon) === PE_MACHINE[normalizeNodePtyWindowsArch(electronArch)] -} - -function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch) { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - if (!existsSync(nodePtyDir)) { - return - } - - // Why delete only conpty.node: node-pty's loader tries build/Release, then - // build/Debug, then prebuilds/-, swallowing every failure in - // between. Only the source build carries Orca's job-object exports, so an ABI - // mismatch or an AV quarantine of build/Release/conpty.node would silently - // fall through to the UNPATCHED prebuild -- teardown back to guessing by PID - // ancestry, with no error anywhere. - // - // Why NOT the whole prebuilds/ tree: Orca's own patch deletes the - // `conpty_console_list` and winpty `pty` gyp targets, so a Windows source - // build emits conpty.node and nothing else. conpty_console_list.node, - // pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them - // silently kills console-membership probing (the forked agent throws at - // require, and its caller resolves null with silent: true), and removes the - // winpty backend that node-pty still selects below Windows build 18309. - // - // Why the arch check: a cross-HOST package can copy a build/Release that is not a Windows - // binary at all, so its mere presence does not mean the target can load it -- deleting the - // target-arch prebuild would then remove the only loadable binary. This used to approximate - // that with `electronArch === process.arch`, which also skipped the arm64 slice cross-built on - // an x64 Windows host -- a rebuild that DOES emit a correct arm64 addon. That slice kept the - // unpatched prebuild as a reachable fallback for any later load failure of build/Release. - // Read the PE header instead of guessing. - if (electronPlatformName === 'win32' && conptyTargetsArch(nodePtyDir, electronArch)) { - const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`) - for (const staleFallback of ['conpty.node', 'conpty.pdb']) { - rmSync(join(prebuildDir, staleFallback), { force: true }) - } - } - - const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName] - if (allowedPrebuildPrefix) { - pruneNodePtyNativeDirectories( - join(nodePtyDir, 'prebuilds'), - allowedPrebuildPrefix, - electronArch, - false - ) - // Why: sequential cross-arch rebuilds accumulate ABI-tagged outputs here. - pruneNodePtyNativeDirectories( - join(nodePtyDir, 'bin'), - allowedPrebuildPrefix, - electronArch, - true - ) - } - - if (electronPlatformName === 'win32') { - ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch) - } else { - // Why: conpty is Windows-only and node-pty resolves runtime binaries from - // build/Release or prebuilds/-, not third_party/conpty. - rmSync(join(nodePtyDir, 'third_party', 'conpty'), { recursive: true, force: true }) - rmSync(join(nodePtyDir, 'deps', 'winpty'), { recursive: true, force: true }) - } -} - function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electronArch) { const parcelDir = join(resourcesDir, 'node_modules', '@parcel') if (!existsSync(parcelDir)) { @@ -593,7 +432,6 @@ function assertPackagedNativeVariantsInstalled(electronPlatformName, electronArc function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) { const architecture = normalizeElectronArchitecture(electronArch) - prunePackagedNodePty(resourcesDir, electronPlatformName, architecture) prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture) // Why before the filename walk: zod/src is deleted wholesale, so walking it first is wasted work. prunePackagedZodSources(resourcesDir) @@ -618,9 +456,7 @@ module.exports = { createPackagedRuntimeNodeModuleResources, findAsarEntry, isPackagedExternalSpecifier, - normalizeNodePtyWindowsArch, packageNameFromSpecifier, - prunePackagedNodePty, prunePackagedParcelWatcher, prunePackagedRuntimeNodeModules, prunePackagedRuntimeTypeAndSourceMapArtifacts, diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch deleted file mode 100644 index 40b240f3296c..000000000000 --- a/config/patches/node-pty@1.1.0.patch +++ /dev/null @@ -1,1442 +0,0 @@ -diff --git a/binding.gyp b/binding.gyp -index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644 ---- a/binding.gyp -+++ b/binding.gyp -@@ -1,13 +1,18 @@ - { - 'target_defaults': { - 'dependencies': [ -- " - #include - #include -+#include - #include -+#include - #include - - #include -@@ -47,6 +49,25 @@ - #include - #endif - -+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc -+ * under new symbol versions, so building on a newer glibc produces references -+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to -+ * launch. Pin these to the pre-merge version glibc still ships as a compat -+ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so -+ * those aliases are actually loaded on the target. */ -+#if defined(__linux__) -+# if defined(__x86_64__) -+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5" -+# elif defined(__aarch64__) -+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17" -+# endif -+# ifdef ORCA_GLIBC_COMPAT_VERSION -+__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION); -+__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION); -+__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION); -+# endif -+#endif -+ - /* Some platforms name VWERASE and VDISCARD differently */ - #if !defined(VWERASE) && defined(VWERSE) - #define VWERASE VWERSE -@@ -228,6 +249,9 @@ Napi::Value PtyGetProc(const Napi::CallbackInfo& info); - static int - pty_nonblock(int); - -+static int -+pty_cloexec(int); -+ - #if defined(__APPLE__) - static char * - pty_getproc(int); -@@ -237,13 +261,23 @@ pty_getproc(int, char *); - #endif - - #if defined(__APPLE__) || defined(__OpenBSD__) -+struct pty_spawn_error { -+ const char* step; -+ int errnum; -+ std::string detail_name; -+ std::string detail_value; -+}; -+ -+static std::string -+pty_format_spawn_error(const pty_spawn_error&); -+ - static void - pty_posix_spawn(char** argv, char** env, - const struct termios *termp, - const struct winsize *winp, - int* master, - pid_t* pid, -- int* err); -+ pty_spawn_error* err); - #endif - - struct DelBuf { -@@ -367,14 +401,18 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { - argv[i + 3] = strdup(arg.c_str()); - } - -- int err = -1; -- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err); -- if (err != 0) { -- throw Napi::Error::New(napiEnv, "posix_spawnp failed."); -+ pty_spawn_error spawn_error = { NULL, 0, "", "" }; -+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error); -+ if (spawn_error.errnum != 0) { -+ std::string spawn_message = pty_format_spawn_error(spawn_error); -+ throw Napi::Error::New(napiEnv, spawn_message); - } - if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } -+ if (pty_cloexec(master) == -1) { -+ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); -+ } - #else - int argc = argv_.Length(); - int argl = argc + 2; -@@ -445,6 +483,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { - if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } -+ if (pty_cloexec(master) == -1) { -+ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); -+ } - } - #endif - -@@ -586,6 +627,23 @@ pty_nonblock(int fd) { - return fcntl(fd, F_SETFL, flags | O_NONBLOCK); - } - -+/** -+ * Orca: close-on-exec FD -+ * -+ * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without -+ * FD_CLOEXEC is inherited by every later child of this process -- including -+ * later pty children -- which keeps its /dev/pts device and buffers alive long -+ * after its own session ends (#8362). -+ */ -+ -+static int -+pty_cloexec(int fd) { -+ int flags = fcntl(fd, F_GETFD); -+ if (flags == -1) return -1; -+ if (flags & FD_CLOEXEC) return 0; -+ return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); -+} -+ - /** - * pty_getproc - * Taken from tmux. -@@ -684,15 +742,73 @@ pty_getproc(int fd, char *tty) { - #endif - - #if defined(__APPLE__) -+static const char* -+pty_errno_name(int errnum) { -+ switch (errnum) { -+ case E2BIG: return "E2BIG"; -+ case EACCES: return "EACCES"; -+ case EAGAIN: return "EAGAIN"; -+ case EMFILE: return "EMFILE"; -+ case ENFILE: return "ENFILE"; -+ case ENOENT: return "ENOENT"; -+ case ENOMEM: return "ENOMEM"; -+ default: return "errno"; -+ } -+} -+ -+static void -+pty_set_spawn_error(pty_spawn_error* err, -+ const char* step, -+ int errnum, -+ const char* detail_name = NULL, -+ const char* detail_value = NULL) { -+ err->step = step; -+ err->errnum = errnum; -+ err->detail_name = detail_name ? detail_name : ""; -+ err->detail_value = detail_value ? detail_value : ""; -+} -+ -+static std::string -+pty_format_spawn_error(const pty_spawn_error& err) { -+ char errno_buf[64]; -+ snprintf(errno_buf, sizeof(errno_buf), "%d", err.errnum); -+ -+ std::string message = "node-pty: "; -+ message += err.step ? err.step : "unknown"; -+ message += " failed: "; -+ message += pty_errno_name(err.errnum); -+ message += " (errno "; -+ message += errno_buf; -+ message += ", "; -+ message += strerror(err.errnum); -+ message += ")"; -+ -+ if (!err.detail_name.empty()) { -+ message += " - "; -+ message += err.detail_name; -+ message += "='"; -+ message += err.detail_value; -+ message += "'"; -+ } -+ -+ return message; -+} -+ - static void - pty_posix_spawn(char** argv, char** env, - const struct termios *termp, - const struct winsize *winp, - int* master, - pid_t* pid, -- int* err) { -- int low_fds[3]; -+ pty_spawn_error* err) { -+ int low_fds[3] = {-1, -1, -1}; - size_t count = 0; -+ int res = -1; -+ int slave = -1; -+ posix_spawn_file_actions_t acts; -+ bool acts_initialized = false; -+ posix_spawnattr_t attrs; -+ bool attrs_initialized = false; - - for (; count < 3; count++) { - low_fds[count] = posix_openpt(O_RDWR); -@@ -706,80 +822,118 @@ pty_posix_spawn(char** argv, char** env, - POSIX_SPAWN_SETSID; - *master = posix_openpt(O_RDWR); - if (*master == -1) { -- return; -+ pty_set_spawn_error(err, "posix_openpt", errno); -+ goto done; - } - -- int res = grantpt(*master) || unlockpt(*master); -+ res = grantpt(*master); - if (res == -1) { -- return; -+ pty_set_spawn_error(err, "grantpt", errno); -+ goto done; -+ } -+ -+ res = unlockpt(*master); -+ if (res == -1) { -+ pty_set_spawn_error(err, "unlockpt", errno); -+ goto done; - } - - // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. -- int slave; - char slave_pty_name[128]; - res = ioctl(*master, TIOCPTYGNAME, slave_pty_name); - if (res == -1) { -- return; -+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno); -+ goto done; - } - - slave = open(slave_pty_name, O_RDWR | O_NOCTTY); - if (slave == -1) { -- return; -+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name); -+ goto done; - } - - if (termp) { - res = tcsetattr(slave, TCSANOW, termp); - if (res == -1) { -- return; -+ pty_set_spawn_error(err, "tcsetattr", errno, "slave", slave_pty_name); -+ goto done; - }; - } - - if (winp) { - res = ioctl(slave, TIOCSWINSZ, winp); - if (res == -1) { -- return; -+ pty_set_spawn_error(err, "ioctl_TIOCSWINSZ", errno, "slave", slave_pty_name); -+ goto done; - } - } - -- posix_spawn_file_actions_t acts; -- posix_spawn_file_actions_init(&acts); -+ res = posix_spawn_file_actions_init(&acts); -+ if (res != 0) { -+ pty_set_spawn_error(err, "posix_spawn_file_actions_init", res); -+ goto done; -+ } -+ acts_initialized = true; - posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO); - posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO); - posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); - posix_spawn_file_actions_addclose(&acts, slave); - posix_spawn_file_actions_addclose(&acts, *master); - -- posix_spawnattr_t attrs; -- posix_spawnattr_init(&attrs); -- *err = posix_spawnattr_setflags(&attrs, flags); -- if (*err != 0) { -+ res = posix_spawnattr_init(&attrs); -+ if (res != 0) { -+ pty_set_spawn_error(err, "posix_spawnattr_init", res); -+ goto done; -+ } -+ attrs_initialized = true; -+ res = posix_spawnattr_setflags(&attrs, flags); -+ if (res != 0) { -+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res); - goto done; - } - - sigset_t signal_set; - /* Reset all signal the child to their default behavior */ - sigfillset(&signal_set); -- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set); -- if (*err != 0) { -+ res = posix_spawnattr_setsigdefault(&attrs, &signal_set); -+ if (res != 0) { -+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res); - goto done; - } - - /* Reset the signal mask for all signals */ - sigemptyset(&signal_set); -- *err = posix_spawnattr_setsigmask(&attrs, &signal_set); -- if (*err != 0) { -+ res = posix_spawnattr_setsigmask(&attrs, &signal_set); -+ if (res != 0) { -+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res); - goto done; - } - - do -- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); -- while (*err == EINTR); -+ res = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); -+ while (res == EINTR); -+ if (res != 0) { -+ pty_set_spawn_error(err, "posix_spawn", res, "helper", argv[0]); -+ } - done: -- posix_spawn_file_actions_destroy(&acts); -- posix_spawnattr_destroy(&attrs); -+ if (acts_initialized) { -+ posix_spawn_file_actions_destroy(&acts); -+ } -+ if (attrs_initialized) { -+ posix_spawnattr_destroy(&attrs); -+ } -+ if (slave != -1) { -+ close(slave); -+ } -+ if (err->errnum != 0 && *master != -1) { -+ close(*master); -+ *master = -1; -+ } - -- for (; count > 0; count--) { -- close(low_fds[count]); -+ for (size_t i = 0; i <= count && i < 3; i++) { -+ if (low_fds[i] != -1) { -+ close(low_fds[i]); -+ } - } - } - #endif -diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts -index 98733dc0cd752b554bd94e45904ca341ad141bba..3dd5ad9b3124dbd5ba7f76679b26650dad503ec6 100644 ---- a/src/unixTerminal.ts -+++ b/src/unixTerminal.ts -@@ -306,6 +306,14 @@ interface IWriteTask { - offset: number; - } - -+/** -+ * Orca: upstream retries EAGAIN with `setImmediate`, which re-attempts within microseconds -+ * and pins a core on the daemon thread when a reader stops draining. 1ms keeps ~97% of that -+ * CPU saving; longer delays cost 2-3x delivery time to a reader that drains in bursts (an -+ * agent). Re-measure both before changing. -+ */ -+const EAGAIN_RETRY_DELAY_MS = 1; -+ - /** - * A custom write stream that writes directly to a file descriptor with proper - * handling of backpressure and errors. This avoids some event loop exhaustion -@@ -314,20 +322,28 @@ interface IWriteTask { - class CustomWriteStream implements IDisposable { - - private readonly _writeQueue: IWriteTask[] = []; -- private _writeImmediate: NodeJS.Immediate | undefined; -+ private _writeImmediate: NodeJS.Timeout | undefined; - - constructor( -- private readonly _fd: number, -+ private _fd: number, - private readonly _encoding: BufferEncoding - ) { - } - - dispose(): void { -- clearImmediate(this._writeImmediate); -+ clearTimeout(this._writeImmediate); - this._writeImmediate = undefined; -+ // Orca: retire this stream's own copy of the master fd and drop what has -+ // not shipped, so nothing queued here reaches a reused descriptor. -+ this._fd = -1; -+ this._writeQueue.length = 0; - } - - write(data: string | Buffer): void { -+ if (this._fd < 0) { -+ return; -+ } -+ - // Writes are put in a queue and processed asynchronously in order to handle - // backpressure from the kernel buffer. - const buffer = typeof data === 'string' -@@ -345,7 +361,8 @@ class CustomWriteStream implements IDisposable { - private _processWriteQueue(): void { - this._writeImmediate = undefined; - -- if (this._writeQueue.length === 0) { -+ // Orca: an in-flight fs.write can re-enter here after dispose(). -+ if (this._fd < 0 || this._writeQueue.length === 0) { - return; - } - -@@ -357,9 +374,9 @@ class CustomWriteStream implements IDisposable { - fs.write(this._fd, task.buffer, task.offset, (err, written) => { - if (err) { - if ('code' in err && err.code === 'EAGAIN') { -- // `setImmediate` is used to yield to the event loop and re-attempt -- // the write later. -- this._writeImmediate = setImmediate(() => this._processWriteQueue()); -+ // Paced, not `setImmediate`: a stalled reader keeps this branch EAGAIN-ing, and an -+ // immediate re-attempt turns the retry into a busy-loop on the daemon thread. -+ this._writeImmediate = setTimeout(() => this._processWriteQueue(), EAGAIN_RETRY_DELAY_MS); - } else { - // Stop processing immediately on unexpected error and log - this._writeQueue.length = 0; -diff --git a/src/win/conpty.cc b/src/win/conpty.cc -index 7b286d3d64..5239ba4e40 100644 ---- a/src/win/conpty.cc -+++ b/src/win/conpty.cc -@@ -18,6 +18,7 @@ - #include - #include - #include -+#include - #include - #include - #include -@@ -44,15 +45,37 @@ struct pty_baton { - HANDLE hOut; - HPCON hpc; - -- HANDLE hShell; -+ HANDLE hShell = nullptr; -+ // Orca: the shell's pid, captured at spawn. The ownership guard compares -+ // against this rather than calling GetProcessId(hShell), because the exit -+ // watcher closes hShell on another thread -- reading it there is an -+ // invalid-handle operation, and under strict handle checks that is fatal. -+ DWORD shellPid = 0; -+ -+ // Orca: job object owning this pty's whole process tree. Null when the OS -+ // refused to create or assign one (an outer job without breakaway rights), -+ // in which case callers fall back to their pre-job behaviour. -+ HANDLE hJob = nullptr; -+ bool allowJobBreakaway = true; -+ -+ // Orca: teardown needs BOTH the shell's death and an explicit kill() before -+ // the baton can be freed, so each side records that it has run. Whichever -+ // arrives second frees it. Freeing on the shell's death alone -- what this -+ // file did before -- destroyed the only record of `hpc` while -+ // ClosePseudoConsole was still owed, which is why a self-exiting shell -+ // leaked its pseudoconsole and the console host it reaps (#18601 / F24). -+ bool shellExited = false; -+ bool consoleClosed = false; - - pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {}; - }; - - static std::vector> ptyHandles; -+// Orca: every table access shares the exit watcher's lock; handles can be recycled. -+static std::mutex ptyJobMutex; - static volatile LONG ptyCounter; - --static pty_baton* get_pty_baton(int id) { -+static pty_baton* get_pty_baton_locked(int id) { - auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { - return ptyHandle->id == id; - }); -@@ -62,7 +85,7 @@ static pty_baton* get_pty_baton(int id) { - return nullptr; - } - --static bool remove_pty_baton(int id) { -+static bool remove_pty_baton_locked(int id) { - auto it = std::remove_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { - return ptyHandle->id == id; - }); -@@ -102,8 +125,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { - // Get process exit code. - GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code)); - // Clean up handles -- CloseHandle(baton->hShell); -- assert(remove_pty_baton(baton->id)); -+ // Orca: release the job once the shell is gone. Without kill-on-close this -+ // only frees the handle -- anything the user backgrounded is orphaned, as -+ // it was before this patch. -+ { -+ std::lock_guard guard(ptyJobMutex); -+ CloseHandle(baton->hShell); -+ baton->hShell = nullptr; -+ if (baton->hJob != nullptr) { -+ CloseHandle(baton->hJob); -+ baton->hJob = nullptr; -+ } -+ // Why inside the lock: erasing frees the baton the job accessors hold a -+ // pointer to. Note remove_pty_baton must not be an assert() argument -- -+ // NDEBUG would compile the call away and leak every baton. -+ baton->shellExited = true; -+ if (baton->consoleClosed) { -+ const bool removed = remove_pty_baton_locked(baton->id); -+ assert(removed); -+ (void)removed; -+ } -+ // Else PtyKill has not run yet and still owns hpc. It frees the baton. -+ } -+ // Why the lock ends here: BlockingCall below waits on the JS thread, and the -+ // JS thread can be waiting on ptyJobMutex inside PtyTerminateJob. Holding -+ // the lock across it deadlocks. Do not widen this scope. - - auto status = tsfn.BlockingCall(exit_event, callback); // In main thread - switch (status) { -@@ -242,6 +288,20 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info, - return HRESULT_FROM_WIN32(GetLastError()); - } - -+// Cygwin and MSYS request breakaway for every child whenever the job allows it, -+// so their shells need one that does not. The runtime DLL on the exe's search -+// path is the signal; Git for Windows ships bash.exe in bin\ beside usr\bin\. -+static bool usesCygwinRuntime(const std::wstring& shellpath) { -+ const size_t separator = shellpath.find_last_of(L"\\/"); -+ if (separator == std::wstring::npos) return false; -+ const std::wstring directory = shellpath.substr(0, separator + 1); -+ for (const wchar_t* dll : {L"msys-2.0.dll", L"cygwin1.dll"}) { -+ if (path_util::file_exists(directory + dll) || -+ path_util::file_exists(directory + L"..\\usr\\bin\\" + dll)) return true; -+ } -+ return false; -+} -+ - static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { - Napi::Env env(info.Env()); - Napi::HandleScope scope(env); -@@ -301,8 +361,10 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { - // We were able to instantiate a conpty - const int ptyId = InterlockedIncrement(&ptyCounter); - marshal.Set("pty", Napi::Number::New(env, ptyId)); -- ptyHandles.emplace_back( -- std::make_unique(ptyId, hIn, hOut, hpc)); -+ auto baton = std::make_unique(ptyId, hIn, hOut, hpc); -+ baton->allowJobBreakaway = !usesCygwinRuntime(shellpath); -+ std::lock_guard guard(ptyJobMutex); -+ ptyHandles.emplace_back(std::move(baton)); - } else { - throw Napi::Error::New(env, "Cannot launch conpty"); - } -@@ -350,11 +412,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { - const bool useConptyDll = info[4].As().Value(); - Napi::Function exitCallback = info[5].As(); - -- // Fetch pty handle from ID and start process -- pty_baton* handle = get_pty_baton(id); -- if (!handle) { -- throw Napi::Error::New(env, "Invalid pty handle"); -+ pty_baton* handle; -+ { -+ std::lock_guard guard(ptyJobMutex); -+ handle = get_pty_baton_locked(id); -+ if (!handle || handle->consoleClosed) { -+ throw Napi::Error::New(env, "Invalid pty handle"); -+ } - } -+ // No watcher exists for this baton until SetupExitCallback below; pipe waits stay unlocked. - - // Prepare command line - std::unique_ptr mutableCommandline = std::make_unique(cmdline.length() + 1); -@@ -409,6 +475,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { - throw errorWithCode(info, "UpdateProcThreadAttribute failed"); - } - -+ // Orca: resolve the DLL BEFORE creating anything. It throws when conpty.dll -+ // is missing -- a real state, and one this branch hit during development -- -+ // and every throw between CreateProcessW and SetupExitCallback leaks the job, -+ // process and thread handles AND leaves an untracked shell tree running, -+ // once per attempt. Validating first means the only throw after creation is -+ // the resume failure, which cleans up after itself. -+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll); -+ bool fLoadedDll = hLibrary != nullptr; -+ - PROCESS_INFORMATION piClient{}; - fSuccess = !!CreateProcessW( - nullptr, -@@ -416,7 +491,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { - nullptr, // lpProcessAttributes - nullptr, // lpThreadAttributes - false, // bInheritHandles VERY IMPORTANT that this is false -- EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, // dwCreationFlags -+ // Orca: CREATE_SUSPENDED so the shell is inside its job before it can -+ // spawn anything. Assigning after the fact leaves a window in which a -+ // fast child escapes the job and outlives the pane. -+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED, // dwCreationFlags - envArg, // lpEnvironment - mutableCwd.get(), // lpCurrentDirectory - &siEx.StartupInfo, // lpStartupInfo -@@ -426,8 +504,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { - throw errorWithCode(info, "Cannot create process"); - } - -- HANDLE hLibrary = LoadConptyDll(info, useConptyDll); -- bool fLoadedDll = hLibrary != nullptr; -+ // Orca: own the tree with a handle instead of inferring it later from a -+ // parent-pid walk. A pid walk cannot survive pid reuse and cannot see a -+ // descendant that reparented, which is why detached agent children outlived -+ // their pane and held the worktree directory open. -+ // -+ // Deliberately WITHOUT JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Measured on -+ // Windows 11: with that flag, closing the handle when the shell exits also -+ // kills whatever the user left running, so typing `exit` in a pane reaped a -+ // `start /b` server that used to survive. This job exists to make an -+ // EXPLICIT teardown exact, not to redefine what a clean exit means. -+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr); -+ if (hJob != nullptr) { -+ // Native shells retain explicit breakaway for installers and updaters. -+ // Cygwin/MSYS shells take it automatically for ordinary children whenever -+ // this flag is present, so they get strict per-PTY membership instead. -+ // Explicit breakaway requests inside such a pane are consequently denied; -+ // ordinary backgrounding and clean shell exit remain supported. -+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{}; -+ jobLimits.BasicLimitInformation.LimitFlags = -+ handle->allowJobBreakaway ? JOB_OBJECT_LIMIT_BREAKAWAY_OK : 0; -+ if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobLimits, sizeof(jobLimits)) || -+ !AssignProcessToJobObject(hJob, piClient.hProcess)) { -+ // Why tolerate failure: an outer job without JOB_OBJECT_LIMIT_BREAKAWAY_OK -+ // (some EDR and container hosts) refuses the assignment. The pty must -+ // still start; ownership just degrades to the older best-effort path. -+ CloseHandle(hJob); -+ hJob = nullptr; -+ } -+ } -+ // Safe to run now: either it is in the job, or we accepted that it is not. -+ if (ResumeThread(piClient.hThread) == static_cast(-1)) { -+ // Why fatal: a shell left suspended produces a pane that never prints and -+ // never exits, which is far harder to diagnose than a failed spawn. -+ if (hJob != nullptr) { -+ CloseHandle(hJob); -+ } -+ TerminateProcess(piClient.hProcess, 1); -+ CloseHandle(piClient.hProcess); -+ CloseHandle(piClient.hThread); -+ throw errorWithCode(info, "Cannot resume process"); -+ } -+ - if (useConptyDll && fLoadedDll) - { - PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress( -@@ -438,8 +556,12 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { - } - } - -- // Update handle -- handle->hShell = piClient.hProcess; -+ { -+ std::lock_guard guard(ptyJobMutex); -+ handle->hShell = piClient.hProcess; -+ handle->shellPid = piClient.dwProcessId; -+ handle->hJob = hJob; -+ } - - // Close the thread handle to avoid resource leak - CloseHandle(piClient.hThread); -@@ -472,9 +594,10 @@ static Napi::Value PtyResize(const Napi::CallbackInfo& info) { - SHORT rows = static_cast(info[2].As().Uint32Value()); - const bool useConptyDll = info[3].As().Value(); - -- const pty_baton* handle = get_pty_baton(id); -+ std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton_locked(id); - -- if (handle != nullptr) { -+ if (handle != nullptr && !handle->consoleClosed) { - HANDLE hLibrary = LoadConptyDll(info, useConptyDll); - bool fLoadedDll = hLibrary != nullptr; - if (fLoadedDll) -@@ -513,9 +636,10 @@ static Napi::Value PtyClear(const Napi::CallbackInfo& info) { - return env.Undefined(); - } - -- const pty_baton* handle = get_pty_baton(id); -+ std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton_locked(id); - -- if (handle != nullptr) { -+ if (handle != nullptr && !handle->consoleClosed) { - HANDLE hLibrary = LoadConptyDll(info, useConptyDll); - bool fLoadedDll = hLibrary != nullptr; - if (fLoadedDll) -@@ -544,29 +668,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { - int id = info[0].As().Int32Value(); - const bool useConptyDll = info[1].As().Value(); - -- const pty_baton* handle = get_pty_baton(id); -+ // Orca: resolve the DLL BEFORE touching any baton state, for the same reason -+ // PtyConnect does it before creating anything. LoadConptyDll throws when -+ // conpty.dll is missing, and a throw after consoleClosed was set would strand -+ // the pseudoconsole permanently: the retry would find the work already -+ // claimed and do nothing. Only the useConptyDll path can throw here; the -+ // other returns kernel32. -+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll); -+ PFNCLOSEPSEUDOCONSOLE pfnClosePseudoConsole = nullptr; -+ if (hLibrary != nullptr) { -+ pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( -+ (HMODULE)hLibrary, -+ useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); -+ } - -- if (handle != nullptr) { -- HANDLE hLibrary = LoadConptyDll(info, useConptyDll); -- bool fLoadedDll = hLibrary != nullptr; -- if (fLoadedDll) -- { -- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( -- (HMODULE)hLibrary, -- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); -- if (pfnClosePseudoConsole) -- { -- pfnClosePseudoConsole(handle->hpc); -+ // Orca: the baton now outlives the shell, so this runs on a self-exited pty -+ // too -- that is the whole point. Take what we need under the lock: the -+ // watcher thread nulls hShell the moment the shell dies, and TerminateProcess -+ // on a handle it just closed is an invalid-handle operation. Duplicating -+ // rather than reordering keeps upstream's close-then-terminate sequence. -+ HPCON hpc = nullptr; -+ HANDLE hShellDup = nullptr; -+ bool owed = false; -+ { -+ std::lock_guard guard(ptyJobMutex); -+ pty_baton* handle = get_pty_baton_locked(id); -+ // Why the consoleClosed check: a second kill() would otherwise close the -+ // same pseudoconsole twice. Upstream relied on the baton being gone. -+ if (handle != nullptr && !handle->consoleClosed) { -+ hpc = handle->hpc; -+ owed = true; -+ handle->consoleClosed = true; -+ // Null hShell means a self-exited pty, where there is nothing to kill. -+ if (useConptyDll && handle->hShell != nullptr) { -+ if (!DuplicateHandle(GetCurrentProcess(), handle->hShell, GetCurrentProcess(), -+ &hShellDup, 0, FALSE, DUPLICATE_SAME_ACCESS)) { -+ // Why terminate here instead of skipping: a failed duplication leaves -+ // hShellDup null, which is indistinguishable from the self-exit case, -+ // and skipping would leave the shell RUNNING after its pane closed -- -+ // a worse outcome than the leak this all exists to fix. hShell is -+ // valid under this lock and TerminateProcess does not block, so the -+ // only cost is that this rare path kills before the console closes. -+ hShellDup = nullptr; -+ TerminateProcess(handle->hShell, 1); -+ } - } -+ if (handle->shellExited) { -+ const bool removed = remove_pty_baton_locked(id); -+ assert(removed); -+ (void)removed; -+ } -+ // Else the shell is still running and the watcher frees the baton. -+ } -+ } -+ -+ // Why outside the lock: ClosePseudoConsole blocks until the conout side has -+ // drained, and the watcher must be able to take the lock while it does. -+ if (owed) { -+ if (pfnClosePseudoConsole) -+ { -+ pfnClosePseudoConsole(hpc); - } -- if (useConptyDll) { -- TerminateProcess(handle->hShell, 1); -+ if (hShellDup != nullptr) { -+ TerminateProcess(hShellDup, 1); -+ CloseHandle(hShellDup); - } - } - - return env.Undefined(); - } - -+/** -+ * Orca: confirm a baton really is the pty the caller means. -+ * -+ * The winpty backend mints its own `pty` ids from a separate counter, and the -+ * JS layer stores both in the same field -- so a winpty terminal's id can -+ * collide with a live ConPTY baton here and terminate an unrelated pane's whole -+ * process tree. Matching the shell pid makes the id unforgeable. -+ */ -+static bool ownsShell(const pty_baton* handle, DWORD expectedShellPid) { -+ return handle != nullptr && handle->hJob != nullptr && expectedShellPid != 0 && -+ handle->shellPid == expectedShellPid; -+} -+ -+/** -+ * Orca: kill this pty's entire tree in one syscall. -+ * -+ * Replaces "scrape the process table, walk parent pids, hope none were -+ * recycled, then taskkill /T /F". Returns false when no job was assigned so -+ * the caller knows to fall back rather than assume the tree is gone. -+ */ -+static Napi::Value PtyTerminateJob(const Napi::CallbackInfo& info) { -+ Napi::Env env(info.Env()); -+ Napi::HandleScope scope(env); -+ -+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) { -+ throw Napi::Error::New(env, "Usage: pty.terminateJob(id, shellPid)"); -+ } -+ -+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise -+ // close these handles and free the baton in between. -+ std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton_locked(info[0].As().Int32Value()); -+ if (!ownsShell(handle, info[1].As().Uint32Value())) { -+ return Napi::Boolean::New(env, false); -+ } -+ return Napi::Boolean::New(env, !!TerminateJobObject(handle->hJob, 1)); -+} -+ -+/** -+ * Orca: the pids still alive in this pty's tree, straight from the kernel. -+ * -+ * Descendant liveness for a tree that is still tracked, including children that -+ * detached from the console. Once the shell exits the watcher nulls hJob, which -+ * ownsShell rejects, so this returns null rather than an empty list -- null -+ * means "no answer", never "they died". (The baton itself now outlives the -+ * shell, until kill() runs; hJob is what makes the answer null.) Also returns -+ * null when no job was assigned. -+ * -+ * Does not include the ConPTY console host: CreatePseudoConsole spawns it -+ * before this job exists, so it is not a member and ClosePseudoConsole is what -+ * reaps it. -+ */ -+static Napi::Value PtyListJobProcessIds(const Napi::CallbackInfo& info) { -+ Napi::Env env(info.Env()); -+ Napi::HandleScope scope(env); -+ -+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) { -+ throw Napi::Error::New(env, "Usage: pty.listJobProcessIds(id, shellPid)"); -+ } -+ -+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise -+ // close these handles and free the baton in between. -+ std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton_locked(info[0].As().Int32Value()); -+ if (!ownsShell(handle, info[1].As().Uint32Value())) { -+ return env.Null(); -+ } -+ -+ // Grow until the buffer holds every pid: the count can change between calls, -+ // and a truncated list would read as "these children are gone". -+ DWORD capacity = 64; -+ for (int attempt = 0; attempt < 8; attempt++) { -+ const size_t bytes = sizeof(JOBOBJECT_BASIC_PROCESS_ID_LIST) + sizeof(ULONG_PTR) * capacity; -+ std::vector buffer(bytes, 0); -+ auto* list = reinterpret_cast(buffer.data()); -+ if (QueryInformationJobObject(handle->hJob, JobObjectBasicProcessIdList, list, static_cast(bytes), nullptr)) { -+ auto pids = Napi::Array::New(env, list->NumberOfProcessIdsInList); -+ for (DWORD i = 0; i < list->NumberOfProcessIdsInList; i++) { -+ pids.Set(i, Napi::Number::New(env, static_cast(list->ProcessIdList[i]))); -+ } -+ return pids; -+ } -+ if (GetLastError() != ERROR_MORE_DATA) { -+ return env.Null(); -+ } -+ capacity *= 4; -+ } -+ return env.Null(); -+} -+ -+/** -+ * Orca: put THIS process in a kill-on-close job, so its whole descendant tree -+ * dies with it. -+ * -+ * Why here and not per-pty: a per-pty job cannot carry KILL_ON_JOB_CLOSE, -+ * because its handle is released when the shell exits and that would reap -+ * whatever the user had backgrounded. This job's handle is released only when -+ * the process itself dies, so it reaps a crashed host without changing what a -+ * clean shell exit means. Children inherit job membership, so every pty the -+ * caller later spawns is covered without further work, and the per-pty jobs -+ * simply nest inside this one. -+ * -+ * The handle is deliberately never closed: it must outlive every caller, and -+ * process teardown is what releases it. -+ */ -+static Napi::Value PtyAssignCurrentProcessToJob(const Napi::CallbackInfo& info) { -+ Napi::Env env(info.Env()); -+ Napi::HandleScope scope(env); -+ -+ // Why locked: two callers racing here would each create a job, put the -+ // process in both, and leak the first handle -- and since the handle is what -+ // keeps a kill-on-close job alive, a leaked one is never released. A worker -+ // thread with its own N-API env shares these statics, so "only JS calls it" -+ // is not a guarantee. -+ static HANDLE hHostJob = nullptr; -+ std::lock_guard guard(ptyJobMutex); -+ if (hHostJob != nullptr) { -+ return Napi::Boolean::New(env, true); -+ } -+ -+ HANDLE job = CreateJobObjectW(nullptr, nullptr); -+ if (job == nullptr) { -+ return Napi::Boolean::New(env, false); -+ } -+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; -+ // BREAKAWAY_OK for the same reason as the per-pty job: without it a child -+ // asking for CREATE_BREAKAWAY_FROM_JOB is refused outright. -+ limits.BasicLimitInformation.LimitFlags = -+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; -+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) || -+ !AssignProcessToJobObject(job, GetCurrentProcess())) { -+ // An outer job that forbids nesting refuses this; the caller degrades. -+ CloseHandle(job); -+ return Napi::Boolean::New(env, false); -+ } -+ hHostJob = job; -+ return Napi::Boolean::New(env, true); -+} -+ - /** - * Init - */ -@@ -577,6 +887,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { - exports.Set("resize", Napi::Function::New(env, PtyResize)); - exports.Set("clear", Napi::Function::New(env, PtyClear)); - exports.Set("kill", Napi::Function::New(env, PtyKill)); -+ exports.Set("terminateJob", Napi::Function::New(env, PtyTerminateJob)); -+ exports.Set("listJobProcessIds", Napi::Function::New(env, PtyListJobProcessIds)); -+ exports.Set("assignCurrentProcessToJob", Napi::Function::New(env, PtyAssignCurrentProcessToJob)); - return exports; - }; - -diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js -index a358ffb177357e177661033c1b092f9c9d0e5f5a..26c2a4c58799ce649f5113131e4c52f7ed2d87ad 100644 ---- a/lib/windowsPtyAgent.js -+++ b/lib/windowsPtyAgent.js -@@ -136,6 +136,9 @@ var WindowsPtyAgent = /** @class */ (function () { - if (this._useConpty) { - if (!this._useConptyDll) { - this._inSocket.readable = false; -+ // The non-DLL path previously only flipped `readable`, leaving the -+ // conin PipeWrap alive until the host exited (#947). -+ this._inSocket.destroy(); - this._outSocket.readable = false; - this._getConsoleProcessList().then(function (consoleProcessList) { - consoleProcessList.forEach(function (pid) { -@@ -154,9 +157,10 @@ var WindowsPtyAgent = /** @class */ (function () { - // Close the input write handle to signal the end of session. - this._inSocket.destroy(); - this._ptyNative.kill(this._pty, this._useConptyDll); -- this._outSocket.on('data', function () { -- _this._conoutSocketWorker.dispose(); -- }); -+ // Orca: dispose unconditionally, as the non-DLL branch above does. -+ // Waiting for another 'data' event leaks the conout worker on every -+ // self-exiting shell, because no more data ever arrives (F24). -+ this._conoutSocketWorker.dispose(); - } - } - else { -diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js -index 3c38f89..e20b3e6 100644 ---- a/lib/windowsTerminal.js -+++ b/lib/windowsTerminal.js -@@ -50,6 +50,27 @@ var WindowsTerminal = /** @class */ (function (_super) { - // Create new termal. - _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); - _this._socket = _this._agent.outSocket; -+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled. -+ _this._socket.on('error', function (err) { -+ var code = err && err.code; -+ // PTY output can report EPIPE before `_close()` wins the race. -+ _this._close(); -+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') { -+ return; -+ } -+ // EIO, happens when someone closes our child process: the only process -+ // in the terminal. -+ // node < 0.6.14: errno 5 -+ // node >= 0.6.14: read EIO -+ if (typeof code === 'string') { -+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO')) -+ return; -+ } -+ // Throw anything else. -+ if (_this.listeners('error').length < 2) { -+ throw err; -+ } -+ }); - // Not available until `ready` event emitted. - _this._pid = _this._agent.innerPid; - _this._fd = _this._agent.fd; -@@ -76,23 +99,6 @@ var WindowsTerminal = /** @class */ (function (_super) { - _this._deferreds = []; - } - }); -- // Shutdown if `error` event is emitted. -- _this._socket.on('error', function (err) { -- // Close terminal session. -- _this._close(); -- // EIO, happens when someone closes our child process: the only process -- // in the terminal. -- // node < 0.6.14: errno 5 -- // node >= 0.6.14: read EIO -- if (err.code) { -- if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) -- return; -- } -- // Throw anything else. -- if (_this.listeners('error').length < 2) { -- throw err; -- } -- }); - // Cleanup after the socket is closed. - _this._socket.on('close', function () { - _this.emit('exit', _this._agent.exitCode); -@@ -103,6 +109,20 @@ var WindowsTerminal = /** @class */ (function (_super) { - _this._name = name; - _this._readable = true; - _this._writable = true; -+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes -+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon. -+ _this._agent.inSocket.on('error', function () { -+ if (!_this._writable) { -+ return; -+ } -+ _this._close(); -+ try { -+ _this._agent.kill(); -+ } -+ catch (_a) { -+ // The failing pipe may have raced process exit; the terminal is already unwritable. -+ } -+ }); - _this._forwardEvents(); - return _this; - } -@@ -196,4 +216,4 @@ var WindowsTerminal = /** @class */ (function (_super) { - return WindowsTerminal; - }(terminal_1.Terminal)); - exports.WindowsTerminal = WindowsTerminal; --//# sourceMappingURL=windowsTerminal.js.map -\ No newline at end of file -+//# sourceMappingURL=windowsTerminal.js.map -diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts -index d7054449516f0c9a62af351c2caa17331206d530..0c28a32e2e1db2b3f208ddde8443cd4e67bb1ad6 100644 ---- a/src/windowsPtyAgent.ts -+++ b/src/windowsPtyAgent.ts -@@ -143,6 +143,9 @@ export class WindowsPtyAgent { - if (this._useConpty) { - if (!this._useConptyDll) { - this._inSocket.readable = false; -+ // The non-DLL path previously only flipped `readable`, leaving the -+ // conin PipeWrap alive until the host exited (#947). -+ this._inSocket.destroy(); - this._outSocket.readable = false; - this._getConsoleProcessList().then(consoleProcessList => { - consoleProcessList.forEach((pid: number) => { -@@ -159,9 +162,10 @@ export class WindowsPtyAgent { - // Close the input write handle to signal the end of session. - this._inSocket.destroy(); - (this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll); -- this._outSocket.on('data', () => { -- this._conoutSocketWorker.dispose(); -- }); -+ // Orca: dispose unconditionally, as the non-DLL branch above does. -+ // Waiting for another 'data' event leaks the conout worker on every -+ // self-exiting shell, because no more data ever arrives (F24). -+ this._conoutSocketWorker.dispose(); - } - } else { - // Because pty.kill closes the handle, it will kill most processes by itself. -diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts -index 13f6c6d..eda63c8 100644 ---- a/src/windowsTerminal.ts -+++ b/src/windowsTerminal.ts -@@ -51,6 +51,30 @@ export class WindowsTerminal extends Terminal { - this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); - this._socket = this._agent.outSocket; -- -+ -+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled. -+ this._socket.on('error', err => { -+ const code = (err).code; -+ -+ // PTY output can report EPIPE before `_close()` wins the race. -+ this._close(); -+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') { -+ return; -+ } -+ -+ // EIO, happens when someone closes our child process: the only process -+ // in the terminal. -+ // node < 0.6.14: errno 5 -+ // node >= 0.6.14: read EIO -+ if (typeof code === 'string') { -+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO')) return; -+ } -+ -+ // Throw anything else. -+ if (this.listeners('error').length < 2) { -+ throw err; -+ } -+ }); -+ - // Not available until `ready` event emitted. - this._pid = this._agent.innerPid; - this._fd = this._agent.fd; -@@ -82,25 +108,6 @@ export class WindowsTerminal extends Terminal { - } - }); -- -+ -- // Shutdown if `error` event is emitted. -- this._socket.on('error', err => { -- // Close terminal session. -- this._close(); -- -- // EIO, happens when someone closes our child process: the only process -- // in the terminal. -- // node < 0.6.14: errno 5 -- // node >= 0.6.14: read EIO -- if ((err).code) { -- if (~(err).code.indexOf('errno 5') || ~(err).code.indexOf('EIO')) return; -- } -- -- // Throw anything else. -- if (this.listeners('error').length < 2) { -- throw err; -- } -- }); -- - // Cleanup after the socket is closed. - this._socket.on('close', () => { - this.emit('exit', this._agent.exitCode); -@@ -114,6 +121,19 @@ export class WindowsTerminal extends Terminal { -- -+ - this._readable = true; - this._writable = true; -+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes -+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon. -+ this._agent.inSocket.on('error', () => { -+ if (!this._writable) { -+ return; -+ } -+ this._close(); -+ try { -+ this._agent.kill(); -+ } catch { -+ // The failing pipe may have raced process exit; the terminal is already unwritable. -+ } -+ }); -- -+ - this._forwardEvents(); - } diff --git a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs index 367f9a10dcd5..badc546ed95d 100644 --- a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs +++ b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs @@ -3,7 +3,7 @@ const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs') const { join, resolve } = require('node:path') /** - * Release the ConPTY teardown handles a relay's npm-installed node-pty never releases. + * Legacy SSH compatibility: repair npm-installed node-pty ConPTY teardown handles. * * Two files, and the ORDER of one of the edits is the whole fix. * @@ -12,7 +12,7 @@ const { join, resolve } = require('node:path') * `_inSocket`, and it wraps a real Windows named-pipe handle from `fs.openSync(term.conin, 'w')`. * Every terminal leaks one File handle for the life of the host process. * - * The obvious fix -- and the placement `config/patches/node-pty@1.1.0.patch` uses -- releases it at + * The obvious fix -- and the placement the retired desktop node-pty patch used -- releases it at * the TOP of the branch, before `_getConsoleProcessList()` forks and before the native kill. That is * measurably worse than leaving the leak alone: teardown aborts partway, the forked console-list * agent is never reaped, and both pipe handles stay alive instead of one. This asset releases it at @@ -38,7 +38,7 @@ const { join, resolve } = require('node:path') * DELIBERATE DIVERGENCE FROM THE DESKTOP, AND WHY IT IS NOT A DESKTOP-TERMINAL BUG: the two hosts * do not run the same branch of `kill()`. node-pty defaults `_useConptyDll` to false * (`windowsPtyAgent.js`). Every desktop site that opens a terminal pane sets it true -- - * `local-pty-utils.ts` (two) and `native-pty-spawn.ts` -- as does the `windows-conpty-warmup.ts` + * the former desktop PTY spawn path (two) and `native-pty-spawn.ts` -- as does the `windows-conpty-warmup.ts` * warm-up, so all of those take the `else` branch, where UPSTREAM ALREADY destroys the input * socket. The relay passes no such option (`src/relay/pty-handler.ts`), so it takes the * `!useConptyDll` branch -- the one this asset and the desktop patch both edit. diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 19228a3ae482..2fed0d52ebb9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2489,7 +2489,7 @@ "providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": ["local", "local-daemon", "ssh-relay", "paired-runtime"], - "coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. 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": "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. Current executable owners are daemon and SSH relay; the in-process provider tests were retired. Historical node-pty and live-app evidence is retained as historical evidence, not relabeled as Bun qualification.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/13767", "https://linear.app/stably/issue/STA-4067", @@ -2500,17 +2500,22 @@ "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": "Split Fish output immediately after the ready marker and after the following ESC introducer; 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 the pinned bundled Bun runtime 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": [ - "node config/scripts/run-bun-profile-tests.mjs src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "node config/scripts/run-bun-profile-tests.mjs src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", // Historical runner retained for the dated evidence records below. - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", - "node config/scripts/run-bun-profile-tests.mjs src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "node config/scripts/run-bun-profile-tests.mjs src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", // Historical runner retained for the dated evidence records below. - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts", "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1" + "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", + "node config/scripts/run-bun-profile-tests.mjs src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts tests/e2e/ssh-startup-exec-readiness.spec.ts tests/e2e/paired-startup-exec-readiness.spec.ts", + // Historical runner retained for the dated evidence records below. + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts tests/e2e/ssh-startup-exec-readiness.spec.ts tests/e2e/paired-startup-exec-readiness.spec.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts" ], "testFiles": [ "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", @@ -2524,9 +2529,8 @@ "src/main/daemon/session.test.ts", "src/main/daemon/shell-ready.test.ts", "src/main/providers/local-pty-shell-ready-marker-scan.test.ts", - "src/main/providers/local-pty-shell-ready-startup-command.test.ts", + "src/main/daemon/daemon-pty-startup-delivery.test.ts", "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts", - "src/main/providers/local-pty-provider-shell-readiness.test.ts", "src/relay/pty-shell-launch.test.ts", "src/relay/pty-handler-startup-command-delivery.test.ts", "tests/e2e/ssh-startup-exec-readiness.spec.ts", @@ -2559,8 +2563,10 @@ ] }, { - "file": "src/main/providers/local-pty-provider-shell-readiness.test.ts", - "assertions": ["local PTY output is byte-exact for both Fish chunk splits"] + "file": "src/main/daemon/session.test.ts", + "assertions": [ + "preserves Fish bracketed-paste output split after the ready marker and after the ESC introducer" + ] }, { "file": "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", @@ -8238,12 +8244,13 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts", "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence-initial-load.test.ts src/main/persistence-pane-identity-migration.test.ts src/main/persistence-pty-binding-reconciliation.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/runtime/worktree-teardown.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/runtime/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-tab-close-restart-persistence.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts --workers=1", - "pnpm run test:e2e -- tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts --workers=1" + "pnpm run test:e2e -- tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence-initial-load.test.ts src/main/persistence-pane-identity-migration.test.ts src/main/persistence-pty-binding-reconciliation.test.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/runtime/worktree-teardown.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts tests/e2e/terminal-tab-close-restart-persistence.spec.ts tests/e2e/terminal-parked-close-retirement.spec.ts tests/e2e/agent-descendant-process-kill.spec.ts tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts" ], "testFiles": [ "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts", @@ -8260,8 +8267,7 @@ "src/main/persistence-pty-binding-reconciliation.test.ts", "src/main/pty-descendant-termination.test.ts", "src/main/daemon/session.test.ts", - "src/main/daemon/terminal-host.test.ts", - "src/main/providers/local-pty-provider-shutdown.test.ts", + "src/main/daemon/terminal-host-concurrent-create.test.ts", "src/main/runtime/worktree-teardown.test.ts", "src/renderer/src/store/slices/agent-pane-authority.test.ts", "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", @@ -8370,7 +8376,7 @@ ] }, { - "file": "src/main/daemon/terminal-host.test.ts", + "file": "src/main/daemon/terminal-host-concurrent-create.test.ts", "assertions": [ "agent immediate kill rejects reattach while descendant capture is pending", "a naturally exited session id remains reserved until capture finishes without force-killing its retired PID", @@ -8755,6 +8761,8 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-wsl-cwd-validation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.bun.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-wsl-cwd-validation.test.ts src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts src/main/pty/omp-shell-wrapper.bun.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts", "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" ], "testFiles": [ @@ -8763,7 +8771,7 @@ "src/main/ipc/pty-wsl-cwd-validation.test.ts", "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts", - "src/main/pty/omp-shell-wrapper.node-pty.test.ts", + "src/main/pty/omp-shell-wrapper.bun.test.ts", "src/main/pty/omp-shell-wrapper-alias-safety.test.ts", "src/main/shell-wrapper-generated-file-snapshot.test.ts" ], @@ -8809,7 +8817,7 @@ ] }, { - "file": "src/main/pty/omp-shell-wrapper.node-pty.test.ts", + "file": "src/main/pty/omp-shell-wrapper.bun.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", @@ -9350,14 +9358,15 @@ "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", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty-pane-materialization-race.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/ipc/pty-pane-reservation-settlement.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", - "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/ipc/pty-daemon-spawn-session-identity.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty-pane-materialization-race.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/ipc/pty-pane-reservation-settlement.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", + "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty-pane-materialization-race.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/ipc/pty-pane-reservation-settlement.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts tests/e2e/live-background-terminal-mount-authority.spec.ts" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "tests/e2e/completed-worker-retirement-resume.unit.test.ts", - "src/main/providers/local-pty-provider-spawn-session.test.ts", - "src/main/daemon/terminal-host.test.ts", + "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts", + "src/main/daemon/terminal-host-concurrent-create.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", "src/main/ipc/pty-pane-materialization-race.test.ts", "src/main/ipc/pty-persisted-incarnation-repair.test.ts", @@ -9518,14 +9527,15 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/remote-agent-session-focus-authority.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-pty-router.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-pty-router.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/rpc/methods/agent-session.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/web-session-intent-owner.test.ts src/renderer/src/runtime/remote-server-parity.test.ts tests/e2e/remote-agent-session-focus-authority.spec.ts config/scripts/remote-agent-session-authority-repro.mjs config/scripts/remote-agent-session-process-cleanup.mjs tests/e2e/remote-terminal-tab-retirement.unit.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ "src/main/providers/pty-process-inspection.test.ts", - "src/main/daemon/terminal-host.test.ts", - "src/main/daemon/daemon-pty-router.test.ts", - "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/daemon/terminal-host-concurrent-create.test.ts", + "src/main/daemon/daemon-init-live-session-preservation.test.ts", "src/relay/pty-handler-spawn-admission.test.ts", "src/main/runtime/orca-runtime.test.ts", "tests/e2e/remote-agent-completion-authority.unit.test.ts", @@ -9552,7 +9562,6 @@ "config/scripts/remote-agent-session-authority-repro.mjs", "config/scripts/remote-agent-session-process-cleanup.mjs", "tests/e2e/remote-terminal-tab-retirement.unit.test.ts", - "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/terminal-orphan-owner.test.ts", "src/main/runtime/terminal-orphan-topology.test.ts", "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts", @@ -9572,16 +9581,14 @@ ] }, { - "file": "src/main/daemon/daemon-pty-router.test.ts", + "file": "src/main/daemon/daemon-init-live-session-preservation.test.ts", "assertions": [ "completion inspection rejects an unmapped session instead of borrowing the current daemon" ] }, { - "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", - "assertions": [ - "completion inspection rejects an unmapped session instead of borrowing the local fallback" - ] + "file": "src/main/daemon/daemon-init-live-session-preservation.test.ts", + "assertions": ["rejects completion inspection when no daemon owns the session"] }, { "file": "src/relay/pty-handler-spawn-admission.test.ts", @@ -10939,7 +10946,7 @@ "providers": ["local", "daemon", "wsl"], "coveredPlatforms": ["windows"], "coveredProviders": ["daemon"], - "coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. A Windows-only patched-node-pty test injects EAGAIN on one ConPTY input pipe and requires only that PTY to close while an unrelated PTY remains writable; a daemon-level classifier test keeps the native exception backstop narrow. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.", + "coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. A Windows-only Bun test closes a real native terminal between the adapter check and write, requires only that PTY to exit while an unrelated PTY remains writable, and checks late writes plus final-output drain ordering; a daemon-level classifier test keeps the native exception backstop narrow. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6541", "https://github.com/stablyai/orca/pull/6858", @@ -10951,16 +10958,16 @@ "https://github.com/stablyai/orca/issues/17027" ], "invariant": "Windows local and daemon terminals must spawn with the intended shell, survive overlapping graceful/forced workspace teardown without affecting unrelated PTYs, contain an asynchronous ConPTY input-pipe failure to the affected terminal without killing the daemon, retain recovered scrollback across the fresh daemon's first checkpoint, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.", - "oracle": "The issue #8048 slice asserts one node-pty ConPTY close for a graceful-then-force sequence, atomically seeds recovered history before fresh shell output and re-anchoring, preserves recovery after seed failure plus adapter restart, and runs 25 built-daemon close races while checking victim session/PID reaping, a stable daemon PID, and a live witness PTY. The EAGAIN slice emits an error from one real patched node-pty Windows input socket, requires its terminal to become unwritable and run the normal per-PTY kill path, then writes through an unrelated PTY without an uncaught exception. A broader Windows live gate still needs shell input, resize, cursor, and CJK/wide-glyph pixel evidence.", + "oracle": "The issue #8048 slice asserts one node-pty ConPTY close for a graceful-then-force sequence, atomically seeds recovered history before fresh shell output and re-anchoring, preserves recovery after seed failure plus adapter restart, and runs 25 built-daemon close races while checking victim session/PID reaping, a stable daemon PID, and a live witness PTY. The native I/O slice closes a real Bun terminal at the native write boundary, requires the actual closed-terminal exception to retire that PTY, then writes through an unrelated PTY without an uncaught exception. Separate cases check late input after observed exit and capture final output at the natural-exit boundary. This does not induce kernel EAGAIN, which Bun handles through its internal input buffer. A broader Windows live gate still needs shell input, resize, cursor, and CJK/wide-glyph pixel evidence.", "commands": [ - "pnpm vitest run src/main/daemon/pty-subprocess.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/node-pty-windows-input-error.win32.test.ts src/main/daemon/daemon-native-pty-exception.test.ts", + "pnpm vitest run src/main/daemon/pty-subprocess.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/bun-pty-windows-io-failure.win32.test.ts src/main/daemon/daemon-native-pty-exception.test.ts", "pnpm build:electron-vite && node config/scripts/windows-daemon-workspace-close-repro.mjs", "node config/scripts/windows-daemon-workspace-close-repro.mjs" ], "testFiles": [ "src/main/daemon/pty-subprocess.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", - "src/main/daemon/node-pty-windows-input-error.win32.test.ts", + "src/main/daemon/bun-pty-windows-io-failure.win32.test.ts", "src/main/daemon/daemon-native-pty-exception.test.ts", "config/scripts/windows-daemon-workspace-close-repro.mjs" ], @@ -10979,9 +10986,9 @@ ] }, { - "file": "src/main/daemon/node-pty-windows-input-error.win32.test.ts", + "file": "src/main/daemon/bun-pty-windows-io-failure.win32.test.ts", "assertions": [ - "an EAGAIN event retires only the affected patched node-pty terminal while a witness remains writable" + "a real native closed-terminal exception retires only the affected Bun PTY while a witness remains writable; late input is safe and final output arrives before the exit event" ] }, { @@ -12554,24 +12561,23 @@ }, { "id": "terminal-provider.daemon-startup-degraded-contract", - "title": "Daemon startup reconcile and degraded fallback preserve provider identity", + "title": "Daemon startup and unavailable-service recovery preserve provider identity", "maturity": "experimental", "protection": "partial", "owner": "terminal-provider", "layer": "provider-contract", "surfaces": [ "daemon startup", - "degraded daemon", - "fallback PTY", + "unavailable daemon", "provider ownership", "startup restore", "stable-pane reopen" ], "platforms": ["macos", "linux", "windows"], - "providers": ["daemon", "local"], + "providers": ["daemon"], "coveredPlatforms": ["macos"], - "coveredProviders": ["daemon", "local"], - "coverageNotes": "Deterministic provider contracts cover complete, incomplete, conflicting, refused, and identity-invalidated owner inventories; degraded fallback/current/legacy routing; repeated stale-binding classification; and fresh-session reattach without local fallback during unresolved ownership. Main IPC contracts cover exact persisted-binding retirement and one fresh replacement after confirmed absence. Live Linux, Windows, WSL, SSH, paired-runtime, and packaged-upgrade journeys remain uncollected.", + "coveredProviders": ["daemon"], + "coverageNotes": "Current deterministic contracts cover daemon owner inventories, current/legacy routing, incomplete-owner refusal, exact reattach and fresh-spawn admission. The in-process fallback provider has been removed: unavailable service startup remains retryable and cannot create a second PTY owner. Historical evidence below describes the former provider topology; it does not qualify the new topology. Live Linux, Windows, WSL, SSH, paired-runtime and packaged-upgrade journeys remain uncollected for this gate.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6830", "https://github.com/stablyai/orca/pull/6866", @@ -12579,16 +12585,18 @@ "https://github.com/stablyai/orca/pull/12776" ], "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions and reap only true orphans. A persisted binding stays fail-closed while any possible owner is incomplete, conflicting, refusing attach, or identity-invalidated, but complete authoritative absence from every configured provider must retire that exact stale binding and let retry or reopen converge without attaching it through local fallback or duplicating a PTY.", - "oracle": "Hold exact fallback, current-daemon, and legacy-daemon inventories behind controlled promises. No attach may settle or dispatch until every provider answers. Complete zero-candidate inventories must report SessionNotFound; incomplete, duplicate, refused, and identity-invalidated attempts must remain owner-unverified. In degraded mode, repeated stale attempts scan each eligible provider once per attempt and dispatch no attach, then one explicit fresh spawn and exact reattach use only the recorded fresh route. Main IPC must compare-and-swap retire only the matching persisted PTY/incarnation, emit one synthetic exit, and create one different fresh PTY.", + "oracle": "Hold current-daemon and legacy-daemon inventories behind controlled promises. Complete authoritative absence reports SessionNotFound; incomplete, duplicate, refused and identity-invalidated attempts remain owner-unverified. Route exact reattach and foreground inspection to the owning daemon. Refuse fresh PTYs when a preserved daemon cannot spawn, then coalesce explicit startup retries without stopping that service. Main IPC compare-and-swap retires only the matching persisted PTY/incarnation.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/daemon/daemon-init-provider-installation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts --reporter=dot" ], "testFiles": [ "src/main/daemon/daemon-session-owner-resolution.test.ts", - "src/main/daemon/daemon-pty-router.test.ts", - "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/daemon/daemon-init-live-session-preservation.test.ts", "src/main/ipc/pty-dead-owner-respawn.test.ts", - "src/main/ipc/pty-persisted-incarnation-repair.test.ts" + "src/main/ipc/pty-persisted-incarnation-repair.test.ts", + "src/main/daemon/daemon-init-provider-installation.test.ts" ], "assertionRefs": [ { @@ -12599,18 +12607,18 @@ ] }, { - "file": "src/main/daemon/daemon-pty-router.test.ts", + "file": "src/main/daemon/daemon-init-live-session-preservation.test.ts", "assertions": [ "complete current and legacy daemon inventories prove liveness absence with one list call per adapter", "one unavailable daemon inventory keeps the same missing PTY liveness unknown" ] }, { - "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "file": "src/main/daemon/daemon-init-live-session-preservation.test.ts", "assertions": [ - "repeated complete absence never dispatches the stale id to fallback or either daemon", - "one explicit fresh fallback PTY reattaches by its exact PTY and incarnation without another inventory", - "a mapped fallback-owned PTY reports live without borrowing fallback authority for unknown ids or scanning daemon inventories" + "does not report absence while any possible daemon owner is unavailable", + "reports absence after every possible daemon owner returns a complete inventory", + "routes fresh foreground confirmation to the session-owning daemon" ] }, { @@ -14347,21 +14355,22 @@ "invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.", "oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts tests/e2e/terminal-shortcuts.spec.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts" ], "testFiles": [ "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", - "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/daemon-init-live-session-preservation.test.ts", "src/main/daemon/daemon-server.test.ts", - "src/main/daemon/degraded-daemon-pty-provider.test.ts", "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts", "src/main/daemon/pty-subprocess.test.ts", "src/main/ipc/pty-runtime-kill-and-exit.test.ts", "src/main/providers/agent-foreground-process.test.ts", - "src/main/providers/local-pty-provider-foreground-process.test.ts", + "src/main/daemon/terminal-host-process-inspection.test.ts", "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts", "src/main/providers/windows-console-attached-processes.test.ts", "src/main/providers/windows-pty-job-membership.test.ts", @@ -14428,10 +14437,8 @@ ] }, { - "file": "src/main/providers/local-pty-provider-foreground-process.test.ts", - "assertions": [ - "a delayed foreground confirmation is discarded after the owning PTY exits" - ] + "file": "src/main/daemon/terminal-host-process-inspection.test.ts", + "assertions": ["returns unverifiable when the expected incarnation is stale"] }, { "file": "src/main/daemon/daemon-pty-adapter.test.ts", @@ -14611,7 +14618,8 @@ "If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.", "Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.", "No live daemon, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected; SSH is covered only by a macOS-client-to-Linux-host relay run.", - "A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs." + "A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs.", + "The retired in-process delayed-confirmation exit test is replaced by daemon stale-incarnation refusal coverage; an equivalent delayed daemon confirmation/exit interleaving is not claimed by that replacement." ], "demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak." }, @@ -14639,6 +14647,8 @@ "oracle": "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ @@ -14647,7 +14657,7 @@ "src/main/runtime/rpc/terminal-send.test.ts", "src/main/ipc/pty-runtime-kill-and-exit.test.ts", "src/main/providers/agent-foreground-process.test.ts", - "src/main/providers/local-pty-provider-foreground-process.test.ts", + "src/main/daemon/terminal-host-process-inspection.test.ts", "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts", "src/main/providers/windows-console-attached-processes.test.ts", "src/main/providers/windows-pty-job-membership.test.ts", @@ -14697,8 +14707,8 @@ ] }, { - "file": "src/main/providers/local-pty-provider-foreground-process.test.ts", - "assertions": ["fresh confirmation is discarded when its owning local PTY exits"] + "file": "src/main/daemon/terminal-host-process-inspection.test.ts", + "assertions": ["returns unverifiable when the expected incarnation is stale"] }, { "file": "src/main/providers/windows-console-attached-processes.test.ts", @@ -14776,7 +14786,8 @@ "Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.", "Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.", "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", - "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." + "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change.", + "The retired in-process delayed-confirmation exit test is replaced by daemon stale-incarnation refusal coverage; an equivalent delayed daemon confirmation/exit interleaving is not claimed by that replacement." ], "demotionRule": "Keep non-blocking or demote to protection none if a generic busy title authorizes without exact title, verified-launch, or foreground identity; provider confirmation becomes unconditional; exact-PTY mismatch can write bytes; unsupported providers fail open; or the focused gate flakes without an actionable product or harness defect." }, @@ -15130,9 +15141,11 @@ "oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then distinguish the three settlement outcomes end to end: only a proven refusal releases the reservation and drains a delivery parked behind the watermark; a dropped in-flight settlement must surface as unverifiable with bytes handed to the transport, preserve the durable write-attempted reservation, and emit no duplicate pointer after restart; a settled write that throws mid-pointer is unverifiable, not a refusal; and an Enter whose settlement is lost stays at enter-attempted so restart emits no second Enter. Install the production PTY controller and verify that it routes settled writes through the owning provider and refuses before any byte when the routed provider cannot settle. Census every production PTY provider class and reject a settlement synthesized from the fire-and-forget write.", "commands": [ "pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-pty-router.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts" ], "testFiles": [ "src/main/runtime/orchestration-message-delivery-identity.test.ts", @@ -15150,8 +15163,7 @@ "src/main/providers/settled-pty-writer-census.test.ts", "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts", "src/main/daemon/client.test.ts", - "src/main/daemon/daemon-pty-router.test.ts", - "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/daemon/daemon-init-live-session-preservation.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts", "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts", @@ -18057,12 +18069,12 @@ "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-pending-setup-waiters.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-parent-removal.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "pnpm run build:electron-vite", "node config/scripts/runtime-file-watcher-resource-probe.mjs", @@ -18071,7 +18083,13 @@ "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "pnpm run build:relay", - "node config/scripts/relay-watcher-fault-harness.mjs" + "node config/scripts/relay-watcher-fault-harness.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts config/scripts/runtime-file-watcher-fault-harness.mjs config/scripts/runtime-file-watcher-resource-probe.mjs src/relay/relay-filesystem-watch-registry.test.ts src/relay/relay-watcher-pending-setup-waiters.test.ts src/relay/relay-watcher-parent-removal.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/fs-handler.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs config/scripts/relay-watcher-fault-harness.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts" ], "testFiles": [ "src/main/daemon/daemon-pty-adapter.test.ts", @@ -18097,11 +18115,9 @@ "src/main/ipc/watcher-removal-gate.test.ts", "src/main/ipc/pty-spawn-cwd-fallback.test.ts", "src/main/ipc/pty-listener-teardown-and-orphans.test.ts", - "src/main/providers/local-pty-provider-shutdown.test.ts", - "src/main/providers/local-pty-provider-session-inventory.test.ts", + "src/main/daemon/terminal-host-concurrent-create.test.ts", "src/main/daemon/pty-subprocess.test.ts", "src/main/daemon/session.test.ts", - "src/main/daemon/terminal-host.test.ts", "src/main/daemon/terminal-host-session-reaping-leak.test.ts", "src/main/runtime/file-watcher-host.test.ts", "src/main/runtime/orca-runtime-files-watch.test.ts", @@ -18330,20 +18346,17 @@ ] }, { - "file": "src/main/providers/local-pty-provider-session-inventory.test.ts", - "assertions": [ - "local process enumeration retains the spawn cwd for destructive ownership fallback" - ] + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": ["returns the cwd passed at spawn time"] }, { - "file": "src/main/providers/local-pty-provider-shutdown.test.ts", + "file": "src/main/daemon/terminal-host-concurrent-create.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" + "escalates an already-graceful termination and joins its physical exit", + "retains an immediate-kill session when physical exit times out", + "coalesces duplicate immediate kill while descendant capture is pending", + "keeps a naturally-exited id reserved until teardown finishes without re-killing its pid", + "upgrades a pending graceful agent teardown when immediate kill arrives" ] }, { @@ -18360,7 +18373,7 @@ ] }, { - "file": "src/main/daemon/terminal-host.test.ts", + "file": "src/main/daemon/terminal-host-concurrent-create.test.ts", "assertions": [ "immediate daemon kill remains pending and listed until the subprocess exit callback", "an unconfirmed immediate kill rejects at the bounded deadline, retains ownership, and blocks same-id replacement" @@ -19779,7 +19792,7 @@ "runner": "local", "platform": "macos", "result": "failed", - "command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", + "command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", "durationSeconds": 0.4, "summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing." } diff --git a/config/scripts/build-cli-runtime.mjs b/config/scripts/build-cli-runtime.mjs index 6949b782e9a2..d3510aaec533 100644 --- a/config/scripts/build-cli-runtime.mjs +++ b/config/scripts/build-cli-runtime.mjs @@ -2,6 +2,7 @@ import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { materializeRuntime } from './build-orcad-bun.mjs' +import { materializeWindowsConpty } from './build-windows-conpty.mjs' import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' import { cliRuntimeTarget, cliRuntimeFilename } from '../bundled-cli-runtime.cjs' @@ -12,6 +13,9 @@ export async function buildCliRuntime(platform, arch, projectDir = root) { const directory = join(projectDir, 'out', 'cli-runtime', `${platform}-${arch}`) mkdirSync(directory, { recursive: true }) await materializeRuntime(target, join(directory, cliRuntimeFilename(platform))) + if (platform === 'win32') { + await materializeWindowsConpty(arch, join(directory, 'conpty')) + } copyFileSync(join(root, 'resources/licenses/bun/LICENSE.md'), join(directory, 'LICENSE.md')) writeFileSync( join(directory, 'runtime.json'), diff --git a/config/scripts/build-cli-runtime.test.mjs b/config/scripts/build-cli-runtime.test.mjs new file mode 100644 index 000000000000..ab70ba4c6421 --- /dev/null +++ b/config/scripts/build-cli-runtime.test.mjs @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('./build-orcad-bun.mjs', () => ({ materializeRuntime: vi.fn() })) +vi.mock('./build-windows-conpty.mjs', () => ({ materializeWindowsConpty: vi.fn() })) +import { materializeRuntime } from './build-orcad-bun.mjs' +import { materializeWindowsConpty } from './build-windows-conpty.mjs' +import { buildCliRuntime } from './build-cli-runtime.mjs' +const roots = [] +afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) + vi.resetAllMocks() +}) +describe('CLI runtime provider packaging', () => { + it.each(['x64', 'arm64'])( + 'adds the pinned %s Windows provider beside the runtime', + async (arch) => { + const root = mkdtempSync(join(tmpdir(), 'cli-build-')) + roots.push(root) + vi.mocked(materializeRuntime).mockImplementation(async (_target, filename) => + writeFileSync(filename, 'runtime') + ) + vi.mocked(materializeWindowsConpty).mockImplementation(async (_arch, directory) => + mkdirSync(directory) + ) + await buildCliRuntime('win32', arch, root) + const directory = join(root, 'out', 'cli-runtime', `win32-${arch}`) + expect(materializeWindowsConpty).toHaveBeenCalledExactlyOnceWith( + arch, + join(directory, 'conpty') + ) + expect(JSON.parse(readFileSync(join(directory, 'runtime.json'), 'utf8')).target).toBe( + `win32-${arch}` + ) + } + ) + it.each(['darwin', 'linux'])('does not provision ConPTY for %s', async (platform) => { + const root = mkdtempSync(join(tmpdir(), 'cli-build-')) + roots.push(root) + await buildCliRuntime(platform, 'arm64', root) + expect(materializeWindowsConpty).not.toHaveBeenCalled() + }) +}) diff --git a/config/scripts/build-orcad-bun.mjs b/config/scripts/build-orcad-bun.mjs index fc46d788292d..b7f2ecd67df0 100644 --- a/config/scripts/build-orcad-bun.mjs +++ b/config/scripts/build-orcad-bun.mjs @@ -18,6 +18,7 @@ import { orcadBunRuntimeFilename } from '../../src/shared/orcad-artifacts.ts' import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION, + orcadBunVersionProbeArgs, orcadBunReleaseUrl } from '../../src/shared/orcad-bun-runtime.ts' import { runProcessSync } from './script-child-process.mjs' @@ -73,7 +74,11 @@ export function findBunExecutable(rootDir, target) { } function verifyRuntime(path) { - const result = runProcessSync({ program: path, args: ['--version'] }) + const result = runProcessSync({ + program: path, + args: [...orcadBunVersionProbeArgs()], + timeoutMs: 10_000 + }) if (result.code !== 0 || result.stdout.trim() !== ORCAD_BUN_VERSION) { throw new Error( `Expected Bun ${ORCAD_BUN_VERSION} at ${path}, got ${result.stdout.trim() || result.stderr.trim()}` diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 41127c28ed20..9284ff6e48be 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node -// Ship Bun with orcad; keep module loading compatible with legacy Node launchers. +// Ship orcad with its pinned Bun runtime. import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' import { buildOrcadEntry, buildOrcadLauncher, ORCAD_BUN_TARGET, - externalNativeAddons, - ORCAD_EXTERNAL_MODULES, + buildOrcadChildEntry, ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' @@ -26,6 +25,7 @@ import { basename, dirname, join, resolve } from 'node:path' import process from 'node:process' import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' +import { materializeWindowsConpty } from './build-windows-conpty.mjs' import { parcelWatcherWrapperSource } from './parcel-watcher-bundle.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { @@ -38,7 +38,7 @@ import { ORCAD_RIPGREP_ARTIFACTS } from '../../src/shared/orcad-artifacts.ts' import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' -import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' +import { ORCAD_BUN_VERSION, orcadBunVersionProbeArgs } from '../../src/shared/orcad-bun-runtime.ts' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' const ROOT = join(import.meta.dirname, '..', '..') @@ -103,7 +103,10 @@ if (!bunRuntimeSource) { throw new Error('ORCAD_BUN_RUNTIME_PATH is required; run `pnpm build:orcad`') } if (targetIsCurrent) { - const version = spawnSync(bunRuntimeSource, ['--version'], { encoding: 'utf8' }) + const version = spawnSync(bunRuntimeSource, orcadBunVersionProbeArgs(), { + encoding: 'utf8', + timeout: 10_000 + }) if (version.status !== 0 || version.stdout.trim() !== ORCAD_BUN_VERSION) { throw new Error( `ORCAD_BUN_RUNTIME_PATH must be Bun ${ORCAD_BUN_VERSION}; got ${version.stdout.trim() || version.stderr.trim()}` @@ -118,6 +121,9 @@ if (!targetIsWindows) { } await stageParcelWatcher(BUILD_TARGET) stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET) +if (targetIsWindows) { + await materializeWindowsConpty(targetArch, join(OUT_DIR, 'conpty')) +} const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET) mkdirSync(dirname(emojiDatasetOutput), { recursive: true }) copyFileSync( @@ -148,37 +154,14 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep', recursive: true }) -/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source - * directory under `outdir`, and both children must land flat beside orcad.js — that is where - * their runtime resolvers look for them. */ -function buildForkedChild(entryPoint, outfile) { - return build({ - entryPoints: [entryPoint], - bundle: true, - platform: 'node', - target: ORCAD_BUN_TARGET, - format: 'cjs', - outfile, - external: ORCAD_EXTERNAL_MODULES, - plugins: [externalNativeAddons], - metafile: true, - minify: true, - sourcemap: false, - define: { - 'process.env.NODE_ENV': '"production"' - }, - logLevel: 'error' - }) -} - const childResults = await Promise.all([ - buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), - buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), - buildForkedChild(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE), + buildOrcadChildEntry(WATCHER_ENTRY, WATCHER_OUT_FILE), + buildOrcadChildEntry(DAEMON_ENTRY, DAEMON_OUT_FILE), + buildOrcadChildEntry(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE), ...Object.entries(ORCAD_CHILD_ENTRY_POINTS) .filter(([role]) => !['watcher', 'daemon', 'ptyGate'].includes(role)) .map(([, entry]) => - buildForkedChild(join(ROOT, entry), join(OUT_DIR, `${basename(entry, '.ts')}.js`)) + buildOrcadChildEntry(join(ROOT, entry), join(OUT_DIR, `${basename(entry, '.ts')}.js`)) ) ]) @@ -213,26 +196,19 @@ const metafiles = [ result.metafile, ...childResults.map((child) => child.metafile) ] -const electronImporters = collectImporters( - metafiles, - (specifier) => specifier === 'electron' || specifier.startsWith('electron/') -) -const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite') - const graphErrors = [] -if (electronImporters.size > 0) { - graphErrors.push( - `${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters] - .map((file) => ` - ${file}`) - .join('\n')}` - ) -} -if (sqliteImporters.size > 0) { - graphErrors.push( - `${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters] - .map((file) => ` - ${file}`) - .join('\n')}` +for (const forbidden of ['electron', 'node:sqlite', 'node-pty']) { + const importers = collectImporters( + metafiles, + (specifier) => specifier === forbidden || specifier.startsWith(`${forbidden}/`) ) + if (importers.size > 0) { + graphErrors.push( + `${importers.size} module(s) in the bundle import ${forbidden}:\n${[...importers] + .map((file) => ` - ${file}`) + .join('\n')}` + ) + } } if (graphErrors.length > 0) { diff --git a/config/scripts/build-terminal-daemon.mjs b/config/scripts/build-terminal-daemon.mjs new file mode 100644 index 000000000000..d3a6c780c8ef --- /dev/null +++ b/config/scripts/build-terminal-daemon.mjs @@ -0,0 +1,21 @@ +import { mkdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildOrcadChildEntry, ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' + +const root = resolve(import.meta.dirname, '../..') + +export async function buildTerminalDaemon(outputDir = join(root, 'out', 'terminal-daemon')) { + mkdirSync(outputDir, { recursive: true }) + return Promise.all([ + buildOrcadChildEntry(ORCAD_CHILD_ENTRY_POINTS.daemon, join(outputDir, 'daemon-entry.js')), + buildOrcadChildEntry( + ORCAD_CHILD_ENTRY_POINTS.ptyGate, + join(outputDir, 'windows-bun-pty-gate-entry.js') + ) + ]) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + await buildTerminalDaemon() +} diff --git a/config/scripts/build-windows-conpty.mjs b/config/scripts/build-windows-conpty.mjs new file mode 100644 index 000000000000..1ddaac49e9fb --- /dev/null +++ b/config/scripts/build-windows-conpty.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + WINDOWS_CONPTY_ARCHIVE, + WINDOWS_CONPTY_FILES, + WINDOWS_CONPTY_VERSION +} from '../../src/shared/windows-conpty-release.ts' +import { getZipExtractorCommand } from './zip-extractor-command.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const root = resolve(import.meta.dirname, '../..') +const licensePath = join(root, 'config', 'licenses', 'windows-conpty-LICENSE.txt') +const maximumArchiveBytes = 16 * 1024 * 1024 +const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex') + +export async function downloadConptyArchive(destination, fetcher = fetch) { + const response = await fetcher(WINDOWS_CONPTY_ARCHIVE.url, { + redirect: 'follow', + signal: AbortSignal.timeout(120_000) + }) + if (!response.ok || !response.body) { + await response.body?.cancel() + throw new Error(`ConPTY download failed: HTTP ${response.status}`) + } + const chunks = [] + let length = 0 + for await (const chunk of response.body) { + length += chunk.byteLength + if (length > maximumArchiveBytes) { + throw new Error('ConPTY archive exceeds download size limit') + } + chunks.push(chunk) + } + writeFileSync(destination, Buffer.concat(chunks)) +} + +export function verifyConptyDirectory(directory, arch) { + const files = WINDOWS_CONPTY_FILES[arch] + if (!files) { + throw new Error(`Unsupported ConPTY architecture: ${arch}`) + } + for (const [filename, expected] of Object.entries(files)) { + if (sha256(join(directory, filename)) !== expected) { + throw new Error(`ConPTY checksum mismatch: ${arch}/${filename}`) + } + } +} + +/** Pins both files independently; no node-pty installation or native rebuild is needed. */ +export async function materializeWindowsConpty(arch, outputDir, options = {}) { + if (!Object.hasOwn(WINDOWS_CONPTY_FILES, arch)) { + throw new Error(`Unsupported ConPTY architecture: ${arch}`) + } + const cacheDir = options.cacheDir ?? join(root, 'out', '.windows-conpty', WINDOWS_CONPTY_VERSION) + mkdirSync(cacheDir, { recursive: true }) + const archive = join(cacheDir, 'conpty.nupkg') + if (existsSync(archive) && sha256(archive) !== WINDOWS_CONPTY_ARCHIVE.sha256) { + rmSync(archive, { force: true }) + } + const temporary = mkdtempSync(join(tmpdir(), 'orca-conpty-')) + try { + if (!existsSync(archive)) { + const downloading = join(temporary, 'conpty.nupkg') + await downloadConptyArchive(downloading, options.fetcher) + if (sha256(downloading) !== WINDOWS_CONPTY_ARCHIVE.sha256) { + throw new Error('ConPTY archive checksum mismatch') + } + // Publish on the cache filesystem, including when TEMP is on another volume. + const staging = mkdtempSync(join(cacheDir, '.download-')) + try { + copyFileSync(downloading, join(staging, 'conpty.nupkg')) + renameSync(join(staging, 'conpty.nupkg'), archive) + } finally { + rmSync(staging, { recursive: true, force: true }) + } + } + const extracted = join(temporary, 'extracted') + mkdirSync(extracted) + const command = getZipExtractorCommand(archive, extracted) + const result = runProcessSync({ program: command.file, args: command.args, timeoutMs: 120_000 }) + if (result.code !== 0) { + throw new Error(`ConPTY extraction failed: ${result.stderr || result.stdout}`) + } + const selected = join(temporary, 'selected') + mkdirSync(selected) + copyFileSync( + join(extracted, 'runtimes', `win-${arch}`, 'native', 'conpty.dll'), + join(selected, 'conpty.dll') + ) + copyFileSync( + join(extracted, 'build', 'native', 'runtimes', arch, 'OpenConsole.exe'), + join(selected, 'OpenConsole.exe') + ) + verifyConptyDirectory(selected, arch) + copyFileSync(licensePath, join(selected, 'LICENSE.txt')) + copyFileSync( + join(extracted, 'Microsoft.Windows.Console.ConPTY.nuspec'), + join(selected, 'Microsoft.Windows.Console.ConPTY.nuspec') + ) + writeFileSync( + join(selected, 'conpty.json'), + `${JSON.stringify( + { + version: WINDOWS_CONPTY_VERSION, + arch, + source: WINDOWS_CONPTY_ARCHIVE.url, + archiveSha256: WINDOWS_CONPTY_ARCHIVE.sha256, + files: WINDOWS_CONPTY_FILES[arch] + }, + null, + 2 + )}\n` + ) + mkdirSync(outputDir, { recursive: true }) + for (const filename of [ + ...Object.keys(WINDOWS_CONPTY_FILES[arch]), + 'LICENSE.txt', + 'Microsoft.Windows.Console.ConPTY.nuspec', + 'conpty.json' + ]) { + copyFileSync(join(selected, filename), join(outputDir, filename)) + } + verifyConptyDirectory(outputDir, arch) + return { directory: outputDir, library: join(outputDir, 'conpty.dll') } + } finally { + rmSync(temporary, { recursive: true, force: true }) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const arch = process.argv[2] ?? process.arch + const output = resolve(process.argv[3] ?? join(root, 'out', 'windows-conpty', arch)) + await materializeWindowsConpty(arch, output) + console.log(`ConPTY ${WINDOWS_CONPTY_VERSION} ${arch}: ${output}`) +} diff --git a/config/scripts/build-windows-conpty.test.mjs b/config/scripts/build-windows-conpty.test.mjs new file mode 100644 index 000000000000..7d50fa136455 --- /dev/null +++ b/config/scripts/build-windows-conpty.test.mjs @@ -0,0 +1,147 @@ +import { createHash } from 'node:crypto' +import { existsSync, 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' + +const fixture = vi.hoisted(() => ({ files: {}, archiveHash: '', archive: 'qualified archive' })) +vi.mock('../../src/shared/windows-conpty-release.ts', () => ({ + WINDOWS_CONPTY_VERSION: 'test-version', + WINDOWS_CONPTY_ARCHIVE: { + url: 'https://example.invalid/conpty.nupkg', + get sha256() { + return fixture.archiveHash + } + }, + WINDOWS_CONPTY_FILES: fixture.files +})) +vi.mock('./zip-extractor-command.mjs', () => ({ + getZipExtractorCommand: (archive, destination) => ({ + file: 'extract', + args: [archive, destination] + }) +})) +vi.mock('./script-child-process.mjs', () => ({ runProcessSync: vi.fn() })) +import { runProcessSync } from './script-child-process.mjs' +import { + downloadConptyArchive, + materializeWindowsConpty, + verifyConptyDirectory +} from './build-windows-conpty.mjs' + +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex') +let root +let cacheDir +let output +let fetcher +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'conpty-build-test-')) + cacheDir = join(root, 'cache') + output = join(root, 'output') + fixture.archiveHash = digest(fixture.archive) + for (const arch of ['x64', 'arm64']) { + fixture.files[arch] = { + 'conpty.dll': digest(`${arch} DLL`), + 'OpenConsole.exe': digest(`${arch} EXE`) + } + } + fetcher = vi.fn(async () => new Response(fixture.archive)) + vi.mocked(runProcessSync).mockImplementation(({ args }) => { + const dir = args[1] + for (const arch of ['x64', 'arm64']) { + const dllDir = join(dir, 'runtimes', `win-${arch}`, 'native') + const exeDir = join(dir, 'build', 'native', 'runtimes', arch) + mkdirSync(dllDir, { recursive: true }) + mkdirSync(exeDir, { recursive: true }) + writeFileSync(join(dllDir, 'conpty.dll'), `${arch} DLL`) + writeFileSync(join(exeDir, 'OpenConsole.exe'), `${arch} EXE`) + } + writeFileSync(join(dir, 'Microsoft.Windows.Console.ConPTY.nuspec'), 'MIT package metadata') + return { code: 0, stdout: '', stderr: '' } + }) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +describe('independent ConPTY payload builder', () => { + it.each(['x64', 'arm64'])( + 'publishes verified %s payload and preserves licensing', + async (arch) => { + await materializeWindowsConpty(arch, output, { cacheDir, fetcher }) + verifyConptyDirectory(output, arch) + expect(readFileSync(join(output, 'LICENSE.txt'), 'utf8')).toContain('MIT License') + expect( + readFileSync(join(output, 'Microsoft.Windows.Console.ConPTY.nuspec'), 'utf8') + ).toContain('MIT') + expect(JSON.parse(readFileSync(join(output, 'conpty.json'), 'utf8'))).toMatchObject({ + arch, + archiveSha256: fixture.archiveHash, + files: fixture.files[arch] + }) + expect(fetcher).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + } + ) + + it('reuses a verified cache without network access', async () => { + await materializeWindowsConpty('x64', output, { cacheDir, fetcher }) + await materializeWindowsConpty('arm64', output, { cacheDir, fetcher }) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('replaces a damaged cache instead of poisoning subsequent builds', async () => { + mkdirSync(cacheDir) + writeFileSync(join(cacheDir, 'conpty.nupkg'), 'damaged') + await materializeWindowsConpty('x64', output, { cacheDir, fetcher }) + expect(readFileSync(join(cacheDir, 'conpty.nupkg'), 'utf8')).toBe(fixture.archive) + }) + + it('rejects a download hash mismatch before extraction or publication', async () => { + fetcher.mockResolvedValue(new Response('wrong archive')) + await expect(materializeWindowsConpty('x64', output, { cacheDir, fetcher })).rejects.toThrow( + 'archive checksum mismatch' + ) + expect(runProcessSync).not.toHaveBeenCalled() + expect(existsSync(output)).toBe(false) + expect(existsSync(join(cacheDir, 'conpty.nupkg'))).toBe(false) + }) + + it('rejects a failed extraction before publishing files', async () => { + vi.mocked(runProcessSync).mockReturnValue({ code: 1, stderr: 'extract failed' }) + await expect(materializeWindowsConpty('x64', output, { cacheDir, fetcher })).rejects.toThrow( + 'extraction failed' + ) + expect(existsSync(output)).toBe(false) + }) + + it('rejects mismatched architecture bytes before publication', async () => { + fixture.files.arm64['conpty.dll'] = digest('foreign DLL') + await expect(materializeWindowsConpty('arm64', output, { cacheDir, fetcher })).rejects.toThrow( + 'checksum mismatch: arm64/conpty.dll' + ) + expect(existsSync(output)).toBe(false) + }) + + it('rejects unsupported architectures before downloading', async () => { + await expect(materializeWindowsConpty('x86', output, { cacheDir, fetcher })).rejects.toThrow( + 'Unsupported' + ) + expect(fetcher).not.toHaveBeenCalled() + }) + + it('bounds download memory and does not retain oversized archives', async () => { + fetcher.mockResolvedValue(new Response(new Uint8Array(16 * 1024 * 1024 + 1))) + const archive = join(root, 'oversize') + await expect(downloadConptyArchive(archive, fetcher)).rejects.toThrow('size limit') + expect(existsSync(archive)).toBe(false) + }) + + it('reports HTTP errors without caching the error body', async () => { + fetcher.mockResolvedValue(new Response('missing', { status: 404 })) + await expect(downloadConptyArchive(join(root, 'download'), fetcher)).rejects.toThrow('HTTP 404') + }) +}) diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/bun-profile-change-scope.test.mjs index 1ce91b24cd6d..3fc436c32cf4 100644 --- a/config/scripts/bun-profile-change-scope.test.mjs +++ b/config/scripts/bun-profile-change-scope.test.mjs @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeAll, describe, expect, it } from 'vitest' @@ -78,7 +78,7 @@ it.each([ '.npmrc', 'tsconfig.json', 'config/tsconfig.node.json', - 'config/patches/node-pty@1.1.0.patch', + 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', '.github/workflows/bun-profile-tests.yml', @@ -124,12 +124,22 @@ describe('the actual Bun build and profile-test dependency graph', () => { 'config/scripts/bun-cpu-baseline-smoke.mjs', 'config/scripts/runtime-serve-terminal-smoke.mjs', 'config/scripts/vitest-host-ports-setup.ts', - 'tests/e2e/daemon-running-work-probe.unit.test.ts' + 'tests/e2e/daemon-running-work-probe.unit.test.ts', + 'src/shared/terminal-unicode-provider.ts', + 'src/shared/terminal-unicode-provider.test.ts', + 'src/main/daemon/headless-emulator-fidelity.fuzz.test.ts' ])('retains the full matrix for a real runtime, worker or test input: %s', async (file) => { expect(inputs.has(file)).toBe(true) expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(true) }) + it('keeps every explicit artifact test selector on disk', () => { + const missing = bunProfileTestPaths({ artifact: true }) + .filter((path) => /\.test\.(?:ts|mjs)$/.test(path)) + .filter((path) => !existsSync(path)) + expect(missing).toEqual([]) + }) + it('retains all selected tests and uses the same selectors as the Bun runner', () => { const tests = discoverBunProfileTests() expect(tests.length).toBeGreaterThan(80) diff --git a/config/scripts/bun-profile-test-paths.mjs b/config/scripts/bun-profile-test-paths.mjs index ccdedef3ce6f..13b17f89b060 100644 --- a/config/scripts/bun-profile-test-paths.mjs +++ b/config/scripts/bun-profile-test-paths.mjs @@ -7,16 +7,25 @@ export function bunProfileTestPaths({ artifact = false } = {}) { 'src/main/sqlite', 'src/main/orcad/orcad-entry.test.ts', 'src/main/orcad/orcad-push-startup.test.ts', + 'src/shared/terminal-unicode-provider.test.ts', + 'src/main/daemon/headless-emulator-unicode-width.test.ts', + 'src/main/daemon/headless-emulator-fidelity.fuzz.test.ts', ...(artifact ? [ 'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts', + 'src/main/pty/posix-pty-process-groups.integration.test.ts', 'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts', 'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts', 'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts', 'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts', 'tests/e2e/daemon-running-work-probe.unit.test.ts', 'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts', - 'src/main/providers/local-pty-bun-artifact.integration.test.ts', + 'src/main/windows/windows-pty-job.win32.test.ts', + 'src/main/daemon/bun-pty-windows-io-failure.win32.test.ts', + 'src/main/windows/windows-msys-job.win32.test.ts', + 'src/main/providers/windows-conpty-wide-char-duplication.bun.test.ts', + 'src/main/providers/pty-repaint-wide-char-buffer.bun.test.ts', + 'src/main/daemon/daemon-bun-pty-artifact.integration.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/orcad/orcad-bun-launcher.integration.test.ts', 'config/scripts/zip-extractor-command.test.mjs', diff --git a/config/scripts/bundled-cli-runtime-fixture.mjs b/config/scripts/bundled-cli-runtime-fixture.mjs index 783e0c8d6768..af36933dc063 100644 --- a/config/scripts/bundled-cli-runtime-fixture.mjs +++ b/config/scripts/bundled-cli-runtime-fixture.mjs @@ -1,3 +1,5 @@ +import { createRequire } from 'node:module' +import { afterEach, beforeEach } from 'vitest' import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' import { createHash } from 'node:crypto' import { copyFile, mkdir, writeFile } from 'node:fs/promises' @@ -12,6 +14,9 @@ export async function writeBundledCliRuntimeFixture(directory, platform, arch) { join(import.meta.dirname, '../../resources/licenses/bun/LICENSE.md'), join(directory, 'LICENSE.md') ) + if (platform === 'win32') { + await writeBundledConptyFixture(join(directory, 'conpty'), arch) + } await writeFile( join(directory, 'runtime.json'), JSON.stringify({ @@ -21,3 +26,53 @@ export async function writeBundledCliRuntimeFixture(directory, platform, arch) { }) ) } + +// CJS packaging verification reads native require's module cache, outside Vitest's ESM mocks. +const require = createRequire(import.meta.url) +const release = require('../../src/shared/windows-conpty-release.ts') +const originalFiles = structuredClone(release.WINDOWS_CONPTY_FILES) +const originalNuspecHash = release.WINDOWS_CONPTY_ARCHIVE.nuspecSha256 +const fixtureArchitectures = new Set() +function mockFixtureHashes(arch) { + for (const filename of Object.keys(release.WINDOWS_CONPTY_FILES[arch])) { + release.WINDOWS_CONPTY_FILES[arch][filename] = createHash('sha256') + .update(`${arch}/${filename} fixture`) + .digest('hex') + } + release.WINDOWS_CONPTY_ARCHIVE.nuspecSha256 = createHash('sha256') + .update('ConPTY MIT package fixture') + .digest('hex') +} +beforeEach(() => fixtureArchitectures.forEach(mockFixtureHashes)) +afterEach(() => { + for (const arch of Object.keys(originalFiles)) { + Object.assign(release.WINDOWS_CONPTY_FILES[arch], originalFiles[arch]) + } + release.WINDOWS_CONPTY_ARCHIVE.nuspecSha256 = originalNuspecHash +}) + +export async function writeBundledConptyFixture(directory, arch) { + fixtureArchitectures.add(arch) + mockFixtureHashes(arch) + await mkdir(directory, { recursive: true }) + for (const filename of Object.keys(release.WINDOWS_CONPTY_FILES[arch])) { + const bytes = `${arch}/${filename} fixture` + await writeFile(join(directory, filename), bytes) + } + const nuspec = 'ConPTY MIT package fixture' + await writeFile(join(directory, 'Microsoft.Windows.Console.ConPTY.nuspec'), nuspec) + await copyFile( + join(import.meta.dirname, '../licenses/windows-conpty-LICENSE.txt'), + join(directory, 'LICENSE.txt') + ) + await writeFile( + join(directory, 'conpty.json'), + JSON.stringify({ + version: release.WINDOWS_CONPTY_VERSION, + arch, + source: release.WINDOWS_CONPTY_ARCHIVE.url, + archiveSha256: release.WINDOWS_CONPTY_ARCHIVE.sha256, + files: release.WINDOWS_CONPTY_FILES[arch] + }) + ) +} diff --git a/config/scripts/bundled-cli-runtime.test.mjs b/config/scripts/bundled-cli-runtime.test.mjs index b5987e6deb80..f1d63a890bc1 100644 --- a/config/scripts/bundled-cli-runtime.test.mjs +++ b/config/scripts/bundled-cli-runtime.test.mjs @@ -1,3 +1,4 @@ +import { writeBundledConptyFixture } from './bundled-cli-runtime-fixture.mjs' import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' import { createHash } from 'node:crypto' import { @@ -16,6 +17,7 @@ import { cliRuntimeTarget, cliRuntimeFilename, cliRuntimeExtraResource, + verifyWindowsConptyDirectory, assertBundledCliRuntimeBuilt } from '../bundled-cli-runtime.cjs' @@ -24,7 +26,7 @@ afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true it.each(['darwin', 'linux', 'win32'])( 'verifies the exact %s architecture and executable bytes', - (platform) => { + async (platform) => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-runtime-')) roots.push(root) const directory = join(root, 'out/cli-runtime', `${platform}-arm64`) @@ -43,6 +45,9 @@ it.each(['darwin', 'linux', 'win32'])( sha256: createHash('sha256').update(bytes).digest('hex') }) ) + if (platform === 'win32') { + await writeBundledConptyFixture(join(directory, 'conpty'), 'arm64') + } expect(() => assertBundledCliRuntimeBuilt(platform, 3, root)).not.toThrow() expect(() => assertBundledCliRuntimeBuilt(platform, 1, root)).toThrow('build-cli-runtime.mjs') const manifestPath = join(directory, 'runtime.json') @@ -64,9 +69,29 @@ it('packages only the selected platform/architecture and rejects unsupported tar expect(cliRuntimeExtraResource('win32')).toEqual({ from: 'out/cli-runtime/win32-${arch}', to: 'cli-runtime', - filter: ['bun-runtime.exe', 'runtime.json', 'LICENSE.md'] + filter: ['bun-runtime.exe', 'runtime.json', 'LICENSE.md', 'conpty/**'] }) expect(cliRuntimeTarget('linux', 'x64')).toBe('linux-x64-glibc') expect(() => cliRuntimeTarget('linux', 'ia32')).toThrow('Unsupported') expect(() => cliRuntimeTarget('freebsd', 'x64')).toThrow('Unsupported') }) + +it.each([ + 'conpty.dll', + 'OpenConsole.exe', + 'LICENSE.txt', + 'Microsoft.Windows.Console.ConPTY.nuspec' +])('rejects missing and corrupted provider %s independently of the manifest', async (filename) => { + const root = mkdtempSync(join(tmpdir(), 'orca-conpty-package-')) + roots.push(root) + await writeBundledConptyFixture(root, 'x64') + expect(() => verifyWindowsConptyDirectory(root, 'x64')).not.toThrow() + expect(() => verifyWindowsConptyDirectory(root, 'arm64')).toThrow('manifest') + unlinkSync(join(root, filename)) + expect(() => verifyWindowsConptyDirectory(root, 'x64')).toThrow() + writeFileSync(join(root, filename), 'corrupt') + const manifest = JSON.parse(readFileSync(join(root, 'conpty.json'), 'utf8')) + manifest.files[filename] = createHash('sha256').update('corrupt').digest('hex') + writeFileSync(join(root, 'conpty.json'), JSON.stringify(manifest)) + expect(() => verifyWindowsConptyDirectory(root, 'x64')).toThrow() +}) diff --git a/config/scripts/capture-agent-pty-transcript.mjs b/config/scripts/capture-agent-pty-transcript.mjs index a60d0adbdd5b..3586af51320a 100644 --- a/config/scripts/capture-agent-pty-transcript.mjs +++ b/config/scripts/capture-agent-pty-transcript.mjs @@ -19,7 +19,6 @@ import { const REPO_ROOT = resolve(import.meta.dirname, '..', '..') const FIXTURE_DIR = join(REPO_ROOT, 'src', 'main', 'runtime', '__fixtures__') -const STOP_KEY = 0x1d // Ctrl-], consumed by the recorder and never forwarded to the agent. const NAME_RE = /^[a-z0-9][a-z0-9-]*$/ const USAGE = `Capture a raw agent PTY transcript into src/main/runtime/__fixtures__/. @@ -121,10 +120,6 @@ function runScan(files, redact) { } function resolveSpawn(command) { - // node-pty cannot run a .cmd/.bat shim directly on Windows; those need cmd.exe. - if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command[0])) { - return { file: 'cmd.exe', args: ['/c', `"${command[0]}"`, ...command.slice(1)] } - } return { file: command[0], args: command.slice(1) } } @@ -137,87 +132,33 @@ async function runCapture(options, command) { const outPath = options.out ? resolve(options.out) : join(FIXTURE_DIR, `${name}.txt`) mkdirSync(dirname(outPath), { recursive: true }) - const pty = await import('node-pty').catch((error) => { - console.error( - `node-pty failed to load. Build it for plain node first: - node config/scripts/ensure-native-runtime.mjs --runtime=node -${String(error)}` - ) - return null - }) - if (pty === null) { - return 2 - } - + const { spawnTranscriptPty } = await import('./pty-transcript-bun-runtime.mjs') + const { recordPtyTranscript } = await import('./pty-transcript-recording.mjs') const cols = options.cols ?? process.stdout.columns ?? 120 const rows = options.rows ?? process.stdout.rows ?? 40 const { file, args } = resolveSpawn(command) - const term = pty.spawn(file, args, { - name: 'xterm-256color', - cols, - rows, - cwd: process.cwd(), - env: { ...process.env, TERM: 'xterm-256color' }, - encoding: null - }) - - const sink = createWriteStream(outPath) - let recording = true - term.onData((chunk) => { - const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk - // Why recording stops before the kill: an agent repaints an idle frame on its way out, so - // a transcript that keeps writing through shutdown ends on that frame instead of on the - // state you stopped to capture. A mid-turn or dialog capture cannot survive that. - if (recording) { - sink.write(bytes) - } - process.stdout.write(bytes) - }) - - const wasRaw = process.stdin.isTTY === true && process.stdin.isRaw === true - if (process.stdin.isTTY) { - process.stdin.setRawMode(true) - } - process.stdin.resume() - let stopping = false - const stop = () => { - if (stopping) { - return - } - stopping = true - recording = false - try { - term.kill() - } catch { - // The agent may have exited on its own; the transcript is already on disk. - } - } - process.stdin.on('data', (chunk) => { - if (chunk.includes(STOP_KEY)) { - stop() - return - } - term.write(chunk.toString('binary')) - }) - // Why scripted input: a dialog capture has to be driven, and CI (or an agent) has no TTY to - // type into. The keystrokes ride the same PTY a human's would, so the capture is unchanged. - const sendTimers = options.sends.map((send) => setTimeout(() => term.write(send.text), send.atMs)) - const durationTimer = options.duration === null ? null : setTimeout(stop, options.duration * 1000) - - const exitCode = await new Promise((resolveExit) => { - term.onExit(({ exitCode: code }) => resolveExit(code ?? 0)) + const env = Object.fromEntries( + Object.entries(process.env).filter(([, value]) => value !== undefined) + ) + const exitCode = await recordPtyTranscript({ + spawn: (onBytes) => + spawnTranscriptPty( + { + file, + args, + cols, + rows, + cwd: process.cwd(), + env: { ...env, TERM: 'xterm-256color' }, + windowsJobKillOnClose: true + }, + onBytes + ), + sink: createWriteStream(outPath), + stdin: process.stdin, + stdout: process.stdout, + options }) - for (const timer of sendTimers) { - clearTimeout(timer) - } - if (durationTimer !== null) { - clearTimeout(durationTimer) - } - if (process.stdin.isTTY) { - process.stdin.setRawMode(wasRaw) - } - process.stdin.pause() - await new Promise((done) => sink.end(done)) writeMeta(outPath, { command, cols, rows, note: options.note ?? null, exitCode }) const findings = scanTranscriptForSecrets(readFileSync(outPath, 'utf8')) @@ -265,7 +206,9 @@ async function main() { console.error(USAGE) return 2 } - return runCapture(options, command) + const { relaunchTranscriptWithBun } = await import('./pty-transcript-bun-runtime.mjs') + const relaunched = await relaunchTranscriptWithBun(REPO_ROOT, process.argv.slice(2)) + return relaunched ?? runCapture(options, command) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 7c165bfc02b7..9489878390c7 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -45,7 +45,6 @@ const ENTRY_POINTS = [ // relay build already does (config/scripts/build-relay.mjs). const EXTERNAL = [ 'electron', - 'node-pty', '@parcel/watcher', 'better-sqlite3', 'keytar', diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index e7e3e279f366..5cf35d457657 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -63,7 +63,6 @@ "config/scripts/electron-builder-speech-config.test.mjs": 270, "config/scripts/electron-runtime-floor.test.ts": 36, "config/scripts/electron-vite-output-contract.test.ts": 847, - "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 59, "config/scripts/ensure-native-runtime.test.mjs": 679, "config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts": 37, "config/scripts/generate-bundled-skill-guides.test.mjs": 625, @@ -180,7 +179,6 @@ "config/scripts/package-electron-runtime-contract.test.mjs": 260, "config/scripts/packaged-browser-lane-contract.test.mjs": 70, "config/scripts/packaged-hang-watchdog-worker-contract.test.mjs": 107, - "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 85, "config/scripts/packaged-source-map-prune.test.mjs": 93, "config/scripts/patched-dependencies-frozen-install.test.mjs": 699, "config/scripts/plain-node-entry-guard.test.ts": 700, @@ -197,7 +195,6 @@ "config/scripts/pty-transcript-secret-scan.test.mjs": 82, "config/scripts/publish-complete-draft-releases.test.mjs": 190, "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 2300, - "config/scripts/rebuild-native-deps-node-pty.test.mjs": 3978, "config/scripts/rebuild-native-deps-windows-process-tree.test.mjs": 34, "config/scripts/rebuild-native-deps.test.mjs": 1038, "config/scripts/reclaim-dev-electron-bundles.test.ts": 296, @@ -217,7 +214,6 @@ "config/scripts/resolve-7za-path.test.mjs": 1528, "config/scripts/run-codex-real-account-validation.test.ts": 3022, "config/scripts/run-internal-dev-setup.test.mjs": 65, - "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 32, "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 54, "config/scripts/run-typecheck-projects-in-parallel.test.mjs": 48, "config/scripts/script-entry-detection.test.mjs": 93, @@ -249,7 +245,6 @@ "config/scripts/verify-packaged-browser-participation.test.mjs": 71, "config/scripts/verify-packaged-daemon-entry.test.mjs": 192, "config/scripts/verify-packaged-mobile-web-bundle.test.mjs": 507, - "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 94, "config/scripts/verify-packaged-plugin-resources.test.mjs": 91, "config/scripts/verify-release-required-assets.test.mjs": 60, "config/scripts/verify-skills-cli-runtime.test.mjs": 366, @@ -1375,7 +1370,7 @@ "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 222, "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 54, "src/main/cursor/hook-service.test.ts": 289, - "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 2437, + "src/main/daemon/agent-startup-prompt-latency.bun.test.ts": 2437, "src/main/daemon/bash-prompt-command-composition.test.ts": 4252, "src/main/daemon/client.test.ts": 403, "src/main/daemon/cold-restore-payload-cache.test.ts": 54, @@ -1474,7 +1469,6 @@ "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 76, "src/main/daemon/daemon-tcc-attribution.test.ts": 71, "src/main/daemon/daemon-transport-attachment-release.test.ts": 714, - "src/main/daemon/degraded-daemon-pty-provider.test.ts": 127, "src/main/daemon/hangul-cell-width-agreement.test.ts": 82, "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 18351, "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 154, @@ -1494,7 +1488,6 @@ "src/main/daemon/macos-login-session-death-watch.test.ts": 104, "src/main/daemon/ndjson.test.ts": 206, "src/main/daemon/node-pty-error-hints.test.ts": 54, - "src/main/daemon/node-pty-windows-input-error.win32.test.ts": 46, "src/main/daemon/osc7-file-uri.test.ts": 59, "src/main/daemon/osc7-uri-extraction.test.ts": 32, "src/main/daemon/post-ready-flush-gate.test.ts": 41, @@ -2936,25 +2929,8 @@ "src/main/providers/agent-foreground-process-wsl-shell-proof.test.ts": 95, "src/main/providers/agent-foreground-process.test.ts": 232, "src/main/providers/execution-host-provider-dispatch.test.ts": 54, - "src/main/providers/local-pty-child-process-verdict.test.ts": 311, - "src/main/providers/local-pty-default-shell.test.ts": 148, - "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 189, - "src/main/providers/local-pty-pi-owner-env.test.ts": 92, - "src/main/providers/local-pty-provider-foreground-process.test.ts": 311, - "src/main/providers/local-pty-provider-io-events.test.ts": 291, - "src/main/providers/local-pty-provider-session-inventory.test.ts": 278, - "src/main/providers/local-pty-provider-shell-readiness.test.ts": 296, - "src/main/providers/local-pty-provider-shutdown.test.ts": 547, - "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 295, - "src/main/providers/local-pty-provider-spawn-env.test.ts": 363, - "src/main/providers/local-pty-provider-spawn-session.test.ts": 293, - "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 325, "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 70, - "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 50, "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 652, - "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 260, - "src/main/providers/local-pty-utils-windows-fallback.test.ts": 51, - "src/main/providers/local-pty-utils.test.ts": 67, "src/main/providers/macos-login-session-pty-probe.test.ts": 66, "src/main/providers/macos-tcc-login-shell.test.ts": 133, "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 51, @@ -2963,7 +2939,7 @@ "src/main/providers/pty-default-cwd.test.ts": 63, "src/main/providers/pty-process-inspection.test.ts": 32, "src/main/providers/pty-process-list-admission.test.ts": 140, - "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1987, + "src/main/providers/pty-repaint-wide-char-buffer.bun.test.ts": 1987, "src/main/providers/settled-pty-writer-census.test.ts": 805, "src/main/providers/ssh-filesystem-dispatch.test.ts": 44, "src/main/providers/ssh-filesystem-doc-preview.test.ts": 62, @@ -3009,7 +2985,7 @@ "src/main/providers/terminal-path-existence-batch.integration.test.ts": 183, "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 95, "src/main/providers/windows-cached-agent-revalidation.test.ts": 57, - "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 72, + "src/main/providers/windows-conpty-wide-char-duplication.bun.test.ts": 72, "src/main/providers/windows-console-attached-processes.test.ts": 66, "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 70, "src/main/providers/windows-foreground-process-rows.test.ts": 61, @@ -3034,8 +3010,6 @@ "src/main/pty/conda-activation-env.test.ts": 50, "src/main/pty/legacy-terminal-shim-dir.test.ts": 487, "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 79, - "src/main/pty/node-pty-master-fd-retirement.test.ts": 5186, - "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 50, "src/main/pty/omp-sqlite-overlay.test.ts": 67, "src/main/pty/overlay-mirror.test.ts": 71, "src/main/pty/pi-process-owner-env.test.ts": 1019, @@ -4241,7 +4215,6 @@ "src/main/ssh/ssh-relay-node-headers.test.ts": 930, "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 124, "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 194, - "src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts": 234, "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 2548, "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 2094, "src/main/ssh/ssh-relay-reset.test.ts": 188, @@ -9223,7 +9196,6 @@ "src/shared/node-bounded-json-stringify.test.ts": 59, "src/shared/node-file-content-equality.test.ts": 58, "src/shared/node-markdown-document-discovery.test.ts": 48, - "src/shared/node-pty-spawn-helper.test.ts": 41, "src/shared/node-readable-text.test.ts": 69, "src/shared/node-source-copy-content-equality.test.ts": 54, "src/shared/notebook-venv-location.test.ts": 30, @@ -9682,8 +9654,6 @@ "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 66, "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 106, "tests/tools/win-update-e2e/app-driver.test.mjs": 753, - "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 61, - "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 108, "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 53 } }, diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs index d9796f3a9623..abd799e89ff9 100644 --- a/config/scripts/daemon-boot-smoke.mjs +++ b/config/scripts/daemon-boot-smoke.mjs @@ -1,21 +1,5 @@ -/** - * Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node — - * the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node - * process) — and asserts it starts, serves a real PTY, and stops. - * - * Why this exists: native-smoke CI (and packaging) went green while - * v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an - * electron `require` leaked into its bundle graph. Nothing executed the built - * entry under plain Node, so the outage was invisible until an adopted old - * daemon died in the field. This runs on every PR that touches the daemon. - * - * Hard assertions (fail the job): - * - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and - * - it terminates when asked (no hang / zombie). - * Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC, - * because node-pty spawn can be flaky on constrained CI runners. - */ -import { fork } from 'node:child_process' +/** Boots the shipped standalone daemon with its verified Bun runtime and a real PTY. */ +import { daemonSmokeEntry, spawnDaemonSmoke } from './daemon-smoke-launch.mjs' import { connect } from 'node:net' import { randomUUID } from 'node:crypto' import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' @@ -23,7 +7,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' const projectDir = resolve(import.meta.dirname, '../..') -const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js') +const entryPath = daemonSmokeEntry(projectDir) const READY_TIMEOUT_MS = 30_000 const PTY_HEALTH_TIMEOUT_MS = 10_000 @@ -132,21 +116,14 @@ function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs }) } -// Best-effort: constrained CI runners can make node-pty spawn flaky. async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { - try { - await runDaemonRpc( - socketPath, - tokenPath, - protocolVersion, - { id: 'health-1', type: 'ptySpawnHealth' }, - PTY_HEALTH_TIMEOUT_MS - ) - return true - } catch (error) { - log(`PTY spawn health check skipped (best-effort): ${error.message}`) - return false - } + await runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { id: 'health-1', type: 'ptySpawnHealth' }, + PTY_HEALTH_TIMEOUT_MS + ) } async function main() { @@ -157,31 +134,21 @@ async function main() { const launchNonce = randomUUID() const protocolVersion = readProtocolVersion() - log(`forking ${entryPath} under plain Node (${process.execPath})`) - const child = fork( + log(`launching ${entryPath} under verified bundled Bun`) + const child = spawnDaemonSmoke(projectDir, userDataDir, [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + '--entry-path', entryPath, - [ - '--socket', - socketPath, - '--token', - tokenPath, - '--pid-record', - pidPath, - '--launch-nonce', - launchNonce, - '--entry-path', - entryPath, - '--app-version', - 'daemon-boot-smoke' - ], - { - // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in - // CI, and this is exactly the runtime where a leaked `require("electron")` - // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } - } - ) + '--app-version', + 'daemon-boot-smoke' + ]) let stderr = '' child.stderr?.on('data', (chunk) => { @@ -252,10 +219,8 @@ async function main() { stderr += '[boot-smoke] stderr released at readiness, mirroring production\n' child.disconnect() - const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) - if (ptyHealthy) { - log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end') - } + await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) + log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end') await new Promise((resolveExit, rejectExit) => { const timer = setTimeout(() => { @@ -298,7 +263,7 @@ async function main() { throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`) } - log('PASS: daemon booted, served, and shut down under plain Node') + log('PASS: daemon booted, served, and shut down under bundled Bun') } finally { cleanup() } diff --git a/config/scripts/daemon-endpoint-handover-smoke.mjs b/config/scripts/daemon-endpoint-handover-smoke.mjs index 729b332c67f0..9d459e740bc8 100644 --- a/config/scripts/daemon-endpoint-handover-smoke.mjs +++ b/config/scripts/daemon-endpoint-handover-smoke.mjs @@ -9,7 +9,7 @@ * * Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs */ -import { fork } from 'node:child_process' +import { daemonSmokeEntry, spawnDaemonSmoke } from './daemon-smoke-launch.mjs' import { connect } from 'node:net' import { randomUUID } from 'node:crypto' import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' @@ -17,7 +17,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' const repoRoot = resolve(import.meta.dirname, '..', '..') -const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js') +const entryPath = daemonSmokeEntry(repoRoot) // Why read it from source: the launcher keys adoption of a live incumbent on this exact code, // and hardcoding it here would let the two drift silently — which is the failure the assertion @@ -45,27 +45,20 @@ function readProtocolVersion() { function bootDaemon(tag, dir, socketPath) { const tokenPath = join(dir, `${tag}.token`) const pidPath = join(dir, `${tag}.pid`) - const child = fork( + const child = spawnDaemonSmoke(repoRoot, dir, [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + randomUUID(), + '--entry-path', entryPath, - [ - '--socket', - socketPath, - '--token', - tokenPath, - '--pid-record', - pidPath, - '--launch-nonce', - randomUUID(), - '--entry-path', - entryPath, - '--app-version', - 'endpoint-handover-smoke' - ], - { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - env: { ...process.env, ORCA_USER_DATA_PATH: dir } - } - ) + '--app-version', + 'endpoint-handover-smoke' + ]) let stderr = '' child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8') diff --git a/config/scripts/daemon-smoke-launch.mjs b/config/scripts/daemon-smoke-launch.mjs new file mode 100644 index 000000000000..bef6c5ddf8df --- /dev/null +++ b/config/scripts/daemon-smoke-launch.mjs @@ -0,0 +1,54 @@ +import { bunOwnedRuntimeArgs } from '../../src/shared/bun-owned-runtime-args.ts' +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { cliRuntimeFilename, verifyCliRuntimeDirectory } from '../bundled-cli-runtime.cjs' + +export function daemonSmokeEntry(projectDir) { + return join(projectDir, 'out', 'terminal-daemon', 'daemon-entry.js') +} + +export function daemonSmokeLaunchOptions(projectDir, userDataDir, env = process.env) { + const runtimeDir = join(projectDir, 'out', 'cli-runtime', `${process.platform}-${process.arch}`) + verifyCliRuntimeDirectory(runtimeDir, process.platform, process.arch) + const entryPath = daemonSmokeEntry(projectDir) + if (!existsSync(entryPath)) { + throw new Error(`Missing ${entryPath}; run pnpm build:terminal-daemon first`) + } + const daemonEnv = { ...env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_USER_DATA_PATH: userDataDir } + for (const key of [ + 'ELECTRON_RUN_AS_NODE', + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'BUN_INSPECT_BRK', + 'BUN_INSPECT_WAIT', + 'BUN_CONPTY_LIBRARY' + ]) { + delete daemonEnv[key] + } + if (process.platform === 'win32') { + daemonEnv.BUN_CONPTY_LIBRARY = join(runtimeDir, 'conpty', 'conpty.dll') + } + return { + program: join(runtimeDir, cliRuntimeFilename(process.platform)), + entryPath, + options: { + cwd: userDataDir, + detached: true, + windowsHide: true, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: daemonEnv + } + } +} + +export function spawnDaemonSmoke(projectDir, userDataDir, args) { + const launch = daemonSmokeLaunchOptions(projectDir, userDataDir) + return spawn( + launch.program, + [...bunOwnedRuntimeArgs(), launch.entryPath, ...args], + launch.options + ) +} diff --git a/config/scripts/daemon-smoke-launch.test.mjs b/config/scripts/daemon-smoke-launch.test.mjs new file mode 100644 index 000000000000..eaef8fdb88d8 --- /dev/null +++ b/config/scripts/daemon-smoke-launch.test.mjs @@ -0,0 +1,102 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { daemonSmokeEntry, daemonSmokeLaunchOptions } from './daemon-smoke-launch.mjs' +import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'daemon-smoke-launch-')) + const runtime = join(root, 'out', 'cli-runtime', `${process.platform}-${process.arch}`) + mkdirSync(runtime, { recursive: true }) + mkdirSync(join(root, 'out', 'terminal-daemon')) + writeFileSync(daemonSmokeEntry(root), '// daemon fixture') + const binary = join(runtime, 'bun-runtime') + writeFileSync(binary, 'runtime fixture') + writeFileSync( + join(runtime, 'runtime.json'), + JSON.stringify({ + target: `${process.platform}-${process.arch}${process.platform === 'linux' ? '-glibc' : ''}`, + version: ORCAD_BUN_VERSION, + sha256: createHash('sha256').update('runtime fixture').digest('hex') + }) + ) + writeFileSync( + join(runtime, 'LICENSE.md'), + readFileSync(new URL('../../resources/licenses/bun/LICENSE.md', import.meta.url)) + ) + return { root, binary } +} + +test.skipIf(process.platform === 'win32')( + 'launch options use standalone Bun and reject altered runtime bytes', + () => { + const { root, binary } = fixture() + try { + const launch = daemonSmokeLaunchOptions(root, root, { + NODE_OPTIONS: 'injected', + NODE_PATH: 'foreign', + BUN_OPTIONS: 'foreign', + BUN_INSPECT: 'foreign', + BUN_INSPECT_BRK: 'foreign', + BUN_INSPECT_WAIT: 'foreign', + ELECTRON_RUN_AS_NODE: '1', + BUN_CONPTY_LIBRARY: 'foreign' + }) + assert.equal(launch.program, binary) + assert.equal(launch.entryPath, join(root, 'out', 'terminal-daemon', 'daemon-entry.js')) + assert.equal(launch.options.cwd, root) + assert.equal(launch.options.env.ORCA_BACKGROUND_LAUNCH, '1') + for (const key of [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'BUN_INSPECT_BRK', + 'BUN_INSPECT_WAIT', + 'ELECTRON_RUN_AS_NODE', + 'BUN_CONPTY_LIBRARY' + ]) { + assert.equal(launch.options.env[key], undefined) + } + assert.deepEqual(launch.options.stdio, ['ignore', 'ignore', 'pipe', 'ipc']) + writeFileSync(binary, 'tampered') + assert.throws(() => daemonSmokeLaunchOptions(root, root), /checksum mismatch/) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) + +test('every lifecycle smoke uses the standalone verified launch path', () => { + for (const name of [ + 'daemon-boot-smoke', + 'daemon-endpoint-handover-smoke', + 'windows-daemon-workspace-close-repro' + ]) { + const source = readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') + assert.match(source, /spawnDaemonSmoke\(/) + assert.doesNotMatch(source, /\bfork\(/) + assert.doesNotMatch(source, /'out', 'main'/) + } + const boot = readFileSync(new URL('./daemon-boot-smoke.mjs', import.meta.url), 'utf8') + assert.doesNotMatch(boot, /best-effort|health check skipped/i) + const workflow = readFileSync( + new URL('../../.github/workflows/computer-e2e.yml', import.meta.url), + 'utf8' + ) + assert.ok( + workflow.indexOf('run: pnpm run build:terminal-daemon') < + workflow.indexOf('name: Daemon boot smoke') + ) +}) + +test('Windows launch fixes the provider selector and suppresses inherited runtime injection', () => { + const source = readFileSync(new URL('./daemon-smoke-launch.mjs', import.meta.url), 'utf8') + assert.match(source, /verifyCliRuntimeDirectory\(runtimeDir, process.platform, process.arch\)/) + assert.match(source, /daemonEnv.BUN_CONPTY_LIBRARY = join\(runtimeDir, 'conpty', 'conpty.dll'\)/) + assert.match(source, /\[\.\.\.bunOwnedRuntimeArgs\(\), launch.entryPath, \.\.\.args\]/) + assert.match(source, /windowsHide: true/) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 9d38dc6de15d..531405b3a614 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,6 +1,6 @@ import { writeBundledCliRuntimeFixture } from './bundled-cli-runtime-fixture.mjs' import { existsSync } from 'node:fs' -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -17,6 +17,34 @@ const FpmTarget = require('app-builder-lib/out/targets/FpmTarget').default const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs') describe('electron-builder config', () => { + it('preserves the pinned Bun vendor signature without excluding other application code', () => { + const ignored = (path) => + electronBuilderConfig.mac.signIgnore.some((pattern) => path.match(pattern)) + expect(ignored('/Orca.app/Contents/Resources/cli-runtime/bun-runtime')).toBe(true) + expect(ignored('/Orca.app/Contents/MacOS/Orca')).toBe(false) + expect(ignored('/Orca.app/Contents/Resources/terminal-daemon/daemon-entry.js')).toBe(false) + expect(ignored('/Orca.app/Contents/Resources/cli-runtime/bun-runtime-extra')).toBe(false) + }) + + it('refuses a macOS package if signing changed the pinned runtime bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-signed-runtime-')) + const directory = join(root, 'Orca.app', 'Contents', 'Resources', 'cli-runtime') + const context = { + appOutDir: root, + electronPlatformName: 'darwin', + arch: 3, + packager: { appInfo: { productFilename: 'Orca' } } + } + try { + await writeBundledCliRuntimeFixture(directory, 'darwin', 'arm64') + expect(() => electronBuilderConfig.afterSign(context)).not.toThrow() + await writeFile(join(directory, 'bun-runtime'), 'changed by signing') + expect(() => electronBuilderConfig.afterSign(context)).toThrow('checksum mismatch') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('keeps the packaged app identity aligned with local-build validation', () => { expect(electronBuilderConfig.appId).toBe( require('../../src/shared/local-build-compatibility-contract.json').appId @@ -26,6 +54,7 @@ describe('electron-builder config', () => { it('excludes repo-only source trees from app.asar', () => { expect(electronBuilderConfig.files).toEqual( expect.arrayContaining([ + '!.build{,/**/*}', '!src{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', @@ -454,6 +483,10 @@ describe('arch-aware packaging guard', () => { scratch = await mkdtemp(join(tmpdir(), 'orca-electron-builder-guard-')) bundleDir = join(scratch, 'mobile-web') await writeMobileWebBundleFixtureTree({ outDir: bundleDir }) + const daemonDirectory = join(scratch, 'out', 'terminal-daemon') + await mkdir(daemonDirectory, { recursive: true }) + await writeFile(join(daemonDirectory, 'daemon-entry.js'), '') + await writeFile(join(daemonDirectory, 'windows-bun-pty-gate-entry.js'), '') for (const platform of ['darwin', 'linux', 'win32']) { for (const arch of ['x64', 'arm64']) { const directory = join(scratch, 'out', 'cli-runtime', `${platform}-${arch}`) @@ -509,3 +542,20 @@ describe('arch-aware packaging guard', () => { } }) }) + +describe('shared Bun terminal daemon packaging', () => { + it('packages the standalone entries with one shared CLI runtime on every platform', () => { + for (const platform of ['mac', 'linux', 'win']) { + const resources = electronBuilderConfig[platform].extraResources + expect(resources.filter((resource) => resource.to === 'terminal-daemon')).toEqual([ + { + from: 'out/terminal-daemon', + to: 'terminal-daemon', + filter: ['daemon-entry.js', 'windows-bun-pty-gate-entry.js'] + } + ]) + expect(resources.filter((resource) => resource.to === 'cli-runtime')).toHaveLength(1) + expect(resources.some((resource) => resource.to === 'orcad-template')).toBe(false) + } + }) +}) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 8a82b6938cbc..e5fa704aeec2 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -15,7 +15,6 @@ const { findAsarEntry, isPackagedExternalSpecifier, packageNameFromSpecifier, - prunePackagedNodePty, prunePackagedParcelWatcher, prunePackagedSherpaOnnx, prunePackagedRuntimeTypeAndSourceMapArtifacts, @@ -189,80 +188,13 @@ describe('packaged runtime resources', () => { expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js') }) - it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-')) - try { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - const prebuildsDir = join(nodePtyDir, 'prebuilds') - const binDir = join(nodePtyDir, 'bin') - await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true }) - await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true }) - await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true }) - await mkdir(join(nodePtyDir, 'third_party', 'conpty'), { - recursive: true - }) - await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true }) - - prunePackagedNodePty(resourcesDir, 'darwin', 3) - - await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64']) - await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148']) - await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([]) - await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([]) - expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow( - 'Unsupported packaged runtime architecture: 4' - ) - } finally { - await removeTree(resourcesDir) - } - }) - - it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => { - for (const [arch, electronArch] of [ - ['x64', 1], - ['arm64', 3] - ]) { - const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`)) - try { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - const releaseDir = join(nodePtyDir, 'build', 'Release') - const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0') - await mkdir(releaseDir, { recursive: true }) - await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8') - for (const sourceArch of ['x64', 'arm64']) { - const sourceDir = join(conptyRoot, `win10-${sourceArch}`) - await mkdir(sourceDir, { recursive: true }) - await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8') - await writeFile( - join(sourceDir, 'OpenConsole.exe'), - `console payload ${sourceArch}`, - 'utf8' - ) - } - - prunePackagedNodePty(resourcesDir, 'win32', electronArch) - - await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe( - `dll payload ${arch}` - ) - await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe( - `console payload ${arch}` - ) - } finally { - await removeTree(resourcesDir) - } - } - }) - it('includes external main dependencies in the packaged runtime closure', () => { // Why: the main process imports '@parcel/watcher' for filesystem change // events; if it is absent from the packaged closure the serve host silently // stops propagating file changes to clients (regression guard for #4851). const packaged = createPackagedRuntimeNodeModuleResources() const packagedTargets = packaged.map((resource) => resource.to) + expect(packagedTargets).not.toContain(join('node_modules', 'node-pty')) expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher')) expect( packagedTargets.some((target) => @@ -398,6 +330,24 @@ describe('packaged runtime resources', () => { } }) + it.each(['x64', 'arm64'])('rejects a missing packaged %s Bun ConPTY provider', async (arch) => { + const root = await mkdtemp(join(tmpdir(), 'orca-packaged-conpty-')) + try { + const runtimeDir = join(root, 'resources', 'cli-runtime') + await writeBundledCliRuntimeFixture(runtimeDir, 'win32', arch) + await removeTree(join(runtimeDir, 'conpty')) + await expect( + electronBuilderConfig.afterPack({ + appOutDir: root, + electronPlatformName: 'win32', + arch: arch === 'x64' ? 1 : 3 + }) + ).rejects.toThrow(/conpty/) + } finally { + await removeTree(root) + } + }) + it.skipIf(process.platform === 'win32')( 'prunes non-target native packages before the Linux glibc gate', async () => { @@ -431,6 +381,8 @@ describe('packaged runtime resources', () => { process.arch === 'x64' ? { electronArch: 3, machine: 0xb7, nonTarget: 'x64' } : { electronArch: 1, machine: 0x3e, nonTarget: 'arm64' } + await mkdir(join(resourcesDir, 'terminal-daemon'), { recursive: true }) + await writeFile(join(resourcesDir, 'terminal-daemon', 'daemon-entry.js'), '') await writeBundledCliRuntimeFixture( join(resourcesDir, 'cli-runtime'), 'linux', @@ -513,6 +465,8 @@ describe('packaged runtime resources', () => { 'utf8' ) await writeFile(join(unpackedCliDir, 'cli-bin.js'), "require('./index.js')\n", 'utf8') + await mkdir(join(resourcesDir, 'terminal-daemon'), { recursive: true }) + await writeFile(join(resourcesDir, 'terminal-daemon', 'daemon-entry.js'), '') await writeBundledCliRuntimeFixture(join(resourcesDir, 'cli-runtime'), 'linux', 'x64') await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 958e461ffd94..e9d620b65d36 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -139,7 +139,7 @@ describe('Electron Vite output contract', () => { throw new Error('Expected main-process external predicate') } - expect(external('node-pty', undefined, false)).toBe(true) + expect(external('node-pty', undefined, false)).toBe(false) expect(external('@parcel/watcher', undefined, false)).toBe(true) expect(external('electron', undefined, false)).toBe(true) expect(external('node:fs', undefined, false)).toBe(true) diff --git a/config/scripts/ensure-native-runtime-job-ownership.test.mjs b/config/scripts/ensure-native-runtime-job-ownership.test.mjs deleted file mode 100644 index 960c87e362b5..000000000000 --- a/config/scripts/ensure-native-runtime-job-ownership.test.mjs +++ /dev/null @@ -1,299 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { peImage } from './windows-pe-image-fixture.mjs' - -const require = createRequire(import.meta.url) -const { - CYGWIN_BREAKAWAY_MARKER, - CYGWIN_BREAKAWAY_MARKER_TEXT, - assertNodePtyJobOwnership, - assertRebuiltConptyDeniesMsysBreakaway, - nodePtyAddonPath -} = require('./node-pty-job-ownership.cjs') -const NODE_PTY_PATCH = readFileSync( - new URL('../patches/node-pty@1.1.0.patch', import.meta.url), - 'utf8' -) - -const JOB_EXPORTS = { - listJobProcessIds: () => [], - terminateJob: () => true, - assignCurrentProcessToJob: () => true -} - -const fixtureDir = mkdtempSync(join(tmpdir(), 'node-pty-job-ownership-')) - -/** A stand-in addon; only the wide literal the gate reads has to be real. */ -function writeAddon(name, { cygwinBreakawayDenied }) { - const path = join(fixtureDir, name) - writeFileSync( - path, - Buffer.concat([ - Buffer.from('MZ fake addon '), - cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0) - ]) - ) - return path -} - -const CURRENT_ADDON = writeAddon('current.node', { cygwinBreakawayDenied: true }) -const PRE_MSYS_ADDON = writeAddon('pre-msys.node', { cygwinBreakawayDenied: false }) - -const PATCHED = { dir: 'build/Release/', module: JOB_EXPORTS } -const PREBUILD = { - dir: 'prebuilds/win32-x64/', - module: { - startProcess: () => {}, - connect: () => {}, - resize: () => {}, - clear: () => {}, - kill: () => {} - } -} - -const onWindows = (native, addonPath) => ({ - platform: 'win32', - nativeName: 'conpty', - native, - addonPath -}) - -describe('assertNodePtyJobOwnership', () => { - it('keeps node-addon-api project paths absolute during Windows source builds', () => { - expect(NODE_PTY_PATCH).toContain( - `+ " { - expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'") - expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'") - }) - - // The gate's whole case rests on this literal, and nothing else ties the - // constant to the C++ that compiles it in. Drift either way has to fail HERE: - // otherwise it fails every correctly rebuilt addon, and no rebuild can fix it. - it('sniffs for a literal the patch really adds to conpty.cc', () => { - const conptyHunk = NODE_PTY_PATCH.split(/^diff --git /m).find((section) => - section.startsWith('a/src/win/conpty.cc ') - ) - expect(conptyHunk, 'the patch no longer touches src/win/conpty.cc').toBeDefined() - const addedCode = conptyHunk - .split('\n') - .filter((line) => line.startsWith('+') && !/^\+\s*(\/\/|\*)/.test(line)) - expect( - addedCode.some((line) => line.includes(`L"${CYGWIN_BREAKAWAY_MARKER_TEXT}"`)), - `No added conpty.cc line carries L"${CYGWIN_BREAKAWAY_MARKER_TEXT}". Either the patch ` + - 'stopped adding it or CYGWIN_BREAKAWAY_MARKER_TEXT drifted; until they agree the gate ' + - 'rejects every correctly rebuilt addon.' - ).toBe(true) - }) - - // MSVC compiles L"" to UTF-16LE; reading the addon as anything else finds nothing. - it('looks for that literal in the encoding the compiler stores it in', () => { - expect(CYGWIN_BREAKAWAY_MARKER.toString('utf16le')).toBe(CYGWIN_BREAKAWAY_MARKER_TEXT) - expect(CYGWIN_BREAKAWAY_MARKER.length).toBe(CYGWIN_BREAKAWAY_MARKER_TEXT.length * 2) - }) - - it('rejects the prebuild that shipped without the job exports', () => { - expect(() => assertNodePtyJobOwnership(onWindows(PREBUILD, CURRENT_ADDON))).toThrow( - /listJobProcessIds, terminateJob, assignCurrentProcessToJob/ - ) - }) - - it('names where the bad native came from, so the fix is obvious', () => { - expect(() => assertNodePtyJobOwnership(onWindows(PREBUILD, CURRENT_ADDON))).toThrow( - /prebuilds\/win32-x64/ - ) - }) - - it('accepts a source build carrying the patch', () => { - expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, CURRENT_ADDON))).not.toThrow() - }) - - // The reason this gate reads the binary at all: every export above predates - // the Cygwin/MSYS breakaway denial, so a build that leaks every Git Bash - // child out of its pane's job satisfies all of them. - it('rejects a source build that predates the Cygwin/MSYS breakaway denial', () => { - expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, PRE_MSYS_ADDON))).toThrow( - /predates the Cygwin\/MSYS job-breakaway denial/ - ) - }) - - it('tells that build apart by path, and says to rebuild', () => { - expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, PRE_MSYS_ADDON))).toThrow( - /pre-msys\.node[\s\S]*Rebuild node-pty from source/ - ) - }) - - it.each([ - ['no path at all', undefined], - ['a path that is not there', join(fixtureDir, 'absent.node')] - ])('refuses rather than skip when the addon cannot be read: %s', (_case, addonPath) => { - expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, addonPath))).toThrow( - /Cannot read node-pty's conpty native/ - ) - }) - - // Passing the conpty name and no readable addon: on win32 every remaining - // branch throws, so only the platform gate can keep these quiet. The MSYS - // breakaway denial is a Windows concern and must cost other hosts nothing. - it.each([ - ['non-Windows hosts', { platform: 'darwin', nativeName: 'conpty' }], - ['non-Windows hosts building for one', { platform: 'linux', nativeName: 'conpty' }], - ['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }] - ])('stays out of the way on %s', (_case, spec) => { - expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow() - }) - - it('would have thrown on Windows for the very same input', () => { - expect(() => - assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD }) - ).toThrow() - }) -}) - -describe('nodePtyAddonPath', () => { - // Built from segments rather than a POSIX string: on Windows `resolve` returns - // a drive letter and backslashes, so a literal only ever passed off Windows. - it('resolves the addon against node-pty lib, which is the only base callers share', () => { - expect( - nodePtyAddonPath( - resolve('/app/node_modules/node-pty/lib/utils.js'), - { dir: '../build/Release/' }, - 'conpty' - ) - ).toBe(join(resolve('/app/node_modules/node-pty'), 'build', 'Release', 'conpty.node')) - }) - - it('handles the bundled layout, where the addon sits beside lib', () => { - expect( - nodePtyAddonPath( - resolve('/app/resources/node-pty/lib/utils.js'), - { dir: './build/Release/' }, - 'conpty' - ) - ).toBe(join(resolve('/app/resources/node-pty/lib'), 'build', 'Release', 'conpty.node')) - }) -}) - -describe('assertRebuiltConptyDeniesMsysBreakaway', () => { - const rebuiltInto = (files) => { - const nodePtyDir = join(mkdtempSync(join(fixtureDir, 'rebuild-')), 'node-pty') - for (const [relativePath, options] of Object.entries(files)) { - const { arch = 'x64', cygwinBreakawayDenied = true } = options - const addonPath = join(nodePtyDir, ...relativePath.split('/')) - mkdirSync(dirname(addonPath), { recursive: true }) - writeFileSync( - addonPath, - Buffer.concat([ - peImage({ arch }), - cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0) - ]) - ) - } - return nodePtyDir - } - - it('accepts the addon a good same-host rebuild leaves behind', () => { - const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': {} }) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false }) - ).not.toThrow() - }) - - it('rejects one that predates the denial, wherever the rebuild ran', () => { - const nodePtyDir = rebuiltInto({ - 'build/Release/conpty.node': { cygwinBreakawayDenied: false } - }) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: true }) - ).toThrow(/predates the Cygwin\/MSYS job-breakaway denial/) - }) - - // On the host that will run this install, no addon means loadNativeModule - // falls through to the published prebuild -- the binary that leaks every MSYS - // pane child. That is a broken build, not an absence to shrug at. - it('refuses a same-host rebuild that reported success and produced nothing', () => { - const nodePtyDir = rebuiltInto({ 'package.json': {} }) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false }) - ).toThrow(/the rebuild reported success/) - }) - - it('names both the addon it wanted and the prebuild that would load instead', () => { - const nodePtyDir = rebuiltInto({ 'package.json': {} }) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'arm64', crossHost: false }) - ).toThrow(/build[\\/]Release[\s\S]*prebuilds[\\/]win32-arm64/) - }) - - // A rebuild that ignored --arch leaves a binary the target cannot load, so - // node-pty falls back to the prebuild. Saying so here is two steps closer to - // the command that fixes it than saying so at packaging time. - it('rejects an addon of an architecture this rebuild did not target', () => { - const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': { arch: 'x64' } }) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'arm64', crossHost: true }) - ).toThrow(/machine 0x8664, but this rebuild targets win32-arm64/) - }) - - // "node-gyp ignored --arch" is a guess when the file is not a PE at all: that - // is a truncated or quarantined artifact, and saying otherwise sends the - // reader to the wrong command. - it('does not blame --arch for a file that is not a PE image', () => { - const nodePtyDir = join(mkdtempSync(join(fixtureDir, 'rebuild-')), 'node-pty') - mkdirSync(join(nodePtyDir, 'build', 'Release'), { recursive: true }) - writeFileSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'), Buffer.alloc(0x200)) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false }) - ).toThrow(/is not a PE image[\s\S]*truncated or quarantined/) - }) - - it('still names the consequence for that file, which is the prebuild', () => { - const nodePtyDir = join(mkdtempSync(join(fixtureDir, 'rebuild-')), 'node-pty') - mkdirSync(join(nodePtyDir, 'build', 'Release'), { recursive: true }) - writeFileSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'), Buffer.alloc(0x200)) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false }) - ).toThrow(/fall back to the published prebuild/) - }) - - it('accepts one a cross-arch rebuild really did emit for the target', () => { - const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': { arch: 'arm64' } }) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'arm64', crossHost: true }) - ).not.toThrow() - }) - - // PE_MACHINE covers what Orca ships; an arch it does not know is not one this - // can judge, and guessing would fail a rebuild that was fine. - it('does not judge an architecture it has no machine value for', () => { - const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': { arch: 'x64' } }) - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'ia32', crossHost: true }) - ).not.toThrow() - }) - - it.each([ - ['a cross-host rebuild need not leave a win32 addon here', { crossHost: true }, true], - ['no node-pty on this disk is not a bad build', { crossHost: false }, false] - ])('warns instead: %s', (_case, verdict, nodePtyInstalled) => { - const nodePtyDir = nodePtyInstalled - ? rebuiltInto({ 'package.json': {} }) - : join(fixtureDir, 'no-node-pty-here') - const warn = vi.fn() - expect(() => - assertRebuiltConptyDeniesMsysBreakaway({ - nodePtyDir, - rebuildArch: 'x64', - ...verdict, - warn - }) - ).not.toThrow() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not check the MSYS')) - }) -}) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index c93b6a19d621..1df250651b31 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -3,7 +3,6 @@ import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { release } from 'node:os' import { basename, dirname, resolve } from 'node:path' import { ensureWindowsProcessTreeCommandLinePatch, @@ -13,19 +12,13 @@ import { } from './windows-process-tree-gyp-rebuild.mjs' const require = createRequire(import.meta.url) -const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs') const { assertWindowsProcessTreeCreationTime } = require('./windows-process-tree-creation-time.cjs') const scriptPath = import.meta.filename const projectDir = resolve(import.meta.dirname, '../..') const runtime = readRuntimeArg() -const NATIVE_MODULES = [ - 'node-pty', - ...(process.platform === 'win32' - ? ['@orca/windows-registry', '@vscode/windows-process-tree'] - : []) -] -const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] +const NATIVE_MODULES = + process.platform === 'win32' ? ['@orca/windows-registry', '@vscode/windows-process-tree'] : [] const CHILD_CHECK_FLAG = '--check-only' if (process.argv.includes(CHILD_CHECK_FLAG)) { @@ -64,23 +57,7 @@ function readRuntimeArg() { function ensureNodeRuntime() { const initial = runNodeCheck() - const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason() - if (initial.ok && !patchedNodePtyRebuildReason) { - return - } - - if (patchedNodePtyRebuildReason) { - console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`) - if (!initial.ok) { - printCheckError(initial) - } - const failedModules = initial.failures.map((failure) => failure.moduleName) - const rebuildModules = [ - 'node-pty', - ...failedModules.filter((moduleName) => moduleName !== 'node-pty') - ] - rebuildNodeRuntimeModules(rebuildModules) - verifyNodeRuntimeAfterRebuild() + if (initial.ok) { return } @@ -106,22 +83,13 @@ function verifyNodeRuntimeAfterRebuild() { function ensureElectronRuntime() { const initial = runElectronCheck() - const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason() - if (initial.ok && !patchedNodePtyRebuildReason) { + if (initial.ok) { return } - - if (patchedNodePtyRebuildReason) { - console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`) - if (!initial.ok) { - printCheckError(initial) - } - } else { - console.warn( - `[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.` - ) - printCheckError(initial) - } + console.warn( + `[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.` + ) + printCheckError(initial) runNodeScript(['config/scripts/rebuild-native-deps.mjs']) const final = runElectronCheck() @@ -281,109 +249,10 @@ function loadNativeModule(moduleName) { registry.getRegistryKey(registry.HK.CU, 'Environment') return } - if (moduleName === 'node-pty') { - loadNodePtyNativeModule() - return - } require(moduleName) } -function loadNodePtyNativeModule() { - require('node-pty') - - const { loadNativeModule } = require('node-pty/lib/utils') - const nativeName = getNodePtyNativeModuleName() - // Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a - // terminal is created, so require('node-pty') alone can miss ABI mismatches. - const native = loadNativeModule(nativeName) - assertNodePtyWindowsConptyRuntime(native?.dir) - assertNodePtyJobOwnership({ - nativeName, - native, - addonPath: nodePtyAddonPath(require.resolve('node-pty/lib/utils'), native, nativeName) - }) - if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) { - throw new Error( - `node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active` - ) - } -} - -function assertNodePtyWindowsConptyRuntime(nativeDir) { - if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) { - return - } - const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') - const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find( - (filename) => !existsSync(resolve(runtimeDir, filename)) - ) - if (missingFile) { - throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`) - } -} - -function getNodePtyNativeModuleName() { - if (process.platform !== 'win32') { - return 'pty' - } - - return getWindowsBuildNumber() >= 18309 ? 'conpty' : 'pty' -} - -function getPatchedNodePtyRebuildReason() { - if (!requiresPatchedNodePtySourceBuild()) { - return null - } - - // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and - // Windows patches only land in the source-built build/Release artifacts. - const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') - const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir) - const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath)) - - if (!missingArtifact) { - return null - } - - return 'Patched node-pty build artifacts are missing; rebuilding native deps.' -} - -function patchedNodePtyArtifactPaths(nodePtyDir) { - if (process.platform === 'win32') { - const releaseDir = resolve(nodePtyDir, 'build', 'Release') - return [ - resolve(releaseDir, 'conpty.node'), - ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename)) - ] - } - - const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')] - // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node. - if (process.platform === 'darwin') { - artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')) - } - return artifactPaths -} - -function requiresPatchedNodePtySourceBuild() { - const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch') - if (!existsSync(nodePtyPatchPath)) { - return false - } - - return existsSync(resolve(projectDir, 'node_modules', 'node-pty')) -} - -function isNodePtyReleaseBuildDir(nativeDir) { - return typeof nativeDir === 'string' && nativeDir.replace(/\\/g, '/').includes('build/Release/') -} - -function getWindowsBuildNumber() { - const match = /(\d+)\.(\d+)\.(\d+)/g.exec(release()) - return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0 -} - function rebuildNodeRuntimeModules(moduleNames) { for (const moduleName of moduleNames) { let moduleDir = dirname(require.resolve(`${moduleName}/package.json`)) @@ -401,9 +270,6 @@ function rebuildNodeRuntimeModules(moduleNames) { } console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir }) - if (moduleName === 'node-pty' && process.platform === 'win32') { - runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')]) - } } } diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 47fe435edccb..98e68a7232a2 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -1,127 +1,38 @@ import { spawnSync } from 'node:child_process' -import { - chmodSync, - copyFileSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' - -const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) -// The import walk sees `from './x.mjs'` only, so the createRequire'd CJS -// siblings have to be named. Without them the temp project cannot even load. -const REQUIRED_CJS_SIBLINGS = [ - 'node-pty-job-ownership.cjs', - 'windows-process-tree-creation-time.cjs' -] +import { + mkTempProject, + writeFakeElectronRebuild, + writeFakeUsableElectronPackage, + writeFakeWindowsProcessTree +} from './rebuild-native-deps-test-fixtures.mjs' + +function stageEnsureScript(projectDir) { + const path = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') + copyFileSync(fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)), path) + return path +} describe('ensure-native-runtime', () => { - it('rechecks Node native modules in fresh child processes after rebuilding', () => { - const projectDir = mkTempProject() - - try { - const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') - const logPath = join(projectDir, 'native-runtime.log') - const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') - writeFakeNativeModules(projectDir) - writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) - - const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { - cwd: projectDir, - encoding: 'utf8', - env: envWithPrependedPath(binDir, { - ORCA_NATIVE_TEST_LOG: logPath, - ORCA_NATIVE_TEST_MARKER: markerPath - }) - }) - - expect(result.status, result.stderr).toBe(0) - const log = readFileSync(logPath, 'utf8') - expect(log).toContain('pnpm exec node-gyp rebuild\n') - expect(log).toContain(join('node_modules', 'node-pty')) - if (process.platform === 'linux') { - expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m) - } - expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([ - expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/), - expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/) - ]) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - }) - - it.skipIf(process.platform !== 'win32')( - 'rebuilds other failed Windows addons with patched node-pty', - () => { - const projectDir = mkTempProject() - - try { - const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') - const logPath = join(projectDir, 'native-runtime.log') - const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') - writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true }) - writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) - - const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { - cwd: projectDir, - encoding: 'utf8', - env: envWithPrependedPath(binDir, { - ORCA_NATIVE_TEST_LOG: logPath, - ORCA_NATIVE_TEST_MARKER: markerPath - }) - }) - - expect(result.status, result.stderr).toBe(0) - const log = readFileSync(logPath, 'utf8') - expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2) - expect(log).toContain(join('node_modules', 'node-pty')) - expect(log).toContain(join('node_modules', '@orca', 'windows-registry')) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - } - ) - it.skipIf(process.platform === 'win32')( - 'rebuilds patched node-pty artifacts even when the Node load check passes', + 'accepts Node without any installed native package', () => { const projectDir = mkTempProject() - try { - const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') - const logPath = join(projectDir, 'native-runtime.log') - const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') - writeLoadableNativeModules(projectDir) - writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) - - const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { - cwd: projectDir, - encoding: 'utf8', - env: envWithPrependedPath(binDir, { - ORCA_NATIVE_TEST_LOG: logPath, - ORCA_NATIVE_TEST_MARKER: markerPath - }) - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stderr).toContain( - 'Patched node-pty build artifacts are missing; rebuilding native deps.' + const result = spawnSync( + process.execPath, + [stageEnsureScript(projectDir), '--runtime=node'], + { + cwd: projectDir, + encoding: 'utf8' + } ) - expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') + expect(result.status, result.stderr).toBe(0) + expect(result.stderr).not.toContain('Rebuilding') + expect(existsSync(join(projectDir, 'node_modules'))).toBe(false) } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -129,242 +40,101 @@ describe('ensure-native-runtime', () => { ) it.skipIf(process.platform === 'win32')( - 'rebuilds when patched artifacts exist but node-pty resolves to prebuilds', + 'repairs Electron metadata even without rebuildable addons', () => { const projectDir = mkTempProject() - try { - const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') - const logPath = join(projectDir, 'native-runtime.log') - const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') - writeLoadableNativeModules(projectDir) - writeNodePtyPatchFile(projectDir) - writePatchedNodePtyBuildArtifacts(projectDir) - writeFakePnpm(binDir) - - const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { - cwd: projectDir, - encoding: 'utf8', - env: envWithPrependedPath(binDir, { - ORCA_NATIVE_TEST_LOG: logPath, - ORCA_NATIVE_TEST_MARKER: markerPath - }) - }) - + const logPath = join(projectDir, 'rebuild.log') + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + rmSync(join(projectDir, 'node_modules/electron/path.txt')) + const result = spawnSync( + process.execPath, + [stageEnsureScript(projectDir), '--runtime=electron'], + { + cwd: projectDir, + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_INSTALL_PLATFORM: 'linux', + ORCA_REBUILD_TEST_LOG: logPath + } + } + ) expect(result.status, result.stderr).toBe(0) - expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active") - expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') + expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe( + 'electron' + ) + expect(existsSync(logPath)).toBe(false) } finally { rmSync(projectDir, { recursive: true, force: true }) } } ) - it.skipIf(process.platform === 'win32')( - 'keeps the fast path when the platform-specific patched artifacts exist', + it.skipIf(process.platform !== 'win32')( + 'rechecks failed Windows addons in fresh Node children', () => { const projectDir = mkTempProject() - try { - const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') - const logPath = join(projectDir, 'native-runtime.log') + const logPath = join(projectDir, 'native.log') const markerPath = join(projectDir, 'rebuilt.marker') + const registryDir = join(projectDir, 'node_modules', '@orca', 'windows-registry') + mkdirSync(registryDir, { recursive: true }) + writeFileSync( + join(registryDir, 'package.json'), + '{"name":"@orca/windows-registry","main":"index.js"}' + ) + writeFileSync( + join(registryDir, 'index.js'), + ` +const { appendFileSync, existsSync } = require('node:fs'); +exports.HK = { CU: 0x80000001 }; +exports.getRegistryKey = () => { + const ready = existsSync(process.env.ORCA_NATIVE_TEST_MARKER); + appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, JSON.stringify({ ready, pid: process.pid }) + '\\n'); + if (!ready) throw new Error('ABI mismatch sentinel'); +};` + ) + writeFakeWindowsProcessTree(projectDir) const binDir = join(projectDir, 'bin') - writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' }) - writeNodePtyPatchFile(projectDir) - writePatchedNodePtyBuildArtifacts(projectDir) - writeFakePnpm(binDir) - - const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { - cwd: projectDir, - encoding: 'utf8', - env: envWithPrependedPath(binDir, { - ORCA_NATIVE_TEST_LOG: logPath, - ORCA_NATIVE_TEST_MARKER: markerPath - }) - }) - + mkdirSync(binDir) + writeFileSync( + join(binDir, 'rebuild.cjs'), + "require('node:fs').writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, process.cwd())" + ) + writeFileSync( + join(binDir, 'pnpm.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0rebuild.cjs"\r\n` + ) + const pathKey = + Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path' + const result = spawnSync( + process.execPath, + [stageEnsureScript(projectDir), '--runtime=node'], + { + cwd: projectDir, + encoding: 'utf8', + env: { + ...process.env, + [pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}`, + ORCA_NATIVE_TEST_LOG: logPath, + ORCA_NATIVE_TEST_MARKER: markerPath + } + } + ) expect(result.status, result.stderr).toBe(0) - expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing') - expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild') + const probes = readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(probes.map((row) => row.ready)).toEqual([false, true]) + expect(probes[0].pid).not.toBe(probes[1].pid) + expect(readFileSync(markerPath, 'utf8')).toBe(registryDir) + expect(result.stderr).not.toContain('Rebuilding @vscode/windows-process-tree') } finally { rmSync(projectDir, { recursive: true, force: true }) } } ) }) - -function mkTempProject() { - const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) - // Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture - // missing it fails every case with a module-resolution error instead of the defect under test. - copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) - for (const name of REQUIRED_CJS_SIBLINGS) { - copyFileSync( - fileURLToPath(new URL(`./${name}`, import.meta.url)), - join(projectDir, 'config', 'scripts', name) - ) - } - return projectDir -} - -function envWithPrependedPath(binDir, extraEnv) { - const pathKey = - process.platform === 'win32' - ? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path') - : 'PATH' - return { - ...process.env, - ...extraEnv, - [pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}` - } -} - -function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) { - const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') - mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) - writeFileSync( - join(nodePtyDir, 'package.json'), - '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n' - ) - mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true }) - writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '') - - writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') - writeFileSync( - join(nodePtyDir, 'lib', 'utils.js'), - ` -const { appendFileSync, existsSync } = require('node:fs') - -exports.loadNativeModule = function loadNativeModule(nativeName) { - const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER) - appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName} marker=\${markerExists}\\n\` - ) - if (!markerExists) { - throw new Error('ABI mismatch sentinel') - } - return { - dir: '../build/Release/', - module: { - listJobProcessIds() {}, - terminateJob() {}, - assignCurrentProcessToJob() {} - } - } -} -` - ) - writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker }) -} - -function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) { - const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') - mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) - writeFileSync( - join(nodePtyDir, 'package.json'), - '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n' - ) - mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true }) - writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '') - - writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') - writeFileSync( - join(nodePtyDir, 'lib', 'utils.js'), - ` -const { appendFileSync, existsSync } = require('node:fs') - -exports.loadNativeModule = function loadNativeModule(nativeName) { - const rebuilt = existsSync(process.env.ORCA_NATIVE_TEST_MARKER) - const dir = ${JSON.stringify(nativeDir)} ?? - (rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/') - appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`) - return { - dir, - module: { - listJobProcessIds: () => [], - terminateJob: () => true, - assignCurrentProcessToJob: () => true - } - } -} -` - ) - writeFakeWindowsRegistry(projectDir) -} - -function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { - if (process.platform !== 'win32') { - return - } - const registryDir = join(projectDir, 'node_modules', '@orca', 'windows-registry') - mkdirSync(registryDir, { recursive: true }) - writeFileSync( - join(registryDir, 'package.json'), - '{"name":"@orca/windows-registry","version":"1.0.0","main":"index.js"}\n' - ) - const markerGate = requiresMarker - ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }` - : '' - writeFileSync( - join(registryDir, 'index.js'), - `exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n` - ) - const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') - mkdirSync(processTreeDir, { recursive: true }) - writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') -} - -function writeNodePtyPatchFile(projectDir) { - mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) - writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') -} - -function writePatchedNodePtyBuildArtifacts(projectDir) { - const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') - mkdirSync(buildDir, { recursive: true }) - if (process.platform === 'win32') { - writeFileSync(join(buildDir, 'conpty.node'), '') - mkdirSync(join(buildDir, 'conpty'), { recursive: true }) - writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '') - writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '') - return - } - writeFileSync(join(buildDir, 'pty.node'), '') - if (process.platform === 'darwin') { - writeFileSync(join(buildDir, 'spawn-helper'), '') - } -} - -function writeFakePnpm(binDir) { - mkdirSync(binDir, { recursive: true }) - const shimPath = join(binDir, 'pnpm-shim.cjs') - writeFileSync( - shimPath, - ` -const { appendFileSync, writeFileSync } = require('node:fs') - -appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`) -appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`) -appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\` -) -appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\` -) -writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt') -` - ) - - const posixPnpmPath = join(binDir, 'pnpm') - writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`) - chmodSync(posixPnpmPath, 0o755) - writeFileSync( - join(binDir, 'pnpm.cmd'), - `@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n` - ) -} diff --git a/config/scripts/finalize-signed-cli-runtime.mjs b/config/scripts/finalize-signed-cli-runtime.mjs new file mode 100644 index 000000000000..a6db409ae42c --- /dev/null +++ b/config/scripts/finalize-signed-cli-runtime.mjs @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto' +import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' +import { verifyCliRuntimeDirectory } from '../bundled-cli-runtime.cjs' + +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex') + +function peSigningFields(bytes) { + if (bytes.length < 64 || bytes.toString('ascii', 0, 2) !== 'MZ') { + throw new Error('Runtime signing requires a complete PE image') + } + const pe = bytes.readUInt32LE(60) + const optional = pe + 24 + if (optional + 2 > bytes.length || bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0') { + throw new Error('Invalid runtime PE header') + } + const magic = bytes.readUInt16LE(optional) + if (magic !== 0x20b && magic !== 0x10b) { + throw new Error('Unsupported runtime PE optional header') + } + const directories = optional + (magic === 0x20b ? 112 : 96) + const security = directories + 4 * 8 + if ( + security + 8 > bytes.length || + bytes.readUInt16LE(pe + 20) < security + 8 - optional || + bytes.readUInt32LE(directories - 4) < 5 + ) { + throw new Error('Missing runtime PE certificate directory') + } + return { + checksum: optional + 64, + security, + offset: bytes.readUInt32LE(security), + size: bytes.readUInt32LE(security + 4) + } +} + +/** Signing may append a certificate and edit its directory/checksum, never executable bytes. */ +export function assertAuthenticodeOnlyChange(original, signed) { + const before = peSigningFields(original) + const after = peSigningFields(signed) + if (before.offset !== 0 || before.size !== 0) { + throw new Error('Refusing to replace an existing vendor runtime signature') + } + const alignedLength = Math.ceil(original.length / 8) * 8 + if ( + after.checksum !== before.checksum || + after.security !== before.security || + after.offset !== alignedLength || + after.size < 8 || + after.offset + after.size !== signed.length + ) { + throw new Error('Signed runtime has unexpected certificate layout') + } + for (let offset = after.offset; offset < signed.length;) { + if (offset + 8 > signed.length) { + throw new Error('Truncated runtime certificate header') + } + const length = signed.readUInt32LE(offset) + const paddedLength = Math.ceil(length / 8) * 8 + if ( + length < 8 || + offset + paddedLength > signed.length || + signed.readUInt16LE(offset + 4) !== 0x200 || + signed.readUInt16LE(offset + 6) !== 2 + ) { + throw new Error('Malformed runtime certificate record') + } + offset += paddedLength + } + if (signed.subarray(original.length, alignedLength).some((byte) => byte !== 0)) { + throw new Error('Signed runtime has nonzero alignment padding') + } + const image = Buffer.from(signed.subarray(0, original.length)) + original.copy(image, before.checksum, before.checksum, before.checksum + 4) + original.copy(image, before.security, before.security, before.security + 8) + if (!image.equals(original)) { + throw new Error('Signing changed runtime executable bytes') + } +} + +export function finalizeSignedCliRuntime(appDirectory, signingStage) { + const relative = join('resources', 'cli-runtime') + const directory = join(appDirectory, relative) + const manifestPath = join(directory, 'runtime.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (!/^win32-(x64|arm64)$/.test(manifest.target) || manifest.version !== ORCAD_BUN_VERSION) { + throw new Error('Invalid Windows runtime signing manifest') + } + const expected = ORCAD_BUN_RELEASE_ASSETS[manifest.target].executableSha256 + const signed = readFileSync(join(directory, 'bun-runtime.exe')) + const signedHash = digest(signed) + if (signedHash !== manifest.sha256) { + const original = readFileSync(join(signingStage, relative, 'bun-runtime.exe')) + if (digest(original) !== expected || manifest.sha256 !== expected) { + throw new Error('Pre-sign runtime does not match the pinned release') + } + assertAuthenticodeOnlyChange(original, signed) + const temporary = `${manifestPath}.signed-${process.pid}` + try { + writeFileSync( + temporary, + `${JSON.stringify({ ...manifest, sha256: signedHash, unsignedSha256: expected })}\n`, + { flag: 'wx' } + ) + renameSync(temporary, manifestPath) + } finally { + rmSync(temporary, { force: true }) + } + } else if (signedHash !== expected) { + // Re-running is safe only while the retained original still proves the signed payload. + const original = readFileSync(join(signingStage, relative, 'bun-runtime.exe')) + if (digest(original) !== expected || manifest.unsignedSha256 !== expected) { + throw new Error('Signed runtime provenance is missing') + } + assertAuthenticodeOnlyChange(original, signed) + } + verifyCliRuntimeDirectory(directory, 'win32', manifest.target.slice('win32-'.length)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + if (!process.argv[2] || !process.argv[3]) { + throw new Error('Usage: finalize-signed-cli-runtime.mjs ') + } + finalizeSignedCliRuntime(resolve(process.argv[2]), resolve(process.argv[3])) +} diff --git a/config/scripts/finalize-signed-cli-runtime.test.mjs b/config/scripts/finalize-signed-cli-runtime.test.mjs new file mode 100644 index 000000000000..ad2fa71289a2 --- /dev/null +++ b/config/scripts/finalize-signed-cli-runtime.test.mjs @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { writeBundledCliRuntimeFixture } from './bundled-cli-runtime-fixture.mjs' +const pins = vi.hoisted(() => ({ hash: '' })) +vi.mock('../../src/shared/orcad-bun-runtime.ts', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + ORCAD_BUN_RELEASE_ASSETS: { + 'win32-x64': { + get executableSha256() { + return pins.hash + } + } + } + } +}) +import { + assertAuthenticodeOnlyChange, + finalizeSignedCliRuntime +} from './finalize-signed-cli-runtime.mjs' +import { verifyCliRuntimeDirectory } from '../bundled-cli-runtime.cjs' +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') +function images(magic = 0x20b) { + const original = Buffer.alloc(515) + original.write('MZ') + original.writeUInt32LE(64, 60) + original.write('PE\0\0', 64) + original.writeUInt16LE(240, 84) + original.writeUInt16LE(magic, 88) + const directory = 88 + (magic === 0x20b ? 112 : 96) + original.writeUInt32LE(16, directory - 4) + original[510] = 42 + const signed = Buffer.concat([original, Buffer.alloc(5), Buffer.alloc(16, 7)]) + signed.writeUInt32LE(16, 520) + signed.writeUInt16LE(0x200, 524) + signed.writeUInt16LE(2, 526) + signed.writeUInt32LE(123, 88 + 64) + signed.writeUInt32LE(520, directory + 32) + signed.writeUInt32LE(16, directory + 36) + return { original, signed, security: directory + 32 } +} + +it.each([0x20b, 0x10b])( + 'allows only certificate append/checksum edits for PE magic %s', + (magic) => { + const { original, signed } = images(magic) + expect(() => assertAuthenticodeOnlyChange(original, signed)).not.toThrow() + } +) +it.each([ + 'code', + 'padding', + 'tail', + 'directory', + 'vendor-signature', + 'certificate-length', + 'certificate-type' +])('rejects unexpected signing change: %s', (kind) => { + let { original, signed, security } = images() + if (kind === 'certificate-length') { + signed.writeUInt32LE(1000, 520) + } + if (kind === 'certificate-type') { + signed.writeUInt16LE(1, 526) + } + if (kind === 'code') { + signed[510]++ + } + if (kind === 'padding') { + signed[517] = 1 + } + if (kind === 'tail') { + signed = Buffer.concat([signed, Buffer.from('extra')]) + } + if (kind === 'directory') { + signed.writeUInt32LE(512, security) + } + if (kind === 'vendor-signature') { + original.writeUInt32LE(512, security) + } + expect(() => assertAuthenticodeOnlyChange(original, signed)).toThrow() +}) +it('rejects truncated or non-PE images', () => { + expect(() => assertAuthenticodeOnlyChange(Buffer.from('bad'), Buffer.from('bad'))).toThrow() +}) + +async function stagedFixture() { + const root = mkdtempSync(join(tmpdir(), 'signed-runtime-')) + roots.push(root) + const app = join(root, 'app') + const stage = join(root, 'signing-stage') + const relative = join('resources', 'cli-runtime') + const directory = join(app, relative) + await writeBundledCliRuntimeFixture(directory, 'win32', 'x64') + const { original, signed } = images() + pins.hash = hash(original) + mkdirSync(join(stage, relative), { recursive: true }) + writeFileSync(join(stage, relative, 'bun-runtime.exe'), original) + writeFileSync(join(directory, 'bun-runtime.exe'), signed) + const manifestPath = join(directory, 'runtime.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + writeFileSync(manifestPath, JSON.stringify({ ...manifest, sha256: pins.hash })) + return { app, stage, directory, manifestPath, signed } +} +it('finalizes the signed full-file identity, preserving runtime verification and repeatability', async () => { + const f = await stagedFixture() + expect(() => verifyCliRuntimeDirectory(f.directory, 'win32', 'x64')).toThrow('checksum') + finalizeSignedCliRuntime(f.app, f.stage) + expect(JSON.parse(readFileSync(f.manifestPath, 'utf8'))).toMatchObject({ + sha256: hash(f.signed), + unsignedSha256: pins.hash + }) + expect(() => verifyCliRuntimeDirectory(f.directory, 'win32', 'x64')).not.toThrow() + expect(() => finalizeSignedCliRuntime(f.app, f.stage)).not.toThrow() + f.signed[510]++ + writeFileSync(join(f.directory, 'bun-runtime.exe'), f.signed) + expect(() => verifyCliRuntimeDirectory(f.directory, 'win32', 'x64')).toThrow('checksum') +}) +it('refuses to bless payload changes or unpinned original bytes', async () => { + const f = await stagedFixture() + f.signed[510]++ + writeFileSync(join(f.directory, 'bun-runtime.exe'), f.signed) + expect(() => finalizeSignedCliRuntime(f.app, f.stage)).toThrow('executable bytes') + pins.hash = '0'.repeat(64) + expect(() => finalizeSignedCliRuntime(f.app, f.stage)).toThrow('pinned release') +}) + +it('refuses a changed original manifest without rewriting it', async () => { + const f = await stagedFixture() + const manifest = JSON.parse(readFileSync(f.manifestPath, 'utf8')) + const changed = JSON.stringify({ ...manifest, sha256: 'f'.repeat(64) }) + writeFileSync(f.manifestPath, changed) + expect(() => finalizeSignedCliRuntime(f.app, f.stage)).toThrow('pinned release') + expect(readFileSync(f.manifestPath, 'utf8')).toBe(changed) +}) diff --git a/config/scripts/fixtures/node-pty-1.1.0/LICENSE b/config/scripts/fixtures/node-pty-1.1.0/LICENSE new file mode 100644 index 000000000000..22f780daf969 --- /dev/null +++ b/config/scripts/fixtures/node-pty-1.1.0/LICENSE @@ -0,0 +1,69 @@ +Copyright (c) 2012-2015, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + + +The MIT License (MIT) + +Copyright (c) 2016, Daniel Imms (http://www.growingwiththeweb.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +MIT License + +Copyright (c) 2018 - present Microsoft Corporation + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/config/scripts/fixtures/node-pty-1.1.0/README.md b/config/scripts/fixtures/node-pty-1.1.0/README.md new file mode 100644 index 000000000000..8d6428b9f814 --- /dev/null +++ b/config/scripts/fixtures/node-pty-1.1.0/README.md @@ -0,0 +1,9 @@ +# Legacy SSH teardown patch fixtures + +`windowsPtyAgent.js.txt` and `windowsTerminal.js.txt` are unmodified `lib/` files +from the published npm `node-pty@1.1.0` tarball. The upstream MIT license is in +`LICENSE`. These are test inputs only, not a shipped runtime dependency. + +The legacy SSH repair asset verifies both original and patched SHA-256 hashes. +Keeping its published input here lets clean installs test that compatibility +path after the desktop dependency is retired. diff --git a/config/scripts/fixtures/node-pty-1.1.0/windowsPtyAgent.js.txt b/config/scripts/fixtures/node-pty-1.1.0/windowsPtyAgent.js.txt new file mode 100644 index 000000000000..a358ffb17735 --- /dev/null +++ b/config/scripts/fixtures/node-pty-1.1.0/windowsPtyAgent.js.txt @@ -0,0 +1,320 @@ +"use strict"; +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.argsToCommandLine = exports.WindowsPtyAgent = void 0; +var fs = require("fs"); +var os = require("os"); +var path = require("path"); +var child_process_1 = require("child_process"); +var net_1 = require("net"); +var windowsConoutConnection_1 = require("./windowsConoutConnection"); +var utils_1 = require("./utils"); +var conptyNative; +var winptyNative; +/** + * The amount of time to wait for additional data after the conpty shell process has exited before + * shutting down the socket. The timer will be reset if a new data event comes in after the timer + * has started. + */ +var FLUSH_DATA_INTERVAL = 1000; +/** + * This agent sits between the WindowsTerminal class and provides a common interface for both conpty + * and winpty. + */ +var WindowsPtyAgent = /** @class */ (function () { + function WindowsPtyAgent(file, args, env, cwd, cols, rows, debug, _useConpty, _useConptyDll, conptyInheritCursor) { + var _this = this; + if (_useConptyDll === void 0) { _useConptyDll = false; } + if (conptyInheritCursor === void 0) { conptyInheritCursor = false; } + this._useConpty = _useConpty; + this._useConptyDll = _useConptyDll; + this._pid = 0; + this._innerPid = 0; + if (this._useConpty === undefined || this._useConpty === true) { + this._useConpty = this._getWindowsBuildNumber() >= 18309; + } + if (this._useConpty) { + if (!conptyNative) { + conptyNative = utils_1.loadNativeModule('conpty').module; + } + } + else { + if (!winptyNative) { + winptyNative = utils_1.loadNativeModule('pty').module; + } + } + this._ptyNative = this._useConpty ? conptyNative : winptyNative; + // Sanitize input variable. + cwd = path.resolve(cwd); + // Compose command line + var commandLine = argsToCommandLine(file, args); + // Open pty session. + var term; + if (this._useConpty) { + term = this._ptyNative.startProcess(file, cols, rows, debug, this._generatePipeName(), conptyInheritCursor, this._useConptyDll); + } + else { + term = this._ptyNative.startProcess(file, commandLine, env, cwd, cols, rows, debug); + this._pid = term.pid; + this._innerPid = term.innerPid; + } + // Not available on windows. + this._fd = term.fd; + // Generated incremental number that has no real purpose besides using it + // as a terminal id. + this._pty = term.pty; + // Create terminal pipe IPC channel and forward to a local unix socket. + this._outSocket = new net_1.Socket(); + this._outSocket.setEncoding('utf8'); + // The conout socket must be ready out on another thread to avoid deadlocks + this._conoutSocketWorker = new windowsConoutConnection_1.ConoutConnection(term.conout, this._useConptyDll); + this._conoutSocketWorker.onReady(function () { + _this._conoutSocketWorker.connectSocket(_this._outSocket); + }); + this._outSocket.on('connect', function () { + _this._outSocket.emit('ready_datapipe'); + }); + var inSocketFD = fs.openSync(term.conin, 'w'); + this._inSocket = new net_1.Socket({ + fd: inSocketFD, + readable: false, + writable: true + }); + this._inSocket.setEncoding('utf8'); + if (this._useConpty) { + var connect = this._ptyNative.connect(this._pty, commandLine, cwd, env, this._useConptyDll, function (c) { return _this._$onProcessExit(c); }); + this._innerPid = connect.pid; + } + } + Object.defineProperty(WindowsPtyAgent.prototype, "inSocket", { + get: function () { return this._inSocket; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "outSocket", { + get: function () { return this._outSocket; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "fd", { + get: function () { return this._fd; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "innerPid", { + get: function () { return this._innerPid; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsPtyAgent.prototype, "pty", { + get: function () { return this._pty; }, + enumerable: false, + configurable: true + }); + WindowsPtyAgent.prototype.resize = function (cols, rows) { + if (this._useConpty) { + if (this._exitCode !== undefined) { + throw new Error('Cannot resize a pty that has already exited'); + } + this._ptyNative.resize(this._pty, cols, rows, this._useConptyDll); + return; + } + this._ptyNative.resize(this._pid, cols, rows); + }; + WindowsPtyAgent.prototype.clear = function () { + if (this._useConpty) { + this._ptyNative.clear(this._pty, this._useConptyDll); + } + }; + WindowsPtyAgent.prototype.kill = function () { + var _this = this; + // Tell the agent to kill the pty, this releases handles to the process + if (this._useConpty) { + if (!this._useConptyDll) { + this._inSocket.readable = false; + this._outSocket.readable = false; + this._getConsoleProcessList().then(function (consoleProcessList) { + consoleProcessList.forEach(function (pid) { + try { + process.kill(pid); + } + catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + }); + this._ptyNative.kill(this._pty, this._useConptyDll); + this._conoutSocketWorker.dispose(); + } + else { + // Close the input write handle to signal the end of session. + this._inSocket.destroy(); + this._ptyNative.kill(this._pty, this._useConptyDll); + this._outSocket.on('data', function () { + _this._conoutSocketWorker.dispose(); + }); + } + } + else { + // Because pty.kill closes the handle, it will kill most processes by itself. + // Process IDs can be reused as soon as all handles to them are + // dropped, so we want to immediately kill the entire console process list. + // If we do not force kill all processes here, node servers in particular + // seem to become detached and remain running (see + // Microsoft/vscode#26807). + var processList = this._ptyNative.getProcessList(this._pid); + this._ptyNative.kill(this._pid, this._innerPid); + processList.forEach(function (pid) { + try { + process.kill(pid); + } + catch (e) { + // Ignore if process cannot be found (kill ESRCH error) + } + }); + } + }; + WindowsPtyAgent.prototype._getConsoleProcessList = function () { + var _this = this; + return new Promise(function (resolve) { + var agent = child_process_1.fork(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]); + agent.on('message', function (message) { + clearTimeout(timeout); + resolve(message.consoleProcessList); + }); + var timeout = setTimeout(function () { + // Something went wrong, just send back the shell PID + agent.kill(); + resolve([_this._innerPid]); + }, 5000); + }); + }; + Object.defineProperty(WindowsPtyAgent.prototype, "exitCode", { + get: function () { + if (this._useConpty) { + return this._exitCode; + } + var winptyExitCode = this._ptyNative.getExitCode(this._innerPid); + return winptyExitCode === -1 ? undefined : winptyExitCode; + }, + enumerable: false, + configurable: true + }); + WindowsPtyAgent.prototype._getWindowsBuildNumber = function () { + var osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); + var buildNumber = 0; + if (osVersion && osVersion.length === 4) { + buildNumber = parseInt(osVersion[3]); + } + return buildNumber; + }; + WindowsPtyAgent.prototype._generatePipeName = function () { + return "conpty-" + Math.random() * 10000000; + }; + /** + * Triggered from the native side when a contpy process exits. + */ + WindowsPtyAgent.prototype._$onProcessExit = function (exitCode) { + var _this = this; + this._exitCode = exitCode; + if (!this._useConptyDll) { + this._flushDataAndCleanUp(); + this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); }); + } + }; + WindowsPtyAgent.prototype._flushDataAndCleanUp = function () { + var _this = this; + if (this._useConptyDll) { + return; + } + if (this._closeTimeout) { + clearTimeout(this._closeTimeout); + } + this._closeTimeout = setTimeout(function () { return _this._cleanUpProcess(); }, FLUSH_DATA_INTERVAL); + }; + WindowsPtyAgent.prototype._cleanUpProcess = function () { + if (this._useConptyDll) { + return; + } + this._inSocket.readable = false; + this._outSocket.readable = false; + this._outSocket.destroy(); + }; + return WindowsPtyAgent; +}()); +exports.WindowsPtyAgent = WindowsPtyAgent; +// Convert argc/argv into a Win32 command-line following the escaping convention +// documented on MSDN (e.g. see CommandLineToArgvW documentation). Copied from +// winpty project. +function argsToCommandLine(file, args) { + if (isCommandLine(args)) { + if (args.length === 0) { + return file; + } + return argsToCommandLine(file, []) + " " + args; + } + var argv = [file]; + Array.prototype.push.apply(argv, args); + var result = ''; + for (var argIndex = 0; argIndex < argv.length; argIndex++) { + if (argIndex > 0) { + result += ' '; + } + var arg = argv[argIndex]; + // if it is empty or it contains whitespace and is not already quoted + var hasLopsidedEnclosingQuote = xOr((arg[0] !== '"'), (arg[arg.length - 1] !== '"')); + var hasNoEnclosingQuotes = ((arg[0] !== '"') && (arg[arg.length - 1] !== '"')); + var quote = arg === '' || + (arg.indexOf(' ') !== -1 || + arg.indexOf('\t') !== -1) && + ((arg.length > 1) && + (hasLopsidedEnclosingQuote || hasNoEnclosingQuotes)); + if (quote) { + result += '\"'; + } + var bsCount = 0; + for (var i = 0; i < arg.length; i++) { + var p = arg[i]; + if (p === '\\') { + bsCount++; + } + else if (p === '"') { + result += repeatText('\\', bsCount * 2 + 1); + result += '"'; + bsCount = 0; + } + else { + result += repeatText('\\', bsCount); + bsCount = 0; + result += p; + } + } + if (quote) { + result += repeatText('\\', bsCount * 2); + result += '\"'; + } + else { + result += repeatText('\\', bsCount); + } + } + return result; +} +exports.argsToCommandLine = argsToCommandLine; +function isCommandLine(args) { + return typeof args === 'string'; +} +function repeatText(text, count) { + var result = ''; + for (var i = 0; i < count; i++) { + result += text; + } + return result; +} +function xOr(arg1, arg2) { + return ((arg1 && !arg2) || (!arg1 && arg2)); +} +//# sourceMappingURL=windowsPtyAgent.js.map \ No newline at end of file diff --git a/config/scripts/fixtures/node-pty-1.1.0/windowsTerminal.js.txt b/config/scripts/fixtures/node-pty-1.1.0/windowsTerminal.js.txt new file mode 100644 index 000000000000..3c38f89d14ce --- /dev/null +++ b/config/scripts/fixtures/node-pty-1.1.0/windowsTerminal.js.txt @@ -0,0 +1,199 @@ +"use strict"; +/** + * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) + * Copyright (c) 2016, Daniel Imms (MIT License). + * Copyright (c) 2018, Microsoft Corporation (MIT License). + */ +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WindowsTerminal = void 0; +var terminal_1 = require("./terminal"); +var windowsPtyAgent_1 = require("./windowsPtyAgent"); +var utils_1 = require("./utils"); +var DEFAULT_FILE = 'cmd.exe'; +var DEFAULT_NAME = 'Windows Shell'; +var WindowsTerminal = /** @class */ (function (_super) { + __extends(WindowsTerminal, _super); + function WindowsTerminal(file, args, opt) { + var _this = _super.call(this, opt) || this; + _this._checkType('args', args, 'string', true); + // Initialize arguments + args = args || []; + file = file || DEFAULT_FILE; + opt = opt || {}; + opt.env = opt.env || process.env; + if (opt.encoding) { + console.warn('Setting encoding on Windows is not supported'); + } + var env = utils_1.assign({}, opt.env); + _this._cols = opt.cols || terminal_1.DEFAULT_COLS; + _this._rows = opt.rows || terminal_1.DEFAULT_ROWS; + var cwd = opt.cwd || process.cwd(); + var name = opt.name || env.TERM || DEFAULT_NAME; + var parsedEnv = _this._parseEnv(env); + // If the terminal is ready + _this._isReady = false; + // Functions that need to run after `ready` event is emitted. + _this._deferreds = []; + // Create new termal. + _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); + _this._socket = _this._agent.outSocket; + // Not available until `ready` event emitted. + _this._pid = _this._agent.innerPid; + _this._fd = _this._agent.fd; + _this._pty = _this._agent.pty; + // The forked windows terminal is not available until `ready` event is + // emitted. + _this._socket.on('ready_datapipe', function () { + // Run deferreds and set ready state once the first data event is received. + _this._socket.once('data', function () { + // Wait until the first data event is fired then we can run deferreds. + if (!_this._isReady) { + // Terminal is now ready and we can avoid having to defer method + // calls. + _this._isReady = true; + // Execute all deferred methods + _this._deferreds.forEach(function (fn) { + // NB! In order to ensure that `this` has all its references + // updated any variable that need to be available in `this` before + // the deferred is run has to be declared above this forEach + // statement. + fn.run(); + }); + // Reset + _this._deferreds = []; + } + }); + // Shutdown if `error` event is emitted. + _this._socket.on('error', function (err) { + // Close terminal session. + _this._close(); + // EIO, happens when someone closes our child process: the only process + // in the terminal. + // node < 0.6.14: errno 5 + // node >= 0.6.14: read EIO + if (err.code) { + if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) + return; + } + // Throw anything else. + if (_this.listeners('error').length < 2) { + throw err; + } + }); + // Cleanup after the socket is closed. + _this._socket.on('close', function () { + _this.emit('exit', _this._agent.exitCode); + _this._close(); + }); + }); + _this._file = file; + _this._name = name; + _this._readable = true; + _this._writable = true; + _this._forwardEvents(); + return _this; + } + WindowsTerminal.prototype._write = function (data) { + this._defer(this._doWrite, data); + }; + WindowsTerminal.prototype._doWrite = function (data) { + this._agent.inSocket.write(data); + }; + /** + * openpty + */ + WindowsTerminal.open = function (options) { + throw new Error('open() not supported on windows, use Fork() instead.'); + }; + /** + * TTY + */ + WindowsTerminal.prototype.resize = function (cols, rows) { + var _this = this; + if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { + throw new Error('resizing must be done using positive cols and rows'); + } + this._deferNoArgs(function () { + _this._agent.resize(cols, rows); + _this._cols = cols; + _this._rows = rows; + }); + }; + WindowsTerminal.prototype.clear = function () { + var _this = this; + this._deferNoArgs(function () { + _this._agent.clear(); + }); + }; + WindowsTerminal.prototype.destroy = function () { + var _this = this; + this._deferNoArgs(function () { + _this.kill(); + }); + }; + WindowsTerminal.prototype.kill = function (signal) { + var _this = this; + this._deferNoArgs(function () { + if (signal) { + throw new Error('Signals not supported on windows.'); + } + _this._close(); + _this._agent.kill(); + }); + }; + WindowsTerminal.prototype._deferNoArgs = function (deferredFn) { + var _this = this; + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this); + return; + } + // Queue until terminal is ready. + this._deferreds.push({ + run: function () { return deferredFn.call(_this); } + }); + }; + WindowsTerminal.prototype._defer = function (deferredFn, arg) { + var _this = this; + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this, arg); + return; + } + // Queue until terminal is ready. + this._deferreds.push({ + run: function () { return deferredFn.call(_this, arg); } + }); + }; + Object.defineProperty(WindowsTerminal.prototype, "process", { + get: function () { return this._name; }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsTerminal.prototype, "master", { + get: function () { throw new Error('master is not supported on Windows'); }, + enumerable: false, + configurable: true + }); + Object.defineProperty(WindowsTerminal.prototype, "slave", { + get: function () { throw new Error('slave is not supported on Windows'); }, + enumerable: false, + configurable: true + }); + return WindowsTerminal; +}(terminal_1.Terminal)); +exports.WindowsTerminal = WindowsTerminal; +//# sourceMappingURL=windowsTerminal.js.map \ No newline at end of file diff --git a/config/scripts/linux-packaged-node-pty-floor-child.cjs b/config/scripts/linux-packaged-node-pty-floor-child.cjs deleted file mode 100644 index a31efe35ba51..000000000000 --- a/config/scripts/linux-packaged-node-pty-floor-child.cjs +++ /dev/null @@ -1,47 +0,0 @@ -const { join } = require('node:path') - -const resourcesDirectory = process.argv[2] -if (!resourcesDirectory) { - throw new Error('linux-packaged-node-pty-floor-resources-required') -} - -const pty = require(join(resourcesDirectory, 'node_modules', 'node-pty')) -const expected = 'orca-node-pty-floor-ok' -let output = '' -let settled = false -const child = pty.spawn('/bin/sh', ['-c', `printf ${expected}; sleep 0.1`], { - name: 'xterm-color', - cols: 80, - rows: 24, - cwd: '/tmp', - env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } -}) - -const timeout = setTimeout(() => { - if (settled) { - return - } - settled = true - child.kill() - process.stderr.write('packaged node-pty floor smoke timed out\n') - process.exitCode = 1 -}, 5_000) - -child.onData((data) => { - output = `${output}${data}`.slice(-256) -}) -child.onExit(({ exitCode }) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (exitCode !== 0 || output !== expected) { - process.stderr.write( - `packaged node-pty floor smoke failed: exit=${exitCode} bytes=${Buffer.byteLength(output)}\n` - ) - process.exitCode = 1 - return - } - process.stdout.write(`${expected}\n`) -}) diff --git a/config/scripts/node-pty-job-ownership.cjs b/config/scripts/node-pty-job-ownership.cjs deleted file mode 100644 index c639e94aa6a0..000000000000 --- a/config/scripts/node-pty-job-ownership.cjs +++ /dev/null @@ -1,227 +0,0 @@ -'use strict' - -const { existsSync, readFileSync } = require('node:fs') -const { dirname, join, resolve } = require('node:path') -const { PE_MACHINE, describePeMachine, readPeMachine } = require('./windows-pe-machine.cjs') - -const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob'] - -/** - * The wide literal `usesCygwinRuntime` probes for in conpty.cc, as it sits in - * the compiled addon. - * - * Why sniff the binary rather than trust the exports: all three job exports - * predate the Cygwin/MSYS breakaway denial, so symbol presence cannot tell a - * current build from one whose per-PTY job still carries - * JOB_OBJECT_LIMIT_BREAKAWAY_OK. Measured on Windows 11: such a build passes - * every export check, reports isPtyJobOwnershipAvailable() true, and passes - * windows-pty-job.win32.test.ts 6/6, while every child of a Git Bash pane is - * created outside the pane's job and survives terminatePtyJob. See - * docs/reference/windows-msys-job-breakaway.md. - * - * Same shape as stagedRelayAddonIsUnpatched() in - * src/main/windows/windows-process-table.ts, which already tells a patched - * addon from a published one by a binary import name. - */ -const CYGWIN_BREAKAWAY_MARKER_TEXT = 'msys-2.0.dll' -const CYGWIN_BREAKAWAY_MARKER = Buffer.from(CYGWIN_BREAKAWAY_MARKER_TEXT, 'utf16le') - -/** True when the addon carries the denial. Read errors propagate: callers that cannot read it must not pass. */ -function conptyDeniesCygwinBreakaway(addonPath) { - return readFileSync(addonPath).includes(CYGWIN_BREAKAWAY_MARKER) -} - -/** Where the patch adds the literal above, relative to node-pty's root. */ -const NODE_PTY_CONPTY_SOURCE = join('src', 'win', 'conpty.cc') - -/** - * The verdict on the source a Windows rebuild is about to compile. - * - * Why before compiling and not only after: pnpm materializes node_modules/node-pty - * from config/patches/node-pty@1.1.0.patch at install time, so an install that - * predates the denial holds source without it, and no rebuild of that source can - * yield an addon the post-rebuild gate accepts. Measured on a Windows dev - * checkout: `--force` compiled for minutes, rewrote conpty.node byte-identical - * and unpatched, and the gate then advised "rebuild from source" -- the step - * that had just run. The remedy is a reinstall, and that is what this says. - * - * An absent source file is not judged here: node-pty ships it, and the addon - * gate that follows the rebuild still reads the binary either way. - */ -function assertNodePtySourceDeniesMsysBreakaway({ nodePtyDir }) { - const sourcePath = join(nodePtyDir, NODE_PTY_CONPTY_SOURCE) - if (!existsSync(sourcePath)) { - return - } - if (readFileSync(sourcePath, 'utf8').includes(`L"${CYGWIN_BREAKAWAY_MARKER_TEXT}"`)) { - return - } - throw new Error( - [ - `node-pty's source at ${sourcePath} does not carry the Cygwin/MSYS job-breakaway denial,`, - 'so no rebuild of it can produce an addon that does; the compile was not started.', - 'pnpm applies config/patches/node-pty@1.1.0.patch when it installs node-pty, so run', - '`pnpm install` to re-materialize it with the current patch, then rerun this command.', - `If that patch does not itself add L"${CYGWIN_BREAKAWAY_MARKER_TEXT}" to ${NODE_PTY_CONPTY_SOURCE},`, - 'this checkout predates the denial and no reinstall can supply it.', - 'See docs/reference/windows-msys-job-breakaway.md.' - ].join(' ') - ) -} - -/** - * Why here and not only at packaging: a rebuild that did not honour `--arch` - * leaves a binary the target cannot load, the app falls back to the published - * prebuild, and the packaged gate then reports it two steps from the command - * that could fix it. `PE_MACHINE` covers the Windows arches Orca ships; anything - * else this cannot judge, so it does not pretend to. - */ -function assertRebuiltConptyMatchesArch(addonPath, rebuildArch) { - const expected = PE_MACHINE[rebuildArch] - if (expected === undefined) { - return - } - const machine = readPeMachine(addonPath) - if (machine === expected) { - return - } - const consequence = [ - ', so node-pty would fall back to the published prebuild, which predates the', - 'Cygwin/MSYS job-breakaway denial and leaks every MSYS pane child out of its job.' - ].join(' ') - throw new Error( - machine === null - ? `${addonPath} is not a PE image${consequence} Check the ` + - `node-pty build output above; a truncated or quarantined artifact looks like this.` - : `${addonPath} is ${describePeMachine(machine)}, but this rebuild targets ` + - `win32-${rebuildArch} (0x${expected.toString(16)}): node-gyp did not honour ` + - `--arch${consequence}` - ) -} - -/** - * The verdict on the addon a Windows rebuild just claimed to produce. - * - * Takes the host as arguments rather than reading `process`, because the branch - * that matters -- a rebuild for the very host running it -- is otherwise - * reachable only from Windows, and a gate nobody can run is a gate nobody - * checks. - * - * Absent is fatal on that host: `loadNativeModule` falls through to - * prebuilds/win32-, and the published prebuild predates the denial, so - * the app would load it with nothing said. A cross-host rebuild need not leave - * a win32 addon on this disk, and node-pty may not be installed at all -- - * neither is evidence of a bad build. - */ -function assertRebuiltConptyDeniesMsysBreakaway({ - nodePtyDir, - rebuildArch, - crossHost, - warn = console.warn -}) { - const addonPath = join(nodePtyDir, 'build', 'Release', 'conpty.node') - if (existsSync(addonPath)) { - assertRebuiltConptyMatchesArch(addonPath, rebuildArch) - assertCygwinBreakawayDenied(addonPath, { dir: addonPath }) - return - } - if (crossHost || !existsSync(nodePtyDir)) { - warn(`[rebuild] no addon at ${addonPath}; could not check the MSYS job-breakaway denial.`) - return - } - const prebuildPath = join(nodePtyDir, 'prebuilds', `win32-${rebuildArch}`, 'conpty.node') - throw new Error( - `the rebuild reported success but ${addonPath} is not there, so node-pty would fall through ` + - `to ${prebuildPath}. That published prebuild predates the Cygwin/MSYS ` + - 'job-breakaway denial: every Git Bash pane child would be created outside its job and ' + - 'survive terminatePtyJob. Check the node-pty build output above; a same-host source ' + - 'build must leave conpty.node in build/Release.' - ) -} - -/** - * Absolute path of the addon `loadNativeModule` just resolved. - * - * `native.dir` is relative to node-pty's own `lib/`, which is the only base - * every caller shares -- the project install, a staged rebuild and the packaged - * resources tree all reach the addon through a different root. - */ -function nodePtyAddonPath(nodePtyUtilsPath, native, nativeName) { - return resolve(dirname(nodePtyUtilsPath), native.dir, `${nativeName}.node`) -} - -function assertNodePtyJobOwnership({ nativeName, native, addonPath, platform = process.platform }) { - if (platform !== 'win32' || nativeName !== 'conpty') { - return - } - const exported = native?.module ?? native - const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function') - if (missing.length > 0) { - throw new Error( - [ - `node-pty's conpty native is missing ${missing.join(', ')}.`, - `Resolved from: ${native?.dir ?? 'unknown'}`, - 'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"', - 'and pane teardown falls back to guessing by PID ancestry.', - 'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.' - ].join(' ') - ) - } - assertCygwinBreakawayDenied(addonPath, native) -} - -/** - * Why this refuses instead of skipping when the addon cannot be read: an - * unreadable binary is exactly the state that used to pass. `loadNativeModule` - * has already required this file, so "cannot read it" means the caller did not - * say which file it loaded, and a gate that cannot see its subject is not a - * gate. - */ -function assertCygwinBreakawayDenied(addonPath, native) { - let binary - try { - binary = readFileSync(addonPath) - } catch (error) { - throw new Error( - [ - `Cannot read node-pty's conpty native at ${addonPath ?? ''}`, - `(resolved from ${native?.dir ?? 'unknown'}): ${error.message}.`, - 'Without the binary this cannot tell a current build from one that leaks', - 'every MSYS pane child out of its job, so it refuses rather than assume.' - ].join(' ') - ) - } - if (binary.includes(CYGWIN_BREAKAWAY_MARKER)) { - return - } - throw staleConptySourceBuildError(addonPath) -} - -/** The verdict on a source build that is simply out of date: rebuild it here. */ -function staleConptySourceBuildError(addonPath) { - return new Error( - [ - `node-pty's conpty native at ${addonPath} predates the Cygwin/MSYS job-breakaway denial.`, - 'It exports the job functions, so it looks patched, but its per-PTY job still carries', - 'JOB_OBJECT_LIMIT_BREAKAWAY_OK and every Git Bash child is created outside the job:', - 'terminatePtyJob reports "terminated" and leaves the tree running.', - 'Rebuild node-pty from source so the current config/patches/node-pty@1.1.0.patch applies', - '(a worktree sharing node_modules with its main checkout shares that stale addon).', - `If that patch no longer adds L"${CYGWIN_BREAKAWAY_MARKER_TEXT}" to conpty.cc then this marker is`, - 'stale, not the addon, and no rebuild can satisfy it.', - 'See docs/reference/windows-msys-job-breakaway.md.' - ].join(' ') - ) -} - -module.exports = { - CYGWIN_BREAKAWAY_MARKER, - CYGWIN_BREAKAWAY_MARKER_TEXT, - assertNodePtyJobOwnership, - assertCygwinBreakawayDenied, - assertNodePtySourceDeniesMsysBreakaway, - assertRebuiltConptyDeniesMsysBreakaway, - conptyDeniesCygwinBreakaway, - nodePtyAddonPath, - staleConptySourceBuildError -} diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index ba3e64bfa2f8..c2c76cf663db 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -1,18 +1,3 @@ -// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with -// `config/patches/node-pty@1.1.0.patch`. pnpm patches do not cross the SSH boundary, so a relay runs -// the tree `npm install` put there, and every terminal on a Windows SSH host leaked one File handle -// for the life of the relay process. -// -// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- the placement -// the desktop patch uses -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a -// new Process +1/terminal); releasing it after the console-list fork and the native kill is flat. -// -// Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop -// site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where -// upstream already destroys the input socket. Two hidden rate-limit probes -// (`src/main/rate-limits/claude-pty.ts`, `codex-pty-rate-limit-probe.ts`) do omit the option and so -// do run this hunk, but no user-visible pane does. The divergence pinned below is about which -// branch each host runs for terminals -- not about a regression in the panes users open. import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' @@ -28,56 +13,6 @@ const cleanupDirs = [] const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js'] -/** The hunks config/patches/node-pty@1.1.0.patch adds to the installed desktop tree. */ -const DESKTOP_HUNKS = { - 'windowsPtyAgent.js': [ - [ - [ - ' this._inSocket.readable = false;', - ' // The non-DLL path previously only flipped `readable`, leaving the', - ' // conin PipeWrap alive until the host exited (#947).', - ' this._inSocket.destroy();', - ' this._outSocket.readable = false;', - '' - ].join('\n'), - [ - ' this._inSocket.readable = false;', - ' this._outSocket.readable = false;', - '' - ].join('\n') - ], - // The useConptyDll branch, which only the DESKTOP runs -- the relay takes the - // non-DLL branch above, where the dispose is already unconditional. Listed here - // so un-applying still yields published; the relay asset needs no counterpart. - [ - [ - ' // Orca: dispose unconditionally, as the non-DLL branch above does.', - " // Waiting for another 'data' event leaks the conout worker on every", - ' // self-exiting shell, because no more data ever arrives (F24).', - ' this._conoutSocketWorker.dispose();', - '' - ].join('\n'), - [ - " this._outSocket.on('data', function () {", - ' _this._conoutSocketWorker.dispose();', - ' });', - '' - ].join('\n') - ] - ], - 'windowsTerminal.js': [ - [ - ' // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.', - null - ], - [' // A ConPTY input-pipe error must retire only this terminal.', null] - ] -} - -function desktopPath(file) { - return join(projectDir, 'node_modules', 'node-pty', 'lib', file) -} - afterEach(() => { for (const dir of cleanupDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) @@ -85,23 +20,7 @@ afterEach(() => { }) describe('Windows SSH relay node-pty ConPTY teardown patch', () => { - // Why reconstruct rather than vendor upstream: the installed tree IS the published file plus the - // desktop's hunks, so un-applying them yields upstream exactly -- and pinning that against this - // asset's own hashes is what fails loudly if either side of the pair moves. - it('takes the desktop error listeners verbatim', () => { - const fixture = writeNodePtyFixture('1.1.0') - patchNodePtyWindowsTeardown(fixture.root) - - expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe( - readFileSync(desktopPath('windowsTerminal.js'), 'utf8') - ) - }) - - // The one hunk that must NOT match the desktop patch, and the reason is measured, not stylistic: - // on the branch a relay runs, releasing conin before `_getConsoleProcessList()` forks aborts - // teardown partway. Desktop terminal panes take the other branch, so no pane is affected either - // way; what this guards is a patch sync putting the early placement onto the relay's branch. - it('releases conin after the console-list fork, unlike the desktop patch placement', () => { + it('releases conin after the console-list fork', () => { const fixture = writeNodePtyFixture('1.1.0') patchNodePtyWindowsTeardown(fixture.root) const patched = readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'), 'utf8') @@ -117,9 +36,6 @@ describe('Windows SSH relay node-pty ConPTY teardown patch', () => { expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan( branch.indexOf('this._getConsoleProcessList()') ) - // Pinned so a future "sync the relay asset to config/patches" cannot copy the early placement - // onto the relay's branch, where it costs +2 File and +1 Process per terminal. - expect(patched).not.toBe(readFileSync(desktopPath('windowsPtyAgent.js'), 'utf8')) }) it('installs and verifies idempotently', () => { @@ -161,7 +77,7 @@ describe('Windows SSH relay node-pty ConPTY teardown patch', () => { }) }) -/** A published node-pty tree, rebuilt by un-applying the desktop hunks from the installed one. */ +/** Published upstream bytes keep legacy SSH repair coverage independent of local dependencies. */ function writeNodePtyFixture(version) { const root = mkdtempSync(join(projectDir, '.node-pty-teardown-patch-test-')) cleanupDirs.push(root) @@ -169,55 +85,10 @@ function writeNodePtyFixture(version) { mkdirSync(libDir, { recursive: true }) writeFileSync(join(root, 'node_modules', 'node-pty', 'package.json'), JSON.stringify({ version })) for (const file of PATCHED_FILES) { - const desktop = readFileSync(desktopPath(file), 'utf8') - for (const [marker] of DESKTOP_HUNKS[file]) { - expect(desktop).toContain(marker) - } - writeFileSync(join(libDir, file), unapplyDesktopHunks(file, desktop)) + writeFileSync( + join(libDir, file), + readFileSync(join(import.meta.dirname, 'fixtures/node-pty-1.1.0', `${file}.txt`)) + ) } return { root, libDir } } - -/** - * Reverse of the published-to-desktop transform. - * - * `windowsTerminal.js` is taken verbatim from the desktop, so the asset's own replacement table is - * the transform and reversing it is exact. `windowsPtyAgent.js` deliberately diverges, so its - * published form is rebuilt from the desktop hunk instead -- which is also what makes this file the - * place that notices if the desktop hunk itself ever moves. - */ -function unapplyDesktopHunks(file, desktop) { - if (file === 'windowsPtyAgent.js') { - let published = desktop - for (const [patched, original] of DESKTOP_HUNKS[file]) { - expect(published.split(patched).length - 1).toBe(1) - published = published.replace(patched, original) - } - return published - } - const asset = readFileSync( - join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), - 'utf8' - ) - const { PATCH_TARGETS } = loadPatchTargets(asset) - const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file) - expect(target).toBeDefined() - let published = desktop - for (const [from, to] of target.replacements.toReversed()) { - expect(published.split(to).length - 1).toBe(1) - published = published.replace(to, from) - } - return published -} - -function loadPatchTargets(assetSource) { - const module = { exports: {} } - const factory = new Function( - 'module', - 'exports', - 'require', - `${assetSource}\nmodule.exports.PATCH_TARGETS = PATCH_TARGETS` - ) - factory(module, module.exports, require) - return module.exports -} diff --git a/config/scripts/nsis-bun-terminal-uninstall.test.mjs b/config/scripts/nsis-bun-terminal-uninstall.test.mjs new file mode 100644 index 000000000000..e02bc6faff61 --- /dev/null +++ b/config/scripts/nsis-bun-terminal-uninstall.test.mjs @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcessSync } from '../../src/shared/child-process/run-process' + +const hooks = readFileSync(new URL('../nsis/orca-installer-hooks.nsh', import.meta.url), 'utf8') +const source = readFileSync( + new URL('../nsis/orca-bun-terminal-uninstall.nsh', import.meta.url), + 'utf8' +) +const command = source.match(/-Command "([^"\n]+)"`/)?.[1]?.replaceAll('$$', '$') +if (!command) { + throw new Error('Missing fixed Bun terminal uninstall command') +} + +it('only cleans the dedicated Bun runtime root during genuine uninstall', () => { + const customUninstall = hooks.match(/!macro customUnInstall\b[\s\S]*?!macroend/)?.[0] + expect(customUninstall).toMatch( + /\$\{ifNot\} \$\{isUpdated\}[\s\S]*?!insertmacro ORCA_UNINSTALL_BUN_TERMINAL_HOST[\s\S]*?\$\{endIf\}/ + ) + expect(source).toContain( + '${if} $0 == 0\n RMDir /r "$LOCALAPPDATA\\Orca\\terminal-daemon-host"' + ) + expect(command).toContain( + "[IO.Path]::Combine($env:LOCALAPPDATA,'Orca','terminal-daemon-host')+'\\'" + ) + expect(command).toContain('$null=$p.Handle') + expect(command).toContain("$p.StartTime.ToUniversalTime().ToString('yyyyMMddHHmmssffffff')") + expect(command).toContain('-MethodName GetOwnerSid') + expect(command).not.toMatch(/taskkill|ExecutionPolicy|EncodedCommand|Add-Type/i) + expect(source).not.toMatch(/\/IM.*bun-runtime/i) +}) + +const syntheticProcesses = ` +if((Get-ExecutionPolicy -Scope Process) -ne 'Restricted'){exit 19}; +$script:root=[IO.Path]::Combine($env:LOCALAPPDATA,'Orca','terminal-daemon-host'); +$script:created=[DateTime]::Parse('2026-01-02T00:00:00Z').ToUniversalTime(); +function Get-CimInstance { + if($env:ORCA_UNINSTALL_CASE -eq 'query-failure'){throw 'query failed'}; + $image=$script:root+'\\v1\\bun-runtime.exe'; + if($env:ORCA_UNINSTALL_CASE -eq 'unrelated'){$image=$script:root+'-other\\bun-runtime.exe'}; + if($env:ORCA_UNINSTALL_CASE -eq 'unknown-image'){$image=$null}; + [pscustomobject]@{Name='bun-runtime.exe';ExecutablePath=$image;ProcessId=123;CreationDate=$script:created} +}; +function Invoke-CimMethod { + param($InputObject,$MethodName); + $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value; + if($env:ORCA_UNINSTALL_CASE -eq 'foreign-owner'){$sid='foreign'}; + [pscustomobject]@{Sid=$sid} +}; +function Get-Process { + param($Id); + $start=$script:created; + if($env:ORCA_UNINSTALL_CASE -eq 'owned-submicrosecond'){$start=$start.AddTicks(1)}; + if($env:ORCA_UNINSTALL_CASE -eq 'reused-pid'){$start=$start.AddSeconds(1)}; + $p=[pscustomobject]@{Handle=1;Path=$script:root+'\\v1\\bun-runtime.exe';StartTime=$start}; + $p|Add-Member ScriptMethod Kill {[Console]::Out.WriteLine('KILLED_OWNED_FIXTURE')}; + $p|Add-Member ScriptMethod WaitForExit {param($milliseconds);return $true}; + $p|Add-Member ScriptMethod Dispose {}; + $p +};` + +describe.runIf(process.platform === 'win32')( + 'Bun uninstall ownership under Windows PowerShell', + () => { + it.each([ + ['owned', 0, true], + ['owned-submicrosecond', 0, true], + ['unrelated', 0, false], + ['foreign-owner', 1, false], + ['reused-pid', 1, false], + ['unknown-image', 1, false], + ['query-failure', 1, false] + ])('%s never kills an unproven owner', (scenario, code, kills) => { + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'psmodulepath') + ) + const result = runProcessSync({ + program: join( + process.env.SystemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ), + args: ['-NoProfile', '-NonInteractive', '-Command', syntheticProcesses + command], + env: { + ...env, + LOCALAPPDATA: "C:\\Users\\O'Connor Test\\AppData\\Local", + ORCA_UNINSTALL_CASE: scenario, + ORCA_BACKGROUND_LAUNCH: '1', + PSExecutionPolicyPreference: 'Restricted' + }, + timeoutMs: 15_000 + }) + expect(result.code, JSON.stringify(result)).toBe(code) + expect(result.stdout.includes('KILLED_OWNED_FIXTURE')).toBe(kills) + }) + } +) diff --git a/config/scripts/orcad-artifact-version.test.mjs b/config/scripts/orcad-artifact-version.test.mjs index 01c1ae3f8ff8..2574740b593e 100644 --- a/config/scripts/orcad-artifact-version.test.mjs +++ b/config/scripts/orcad-artifact-version.test.mjs @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { ORCAD_BUILD_TARGET_FILENAME, + ORCAD_WINDOWS_CONPTY_ARTIFACTS, ORCAD_RIPGREP_ARTIFACTS, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts' @@ -32,6 +33,24 @@ function createArtifactDirectory(target = '') { } describe('standalone runtime version', () => { + it.each(ORCAD_WINDOWS_CONPTY_ARTIFACTS)( + 'hashes and requires Windows provider artifact %s', + async (filename) => { + const target = 'win32-x64' + const directory = createArtifactDirectory(target) + const options = { target } + const before = computeOrcadFullVersion(directory, options) + writeFileSync(join(directory, filename), 'changed provider artifact') + expect(computeOrcadFullVersion(directory, options)).not.toBe(before) + expect(computeOrcadFullVersion(directory, options)).toBe( + await readOrcadArtifactIdentity(directory) + ) + rmSync(join(directory, filename)) + expect(() => computeOrcadFullVersion(directory, options)).toThrow(filename) + await expect(readOrcadArtifactIdentity(directory)).rejects.toThrow() + } + ) + it('changes when a shipped search binary changes and rejects a missing binary', () => { const dir = createArtifactDirectory() const before = computeOrcadFullVersion(dir) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index c0af0894d131..0c175a1742bc 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -1,5 +1,5 @@ import { build } from 'esbuild' -import { join } from 'node:path' +import { join, resolve } from 'node:path' const root = join(import.meta.dirname, '..', '..') @@ -22,7 +22,6 @@ export const ORCAD_CHILD_ENTRY_POINTS = { export const ORCAD_EXTERNAL_MODULES = [ 'electron', - 'node-pty', '@parcel/watcher', 'fsevents', 'bun:ffi', @@ -79,3 +78,21 @@ export function buildOrcadLauncher(outfile) { logLevel: 'error' }) } + +export function buildOrcadChildEntry(entryPoint, outfile) { + return build({ + entryPoints: [resolve(root, entryPoint)], + bundle: true, + platform: 'node', + target: ORCAD_BUN_TARGET, + format: 'cjs', + outfile, + external: ORCAD_EXTERNAL_MODULES, + plugins: [externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' + }) +} diff --git a/config/scripts/orcad-launcher-build.test.mjs b/config/scripts/orcad-launcher-build.test.mjs index b1f89a701ebd..94d9bda7bcbe 100644 --- a/config/scripts/orcad-launcher-build.test.mjs +++ b/config/scripts/orcad-launcher-build.test.mjs @@ -1,5 +1,5 @@ import { existsSync, realpathSync, statSync } from 'node:fs' -import { copyFile, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { copyFile, cp, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -47,6 +47,9 @@ describe.skipIf(!existsSync(bundledRuntime))('bundled launcher application bound beforeAll(async () => { await copyFile(bundledRuntime, join(directory, orcadBunRuntimeFilename(process.platform))) await writeFile(join(directory, '.build-target'), `${process.platform}-${process.arch}\n`) + if (process.platform === 'win32') { + await cp(resolve('out/orcad/conpty'), join(directory, 'conpty'), { recursive: true }) + } await writeFile( join(directory, 'orcad-app.js'), ` diff --git a/config/scripts/packaged-node-pty-prebuild-prune.test.mjs b/config/scripts/packaged-node-pty-prebuild-prune.test.mjs deleted file mode 100644 index 196e6aceb237..000000000000 --- a/config/scripts/packaged-node-pty-prebuild-prune.test.mjs +++ /dev/null @@ -1,138 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { createRequire } from 'node:module' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { prunePackagedNodePty } = require('../packaged-runtime-node-modules.cjs') -const { PE_MACHINE } = require('./windows-pe-machine.cjs') - -/** - * node-pty's loader tries build/Release, then build/Debug, then - * prebuilds/-, swallowing failures in between. Only the source - * build carries Orca's job-object exports, so leaving the prebuilt conpty.node - * beside it means an ABI mismatch or an AV quarantine silently downgrades the - * app to a binary that cannot own a PTY tree. - * - * The siblings must survive: Orca's patch deletes the `conpty_console_list` and - * winpty `pty` gyp targets, so those binaries exist nowhere but here. - */ -describe('prunePackagedNodePty: the Windows conpty fallback', () => { - let resources - const HOST_ARCH = process.arch - - const nodePty = () => join(resources, 'node_modules', 'node-pty') - const write = (relative, contents = 'x') => { - const target = join(nodePty(), relative) - mkdirSync(join(target, '..'), { recursive: true }) - writeFileSync(target, contents) - } - - /** Minimal PE far enough to carry a `Machine`, which is what the prune now reads. */ - const peAddon = (arch) => { - const buffer = Buffer.alloc(0x50) - buffer.write('MZ') - buffer.writeUInt32LE(0x40, 0x3c) - buffer.write('PE\0\0', 0x40) - buffer.writeUInt16LE(PE_MACHINE[arch], 0x44) - return buffer - } - const prebuilt = (name, arch = HOST_ARCH) => join(nodePty(), 'prebuilds', `win32-${arch}`, name) - - /** What a real packaged win32 prebuilds/ directory holds. */ - const SIBLINGS = ['conpty_console_list.node', 'pty.node', 'winpty.dll', 'winpty-agent.exe'] - - /** `buildArch` is what build/Release actually holds, which is not always the slice's arch. */ - const seedWindowsTree = (arch = HOST_ARCH, buildArch = arch) => { - write(join('build', 'Release', 'conpty.node'), peAddon(buildArch)) - write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'conpty.dll')) - write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'OpenConsole.exe')) - write(join('prebuilds', `win32-${arch}`, 'conpty.node')) - for (const sibling of SIBLINGS) { - write(join('prebuilds', `win32-${arch}`, sibling)) - } - } - - beforeEach(() => { - resources = mkdtempSync(join(tmpdir(), 'orca-prune-')) - }) - afterEach(() => { - rmSync(resources, { recursive: true, force: true }) - }) - - it('removes the stale conpty fallback when the source build is present', () => { - seedWindowsTree() - - prunePackagedNodePty(resources, 'win32', HOST_ARCH) - - expect(existsSync(join(nodePty(), 'build', 'Release', 'conpty.node'))).toBe(true) - expect(existsSync(prebuilt('conpty.node'))).toBe(false) - }) - - it.each(SIBLINGS)('keeps %s, which exists nowhere else in a packaged build', (sibling) => { - // Orca's patch removes the conpty_console_list and winpty gyp targets, so a - // Windows source build never produces these. Deleting them kills console - // membership silently and breaks PTY spawn below Windows build 18309. - seedWindowsTree() - - prunePackagedNodePty(resources, 'win32', HOST_ARCH) - - expect(existsSync(prebuilt(sibling))).toBe(true) - }) - - it('keeps the fallback when there is no source build to prefer', () => { - write(join('prebuilds', `win32-${HOST_ARCH}`, 'conpty.node')) - write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'conpty.dll')) - write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'OpenConsole.exe')) - - prunePackagedNodePty(resources, 'win32', HOST_ARCH) - - expect(existsSync(prebuilt('conpty.node'))).toBe(true) - }) - - it('keeps the fallback when build/Release holds a different arch than the slice', () => { - // A cross-HOST package can copy a build/Release that the target cannot load; - // deleting the prebuild would remove the only binary the shipped app has. - const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64' - seedWindowsTree(target, HOST_ARCH) - - prunePackagedNodePty(resources, 'win32', target) - - expect(existsSync(prebuilt('conpty.node', target))).toBe(true) - }) - - it('removes the fallback on a cross-arch package whose build/Release IS the slice arch', () => { - // Measured on Windows: `electron-builder --win --arm64` on an x64 host does - // cross-rebuild a correct arm64 addon. Keying off the host arch skipped this - // case, so the slice shipped the unpatched prebuild as a reachable fallback. - const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64' - seedWindowsTree(target, target) - - prunePackagedNodePty(resources, 'win32', target) - - expect(existsSync(prebuilt('conpty.node', target))).toBe(false) - expect(existsSync(prebuilt('pty.node', target))).toBe(true) - }) - - it('keeps the fallback when build/Release is not a readable PE at all', () => { - seedWindowsTree() - write(join('build', 'Release', 'conpty.node'), 'not-a-pe') - - prunePackagedNodePty(resources, 'win32', HOST_ARCH) - - expect(existsSync(prebuilt('conpty.node'))).toBe(true) - }) - - it.each([ - ['darwin', 'arm64'], - ['linux', 'x64'] - ])('leaves %s prebuilds alone', (platform, arch) => { - write(join('build', 'Release', 'conpty.node'), peAddon('x64')) - write(join('prebuilds', `${platform}-${arch}`, 'pty.node')) - - prunePackagedNodePty(resources, platform, arch) - - expect(existsSync(join(nodePty(), 'prebuilds', `${platform}-${arch}`, 'pty.node'))).toBe(true) - }) -}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8ed879dcf2e8..dd6503559253 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -78,11 +78,18 @@ const XTERM_PREFIXES = [ ] const SHELL_PREFIXES = [ + 'src/main/shell-prompt-readiness-probe', + 'src/main/line-editor-ready-output-scanner', + 'src/main/daemon/session-shell-ready-barrier', + 'src/main/daemon/bun-pty-fd-lifecycle', + 'src/shared/shell-process-readiness', + 'src/shared/shell-terminal-path', + 'src/shared/pty-slave-line-discipline-echo', 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec', 'src/main/daemon/shell-ready', 'src/main/daemon/daemon-bash-shell-ready', 'src/main/daemon/daemon-shell-ready-wrapper', - 'src/main/daemon/node-pty-fd-leak', + 'src/main/daemon/pty-subprocess/bun-pty', 'src/main/providers/local-pty-shell-ready', 'src/main/providers/__tests__/shell-ready-framework-example', 'src/main/pty/', @@ -96,9 +103,8 @@ const SHELL_PREFIXES = [ 'src/shared/pty-reply-echo-shapes', 'src/shared/startup-shell-portability', 'src/shared/posix-command-path-lookup', - 'config/patches/node-pty@', 'config/scripts/ensure-native-runtime', - 'config/scripts/node-pty-job-ownership' + 'src/main/daemon/pty-subprocess/windows-bun-pty' ] const ORCAD_BROWSER_PREFIXES = [ @@ -173,14 +179,13 @@ const MANAGED_HOOK_PREFIXES = [ const NATIVE_RUNTIME_PREFIXES = [ 'config/scripts/ensure-native-runtime', 'config/scripts/rebuild-native-deps', - 'config/scripts/node-pty-job-ownership', + 'src/main/daemon/pty-subprocess/windows-bun-pty', 'config/scripts/windows-pe-machine', 'config/scripts/windows-pe-image-fixture', 'config/scripts/script-module-dependencies', 'config/scripts/windows-process-tree-creation-time', 'config/scripts/windows-process-tree-gyp-rebuild', 'config/scripts/electron-builder-native-rebuild', - 'config/patches/node-pty@', 'config/patches/@vscode__windows-process-tree' ] @@ -192,15 +197,14 @@ const NATIVE_CACHE_FILES = new Set([ 'config/scripts/rebuild-native-deps.mjs' ]) -const NATIVE_CACHE_PREFIXES = [ - 'config/patches/node-pty@', - 'config/patches/@vscode__windows-process-tree' -] +const NATIVE_CACHE_PREFIXES = ['config/patches/@vscode__windows-process-tree'] const SHARED_PACKAGE_PREFIXES = [ 'electron.vite.config.ts', 'config/electron-builder', 'config/packaged-runtime', + 'config/bundled-cli-runtime', + 'config/scripts/bundled-cli-runtime', 'config/build-plugins/', 'config/scripts/build-', 'config/scripts/smoke-packaged', @@ -226,6 +230,7 @@ const LINUX_PACKAGE_PREFIXES = [ 'config/docker/headless-serve-shutdown/', 'config/docker/daemon-shutdown-descendants/', 'config/scripts/run-linux-cli-launch-contract', + 'config/scripts/run-linux-packaged-terminal-floor-smoke', 'config/scripts/run-headless-linux-pairing-docker', 'config/scripts/run-daemon-shutdown-descendants-docker', 'config/scripts/static-appimage-package-contract', @@ -240,6 +245,7 @@ const WINDOWS_PACKAGE_PREFIXES = [ 'native/computer-use-windows/', 'resources/win32/', 'config/scripts/build-windows-cli-launcher', + 'config/scripts/finalize-signed-cli-runtime', 'config/scripts/windows-pty-native-capability', 'tests/tools/windows-pty-native-capability-smoke/' ] @@ -256,15 +262,13 @@ const WINDOWS_PACKAGE_TESTS = [ ...LINUX_PACKAGE_TESTS, 'config/scripts/rebuild-native-deps.test.mjs', 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', - 'config/scripts/rebuild-native-deps-node-pty.test.mjs', 'config/scripts/nsis-process-check.test.mjs', - 'config/scripts/ensure-native-runtime-job-ownership.test.mjs', - 'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs', + 'config/scripts/nsis-bun-terminal-uninstall.test.mjs', 'config/scripts/windows-pe-machine.test.mjs', 'config/scripts/script-module-dependencies.test.mjs', 'src/main/windows-registry-addon.test.ts', - 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', - 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', + 'src/main/providers/windows-conpty-wide-char-duplication.bun.test.ts', + 'src/main/providers/pty-repaint-wide-char-buffer.bun.test.ts', 'src/shared/child-process/windows-command-line.win32.test.ts', 'src/shared/child-process/windows-cmd-shim-resolution.test.ts', 'src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts', @@ -273,6 +277,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/codex/windows-hook-command.test.ts', 'src/main/codex/windows-hook-upgrade.test.ts', 'src/main/windows/windows-pty-job.win32.test.ts', + 'src/main/daemon/bun-pty-windows-io-failure.win32.test.ts', 'src/main/windows/windows-msys-job.win32.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/windows/windows-host-job.win32.test.ts', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 01ff8ac2bed2..6e7f9e821743 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -250,6 +250,20 @@ describe('per-job path classification', () => { }) }) + it('runs real shell contracts when readiness probes or terminal inspection change', () => { + for (const file of [ + 'src/main/shell-prompt-readiness-probe.ts', + 'src/main/line-editor-ready-output-scanner.ts', + 'src/main/daemon/session-shell-ready-barrier.ts', + 'src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts', + 'src/shared/shell-process-readiness.ts', + 'src/shared/shell-terminal-path.test.ts', + 'src/shared/pty-slave-line-discipline-echo.ts' + ]) { + expect(classifyPrJobs([file]).shell_contracts).toBe(true) + } + }) + it('runs shell contracts when wrapper templates or live-shell fixtures change', () => { expectClassification(['src/main/shell-templates.ts'], { shell_contracts: true, @@ -371,7 +385,7 @@ describe('per-job path classification', () => { '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', - 'config/patches/node-pty@1.1.0.patch' + 'config/patches/@vscode__windows-process-tree@0.8.0.patch' ]) { expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true) } @@ -623,3 +637,16 @@ describe('PR Checks skip wiring', () => { } }) }) + +describe('bundled terminal runtime packaging gates', () => { + it.each([ + ['config/bundled-cli-runtime.cjs', true, true], + ['config/scripts/bundled-cli-runtime.test.mjs', false, false], + ['config/scripts/run-linux-packaged-terminal-floor-smoke.mjs', true, false], + ['config/scripts/run-linux-packaged-terminal-floor-smoke.test.mjs', false, false], + ['config/scripts/finalize-signed-cli-runtime.mjs', false, true], + ['config/scripts/finalize-signed-cli-runtime.test.mjs', false, false] + ])('selects package checks for %s', (file, linux, windows) => { + expect(classifyPrJobs([file])).toMatchObject({ package: linux, package_windows: windows }) + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index a847b85d26e6..04b33f093c6f 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -21,18 +21,19 @@ const shellContractFiles = [ 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', - 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', + 'src/main/pty/omp-shell-wrapper.bun.test.ts', 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] -const patchedNodePtyContractFiles = [ - 'src/main/daemon/node-pty-fd-leak.test.ts', - 'src/shared/fish-query-reply-child-stdin.node-pty.test.ts' +const additionalNativeShellContractFiles = [ + 'src/main/daemon/pty-subprocess/bun-tests/pty-reply-echo-shapes.bun.test.ts', + 'src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts', + 'src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin.bun.test.ts' ] -const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles] +const nativeShellContractFiles = [...shellContractFiles, ...additionalNativeShellContractFiles] const testFilePatterns = [ 'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', 'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', @@ -47,6 +48,16 @@ const realZshUsage = /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { + it('keeps every explicitly selected shell test on disk', () => { + const step = workflow.jobs.shell_contracts.steps.find( + (candidate) => candidate.name === 'Test real shell contracts' + ) + expect(step).toBeDefined() + const files = step.run.match(/src\/[\w./-]+\.test\.ts/g) ?? [] + expect(files.length).toBeGreaterThan(0) + expect(files.filter((file) => !existsSync(file))).toEqual([]) + }) + it('keeps lightweight orchestration jobs on the free slim runner', () => { expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm') @@ -227,7 +238,9 @@ describe('PR workflow parallelism', () => { (step) => step.name === 'Build package inputs' ) - expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)') + expect(buildStep.run).toContain( + 'scripts=(build:relay build:terminal-daemon build:electron-vite:parallel)' + ) expect(buildStep.run).toContain('pnpm run "$script" &') expect( workflow.jobs.package.steps.find( @@ -404,14 +417,14 @@ describe('PR workflow parallelism', () => { 'steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version' ) expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope') - expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch') + expect(cacheStep.with.key).not.toContain('node-pty') expect(cacheStep.with.key).toContain( 'config/patches/@vscode__windows-process-tree@0.8.0.patch' ) expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml') expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs') expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs') - expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build') + expect(cacheStep.with.path).not.toContain('node-pty') expect(cacheStep.with.path).toContain('native/windows-registry/build') expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*') expect(cacheStep.with['restore-keys']).toBeUndefined() diff --git a/config/scripts/pty-transcript-bun-runtime.mjs b/config/scripts/pty-transcript-bun-runtime.mjs new file mode 100644 index 000000000000..487fdc475572 --- /dev/null +++ b/config/scripts/pty-transcript-bun-runtime.mjs @@ -0,0 +1,90 @@ +import { bunOwnedRuntimeArgs } from '../../src/shared/bun-owned-runtime-args.ts' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** Observe native bytes before the product adapter's streaming UTF-8 decoder. */ +export function tapBunTerminalBytes(runtime, onBytes) { + const tap = (options) => ({ + ...options, + data(terminal, bytes) { + onBytes(Buffer.from(bytes)) + options.data(terminal, bytes) + } + }) + return { + Terminal: class extends runtime.Terminal { + constructor(options) { + super(tap(options)) + } + }, + spawn(command, options) { + return runtime.spawn(command, { + ...options, + terminal: 'data' in options.terminal ? tap(options.terminal) : options.terminal + }) + } + } +} + +export async function spawnTranscriptPty(args, onBytes) { + const { spawnBunPty } = await import('../../src/main/daemon/pty-subprocess/bun-pty-process.ts') + const { resolveBunRuntime } = + await import('../../src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts') + const { createWindowsBunPtyLaunch } = + await import('../../src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts') + const term = spawnBunPty(args, { + runtime: tapBunTerminalBytes(resolveBunRuntime(), onBytes), + createWindowsLaunch: (options) => + createWindowsBunPtyLaunch(options, { + workerPath: join( + import.meta.dirname, + '../../src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts' + ) + }) + }) + // The recorder consumes native bytes; do not retain the adapter's decoded replay buffer. + const subscription = term.onData(() => {}) + term.onExit(() => subscription.dispose()) + return term +} + +export async function relaunchTranscriptWithBun( + root, + argv, + entry = join(root, 'config/scripts/capture-agent-pty-transcript.mjs') +) { + const { ORCAD_BUN_VERSION } = await import('../../src/shared/orcad-bun-runtime.ts') + if (process.versions.bun) { + if (process.versions.bun !== ORCAD_BUN_VERSION) { + throw new Error(`Capture requires Bun ${ORCAD_BUN_VERSION}`) + } + return null + } + const { orcadBunRuntimeFilename } = await import('../../src/shared/orcad-artifacts.ts') + const filename = orcadBunRuntimeFilename(process.platform) + const runtime = [ + process.env.BUN_EXECUTABLE, + join(root, 'out/orcad', filename), + join(root, 'out/cli-runtime', `${process.platform}-${process.arch}`, filename) + ].find((candidate) => candidate && existsSync(candidate)) + if (!runtime) { + throw new Error( + 'Prepare bundled Bun with node config/scripts/build-orcad-bun.mjs --runtime-only --out-dir out/orcad' + ) + } + const { runProcessSync } = await import('./script-child-process.mjs') + const env = { ...process.env } + for (const key of Object.keys(env)) { + if (/^(NODE_OPTIONS|NODE_PATH|BUN_OPTIONS|BUN_INSPECT.*)$/i.test(key)) { + delete env[key] + } + } + const result = runProcessSync({ + program: runtime, + env, + args: [...bunOwnedRuntimeArgs(), entry, ...argv], + stdio: 'inherit', + timeoutMs: null + }) + return result.code ?? 1 +} diff --git a/config/scripts/pty-transcript-recording.mjs b/config/scripts/pty-transcript-recording.mjs new file mode 100644 index 000000000000..4df85aea74ae --- /dev/null +++ b/config/scripts/pty-transcript-recording.mjs @@ -0,0 +1,98 @@ +const STOP_KEY = 0x1d + +export async function recordPtyTranscript({ spawn, sink, stdin, stdout, options }) { + let recording = true + let term + let stopping = false + const timers = [] + const wasRaw = stdin.isTTY === true && stdin.isRaw === true + const stop = () => { + if (stopping) { + return + } + stopping = true + recording = false + try { + term.kill() + } catch { + // A concurrent physical exit can make the native handle unavailable. + } + } + const onInput = (chunk) => { + if (chunk.includes(STOP_KEY)) { + stop() + } else if (!stopping) { + term.write(chunk) + } + } + let sinkFailure + const onSinkError = (error) => { + sinkFailure = error + if (term) { + stop() + } + } + sink.on('error', onSinkError) + let exitCode + let captureFailure + try { + term = await spawn((bytes) => { + if (recording) { + sink.write(bytes) + } + stdout.write(bytes) + }) + const exit = new Promise((resolve) => term.onExit(({ exitCode }) => resolve(exitCode ?? 0))) + await term.waitForSpawn?.() + if (sinkFailure) { + throw sinkFailure + } + if (stdin.isTTY) { + stdin.setRawMode(true) + } + stdin.on('data', onInput) + stdin.resume() + for (const send of options.sends) { + timers.push( + setTimeout(() => { + if (!stopping) { + term.write(send.text) + } + }, send.atMs) + ) + } + if (options.duration !== null) { + timers.push(setTimeout(stop, options.duration * 1000)) + } + exitCode = await exit + } catch (error) { + captureFailure = error + } finally { + recording = false + for (const timer of timers) { + clearTimeout(timer) + } + stdin.off('data', onInput) + if (stdin.isTTY) { + stdin.setRawMode(wasRaw) + } + stdin.pause() + try { + term?.destroy() + } catch (error) { + captureFailure ??= error + } + await new Promise((resolve) => { + if (sink.destroyed) { + return resolve() + } + sink.once('error', resolve) + sink.end(resolve) + }) + sink.off('error', onSinkError) + } + if (captureFailure || sinkFailure) { + throw captureFailure ?? sinkFailure + } + return exitCode +} diff --git a/config/scripts/pty-transcript-recording.test.mjs b/config/scripts/pty-transcript-recording.test.mjs new file mode 100644 index 000000000000..5fe7be1e96f1 --- /dev/null +++ b/config/scripts/pty-transcript-recording.test.mjs @@ -0,0 +1,182 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { PassThrough, Writable } from 'node:stream' +import { tapBunTerminalBytes } from './pty-transcript-bun-runtime.mjs' +import { recordPtyTranscript } from './pty-transcript-recording.mjs' + +test('raw tap preserves invalid UTF-8, split UTF-8 and control bytes in both terminal forms', () => { + for (const inline of [true, false]) { + let options + const raw = [] + const decoded = [] + const runtime = { + Terminal: class { + constructor(value) { + options = value + } + }, + spawn(_command, value) { + if (inline) { + options = value.terminal + } + } + } + const tapped = tapBunTerminalBytes(runtime, (bytes) => raw.push(bytes)) + const terminal = { data: (_term, bytes) => decoded.push(bytes) } + tapped.spawn([], { terminal: inline ? terminal : new tapped.Terminal(terminal) }) + for (const bytes of [[0xff, 0xc0], [0xe2], [0x82, 0xac, 0, 27, 13]]) { + options.data({}, Uint8Array.from(bytes)) + } + assert.deepEqual(Buffer.concat(raw), Buffer.from([255, 192, 226, 130, 172, 0, 27, 13])) + assert.equal(decoded.length, 3) + } +}) + +function harness({ fastExit = false, stop = false } = {}) { + const stdin = new PassThrough() + const chunks = [] + const sink = new Writable({ + write(chunk, _encoding, done) { + chunks.push(Buffer.from(chunk)) + done() + } + }) + const output = [] + const writes = [] + let disposed = 0 + return { + chunks, + writes, + get disposed() { + return disposed + }, + stdin, + run: () => + recordPtyTranscript({ + sink, + stdin, + stdout: { write: (bytes) => output.push(bytes) }, + options: { sends: [], duration: null }, + spawn: async (data) => { + data(Buffer.from([255, 27, 13])) + let exit + const term = { + onExit(listener) { + exit = listener + if (fastExit) { + listener({ exitCode: 7 }) + } + return { dispose() {} } + }, + write: (bytes) => writes.push(bytes), + kill() { + data(Buffer.from('SHUTDOWN')) + exit({ exitCode: 0 }) + }, + destroy() { + disposed++ + } + } + if (!fastExit) { + setImmediate(() => { + stdin.write(Buffer.from([0xc3, 0xa9])) + if (stop) { + stdin.write(Buffer.from([0x1d])) + } else { + data(Buffer.from([0xe2, 0x82, 0xac])) + exit({ exitCode: 3 }) + } + }) + } + return term + } + }) + } +} + +test('stop freezes transcript before killing while preserving raw input', async () => { + const fixture = harness({ stop: true }) + assert.equal(await fixture.run(), 0) + assert.deepEqual(Buffer.concat(fixture.chunks), Buffer.from([255, 27, 13])) + assert.deepEqual(fixture.writes, [Buffer.from([0xc3, 0xa9])]) + assert.equal(fixture.stdin.listenerCount('data'), 0) + assert.equal(fixture.disposed, 1) +}) + +test('natural exit retains final drained output', async () => { + const fixture = harness() + assert.equal(await fixture.run(), 3) + assert.deepEqual(Buffer.concat(fixture.chunks), Buffer.from([255, 27, 13, 226, 130, 172])) +}) + +test('fast exit replays completion without hanging', async () => { + const fixture = harness({ fastExit: true }) + assert.equal(await fixture.run(), 7) + assert.equal(fixture.disposed, 1) +}) + +test('spawn receipt failure cleans input and closes output', async () => { + const stdin = new PassThrough() + const sink = new PassThrough() + let destroyed = false + await assert.rejects( + recordPtyTranscript({ + stdin, + sink, + stdout: { write() {} }, + options: { sends: [], duration: null }, + spawn: async () => ({ + onExit() { + return { dispose() {} } + }, + waitForSpawn: async () => { + throw new Error('shell failed') + }, + destroy() { + destroyed = true + } + }) + }), + /shell failed/ + ) + assert.equal(destroyed, true) + assert.equal(sink.writableFinished, true) + assert.equal(stdin.listenerCount('data'), 0) +}) + +test('sink failure refuses success and retires terminal', async () => { + const stdin = new PassThrough() + const sink = new Writable({ + write(_chunk, _encoding, done) { + done(new Error('disk full')) + } + }) + let exit + let destroyed = false + await assert.rejects( + recordPtyTranscript({ + stdin, + sink, + stdout: { write() {} }, + options: { sends: [], duration: null }, + spawn: async (data) => { + setImmediate(() => data(Buffer.from('output'))) + return { + onExit(listener) { + exit = listener + return { dispose() {} } + }, + kill() { + exit({ exitCode: 1 }) + }, + destroy() { + destroyed = true + } + } + } + }), + /disk full/ + ) + assert.equal(destroyed, true) + assert.equal(stdin.listenerCount('data'), 0) +}) diff --git a/config/scripts/real-terminal-runtime-workflows.test.ts b/config/scripts/real-terminal-runtime-workflows.test.ts new file mode 100644 index 000000000000..212d9df9ee01 --- /dev/null +++ b/config/scripts/real-terminal-runtime-workflows.test.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const prepare = 'node config/scripts/build-orcad-bun.mjs --runtime-only --out-dir out/orcad' + +describe('real terminal test runtime preparation', () => { + it.each([ + ['pr.yml', 'shell_contracts', 'Test real shell contracts'], + ['pr.yml', 'package_windows', 'Test Windows-specific boundaries'], + ['unit-tests.yml', 'test', 'Test shard'] + ])('prepares pinned Bun before %s/%s executes %s', (file, job, testStep) => { + const workflow = parse(readFileSync(`.github/workflows/${file}`, 'utf8')) + const steps = workflow.jobs[job].steps + const testIndex = steps.findIndex((step: { name?: string }) => step.name === testStep) + const preparation = steps.findIndex((step: { run?: string }) => step.run === prepare) + expect(preparation).toBeGreaterThan(-1) + expect(preparation).toBeLessThan(testIndex) + expect(steps[preparation].if).toBeUndefined() + expect(steps[preparation]['continue-on-error']).toBeUndefined() + }) +}) + +it('runs the shell exec regression with Bun before Node-based shell contracts', () => { + const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + const step = workflow.jobs.shell_contracts.steps.find( + (entry: { name?: string }) => entry.name === 'Test real shell contracts' + ) + const file = 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts' + const [bun, ...node] = step.run.trim().split('\n') + expect(bun).toContain(`node config/scripts/run-bun-profile-tests.mjs --maxWorkers=1 ${file}`) + expect(node.join('\n')).not.toContain(file) +}) + +describe('desktop terminal build closure', () => { + it.each([ + ['pr.yml', 'package', 'Build package inputs'], + ['e2e.yml', 'build', 'Build E2E outputs'] + ])('builds the runtime and daemon before %s/%s packaging or upload', (file, job, stepName) => { + const workflow = parse(readFileSync(`.github/workflows/${file}`, 'utf8')) + const step = workflow.jobs[job].steps.find( + (entry: { name?: string }) => entry.name === stepName + ) + expect(step.run).toContain('build:cli') + expect(step.run).toContain('build:terminal-daemon') + }) + it('prepares the runtime and daemon before launching the development app', () => { + const source = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') + expect(source).toContain('if (!isHelpOrVersion) {\n await buildCliRuntime') + const launch = source.indexOf('const child = spawn(') + for (const call of [ + 'await buildCliRuntime(process.platform, process.arch)', + 'await buildTerminalDaemon()' + ]) { + expect(source.indexOf(call)).toBeGreaterThan(-1) + expect(source.indexOf(call)).toBeLessThan(launch) + } + }) + it('repairs missing E2E runtime and daemon outputs even with SKIP_BUILD', () => { + const source = readFileSync('tests/e2e/global-setup.ts', 'utf8') + expect(source).toContain('!existsSync(runtimeManifest) || !existsSync(runtimeBinary)') + expect(source).toContain('!existsSync(terminalEntry) || !existsSync(terminalGate)') + expect(source).toContain('node config/scripts/build-terminal-daemon.mjs') + expect(source).toContain("['config/scripts/build-cli-runtime.mjs', '--arch', process.arch]") + }) + it('does not silently filter retired terminal suites out of reliability commands', () => { + const gates = readFileSync('config/reliability-gates.jsonc', 'utf8') + expect(gates).not.toMatch( + /(?:local-pty-provider-(?:foreground-process|shell-readiness|spawn-session|shutdown|session-inventory)|local-pty-shell-ready-startup-command|degraded-daemon-pty-provider|omp-shell-wrapper\.node-pty)\.test\.ts/ + ) + }) +}) diff --git a/config/scripts/rebuild-native-deps-addon-validation.test.mjs b/config/scripts/rebuild-native-deps-addon-validation.test.mjs new file mode 100644 index 000000000000..bb0e75bcbb12 --- /dev/null +++ b/config/scripts/rebuild-native-deps-addon-validation.test.mjs @@ -0,0 +1,208 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' +import { + gitLineEndingEnv, + initGitWorkTree, + mkTempProject, + runRebuildScript, + writeFakeElectronRebuild, + writeFakeUsableElectronPackage, + writeFakeWindowsProcessTree, + writeFakeWindowsProcessTreeWithNodeAddonApi, + writeWindowsProcessTreePatchFile +} from './rebuild-native-deps-test-fixtures.mjs' + +describe('rebuild-native-deps addon validation', () => { + it('stages windows-process-tree node-addon-api headers before a Windows rebuild', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect( + readFileSync( + join( + projectDir, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'deps', + 'node-addon-api', + 'napi.h' + ), + 'utf8' + ) + ).toBe('// napi.h\n') + } finally { + removeTreeSync(projectDir) + } + }) + + const commandLineSourcePath = (projectDir) => + join( + projectDir, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'src', + 'process_commandline.cc' + ) + + // Why inside a git work tree: `git apply` run under one prefixes patch paths + // with the cwd-relative prefix, silently skips what does not match, and still + // exits 0. The package dir is always under the project root in production, so + // a fixture in %TEMP% alone would pass while the real repair did nothing. + // + // Why both line-ending modes: the patch is stored LF while upstream ships this + // source CRLF, so whether the pre-image matches depends on `core.autocrlf` -- + // and under `false`, Git's own built-in default, it did not. The repair blinds + // git to the repo, so that value comes from global config, i.e. from whichever + // option the developer's installer wrote. Pinning both makes the case cover the + // host that breaks rather than the host that happens to run it. + for (const autocrlf of ['false', 'true']) { + it(`repairs an un-applied command-line patch in a work tree (autocrlf=${autocrlf})`, () => { + const projectDir = mkTempProject() + + try { + initGitWorkTree(projectDir) + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { + commandLinePatchApplied: false + }) + writeWindowsProcessTreePatchFile(projectDir) + + const result = runRebuildScript( + projectDir, + { + npm_config_platform: 'win32', + npm_config_arch: 'x64', + ...gitLineEndingEnv(autocrlf) + }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).toContain( + 'kProcessCommandLineInformation' + ) + } finally { + removeTreeSync(projectDir) + } + }) + } + + // Why fail rather than build: an unpatched command-line reader compiles fine + // and then opens every process with PROCESS_VM_READ to walk its PEB, which is + // the primitive the patch exists to remove. + it('refuses a Windows rebuild when the command-line patch cannot be applied', () => { + const projectDir = mkTempProject() + + try { + initGitWorkTree(projectDir) + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { commandLinePatchApplied: false }) + // No patch file, so the repair has nothing to apply. + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('process_commandline.cc') + expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).not.toContain( + 'kProcessCommandLineInformation' + ) + } finally { + removeTreeSync(projectDir) + } + }) + + it('refuses a Windows rebuild when the process creation-time patch is missing', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { creationTimePatchApplied: false }) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('process creation-time patch') + } finally { + removeTreeSync(projectDir) + } + }) + + it.skipIf(process.platform !== 'win32')( + 'rebuilds only the failing registry while the process-tree addon stays healthy', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeWindowsProcessTree(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Rebuilding failed native modules: @orca/windows-registry') + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['@orca/windows-registry']) + } finally { + removeTreeSync(projectDir) + } + } + ) + + for (const [addon, expected] of [ + ['unpatched', 'still imports ReadProcessMemory'], + ['none', 'is not there'] + ]) { + it(`fails a Windows rebuild that leaves ${addon} windows-process-tree bytes`, () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { addon }) + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(expected) + } finally { + removeTreeSync(projectDir) + } + }) + } +}) diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs deleted file mode 100644 index a9ec246dd95b..000000000000 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ /dev/null @@ -1,606 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -import { - gitLineEndingEnv, - initGitWorkTree, - mkTempProject, - runRebuildScript, - writeFakeElectronRebuild, - writeFakeLoadableNodePty, - writeFakeNodePtyConptyPayload, - writeFakeNodePtyConptySource, - writeFakeUsableElectronPackage, - writeFakeWindowsProcessTree, - writeFakeWindowsProcessTreeWithNodeAddonApi, - writeFakeWindowsRegistry, - writeNodePtyPatchFile, - writePatchedNodePtyBuildArtifacts, - writeWindowsProcessTreePatchFile -} from './rebuild-native-deps-test-fixtures.mjs' - -describe('rebuild-native-deps patched node-pty rebuild', () => { - it.skipIf(process.platform !== 'win32')( - 'repairs a missing ConPTY runtime before probing without recompiling node-pty', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) - writeFakeWindowsRegistry(projectDir) - writeFakeWindowsProcessTree(projectDir) - writeFakeNodePtyConptyPayload(projectDir, process.arch) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: process.arch - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Restored node-pty ConPTY runtime files') - expect(result.stdout).toContain( - 'Native modules already load in Electron; skipping rebuild.' - ) - expect(existsSync(rebuildLogPath)).toBe(false) - } finally { - removeTreeSync(projectDir) - } - } - ) - - it('stages windows-process-tree node-addon-api headers before a Windows rebuild', () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status, result.stderr).toBe(0) - expect( - readFileSync( - join( - projectDir, - 'node_modules', - '@vscode', - 'windows-process-tree', - 'deps', - 'node-addon-api', - 'napi.h' - ), - 'utf8' - ) - ).toBe('// napi.h\n') - } finally { - removeTreeSync(projectDir) - } - }) - - const commandLineSourcePath = (projectDir) => - join( - projectDir, - 'node_modules', - '@vscode', - 'windows-process-tree', - 'src', - 'process_commandline.cc' - ) - - // Why inside a git work tree: `git apply` run under one prefixes patch paths - // with the cwd-relative prefix, silently skips what does not match, and still - // exits 0. The package dir is always under the project root in production, so - // a fixture in %TEMP% alone would pass while the real repair did nothing. - // - // Why both line-ending modes: the patch is stored LF while upstream ships this - // source CRLF, so whether the pre-image matches depends on `core.autocrlf` -- - // and under `false`, Git's own built-in default, it did not. The repair blinds - // git to the repo, so that value comes from global config, i.e. from whichever - // option the developer's installer wrote. Pinning both makes the case cover the - // host that breaks rather than the host that happens to run it. - for (const autocrlf of ['false', 'true']) { - it(`repairs an un-applied command-line patch in a work tree (autocrlf=${autocrlf})`, () => { - const projectDir = mkTempProject() - - try { - initGitWorkTree(projectDir) - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { - commandLinePatchApplied: false - }) - writeWindowsProcessTreePatchFile(projectDir) - - const result = runRebuildScript( - projectDir, - { - npm_config_platform: 'win32', - npm_config_arch: 'x64', - ...gitLineEndingEnv(autocrlf) - }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status, result.stderr).toBe(0) - expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).toContain( - 'kProcessCommandLineInformation' - ) - } finally { - removeTreeSync(projectDir) - } - }) - } - - // Why fail rather than build: an unpatched command-line reader compiles fine - // and then opens every process with PROCESS_VM_READ to walk its PEB, which is - // the primitive the patch exists to remove. - it('refuses a Windows rebuild when the command-line patch cannot be applied', () => { - const projectDir = mkTempProject() - - try { - initGitWorkTree(projectDir) - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { commandLinePatchApplied: false }) - // No patch file, so the repair has nothing to apply. - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('process_commandline.cc') - expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).not.toContain( - 'kProcessCommandLineInformation' - ) - } finally { - removeTreeSync(projectDir) - } - }) - - it('refuses a Windows rebuild when the process creation-time patch is missing', () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { creationTimePatchApplied: false }) - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('process creation-time patch') - } finally { - removeTreeSync(projectDir) - } - }) - - it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Restored node-pty ConPTY runtime files for win10-x64') - const runtimeDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') - expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64') - expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64') - } finally { - removeTreeSync(projectDir) - } - }) - - it.skipIf(process.platform !== 'win32')( - 'does not rebuild a healthy node-pty when another Windows addon fails its probe', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir) - writeFakeWindowsProcessTree(projectDir) - writeFakeNodePtyConptyPayload(projectDir, process.arch) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: process.arch - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Rebuilding failed native modules: @orca/windows-registry') - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['@orca/windows-registry']) - } finally { - removeTreeSync(projectDir) - } - } - ) - - it.skipIf(process.platform !== 'win32')( - 'rebuilds a loadable ConPTY native that lacks Orca job ownership', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false }) - writeFakeWindowsRegistry(projectDir) - writeFakeWindowsProcessTree(projectDir) - writeFakeNodePtyConptyPayload(projectDir, process.arch) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: process.arch - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') - expect(result.stdout).toContain('missing listJobProcessIds') - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['node-pty']) - } finally { - removeTreeSync(projectDir) - } - } - ) - - // The shape measured on a Windows dev checkout: the addon loads under - // Electron, exports all three job functions, and predates the denial. A bare - // require proves nothing about it; the probe has to read the binary. - it.skipIf(process.platform !== 'win32')( - 'rebuilds a loadable ConPTY native that owns its job but predates the MSYS breakaway denial', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { cygwinBreakawayDenied: false }) - writeFakeWindowsRegistry(projectDir) - writeFakeWindowsProcessTree(projectDir) - writeFakeNodePtyConptyPayload(projectDir, process.arch) - writeFakeNodePtyConptySource(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: process.arch - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') - expect(result.stdout).toContain('predates the Cygwin/MSYS job-breakaway denial') - expect(result.stdout).not.toContain('skipping rebuild') - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['node-pty']) - } finally { - removeTreeSync(projectDir) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'rebuilds when Electron can load node-pty but patched build artifacts are missing', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir) - writeNodePtyPatchFile(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain( - 'Patched node-pty build artifacts are missing; rebuilding from source.' - ) - - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['node-pty']) - expect(rebuildCall.ignoreModules).toEqual(['cpu-features']) - expect(rebuildCall.force).toBe(true) - } finally { - removeTreeSync(projectDir) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'keeps the Electron load-probe fast path once patched node-pty artifacts exist', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) - writeNodePtyPatchFile(projectDir) - writePatchedNodePtyBuildArtifacts(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain( - 'Native modules already load in Electron; skipping rebuild.' - ) - expect(existsSync(rebuildLogPath)).toBe(false) - } finally { - removeTreeSync(projectDir) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'rebuilds when patched artifacts exist but Electron falls back to node-pty prebuilds', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { nativeDir: '../prebuilds/darwin-arm64/' }) - writeNodePtyPatchFile(projectDir) - writePatchedNodePtyBuildArtifacts(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') - expect(result.stdout).toContain("expected build/Release so Orca's node-pty patch is active") - - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['node-pty']) - expect(rebuildCall.force).toBe(true) - } finally { - removeTreeSync(projectDir) - } - } - ) - - // The binary this step produces is the one copied into the packaged app. The - // relay build checks its own artifact and ensure-native-runtime checks what it - // loads; nothing checked this one, so a rebuild that quietly emitted the - // upstream reader shipped. Both non-clean states have to fail, which is the - // caller the tri-state was missing: after a rebuild that reported success, an - // absent binary is a broken build, not an absence to shrug at. - for (const [addon, expected] of [ - ['unpatched', 'still imports ReadProcessMemory'], - ['none', 'is not there'] - ]) { - it(`fails a Windows rebuild that leaves ${addon} windows-process-tree bytes`, () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { addon }) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status).not.toBe(0) - expect(result.stderr).toContain(expected) - } finally { - removeTreeSync(projectDir) - } - }) - } - - // The Electron probe carries this check too, but it is skipped whenever the - // Electron package binary is unusable. Every job export predates the MSYS - // breakaway denial, so without reading the binary this step would hand the - // packaged app one that leaks every Git Bash child out of its pane's job. - it('fails a Windows rebuild that leaves an addon predating the MSYS breakaway denial', () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64', { cygwinBreakawayDenied: false }) - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('predates the Cygwin/MSYS job-breakaway denial') - } finally { - removeTreeSync(projectDir) - } - }) - - // Measured on a Windows dev checkout whose node_modules predated the denial: - // `--force` compiled for minutes, rewrote conpty.node byte-identical and - // unpatched, and the addon gate then said "rebuild from source" -- the step - // that had just run. The source is readable before the compile; read it. - it('refuses to compile node-pty source that lacks the denial, before the rebuild runs', () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeNodePtyConptySource(projectDir, { cygwinBreakawayDenied: false }) - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - - const result = runRebuildScript( - projectDir, - { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: 'x64' - }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status).not.toBe(0) - expect(result.stderr).toContain(join('src', 'win', 'conpty.cc')) - expect(result.stderr).toContain('`pnpm install`') - expect(result.stderr).not.toContain('Rebuild node-pty from source') - expect(existsSync(rebuildLogPath)).toBe(false) - } finally { - removeTreeSync(projectDir) - } - }) - - it('compiles node-pty when its source carries the denial', () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeNodePtyConptySource(projectDir) - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - - const result = runRebuildScript( - projectDir, - { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: 'x64' - }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status, result.stderr).toBe(0) - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toContain('node-pty') - } finally { - removeTreeSync(projectDir) - } - }) - - it('accepts a Windows rebuild whose addon carries the denial', () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status, result.stderr).toBe(0) - expect(result.stderr).not.toContain('job-breakaway denial') - } finally { - removeTreeSync(projectDir) - } - }) - - // A cross-host rebuild does not necessarily leave a win32 addon on this disk, - // and neither does a tree with no node-pty in it. That must warn, not fail an - // install that was working. - it('warns rather than fails when no addon is expected on this disk', () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] - ) - - expect(result.status, result.stderr).toBe(0) - expect(result.stderr + result.stdout).toContain('could not check the MSYS job-breakaway') - } finally { - removeTreeSync(projectDir) - } - }) - - // The other half: on the host that will run this install, a missing addon is - // not an absence to shrug at. loadNativeModule falls through to the published - // prebuild, which is the binary that leaks every MSYS pane child. - // Runs only on Windows -- nothing else can make a win32 rebuild same-host. - it.skipIf(process.platform !== 'win32')( - 'fails a same-host Windows rebuild that left no addon, naming the prebuild that would load', - () => { - const projectDir = mkTempProject() - - try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir) - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) - writeFakeLoadableNodePty(projectDir, { nativeDir: `prebuilds/win32-${process.arch}` }) - - const result = runRebuildScript( - projectDir, - { npm_config_platform: 'win32', npm_config_arch: process.arch }, - ['--platform=win32', `--arch=${process.arch}`, '--force'] - ) - - expect(result.status).not.toBe(0) - expect(result.stderr).toContain(join('build', 'Release', 'conpty.node')) - expect(result.stderr).toContain(join('prebuilds', `win32-${process.arch}`, 'conpty.node')) - } finally { - removeTreeSync(projectDir) - } - } - ) -}) diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 333c8f939ef5..726b4a0ba1bb 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -9,22 +9,10 @@ import { } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' -import { peImage } from './windows-pe-image-fixture.mjs' -/** - * The wide literal `usesCygwinRuntime` holds, as it sits in a real addon. A - * fixture addon without it is a build that predates the MSYS breakaway denial, - * which is what these tests need to be able to represent. - * - * Taken from the gate itself: a re-typed copy agrees with a stale gate by - * construction, which is the one thing these fixtures must not do. - */ -const { CYGWIN_BREAKAWAY_MARKER, CYGWIN_BREAKAWAY_MARKER_TEXT } = createRequire(import.meta.url)( - './node-pty-job-ownership.cjs' -) const { CREATION_TIME_FLAG } = createRequire(import.meta.url)( './windows-process-tree-creation-time.cjs' ) @@ -33,9 +21,6 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo const sourceInstallScriptPath = fileURLToPath( new URL('./install-electron-package-binary.mjs', import.meta.url) ) -const sourceNodePtyJobOwnershipPath = fileURLToPath( - new URL('./node-pty-job-ownership.cjs', import.meta.url) -) const sourceWindowsProcessTreeGypRebuildPath = fileURLToPath( new URL('./windows-process-tree-gyp-rebuild.mjs', import.meta.url) ) @@ -112,7 +97,6 @@ export function mkTempProject() { mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs')) copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts')) - copyScriptWithLocalModules(sourceNodePtyJobOwnershipPath, join(projectDir, 'config', 'scripts')) copyFileSync( sourceWindowsProcessTreeCreationTimePath, join(projectDir, 'config', 'scripts', 'windows-process-tree-creation-time.cjs') @@ -333,93 +317,6 @@ process.exit(result.status ?? 0) } } -export function writeFakeNodePtyConptyPayload( - projectDir, - arch, - { cygwinBreakawayDenied = true } = {} -) { - const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') - mkdirSync(releaseDir, { recursive: true }) - writeFileSync( - join(releaseDir, 'conpty.node'), - Buffer.concat([ - peImage({ arch }), - cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0) - ]) - ) - const sourceDir = join( - projectDir, - 'node_modules', - 'node-pty', - 'third_party', - 'conpty', - '0.1.0', - `win10-${arch}` - ) - mkdirSync(sourceDir, { recursive: true }) - writeFileSync(join(sourceDir, 'conpty.dll'), `conpty.dll ${arch}`) - writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) -} - -/** The C++ a Windows rebuild would compile; only the wide literal the source gate reads has to be real. */ -export function writeFakeNodePtyConptySource(projectDir, { cygwinBreakawayDenied = true } = {}) { - const sourceDir = join(projectDir, 'node_modules', 'node-pty', 'src', 'win') - mkdirSync(sourceDir, { recursive: true }) - writeFileSync( - join(sourceDir, 'conpty.cc'), - cygwinBreakawayDenied - ? `for (const wchar_t* dll : {L"${CYGWIN_BREAKAWAY_MARKER_TEXT}", L"cygwin1.dll"}) {}\n` - : 'JOB_OBJECT_LIMIT_BREAKAWAY_OK\n' - ) -} - -function writeFakeNodePtyAddon(nodePtyDir, nativeDir, { cygwinBreakawayDenied }) { - const addonDir = resolve(join(nodePtyDir, 'lib'), nativeDir) - mkdirSync(addonDir, { recursive: true }) - for (const nativeName of ['conpty', 'pty']) { - writeFileSync( - join(addonDir, `${nativeName}.node`), - Buffer.concat([ - peImage({ arch: process.arch === 'arm64' ? 'arm64' : 'x64' }), - cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0) - ]) - ) - } -} - -export function writeFakeLoadableNodePty( - projectDir, - { nativeDir = 'prebuilds/pty', ownsPtyJob = true, cygwinBreakawayDenied = true } = {} -) { - const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') - mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) - // Why a real file: the job-ownership gate reads the addon it was told about, - // because every job export predates the MSYS breakaway denial and so cannot - // distinguish a current build from one that leaks Git Bash children. - writeFakeNodePtyAddon(nodePtyDir, nativeDir, { cygwinBreakawayDenied }) - writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') - writeFileSync( - join(nodePtyDir, 'lib', 'utils.js'), - ` -exports.loadNativeModule = function loadNativeModule(nativeName) { - return { - dir: ${JSON.stringify(nativeDir)}, - module: { - nativeName, - ...(${JSON.stringify(ownsPtyJob)} - ? { - listJobProcessIds() {}, - terminateJob() {}, - assignCurrentProcessToJob() {} - } - : {}) - } - } -} -` - ) -} - export function writeFakeWindowsRegistry(projectDir) { const registryDir = join(projectDir, 'node_modules', '@orca', 'windows-registry') mkdirSync(registryDir, { recursive: true }) @@ -509,30 +406,3 @@ export function writeFakeWindowsProcessTreeWithNodeAddonApi( writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n') writeFileSync(join(nodeAddonApiDir, 'napi-inl.deprecated.h'), '// napi-inl.deprecated.h\n') } - -export function writeNodePtyPatchFile(projectDir) { - mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) - writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') -} - -export function writePatchedNodePtyBuildArtifacts( - projectDir, - { cygwinBreakawayDenied = true } = {} -) { - const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') - mkdirSync(buildDir, { recursive: true }) - if (process.platform === 'win32') { - writeFileSync( - join(buildDir, 'conpty.node'), - cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0) - ) - mkdirSync(join(buildDir, 'conpty'), { recursive: true }) - writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '') - writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '') - return - } - writeFileSync(join(buildDir, 'pty.node'), '') - if (process.platform === 'darwin') { - writeFileSync(join(buildDir, 'spawn-helper'), '') - } -} diff --git a/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs b/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs index 4f98c1b092d3..528cb848e4bc 100644 --- a/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs +++ b/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs @@ -9,7 +9,6 @@ import { mkTempProject, runRebuildScript, writeFakeElectronRebuild, - writeFakeNodePtyConptyPayload, writeFakeUsableElectronPackage, writeFakeWindowsProcessTreeWithNodeAddonApi } from './rebuild-native-deps-test-fixtures.mjs' @@ -75,7 +74,6 @@ describe.runIf(process.platform === 'win32')('rebuild-native-deps stale addon un try { writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, process.arch) writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) holder = await stageLoadedStaleAddon(projectDir) diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 68de5d49e218..a8cd655e3272 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -19,28 +19,17 @@ */ import { rebuild } from '@electron/rebuild' -import { execFileSync, spawnSync } from 'node:child_process' +import { spawnSync } from 'node:child_process' import { ensureWindowsProcessTreeCommandLinePatch, inspectWindowsProcessTreeAddon, stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' -import { - copyFileSync, - existsSync, - globSync, - mkdirSync, - readdirSync, - readFileSync, - writeFileSync -} from 'node:fs' -import { createRequire } from 'node:module' +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { platform as osPlatform } from 'node:os' import { join, resolve } from 'node:path' -const requireLocal = createRequire(import.meta.url) - const projectDir = process.cwd() let cliOptions try { @@ -66,7 +55,6 @@ const electronVersion = JSON.parse( ).version const ignoreModules = ['cpu-features'] -const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] if (ignoreModules.length > 0) { console.log(`[rebuild] Skipping optional Electron rebuild modules: ${ignoreModules.join(', ')}`) @@ -77,7 +65,6 @@ if (ignoreModules.length > 0) { // rebuild via `onlyModules` ensures they're recompiled against Electron's Node // ABI regardless of the package manager's store layout. const NATIVE_MODULES = [ - 'node-pty', 'cpu-features', ...(rebuildPlatform === 'win32' ? ['@orca/windows-registry', '@vscode/windows-process-tree'] : []) ] @@ -89,13 +76,14 @@ const forceRebuild = let modulesToRebuild = onlyModules ensureElectronPackageInstalled() -restoreNodePtyWindowsConptyRuntime() -const patchedNodePtyRebuildReason = forceRebuild ? null : getPatchedNodePtyRebuildReason() +// An empty allowlist invokes @electron/rebuild's default scanner. +if (onlyModules.length === 0) { + console.log('[rebuild] No native modules selected; Electron package is ready.') + process.exit(0) +} -if (patchedNodePtyRebuildReason) { - console.log(`[rebuild] ${patchedNodePtyRebuildReason}`) -} else if (!forceRebuild) { +if (!forceRebuild) { // Why: independent probes avoid rebuilding healthy Windows DLLs that may already be loaded and locked. const probes = onlyModules.map((moduleName) => ({ moduleName, @@ -116,42 +104,11 @@ if (patchedNodePtyRebuildReason) { console.log(`[rebuild] Forcing native rebuild for ${rebuildPlatform}-${rebuildArch}.`) } -// Why: cpu-features ships without `buildcheck.gypi`; its own `install` script -// generates it by running `node buildcheck.js > buildcheck.gypi` before -// node-gyp. @electron/rebuild with `force: true` invokes node-gyp directly -// and bypasses that install hook, so if the file is missing (fresh install, -// store prune, or a prior failed run) node-gyp aborts with -// "buildcheck.gypi not found". Regenerate it here before rebuilding. -if (!ignoreModules.includes('cpu-features')) { - const cpuFeatureDirs = globSync('node_modules/.pnpm/cpu-features@*/node_modules/cpu-features', { - cwd: projectDir - }) - for (const relDir of cpuFeatureDirs) { - const dir = resolve(projectDir, relDir) - const gypiPath = resolve(dir, 'buildcheck.gypi') - if (existsSync(gypiPath)) { - continue - } - try { - const out = execFileSync(process.execPath, ['buildcheck.js'], { - cwd: dir, - encoding: 'utf8' - }) - writeFileSync(gypiPath, out) - console.log(`[rebuild] Generated ${relDir}/buildcheck.gypi`) - } catch (/** @type {any} */ err) { - console.error(`[rebuild] Failed to generate ${relDir}/buildcheck.gypi:`, err?.message ?? err) - process.exit(1) - } - } -} - try { // Why inside the try: the patch guard deletes a stale addon binary, and that // delete fails EPERM when the addon is loaded -- exactly the running-Orca case // the catch below is written for. Outside, it aborted `pnpm install` with a // raw stack instead of the "close running Orca/Electron processes" message. - assertNodePtyConptySourceDeniesMsysBreakaway() if ( rebuildPlatform === 'win32' && modulesToRebuild.includes('@vscode/windows-process-tree') && @@ -176,9 +133,7 @@ try { // Node before postinstall runs this script. force: true }) - restoreNodePtyWindowsConptyRuntime() assertWindowsProcessTreeAddonIsPatched() - assertNodePtyConptyDeniesMsysBreakaway() } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) if (isWindowsNativeLockError(err)) { @@ -232,79 +187,6 @@ function assertWindowsProcessTreeAddonIsPatched() { ) } -/** - * The other half of the same problem, for the addon this rebuild just produced. - * - * The Electron probe below carries the marker check too, but it is skipped - * whenever the Electron package binary is unusable -- and "covered by another - * path" is not "this path checks". Reading the binary needs neither a loadable - * Electron nor an executable target arch, so it runs here regardless. - * - * Absent is fatal on the host that will run this install: loadNativeModule - * falls through to prebuilds/win32-, and the published prebuild predates - * the denial, so the app would load it with nothing said. A cross-host rebuild - * does not necessarily leave a win32 addon on this disk, and that must not fail - * an install that was working. - */ -function assertNodePtyConptyDeniesMsysBreakaway() { - if (rebuildPlatform !== 'win32' || !modulesToRebuild.includes('node-pty')) { - return - } - const { assertRebuiltConptyDeniesMsysBreakaway } = requireLocal('./node-pty-job-ownership.cjs') - assertRebuiltConptyDeniesMsysBreakaway({ - nodePtyDir: resolve(projectDir, 'node_modules', 'node-pty'), - rebuildArch, - crossHost: isCrossHostRebuild - }) -} - -/** - * Refuse to compile node-pty source that cannot yield the denial. Why before the - * rebuild: the gate above would spend the compile and then advise "rebuild from - * source" -- the step that just ran. Only the source is read; the addon is not - * touched, so a locked binary cannot turn this into a spurious EPERM. - */ -function assertNodePtyConptySourceDeniesMsysBreakaway() { - if (rebuildPlatform !== 'win32' || !modulesToRebuild.includes('node-pty')) { - return - } - const { assertNodePtySourceDeniesMsysBreakaway } = requireLocal('./node-pty-job-ownership.cjs') - assertNodePtySourceDeniesMsysBreakaway({ - nodePtyDir: resolve(projectDir, 'node_modules', 'node-pty') - }) -} - -function restoreNodePtyWindowsConptyRuntime() { - if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) { - return - } - - const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') - if (!existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))) { - return - } - const conptyRoot = join(nodePtyDir, 'third_party', 'conpty') - const sourceDir = readdirSync(conptyRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => join(conptyRoot, entry.name, `win10-${rebuildArch}`)) - .find((candidate) => existsSync(candidate)) - if (!sourceDir) { - throw new Error(`node-pty has no ConPTY runtime payload for win10-${rebuildArch}`) - } - - const runtimeDir = join(nodePtyDir, 'build', 'Release', 'conpty') - mkdirSync(runtimeDir, { recursive: true }) - for (const filename of NODE_PTY_CONPTY_RUNTIME_FILES) { - const sourceFile = join(sourceDir, filename) - if (!existsSync(sourceFile)) { - throw new Error(`node-pty is missing ${sourceFile}`) - } - copyFileSync(sourceFile, join(runtimeDir, filename)) - } - // Why: @electron/rebuild bypasses node-pty's postinstall step that normally copies these DLLs. - console.log(`[rebuild] Restored node-pty ConPTY runtime files for win10-${rebuildArch}.`) -} - function ensureElectronPackageInstalled() { repairElectronPathFile() if (electronPackageIsUsable()) { @@ -509,53 +391,6 @@ function getElectronExecutablePath() { : resolve(electronPackageDir, 'dist', platformPath) } -function getPatchedNodePtyRebuildReason() { - if (!requiresPatchedNodePtySourceBuild()) { - return null - } - - // Why: Orca patches node-pty's native Unix spawn path and Windows job-object - // exports; upstream prebuilds can load while missing those patches. - const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') - const artifactPaths = - rebuildPlatform === 'win32' - ? [ - resolve(nodePtyDir, 'build', 'Release', 'conpty.node'), - ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => - resolve(nodePtyDir, 'build', 'Release', 'conpty', filename) - ) - ] - : [ - resolve(nodePtyDir, 'build', 'Release', 'pty.node'), - ...(osPlatform() === 'darwin' - ? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')] - : []) - ] - const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath)) - - if (!missingArtifact) { - return null - } - - return 'Patched node-pty build artifacts are missing; rebuilding from source.' -} - -function requiresPatchedNodePtySourceBuild() { - if (!onlyModules.includes('node-pty')) { - return false - } - if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) { - return false - } - - const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch') - if (!existsSync(nodePtyPatchPath)) { - return false - } - - return existsSync(resolve(projectDir, 'node_modules', 'node-pty')) -} - function probeElectronNativeModules(moduleNames) { if (!electronPackageIsUsable()) { return { ok: false, status: null, stderr: 'Electron package binary is unavailable.' } @@ -564,12 +399,9 @@ function probeElectronNativeModules(moduleNames) { const probeSource = ` const { createRequire } = require('node:module') -const { existsSync } = require('node:fs') -const { release } = require('node:os') const { resolve } = require('node:path') const projectRequire = createRequire(resolve(process.cwd(), 'package.json')) const moduleNames = ${JSON.stringify(moduleNames)} -const requirePatchedNodePtySourceBuild = ${JSON.stringify(requiresPatchedNodePtySourceBuild())} const failures = [] for (const moduleName of moduleNames) { @@ -592,33 +424,6 @@ function loadNativeModule(moduleName) { registry.getRegistryKey(registry.HK.CU, 'Environment') return } - if (moduleName === 'node-pty') { - projectRequire('node-pty') - const { assertNodePtyJobOwnership, nodePtyAddonPath } = projectRequire( - './config/scripts/node-pty-job-ownership.cjs' - ) - const { loadNativeModule } = projectRequire('node-pty/lib/utils') - const nativeName = getNodePtyNativeModuleName() - const native = loadNativeModule(nativeName) - assertNodePtyWindowsConptyRuntime(native.dir) - assertNodePtyJobOwnership({ - nativeName, - native, - addonPath: nodePtyAddonPath( - projectRequire.resolve('node-pty/lib/utils'), - native, - nativeName - ) - }) - if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) { - throw new Error( - 'node-pty resolved to ' + - native.dir + - '; expected build/Release so Orca\\'s node-pty patch is active' - ) - } - return - } if (moduleName === '@vscode/windows-process-tree') { // The tarball prebuilt loads under Electron too -- the addon is N-API, so // a bare require proves nothing about which source it was built from. @@ -631,39 +436,6 @@ function loadNativeModule(moduleName) { projectRequire(moduleName) } -function assertNodePtyWindowsConptyRuntime(nativeDir) { - if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) { - return - } - const runtimeDir = resolve( - process.cwd(), - 'node_modules', - 'node-pty', - 'build', - 'Release', - 'conpty' - ) - for (const filename of ${JSON.stringify(NODE_PTY_CONPTY_RUNTIME_FILES)}) { - const runtimeFile = resolve(runtimeDir, filename) - if (!existsSync(runtimeFile)) { - throw new Error('node-pty ConPTY runtime file is missing: ' + runtimeFile) - } - } -} - -function isNodePtyReleaseBuildDir(nativeDir) { - return typeof nativeDir === 'string' && nativeDir.replace(/\\\\/g, '/').includes('build/Release/') -} - -function getNodePtyNativeModuleName() { - if (process.platform !== 'win32') { - return 'pty' - } - const match = /(\\d+)\\.(\\d+)\\.(\\d+)/g.exec(release()) - const buildNumber = match && match.length === 4 ? Number.parseInt(match[3], 10) : 0 - return buildNumber >= 18309 ? 'conpty' : 'pty' -} - function formatError(error) { return error instanceof Error ? error.message : String(error) } @@ -695,9 +467,7 @@ function isWindowsNativeLockError(error) { const text = [error?.message, error?.stack, error?.stdout, error?.stderr] .filter(Boolean) .join('\n') - return /(?:EPERM|operation not permitted)[\s\S]*(?:unlink|\.node|conpty\.node|pty\.node)/i.test( - text - ) + return /(?:EPERM|operation not permitted)[\s\S]*(?:unlink|\.node)/i.test(text) } function isPostinstall() { diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index d4db08d3e2e9..53000dce19cb 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -235,3 +235,40 @@ describe('rebuild-native-deps Electron install fallback', () => { } }) }) + +describe('rebuild-native-deps empty native selection', () => { + it.each([ + { name: 'ordinary rebuild', args: ['--platform=linux'], env: {} }, + { name: 'forced rebuild', args: ['--platform=linux', '--force'], env: {} }, + { + name: 'environment-forced rebuild', + args: ['--platform=linux'], + env: { ORCA_FORCE_NATIVE_REBUILD: '1' } + }, + { + name: 'cross-architecture rebuild', + args: ['--platform=linux', `--arch=${process.arch === 'arm64' ? 'x64' : 'arm64'}`], + env: {} + } + ])('installs Electron without scanning unrelated addons: $name', ({ args, env }) => { + const projectDir = mkTempProject() + try { + const logPath = join(projectDir, 'rebuild.log') + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir) + writeFakeElectronExtractor(projectDir, { createExecutable: true }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + const result = runRebuildScript(projectDir, { ...env, ORCA_REBUILD_TEST_LOG: logPath }, args) + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe( + 'electron' + ) + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toBe( + 'download attempted\n' + ) + expect(existsSync(logPath)).toBe(false) + } finally { + removeTreeSync(projectDir) + } + }) +}) diff --git a/config/scripts/run-bundled-runtime-tests.mjs b/config/scripts/run-bundled-runtime-tests.mjs index f10e7532af84..9b91d69cd57b 100644 --- a/config/scripts/run-bundled-runtime-tests.mjs +++ b/config/scripts/run-bundled-runtime-tests.mjs @@ -59,6 +59,9 @@ run([ 'src/main/browser/wsl-browser-network-bun.integration.test.ts', 'src/main/cli/windows-cli-bun.integration.test.ts', 'src/cli/cli-bun-runtime.integration.test.ts', + 'src/cli/runtime/cli-bun-environment.integration.test.ts', + 'src/shared/child-process/fork-process-bun.integration.test.ts', + 'src/shared/bun-owned-runtime-args.integration.test.ts', 'src/cli/runtime/serve-bun-ipc.integration.test.ts', 'src/cli/cli-bin.integration.test.ts', 'src/cli/cli-bin-signals.integration.test.ts', diff --git a/config/scripts/run-daemon-shutdown-descendants-docker.mjs b/config/scripts/run-daemon-shutdown-descendants-docker.mjs index 30af813f7bd3..c69db33b2b58 100644 --- a/config/scripts/run-daemon-shutdown-descendants-docker.mjs +++ b/config/scripts/run-daemon-shutdown-descendants-docker.mjs @@ -1,9 +1,10 @@ -import { execFileSync, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { build } from 'esbuild' +import { materializeRuntime } from './build-orcad-bun.mjs' +import { runProcessSync } from './script-child-process.mjs' const repo = resolve(fileURLToPath(new URL('../..', import.meta.url))) const docker = @@ -49,10 +50,16 @@ const platform = process.env.ORCA_DOCKER_PLATFORM ?? (process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64') function gitSource(relativePath, ref) { - return execFileSync('git', ['show', `${ref}:${relativePath}`], { + const result = runProcessSync({ + program: 'git', + args: ['show', `${ref}:${relativePath}`], cwd: repo, - encoding: 'utf8' + maxOutputBytes: 1024 * 1024 }) + if (result.code !== 0 || result.outputTruncated) { + throw new Error(`Could not read baseline source ${ref}:${relativePath}: ${result.stderr}`) + } + return result.stdout } function baselinePlugin(ref) { @@ -78,8 +85,8 @@ async function bundle(outfile, plugin) { bundle: true, platform: 'node', format: 'cjs', - target: 'node22', - external: ['node-pty'], + target: 'es2024', + external: ['bun:ffi'], plugins: plugin ? [plugin] : [], outfile, sourcemap: false, @@ -88,24 +95,33 @@ async function bundle(outfile, plugin) { } function runDocker(args, allowFailure = false) { - const result = spawnSync(docker, args, { + const result = runProcessSync({ + program: docker, + args, cwd: repo, - encoding: 'utf8', - maxBuffer: 16 * 1024 * 1024, + maxOutputBytes: 16 * 1024 * 1024, env: dockerEnv }) - if (result.error) { - throw result.error - } - if (result.status !== 0 && !allowFailure) { + if (result.code !== 0 && !allowFailure) { process.stdout.write(result.stdout ?? '') process.stderr.write(result.stderr ?? '') - throw new Error(`docker ${args[0]} failed with ${result.status}`) + throw new Error(`docker ${args[0]} failed with ${result.code}`) } return result } +if (!['linux/amd64', 'linux/arm64'].includes(platform)) { + rmSync(temp, { recursive: true, force: true }) + throw new Error(`Unsupported daemon oracle platform: ${platform}`) +} + +let imageBuilt = false try { + for (const name of ['Dockerfile', 'run-case.sh', 'fixture.cjs']) { + copyFileSync(join(dockerDir, name), join(temp, name)) + } + const target = platform === 'linux/arm64' ? 'linux-arm64-glibc' : 'linux-x64-glibc' + await materializeRuntime(target, join(temp, 'bun-runtime')) const candidate = join(temp, 'candidate.cjs') await bundle(candidate) const bundles = [['candidate', candidate]] @@ -115,7 +131,8 @@ try { bundles.unshift(['baseline', baseline]) } - runDocker(['build', '--platform', platform, '-t', image, dockerDir]) + runDocker(['build', '--platform', platform, '-t', image, temp]) + imageBuilt = true for (const [mode, bundlePath] of bundles) { const result = runDocker( [ @@ -135,8 +152,8 @@ try { ) process.stdout.write(result.stdout ?? '') process.stderr.write(result.stderr ?? '') - if (result.status !== 0) { - throw new Error(`${mode} daemon-shutdown oracle failed with ${result.status}`) + if (result.code !== 0) { + throw new Error(`${mode} daemon-shutdown oracle failed with ${result.code}`) } } console.log( @@ -145,6 +162,8 @@ try { : 'Linux daemon shutdown descendant oracle passed (candidate only): descendant reaped, canary survives.' ) } finally { - runDocker(['image', 'rm', image], true) + if (imageBuilt) { + runDocker(['image', 'rm', image], true) + } rmSync(temp, { recursive: true, force: true }) } diff --git a/config/scripts/run-daemon-shutdown-descendants-docker.test.mjs b/config/scripts/run-daemon-shutdown-descendants-docker.test.mjs new file mode 100644 index 000000000000..7190fb9500ab --- /dev/null +++ b/config/scripts/run-daemon-shutdown-descendants-docker.test.mjs @@ -0,0 +1,92 @@ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ target: '', builds: [], runs: [], gitReads: [] })) +vi.mock('./build-orcad-bun.mjs', () => ({ + materializeRuntime: async (target, destination) => { + state.target = target + writeFileSync(destination, 'verified-runtime-fixture') + } +})) +vi.mock('./script-child-process.mjs', () => ({ + runProcessSync: ({ program, args }) => { + if (program === 'git') { + state.gitReads.push(args[1]) + return { + code: 0, + stdout: readFileSync(args[1].split(':').slice(1).join(':'), 'utf8'), + stderr: '' + } + } + if (args[0] === 'build') { + const context = args.at(-1) + state.builds.push({ + runtime: readFileSync(join(context, 'bun-runtime'), 'utf8'), + dockerfile: readFileSync(join(context, 'Dockerfile'), 'utf8'), + fixture: readFileSync(join(context, 'fixture.cjs'), 'utf8'), + candidate: readFileSync(join(context, 'candidate.cjs'), 'utf8'), + baseline: existsSync(join(context, 'baseline.cjs')), + context + }) + } + if (args[0] === 'run') { + state.runs.push(args) + } + return { code: 0, stdout: '', stderr: '' } + } +})) + +const argv = process.argv +const platform = process.env.ORCA_DOCKER_PLATFORM +afterEach(() => { + process.argv = argv + if (platform === undefined) { + delete process.env.ORCA_DOCKER_PLATFORM + } else { + process.env.ORCA_DOCKER_PLATFORM = platform + } + state.builds.length = 0 + state.runs.length = 0 + state.gitReads.length = 0 + state.target = '' + vi.resetModules() +}) + +async function run(target, args = []) { + process.env.ORCA_DOCKER_PLATFORM = target + process.argv = ['node', 'runner', ...args] + await import('./run-daemon-shutdown-descendants-docker.mjs') +} + +describe('Bun daemon descendant shutdown oracle', () => { + it.each([ + ['linux/amd64', 'linux-x64-glibc'], + ['linux/arm64', 'linux-arm64-glibc'] + ])('stages the verified runtime and real provider for %s', async (platform, target) => { + await run(platform) + expect(state.target).toBe(target) + const staged = state.builds[0] + expect(staged.runtime).toBe('verified-runtime-fixture') + expect(staged.dockerfile).not.toMatch(/node-pty|npm|FROM node:/) + expect(staged.fixture).toContain('spawnBunPty({') + expect(staged.candidate).toContain('spawnBunPty') + expect(staged.candidate).not.toMatch(/require\(["']node-pty["']\)/) + expect(state.runs[0]).toContain('candidate') + expect(existsSync(staged.context)).toBe(false) + }) + + it('compares selected baseline shutdown sources on the same Bun provider', async () => { + await run('linux/amd64', ['--baseline', 'baseline-ref']) + expect(state.gitReads).toHaveLength(5) + expect(state.gitReads.every((source) => source.startsWith('baseline-ref:'))).toBe(true) + expect(state.builds[0].baseline).toBe(true) + expect(state.runs.map((args) => args.at(-1))).toEqual(['baseline', 'candidate']) + }) + + it('rejects unsupported architectures before materializing a runtime', async () => { + await expect(run('linux/386')).rejects.toThrow('Unsupported daemon oracle platform') + expect(state.target).toBe('') + expect(state.builds).toEqual([]) + }) +}) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index c520083cb6ad..f99efb08f2e1 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -16,6 +16,8 @@ import net from 'node:net' import { createRequire } from 'node:module' import path from 'node:path' +import { buildTerminalDaemon } from './build-terminal-daemon.mjs' +import { buildCliRuntime } from './build-cli-runtime.mjs' import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs' import { DEV_BUNDLE_MARKER_FILENAME, @@ -646,6 +648,10 @@ if (!userPassedPort && !isHelpOrVersion) { } } prepareDevWebClient() +if (!isHelpOrVersion) { + await buildCliRuntime(process.platform, process.arch) + await buildTerminalDaemon() +} const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras] const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], { stdio: 'inherit', diff --git a/config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs b/config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs deleted file mode 100644 index 17f2d678e4e1..000000000000 --- a/config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs +++ /dev/null @@ -1,94 +0,0 @@ -import { existsSync } from 'node:fs' -import { spawnSync } from 'node:child_process' -import { isAbsolute, relative, resolve, sep } from 'node:path' -import { pathToFileURL } from 'node:url' - -const FLOOR_IMAGE = 'ubuntu:20.04' -const LINUX_EXECUTABLE = 'orca-ide' -const FLOOR_PACKAGES = [ - 'ca-certificates', - 'libasound2', - 'libatspi2.0-0', - 'libdrm2', - 'libgbm1', - 'libgtk-3-0', - 'libnss3', - 'libx11-xcb1', - 'libxkbcommon0', - 'libxss1' -] - -function workspaceRelativePath(workspaceDirectory, inputPath) { - const absolutePath = resolve(workspaceDirectory, inputPath) - const relativePath = relative(workspaceDirectory, absolutePath) - if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`)) { - throw new Error('linux-packaged-node-pty-floor-app-directory-invalid') - } - return relativePath.split(sep).join('/') -} - -export function packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }) { - const relativeAppDirectory = workspaceRelativePath(workspaceDirectory, appDirectory) - const containerAppDirectory = `/workspace/${relativeAppDirectory}` - const command = [ - 'export DEBIAN_FRONTEND=noninteractive', - 'apt-get update -qq', - `apt-get install -y -qq ${FLOOR_PACKAGES.join(' ')} >/dev/null`, - `ELECTRON_RUN_AS_NODE=1 ${containerAppDirectory}/${LINUX_EXECUTABLE} ` + - '/workspace/config/scripts/linux-packaged-node-pty-floor-child.cjs ' + - `${containerAppDirectory}/resources` - ].join(' && ') - return [ - 'run', - '--rm', - '--mount', - `type=bind,src=${workspaceDirectory},dst=/workspace,readonly`, - '--workdir', - '/workspace', - FLOOR_IMAGE, - '/bin/bash', - '-lc', - command - ] -} - -function parseAppDirectory(argv) { - const index = argv.indexOf('--app-dir') - const value = index !== -1 ? argv[index + 1] : undefined - if (!value || isAbsolute(value) || value.startsWith('-')) { - throw new Error('Usage: run-linux-packaged-node-pty-floor-smoke.mjs --app-dir ') - } - return value -} - -export function runPackagedNodePtyFloorSmoke({ - workspaceDirectory = process.cwd(), - appDirectory, - spawn = spawnSync -}) { - if (process.platform !== 'linux') { - throw new Error('linux-packaged-node-pty-floor-smoke-requires-linux') - } - const absoluteAppDirectory = resolve(workspaceDirectory, appDirectory) - if (!existsSync(resolve(absoluteAppDirectory, LINUX_EXECUTABLE))) { - throw new Error(`linux-packaged-node-pty-floor-executable-missing: ${absoluteAppDirectory}`) - } - const result = spawn( - 'docker', - packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }), - { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 } - ) - if (result.error || result.signal || result.status !== 0) { - const detail = - result.error?.message || - result.stderr?.trim() || - `status-${result.status}-signal-${result.signal ?? 'none'}` - throw new Error(`linux-packaged-node-pty-floor-smoke-failed: ${detail}`) - } - process.stdout.write(result.stdout) -} - -const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url -if (isMain) { - runPackagedNodePtyFloorSmoke({ appDirectory: parseAppDirectory(process.argv.slice(2)) }) -} diff --git a/config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs b/config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs deleted file mode 100644 index c04aced995f7..000000000000 --- a/config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { packagedNodePtyFloorDockerArgs } from './run-linux-packaged-node-pty-floor-smoke.mjs' - -describe('packaged node-pty Linux floor smoke', () => { - it('mounts the exact package read-only in an Ubuntu 20.04 container', () => { - const args = packagedNodePtyFloorDockerArgs({ - workspaceDirectory: '/repo', - appDirectory: 'dist/linux-arm64-unpacked' - }) - - expect(args).toContain('type=bind,src=/repo,dst=/workspace,readonly') - expect(args).toContain('ubuntu:20.04') - expect(args.at(-1)).toContain('/workspace/dist/linux-arm64-unpacked/orca-ide') - expect(args.at(-1)).toContain('ELECTRON_RUN_AS_NODE=1') - }) - - it('rejects an app directory outside the workspace', () => { - expect(() => - packagedNodePtyFloorDockerArgs({ - workspaceDirectory: '/repo', - appDirectory: '../outside' - }) - ).toThrow('linux-packaged-node-pty-floor-app-directory-invalid') - }) - - it('loads node-pty from packaged runtime resources', () => { - const childSource = readFileSync( - new URL('./linux-packaged-node-pty-floor-child.cjs', import.meta.url), - 'utf8' - ) - - expect(childSource).toContain("join(resourcesDirectory, 'node_modules', 'node-pty')") - expect(childSource).not.toContain("join(resourcesDirectory, 'app.asar'") - }) -}) diff --git a/config/scripts/run-linux-packaged-terminal-floor-smoke.mjs b/config/scripts/run-linux-packaged-terminal-floor-smoke.mjs new file mode 100644 index 000000000000..0e9a143d7827 --- /dev/null +++ b/config/scripts/run-linux-packaged-terminal-floor-smoke.mjs @@ -0,0 +1,120 @@ +import { build } from 'esbuild' +import { randomUUID } from 'node:crypto' +import { createRequire } from 'node:module' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ROOT = resolve(fileURLToPath(new URL('../..', import.meta.url))) +const PACKAGES = + 'ca-certificates libasound2 libatspi2.0-0 libdrm2 libgbm1 libgtk-3-0 libnss3 libx11-xcb1 libxkbcommon0 libxss1' +const CHILD = `require(process.argv[1]).qualifyPackagedTerminal(process.argv[2]).then(report => console.log(JSON.stringify(report)), error => { console.error(error); process.exitCode = 1 })` + +export async function buildPackagedTerminalFloorFixture(outputDirectory) { + const outfile = join(outputDirectory, 'packaged-terminal-floor.cjs') + await build({ + entryPoints: [join(ROOT, 'src/main/daemon/linux-packaged-terminal-floor-fixture.ts')], + outfile, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node24', + external: ['electron'], + logLevel: 'silent' + }) + return outfile +} + +export function packagedTerminalFloorDockerArgs({ appDirectory, fixtureDirectory, containerName }) { + for (const path of [appDirectory, fixtureDirectory]) { + if (path.includes(',')) { + throw new Error('Docker bind paths must not contain commas') + } + } + return [ + 'run', + '--rm', + ...(containerName ? ['--name', containerName] : []), + '--network', + 'bridge', + '--mount', + `type=bind,src=${resolve(appDirectory)},dst=/artifact,readonly`, + '--mount', + `type=bind,src=${resolve(fixtureDirectory)},dst=/qualification,readonly`, + '--env', + 'ORCA_BACKGROUND_LAUNCH=1', + '--env', + 'ELECTRON_RUN_AS_NODE=1', + 'ubuntu:20.04', + '/bin/bash', + '-ec', + `export DEBIAN_FRONTEND=noninteractive; apt-get update -qq; apt-get install -y -qq ${PACKAGES} >/dev/null; exec /artifact/orca-ide -e "$1" /qualification/packaged-terminal-floor.cjs /artifact/resources`, + 'terminal-floor', + CHILD + ] +} + +export async function runPackagedTerminalFloorSmoke(appDirectory) { + if (process.platform !== 'linux') { + throw new Error('Packaged Linux floor smoke requires Linux') + } + const app = resolve(appDirectory) + for (const path of [ + 'orca-ide', + 'resources/cli-runtime/bun-runtime', + 'resources/terminal-daemon/daemon-entry.js' + ]) { + if (!existsSync(join(app, path))) { + throw new Error(`Missing packaged artifact: ${join(app, path)}`) + } + } + const fixtureDirectory = mkdtempSync(join(tmpdir(), 'orca-terminal-floor-runner-')) + try { + const fixture = await buildPackagedTerminalFloorFixture(fixtureDirectory) + const { runProcess } = createRequire(import.meta.url)(fixture) + const containerName = `orca-terminal-floor-${randomUUID()}` + let result + let cleanup + try { + result = await runProcess({ + program: 'docker', + args: packagedTerminalFloorDockerArgs({ + appDirectory: app, + fixtureDirectory, + containerName + }), + timeoutMs: 300_000, + maxOutputBytes: 8 * 1024 * 1024 + }) + } finally { + // A terminated Docker client does not stop its container. + cleanup = await runProcess({ + program: 'docker', + args: ['rm', '--force', containerName], + timeoutMs: 10_000, + maxOutputBytes: 8192 + }) + } + if (cleanup.timedOut || (cleanup.code !== 0 && !cleanup.stderr.includes('No such container'))) { + throw new Error( + `Could not remove qualification container ${containerName}: ${cleanup.stderr}` + ) + } + if (result.timedOut || result.outputTruncated || result.code !== 0) { + throw new Error( + `Packaged terminal floor smoke failed: ${result.stderr || `exit=${result.code} timeout=${result.timedOut}`}` + ) + } + process.stdout.write(result.stdout) + } finally { + rmSync(fixtureDirectory, { recursive: true, force: true }) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + if (process.argv[2] !== '--app-dir' || !process.argv[3]) { + throw new Error('Usage: run-linux-packaged-terminal-floor-smoke.mjs --app-dir ') + } + await runPackagedTerminalFloorSmoke(process.argv[3]) +} diff --git a/config/scripts/run-linux-packaged-terminal-floor-smoke.test.mjs b/config/scripts/run-linux-packaged-terminal-floor-smoke.test.mjs new file mode 100644 index 000000000000..78f09d1a847f --- /dev/null +++ b/config/scripts/run-linux-packaged-terminal-floor-smoke.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { packagedTerminalFloorDockerArgs } from './run-linux-packaged-terminal-floor-smoke.mjs' + +describe('packaged terminal Linux floor qualification', () => { + it('isolates exact packaged artifacts from writable fixture state on Ubuntu 20.04', () => { + const args = packagedTerminalFloorDockerArgs({ + appDirectory: '/tmp/app with spaces', + fixtureDirectory: '/tmp/gate' + }) + expect(args).toContain('type=bind,src=/tmp/app with spaces,dst=/artifact,readonly') + expect(args).toContain('type=bind,src=/tmp/gate,dst=/qualification,readonly') + expect(args).toContain('ubuntu:20.04') + expect(args).toContain('ELECTRON_RUN_AS_NODE=1') + expect(args).toContain('ORCA_BACKGROUND_LAUNCH=1') + expect(args.at(-3)).toContain('/artifact/orca-ide') + expect(args.at(-3)).toContain('/artifact/resources') + }) + it('does not splice caller paths into a shell command', () => { + const args = packagedTerminalFloorDockerArgs({ + appDirectory: '/tmp/$(unexpected)', + fixtureDirectory: '/tmp/gate' + }) + expect(args.at(-3)).not.toContain('unexpected') + expect(args).toContain('type=bind,src=/tmp/$(unexpected),dst=/artifact,readonly') + expect(() => + packagedTerminalFloorDockerArgs({ + appDirectory: '/tmp/app,readonly', + fixtureDirectory: '/tmp/gate' + }) + ).toThrow('commas') + }) + it('runs the exact packaged terminal on both release architectures before upload', () => { + const workflow = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8')) + const steps = workflow.jobs.build.steps + const gate = steps.find( + (step) => step.name === 'Qualify packaged Bun terminal on the Linux floor' + ) + expect(gate.if).toContain("matrix.platform == 'linux-x64'") + expect(gate.if).toContain("matrix.platform == 'linux-arm64'") + expect(gate.with.command).toContain('run-linux-packaged-terminal-floor-smoke.mjs') + expect(gate.with.command).toContain('${{ matrix.unpacked_dir }}') + expect(steps.indexOf(gate)).toBeLessThan( + steps.findIndex((step) => step.uses?.startsWith('actions/upload-artifact')) + ) + expect( + packagedTerminalFloorDockerArgs({ + appDirectory: '/app', + fixtureDirectory: '/gate', + containerName: 'owned-probe' + }) + ).toContain('owned-probe') + }) +}) diff --git a/config/scripts/script-module-dependencies.test.mjs b/config/scripts/script-module-dependencies.test.mjs index 68482954ee3e..117d00a4fe2f 100644 --- a/config/scripts/script-module-dependencies.test.mjs +++ b/config/scripts/script-module-dependencies.test.mjs @@ -8,7 +8,6 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' @@ -108,18 +107,4 @@ describe('copyScriptWithLocalModules', () => { copyScriptWithLocalModules(join(sourceDir, 'entry.mjs'), destinationDir) expect(existsSync(join(destinationDir, 'entry.mjs'))).toBe(true) }) - - // The real tree this stages: the packaged-addon gate reaches its PE reader by - // require, so a walker that missed it would break every rebuild fixture. - it('stages the node-pty job-ownership gate with everything it requires', () => { - const destinationDir = join(mkdtempSync(join(fixtureDir, 'dest-')), 'scripts') - copyScriptWithLocalModules( - fileURLToPath(new URL('./node-pty-job-ownership.cjs', import.meta.url)), - destinationDir - ) - expect(readdirSync(destinationDir).sort()).toEqual([ - 'node-pty-job-ownership.cjs', - 'windows-pe-machine.cjs' - ]) - }) }) diff --git a/config/scripts/skill-sharing-release-workflow.test.mjs b/config/scripts/skill-sharing-release-workflow.test.mjs index aa823b20bdd6..43a843393b68 100644 --- a/config/scripts/skill-sharing-release-workflow.test.mjs +++ b/config/scripts/skill-sharing-release-workflow.test.mjs @@ -129,7 +129,7 @@ describe('skill-sharing release workflow', () => { it('loads each exact Linux package on the glibc 2.31 floor', () => { const build = workflow.jobs.build - const smoke = stepNamed(build, 'Load packaged node-pty on the Linux floor') + const smoke = stepNamed(build, 'Qualify packaged Bun terminal on the Linux floor') const linuxEntries = build.strategy.matrix.include.filter(({ platform }) => platform.startsWith('linux-') ) @@ -142,7 +142,7 @@ describe('skill-sharing release workflow', () => { }) ]) expect(smoke.if).toContain("matrix.platform == 'linux-x64'") - expect(smoke.with.command).toContain('run-linux-packaged-node-pty-floor-smoke.mjs') + expect(smoke.with.command).toContain('run-linux-packaged-terminal-floor-smoke.mjs') expect(smoke.with.command).toContain('${{ matrix.unpacked_dir }}') }) }) diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index ebfdb2b88161..525df98f18a9 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -139,8 +139,7 @@ function findFloorViolations(needs, filePath = '') { // On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs — // glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that // imports them must keep the DSO in DT_NEEDED or they will not resolve on the -// floor. This guards config/patches/node-pty@1.1.0.patch's forced -// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned +// floor. Keep `-l:libutil.so.1` when pinning these symbols: without it, the pinned // openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time // (so the version-floor check passes) yet fail to load on 20.04. libpthread // (pthread_sigmask) is intentionally omitted — the Node/Electron host always @@ -488,7 +487,7 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { `[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` + `will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` + 'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' + - 'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).' + 'toolchain, or pin relocated symbols and retain their provider libraries in DT_NEEDED.' ) } diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs index 8f5e802825c3..d6d5c3d8c031 100644 --- a/config/scripts/verify-packaged-daemon-entry.cjs +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -2,39 +2,38 @@ const { existsSync } = require('node:fs') const { spawnSync } = require('node:child_process') const { join } = require('node:path') -// Why: `asarUnpack` in config/electron-builder.config.cjs lists -// out/main/daemon-entry.js on every platform, and the packaged daemon fork -// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A -// missing entry means the package layout regressed, so the check throws -// instead of skipping — a silent skip false-passed exactly the layout bug -// this gate exists to catch. -function assertPackagedDaemonEntryExists(resourcesDir) { - const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') +function assertPackagedDaemonEntryExists(resourcesDir, platform = process.platform) { + const entryPath = join(resourcesDir, 'terminal-daemon', 'daemon-entry.js') if (!existsSync(entryPath)) { throw new Error( - `[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` + - `asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` + - `daemon cannot be forked from this layout` + `[verify-packaged-daemon-entry] missing terminal daemon entry at ${entryPath}; run build:terminal-daemon` + ) + } + if ( + platform === 'win32' && + !existsSync(join(resourcesDir, 'terminal-daemon', 'windows-bun-pty-gate-entry.js')) + ) { + throw new Error( + '[verify-packaged-daemon-entry] missing Windows Bun PTY gate; run build:terminal-daemon' ) } return entryPath } -// Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron -// `require` leaked into its bundle) while every build check passed. This boots -// the PACKAGED daemon-entry under plain Node against the asar-unpacked layout, -// so a bundling / asar-unpack regression fails packaging instead of reaching -// users. Module-load proof only: with no args the entry must reach argv parsing -// and print its "Usage: daemon-entry" error — a MODULE_NOT_FOUND or a missing -// usage line means the packaged graph does not load and the build must fail. -// -// resourcesDir is the packaged Resources dir (Contents/Resources on macOS, -// /resources elsewhere). execPath defaults to the packaging Node. +// Check the real resource layout with its shipped runtime before signing. function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { - const execPath = options.execPath || process.execPath - const entryPath = assertPackagedDaemonEntryExists(resourcesDir) + const platform = options.platform ?? process.platform + const execPath = + options.execPath ?? + join(resourcesDir, 'cli-runtime', platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime') + const entryPath = assertPackagedDaemonEntryExists(resourcesDir, platform) - const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 }) + const result = spawnSync(execPath, [entryPath], { + encoding: 'utf8', + timeout: 10_000, + windowsHide: true, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) if (result.error) { throw new Error( `[verify-packaged-daemon-entry] could not launch daemon-entry.js: ${result.error.message}` @@ -43,7 +42,7 @@ function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { const stderr = result.stderr || '' if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) { throw new Error( - `[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under plain Node:\n${stderr}` + `[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under bundled Bun:\n${stderr}` ) } if (!stderr.includes('Usage: daemon-entry')) { @@ -52,7 +51,7 @@ function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}` ) } - console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node') + console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under bundled Bun') } module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots } diff --git a/config/scripts/verify-packaged-daemon-entry.test.mjs b/config/scripts/verify-packaged-daemon-entry.test.mjs index 8705953ba619..d105f1fc0621 100644 --- a/config/scripts/verify-packaged-daemon-entry.test.mjs +++ b/config/scripts/verify-packaged-daemon-entry.test.mjs @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const { assertPackagedDaemonEntryExists, - verifyPackagedDaemonEntryBoots + verifyPackagedDaemonEntryBoots: verifyBoot } = require('./verify-packaged-daemon-entry.cjs') describe('verify-packaged-daemon-entry', () => { @@ -21,8 +21,11 @@ describe('verify-packaged-daemon-entry', () => { rmSync(resourcesDir, { recursive: true, force: true }) }) + const verifyPackagedDaemonEntryBoots = (directory) => + verifyBoot(directory, { execPath: process.execPath, platform: 'linux' }) + function writePackagedEntry(source) { - const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + const entryDir = join(resourcesDir, 'terminal-daemon') mkdirSync(entryDir, { recursive: true }) writeFileSync(join(entryDir, 'daemon-entry.js'), source) } @@ -30,12 +33,26 @@ describe('verify-packaged-daemon-entry', () => { // Why: a silent skip on a missing entry false-passed exactly the packaged // layout regression this gate exists to catch (rc.1 daemon-load incident). it('throws when the unpacked daemon entry is missing', () => { - expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow( - /missing unpacked daemon entry/ + expect(() => assertPackagedDaemonEntryExists(resourcesDir, 'linux')).toThrow( + /missing terminal daemon entry/ ) expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( - /missing unpacked daemon entry/ + /missing terminal daemon entry/ + ) + }) + + it('requires the Windows gate even when the daemon entry exists', () => { + writePackagedEntry('process.exit(0)') + expect(() => assertPackagedDaemonEntryExists(resourcesDir, 'win32')).toThrow( + /missing Windows Bun PTY gate/ ) + writeFileSync(join(resourcesDir, 'terminal-daemon', 'windows-bun-pty-gate-entry.js'), '') + expect(() => assertPackagedDaemonEntryExists(resourcesDir, 'win32')).not.toThrow() + }) + + it('fails when the packaged Bun runtime is absent', () => { + writePackagedEntry('console.error("Usage: daemon-entry"); process.exit(1)') + expect(() => verifyBoot(resourcesDir, { platform: 'linux' })).toThrow(/could not launch/) }) it('passes when the packaged entry loads and reaches argv parsing', () => { @@ -46,7 +63,7 @@ describe('verify-packaged-daemon-entry', () => { it('fails when the packaged entry cannot resolve its module graph', () => { writePackagedEntry('require("orca-module-that-does-not-exist")\n') expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( - /failed to load under plain Node/ + /failed to load under bundled Bun/ ) }) diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.cjs b/config/scripts/verify-packaged-node-pty-job-ownership.cjs deleted file mode 100644 index 2d746d236612..000000000000 --- a/config/scripts/verify-packaged-node-pty-job-ownership.cjs +++ /dev/null @@ -1,199 +0,0 @@ -const { existsSync } = require('node:fs') -const { createRequire } = require('node:module') -const { join } = require('node:path') -const { - assertNodePtyJobOwnership, - conptyDeniesCygwinBreakaway, - nodePtyAddonPath, - staleConptySourceBuildError -} = require('./node-pty-job-ownership.cjs') -const { normalizeNodePtyWindowsArch } = require('../packaged-runtime-node-modules.cjs') -const { PE_MACHINE, describePeMachine, readPeMachine } = require('./windows-pe-machine.cjs') - -/** - * Every conpty.node the packaged tree can hand `loadNativeModule`, in its order. - * - * Why the order matters: the loader swallows each require failure and falls - * through, so a wrong-arch or otherwise unloadable build hands the pane to the - * next candidate. First loadable wins, and the published prebuild is always the - * last one standing. - */ -function packagedConptyCandidates(resourcesDir, targetArch) { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - const layouts = [ - { segments: ['build', 'Release'], prebuilt: false }, - { segments: ['build', 'Debug'], prebuilt: false }, - { segments: ['prebuilds', `win32-${targetArch}`], prebuilt: true } - ] - // Each layout is tried relative to node-pty's root, then to lib/, before the - // next layout -- the unbundled then bundled pair node-pty's loader walks. - return layouts.flatMap(({ segments, prebuilt }) => - [nodePtyDir, join(nodePtyDir, 'lib')].map((root) => ({ - path: join(root, ...segments, 'conpty.node'), - prebuilt - })) - ) -} - -function describeCandidates(candidates) { - return candidates - .map((candidate) => `${candidate.path} (${describePeMachine(candidate.machine)})`) - .join(', ') -} - -function loadPackagedConpty(resourcesDir) { - const packagedRequire = createRequire(join(resourcesDir, 'package.json')) - const utilsPath = packagedRequire.resolve('./node_modules/node-pty/lib/utils') - const { loadNativeModule } = packagedRequire(utilsPath) - const native = loadNativeModule('conpty') - return { native, addonPath: nodePtyAddonPath(utilsPath, native, 'conpty') } -} - -function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) { - const platform = options.platform ?? process.platform - if (platform !== 'win32') { - return - } - - const { native, addonPath } = (options.loadNative ?? loadPackagedConpty)(resourcesDir) - assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native, addonPath }) - if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) { - throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`) - } - console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees') -} - -/** - * The half of the packaged check that survives a cross-host build. - * - * The export check has to load the addon, so it cannot run when the packaging - * host is not the target platform/arch -- and that skip is how a Windows - * release built elsewhere could ship a node-pty that leaks every MSYS pane - * child out of its job. Reading the binary needs neither. - * - * It resolves the addon the way the loader does rather than reading one path: - * only the PE machine field separates a cross-arch package that built correctly - * from one whose rebuild silently emitted the host's arch, and the first is a - * correct package whose leftover prebuild is never reached. See the table in - * docs/reference/windows-msys-job-breakaway.md. - * - * Nothing loadable is fatal, not skipped: that package has no ConPTY backend, - * which a gate must not shrug at. - */ -function verifyPackagedConptyBreakawayMarker(resourcesDir, targetArch, options = {}) { - // Deliberately no host-platform gate: the caller has already established that - // the *target* is Windows, and gating on the host is the very skip this - // closes. - const architecture = normalizeNodePtyWindowsArch(targetArch) - const candidates = packagedConptyCandidates(resourcesDir, architecture) - const exists = options.exists ?? existsSync - const present = candidates.filter((candidate) => exists(candidate.path)) - if (present.length === 0) { - throw new Error( - [ - `Packaged node-pty for win32-${architecture} has no conpty.node on any path its loader`, - `tries (${candidates.map((c) => c.path).join(', ')}), so the packaged app has no`, - 'ConPTY backend at all.', - 'Nothing here can be checked for the Cygwin/MSYS job-breakaway denial, and a gate that', - 'cannot see its subject refuses rather than assume.' - ].join(' ') - ) - } - // Read once: the same header answers "which one loads" and "what did we find". - const inspected = present.map((candidate) => ({ - ...candidate, - machine: readPeMachine(candidate.path) - })) - const loaded = inspected.find((candidate) => candidate.machine === PE_MACHINE[architecture]) - if (!loaded) { - throw new Error( - [ - `Packaged node-pty for win32-${architecture} has conpty.node at`, - `${describeCandidates(inspected)},`, - 'and the app can load none of them: a Windows process only loads a PE of its own', - `machine, which for win32-${architecture} is`, - `0x${PE_MACHINE[architecture].toString(16)}.`, - 'Rebuild node-pty for the target architecture and repackage.' - ].join(' ') - ) - } - const addonPath = loaded.path - if (conptyDeniesCygwinBreakaway(addonPath)) { - console.log( - `[verify-packaged-node-pty] OK — win32-${architecture} loads ${addonPath}, which denies ` + - 'MSYS job breakaway' - ) - return - } - if (!loaded.prebuilt) { - throw staleConptySourceBuildError(addonPath) - } - // Past here the app falls back to the published prebuild, which never carries - // the patch. Why it fell back decides the remedy, and the three are different - // enough that naming the wrong one wastes the reader's build. - const unusableSourceBuilds = inspected.filter((candidate) => !candidate.prebuilt) - if (unusableSourceBuilds.some((candidate) => candidate.machine === null)) { - throw new Error( - [ - `Packaged node-pty for win32-${architecture} falls back to ${addonPath}, which predates`, - 'the Cygwin/MSYS job-breakaway denial, because the source build beside it is not a PE', - `image at all: ${describeCandidates(unusableSourceBuilds)}.`, - 'A truncated, empty or quarantined build artifact looks like this. Rebuild node-pty and', - 'repackage. See docs/reference/windows-msys-job-breakaway.md.' - ].join(' ') - ) - } - if (unusableSourceBuilds.length > 0) { - throw new Error( - [ - `Packaged node-pty for win32-${architecture} falls back to ${addonPath}, which predates`, - 'the Cygwin/MSYS job-breakaway denial, because the source build beside it is the wrong', - `architecture: ${describeCandidates(unusableSourceBuilds)}.`, - 'A cross-arch rebuild that did not honour --arch looks exactly like this. Re-run', - `config/scripts/rebuild-native-deps.mjs --platform=win32 --arch=${architecture},`, - 'confirm it emitted a conpty.node of that machine, and repackage.', - 'See docs/reference/windows-msys-job-breakaway.md.' - ].join(' ') - ) - } - throw new Error( - [ - `Packaged node-pty for win32-${architecture} loads ${addonPath}, the published prebuilt`, - 'fallback, which predates the Cygwin/MSYS job-breakaway denial: its per-PTY job still', - 'carries JOB_OBJECT_LIMIT_BREAKAWAY_OK, so every Git Bash pane child is created outside', - 'the job and survives terminatePtyJob.', - 'It is here because this package holds no node-pty source build at all for', - 'prunePackagedNodePty to have replaced it with, and only a host that can build node-pty', - `for win32-${architecture} produces one.`, - `If this IS a Windows ${architecture} host, the rebuild did not leave one -- check the`, - 'beforeBuild output above. Otherwise package this Windows slice on a host that can.', - 'See docs/reference/windows-msys-job-breakaway.md.' - ].join(' ') - ) -} - -/** - * The whole Windows verdict for one packaged slice. - * - * Both halves live here rather than in the afterPack hook so that "the marker - * sweep runs even when the export check cannot" is a tested claim instead of - * the shape of an if/else somebody could re-nest. - */ -function verifyPackagedWindowsNodePty(resourcesDir, targetArch, options = {}) { - ;(options.verifyMarker ?? verifyPackagedConptyBreakawayMarker)(resourcesDir, targetArch) - const hostPlatform = options.hostPlatform ?? process.platform - if (hostPlatform !== 'win32' || !options.canExecuteTargetArch) { - console.log( - '[verify-packaged-node-pty] skipped the export check on a cross-platform or cross-arch package' - ) - return - } - ;(options.verifyExports ?? verifyPackagedNodePtyJobOwnership)(resourcesDir) -} - -module.exports = { - packagedConptyCandidates, - verifyPackagedConptyBreakawayMarker, - verifyPackagedNodePtyJobOwnership, - verifyPackagedWindowsNodePty -} diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs deleted file mode 100644 index 9d2317c8a339..000000000000 --- a/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs +++ /dev/null @@ -1,430 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { peImage } from './windows-pe-image-fixture.mjs' - -const require = createRequire(import.meta.url) -const { - packagedConptyCandidates, - verifyPackagedConptyBreakawayMarker, - verifyPackagedNodePtyJobOwnership, - verifyPackagedWindowsNodePty -} = require('./verify-packaged-node-pty-job-ownership.cjs') -const { CYGWIN_BREAKAWAY_MARKER } = require('./node-pty-job-ownership.cjs') - -const fixtureDir = mkdtempSync(join(tmpdir(), 'packaged-node-pty-job-')) -const ELECTRON_BUILDER_CONFIG = readFileSync( - new URL('../electron-builder.config.cjs', import.meta.url), - 'utf8' -) - -/** A real enough addon: a machine field the arch check reads, and the marker. */ -function conptyImage({ arch = 'x64', cygwinBreakawayDenied = true } = {}) { - return Buffer.concat([ - peImage({ arch }), - cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0) - ]) -} - -function writeAddon(name, options) { - const path = join(fixtureDir, name) - writeFileSync(path, conptyImage(options)) - return path -} - -/** A packaged resources tree carrying exactly the conpty.node files named. */ -function packagedResources(addons) { - const resourcesDir = mkdtempSync(join(fixtureDir, 'resources-')) - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - for (const [relativePath, options] of Object.entries(addons)) { - const addonPath = join(nodePtyDir, ...relativePath.split('/')) - mkdirSync(dirname(addonPath), { recursive: true }) - writeFileSync(addonPath, conptyImage(options)) - } - return resourcesDir -} - -const CURRENT_ADDON = writeAddon('current.node', { cygwinBreakawayDenied: true }) -const PRE_MSYS_ADDON = writeAddon('pre-msys.node', { cygwinBreakawayDenied: false }) - -const PATCHED = { - dir: '../build/Release/', - module: { - listJobProcessIds: () => [], - terminateJob: () => true, - assignCurrentProcessToJob: () => true - } -} - -const packaged = (native, addonPath = CURRENT_ADDON) => ({ - platform: 'win32', - loadNative: () => ({ native, addonPath }) -}) - -describe('verifyPackagedNodePtyJobOwnership', () => { - it('accepts the packaged patched ConPTY binding', () => { - expect(() => verifyPackagedNodePtyJobOwnership('resources', packaged(PATCHED))).not.toThrow() - }) - - it('rejects a packaged upstream prebuild', () => { - expect(() => - verifyPackagedNodePtyJobOwnership( - 'resources', - packaged({ dir: '../prebuilds/win32-x64/', module: {} }) - ) - ).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/) - }) - - // A release built against a stale native cache ships the MSYS orphan bug - // while exporting every job function, so packaging has to read the binary. - it('rejects a packaged build that predates the Cygwin/MSYS breakaway denial', () => { - expect(() => - verifyPackagedNodePtyJobOwnership('resources', packaged(PATCHED, PRE_MSYS_ADDON)) - ).toThrow(/predates the Cygwin\/MSYS job-breakaway denial/) - }) - - it('requires the patched source-build directory', () => { - expect(() => - verifyPackagedNodePtyJobOwnership( - 'resources', - packaged({ ...PATCHED, dir: '../prebuilds/win32-x64/' }) - ) - ).toThrow(/expected patched build\/Release/) - }) - - it('does not load Windows natives for other targets', () => { - const loadNative = vi.fn() - verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative }) - expect(loadNative).not.toHaveBeenCalled() - }) -}) - -describe('packagedConptyCandidates', () => { - // Pinned because the gate resolves the addon by walking this list in order: - // a wrong order blesses a binary the app would never reach. - it('walks the paths node-pty tries, in node-pty order', () => { - expect(packagedConptyCandidates('RES', 'arm64').map((candidate) => candidate.path)).toEqual([ - join('RES', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node'), - join('RES', 'node_modules', 'node-pty', 'lib', 'build', 'Release', 'conpty.node'), - join('RES', 'node_modules', 'node-pty', 'build', 'Debug', 'conpty.node'), - join('RES', 'node_modules', 'node-pty', 'lib', 'build', 'Debug', 'conpty.node'), - join('RES', 'node_modules', 'node-pty', 'prebuilds', 'win32-arm64', 'conpty.node'), - join('RES', 'node_modules', 'node-pty', 'lib', 'prebuilds', 'win32-arm64', 'conpty.node') - ]) - }) - - // Only the published prebuild gets the "no rebuild here can fix this" advice. - it('knows which of them node-pty publishes prebuilt', () => { - expect(packagedConptyCandidates('RES', 'x64').map((candidate) => candidate.prebuilt)).toEqual([ - false, - false, - false, - false, - true, - true - ]) - }) -}) - -describe('verifyPackagedConptyBreakawayMarker', () => { - // The release as built today: prunePackagedNodePty already dropped the - // same-arch prebuild because a patched source build replaced it. - it('passes a package whose only ConPTY load path carries the denial', () => { - const resourcesDir = packagedResources({ 'build/Release/conpty.node': {} }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).not.toThrow() - }) - - // The cross-host package. No host but Windows can build conpty.node, so there - // is no build/Release for prune to have replaced the prebuild with -- and the - // published prebuild is exactly the binary that leaks every MSYS pane child. - it('fails a cross-host package left holding the published prebuild', () => { - const resourcesDir = packagedResources({ - 'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow( - /predates the Cygwin\/MSYS job-breakaway denial/ - ) - }) - - it('tells that package how to fix it, which is not a rebuild it can run', () => { - const resourcesDir = packagedResources({ - 'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow( - /this package holds no node-pty source build at all[\s\S]*Otherwise package this Windows slice on a host that can/ - ) - }) - - // The same state reaches this from a capable host too, when the rebuild left - // nothing: telling that packager to change hosts would send them nowhere. - it('does not assume the packaging host is the wrong one', () => { - const resourcesDir = packagedResources({ - 'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow( - /If this IS a Windows x64 host, the rebuild did not leave one/ - ) - }) - - // The cross-arch package that worked: beforeBuild rebuilds node-pty for the - // TARGET arch, so build/Release is patched and loadable and the prebuild - // prune left behind is never reached. Failing this would be a false positive - // whose advice -- change hosts -- is both wrong and impossible. - it('passes a cross-arch package whose build/Release really is the target arch', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': { arch: 'arm64' }, - 'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).not.toThrow() - }) - - // The cross-arch package that silently did not: build/Release is the - // packaging host's own arch, the target cannot load it, and the loader falls - // through to the unpatched prebuild underneath. - it('fails a cross-arch package whose build/Release is the packaging host arch', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': { arch: 'x64' }, - 'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow( - /prebuilds[\\/]win32-arm64/ - ) - }) - - it('refuses a package whose every conpty.node is the wrong architecture', () => { - const resourcesDir = packagedResources({ 'build/Release/conpty.node': { arch: 'x64' } }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow( - /the app can load none of them/ - ) - }) - - // Naming the machine it found is what separates a cross-arch build from a - // truncated download, which are the same "cannot load this" to the loader. - it('names what it found rather than guessing why', () => { - const resourcesDir = packagedResources({ 'build/Release/conpty.node': { arch: 'x64' } }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow( - /machine 0x8664[\s\S]*0xaa64/ - ) - }) - - it('calls a candidate that is not a PE image what it is', () => { - const resourcesDir = packagedResources({}) - const addonPath = join( - resourcesDir, - 'node_modules', - 'node-pty', - 'build', - 'Release', - 'conpty.node' - ) - mkdirSync(dirname(addonPath), { recursive: true }) - writeFileSync(addonPath, Buffer.alloc(0x200)) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(/not a PE image/) - }) - - // A truncated or quarantined artifact reaches the gate looking exactly like a - // cross-arch build, and "re-run with --arch" is not the command that fixes it. - it('does not blame --arch for a source build that is not a PE image', () => { - const resourcesDir = packagedResources({ - 'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false } - }) - const addonPath = join( - resourcesDir, - 'node_modules', - 'node-pty', - 'build', - 'Release', - 'conpty.node' - ) - mkdirSync(dirname(addonPath), { recursive: true }) - writeFileSync(addonPath, Buffer.alloc(0x200)) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow( - /is not a PE image at all[\s\S]*truncated, empty or quarantined/ - ) - }) - - // The remedy for this one is a rebuild, not a different host, and the - // difference is a build somebody has to run twice to find out. - it('blames the wrong-arch source build rather than the host, when there is one', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': { arch: 'x64' }, - 'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow( - /the source build beside it is the wrong architecture[\s\S]*machine 0x8664/ - ) - }) - - it('tells that build the command that would fix it', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': { arch: 'x64' }, - 'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow( - /rebuild-native-deps\.mjs --platform=win32 --arch=arm64/ - ) - }) - - it('does not tell it to change hosts, which would not help', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': { arch: 'x64' }, - 'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow( - /^(?![\s\S]*Package this Windows slice on such a host)[\s\S]*$/ - ) - }) - - it('ignores a prebuild for an arch this slice will never load', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': {}, - 'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).not.toThrow() - }) - - // build/Debug sits between Release and the prebuilds in the load order, and - // nothing prunes it, so it wins whenever Release cannot be loaded. - it('resolves past a Release build the target cannot load', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': { arch: 'x64' }, - 'build/Debug/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow( - /build[\\/]Debug/ - ) - }) - - // A stale source build is the packaging host's own to rebuild, so it gets the - // advice that actually works rather than the cross-host one. - it('tells a stale source build to rebuild, not to change hosts', () => { - const resourcesDir = packagedResources({ - 'build/Release/conpty.node': { cygwinBreakawayDenied: false } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow( - /Rebuild node-pty from source/ - ) - }) - - // Nothing to load is not "a layout we do not recognise", it is a package with - // no ConPTY backend, and a gate that cannot see its subject is not a gate. - it('refuses rather than skip a package with no conpty.node at all', () => { - const resourcesDir = packagedResources({}) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow( - /no conpty\.node on any path its loader tries/ - ) - }) - - it('names every path it looked at when it finds none', () => { - const resourcesDir = packagedResources({}) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow( - /build[\\/]Release[\s\S]*build[\\/]Debug[\s\S]*prebuilds[\\/]win32-x64/ - ) - }) - - // Present but unreadable is the state that used to pass, so it must not warn. - // The read error itself is the message; the point is that it does not return. - it('fails rather than pass a candidate it cannot read', () => { - const resourcesDir = packagedResources({}) - mkdirSync(join(resourcesDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node'), { - recursive: true - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow() - }) - - it('accepts the electron-builder Arch enum the afterPack hook passes', () => { - const resourcesDir = packagedResources({ - 'prebuilds/win32-arm64/conpty.node': { arch: 'arm64' } - }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 3)).not.toThrow() - }) - - it('refuses a target arch no Windows slice ships', () => { - const resourcesDir = packagedResources({ 'build/Release/conpty.node': {} }) - expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'ia32')).toThrow( - /Unsupported packaged node-pty Windows architecture/ - ) - }) - - it('looks where electron-builder actually lands the addon', () => { - const exists = vi.fn().mockReturnValue(false) - expect(() => - verifyPackagedConptyBreakawayMarker(join('out', 'win-unpacked', 'resources'), 'x64', { - exists - }) - ).toThrow() - expect(exists).toHaveBeenCalledWith( - join( - 'out', - 'win-unpacked', - 'resources', - 'node_modules', - 'node-pty', - 'build', - 'Release', - 'conpty.node' - ) - ) - }) -}) - -describe('verifyPackagedWindowsNodePty', () => { - const spies = () => ({ verifyMarker: vi.fn(), verifyExports: vi.fn() }) - - // The bug this replaced: the marker check sat in the else of the host gate, so - // the cross-host package it exists for was the one package it never checked. - it.each([ - ['a cross-platform host', { hostPlatform: 'darwin', canExecuteTargetArch: true }], - ['a cross-arch slice', { hostPlatform: 'win32', canExecuteTargetArch: false }], - ['both', { hostPlatform: 'linux', canExecuteTargetArch: false }], - ['neither', { hostPlatform: 'win32', canExecuteTargetArch: true }] - ])('checks the marker on %s', (_case, host) => { - const { verifyMarker, verifyExports } = spies() - verifyPackagedWindowsNodePty('resources', 'x64', { ...host, verifyMarker, verifyExports }) - expect(verifyMarker).toHaveBeenCalledWith('resources', 'x64') - }) - - it('loads the addon for the export check only where that can work', () => { - const { verifyMarker, verifyExports } = spies() - verifyPackagedWindowsNodePty('resources', 'x64', { - hostPlatform: 'win32', - canExecuteTargetArch: true, - verifyMarker, - verifyExports - }) - expect(verifyExports).toHaveBeenCalledWith('resources') - }) - - it.each([ - ['a cross-platform host', { hostPlatform: 'darwin', canExecuteTargetArch: true }], - ['a cross-arch slice', { hostPlatform: 'win32', canExecuteTargetArch: false }] - ])('skips the export check on %s', (_case, host) => { - const { verifyMarker, verifyExports } = spies() - verifyPackagedWindowsNodePty('resources', 'x64', { ...host, verifyMarker, verifyExports }) - expect(verifyExports).not.toHaveBeenCalled() - }) - - // Swallowing the marker verdict would leave a gate that runs and decides - // nothing, which is the failure mode this whole change is about. - it('lets the marker verdict fail the package', () => { - const verifyMarker = vi.fn(() => { - throw new Error('predates the Cygwin/MSYS job-breakaway denial') - }) - expect(() => - verifyPackagedWindowsNodePty('resources', 'x64', { - hostPlatform: 'win32', - canExecuteTargetArch: true, - verifyMarker, - verifyExports: vi.fn() - }) - ).toThrow(/predates the Cygwin\/MSYS job-breakaway denial/) - }) - - it('is what the afterPack hook calls for a Windows slice', () => { - expect(ELECTRON_BUILDER_CONFIG).toContain( - 'verifyPackagedWindowsNodePty(resourcesDir, context.arch, { canExecuteTargetArch })' - ) - }) -}) diff --git a/config/scripts/win32-test-lane-registration.test.mjs b/config/scripts/win32-test-lane-registration.test.mjs index 26d5951e1a05..7e3644673905 100644 --- a/config/scripts/win32-test-lane-registration.test.mjs +++ b/config/scripts/win32-test-lane-registration.test.mjs @@ -84,7 +84,6 @@ const UNREGISTERED_ON_MAIN = [ // half of the file still runs on ubuntu, the Windows half runs nowhere. 'src/main/antigravity/windows-hook-payload-delivery.test.ts', // `.win32.test.ts` by name yet in neither list -- the plainest instance of the class. - 'src/main/daemon/node-pty-windows-input-error.win32.test.ts', // Same shape as the antigravity file: a win32-only sibling suite that never runs. 'src/main/grok/windows-grok-hook-script.test.ts', // Whole file is `describe.runIf(platform === 'win32')`; runs on no machine. diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs index 1385ffc17d2e..08ce66a0bd68 100644 --- a/config/scripts/windows-daemon-workspace-close-repro.mjs +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -5,7 +5,7 @@ * graceful-then-immediate kill pair emitted when Orca closes a workspace. * The daemon PID and witness session must survive every iteration. */ -import { fork } from 'node:child_process' +import { daemonSmokeEntry, spawnDaemonSmoke } from './daemon-smoke-launch.mjs' import { randomUUID } from 'node:crypto' import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { connect } from 'node:net' @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' const projectDir = resolve(import.meta.dirname, '../..') -const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js') +const entryPath = daemonSmokeEntry(projectDir) const iterations = Number(process.env.ORCA_WINDOWS_DAEMON_CLOSE_ITERATIONS ?? 25) const requestTimeoutMs = 15_000 @@ -287,22 +287,21 @@ async function main() { return } if (!existsSync(entryPath)) { - throw new Error(`Missing ${entryPath}; run pnpm build:electron-vite first`) + throw new Error(`Missing ${entryPath}; run pnpm build:terminal-daemon first`) } const scratch = mkdtempSync(join(tmpdir(), 'orca-windows-daemon-close-')) const socketPath = `\\\\.\\pipe\\orca-daemon-close-${process.pid}-${randomUUID()}` const tokenPath = join(scratch, 'daemon.token') const daemonLogPath = join(scratch, 'daemon.log') - const child = fork( - entryPath, - ['--socket', socketPath, '--token', tokenPath, '--log-file', daemonLogPath], - { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - windowsHide: true, - env: { ...process.env, ORCA_USER_DATA_PATH: scratch } - } - ) + const child = spawnDaemonSmoke(projectDir, scratch, [ + '--socket', + socketPath, + '--token', + tokenPath, + '--log-file', + daemonLogPath + ]) const daemonPid = child.pid let stderr = '' child.stderr?.on('data', (chunk) => { diff --git a/config/scripts/windows-process-tree-creation-time.cjs b/config/scripts/windows-process-tree-creation-time.cjs index 769cd8854349..d265f8e23b89 100644 --- a/config/scripts/windows-process-tree-creation-time.cjs +++ b/config/scripts/windows-process-tree-creation-time.cjs @@ -3,7 +3,7 @@ /** * Prove the compiled addon supports table and synchronous process creation-time reads. * - * Unlike node-pty, this package ships a prebuilt `.node` at the same + * This package ships a prebuilt `.node` at the same * `build/Release/` path node-gyp writes to, so neither a load nor a path check * can tell a stale prebuilt from a source build. pnpm patches the source tree * and leaves that prebuilt in place, which is how `ProcessDataFlag.CreationTime` @@ -11,8 +11,7 @@ * read true and every row came back without `creationTimeMs`. * * `supportedProcessDataFlags` is exported by the patched `addon.cc`, so its - * presence is the binary's own answer. Shared by the Node and Electron probes - * the way `node-pty-job-ownership.cjs` is. + * presence is the binary's own answer, checked by both Node and Electron probes. */ /** `ProcessDataFlags::CREATIONTIME` in src/process.h. */ diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs index e1742a43bb09..116401725ad2 100644 --- a/config/scripts/windows-pty-native-capability-workflow.test.mjs +++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs @@ -56,7 +56,6 @@ describe('packaged Windows PTY native capability routing', () => { expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs') } expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']") - expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')") expect(build.run).toBe('pnpm run build:release:parallel') expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1') expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') @@ -66,15 +65,15 @@ describe('packaged Windows PTY native capability routing', () => { expect(verify.run).toContain('"$PACKAGE_WINDOWS"') }) - it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => { + it('keeps the native probe event-based, scoped, and runnable under the packaged Bun runtime', () => { const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8') const probe = readFileSync( - 'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs', + 'tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-probe.cjs', 'utf8' ) const source = `${driver}\n${probe}` - expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'") + expect(driver).toContain("'bun-runtime.exe'") expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i) expect(source).not.toContain('maxRetries') expect(source).not.toContain('retryDelay') @@ -83,6 +82,6 @@ describe('packaged Windows PTY native capability routing', () => { expect(probe).toContain("'System32', 'wscript.exe'") expect(probe).toContain('real-orca-detached-launcher.vbs') expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i) - expect(probe).toContain('native.terminateJob(target._pty, target.pid)') + expect(probe).toContain('target.terminateOwnedTree()') }) }) diff --git a/config/scripts/windows-pty-table-stress.cjs b/config/scripts/windows-pty-table-stress.mjs similarity index 68% rename from config/scripts/windows-pty-table-stress.cjs rename to config/scripts/windows-pty-table-stress.mjs index b703e18d19ce..7f51c72a4831 100644 --- a/config/scripts/windows-pty-table-stress.cjs +++ b/config/scripts/windows-pty-table-stress.mjs @@ -1,9 +1,13 @@ -'use strict' +import { createWindowsBunPtyLaunch } from '../../src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts' +import { fileURLToPath } from 'node:url' +import { spawnBunPty } from '../../src/main/daemon/pty-subprocess/bun-pty-process.ts' +import { listPtyJobProcessIds } from '../../src/main/windows/windows-pty-job.ts' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) const assert = require('node:assert/strict') const { createHash } = require('node:crypto') const { readFileSync, writeSync } = require('node:fs') -const { dirname, resolve } = require('node:path') function report(phase, details = {}) { writeSync(1, `${JSON.stringify({ phase, hostPid: process.pid, ...details })}\n`) @@ -13,31 +17,39 @@ async function exerciseTable() { assert.equal(process.platform, 'win32', 'This probe requires real Windows ConPTY') const rounds = Number(process.env.ORCA_PTY_TABLE_STRESS_ROUNDS ?? 8) assert.ok(Number.isInteger(rounds) && rounds > 0 && rounds <= 2000) - const pty = require('node-pty') - const nativePath = require.resolve('node-pty/lib/utils') - const loaded = require(nativePath).loadNativeModule('conpty') - const native = loaded.module - const addonPath = resolve(dirname(nativePath), loaded.dir, 'conpty.node') - report('native', { - addonPath, - sha256: createHash('sha256').update(readFileSync(addonPath)).digest('hex'), - node: process.version, + report('runtime', { + runtime: process.execPath, + sha256: createHash('sha256').update(readFileSync(process.execPath)).digest('hex'), + bun: process.versions.bun, rounds }) - // Unlike production's fallback, this crash probe requires a host that permits nested jobs. - const hostJobAssigned = native.assignCurrentProcessToJob() - report('host-job-precondition', { assigned: hostJobAssigned }) - assert.equal(hostJobAssigned, true, 'Probe precondition: host must permit a crash-cleanup job') const spawned = [] function spawn(round, slot) { report('spawn', { round, slot }) - const proc = pty.spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/q'], { - cwd: process.cwd(), - cols: 80, - rows: 24, - useConptyDll: true - }) + const proc = spawnBunPty( + { + file: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/q'], + env: Object.fromEntries( + Object.entries(process.env).filter(([, value]) => value !== undefined) + ), + cwd: process.cwd(), + cols: 80, + rows: 24 + }, + { + createWindowsLaunch: (launch) => + createWindowsBunPtyLaunch(launch, { + workerPath: fileURLToPath( + new URL( + '../../src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts', + import.meta.url + ) + ) + }) + } + ) const record = { proc, output: '', exited: false, closed: false } const marker = `ORCA_PTY_READY_${spawned.length}` let resolveReady @@ -59,7 +71,15 @@ async function exerciseTable() { }) spawned.push(record) // Escaping one letter keeps echoed input from satisfying the output marker. - proc.write(`echo ${marker.replace('READY', 'REA^DY')}\r`) + void proc.waitForSpawn().then( + () => { + proc.write(`echo ${marker.replace('READY', 'REA^DY')}\r`) + }, + (error) => { + record.output += String(error) + resolveReady(false) + } + ) return record } @@ -83,8 +103,11 @@ async function exerciseTable() { clearTimeout(timer) } for (const { proc } of records) { - const members = native.listJobProcessIds(proc._pty, proc.pid) - assert.ok(members?.includes(proc.pid), `Ready shell ${proc.pid} must retain its job`) + const members = listPtyJobProcessIds(proc) + assert.ok( + members?.includes(proc.shellProcessId), + `Ready shell ${proc.pid} must retain its job` + ) } report('ready', { shellPids: records.map(({ proc }) => proc.pid) }) } @@ -110,8 +133,11 @@ async function exerciseTable() { report('resize-clear-list', { round, slot, shellPid: survivor.proc.pid }) survivor.proc.resize(80 + (round % 2), 24) survivor.proc.clear() - const members = native.listJobProcessIds(survivor.proc._pty, survivor.proc.pid) - assert.ok(members?.includes(survivor.proc.pid), 'A live survivor must retain its job') + const members = listPtyJobProcessIds(survivor.proc) + assert.ok( + members?.includes(survivor.proc.shellProcessId), + 'A live survivor must retain its job' + ) } // A ready terminal runs kill/resize/clear immediately instead of deferring them. await waitForReady(slots) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs index c321db8cfd23..9c1ccc9a6dcd 100644 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -17,18 +17,18 @@ describe('Windows signing workflow contract', () => { name === 'Install SignPath PowerShell module' ? [index] : [] ) const buildIndex = stepNames.indexOf('Build Windows release artifacts') - const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') + const verifyRuntimeIndex = stepNames.indexOf('Verify Windows Bun and ConPTY runtime') const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') - expect(verifyNodePtyIndex).toBe(buildIndex + 1) - expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) + expect(verifyRuntimeIndex).toBe(buildIndex + 1) + expect(installStepIndexes).toEqual([verifyRuntimeIndex + 1]) expect(installStepIndexes[0]).toBeLessThan(uploadIndex) - expect(steps[verifyNodePtyIndex].run).toContain( - 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' - ) - expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') + expect(steps[verifyRuntimeIndex].run).toContain('dist/win-unpacked/resources/cli-runtime') + expect(steps[verifyRuntimeIndex].run).toContain('verifyCliRuntimeDirectory') + expect(steps[verifyRuntimeIndex].run).toContain("'win32', 'x64'") + expect(steps[verifyRuntimeIndex].run).toContain('$LASTEXITCODE') const uploadThroughDownloadScript = steps .slice(uploadIndex, downloadIndex + 1) @@ -456,3 +456,32 @@ describe('Windows NSIS uninstaller signing', () => { delete require.cache[require.resolve(configPath)] }) }) + +it.each(['release-cut.yml', 'windows-signing-rehearsal.yml'])( + 'finalizes signed Bun identity before packaging in %s', + (filename) => { + const workflow = readWorkflow(`.github/workflows/${filename}`) + const steps = Object.values(workflow.jobs).flatMap((job) => job.steps ?? []) + const stage = steps.find((step) => step.run?.includes("$list -contains 'Orca.exe'")) + expect(stage.run).toContain("'*windows_process_tree.node'") + expect(stage.run).not.toContain('conpty_console_list.node') + const restoreIndex = steps.findIndex( + (step) => step.name === 'Restore signed inner binaries into unpacked app' + ) + const restore = steps[restoreIndex] + expect(restore.run).toContain( + "node config/scripts/finalize-signed-cli-runtime.mjs $root 'signing-stage'" + ) + expect(restore.run).toContain( + "if ($LASTEXITCODE -ne 0) { throw 'Signed runtime identity finalization failed.' }" + ) + expect(restore.run.indexOf('finalize-signed-cli-runtime.mjs')).toBeGreaterThan( + restore.run.indexOf('if ($failures.Count -gt 0)') + ) + const rebuildIndex = steps.findIndex( + (step) => + step.run?.includes('electron-builder --config') && step.run?.includes('--prepackaged') + ) + expect(rebuildIndex).toBeGreaterThan(restoreIndex) + } +) diff --git a/config/scripts/windows-ssh-attach-console-repro.mjs b/config/scripts/windows-ssh-attach-console-repro.mjs deleted file mode 100644 index ce0b9b1d40aa..000000000000 --- a/config/scripts/windows-ssh-attach-console-repro.mjs +++ /dev/null @@ -1,436 +0,0 @@ -import { spawn, spawnSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { createConnection } from 'node:net' -import { join, resolve } from 'node:path' - -const projectDir = resolve(import.meta.dirname, '..', '..') -const relayBuildDir = join(projectDir, 'out', 'relay', 'win32-x64') -const SENTINEL = Buffer.from('ORCA-RELAY v0.1.0 READY\n') -const HEADER_LENGTH = 13 -const REGRESSION_TIMEOUT_MS = 15_000 -const NODE_PTY_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' - -if (process.platform !== 'win32') { - console.log('SKIP: Windows ConPTY regression only runs on Windows.') - process.exit(0) -} - -const options = parseArgs(process.argv.slice(2)) -const nodePath = resolve(options.node ?? process.execPath) -const nodePtyDir = resolve(options.nodePty ?? join(projectDir, 'node_modules', 'node-pty')) -const expectFailure = options.expect === 'attach-console-failure' - -for (const required of [ - nodePath, - nodePtyDir, - join(relayBuildDir, 'relay.js'), - join(relayBuildDir, '.version') -]) { - if (!existsSync(required)) { - throw new Error(`Required regression input is missing: ${required}`) - } -} - -const runDir = mkdtempSync(join(projectDir, '.issue-9586-relay-repro-')) -const relayPath = join(runDir, 'relay.js') -const stdoutLog = join(runDir, 'relay.log') -const stderrLog = join(runDir, 'relay.err.log') -const socketPath = `\\\\.\\pipe\\orca-issue-9586-${process.pid}-${Date.now()}` -let relayPid - -try { - prepareRelayTree(runDir, nodePtyDir) - if (!options.skipRelayPatch) { - applyPackagedNodePtyPatch(nodePath, runDir) - } - relayPid = launchRelayWithoutConsole({ - nodePath, - relayPath, - runDir, - socketPath, - stdoutLog, - stderrLog - }) - await waitForPipe(socketPath, 5_000) - const observation = await exerciseRelayClient({ - nodePath, - relayPath, - runDir, - socketPath, - shell: options.shell - }) - await waitForExit(relayPid, 8_000) - - const relayStdout = readIfPresent(stdoutLog) - const relayStderr = readIfPresent(stderrLog) - const attachConsoleFailed = relayStderr.includes('Error: AttachConsole failed') - const installedNodePtyDir = join(runDir, 'node_modules', 'node-pty') - const agentPath = join(installedNodePtyDir, 'lib', 'conpty_console_list_agent.js') - const nativeBindingPath = join( - installedNodePtyDir, - 'prebuilds', - `${process.platform}-${process.arch}`, - 'conpty.node' - ) - const summary = { - node: observation.nodeVersion, - nodePty: JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')).version, - shell: options.shell, - relayPid, - handshake: observation.handshake, - ptySpawn: observation.ptySpawn, - ptyShutdown: observation.ptyShutdown, - relayAliveAfterPtyShutdown: observation.relayAliveAfterPtyShutdown, - bridgeExit: observation.bridgeExit, - relayPatchApplied: !options.skipRelayPatch, - agentSha256: fileSha256(agentPath), - nativeBindingPresent: existsSync(nativeBindingPath), - attachConsoleFailed, - relayExitedAfterClientDisconnect: !isProcessAlive(relayPid) - } - console.log(JSON.stringify(summary, null, 2)) - - if (expectFailure !== attachConsoleFailed) { - throw new Error( - expectFailure - ? `Expected the real console-list agent to fail AttachConsole. stderr:\n${relayStderr}` - : `The real console-list agent failed AttachConsole. stderr:\n${relayStderr}` - ) - } - if ( - !observation.handshake || - !observation.ptySpawn || - !observation.ptyShutdown || - !observation.relayAliveAfterPtyShutdown - ) { - throw new Error( - `Relay lifecycle did not complete. stdout:\n${relayStdout}\nstderr:\n${relayStderr}` - ) - } -} finally { - if (relayPid && isProcessAlive(relayPid)) { - stopExactProcess(relayPid, relayPath) - } - rmSync(runDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) -} - -function parseArgs(args) { - const parsed = { expect: 'clean', shell: 'cmd.exe', skipRelayPatch: false } - for (let index = 0; index < args.length; index++) { - const flag = args[index] - if (flag === '--skip-relay-patch') { - parsed.skipRelayPatch = true - continue - } - const value = args[index + 1] - if (!value || !['--node', '--node-pty', '--expect', '--shell'].includes(flag)) { - throw new Error( - 'Usage: node windows-ssh-attach-console-repro.mjs [--node PATH] [--node-pty DIR] [--shell PATH] [--skip-relay-patch] [--expect clean|attach-console-failure]' - ) - } - if (flag === '--node') { - parsed.node = value - } - if (flag === '--node-pty') { - parsed.nodePty = value - } - if (flag === '--expect') { - parsed.expect = value - } - if (flag === '--shell') { - parsed.shell = value - } - index++ - } - if (!['clean', 'attach-console-failure'].includes(parsed.expect)) { - throw new Error(`Unsupported expectation: ${parsed.expect}`) - } - return parsed -} - -function prepareRelayTree(runDir, nodePtyDir) { - for (const filename of [ - 'relay.js', - 'relay-watcher.js', - 'relay-ai-vault-service.js', - 'managed-hook-runtime.js', - NODE_PTY_PATCH_FILENAME, - '.version' - ]) { - copyFileSync(join(relayBuildDir, filename), join(runDir, filename)) - } - cpSync(nodePtyDir, join(runDir, 'node_modules', 'node-pty'), { recursive: true }) -} - -function applyPackagedNodePtyPatch(nodePath, runDir) { - const result = spawnSync(nodePath, [join(runDir, NODE_PTY_PATCH_FILENAME)], { - cwd: runDir, - encoding: 'utf8', - windowsHide: true - }) - if (result.status !== 0) { - throw new Error(`Packaged node-pty patch failed: ${result.stderr || result.stdout}`) - } -} - -function launchRelayWithoutConsole({ - nodePath, - relayPath, - runDir, - socketPath, - stdoutLog, - stderrLog -}) { - const relayArgs = [ - quoteWindowsArg(nodePath), - quoteWindowsArg(relayPath), - '--detached', - '--grace-time', - '5', - '--sock-path', - quoteWindowsArg(socketPath), - '--endpoint-dir', - quoteWindowsArg(join(runDir, 'endpoint')), - '--log-file', - quoteWindowsArg(stdoutLog), - `1>${quoteWindowsArg(stdoutLog)}`, - `2>${quoteWindowsArg(stderrLog)}` - ].join(' ') - const commandLine = `cmd.exe /d /s /c "${relayArgs}"` - const script = [ - `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(commandLine)}; CurrentDirectory = ${powerShellLiteral(runDir)} }`, - `if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`, - '$result.ProcessId' - ].join('; ') - const launched = runPowerShell(script) - const pid = Number(launched.stdout.trim()) - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error(`Could not parse detached relay pid from: ${launched.stdout}`) - } - return pid -} - -function exerciseRelayClient({ nodePath, relayPath, runDir, socketPath, shell }) { - return new Promise((resolvePromise, rejectPromise) => { - const bridge = spawn(nodePath, [relayPath, '--connect', '--sock-path', socketPath], { - cwd: runDir, - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'] - }) - const observation = { - nodeVersion: readNodeVersion(nodePath), - handshake: false, - ptySpawn: false, - ptyShutdown: false, - relayAliveAfterPtyShutdown: false, - bridgeExit: null - } - let stderr = '' - let buffer = Buffer.alloc(0) - let sentinelRead = false - let outgoingSequence = 1 - let settled = false - const timeout = setTimeout( - () => finish(new Error(`Timed out waiting for relay client lifecycle. stderr:\n${stderr}`)), - REGRESSION_TIMEOUT_MS - ) - - bridge.stderr.on('data', (data) => { - stderr += data.toString() - }) - bridge.on('error', finish) - bridge.on('close', (code, signal) => { - observation.bridgeExit = { code, signal } - if (!settled && observation.relayAliveAfterPtyShutdown) { - finish() - } else if (!settled) { - finish(new Error(`Relay bridge closed before lifecycle completed. stderr:\n${stderr}`)) - } - }) - bridge.stdout.on('data', (data) => { - try { - buffer = Buffer.concat([buffer, data]) - if (!sentinelRead) { - const sentinelIndex = buffer.indexOf(SENTINEL) - if (sentinelIndex === -1) { - return - } - buffer = buffer.subarray(sentinelIndex + SENTINEL.length) - sentinelRead = true - observation.handshake = true - request(1, 'pty.spawn', { - shellOverride: shell, - cwd: projectDir, - cols: 80, - rows: 24, - env: {} - }) - } - for (const message of drainMessages()) { - if (message.id === 1) { - throwResponseError(message) - observation.ptySpawn = true - request(2, 'pty.shutdown', { id: message.result.id, immediate: true }) - } else if (message.id === 2) { - throwResponseError(message) - observation.ptyShutdown = true - request(3, 'relay.status', {}) - } else if (message.id === 3) { - throwResponseError(message) - observation.relayAliveAfterPtyShutdown = message.result.ptys.active === 0 - bridge.stdin.end() - } - } - } catch (error) { - finish(error) - } - }) - - function request(id, method, params) { - bridge.stdin.write(encodeRequestFrame({ id, method, params }, outgoingSequence++)) - } - - function drainMessages() { - const messages = [] - while (buffer.length >= HEADER_LENGTH) { - const type = buffer[0] - const payloadLength = buffer.readUInt32BE(9) - if (buffer.length < HEADER_LENGTH + payloadLength) { - break - } - const payload = buffer.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength) - buffer = buffer.subarray(HEADER_LENGTH + payloadLength) - if (type === 1) { - messages.push(JSON.parse(payload.toString('utf8'))) - } - } - return messages - } - - function finish(error) { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - bridge.kill() - rejectPromise(error) - } else { - resolvePromise(observation) - } - } - }) -} - -function waitForPipe(socketPath, timeoutMs) { - const deadline = Date.now() + timeoutMs - return new Promise((resolvePromise, rejectPromise) => { - const attempt = () => { - const socket = createConnection(socketPath) - let settled = false - const retry = () => { - if (settled) { - return - } - settled = true - socket.destroy() - if (Date.now() >= deadline) { - rejectPromise(new Error(`Detached relay did not listen on ${socketPath}`)) - } else { - setTimeout(attempt, 50) - } - } - socket.once('connect', () => { - if (settled) { - return - } - settled = true - socket.destroy() - resolvePromise() - }) - socket.once('error', retry) - socket.setTimeout(250, retry) - } - attempt() - }) -} - -function encodeRequestFrame({ id, method, params }, sequence) { - const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, method, params })) - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = 1 - header.writeUInt32BE(sequence, 1) - header.writeUInt32BE(0, 5) - header.writeUInt32BE(payload.length, 9) - return Buffer.concat([header, payload]) -} - -function throwResponseError(message) { - if (message.error) { - throw new Error(`Relay RPC failed: ${message.error.message}`) - } -} - -function readNodeVersion(nodePath) { - return spawnSync(nodePath, ['--version'], { encoding: 'utf8' }).stdout.trim() -} - -function waitForExit(pid, timeoutMs) { - const deadline = Date.now() + timeoutMs - return new Promise((resolvePromise) => { - const poll = () => { - if (!isProcessAlive(pid) || Date.now() >= deadline) { - resolvePromise() - return - } - setTimeout(poll, 50) - } - poll() - }) -} - -function isProcessAlive(pid) { - const result = runPowerShell( - `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'ALIVE' }` - ) - return result.stdout.includes('ALIVE') -} - -function stopExactProcess(pid, relayPath) { - const script = [ - `$process = Get-CimInstance Win32_Process -Filter ${powerShellLiteral(`ProcessId = ${pid}`)}`, - `if ($process -and $process.CommandLine -like ${powerShellLiteral(`*${relayPath}*`)}) { Stop-Process -Id ${pid} -Force }` - ].join('; ') - runPowerShell(script) -} - -function runPowerShell(script) { - const encoded = Buffer.from(script, 'utf16le').toString('base64') - const result = spawnSync( - 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], - { encoding: 'utf8' } - ) - if (result.status !== 0) { - throw new Error(`PowerShell failed (${result.status}): ${result.stderr || result.stdout}`) - } - return result -} - -function quoteWindowsArg(value) { - return `"${value.replaceAll('"', '\\"')}"` -} - -function powerShellLiteral(value) { - return `'${value.replaceAll("'", "''")}'` -} - -function readIfPresent(path) { - return existsSync(path) ? readFileSync(path, 'utf8') : '' -} - -function fileSha256(path) { - return createHash('sha256').update(readFileSync(path)).digest('hex') -} diff --git a/docs/reference/agent-pty-transcript-capture.md b/docs/reference/agent-pty-transcript-capture.md index 934f0028a93f..3ae1d613f3c8 100644 --- a/docs/reference/agent-pty-transcript-capture.md +++ b/docs/reference/agent-pty-transcript-capture.md @@ -37,21 +37,23 @@ Each capture also writes `.meta.json` with the timestamp, platform PTY size, note and exit code. Commit it with the transcript; the version and account type behind a screen are not recoverable from the bytes. -**Prerequisite:** `node-pty` must be built for plain Node: +**Prerequisite:** prepare the pinned bundled Bun runtime (no native Node PTY rebuild): ``` -node config/scripts/ensure-native-runtime.mjs --runtime=node +node config/scripts/build-orcad-bun.mjs --runtime-only --out-dir out/orcad ``` -Orca itself does not need to be running, and the recorder never touches Orca state. +The Node entry launches capture under bundled Bun; `--scan` and `--help` stay Node-only. +The recorder taps native bytes before UTF-8 decoding and reuses the production terminal +backend, including Windows job ownership and exit draining. Orca itself does not need to +be running, and the recorder never touches Orca state. ### Platform notes - **macOS / Linux:** nothing special. `TERM=xterm-256color` is set for the child. - **Windows:** run it from Windows Terminal / PowerShell, not a Git Bash (MSYS) pane — MSYS - rewrites arguments that start with `/`, which mangles the `cmd.exe /c` hand-off. A `.cmd` or - `.bat` agent shim cannot be spawned by node-pty directly, so the recorder routes those through - `cmd.exe` for you. + rewrites arguments that start with `/`, which mangles the `cmd.exe /c` hand-off. The production Windows gate resolves recognised npm/pnpm agent shims directly; + other `.cmd`/`.bat` scripts use the canonical batch argument encoder. - **WSL:** capture _inside_ the distro (run the recorder from the distro's checkout). Recording `wsl.exe` from the Windows side adds the login-shell banner to the transcript. - **SSH:** record on the execution host. A transcript recorded locally is not evidence about what diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index 3fab0cfcb5e0..38c9c0014956 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -17,8 +17,7 @@ requirement. ## Why this needs attention A native module (`.node`) links against the glibc of the machine that compiled -it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest` -runner, whose glibc rises over time as the image is bumped. A binary compiled on +it. Build runners' glibc rises over time as their images are bumped. A binary compiled on a newer glibc can reference symbol versions that do not exist on an older target, and the dynamic loader then refuses to load it: @@ -26,8 +25,8 @@ and the dynamic loader then refuses to load it: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node) ``` -Because the Orca main process loads node-pty at startup, that failure crashes the -whole app before a window appears — this is exactly what shipped in v1.4.150 and +Older Orca releases loaded node-pty in the main process at startup, so that failure +crashed the whole app before a window appeared. This shipped in v1.4.150 and broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)). The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved @@ -45,20 +44,11 @@ the floor, so node-pty was the sole blocker. ## How we keep the floor -**1. Pin the relocated symbols (the fix).** -[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch) -adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and -`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64, -`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships -those as compatibility aliases, so the reference resolves on both new build hosts -and old targets. - -The catch: gcc defaults to `--as-needed` and, since the pinned symbols now -resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread` -from `DT_NEEDED`. On the target those libraries are where the symbols actually -live, so the patch's `binding.gyp` `ldflags` force -`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`. -The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched. +**1. Use the qualified bundled terminal runtime.** Desktop and headless terminal +services use pinned Bun. The former node-pty symbol-version patch is retired; +the historical failure above still explains why every native artifact needs a +floor check. Native dependencies compiled from source must use a compatible +sysroot or explicitly preserve the older symbol versions and required libraries. **2. Gate packaging (the regression guard).** [`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs) @@ -76,12 +66,11 @@ only in libutil. A future runner bump, a new native dependency, or a dropped ldflag therefore fails the release build instead of shipping a Linux app that crashes on launch. -> The gate is a static invariant, not an integration test. The load path was -> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require` -> node-pty and spawn a shell). A CI smoke test that loads the packaged -> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended -> follow-up — it would make the load path self-verifying and stay valid even if -> the build ever moves to an old-glibc sysroot. +The static gate is paired with +`config/scripts/run-linux-packaged-terminal-floor-smoke.mjs`, which launches the +packaged Bun daemon through the packaged Electron client in Ubuntu 20.04. It +checks real shell output, resize, history/snapshots, reconnect and exit handling +on the release architectures. The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires `GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker @@ -92,8 +81,8 @@ app itself still launches on stock 20.04. **3. Qualify the bundled headless runtime and its native dependencies.** Orcad and the SSH relay use pinned Bun for terminals. They do not install node-pty -prebuilds or compile node-pty on the remote host. Desktop Electron still uses the -patched node-pty dependency and the build gates above. +prebuilds or compile node-pty on the remote host. Desktop Electron uses the same +terminal service runtime; the build gates above cover its packaged artifacts. The Bun runtime catalog selects glibc or musl artifacts for each supported CPU. The bundled-runtime CI checks the Linux glibc floor and runs native dependency @@ -107,7 +96,7 @@ proof that a host cannot start. - Prefer packages that ship prebuilt binaries compiled against an old toolchain (manylinux / `glibc 2.17`-class), like `@parcel/watcher`. - For a module we compile from source, if the gate flags it, either pin the - offending symbols the way node-pty does, or build it in an old-glibc container. + offending symbols to compatible versions, or build it in an old-glibc container. - To check locally on a Linux host, list what a binary requires (skipping the weak `0x02`-flagged needs the loader tolerates): diff --git a/docs/reference/windows-daemon-host-relocation.md b/docs/reference/windows-daemon-host-relocation.md index c5cb34e8ab54..29baa6abe1c1 100644 --- a/docs/reference/windows-daemon-host-relocation.md +++ b/docs/reference/windows-daemon-host-relocation.md @@ -1,13 +1,21 @@ # Windows daemon-host relocation -On Windows the terminal daemon does not run from the install directory. Before it forks the -daemon, Orca materializes a trimmed copy of its own runtime under -`%LOCALAPPDATA%\Orca\daemon-host\\` and forks the daemon from there -(`src/main/daemon/daemon-host-relocation.ts`). This is what keeps live terminals alive across an -auto-update and across a crash of the main process. - -Read this before changing the copy plan, the host exe name, the LOCALAPPDATA layout, or -`config/nsis/orca-installer-hooks.nsh`. +On Windows the terminal daemon runs from an immutable Bun runtime copy outside +the install directory. `src/main/daemon/daemon-bun-runtime.ts` verifies and +materializes its entry, runtime, ConPTY provider and process-inspection addon under +`%LOCALAPPDATA%\Orca\terminal-daemon-host\managed-v1\bun-\`. +Launch pins protect publication through authenticated readiness; runtime retention +preserves live or unverifiable owners. A packaged Windows launch requires +LOCALAPPDATA; missing storage fails visibly rather than using an update-unsafe +install directory or a roaming profile. + +Existing Electron-hosted daemons keep their original owners and files until their +sessions drain. `daemon-host-relocation.ts` now only prunes those legacy copies. +The Electron copy plan and executable-name discussion below record that older +implementation; do not restore its materialization commands for Bun. + +Read this before changing runtime placement or `config/nsis/orca-installer-hooks.nsh`. +The installer must preserve both live legacy hosts and current Bun hosts. ## What the relocation actually escapes diff --git a/docs/reference/windows-msys-job-breakaway.md b/docs/reference/windows-msys-job-breakaway.md index 7265293bd1be..dd93074b1944 100644 --- a/docs/reference/windows-msys-job-breakaway.md +++ b/docs/reference/windows-msys-job-breakaway.md @@ -5,10 +5,11 @@ object unless the job is created **without** `JOB_OBJECT_LIMIT_BREAKAWAY_OK`. `terminatePtyJob` then reports `terminated` and leaves the child running — the orphan that holds a worktree directory open. -The denial is already in `config/patches/node-pty@1.1.0.patch` -(`usesCygwinRuntime`, added in #19068). This page records the measurement -behind it, because the failure mode it prevents is indistinguishable from a -stale native addon and the gates of the day could not tell the two apart. +Current terminals use the Bun Windows job implementation in +`src/main/daemon/pty-subprocess/windows-bun-pty-job.ts`; native MSYS coverage remains +in `src/main/windows/windows-msys-job.win32.test.ts`. The node-pty patch and addon +rebuild commands below describe the retired backend and are historical evidence, +not installation instructions for the current build. ## The mechanism diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index e56bec2cd7a8..017d48767a4b 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -249,7 +249,7 @@ why only absence does. ## The relay has no binding, and falls back -Relay deployment installs only `node-pty` and `@parcel/watcher` on the remote +The older relay deployment installed only `node-pty` and `@parcel/watcher` on the remote host (`RELAY_NATIVE_DEPS` in `src/main/ssh/ssh-relay-deploy.ts`), so a Windows machine used as an SSH host has no `@vscode/windows-process-tree` at all. It is not added there on purpose. Both ways of installing it fail, and both were @@ -547,7 +547,20 @@ inside one call with a blocking `Sleep(1000)` in the middle, which would hold a libuv threadpool slot for a full second out of the Resource Manager's two-second poll. -## Owning a PTY's process tree +## Current terminal ownership + +Desktop and headless terminals use Bun's PTY adapter. Per-terminal jobs live in +`src/main/daemon/pty-subprocess/windows-bun-pty-job.ts`, with native operations in +`windows-bun-pty-native.ts`. The MSYS no-breakaway and daemon crash-reaping +contracts below still apply. `@vscode/windows-process-tree` remains a separate +patched native dependency for process inspection; it is still rebuilt and +validated by the install/package scripts. + +## Historical node-pty ownership implementation + +The remainder records the retired backend's behavior and incident analysis. +Its node-pty patch, source-build commands and addon gates no longer apply to +current local installations. `src/main/windows/windows-pty-job.ts` is the counterpart to reading the table: it answers "is this tree mine, and how do I kill it?" with a handle instead of diff --git a/docs/reference/wsl-runner-verification.md b/docs/reference/wsl-runner-verification.md index 59667f513ded..16a95aafb9be 100644 --- a/docs/reference/wsl-runner-verification.md +++ b/docs/reference/wsl-runner-verification.md @@ -25,7 +25,7 @@ Each fails on a **new** offender _and_ on a **stale** entry, so the count can on ## 3. Real-binary — the assertions nothing else can make -**Windows CI** (`package (windows)` job in `pr.yml`) rebuilds node-pty from patched source and runs the `win32` suites against a real ConPTY: a real detached grandchild, a real job kill, and the inverse — a clean `exit` must leave backgrounded work alone. +**Windows CI** (`package (windows)` job in `pr.yml`) prepares the pinned Bun runtime and ConPTY provider, then runs the `win32` suites against real terminals: a real detached grandchild, a real job kill, and the inverse — a clean `exit` must leave backgrounded work alone. **Real WSL distro** — not in CI; WSL isn't available on hosted runners. diff --git a/docs/site/content/docs/ssh.mdx b/docs/site/content/docs/ssh.mdx index c97765d3481d..2a1baa00ee1f 100644 --- a/docs/site/content/docs/ssh.mdx +++ b/docs/site/content/docs/ssh.mdx @@ -82,18 +82,16 @@ You do not flip a separate “Kerberos mode” switch for config-imported hosts Hardware-backed OpenSSH identities (`ed25519-sk`, `ecdsa-sk`, including agent-backed security keys) also use **system OpenSSH** rather than the built-in ssh2 client. Orca detects the key type from your identity files and hands the connection to the OS OpenSSH binary so the touch / PIN prompt works as usual. Regular Ed25519, ECDSA, and RSA keys stay on the built-in transport. If OpenSSH is not available on the machine, those FIDO2 targets cannot authenticate until it is installed and on `PATH` (or in the usual Windows/macOS/Linux locations). -## Linux hosts without a C/C++ toolchain +## Remote terminal runtime -On first connect, Orca installs a small relay on the remote. Remote terminals need a native `node-pty` module. Linux packages often compile on the host; macOS/Windows relays use prebuilds. +On first connect, Orca installs a relay with a pinned Bun runtime and the native +artifacts for the remote platform. Current remote terminals do not require a +C/C++ toolchain or a node-pty build on the host. The remote must support the +runtime's operating-system and CPU requirements. -If the remote is missing **make**, a **C++ compiler**, and **python3**, Orca still finishes connect for **files, git, and the editor**, but **remote terminals will not work** until build tools are installed. Example install hints Orca may surface: - -- Debian/Ubuntu: `sudo apt-get install -y build-essential python3` -- Fedora/RHEL: `sudo dnf install -y make gcc gcc-c++ python3` -- Alpine: `sudo apk add build-base python3` -- Arch: `sudo pacman -S --needed base-devel python` - -Install the tools, then reconnect so the relay can install native modules. +Older Orca clients can still deploy the previous Node-based relay. If that client +reports a missing native build toolchain, update the client or follow its +installation guidance before reconnecting. ## Port forwarding diff --git a/package.json b/package.json index b4374a3dc932..cabcd2c8f04f 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:bun:profile": "node config/scripts/run-bun-profile-tests.mjs", "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", - "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", + "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:terminal-daemon && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", @@ -87,24 +87,24 @@ "verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs", "verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check", "audit:localization": "node config/scripts/audit-localization-coverage.mjs", + "build:terminal-daemon": "node config/scripts/build-terminal-daemon.mjs", "build:cli-runtime": "node config/scripts/build-cli-runtime.mjs", "build:cli": "pnpm run build:cli-runtime && tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/build-cli-bin.mjs && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", "test:linux-cli-contract": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", - "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", + "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:terminal-daemon && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", "build:mobile-web": "node config/scripts/build-mobile-web-app-bundle.mjs && node config/scripts/verify-mobile-web-app-bundle.mjs", - "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:terminal-daemon && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", "build": "pnpm run build:desktop && pnpm run build:native", - "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", - "build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:terminal-daemon && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", + "build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:terminal-daemon && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs", "reclaim:dev-bundles": "node config/scripts/reclaim-dev-electron-bundles.mjs", - "rebuild:node": "pnpm rebuild node-pty", "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", @@ -184,7 +184,6 @@ "electron-updater": "^6.8.9", "i18next": "^26.3.1", "jsonc-parser": "^3.3.1", - "node-pty": "^1.1.0", "posthog-node": "^5.52.5", "proper-lockfile": "4.1.2", "qrcode": "^1.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c05265397b9a..c7407b2ee733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,7 +117,6 @@ patchedDependencies: '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e '@xterm/xterm@6.1.0-beta.303': dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 - node-pty@1.1.0: 92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192 importers: @@ -159,9 +158,6 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 - node-pty: - specifier: ^1.1.0 - version: 1.1.0(patch_hash=92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192) posthog-node: specifier: ^5.52.5 version: 5.52.5 @@ -6015,9 +6011,6 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-pty@1.1.0: - resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} - node-releases@2.0.54: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} @@ -13098,10 +13091,6 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192): - dependencies: - node-addon-api: 7.1.1 - node-releases@2.0.54: {} nopt@9.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1cf678294784..8bb211189f93 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,7 +51,6 @@ allowBuilds: electron-winstaller: false esbuild: true msw: false - node-pty: true sherpa-onnx: true ssh2: false '@orca/windows-registry': false @@ -61,7 +60,6 @@ overrides: patchedDependencies: '@xterm/addon-image@0.10.0-beta.300': config/patches/@xterm__addon-image@0.10.0-beta.300.patch - node-pty@1.1.0: config/patches/node-pty@1.1.0.patch '@xterm/addon-ligatures@0.11.0-beta.300': config/patches/@xterm__addon-ligatures@0.11.0-beta.300.patch '@xterm/addon-webgl@0.20.0-beta.299': config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch '@xterm/addon-search@0.17.0-beta.300': config/patches/@xterm__addon-search@0.17.0-beta.300.patch diff --git a/src/cli/runtime/cli-bun-environment.integration.test.ts b/src/cli/runtime/cli-bun-environment.integration.test.ts new file mode 100644 index 000000000000..701d0f202b33 --- /dev/null +++ b/src/cli/runtime/cli-bun-environment.integration.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { build } from 'esbuild' +import { expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +const runtime = process.env.BUN_EXECUTABLE +it.skipIf(!runtime)('does not load project dotenv files when launching the CLI', async () => { + if (!runtime) { + throw new Error('BUN_EXECUTABLE is required') + } + const root = await mkdtemp(join(tmpdir(), 'orca-cli-environment-')) + try { + const launcher = join(root, 'launcher.cjs') + const entry = join(root, 'entry.cjs') + await build({ + stdin: { + contents: + "import { launchBunCli } from './src/cli/runtime/cli-bun-launcher'; launchBunCli(process.argv[2], process.argv[3], [])", + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: launcher + }) + await writeFile( + join(root, '.env'), + 'ORCA_USER_DATA_PATH=foreign-profile\nORCA_CLI_CWD=foreign-workspace\n' + ) + await writeFile( + entry, + 'console.log(JSON.stringify({profile:process.env.ORCA_USER_DATA_PATH??null,cwd:process.env.ORCA_CLI_CWD??null}));process.disconnect?.()' + ) + const env: NodeJS.ProcessEnv = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + delete env.ORCA_USER_DATA_PATH + delete env.ORCA_CLI_CWD + const result = await runProcess({ + program: process.execPath, + args: [launcher, runtime, entry], + cwd: root, + env, + timeoutMs: 10_000 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.timedOut).toBe(false) + expect(JSON.parse(result.stdout)).toEqual({ profile: null, cwd: null }) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts index b3257e6972c4..ad2b4de7d658 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -82,6 +82,7 @@ function sweepWith( server: AgentHookServer, overrides: { probeLiveLocalPty?: (ptyId: string) => boolean | null | Promise + probeLiveWslPty?: (ptyId: string) => Promise executionHostId?: string | null boundPtyIdByPaneKey?: Record persistedPtyIdByPaneKey?: Record @@ -90,6 +91,7 @@ function sweepWith( return sweepRestoredSubagentsWithoutLiveAgent({ probeLiveLocalPty: async (ptyId) => overrides.probeLiveLocalPty ? await overrides.probeLiveLocalPty(ptyId) : false, + probeLiveWslPty: overrides.probeLiveWslPty, isLocalExecutionHost: () => isLocalExecutionHost( overrides.executionHostId === undefined ? 'local' : overrides.executionHostId @@ -114,6 +116,32 @@ function paneStatus( } describe('restored subagent liveness sweep', () => { + it.each([undefined, true, null, false])( + 'uses only the owning guest provider to reap WSL panes (%s)', + async (guestLive) => { + const server = await restartWithInFlightSubagent() + const guestId = 'wsl:Ubuntu@@daemon-build@@guest-pty' + const nativeProbe = vi.fn(async () => false) + const guestProbe = vi.fn(async () => guestLive ?? null) + try { + expect( + await sweepWith(server, { + persistedPtyIdByPaneKey: { [PANE]: guestId }, + probeLiveLocalPty: nativeProbe, + ...(guestLive === undefined ? {} : { probeLiveWslPty: guestProbe }) + }) + ).toBe(guestLive === false ? 1 : 0) + expect(nativeProbe).not.toHaveBeenCalled() + if (guestLive !== undefined) { + expect(guestProbe).toHaveBeenCalledWith(guestId) + } + expect(paneStatus(server).state).toBe(guestLive === false ? 'done' : 'working') + } finally { + server.stop() + } + } + ) + it('reaps the phantom seed so a slept-through pane reaches done', async () => { const server = await restartWithInFlightSubagent() try { diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts index 3402ab7bf5be..1800a740e924 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts @@ -12,6 +12,7 @@ import { parseWorkspaceKey } from '../../shared/workspace-scope' export type RestoredSubagentLivenessSweepDeps = { /** Targeted provider liveness, or null when the provider cannot prove either state. */ probeLiveLocalPty: (ptyId: string) => Promise + probeLiveWslPty?: (ptyId: string) => Promise isLocalExecutionHost: (worktreeId: string | undefined) => boolean /** PTY bound to this pane in the current session, if it has one. */ getBoundPtyIdForPaneKey: (paneKey: string) => string | undefined @@ -44,7 +45,10 @@ export async function sweepRestoredSubagentsWithoutLiveAgent( try { let probe = probesByPtyId.get(ptyId) if (!probe) { - probe = deps.probeLiveLocalPty(ptyId) + // Native inventory cannot prove absence on a guest daemon. + probe = ptyId.startsWith('wsl:') + ? (deps.probeLiveWslPty?.(ptyId) ?? Promise.resolve(null)) + : deps.probeLiveLocalPty(ptyId) probesByPtyId.set(ptyId, probe) } const live = await probe diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 71699f57d284..2bca557220f6 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -21,6 +21,7 @@ export async function installWslGuestHooks(options: { guestHome: string codexHomePath: string | null distro: string + user?: string installHooks: typeof installRemoteManagedAgentHooks settings: ManagedHookDetectionSettings warn: (message: string) => void @@ -50,7 +51,7 @@ export async function installWslGuestHooks(options: { if (agents.length === 0) { return } - if (agents.includes('codex') && codexHomePath) { + if (agents.includes('codex') && codexHomePath && options.user === undefined) { try { const status = await installCodex(codexHomePath, distro) if (status?.state === 'error') { @@ -64,11 +65,11 @@ export async function installWslGuestHooks(options: { ) } } - // Codex is redirected into the runtime home and must use the canonical - // runtime-host writer above; the relay adapter owns all other agents. - const remoteAgents = agents.filter((agent) => agent !== 'codex') + // Explicit users install through their own home-scoped relay, never the default-user writer. + const remoteAgents = agents.filter((agent) => agent !== 'codex' || options.user !== undefined) const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { agents: remoteAgents, + ...(options.user && codexHomePath ? { codexHomeDir: codexHomePath } : {}), ...(claudeVersion ? { claudeVersion } : {}) }) const failed = results.filter((r) => r.state === 'error').length diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 1ee3ee64114b..417db4f9a7dd 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -60,7 +60,7 @@ export type WslHookRelayManagerDeps = { readBundle: (jsPath: string) => Buffer listDistros: () => Promise isDistroRunning: typeof isWslDistroRunning - prepareRuntime: (distro: string) => Promise + prepareRuntime: (distro: string, user?: string) => Promise spawnRelay: typeof spawnWslRelayProcess runInstall: typeof runWslInstallProcess waitForSentinel: typeof waitForWslRelaySentinel @@ -99,7 +99,8 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { readBundle: (jsPath) => readFileSync(jsPath), listDistros: () => listWslDistrosAsync(), isDistroRunning: isWslDistroRunning, - prepareRuntime: (distro) => ensureWslBunRuntime(createRunningWslRuntimeRunner(distro)), + prepareRuntime: (distro, user) => + ensureWslBunRuntime(createRunningWslRuntimeRunner(distro, undefined, user)), spawnRelay: spawnWslRelayProcess, runInstall: runWslInstallProcess, waitForSentinel: waitForWslRelaySentinel, diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index 103a5e9909b5..9fcbdb242440 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -22,6 +22,7 @@ type GuestInstallDeps = { /** Structural slice of the manager's DistroState this pass reads and writes. */ type GuestInstallState = { distro: string + user?: string mux?: SshChannelMultiplexer guestHome?: string codexHomePath?: string @@ -72,6 +73,7 @@ async function installGuestHooksAndPlugins( guestHome, codexHomePath: state.codexHomePath ?? null, distro: state.distro, + user: state.user, installHooks: deps.installHooks, installCodex: deps.installCodex, settings: deps.managedHookSettings(), diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.test.ts b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts index 6dcd93bdda85..a075b07668ae 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts @@ -12,6 +12,26 @@ import { listRunningWslDistrosAsync } from '../wsl' import { isWslDistroRunning, spawnWslRelayProcess } from './wsl-hook-relay-launch' describe('spawnWslRelayProcess', () => { + it('pins the explicit user before --exec', () => { + spawnWslRelayProcess('Ubuntu', {}, '1.2.3', '/home/ada/bun', 'ada') + expect(spawnMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + args: [ + '-d', + 'Ubuntu', + '--user', + 'ada', + '--exec', + 'sh', + '-c', + expect.any(String), + 'orca-hook-relay', + '/home/ada/bun' + ] + }) + ) + }) + it('names an explicit Windows directory rather than inheriting one', () => { spawnWslRelayProcess('Ubuntu', {}, '1.2.3', '/home/ada/bun') diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 7b814ad3bcfe..5fab731c742c 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -124,7 +124,8 @@ export function spawnWslRelayProcess( distro: string, env: NodeJS.ProcessEnv, version: string, - executable: string + executable: string, + user?: string ): ChildProcessWithoutNullStreams { // Why: --exec bypasses the distro's default login shell — a bare `--` // routes through it (a fish/nushell chsh could mangle the command) and @@ -134,7 +135,7 @@ export function spawnWslRelayProcess( const command = `exec sh "${guestRelayDirExpr(version)}/launch.sh" "$1"` return spawnProcess({ program: 'wsl.exe', - args: buildWslExecArgs(distro, ['sh', '-c', command, 'orca-hook-relay', executable]), + args: buildWslExecArgs(distro, ['sh', '-c', command, 'orca-hook-relay', executable], user), env, // Why explicit (#16463): the guest path is in `command`, so the Windows cwd // only decides whether CreateProcessW succeeds -- and an inherited one is a @@ -162,10 +163,12 @@ export async function runWslInstallProcess( script: string, // Unused: the install script embeds its own version/paths and reads // nothing from the crossed guest environment. - _env: NodeJS.ProcessEnv + _env: NodeJS.ProcessEnv, + user?: string ): Promise<{ code: number | null; stderr: string }> { const result = await runWslProcess({ distro, + user, loginPath: 'none', script, // Declared because the payload is opaque here: it is POSIX plus a heredoc. @@ -186,7 +189,7 @@ const TRANSIENT_RETRY_LIMIT = 2 export type WslRelayLaunchIo = { isDistroRunning: typeof isWslDistroRunning - prepareRuntime: (distro: string) => Promise + prepareRuntime: (distro: string, user?: string) => Promise spawnRelay: typeof spawnWslRelayProcess waitForSentinel: typeof waitForWslRelaySentinel runInstall: typeof runWslInstallProcess @@ -201,6 +204,7 @@ export type WslRelayLaunchIo = { * through `onFailure`; non-startup errors propagate to the caller. */ export async function launchWslRelayWithInstall(options: { distro: string + user?: string env: NodeJS.ProcessEnv bundleJsPath: string version: string @@ -228,14 +232,20 @@ export async function launchWslRelayWithInstall(options: { } return running } - const executable = await io.prepareRuntime(distro) + const executable = await io.prepareRuntime(distro, ...(options.user ? [options.user] : [])) let installTried = false let transientRetries = 0 for (;;) { if (!(await requireRunning())) { return } - const child = io.spawnRelay(distro, env, version, executable) + const child = io.spawnRelay( + distro, + env, + version, + executable, + ...(options.user ? [options.user] : []) + ) options.onChild(child) try { const transport = await io.waitForSentinel(child) @@ -269,7 +279,12 @@ export async function launchWslRelayWithInstall(options: { return } const script = buildGuestInstallScript(io.readBundle(bundleJsPath), version) - const result = await io.runInstall(distro, script, env) + const result = await io.runInstall( + distro, + script, + env, + ...(options.user ? [options.user] : []) + ) if (result.code === 0) { continue } diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 68ce8954c332..528583254301 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -184,6 +184,7 @@ describe('WslHookRelayManager', () => { function guestTransport( options: { registerInstallPlugins?: boolean + guestHome?: string detectedAgents?: string[] claudeVersion?: string } = {} @@ -191,7 +192,7 @@ describe('WslHookRelayManager', () => { const { registerInstallPlugins = true, detectedAgents = ['codex'], claudeVersion } = options const harness = createGuestHarness() harnesses.push(harness) - registerWslHookFsHandlers(harness.guestDispatcher, home) + registerWslHookFsHandlers(harness.guestDispatcher, options.guestHome ?? home) harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ replayed: 0 })) @@ -260,6 +261,70 @@ describe('WslHookRelayManager', () => { return { manager: new WslHookRelayManager(deps), deps } } + it('isolates explicit guest users and preserves them across hooks-off restart', async () => { + const aliceHome = `${home}/alice` + const bobHome = `${home}/bob` + const waitForSentinel = vi.fn() + for (const guestHome of [aliceHome, bobHome, aliceHome, bobHome]) { + waitForSentinel.mockImplementationOnce(async () => guestTransport({ guestHome })) + } + const { manager, deps } = createManager({ waitForSentinel }) + try { + await manager.ensureForDistro('Ubuntu', `${aliceHome}/.codex`, undefined, 'alice') + await manager.ensureForDistro('Ubuntu', `${bobHome}/.codex`, undefined, 'bob') + expect(deps.spawnRelay).toHaveBeenCalledTimes(2) + expect(deps.prepareRuntime).toHaveBeenCalledWith('Ubuntu', 'alice') + expect(deps.prepareRuntime).toHaveBeenCalledWith('Ubuntu', 'bob') + expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull() + expect(manager.getGuestEndpointFilePath('Ubuntu', 'alice')).not.toBeNull() + expect(manager.getGuestEndpointFilePath('Ubuntu', 'bob')).not.toBeNull() + expect(deps.installCodex).not.toHaveBeenCalled() + expect(deps.installHooks).toHaveBeenCalledWith( + expect.anything(), + aliceHome, + expect.objectContaining({ agents: ['codex'], codexHomeDir: `${aliceHome}/.codex` }) + ) + expect(deps.installHooks).toHaveBeenCalledWith( + expect.anything(), + bobHome, + expect.objectContaining({ agents: ['codex'], codexHomeDir: `${bobHome}/.codex` }) + ) + await manager.ensureForDistro('ubuntu', undefined, undefined, 'alice') + expect(deps.spawnRelay).toHaveBeenCalledTimes(2) + manager.disposeAll({ permanent: false }) + manager.resumeStoppedRelays() + await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(4)) + const users = vi.mocked(deps.spawnRelay).mock.calls.map((call) => call[4]) + expect(users).toEqual(['alice', 'bob', 'alice', 'bob']) + } finally { + manager.disposeAll() + } + }) + + it('keeps the explicit user through stale-install retry', async () => { + const { manager, deps } = createManager({ + waitForSentinel: vi + .fn() + .mockRejectedValueOnce(startupError(78)) + .mockImplementation(async () => guestTransport()) + }) + try { + await manager.ensureForDistro('Ubuntu', undefined, undefined, 'alice') + expect(deps.runInstall).toHaveBeenCalledWith( + 'Ubuntu', + expect.any(String), + expect.anything(), + 'alice' + ) + expect(vi.mocked(deps.spawnRelay).mock.calls.map((call) => call[4])).toEqual([ + 'alice', + 'alice' + ]) + } finally { + manager.disposeAll() + } + }) + it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro('Ubuntu', codexHome) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 22dcd55dcd9e..ff51d76f1a39 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -38,7 +38,10 @@ export class WslHookRelayManager { private deps: WslHookRelayManagerDeps private recovery: WslRelayRecovery private states = new Map() - private stoppedByHooksOff = new Map() + private stoppedByHooksOff = new Map< + string, + Pick + >() private defaultDistro: string | null = null private disposed = false private warnedBundleMissing = false @@ -48,10 +51,12 @@ export class WslHookRelayManager { isDistroRunning: (distro) => this.deps.isDistroRunning(distro), warn: (message) => this.deps.warn(message), isDisposed: () => this.disposed, - isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state, - restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath), + isCurrent: (state) => + this.states.get(wslHookRelayStateKey(state.distro, state.user)) === state, + restart: (distro, user) => + this.ensureForDistro(distro, this.stateFor(distro, user)?.codexHomePath, undefined, user), dropState: (state) => { - const key = wslHookRelayStateKey(state.distro) + const key = wslHookRelayStateKey(state.distro, state.user) if (this.states.get(key) === state) { this.states.delete(key) } @@ -65,36 +70,36 @@ export class WslHookRelayManager { async ensureForDistro( distro: string | null, codexHomePath?: string | null, - launchKind?: 'pi' | 'omp' + launchKind?: 'pi' | 'omp', + user?: string ): Promise { if (this.disposed || !isWslHookRelayAllowed(this.deps)) { return } - await this.ensureInternal(distro, codexHomePath ?? undefined, launchKind).catch((err) => { + await this.ensureInternal(distro, codexHomePath ?? undefined, launchKind, user).catch((err) => { const detail = err instanceof Error ? err.message : String(err) this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) }) } - private stateFor(distro: string | null): WslRelayDistroState | undefined { - return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? '')) + private stateFor(distro: string | null, user?: string): WslRelayDistroState | undefined { + return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? '', user)) } /** Guest endpoint path once install completes. */ - getGuestEndpointFilePath(distro: string | null): string | null { - return this.stateFor(distro)?.connectedAt - ? (this.stateFor(distro)?.guestEndpointFilePath ?? null) + getGuestEndpointFilePath(distro: string | null, user?: string): string | null { + return this.stateFor(distro, user)?.connectedAt + ? (this.stateFor(distro, user)?.guestEndpointFilePath ?? null) : null } getOpenCodeOverlayDir( distro: string | null, - agent: 'opencode' | 'opencode2' = 'opencode' + agent: 'opencode' | 'opencode2' = 'opencode', + user?: string ): string | null { - const state = this.stateFor(distro) - return agent === 'opencode2' - ? (state?.opencode2OverlayDir ?? null) - : (state?.opencodeOverlayDir ?? null) + const state = this.stateFor(distro, user) + return state?.[agent === 'opencode2' ? 'opencode2OverlayDir' : 'opencodeOverlayDir'] ?? null } - getGuestAgentPath(distro: string | null, kind: 'pi' | 'omp'): string | null { - const state = this.stateFor(distro) + getGuestAgentPath(distro: string | null, kind: 'pi' | 'omp', user?: string): string | null { + const state = this.stateFor(distro, user) return kind === 'pi' ? (state?.piAgentDir ?? null) : (state?.ompStatusExtension ?? null) } /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the @@ -106,7 +111,11 @@ export class WslHookRelayManager { state.mux?.dispose() state.child?.kill() if (!permanent) { - this.stoppedByHooksOff.set(state.distro, state.codexHomePath) + this.stoppedByHooksOff.set(wslHookRelayStateKey(state.distro, state.user), { + distro: state.distro, + user: state.user, + codexHomePath: state.codexHomePath + }) } } this.states.clear() @@ -114,22 +123,25 @@ export class WslHookRelayManager { /** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut * down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */ resumeStoppedRelays(): void { - resumeWslStoppedRelays(this.stoppedByHooksOff, this.deps.isDistroRunning, (distro, home) => - this.ensureForDistro(distro, home) + resumeWslStoppedRelays( + this.stoppedByHooksOff, + this.deps.isDistroRunning, + (distro, home, user) => this.ensureForDistro(distro, home, undefined, user) ) } private async ensureInternal( requestedDistro: string | null, requestedCodexHomePath?: string, - launchKind?: 'pi' | 'omp' + launchKind?: 'pi' | 'omp', + user?: string ): Promise { const distro = requestedDistro ?? (await this.resolveDefaultDistro()) if (!distro || this.disposed) { return } - const key = wslHookRelayStateKey(distro) + const key = wslHookRelayStateKey(distro, user) const existing = this.states.get(key) - if (requestedCodexHomePath) { + if (requestedCodexHomePath && user === undefined) { recordManagedWslCodexHome(distro, requestedCodexHomePath) } if (existing) { @@ -173,6 +185,7 @@ export class WslHookRelayManager { } const state: WslRelayDistroState = { distro, + user, phase: 'starting', failures: existing?.failures ?? 0, opencodeOverlayDir: existing?.opencodeOverlayDir, @@ -187,6 +200,7 @@ export class WslHookRelayManager { const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey) state.startup = launchWslRelayWithInstall({ distro: state.distro, + user: state.user, env, bundleJsPath: bundle.jsPath, version: bundle.version, diff --git a/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts b/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts index 8e9e425a05c3..8fde244706d5 100644 --- a/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts @@ -13,6 +13,11 @@ describe('ensureWslHookRelayForReattach', () => { }) it.each([ + [ + 'guest daemon owner', + { id: 'wsl:Ubuntu@@owner@@terminal', isReattach: true, wslDistro: 'Ubuntu' }, + null + ], ['fresh WSL spawn', { wslDistro: 'Ubuntu' }, null], ['native reattach', { isReattach: true, wslDistro: null }, null], ['legacy reattach without ownership context', { isReattach: true }, null], diff --git a/src/main/agent-hooks/wsl-hook-relay-reattach.ts b/src/main/agent-hooks/wsl-hook-relay-reattach.ts index 837fe82b9837..faceb3fb88d9 100644 --- a/src/main/agent-hooks/wsl-hook-relay-reattach.ts +++ b/src/main/agent-hooks/wsl-hook-relay-reattach.ts @@ -1,7 +1,8 @@ import type { PtySpawnResult } from '../providers/pty-spawn-result' import { wslHookRelayManager } from './wsl-hook-relay-manager' -type ReattachResult = Pick +type ReattachResult = Pick & + Partial> export function ensureWslHookRelayForReattach( result: ReattachResult, @@ -10,7 +11,13 @@ export function ensureWslHookRelayForReattach( wslHookRelayManager.ensureForDistro(distro) ): void { // Why: the current renderer preference can differ from the surviving PTY's proven distro ownership. - if (connectionId || result.isReattach !== true || typeof result.wslDistro !== 'string') { + // Guest daemon hooks must reconnect through their captured user, never the current default. + if ( + connectionId || + result.id?.startsWith('wsl:') || + result.isReattach !== true || + typeof result.wslDistro !== 'string' + ) { return } const distro = result.wslDistro.trim() diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts index 778b9e36172d..57e00173b58d 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -14,21 +14,24 @@ function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { } describe('WslRelayRecovery', () => { - it('re-ensures the distro when the restart timer fires and the distro is running', async () => { - const restart = vi.fn() - const recovery = new WslRelayRecovery({ - isDistroRunning: async () => true, - warn: vi.fn(), - isDisposed: () => false, - isCurrent: () => true, - restart, - dropState: vi.fn() - }) - const state = makeState() - recovery.scheduleRestart(state) - await waitFor(() => restart.mock.calls.length === 1) - expect(restart).toHaveBeenCalledWith('Ubuntu') - }) + it.each([undefined, 'alice'])( + 're-ensures the same user %s when the running distro recovers', + async (user) => { + const restart = vi.fn() + const recovery = new WslRelayRecovery({ + isDistroRunning: async () => true, + warn: vi.fn(), + isDisposed: () => false, + isCurrent: () => true, + restart, + dropState: vi.fn() + }) + const state = { ...makeState(), user } + recovery.scheduleRestart(state) + await waitFor(() => restart.mock.calls.length === 1) + expect(restart).toHaveBeenCalledWith('Ubuntu', ...(user ? [user] : [])) + } + ) it('drops the state instead of booting a stopped distro', async () => { const restart = vi.fn() diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.ts index bebc264d4335..13968c55795c 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.ts @@ -5,6 +5,7 @@ const RUNNING_PROBE_RETRY_DELAY_MS = 60_000 export type WslRelayRecoveryState = { distro: string + user?: string cooldownUntil: number restartTimer?: ReturnType reinstallTimer?: ReturnType @@ -16,7 +17,7 @@ export type WslRelayRecoveryIo = { isDisposed: () => boolean /** True while this state object is still the one in the manager's map. */ isCurrent: (state: WslRelayRecoveryState) => boolean - restart: (distro: string) => void + restart: (distro: string, user?: string) => void dropState: (state: WslRelayRecoveryState) => void } @@ -92,6 +93,6 @@ export class WslRelayRecovery { this.io.dropState(state) return } - this.io.restart(state.distro) + this.io.restart(state.distro, ...(state.user ? [state.user] : [])) } } diff --git a/src/main/agent-hooks/wsl-hook-relay-state-key.ts b/src/main/agent-hooks/wsl-hook-relay-state-key.ts index 45962f22c3a3..26b37393ca0f 100644 --- a/src/main/agent-hooks/wsl-hook-relay-state-key.ts +++ b/src/main/agent-hooks/wsl-hook-relay-state-key.ts @@ -1,3 +1,7 @@ -export function wslHookRelayStateKey(distro: string): string { - return distro.trim().toLowerCase() +export function wslHookRelayStateKey(distro: string, user?: string): string { + if (user !== undefined && (!user || user.trim() !== user || /[\0\r\n]/.test(user))) { + throw new Error('Invalid WSL hook relay user') + } + const normalized = distro.trim().toLowerCase() + return user === undefined ? normalized : JSON.stringify([normalized, user]) } diff --git a/src/main/agent-hooks/wsl-hook-relay-state-machine.ts b/src/main/agent-hooks/wsl-hook-relay-state-machine.ts index 04944712a57a..f2753116b478 100644 --- a/src/main/agent-hooks/wsl-hook-relay-state-machine.ts +++ b/src/main/agent-hooks/wsl-hook-relay-state-machine.ts @@ -40,17 +40,17 @@ export function markWslRelayFailed( } export function resumeWslStoppedRelays( - stopped: Map, + stopped: Map, isDistroRunning: (distro: string) => Promise, - ensure: (distro: string, home: string | undefined) => void + ensure: (distro: string, home: string | undefined, user?: string) => void ): void { - const distros = [...stopped] + const owners = [...stopped.values()] stopped.clear() - for (const [distro, home] of distros) { + for (const { distro, codexHomePath: home, user } of owners) { void isDistroRunning(distro) .then((running) => { if (running) { - ensure(distro, home) + ensure(distro, home, ...(user ? [user] : [])) } }) .catch(() => undefined) diff --git a/src/main/agent-hooks/wsl-hook-relay-state.ts b/src/main/agent-hooks/wsl-hook-relay-state.ts index 509231b97d9d..b11f5392ade9 100644 --- a/src/main/agent-hooks/wsl-hook-relay-state.ts +++ b/src/main/agent-hooks/wsl-hook-relay-state.ts @@ -2,6 +2,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' export type WslRelayDistroState = { distro: string + user?: string phase: 'starting' | 'running' | 'failed' child?: { kill: () => void } mux?: SshChannelMultiplexer diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts index 914028857210..50d7f29b2da8 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import * as processRunner from '../../shared/child-process/run-process' import { runProcess, spawnProcess } from '../../shared/child-process/run-process' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { ORCAD_BUN_VERSION, orcadBunVersionProbeArgs } from '../../shared/orcad-bun-runtime' import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' import SyncDatabase from '../sqlite/sync-database' import { appendTurns, writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' @@ -64,9 +64,11 @@ for (const [runtime, executable] of [ describe.skipIf(!existsSync(executable))(`OpenCode SQLite process under ${runtime}`, () => { it('uses the deployed runtime and reads live WAL, full prompts and captures in one persistent child', async () => { if (runtime === 'Bun') { - expect((await runProcess({ program: executable, args: ['--version'] })).stdout.trim()).toBe( - ORCAD_BUN_VERSION - ) + expect( + ( + await runProcess({ program: executable, args: [...orcadBunVersionProbeArgs()] }) + ).stdout.trim() + ).toBe(ORCAD_BUN_VERSION) } expect(existsSync(`${dbPath}-wal`)).toBe(true) const spawn = vi.spyOn(processRunner, 'spawnProcess') diff --git a/src/main/bundled-bun-test-execution.test.ts b/src/main/bundled-bun-test-execution.test.ts new file mode 100644 index 000000000000..fbd719b71184 --- /dev/null +++ b/src/main/bundled-bun-test-execution.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runBundledBunFixture } from './bundled-bun-test-execution' + +const { run, exists } = vi.hoisted(() => ({ run: vi.fn(), exists: vi.fn() })) +vi.mock('node:fs', () => ({ existsSync: exists })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess: run })) +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() +}) + +describe('bundled Bun fixture launch environment', () => { + it('removes runtime injection while retaining explicit fixture arguments and shell environment', async () => { + exists.mockReturnValue(true) + run.mockResolvedValue({ + code: 0, + stdout: '"done"', + stderr: '', + timedOut: false, + outputTruncated: false + }) + for (const key of [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'BUN_INSPECT_WAIT' + ]) { + vi.stubEnv(key, 'injected-runtime-setting') + } + vi.stubEnv('SHELL', '/bin/zsh') + const args = { env: { NODE_OPTIONS: '--fixture-shell-option' } } + expect(await runBundledBunFixture('/fixture.ts', 'capture', args, 1000)).toBe('done') + const spec = run.mock.calls[0][0] + for (const key of [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'BUN_INSPECT_WAIT' + ]) { + expect(spec.env[key]).toBeUndefined() + } + expect(spec.env.SHELL).toBe('/bin/zsh') + expect(spec.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(JSON.parse(spec.input)).toEqual(args) + }) +}) diff --git a/src/main/bundled-bun-test-execution.ts b/src/main/bundled-bun-test-execution.ts new file mode 100644 index 000000000000..1c405d8aefe7 --- /dev/null +++ b/src/main/bundled-bun-test-execution.ts @@ -0,0 +1,50 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { runProcess } from '../shared/child-process/run-process' +import { ORCAD_BUN_VERSION } from '../shared/orcad-bun-runtime' +import { orcadBunRuntimeFilename } from '../shared/orcad-artifacts' + +/** Runs real PTY fixtures under the same pinned runtime as the terminal service. */ +export async function runBundledBunFixture( + modulePath: string, + exportName: string, + args: unknown, + timeoutMs: number +): Promise { + const filename = orcadBunRuntimeFilename(process.platform) + const runtime = [ + process.env.BUN_EXECUTABLE, + join(process.cwd(), 'out', 'orcad', filename), + join(process.cwd(), 'out', 'cli-runtime', `${process.platform}-${process.arch}`, filename) + ].find((candidate): candidate is string => Boolean(candidate && existsSync(candidate))) + if (!runtime) { + throw new Error('Real terminal tests require the pinned bundled Bun runtime') + } + const script = ` + if (process.versions.bun !== ${JSON.stringify(ORCAD_BUN_VERSION)}) throw new Error('Unexpected Bun version'); + const args = JSON.parse(require('node:fs').readFileSync(0, 'utf8')); + const fixture = require(${JSON.stringify(modulePath)}); + Promise.resolve(fixture[${JSON.stringify(exportName)}](args)).then( + result => process.stdout.write(JSON.stringify(result)), + error => { console.error(error); process.exitCode = 1 } + ); + ` + const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + for (const key of Object.keys(env)) { + if (/^(NODE_OPTIONS|NODE_PATH|BUN_OPTIONS|BUN_INSPECT.*)$/i.test(key)) { + Reflect.deleteProperty(env, key) + } + } + const result = await runProcess({ + program: runtime, + args: ['-e', script], + input: JSON.stringify(args), + env, + timeoutMs, + maxOutputBytes: 4 * 1024 * 1024 + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`Bun terminal fixture failed: ${result.stderr}`) + } + return JSON.parse(result.stdout) +} diff --git a/src/main/claude-accounts/runtime-auth-captured-wsl-owner.test.ts b/src/main/claude-accounts/runtime-auth-captured-wsl-owner.test.ts new file mode 100644 index 000000000000..70c957f56a57 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-captured-wsl-owner.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + resetRuntimeAuthTestState, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +vi.mock('electron', () => createElectronMock()) +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) +vi.mock('./keychain', () => createKeychainMock()) +const execution = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } +const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } +beforeEach(() => { + resetRuntimeAuthTestState() + setPlatform('win32') +}) +afterEach(cleanupRuntimeAuthTestState) + +describe('captured WSL Claude account preparation', () => { + it.each([true, false])( + 'pins ownership reads and preserves selection when owned=%s', + async (owned) => { + const localPath = createManagedClaudeAuth( + testState.userDataDir, + 'alice-account', + createClaudeCredentialsJson('alice@example.test', 'token') + ) + let defaultUser = 'alice' + const runWslProcess = vi.fn(async (spec: { user?: string; script?: string }) => { + await Promise.resolve() + defaultUser = 'bob' + expect(spec.user).toBe('alice') + expect(spec.script).toContain('test "$(id -u)" = \'1000\'') + expect(spec.script).toContain('test "$HOME" = \'/home/alice\'') + return { + code: owned ? 0 : 35, + stdout: spec.script?.includes('exec cat') + ? createClaudeCredentialsJson('alice@example.test', 'token') + : '/home/alice/.local/share/orca/claude-accounts/alice-account/auth', + stderr: '', + timedOut: false + } + }) + vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess })) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => `\\\\wsl.localhost\\Ubuntu\\home\\${defaultUser}`, + toWindowsWslPath: () => localPath + })) + const settings = createSettings({ + localAccountRuntime: 'host', + claudeManagedAccounts: [ + createClaudeAccount( + 'alice-account', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\claude-accounts\\alice-account\\auth', + { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/alice-account/auth' + } + ) + ], + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'alice-account' } } + }) + const store = createStore(settings) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Auth preparation uses only the fixture getSettings/updateSettings methods. + const service = new ClaudeRuntimeAuthService(store as never) + if (owned) { + expect(await service.prepareForClaudeLaunch(target, execution)).toMatchObject({ + wslExecution: execution, + envPatch: { + CLAUDE_CONFIG_DIR: '/home/alice/.local/share/orca/claude-accounts/alice-account/auth' + } + }) + } else { + await expect(service.prepareForClaudeLaunch(target, execution)).rejects.toThrow( + 'captured execution owner' + ) + } + expect(runWslProcess).toHaveBeenCalledTimes(owned ? 2 : 1) + expect(settings.activeClaudeManagedAccountIdsByRuntime?.wsl.Ubuntu).toBe('alice-account') + } + ) + it('uses the captured home for system auth after the distro default changes', async () => { + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => '\\\\wsl.localhost\\Ubuntu\\home\\bob' + })) + const store = createStore(createSettings({ localAccountRuntime: 'host' })) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Auth preparation uses only the fixture getSettings/updateSettings methods. + const service = new ClaudeRuntimeAuthService(store as never) + expect(await service.prepareForClaudeLaunch(target, execution)).toMatchObject({ + wslExecution: execution, + wslLinuxConfigDir: '/home/alice/.claude' + }) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index dead38a7780f..03209be94b02 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,3 +1,4 @@ +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' import type { Store } from '../persistence' import { getSelectedClaudeAccountIdForTarget, @@ -16,11 +17,30 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { } async prepareForClaudeLaunch( - target?: ClaudeAccountSelectionTarget + target?: ClaudeAccountSelectionTarget, + execution?: WslAccountExecutionContext ): Promise { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - await this.syncForCurrentSelection(effectiveTarget) - return this.getPreparation(effectiveTarget) + const effectiveTarget = { ...(target ?? this.getDefaultAccountSelectionTarget()) } + const captured = execution ? Object.freeze({ ...execution }) : undefined + return this.serializeMutation(async () => { + const selected = getSelectedClaudeAccountIdForTarget( + this.store.getSettings(), + effectiveTarget + ) + const before = captured ? this.getPreparation(effectiveTarget, captured) : undefined + await this.doSyncForCurrentSelection(effectiveTarget, captured) + const preparation = this.getPreparation(effectiveTarget, captured) + if ( + captured && + (selected !== + getSelectedClaudeAccountIdForTarget(this.store.getSettings(), effectiveTarget) || + before?.configDir !== preparation.configDir || + before?.wslLinuxConfigDir !== preparation.wslLinuxConfigDir) + ) { + throw new Error('Claude account selection changed during guest preparation') + } + return { ...preparation, ...(captured ? { wslExecution: captured } : {}) } + }) } async prepareForRateLimitFetch( diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts index ad68d59eadb7..4099cf7573ed 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts @@ -1,3 +1,4 @@ +import type { WslAccountExecutionContext } from '../../wsl/wsl-account-execution-context' import { existsSync } from 'node:fs' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { parseWslUncPath } from '../../../shared/wsl-paths' @@ -22,11 +23,36 @@ function shellQuote(value: string): string { } export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCredentialIdentity { - protected async readManagedCredentials(account: ClaudeManagedAccount): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) + protected async readManagedCredentials( + account: ClaudeManagedAccount, + execution?: WslAccountExecutionContext + ): Promise { + const managedAuthPath = await this.getOwnedManagedAuthPath(account, execution) if (!managedAuthPath) { return null } + if (execution) { + const result = await runWslProcess({ + distro: execution.distro, + user: execution.userName, + loginPath: 'none', + script: [ + 'set -e', + `test "$(id -u)" = ${shellQuote(execution.userId)}`, + `test "$HOME" = ${shellQuote(execution.home)}`, + 'exec cat -- "$1"' + ].join('\n'), + args: [`${account.wslLinuxAuthPath}/.credentials.json`], + timeoutMs: 5_000, + maxOutputBytes: 256 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + throw new Error( + 'Selected Claude credentials are unavailable for the captured execution owner' + ) + } + return result.stdout + } if (process.platform === 'darwin') { return readManagedClaudeKeychainCredentials(account.id) } @@ -89,8 +115,19 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden } } - protected async getOwnedManagedAuthPath(account: ClaudeManagedAccount): Promise { + protected async getOwnedManagedAuthPath( + account: ClaudeManagedAccount, + execution?: WslAccountExecutionContext + ): Promise { const wslInfo = parseWslUncPath(account.managedAuthPath) + if ( + execution && + (!wslInfo || + wslInfo.distro.toLowerCase() !== execution.distro.toLowerCase() || + account.wslLinuxAuthPath !== wslInfo.linuxPath) + ) { + throw new Error('Managed Claude account belongs to another execution owner') + } if (wslInfo) { if ( !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || @@ -102,10 +139,17 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden try { const owned = await runWslProcess({ distro: wslInfo.distro, + ...(execution ? { user: execution.userName } : {}), loginPath: 'none', shell: 'bash', script: [ 'set -euo pipefail', + ...(execution + ? [ + `test "$(id -u)" = ${shellQuote(execution.userId)}`, + `test "$HOME" = ${shellQuote(execution.home)}` + ] + : []), `candidate=${shellQuote(wslInfo.linuxPath)}`, 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', 'candidate_real=$(readlink -f -- "$candidate")', @@ -129,7 +173,7 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden // caller persists that -- clearing the user's account selection. A // slow distro must not decide ownership. Swallowing it here is what // made the previous guard dead code. - if (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT) { + if (execution || (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT)) { throw error } return null diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts index dabcd9d472f3..4650617fde00 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts @@ -1,7 +1,11 @@ +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../../wsl/wsl-account-execution-context' +import { parseWslUncPath, toWindowsWslUncPath } from '../../../shared/wsl-paths' import { join } from 'node:path' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime' -import { parseWslUncPath } from '../../../shared/wsl-paths' import { shouldStripClaudeAuthEnvForAccount } from '../environment' import { getDefaultWslDistro, getWslHome } from '../../wsl' import { @@ -13,7 +17,13 @@ import { ClaudeRuntimeAuthSnapshotRestore } from './runtime-auth-snapshot-restor import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapshotRestore { - protected getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { + protected getPreparation( + target?: ClaudeAccountSelectionTarget, + execution?: WslAccountExecutionContext + ): ClaudeRuntimeAuthPreparation { + if (execution) { + assertWslAccountExecutionTarget(execution, target) + } const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() const normalizedTarget = this.resolveWslDefaultTarget( @@ -39,7 +49,11 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { const distro = normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() - const wslHome = distro ? getWslHome(distro) : null + const wslHome = execution + ? toWindowsWslUncPath(execution.home, execution.distro) + : distro + ? getWslHome(distro) + : null const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null if (distro && wslHome && wslHomeInfo) { const windowsConfigDir = join(wslHome, '.claude') diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts index f191689a4466..cbb3632edc18 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts @@ -1,3 +1,7 @@ +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../../wsl/wsl-account-execution-context' import { existsSync, readFileSync } from 'node:fs' import { getSelectedClaudeAccountIdForTarget, @@ -12,12 +16,28 @@ import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { - protected async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { + protected async doSyncForCurrentSelection( + target?: ClaudeAccountSelectionTarget, + execution?: WslAccountExecutionContext + ): Promise { const settings = this.store.getSettings() const effectiveTarget = this.resolveWslDefaultTarget(target) const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) + if (execution) { + assertWslAccountExecutionTarget(execution, effectiveTarget) + if (activeAccountId && (!activeAccount || activeAccount.managedAuthRuntime !== 'wsl')) { + throw new Error('Selected Claude account is unavailable for the captured execution owner') + } + if (activeAccount) { + const credentials = await this.readManagedCredentials(activeAccount, execution) + if (!credentials || !this.isValidCredentialsJsonObject(credentials)) { + throw new Error('Selected Claude account is unavailable for the captured execution owner') + } + } + return + } const previousAccount = this.getActiveAccount( settings.claudeManagedAccounts, this.lastSyncedAccountId diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index 77f0394e5719..788221792a21 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -1,7 +1,9 @@ +import type { WslAccountExecutionContext } from '../../wsl/wsl-account-execution-context' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import type { ClaudeEnvPatch } from '../environment' export type ClaudeRuntimeAuthPreparation = { + wslExecution?: WslAccountExecutionContext configDir: string runtime?: 'host' | 'wsl' wslDistro?: string | null diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 3391a8a8c0fb..f7d0735f7621 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -124,7 +124,6 @@ describe('packaged CLI assets', () => { join('node_modules', 'zod'), join('node_modules', 'yaml'), join('node_modules', 'jsonc-parser'), - join('node_modules', 'node-pty'), join('node_modules', 'sherpa-onnx-darwin-${arch}'), join('node_modules', 'sherpa-onnx-linux-${arch}'), join('node_modules', 'sherpa-onnx-win-x64') diff --git a/src/main/codex-accounts/captured-wsl-account-process.test.ts b/src/main/codex-accounts/captured-wsl-account-process.test.ts new file mode 100644 index 000000000000..08bc4f11091d --- /dev/null +++ b/src/main/codex-accounts/captured-wsl-account-process.test.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runCapturedCodexWslProcess } from './captured-wsl-account-process' +import { + drainLegacyWslRuntimeAuth, + startLegacyWslRuntimeAuthDrain, + _internals +} from './legacy-wsl-runtime-auth-drain' +import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' + +const { runWslProcessMock } = vi.hoisted(() => ({ runWslProcessMock: vi.fn() })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) +const owner = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } +const result = (code = 0, stdout = '') => ({ + code, + stdout, + stderr: '', + timedOut: false, + environmentResolved: true +}) +const options = () => ({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + execution: { ...owner }, + resolveDestination: vi.fn(() => null) +}) + +beforeEach(() => { + runWslProcessMock.mockReset() + _internals.resetDrainQueue() +}) + +describe('captured WSL Codex owner', () => { + it.skipIf(process.platform === 'win32')( + 'refuses UID drift before a real shell writes credentials', + async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-captured-wsl-')) + const destination = join(directory, 'auth.json') + runWslProcessMock.mockImplementation((spec) => + runProcess({ program: '/bin/sh', args: ['-c', spec.script] }) + ) + try { + const response = await runCapturedCodexWslProcess( + { distro: 'Ubuntu', loginPath: 'none', script: `touch ${shellEscape(destination)}` }, + { ...owner, userId: String((process.getuid?.() ?? 0) + 1) } + ) + expect(response.code).toBe(79) + expect(existsSync(destination)).toBe(false) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } + ) + + it('pins the user and checks UID/home before the credential script', async () => { + runWslProcessMock.mockResolvedValue(result()) + await runCapturedCodexWslProcess( + { distro: 'Ubuntu', script: 'touch auth.json', loginPath: 'none' }, + owner + ) + const spec = runWslProcessMock.mock.calls[0]?.[0] + expect(spec.user).toBe('alice') + expect(spec.script).toContain('id -u') + expect(spec.script).toContain('1000') + expect(spec.script).toContain('/home/alice') + expect(spec.script.indexOf('exit 79')).toBeLessThan(spec.script.indexOf('touch auth.json')) + }) + + it('rejects a different distro and drain home before guest work', async () => { + expect(() => + runCapturedCodexWslProcess( + { distro: 'Debian', script: 'touch auth.json', loginPath: 'none' }, + owner + ) + ).toThrow('captured execution owner') + await expect( + drainLegacyWslRuntimeAuth({ ...options(), guestHomeLinuxPath: '/home/bob' }) + ).rejects.toThrow('captured owner') + expect(runWslProcessMock).not.toHaveBeenCalled() + }) + + it('keeps the captured user through an asynchronous drain destination lookup', async () => { + const execution = { ...owner } + const fresh = '{"tokens":{"expires_at":2000}}' + const stale = '{"tokens":{"expires_at":1000}}' + runWslProcessMock + .mockResolvedValueOnce( + result(0, [Buffer.from(fresh).toString('base64'), 'missing', ''].join('\n')) + ) + .mockResolvedValue(result()) + await drainLegacyWslRuntimeAuth({ + ...options(), + execution, + resolveDestination: async () => { + execution.userName = 'bob' + execution.userId = '1001' + execution.home = '/home/bob' + await Promise.resolve() + return { authContents: stale, linuxHomePath: '/home/alice/.codex' } + } + }) + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + for (const [spec] of runWslProcessMock.mock.calls) { + expect(spec.user).toBe('alice') + expect(spec.script).toContain('/home/alice') + expect(spec.script).not.toContain('/home/bob') + } + }) + + it('does not coalesce drains for two users in the same distro', async () => { + runWslProcessMock.mockResolvedValue(result(0, '')) + await Promise.all([ + startLegacyWslRuntimeAuthDrain(options()), + startLegacyWslRuntimeAuthDrain({ + ...options(), + guestHomeLinuxPath: '/home/bob', + execution: { ...owner, userName: 'bob', userId: '1001', home: '/home/bob' } + }) + ]) + expect(runWslProcessMock.mock.calls.map(([spec]) => spec.user).sort()).toEqual(['alice', 'bob']) + }) + + it('does not coalesce session bridges across captured users', async () => { + runWslProcessMock.mockResolvedValue(result()) + const target = { + distro: 'Ubuntu', + systemCodexHomePath: '/shared/system', + managedCodexHomePath: '/shared/managed' + } + await Promise.all([ + startWslCodexSessionBridgeInBackground({ ...target, execution: owner }), + startWslCodexSessionBridgeInBackground({ + ...target, + execution: { ...owner, userName: 'bob', userId: '1001', home: '/home/bob' } + }) + ]) + expect(runWslProcessMock.mock.calls.map(([spec]) => spec.user).sort()).toEqual(['alice', 'bob']) + }) +}) diff --git a/src/main/codex-accounts/captured-wsl-account-process.ts b/src/main/codex-accounts/captured-wsl-account-process.ts new file mode 100644 index 000000000000..d8413050af87 --- /dev/null +++ b/src/main/codex-accounts/captured-wsl-account-process.ts @@ -0,0 +1,19 @@ +import { shellEscape } from '../ssh/ssh-connection-utils' +import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../wsl/wsl-account-execution-context' + +/** Recheck the captured UID/home in the same guest shell before reading or mutating credentials. */ +export function runCapturedCodexWslProcess(spec: WslSpec, execution?: WslAccountExecutionContext) { + if (!execution) { + return runWslProcess(spec) + } + assertWslAccountExecutionTarget(execution, { runtime: 'wsl', wslDistro: spec.distro }) + if (spec.script === undefined) { + throw new Error('Captured Codex account operations require a script') + } + const guard = `[ "$(id -u)" = ${shellEscape(execution.userId)} ] && [ "$HOME" = ${shellEscape(execution.home)} ] || { echo 'WSL account owner changed' >&2; exit 79; }` + return runWslProcess({ ...spec, user: execution.userName, script: `${guard}\n${spec.script}` }) +} diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-owner.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-owner.ts new file mode 100644 index 000000000000..05858bb028f3 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-owner.ts @@ -0,0 +1,40 @@ +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../wsl/wsl-account-execution-context' +import type { LegacyWslRuntimeAuthDestination } from './legacy-wsl-runtime-auth-drain' + +export type LegacyWslRuntimeAuthDrainOptions = { + execution?: WslAccountExecutionContext + distro: string + guestHomeLinuxPath: string + legacyPanePresent: boolean + resolveDestination: ( + runtimeAuthContents: string + ) => LegacyWslRuntimeAuthDestination | null | Promise +} + +export function captureDrainOptions( + options: LegacyWslRuntimeAuthDrainOptions +): LegacyWslRuntimeAuthDrainOptions { + if (!options.execution) { + return options + } + const execution = Object.freeze({ ...options.execution }) + assertWslAccountExecutionTarget(execution, { runtime: 'wsl', wslDistro: options.distro }) + if (options.guestHomeLinuxPath !== execution.home) { + throw new Error('WSL drain home does not match captured owner') + } + return { ...options, execution } +} + +export function drainOwnerKey(options: LegacyWslRuntimeAuthDrainOptions): string { + return options.execution + ? JSON.stringify([ + options.distro.toLowerCase(), + options.execution.userName, + options.execution.userId, + options.execution.home + ]) + : options.distro.trim().toLowerCase() +} diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts index f282e879a77f..9439a24bedc2 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -1,8 +1,14 @@ +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' import { createHash } from 'node:crypto' import { posix as pathPosix } from 'node:path' import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' import { WSL_SESSION_BRIDGE_TIMEOUT_MS } from '../codex/wsl-codex-session-bridge-script' -import { runWslProcess } from '../wsl/wsl-runner' +import { runCapturedCodexWslProcess } from './captured-wsl-account-process' +import { + captureDrainOptions, + drainOwnerKey, + type LegacyWslRuntimeAuthDrainOptions +} from './legacy-wsl-runtime-auth-drain-owner' import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' import { APPLY_LEGACY_AUTH_SCRIPT, @@ -27,15 +33,6 @@ type LegacyWslRuntimeInspection = { credentials: { kind: 'missing' } | { kind: 'present'; contents: string } } -type LegacyWslRuntimeAuthDrainOptions = { - distro: string - guestHomeLinuxPath: string - legacyPanePresent: boolean - resolveDestination: ( - runtimeAuthContents: string - ) => LegacyWslRuntimeAuthDestination | null | Promise -} - const drainQueueByDistro = new Map>() const completedDistroKeys = new Set() const pendingSessionBridgeRouteByDistro = new Map() @@ -69,7 +66,8 @@ export function startLegacyWslRuntimeAuthDrain( options: LegacyWslRuntimeAuthDrainOptions, startOptions: { throwOnFailure?: boolean } = {} ): Promise { - const key = options.distro.trim().toLowerCase() + options = captureDrainOptions(options) + const key = drainOwnerKey(options) if (completedDistroKeys.has(key)) { return Promise.resolve() } @@ -104,22 +102,26 @@ function logDrainFailure(task: Promise): Promise { export async function drainLegacyWslRuntimeAuth( options: LegacyWslRuntimeAuthDrainOptions ): Promise<'complete' | 'pending'> { - const distroKey = options.distro.trim().toLowerCase() + options = captureDrainOptions(options) + const distroKey = drainOwnerKey(options) const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) - const inspection = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: INSPECT_LEGACY_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) + const inspection = await runCapturedCodexWslProcess( + { + distro: options.distro, + loginPath: 'none', + script: INSPECT_LEGACY_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }, + options.execution + ) if (inspection.code === MARKER_PRESENT_EXIT) { return 'complete' } if (inspection.code === LEGACY_HOME_ABSENT_EXIT) { if (!options.legacyPanePresent) { - return finalizeAbsentLegacyAuth(options.distro, paths) + return finalizeAbsentLegacyAuth(options.distro, paths, options.execution) } return 'pending' } @@ -147,29 +149,34 @@ export async function drainLegacyWslRuntimeAuth( ].join('\0') const bridgeAllSessions = deleteSource || pendingSessionBridgeRouteByDistro.get(distroKey) !== sessionBridgeRoute - const result = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: APPLY_LEGACY_AUTH_SCRIPT, - args: [ - paths.runtimeHome, - paths.activeHome, - paths.marker, - destination.linuxHomePath, - sha256(inspected.authContents), - sha256(destination.authContents), - promoteAuth ? '1' : '0', - deleteSource ? '1' : '0', - inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing', - bridgeAllSessions ? 'full' : 'recent' - ], - timeoutMs: bridgeAllSessions ? WSL_SESSION_BRIDGE_TIMEOUT_MS : 5_000, - maxOutputBytes: 16 * 1024 - }) + const result = await runCapturedCodexWslProcess( + { + distro: options.distro, + loginPath: 'none', + script: APPLY_LEGACY_AUTH_SCRIPT, + args: [ + paths.runtimeHome, + paths.activeHome, + paths.marker, + destination.linuxHomePath, + sha256(inspected.authContents), + sha256(destination.authContents), + promoteAuth ? '1' : '0', + deleteSource ? '1' : '0', + inspected.credentials.kind === 'present' + ? sha256(inspected.credentials.contents) + : 'missing', + bridgeAllSessions ? 'full' : 'recent' + ], + timeoutMs: bridgeAllSessions ? WSL_SESSION_BRIDGE_TIMEOUT_MS : 5_000, + maxOutputBytes: 16 * 1024 + }, + options.execution + ) try { assertSuccessfulDrainStep('apply', result) } catch { - return recoverAfterFailedApply(options.distro, paths) + return recoverAfterFailedApply(options.distro, paths, options.execution) } if (!deleteSource) { rememberPendingRoute(distroKey, sessionBridgeRoute) @@ -179,16 +186,20 @@ export async function drainLegacyWslRuntimeAuth( async function recoverAfterFailedApply( distro: string, - paths: ReturnType + paths: ReturnType, + execution?: WslAccountExecutionContext ): Promise<'complete' | 'pending'> { - const recovery = await runWslProcess({ - distro, - loginPath: 'none', - script: INSPECT_LEGACY_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) + const recovery = await runCapturedCodexWslProcess( + { + distro, + loginPath: 'none', + script: INSPECT_LEGACY_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }, + execution + ) if (recovery.code === MARKER_PRESENT_EXIT) { return 'complete' } @@ -248,16 +259,20 @@ function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { async function finalizeAbsentLegacyAuth( distro: string, - paths: ReturnType + paths: ReturnType, + execution?: WslAccountExecutionContext ): Promise<'complete' | 'pending'> { - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: FINALIZE_ABSENT_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 16 * 1024 - }) + const result = await runCapturedCodexWslProcess( + { + distro, + loginPath: 'none', + script: FINALIZE_ABSENT_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }, + execution + ) if (result.code === LEGACY_HOME_STILL_PRESENT_EXIT) { return 'pending' } diff --git a/src/main/codex-accounts/runtime-home-captured-wsl-launch.test.ts b/src/main/codex-accounts/runtime-home-captured-wsl-launch.test.ts new file mode 100644 index 000000000000..eaba9f3e2e84 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-captured-wsl-launch.test.ts @@ -0,0 +1,116 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toWindowsWslUncPath } from '../../shared/wsl-paths' +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createStore, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir } })) +vi.mock('../wsl', () => ({ + getDefaultWslDistro: () => 'Other', + getWslHome: () => '/home/default-changed' +})) + +beforeEach(setupRuntimeHomeTest) +afterEach(teardownRuntimeHomeTest) + +const owner = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } + +describe('captured Codex launch preparation', () => { + it.each([ + { distro: 'Debian', home: '/home/alice/account' }, + { distro: 'Ubuntu', home: '/home/bob/account' } + ])( + 'refuses a selected managed account outside the captured owner: $distro $home', + async ({ distro, home }) => { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + class CapturedService extends CodexRuntimeHomeService { + resolveAccount(account: CodexManagedAccount): string | null { + return this.getWslLaunchCodexHomePath(account, 'Ubuntu', owner) + } + } + const store = createStore(createSettings({})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the settings-store operations used during service construction. + const service = new CapturedService(store as never) + const account: CodexManagedAccount = { + id: 'selected', + email: 'test@example.com', + managedHomePath: toWindowsWslUncPath(home, distro), + managedHomeRuntime: 'wsl', + wslDistro: distro, + wslLinuxHomePath: home, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + expect(() => service.resolveAccount(account)).toThrow('captured WSL') + } + ) + + it('pins the selected home and finishing context while the drain awaits', async () => { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const execution = { ...owner } + const finish = vi.fn() + class CapturedService extends CodexRuntimeHomeService { + protected override async startLegacyWslAuthDrain(): Promise { + execution.userName = 'bob' + execution.home = '/home/bob' + await Promise.resolve() + } + protected override finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + home: string | null, + captured?: WslAccountExecutionContext + ): void { + finish(target, home, captured) + } + } + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every settings-store operation used by launch preparation. + const service = new CapturedService(store as never) + const home = await service.prepareForCodexLaunchAsync( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + undefined, + { wslExecution: execution } + ) + expect(home).toBe(toWindowsWslUncPath('/home/alice/.codex', 'Ubuntu')) + expect(finish).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }, home, owner) + expect(Object.isFrozen(finish.mock.calls[0]?.[2])).toBe(true) + }) + + it('refuses a mismatched target before drain or finishing', async () => { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const work = vi.fn() + class CapturedService extends CodexRuntimeHomeService { + protected override async startLegacyWslAuthDrain(): Promise { + work() + } + protected override finishWslLaunchPreparation(): void { + work() + } + } + const store = createStore(createSettings({})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the settings-store operations used during service construction. + const service = new CapturedService(store as never) + await expect( + service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Debian' }, undefined, { + wslExecution: owner + }) + ).rejects.toThrow('captured execution owner') + expect(work).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts index 19eceda6c0a3..38ef99543d4b 100644 --- a/src/main/codex-accounts/runtime-home-service-home-routing.ts +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -1,3 +1,7 @@ +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../wsl/wsl-account-execution-context' import { posix as pathPosix } from 'node:path' import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' @@ -209,7 +213,14 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom await Promise.all(drains) } - protected getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + protected getWslSystemCodexHomePath( + target: CodexAccountSelectionTarget, + execution?: WslAccountExecutionContext + ): string | null { + if (execution) { + assertWslAccountExecutionTarget(execution, target) + return toWindowsWslUncPath(pathPosix.join(execution.home, '.codex'), execution.distro) + } if (process.platform !== 'win32') { return null } @@ -229,15 +240,17 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom protected finishWslLaunchPreparation( target: CodexAccountSelectionTarget, - homePath: string | null + homePath: string | null, + execution?: WslAccountExecutionContext ): void { - this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) - this.startWslSessionBridgeForLaunch(target, homePath) + this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath, execution) + this.startWslSessionBridgeForLaunch(target, homePath, execution) } protected syncWslConfigAndGlobalInstructionsForLaunch( target: CodexAccountSelectionTarget, - runtimeHomePath: string | null + runtimeHomePath: string | null, + execution?: WslAccountExecutionContext ): void { if (!runtimeHomePath) { return @@ -247,7 +260,10 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom if (!distro) { return } - const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const systemHomePath = this.getWslSystemCodexHomePath( + { runtime: 'wsl', wslDistro: distro }, + execution + ) if (!systemHomePath || systemHomePath === runtimeHomePath) { return } diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts index 7356d041e774..006e19470b20 100644 --- a/src/main/codex-accounts/runtime-home-service-launch.ts +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -1,3 +1,7 @@ +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../wsl/wsl-account-execution-context' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' import { @@ -40,13 +44,17 @@ export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { prepareForCodexLaunch( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } + options?: { unavailableManagedHomePath?: string; wslExecution?: WslAccountExecutionContext } ): string | null { + const execution = options?.wslExecution ? Object.freeze({ ...options.wslExecution }) : undefined + if (execution) { + assertWslAccountExecutionTarget(execution, target) + } if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - this.startLegacyWslAuthDrain(wslTarget) - this.finishWslLaunchPreparation(wslTarget, homePath) + const homePath = this.getWslCodexHomePathForSelection(wslTarget, execution) + this.startLegacyWslAuthDrain(wslTarget, { wslExecution: execution }) + this.finishWslLaunchPreparation(wslTarget, homePath, execution) return homePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -114,17 +122,21 @@ export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { async prepareForCodexLaunchAsync( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } + options?: { unavailableManagedHomePath?: string; wslExecution?: WslAccountExecutionContext } ): Promise { + const execution = options?.wslExecution ? Object.freeze({ ...options.wslExecution }) : undefined + if (execution) { + assertWslAccountExecutionTarget(execution, target) + } if (target?.runtime !== 'wsl') { return this.prepareForCodexLaunch(target, launchEnv, options) } const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) + const homePath = this.getWslCodexHomePathForSelection(wslTarget, execution) // Why: the retired home may hold the freshest credential, so the first // direct-home Codex spawn must wait for its bounded guest transaction. - await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) - this.finishWslLaunchPreparation(wslTarget, homePath) + await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true, wslExecution: execution }) + this.finishWslLaunchPreparation(wslTarget, homePath, execution) return homePath } diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts index 817b2f7adc41..e133748c350a 100644 --- a/src/main/codex-accounts/runtime-home-service-managed-home.ts +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -1,3 +1,4 @@ +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' import { join } from 'node:path' import { syncSystemCodexResourcesIntoManagedHome, @@ -252,7 +253,8 @@ export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { protected startWslSessionBridgeForLaunch( target: CodexAccountSelectionTarget, - runtimeHomePath: string | null + runtimeHomePath: string | null, + execution?: WslAccountExecutionContext ): void { if (process.platform !== 'win32' || !runtimeHomePath) { return @@ -265,13 +267,14 @@ export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. const systemCodexHomePath = resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }, execution) if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. void startWslCodexSessionBridgeInBackground({ distro, systemCodexHomePath, - managedCodexHomePath: runtimeHomePath + managedCodexHomePath: runtimeHomePath, + ...(execution ? { execution } : {}) }) } } diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts index d6a6691fbb44..3f4511a2b9ca 100644 --- a/src/main/codex-accounts/runtime-home-service-state.ts +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -1,3 +1,4 @@ +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' import type { CodexManagedAccount } from '../../shared/managed-account-types' import type { Store } from '../persistence' import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' @@ -41,12 +42,12 @@ export abstract class CodexRuntimeHomeState { abstract prepareForCodexLaunch( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } + options?: { unavailableManagedHomePath?: string; wslExecution?: WslAccountExecutionContext } ): string | null abstract prepareForCodexLaunchAsync( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } + options?: { unavailableManagedHomePath?: string; wslExecution?: WslAccountExecutionContext } ): Promise abstract beginHostSystemDefaultSessionMigrationLaunch( codexHomePath: string | null, @@ -83,7 +84,8 @@ export abstract class CodexRuntimeHomeState { ): boolean | null protected abstract startWslSessionBridgeForLaunch( target: CodexAccountSelectionTarget, - runtimeHomePath: string | null + runtimeHomePath: string | null, + execution?: WslAccountExecutionContext ): void abstract getHostCodexHomePathsForSessionDiscovery(): string[] @@ -100,14 +102,19 @@ export abstract class CodexRuntimeHomeState { abstract reconcileLegacySharedHomeForRetainedPanes(): void abstract syncActiveWslSelectionsBeforeRestart(): Promise - protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null + protected abstract getWslSystemCodexHomePath( + target: CodexAccountSelectionTarget, + execution?: WslAccountExecutionContext + ): string | null protected abstract finishWslLaunchPreparation( target: CodexAccountSelectionTarget, - homePath: string | null + homePath: string | null, + execution?: WslAccountExecutionContext ): void protected abstract syncWslConfigAndGlobalInstructionsForLaunch( target: CodexAccountSelectionTarget, - runtimeHomePath: string | null + runtimeHomePath: string | null, + execution?: WslAccountExecutionContext ): void abstract prepareForRateLimitFetch( target?: CodexAccountSelectionTarget @@ -136,19 +143,22 @@ export abstract class CodexRuntimeHomeState { target: CodexAccountSelectionTarget ): string | null protected abstract getWslCodexHomePathForSelection( - target: CodexAccountSelectionTarget + target: CodexAccountSelectionTarget, + execution?: WslAccountExecutionContext ): string | null protected abstract getWslLaunchCodexHomePath( account: CodexManagedAccount, - targetDistro: string | undefined + targetDistro: string | undefined, + execution?: WslAccountExecutionContext ): string | null protected abstract startLegacyWslAuthDrain( target: CodexAccountSelectionTarget, - options?: { throwOnFailure?: boolean } + options?: { throwOnFailure?: boolean; wslExecution?: WslAccountExecutionContext } ): Promise protected abstract resolveLegacyWslAuthDestination( distro: string, - runtimeAuthContents: string + runtimeAuthContents: string, + execution?: WslAccountExecutionContext ): Promise protected abstract joinWslPath(basePath: string, ...segments: string[]): string protected abstract resolveWslDefaultTarget( diff --git a/src/main/codex-accounts/runtime-home-service-wsl.ts b/src/main/codex-accounts/runtime-home-service-wsl.ts index cf0ec32a23f1..23bf2caa6ffd 100644 --- a/src/main/codex-accounts/runtime-home-service-wsl.ts +++ b/src/main/codex-accounts/runtime-home-service-wsl.ts @@ -1,3 +1,7 @@ +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../wsl/wsl-account-execution-context' import { join, win32 as pathWin32 } from 'node:path' import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' import { @@ -20,7 +24,10 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { return this.getWslCodexHomePathForSelection(target) } - protected getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + protected getWslCodexHomePathForSelection( + target: CodexAccountSelectionTarget, + execution?: WslAccountExecutionContext + ): string | null { const settings = this.store.getSettings() const account = this.getActiveAccount( settings.codexManagedAccounts, @@ -28,24 +35,34 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { ) if (account) { const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() - const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) + const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro, execution) if (accountHome) { return accountHome } } - return this.getWslSystemCodexHomePath(target) + return this.getWslSystemCodexHomePath(target, execution) } protected getWslLaunchCodexHomePath( account: CodexManagedAccount, - targetDistro: string | undefined + targetDistro: string | undefined, + execution?: WslAccountExecutionContext ): string | null { const wslHome = this.getWslManagedHomeIdentity(account) if (!wslHome) { + if (execution) { + throw new Error('Selected Codex account has no captured WSL home') + } return null } + if (execution && !wslHome.linuxHomePath.startsWith(`${execution.home.replace(/\/$/, '')}/`)) { + throw new Error('Selected Codex account does not belong to the captured WSL home') + } const accountDistro = wslHome.distro if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { + if (execution) { + throw new Error('Selected Codex account does not belong to the captured WSL distro') + } return null } if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { @@ -56,7 +73,7 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { protected startLegacyWslAuthDrain( target: CodexAccountSelectionTarget, - options: { throwOnFailure?: boolean } = {} + options: { throwOnFailure?: boolean; wslExecution?: WslAccountExecutionContext } = {} ): Promise { if (process.platform !== 'win32') { return Promise.resolve() @@ -65,7 +82,11 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { if (!distro) { return Promise.resolve() } - const guestHome = getWslHome(distro) + const execution = options.wslExecution + if (execution) { + assertWslAccountExecutionTarget(execution, target) + } + const guestHome = execution?.home ?? getWslHome(distro) const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' if (!guestHomeLinuxPath.startsWith('/')) { return Promise.resolve() @@ -83,8 +104,9 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { distro, guestHomeLinuxPath, legacyPanePresent, + ...(execution ? { execution } : {}), resolveDestination: (runtimeAuthContents) => - this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents, execution) }, options ) @@ -92,23 +114,32 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { protected async resolveLegacyWslAuthDestination( distro: string, - runtimeAuthContents: string + runtimeAuthContents: string, + execution?: WslAccountExecutionContext ): Promise { const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { const wslHome = this.getWslManagedHomeIdentity(account) - return wslHome?.distro.toLowerCase() === distro.toLowerCase() + return wslHome?.distro.toLowerCase() === distro.toLowerCase() && + (!execution || wslHome.linuxHomePath.startsWith(`${execution.home.replace(/\/$/, '')}/`)) ? [{ account, linuxPath: wslHome.linuxHomePath }] : [] }) const accounts = accountHomes.map(({ account }) => account) - const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const systemHome = this.getWslSystemCodexHomePath( + { runtime: 'wsl', wslDistro: distro }, + execution + ) const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null let reads: WslCodexAuthRead[] try { - reads = await readWslCodexAuths(distro, [ - ...accountHomes.map(({ linuxPath }) => linuxPath), - ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) - ]) + reads = await readWslCodexAuths( + distro, + [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ], + execution + ) } catch { reads = accountHomes.map(() => ({ kind: 'unreadable' })) if (parsedSystemHome) { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts index 40870fcd5ef7..63918b0a3c4a 100644 --- a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts +++ b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts @@ -1,4 +1,5 @@ -import { runWslProcess } from '../wsl/wsl-runner' +import { runCapturedCodexWslProcess } from './captured-wsl-account-process' +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' export type WslCodexAuthRead = | { kind: 'missing' | 'unreadable' } @@ -6,19 +7,23 @@ export type WslCodexAuthRead = export async function readWslCodexAuths( distro: string, - linuxHomePaths: readonly string[] + linuxHomePaths: readonly string[], + execution?: WslAccountExecutionContext ): Promise { if (linuxHomePaths.length === 0) { return [] } - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: READ_AUTHS_SCRIPT, - args: linuxHomePaths, - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) + const result = await runCapturedCodexWslProcess( + { + distro, + loginPath: 'none', + script: READ_AUTHS_SCRIPT, + args: linuxHomePaths, + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }, + execution + ) if (result.code !== 0 || result.timedOut) { return linuxHomePaths.map(() => ({ kind: 'unreadable' })) } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index ebad86326762..d170e28cec85 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -342,7 +342,7 @@ export function hasRecordedManagedHostCodexPane(): boolean { ) } -/** Drops records whose daemon PTYs are authoritatively absent. */ +/** Native daemon inventory can retire native records, including legacy Windows-owned WSL. */ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void { // Why: this deletes every pane not in the live list. Against an empty stand-in // it is a no-op, but the write it guards would still persist that stand-in @@ -362,7 +362,7 @@ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly stri changed = true } for (const ptyId of Object.keys(registry.panes)) { - if (!livePtyIdSet.has(ptyId)) { + if (!ptyId.startsWith('wsl:') && !ptyId.startsWith('ssh:') && !livePtyIdSet.has(ptyId)) { delete registry.panes[ptyId] changed = true } diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 027d059f3424..0158b4b447ee 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -244,6 +244,26 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(false) }) + it('preserves guest account provenance when native inventory is empty', () => { + const guestId = 'wsl:Ubuntu@@daemon-build@@guest-pty' + recordCodexPaneAccount(guestId, { + selectionKey: 'wsl:Ubuntu', + accountId: 'captured-account', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('legacy-windows-owned-wsl', { + selectionKey: 'wsl:Ubuntu', + accountId: null, + homeRoute: 'wsl-home' + }) + + reconcileCodexPaneAccountsWithLivePtys([]) + _internals.resetCache() + + expect(getCodexPaneAccount(guestId)).toMatchObject({ accountId: 'captured-account' }) + expect(getCodexPaneAccount('legacy-windows-owned-wsl')).toBeNull() + }) + it('forgets a PTY so a reused id cannot inherit a dead pane account', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' }) diff --git a/src/main/codex/codex-wsl-resume-file-probe.test.ts b/src/main/codex/codex-wsl-resume-file-probe.test.ts new file mode 100644 index 000000000000..94d55fbf741f --- /dev/null +++ b/src/main/codex/codex-wsl-resume-file-probe.test.ts @@ -0,0 +1,97 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + CODEX_WSL_RESUME_FILE_PROBE, + probeCodexWslResumeFiles +} from './codex-wsl-resume-file-probe' +import { runCapturedCodexWslProcess } from '../codex-accounts/captured-wsl-account-process' +vi.mock('../codex-accounts/captured-wsl-account-process', () => ({ + runCapturedCodexWslProcess: vi.fn() +})) +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const id = '11111111-2222-3333-4444-555555555555' +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'codex-resume-owner-'))) + roots.push(root) + const home = join(root, 'account'), + date = join(home, 'sessions', '2026', '09', '27') + mkdirSync(date, { recursive: true }) + const file = join(date, `rollout-date-${id}.jsonl`) + writeFileSync(file, '{}\n') + return { root, home, file, date } +} +async function probe(home: string, transcript: string, prefix = '') { + const statShim = + process.platform === 'darwin' ? 'stat() { /usr/bin/stat -f %u "${@: -1}"; }\n' : '' + return runProcess({ + program: 'bash', + args: ['-c', statShim + prefix + CODEX_WSL_RESUME_FILE_PROBE, 'probe', id, transcript, home], + timeoutMs: 5000, + maxOutputBytes: 65536 + }) +} +it.skipIf(process.platform === 'win32')( + 'executes guest verification for provenance and legacy scans without reading rollout contents', + async () => { + const { home, file } = fixture() + for (const path of [file, '']) { + const result = await probe(home, path) + expect(result.code).toBe(0) + expect(result.stdout).toBe(`${file}\0ORCA_CODEX_RESUME_PROBE_COMPLETE\0`) + } + } +) +it.skipIf(process.platform === 'win32')( + 'rejects symlink provenance and a symlink account home', + async () => { + const { root, home, file, date } = fixture() + const alias = join(date, `rollout-alias-${id}.jsonl`) + symlinkSync(file, alias) + expect((await probe(home, alias)).stdout).toBe('ORCA_CODEX_RESUME_PROBE_COMPLETE\0') + const linkedHome = join(root, 'linked') + symlinkSync(home, linkedHome) + expect((await probe(linkedHome, '')).code).toBe(80) + } +) +it.skipIf(process.platform === 'win32')( + 'rejects a file whose owner differs from the captured user', + async () => { + const { home, file } = fixture() + const result = await probe( + home, + file, + `stat() { if [ "\${@: -1}" = '${file}' ]; then echo 999999; else id -u; fi; }\n` + ) + expect(result.stdout).toBe('ORCA_CODEX_RESUME_PROBE_COMPLETE\0') + } +) +it('treats truncated guest output as failure rather than another-account fallback', async () => { + vi.mocked(runCapturedCodexWslProcess).mockResolvedValue({ + code: 0, + stdout: 'partial', + stderr: '', + timedOut: false, + environmentResolved: true + }) + const execution = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } + await expect( + probeCodexWslResumeFiles({ + execution, + homes: ['/home/alice/.codex'], + sessionId: id, + transcriptPath: '' + }) + ).rejects.toThrow('could not be verified') + expect(runCapturedCodexWslProcess).toHaveBeenCalledWith( + expect.objectContaining({ distro: 'Ubuntu', shell: 'bash', loginPath: 'none' }), + execution + ) +}) diff --git a/src/main/codex/codex-wsl-resume-file-probe.ts b/src/main/codex/codex-wsl-resume-file-probe.ts new file mode 100644 index 000000000000..d189f10eefcb --- /dev/null +++ b/src/main/codex/codex-wsl-resume-file-probe.ts @@ -0,0 +1,70 @@ +import { runCapturedCodexWslProcess } from '../codex-accounts/captured-wsl-account-process' +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' + +/** Guest-local checks reject aliases and files owned by another user before selecting credentials. */ +export const CODEX_WSL_RESUME_FILE_PROBE = ` +set -euo pipefail +session_id=$1 +transcript=$2 +shift 2 +check_file() { + file=$1 + [ -f "$file" ] && [ ! -L "$file" ] && [ -r "$file" ] || return 0 + [ "$(readlink -f -- "$file")" = "$file" ] || return 0 + [ "$(stat -c %u -- "$file")" = "$(id -u)" ] || return 0 + printf '%s\\0' "$file" +} +for codex_home in "$@"; do + if [ ! -e "$codex_home" ] && [ ! -L "$codex_home" ]; then continue; fi + [ -d "$codex_home" ] && [ ! -L "$codex_home" ] && [ -r "$codex_home" ] && [ -x "$codex_home" ] || exit 80 + [ "$(readlink -f -- "$codex_home")" = "$codex_home" ] || exit 80 + [ "$(stat -c %u -- "$codex_home")" = "$(id -u)" ] || exit 80 + if [ -n "$transcript" ]; then + case "$transcript" in "$codex_home"/sessions/*) + plain=\${transcript%.zst} + check_file "$plain" + check_file "$plain.zst" + ;; + esac + else + sessions="$codex_home/sessions" + if [ ! -e "$sessions" ] && [ ! -L "$sessions" ]; then continue; fi + [ -d "$sessions" ] && [ ! -L "$sessions" ] && [ -r "$sessions" ] && [ -x "$sessions" ] || exit 80 + find "$sessions" -mindepth 4 -maxdepth 4 -type f \\( -name "rollout-*-$session_id.jsonl" -o -name "rollout-*-$session_id.jsonl.zst" \\) -print0 | + while IFS= read -r -d '' file; do check_file "$file"; done + fi +done +printf 'ORCA_CODEX_RESUME_PROBE_COMPLETE\\0' +` + +export async function probeCodexWslResumeFiles(args: { + execution: WslAccountExecutionContext + homes: readonly string[] + sessionId: string + transcriptPath: string +}): Promise> { + const result = await runCapturedCodexWslProcess( + { + distro: args.execution.distro, + loginPath: 'none', + shell: 'bash', + script: CODEX_WSL_RESUME_FILE_PROBE, + args: [args.sessionId, args.transcriptPath, ...args.homes], + timeoutMs: 10_000, + maxOutputBytes: 256 * 1024 + }, + args.execution + ) + const complete = result.stdout.endsWith('ORCA_CODEX_RESUME_PROBE_COMPLETE\0') + if ( + result.code !== 0 || + result.timedOut || + !complete || + Buffer.byteLength(result.stdout) >= 256 * 1024 + ) { + throw new Error('The captured WSL Codex session files could not be verified. Retry resume.') + } + return new Set( + result.stdout.slice(0, -'ORCA_CODEX_RESUME_PROBE_COMPLETE\0'.length).split('\0').filter(Boolean) + ) +} diff --git a/src/main/codex/codex-wsl-session-resume.test.ts b/src/main/codex/codex-wsl-session-resume.test.ts new file mode 100644 index 000000000000..cb7f35e40d64 --- /dev/null +++ b/src/main/codex/codex-wsl-session-resume.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { toWindowsWslUncPath } from '../../shared/wsl-paths' +import { prepareCapturedWslCodexSessionResume } from './codex-wsl-session-resume' +import { probeCodexWslResumeFiles } from './codex-wsl-resume-file-probe' +vi.mock('./codex-wsl-resume-file-probe', () => ({ probeCodexWslResumeFiles: vi.fn() })) +const owner = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } +const id = '11111111-2222-3333-4444-555555555555' +const a = '/home/alice/accounts/a', + b = '/home/alice/accounts/b' +const rollout = (home: string) => `${home}/sessions/2026/09/27/rollout-2026-${id}.jsonl` +const account = (home: string, id: string): CodexManagedAccount => ({ + id, + managedHomePath: toWindowsWslUncPath(home, 'Ubuntu'), + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: home, + email: 'test@example.com', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 +}) +const prepare = (transcriptPath?: string) => + prepareCapturedWslCodexSessionResume({ + execution: owner, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession: { key: 'session_id', id, transcriptPath }, + accounts: [account(a, 'a'), account(b, 'b')], + selectedAccountId: 'b' + }) +beforeEach(() => { + vi.mocked(probeCodexWslResumeFiles).mockReset() +}) +it.each([false, true])( + 'pins verified transcript origin rather than currently selected account (UNC=%s)', + async (unc) => { + vi.mocked(probeCodexWslResumeFiles).mockResolvedValue(new Set([rollout(a)])) + expect(await prepare(unc ? toWindowsWslUncPath(rollout(a), 'Ubuntu') : rollout(a))).toEqual({ + outcome: 'resume', + codexHomePath: a + }) + } +) +it('does not fall back to another account after claimed provenance becomes unavailable', async () => { + vi.mocked(probeCodexWslResumeFiles).mockResolvedValue(new Set([rollout(b)])) + expect(await prepare(rollout(a))).toEqual({ outcome: 'fresh', claimedCodexProvenance: true }) +}) +it('reuses selected-account ranking only for legacy metadata with no transcript', async () => { + vi.mocked(probeCodexWslResumeFiles).mockResolvedValue(new Set([rollout(a), rollout(b)])) + expect(await prepare()).toEqual({ outcome: 'resume', codexHomePath: b }) +}) +it.each([ + toWindowsWslUncPath(rollout(a), 'Debian'), + rollout('/home/bob/.codex'), + rollout('/home/alice/../bob/.codex') +])('refuses foreign or noncanonical transcript %s before guest I/O', async (path) => { + expect(await prepare(path)).toEqual({ outcome: 'fresh', claimedCodexProvenance: true }) + expect(probeCodexWslResumeFiles).not.toHaveBeenCalled() +}) +it('refuses an unverified selected home without substituting system default', async () => { + await expect( + prepareCapturedWslCodexSessionResume({ + execution: owner, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession: { key: 'session_id', id }, + accounts: [account('/home/bob/.codex', 'b')], + selectedAccountId: 'b' + }) + ).rejects.toThrow('captured WSL owner') +}) +it('pins execution identity before asynchronous verification', async () => { + const execution = { ...owner } + vi.mocked(probeCodexWslResumeFiles).mockImplementation(async (args) => { + execution.home = '/home/bob' + execution.userName = 'bob' + expect(args.execution).toEqual(owner) + expect(Object.isFrozen(args.execution)).toBe(true) + return new Set([rollout(a)]) + }) + expect( + await prepareCapturedWslCodexSessionResume({ + execution, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession: { key: 'session_id', id, transcriptPath: rollout(a) }, + accounts: [account(a, 'a')], + selectedAccountId: 'a' + }) + ).toEqual({ outcome: 'resume', codexHomePath: a }) +}) + +it('refuses distinct Linux homes that shared Unicode normalization would merge', async () => { + await expect( + prepareCapturedWslCodexSessionResume({ + execution: owner, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession: { key: 'session_id', id }, + accounts: [account('/home/alice/caf\u00e9', 'a'), account('/home/alice/cafe\u0301', 'b')], + selectedAccountId: 'b' + }) + ).rejects.toThrow('ambiguous WSL path identities') + expect(probeCodexWslResumeFiles).not.toHaveBeenCalled() +}) diff --git a/src/main/codex/codex-wsl-session-resume.ts b/src/main/codex/codex-wsl-session-resume.ts new file mode 100644 index 000000000000..f63b6904fec2 --- /dev/null +++ b/src/main/codex/codex-wsl-session-resume.ts @@ -0,0 +1,110 @@ +import { posix } from 'node:path' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { parseWslUncPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../wsl/wsl-account-execution-context' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { + claimsCodexRolloutLayout, + type CodexSessionResumePreparation +} from './codex-session-resume-home' +import { prepareCodexSessionResume } from './codex-session-resume-preparation' +import { probeCodexWslResumeFiles } from './codex-wsl-resume-file-probe' + +function guestPath(path: string, execution: WslAccountExecutionContext): string | null { + const unc = parseWslUncPath(path) + if (unc && unc.distro.toLowerCase() !== execution.distro.toLowerCase()) { + return null + } + const candidate = unc?.linuxPath ?? path + return candidate.startsWith(`${execution.home}/`) && + posix.normalize(candidate) === candidate && + !/[\\\0\r\n]/.test(candidate) + ? candidate + : null +} + +/** Reuse provenance and legacy ranking, but never inspect guest files through Windows filesystem APIs. */ +export async function prepareCapturedWslCodexSessionResume(args: { + execution: WslAccountExecutionContext + target: CodexAccountSelectionTarget + providerSession: AgentProviderSessionMetadata + accounts: readonly CodexManagedAccount[] + selectedAccountId: string | null +}): Promise { + const execution = Object.freeze({ ...args.execution }) + const providerSession = Object.freeze({ ...args.providerSession }) + assertWslAccountExecutionTarget(execution, args.target) + const systemHome = posix.join(execution.home, '.codex') + const homes = new Map() + for (const account of args.accounts) { + const unc = parseWslUncPath(account.managedHomePath) + const distro = account.managedHomeRuntime === 'wsl' ? account.wslDistro : unc?.distro + const path = account.managedHomeRuntime === 'wsl' ? account.wslLinuxHomePath : unc?.linuxPath + if (distro?.toLowerCase() === execution.distro.toLowerCase() && path) { + const home = guestPath(path, execution) + if (home) { + homes.set(account.id, home) + } + } + } + const selectedHome = args.selectedAccountId ? homes.get(args.selectedAccountId) : null + if (args.selectedAccountId && !selectedHome) { + throw new Error('Selected Codex account does not belong to the captured WSL owner') + } + const claimed = providerSession.transcriptPath?.trim() + const transcriptPath = claimed ? guestPath(claimed, execution) : null + const fresh: CodexSessionResumePreparation = { + outcome: 'fresh', + claimedCodexProvenance: claimsCodexRolloutLayout(claimed) + } + if ( + (claimed && !transcriptPath) || + (!claimed && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(providerSession.id)) + ) { + return fresh + } + const trustedHomes = [...new Set([systemHome, ...homes.values()])] + // Linux permits distinct NFC/NFD homes; shared comparison must never merge their accounts. + if (new Set(trustedHomes.map(normalizeRuntimePathForComparison)).size !== trustedHomes.length) { + throw new Error('Codex account homes have ambiguous WSL path identities') + } + const files = await probeCodexWslResumeFiles({ + execution, + homes: trustedHomes, + sessionId: providerSession.id, + transcriptPath: transcriptPath ?? '' + }) + const asUnc = (path: string) => toWindowsWslUncPath(path, execution.distro) + const checkedFiles = new Map( + [...files].map((path) => [normalizeRuntimePathForComparison(asUnc(path)), path]) + ) + return prepareCodexSessionResume({ + sessionId: providerSession.id, + transcriptPath: transcriptPath ? asUnc(transcriptPath) : undefined, + trustedCodexHomes: trustedHomes.map(asUnc), + getSelectedAccountCodexHome: () => (selectedHome ? asUnc(selectedHome) : null), + systemCodexHomePath: asUnc(systemHome), + sharedRuntimeCodexHomePath: null, + fileIsRegular: (path) => checkedFiles.has(normalizeRuntimePathForComparison(path)), + async *listSessionFiles(sessionsRoot) { + const prefix = `${normalizeRuntimePathForComparison(sessionsRoot)}/` + for (const [key, path] of checkedFiles) { + if (key.startsWith(prefix)) { + yield asUnc(path) + } + } + }, + resolveVerifiedResumeHome: async ({ homePath }) => { + const home = guestPath(homePath, execution) + if (!home) { + throw new Error('Verified Codex session escaped its captured WSL owner') + } + return home + } + }) +} diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index a84b10fa6150..32215bca78d0 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -1,6 +1,10 @@ import { posix as pathPosix } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' -import { runWslProcess } from '../wsl/wsl-runner' +import { runCapturedCodexWslProcess } from '../codex-accounts/captured-wsl-account-process' +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from '../wsl/wsl-account-execution-context' import { buildWslCodexSessionBridgeShellCommand, WSL_SESSION_BRIDGE_TIMEOUT_MS @@ -9,6 +13,7 @@ import { export { buildWslCodexSessionBridgeShellCommand } from './wsl-codex-session-bridge-script' export type WslCodexSessionBridgeTarget = { + execution?: WslAccountExecutionContext distro: string systemCodexHomePath: string managedCodexHomePath: string @@ -29,6 +34,7 @@ const backgroundWslSessionBridgeTasks = new Map>() export function startWslCodexSessionBridgeInBackground( target: WslCodexSessionBridgeTarget ): Promise { + target = captureTarget(target) const taskKey = getWslSessionBridgeTaskKey(target) const existingTask = backgroundWslSessionBridgeTasks.get(taskKey) if (existingTask) { @@ -52,19 +58,23 @@ export function startWslCodexSessionBridgeInBackground( export async function syncWslCodexSessionsIntoManagedHome( target: WslCodexSessionBridgeTarget ): Promise { + target = captureTarget(target) const paths = resolveWslCodexSessionBridgeLinuxPaths(target) if (!paths) { return emptySummary } - const result = await runWslProcess({ - distro: target.distro, - loginPath: 'none', - script: buildWslCodexSessionBridgeShellCommand(paths), - // Process substitution and `read -d` are bash-only; dash rejects both. - shell: 'bash', - timeoutMs: WSL_SESSION_BRIDGE_TIMEOUT_MS - }) + const result = await runCapturedCodexWslProcess( + { + distro: target.distro, + loginPath: 'none', + script: buildWslCodexSessionBridgeShellCommand(paths), + // Process substitution and `read -d` are bash-only; dash rejects both. + shell: 'bash', + timeoutMs: WSL_SESSION_BRIDGE_TIMEOUT_MS + }, + target.execution + ) if (result.code !== 0 || result.timedOut) { throw Object.assign( new Error(`WSL codex session bridge failed for ${target.distro} (code ${result.code})`), @@ -89,8 +99,24 @@ export function resolveWslCodexSessionBridgeLinuxPaths( } } +function captureTarget(target: WslCodexSessionBridgeTarget): WslCodexSessionBridgeTarget { + if (!target.execution) { + return target + } + const execution = Object.freeze({ ...target.execution }) + assertWslAccountExecutionTarget(execution, { runtime: 'wsl', wslDistro: target.distro }) + return { ...target, execution } +} + function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string { - return [target.distro, target.systemCodexHomePath, target.managedCodexHomePath].join('\0') + return [ + target.distro, + target.systemCodexHomePath, + target.managedCodexHomePath, + ...(target.execution + ? [target.execution.userName, target.execution.userId, target.execution.home] + : []) + ].join('\0') } function getLinuxPathForWslDistro(path: string, distro: string): string | null { diff --git a/src/main/daemon/agent-startup-prompt-bun-fixture.ts b/src/main/daemon/agent-startup-prompt-bun-fixture.ts new file mode 100644 index 000000000000..fa4db0ab31d6 --- /dev/null +++ b/src/main/daemon/agent-startup-prompt-bun-fixture.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import type { Session } from './session' +const COMMAND = "printf 'AGENT_%s\\n' STARTED" + +export async function launchPromptFixture({ + shell, + slow, + legacy +}: { + shell: string + slow: boolean + legacy: boolean +}): Promise<{ output: string; ms: number }> { + const root = mkdtempSync(join(tmpdir(), 'orca-startup-latency-')) + const bash = shell.endsWith('bash') + const pause = slow ? 'sleep 0.6\n' : '' + const prompt = slow ? "PS1='$(sleep 0.3)prompt> '\n" : "PS1='prompt> '\n" + writeFileSync( + join(root, bash ? '.bash_profile' : '.zshrc'), + `${pause}${bash ? '' : 'setopt PROMPT_SUBST\n'}${prompt}` + ) + process.env.HOME = root + process.env.ZDOTDIR = root + process.env.ORCA_ORIG_ZDOTDIR = root + setAppEnvironment({ + getPath: () => root, + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit() {}, + exit: (code) => process.exit(code), + getAppMetrics: () => [] + }) + const { createPtySubprocess } = await import('./pty-subprocess') + const { Session } = await import('./session') + let session: Session | undefined + let timer: ReturnType | undefined + let legacyTimer: ReturnType | undefined + const readinessEvents: string[] = [] + const started = performance.now() + try { + const subprocess = await createPtySubprocess({ + sessionId: 'startup-latency', + cols: 120, + rows: 30, + cwd: root, + shellOverride: shell, + command: COMMAND, + env: { HOME: root, SHELL: shell, TERM: 'xterm-256color' } + }) + session = new Session({ + sessionId: 'startup-latency', + cols: 120, + rows: 30, + subprocess, + shellReadySupported: !legacy, + reportReadinessEvent: (event) => readinessEvents.push(event) + }) + const active = session + return await new Promise((resolve, reject) => { + let output = '' + timer = setTimeout( + () => reject(new Error(`Startup timed out: ${JSON.stringify(output)}`)), + 5000 + ) + active.attachClient({ + onExit: () => {}, + onData: (data) => { + output += data + if (output.includes('AGENT_STARTED')) { + resolve({ output, ms: performance.now() - started }) + } + } + }) + if (legacy) { + legacyTimer = setTimeout(() => active.write(`${COMMAND}\n`), 300) + } else { + active.write(`${COMMAND}\n`) + } + }) + } finally { + clearTimeout(timer) + clearTimeout(legacyTimer) + if (session) { + await session.forceKillAndWaitForExit(3000) + session.dispose() + } + rmSync(root, { recursive: true, force: true }) + assert.deepEqual(readinessEvents, []) + } +} diff --git a/src/main/daemon/agent-startup-prompt-latency.bun.test.ts b/src/main/daemon/agent-startup-prompt-latency.bun.test.ts new file mode 100644 index 000000000000..4efb8bfae006 --- /dev/null +++ b/src/main/daemon/agent-startup-prompt-latency.bun.test.ts @@ -0,0 +1,70 @@ +import { appendFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runBundledBunFixture } from '../bundled-bun-test-execution' + +const SHELLS = process.platform === 'win32' ? [] : ['/bin/bash', '/bin/zsh'].filter(existsSync) +const COMMAND = "printf 'AGENT_%s\\n' STARTED" + +async function launch( + shell: string, + slow: boolean, + legacy = false +): Promise<{ output: string; ms: number }> { + const result = await runBundledBunFixture( + join(__dirname, 'agent-startup-prompt-bun-fixture.ts'), + 'launchPromptFixture', + { shell, slow, legacy }, + 10_000 + ) + if ( + typeof result !== 'object' || + result === null || + !('output' in result) || + typeof result.output !== 'string' || + !('ms' in result) || + typeof result.ms !== 'number' + ) { + throw new Error('Invalid prompt fixture result') + } + return { output: result.output, ms: result.ms } +} + +describe('agent startup at the rendered shell prompt', () => { + it.each(SHELLS)( + '%s displays the command once after slow startup and prompt expansion', + async (shell) => { + const before = await launch(shell, true, true) + expect(before.output.split(COMMAND)).toHaveLength(3) + const result = await launch(shell, true) + expect(result.output).not.toContain('orca-shell-ready') + expect(result.output.split(COMMAND)).toHaveLength(2) + expect(result.output.indexOf('prompt> ')).toBeLessThan(result.output.indexOf(COMMAND)) + } + ) + + it.skipIf(!process.env.ORCA_STARTUP_BENCH || SHELLS.length === 0)( + 'compares legacy input timing with prompt delivery', + async () => { + for (const shell of SHELLS) { + for (const slow of [false, true]) { + const legacy: number[] = [] + const current: number[] = [] + for (let i = 0; i < 5; i++) { + legacy.push((await launch(shell, slow, true)).ms) + const result = await launch(shell, slow) + expect(result.output).not.toContain('orca-shell-ready') + expect(result.output.split(COMMAND)).toHaveLength(2) + current.push(result.ms) + } + const result = JSON.stringify({ shell, slow, legacy, current }) + if (process.env.ORCA_STARTUP_BENCH_OUTPUT) { + appendFileSync(process.env.ORCA_STARTUP_BENCH_OUTPUT, `${result}\n`) + } + console.log(result) + } + } + }, + 60_000 + ) +}) diff --git a/src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts b/src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts deleted file mode 100644 index cfa01fed8e56..000000000000 --- a/src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { appendFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createPtySubprocess } from './pty-subprocess' -import { Session } from './session' - -const SHELLS = process.platform === 'win32' ? [] : ['/bin/bash', '/bin/zsh'].filter(existsSync) -const COMMAND = "printf 'AGENT_%s\\n' STARTED" - -async function launch( - shell: string, - slow: boolean, - legacy = false -): Promise<{ output: string; ms: number }> { - const root = mkdtempSync(join(tmpdir(), 'orca-startup-latency-')) - const bash = shell.endsWith('bash') - const pause = slow ? 'sleep 0.6\n' : '' - const prompt = slow ? "PS1='$(sleep 0.3)prompt> '\n" : "PS1='prompt> '\n" - writeFileSync( - join(root, bash ? '.bash_profile' : '.zshrc'), - `${pause}${bash ? '' : 'setopt PROMPT_SUBST\n'}${prompt}` - ) - vi.stubEnv('HOME', root) - vi.stubEnv('ZDOTDIR', root) - vi.stubEnv('ORCA_ORIG_ZDOTDIR', root) - let session: Session | undefined - let timer: ReturnType | undefined - let legacyTimer: ReturnType | undefined - const readinessEvents: string[] = [] - const started = performance.now() - try { - const subprocess = await createPtySubprocess({ - sessionId: 'startup-latency', - cols: 120, - rows: 30, - cwd: root, - shellOverride: shell, - command: COMMAND, - env: { HOME: root, SHELL: shell, TERM: 'xterm-256color' } - }) - session = new Session({ - sessionId: 'startup-latency', - cols: 120, - rows: 30, - subprocess, - shellReadySupported: !legacy, - reportReadinessEvent: (event) => readinessEvents.push(event) - }) - const active = session - return await new Promise((resolve, reject) => { - let output = '' - timer = setTimeout( - () => reject(new Error(`Startup timed out: ${JSON.stringify(output)}`)), - 5000 - ) - active.attachClient({ - onExit: () => {}, - onData: (data) => { - output += data - if (output.includes('AGENT_STARTED')) { - resolve({ output, ms: performance.now() - started }) - } - } - }) - if (legacy) { - legacyTimer = setTimeout(() => active.write(`${COMMAND}\n`), 300) - } else { - active.write(`${COMMAND}\n`) - } - }) - } finally { - clearTimeout(timer) - clearTimeout(legacyTimer) - if (session) { - await session.forceKillAndWaitForExit(3000) - session.dispose() - } - vi.unstubAllEnvs() - rmSync(root, { recursive: true, force: true }) - expect(readinessEvents).toEqual([]) - } -} - -describe('agent startup at the rendered shell prompt', () => { - afterEach(() => vi.unstubAllEnvs()) - it.each(SHELLS)( - '%s displays the command once after slow startup and prompt expansion', - async (shell) => { - const before = await launch(shell, true, true) - expect(before.output.split(COMMAND)).toHaveLength(3) - const result = await launch(shell, true) - expect(result.output).not.toContain('orca-shell-ready') - expect(result.output.split(COMMAND)).toHaveLength(2) - expect(result.output.indexOf('prompt> ')).toBeLessThan(result.output.indexOf(COMMAND)) - } - ) - - it.skipIf(!process.env.ORCA_STARTUP_BENCH || SHELLS.length === 0)( - 'compares legacy input timing with prompt delivery', - async () => { - for (const shell of SHELLS) { - for (const slow of [false, true]) { - const legacy: number[] = [] - const current: number[] = [] - for (let i = 0; i < 5; i++) { - legacy.push((await launch(shell, slow, true)).ms) - const result = await launch(shell, slow) - expect(result.output).not.toContain('orca-shell-ready') - expect(result.output.split(COMMAND)).toHaveLength(2) - current.push(result.ms) - } - const result = JSON.stringify({ shell, slow, legacy, current }) - if (process.env.ORCA_STARTUP_BENCH_OUTPUT) { - appendFileSync(process.env.ORCA_STARTUP_BENCH_OUTPUT, `${result}\n`) - } - console.log(result) - } - } - }, - 60_000 - ) -}) diff --git a/src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts b/src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts new file mode 100644 index 000000000000..70344281bec2 --- /dev/null +++ b/src/main/daemon/bun-pty-fd-lifecycle.integration.test.ts @@ -0,0 +1,89 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' + +const runtime = + process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) +const enabled = process.platform !== 'win32' && existsSync(runtime) + +const script = ` +import { readdirSync, readlinkSync } from 'node:fs' +import { spawnBunPty } from './src/main/daemon/pty-subprocess/bun-pty-process' +import { runProcessSync } from './src/shared/child-process/run-process' +const pause = () => new Promise(resolve => setTimeout(resolve, 50)) +function terminalDescriptors() { + if (process.platform === 'linux') { + return readdirSync('/proc/self/fd').flatMap(fd => { + try { return [readlinkSync('/proc/self/fd/' + fd)] } catch { return [] } + }).filter(path => /\\/dev\\/(ptmx|pts\\/)/.test(path)).length + } + const result = runProcessSync({ program: 'lsof', args: ['-p', String(process.pid)], timeoutMs: 5000 }) + if (result.code !== 0) throw new Error('lsof failed: ' + result.stderr) + return result.stdout.split('\\n').filter(line => /\\/dev\\/(ptmx|ttys[0-9])|\\(revoked\\)/.test(line)).length +} +function terminal(file = '/bin/sh', args = ['-c', 'exit 0']) { + return spawnBunPty({ file, args, cwd: process.cwd(), env: process.env, cols: 80, rows: 24 }) +} +async function exitedShell() { + const proc = terminal() + try { await new Promise(resolve => proc.onExit(resolve)) } finally { proc.destroy() } +} +await exitedShell() +await pause() +const before = terminalDescriptors() +for (let index = 0; index < 50; index++) await exitedShell() +await pause() +const afterExit = terminalDescriptors() +let failedSpawns = 0 +for (let index = 0; index < 20; index++) { + try { terminal('/orca-no-such-terminal-shell', []) } catch { failedSpawns++ } +} +await pause() +const afterFailure = terminalDescriptors() +let inherited = null +if (process.platform === 'linux') { + const first = terminal('/bin/sh', ['-c', 'read value']) + let second + try { + const laterChild = runProcessSync({ program: '/bin/sh', args: ['-c', 'ls -l /proc/self/fd'], timeoutMs: 5000 }) + if (laterChild.code !== 0) throw new Error('child descriptor listing failed') + second = terminal('/bin/sh', ['-c', "read value; printf '__FD_LISTING_READY__\\n'; ls -l /proc/self/fd"]) + let output = '' + second.onData(data => { output += data }) + const done = new Promise(resolve => second.onExit(resolve)) + second.write('go\\n') + await done + if (!output.includes('__FD_LISTING_READY__') || !/\\/dev\\/pts\\//.test(output)) throw new Error('missing terminal descriptor evidence') + inherited = /ptmx/.test(laterChild.stdout) || /ptmx/.test(output) + } finally { + second?.destroy() + const done = new Promise(resolve => first.onExit(resolve)) + first.write('done\\n') + await done + first.destroy() + } +} +console.log(JSON.stringify({ exitLeak: afterExit - before, failedLeak: afterFailure - before, failedSpawns, inherited })) +` + +describe.skipIf(!enabled)('Bun native PTY descriptor lifecycle', () => { + it('releases exited and failed-spawn handles and prevents later-child inheritance', async () => { + const result = await runProcess({ + program: runtime, + args: ['--no-install', '--eval', script], + cwd: process.cwd(), + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20_000, + terminationBarrier: true + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + exitLeak: 0, + failedLeak: 0, + failedSpawns: 20, + inherited: process.platform === 'linux' ? false : null + }) + }, 25_000) +}) diff --git a/src/main/daemon/bun-pty-retired-handle-fixture.ts b/src/main/daemon/bun-pty-retired-handle-fixture.ts new file mode 100644 index 000000000000..eeea7406cd33 --- /dev/null +++ b/src/main/daemon/bun-pty-retired-handle-fixture.ts @@ -0,0 +1,71 @@ +import { readdirSync, readlinkSync } from 'node:fs' +import { spawnBunPty } from './pty-subprocess/bun-pty-process' + +function masterDescriptors(): Set { + if (process.platform !== 'linux') { + return new Set() + } + return new Set( + readdirSync('/proc/self/fd').filter((fd) => { + try { + return /\/dev\/(?:pts\/)?ptmx$/.test(readlinkSync(`/proc/self/fd/${fd}`)) + } catch { + return false + } + }) + ) +} + +function shell(command: string, file = '/bin/sh') { + return spawnBunPty({ + file, + args: ['-c', command], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', TERM: 'xterm-256color' }, + cols: 80, + rows: 24 + }) +} + +export async function exerciseRetiredHandle({ destroy }: { destroy: boolean }) { + const before = masterDescriptors() + const retired = shell('read value') + const retiredDescriptors = [...masterDescriptors()].filter((fd) => !before.has(fd)) + const retiredExit = new Promise((resolve) => retired.onExit(() => resolve())) + retired.write('done\r') + await retiredExit + if (destroy) { + retired.destroy() + } + + const live = shell('read value; stty size; printf "__READ:%s__\n" "$value"', '/bin/bash') + const liveDescriptors = masterDescriptors() + let output = '' + live.onData((data) => { + output += data + }) + const liveExit = new Promise((resolve) => + live.onExit(({ exitCode }) => resolve(exitCode)) + ) + try { + retired.resize(200, 50) + retired.write('leak\r') + const retiredProcess = retired.process + live.write('hello\r') + const exitCode = await liveExit + return { + retiredSize: [retired.cols, retired.rows], + retiredProcess, + output, + exitCode, + reusedDescriptor: + process.platform === 'linux' + ? retiredDescriptors.some((fd) => liveDescriptors.has(fd)) + : null + } + } finally { + live.destroy() + retired.destroy() + await liveExit + } +} diff --git a/src/main/daemon/bun-pty-retired-handle.integration.test.ts b/src/main/daemon/bun-pty-retired-handle.integration.test.ts new file mode 100644 index 000000000000..985675878379 --- /dev/null +++ b/src/main/daemon/bun-pty-retired-handle.integration.test.ts @@ -0,0 +1,39 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { runBundledBunFixture } from '../bundled-bun-test-execution' + +const resultSchema = z.object({ + retiredSize: z.array(z.number()), + retiredProcess: z.string(), + output: z.string(), + exitCode: z.number(), + reusedDescriptor: z.boolean().nullable() +}) + +describe.skipIf(process.platform === 'win32')('Bun retired PTY handle isolation', () => { + it.each([false, true])( + 'does not resize, write into or inspect a later PTY after exit (explicit destroy: %s)', + async (destroy) => { + const result = resultSchema.parse( + await runBundledBunFixture( + join(__dirname, 'bun-pty-retired-handle-fixture.ts'), + 'exerciseRetiredHandle', + { destroy }, + 10_000 + ) + ) + expect(result.retiredSize).toEqual([80, 24]) + expect(result.retiredProcess).toBe('/bin/sh') + expect(result.exitCode).toBe(0) + expect(result.output).toContain('24 80') + expect(result.output).toContain('__READ:hello__') + expect(result.output).not.toContain('leak') + if (process.platform === 'linux') { + // Prove the kernel actually reused the descriptor instead of testing unrelated handles. + expect(result.reusedDescriptor).toBe(true) + } + }, + 15_000 + ) +}) diff --git a/src/main/daemon/bun-pty-windows-io-failure-fixture.ts b/src/main/daemon/bun-pty-windows-io-failure-fixture.ts new file mode 100644 index 000000000000..87647ba5f811 --- /dev/null +++ b/src/main/daemon/bun-pty-windows-io-failure-fixture.ts @@ -0,0 +1,175 @@ +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { resolveBunRuntime } from './pty-subprocess/bun-pty-process-capabilities' +import type { BunTerminalOptions } from './pty-subprocess/bun-pty-process-contract' +import { spawnBunPty } from './pty-subprocess/bun-pty-process' +import { createWindowsBunPtyLaunch } from './pty-subprocess/windows-bun-pty-launch' + +const CHILD = ` +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdout.write('NATIVE_READY'); +process.stdin.on('data', chunk => { + if (chunk.toString().includes('finish')) { + process.stdout.write('FINAL_STATE_END', () => process.exit(17)); + } else { + process.stdout.write('WITNESS_REPLY_END'); + } +});` + +export async function runWindowsNativeIoFailure(options: { + operation: 'write-close' | 'late-write' | 'drain-close' + workerPath?: string +}) { + if (process.platform !== 'win32') { + throw new Error('Windows native qualification only') + } + const runtime = resolveBunRuntime() + let closeOnWrite = false + let nativeFailure = '' + class ClosingTerminal extends runtime.Terminal { + constructor(args: BunTerminalOptions) { + super(args) + const nativeWrite = this.write.bind(this) + this.write = (data) => { + if (closeOnWrite) { + closeOnWrite = false + this.close() + } + try { + return nativeWrite(data) + } catch (error) { + nativeFailure = error instanceof Error ? error.message : String(error) + throw error + } + } + } + } + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) + const launch = (victim: boolean) => { + const proc = spawnBunPty( + { + file: process.execPath, + args: ['--no-env-file', '-e', CHILD], + cwd: process.cwd(), + env, + cols: 120, + rows: 30, + windowsJobKillOnClose: true + }, + { + ...(victim + ? { runtime: { Terminal: ClosingTerminal, spawn: runtime.spawn.bind(runtime) } } + : {}), + createWindowsLaunch: (args) => + createWindowsBunPtyLaunch(args, { + workerPath: + options.workerPath ?? join(__dirname, 'pty-subprocess/windows-bun-pty-gate-entry.ts') + }) + } + ) + let output = '' + let exitCount = 0 + let finalOutputBeforeExit = false + proc.onData((data) => { + output += data + }) + const exited = new Promise((resolve) => { + proc.onExit(({ exitCode }) => { + finalOutputBeforeExit = output.includes('FINAL_STATE_END') + exitCount++ + resolve(exitCode) + }) + }) + return { + proc, + exited, + output: () => output, + exitCount: () => exitCount, + finalOutputBeforeExit: () => finalOutputBeforeExit + } + } + const uncaught: string[] = [] + const onUncaught = (error: Error) => { + uncaught.push(error.message) + } + process.on('uncaughtException', onUncaught) + const terminals: ReturnType[] = [] + const bounded = async (promise: Promise): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Native I/O fixture deadline exceeded')), + 15_000 + ) + }) + ]) + } finally { + clearTimeout(timer) + } + } + const waitFor = async (terminal: ReturnType, marker: string) => { + const deadline = Date.now() + 15_000 + while (!terminal.output().includes(marker)) { + if (Date.now() > deadline) { + throw new Error(`Missing ${marker}: ${terminal.output()}`) + } + await delay(10) + } + } + try { + const victim = launch(true) + terminals.push(victim) + const witness = launch(false) + terminals.push(witness) + await bounded(Promise.all(terminals.map(async (t) => t.proc.waitForSpawn?.()))) + await Promise.all(terminals.map((t) => waitFor(t, 'NATIVE_READY'))) + if (options.operation === 'write-close') { + // Close the real native handle after the adapter's check; no synthetic exception. + closeOnWrite = true + victim.proc.write('trigger') + } else if (options.operation === 'late-write') { + victim.proc.kill() + } else { + victim.proc.write('finish') + } + const exitCode = await bounded(victim.exited) + victim.proc.write('late input') + victim.proc.resize(90, 24) + await delay(1_500) + witness.proc.write('probe') + await waitFor(witness, 'WITNESS_REPLY_END') + return { + nativeFailure, + exitCode, + victimExitCount: victim.exitCount(), + witnessExitCount: witness.exitCount(), + witnessWritable: true, + finalOutputBeforeExit: victim.finalOutputBeforeExit(), + uncaught + } + } finally { + try { + await bounded( + Promise.all( + terminals.map(async ({ proc, exited }) => { + try { + proc.kill() + await exited + } finally { + proc.destroy() + } + }) + ) + ) + } finally { + await delay(1_500) + process.off('uncaughtException', onUncaught) + } + } +} diff --git a/src/main/daemon/bun-pty-windows-io-failure.win32.test.ts b/src/main/daemon/bun-pty-windows-io-failure.win32.test.ts new file mode 100644 index 000000000000..a657b54dc984 --- /dev/null +++ b/src/main/daemon/bun-pty-windows-io-failure.win32.test.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runBundledBunFixture } from '../bundled-bun-test-execution' + +describe.skipIf(process.platform !== 'win32')('Bun Windows native terminal I/O isolation', () => { + it.each(['write-close', 'late-write', 'drain-close'] as const)( + 'contains %s while an unrelated native terminal remains writable', + async (operation) => { + const result = await runBundledBunFixture( + join(__dirname, 'bun-pty-windows-io-failure-fixture.ts'), + 'runWindowsNativeIoFailure', + { operation }, + 60_000 + ) + expect(result).toMatchObject({ + victimExitCount: 1, + witnessExitCount: 0, + witnessWritable: true, + uncaught: [] + }) + if (operation === 'write-close') { + expect(result).toMatchObject({ nativeFailure: 'Terminal is closed' }) + } + if (operation === 'drain-close') { + expect(result).toMatchObject({ exitCode: 17, finalOutputBeforeExit: true }) + } + }, + 65_000 + ) +}) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 37d628a1464d..0ccf76e150f4 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -1,5 +1,10 @@ -import type { Socket } from 'node:net' -import { readFileSync } from 'node:fs' +import { DaemonConnectionAttempt } from './daemon-client-connection-attempt' +import type { Duplex } from 'node:stream' +import { + resolveDaemonTransport, + type DaemonClientOptions, + type DaemonClientTransport +} from './daemon-client-transport' import { randomUUID } from 'node:crypto' import { encodeNdjson } from './ndjson' import { @@ -12,7 +17,6 @@ import { import { writeRefused, type WriteSettlement } from '../../shared/pty-write-settlement' import { armDaemonSocketCloseHandlers, - connectDaemonSocket, waitForDaemonConnectionAttempt } from './daemon-client-socket-connect' import { DaemonClientListeners } from './daemon-client-listener-registry' @@ -26,24 +30,17 @@ import { writeNotifyWithSettlement } from './daemon-client-notify-settlement' import { requestDaemonRpc } from './daemon-client-rpc-request' const CONNECT_TIMEOUT_MS = 5000 -const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 -const REQUEST_TIMEOUT_MS = 30000 const NOTIFY_SETTLEMENT_TIMEOUT_MS = 5000 -export type DaemonClientOptions = { - socketPath: string - tokenPath: string - protocolVersion?: number -} - +export type { DaemonClientOptions, DaemonClientTransport } from './daemon-client-transport' export class DaemonClient { - private socketPath: string - private tokenPath: string + private transport: DaemonClientTransport + private connectionAbort: AbortController | null = null private protocolVersion: number private clientId = randomUUID() - private controlSocket: Socket | null = null - private streamSocket: Socket | null = null + private controlSocket: Duplex | null = null + private streamSocket: Duplex | null = null private connected = false private disconnectArmed = false // Why: after a disconnect + reconnect (daemon respawn), a stale 'close' @@ -66,8 +63,7 @@ export class DaemonClient { private cleanupSocketListeners: (() => void) | null = null constructor(opts: DaemonClientOptions) { - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath + this.transport = resolveDaemonTransport(opts) this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION } @@ -101,7 +97,7 @@ export class DaemonClient { if (this.connectingPromise) { // Why: a normal connection may legitimately consume one timeout for each // socket and hello; bounded teardown calls instead keep their one shared budget. - const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS + const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECT_TIMEOUT_MS * 5 return waitForDaemonConnectionAttempt(this.connectingPromise, waiterTimeoutMs) } @@ -114,44 +110,29 @@ export class DaemonClient { } } - // Why: a missing token must not preempt the connect that proves whether the endpoint is gone. - private readToken(): string { - try { - return readFileSync(this.tokenPath, 'utf-8').trim() - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') { - return '' - } - throw error - } - } - private async doConnect( timeoutMs: number, attemptGeneration: number, sharedBudget: boolean ): Promise { - const token = this.readToken() - const deadlineMs = Date.now() + timeoutMs - const remainingMs = (): number => - sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs - const pendingListenerCleanups: (() => void)[] = [] - const cleanupPendingListeners = (): void => { - for (const cleanup of pendingListenerCleanups.splice(0)) { - cleanup() - } - } + const attempt = new DaemonConnectionAttempt(this.transport, timeoutMs, sharedBudget) + const controller = attempt.controller + this.connectionAbort = controller + const pendingListenerCleanups = attempt.readerCleanups + const cleanupPendingListeners = () => attempt.releaseReaders() try { + const token = await attempt.readToken() + this.assertConnectionAttemptCurrent(attemptGeneration) // Sequential: control first, then stream - const pendingControlSocket = await connectDaemonSocket(this.socketPath, remainingMs()) + const pendingControlSocket = await attempt.connect('control') this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket) this.controlSocket = pendingControlSocket const controlIdentity = await this.sendHello( - this.controlSocket, + pendingControlSocket, token, 'control', - remainingMs() + attempt.remainingMs() ) this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket) pendingListenerCleanups.push( @@ -160,10 +141,15 @@ export class DaemonClient { ) ) - const pendingStreamSocket = await connectDaemonSocket(this.socketPath, remainingMs()) + const pendingStreamSocket = await attempt.connect('stream') this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket) this.streamSocket = pendingStreamSocket - const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs()) + const streamIdentity = await this.sendHello( + pendingStreamSocket, + token, + 'stream', + attempt.remainingMs() + ) this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket) if (!sameDaemonIdentity(controlIdentity, streamIdentity)) { throw new DaemonProtocolError('Daemon identity changed during connection') @@ -175,6 +161,9 @@ export class DaemonClient { ) this.assertConnectionAttemptCurrent(attemptGeneration) + if (controller.signal.aborted) { + throw controller.signal.reason + } this.connected = true this.observedAuthenticatedDisconnect = false this.daemonIdentity = controlIdentity @@ -189,6 +178,7 @@ export class DaemonClient { ) this.cleanupSocketListeners = cleanupPendingListeners } catch (error) { + controller.abort(error) cleanupPendingListeners() this.controlSocket?.destroy() this.streamSocket?.destroy() @@ -198,13 +188,18 @@ export class DaemonClient { this.daemonIdentity = null this.disconnectArmed = false throw error + } finally { + attempt.releaseGuards() + if (this.connectionAbort === controller) { + this.connectionAbort = null + } } } async request( type: string, payload: unknown, - timeoutMs = REQUEST_TIMEOUT_MS, + timeoutMs = 30_000, signal?: AbortSignal ): Promise { if (!this.connected || !this.controlSocket) { @@ -283,6 +278,7 @@ export class DaemonClient { } disconnect(): void { + this.connectionAbort?.abort(new DaemonProtocolError('Disconnected')) this.connectionAttemptGeneration++ this.connected = false this.daemonIdentity = null @@ -297,7 +293,7 @@ export class DaemonClient { this.streamSocket = null } - private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void { + private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Duplex): void { if (attemptGeneration === this.connectionAttemptGeneration) { return } @@ -306,7 +302,7 @@ export class DaemonClient { } private sendHello( - socket: Socket, + socket: Duplex, token: string, role: 'control' | 'stream', timeoutMs: number @@ -315,6 +311,7 @@ export class DaemonClient { socket, token, role, + signal: this.connectionAbort?.signal, timeoutMs, protocolVersion: this.protocolVersion, clientId: this.clientId diff --git a/src/main/providers/local-pty-bun-artifact.integration.test.ts b/src/main/daemon/daemon-bun-pty-artifact.integration.test.ts similarity index 67% rename from src/main/providers/local-pty-bun-artifact.integration.test.ts rename to src/main/daemon/daemon-bun-pty-artifact.integration.test.ts index 487127887a34..18f85b914059 100644 --- a/src/main/providers/local-pty-bun-artifact.integration.test.ts +++ b/src/main/daemon/daemon-bun-pty-artifact.integration.test.ts @@ -12,16 +12,17 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal' const runtime = process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) -describe.skipIf(!existsSync(runtime))('isolated Bun in-process PTY artifact', () => { +describe.skipIf(!existsSync(runtime))('isolated Bun daemon PTY artifact', () => { it('spawns, reattaches, delivers data and retires a shell without node-pty installed', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-bun-local-pty-')) + const directory = await mkdtemp(join(tmpdir(), 'orca-bun-daemon-pty-')) try { - const entry = join(directory, 'local-pty.cjs') + const entry = join(directory, 'daemon-pty.cjs') const external = ['node-pty', 'electron', 'bun:ffi', '@parcel/watcher', '*.node'] await build({ stdin: { contents: ` - import { LocalPtyProvider } from './src/main/providers/local-pty-provider' + import { TerminalHost } from './src/main/daemon/terminal-host' + import { createPtySubprocess } from './src/main/daemon/pty-subprocess' import { setAppEnvironment } from './src/shared/app-environment' import { getCmdExePath } from './src/shared/windows-batch-spawn' try { require.resolve('node-pty'); throw new Error('node-pty unexpectedly available') } @@ -31,29 +32,31 @@ describe.skipIf(!existsSync(runtime))('isolated Bun in-process PTY artifact', () getVersion: () => 'test', isPackaged: () => true, onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => [] }) - const provider = new LocalPtyProvider() + const host = new TerminalHost({ spawnSubprocess: createPtySubprocess }) let output = '', resolveExit const exit = new Promise(resolve => { resolveExit = resolve }) - provider.onData(event => { output += event.data }) - provider.onExit(event => resolveExit(event.code)) - const deadline = setTimeout(() => { provider.killAll(); process.exit(98) }, 10_000) + const streamClient = { + onData: data => { output += data }, onExit: code => resolveExit(code) + } + const deadline = setTimeout(() => { void host.dispose().finally(() => process.exit(98)) }, 10_000) ;(async () => { - const first = await provider.spawn({ - sessionId: 'isolated-bun-fallback', cols: 80, rows: 24, cwd: process.cwd(), + const first = await host.createOrAttach({ + sessionId: 'isolated-bun-daemon', streamClient, cols: 80, rows: 24, cwd: process.cwd(), shellOverride: process.platform === 'win32' ? getCmdExePath() : '/bin/sh' }) - const again = await provider.spawn({sessionId:first.id, cols:100, rows:30}) - provider.write(first.id, process.platform === 'win32' - ? 'echo ORCA_BUN_FALLBACK_READY & exit 17\\r' - : 'printf ORCA_BUN_FALLBACK_READY; exit 17\\r') + const again = await host.createOrAttach({sessionId:'isolated-bun-daemon', streamClient, cols:100, rows:30}) + host.write('isolated-bun-daemon', process.platform === 'win32' + ? 'echo ORCA_BUN_DAEMON_READY & exit 17\\r' + : 'printf ORCA_BUN_DAEMON_READY; exit 17\\r') const code = await exit clearTimeout(deadline) console.log(JSON.stringify({ - version:process.versions.bun, code, output:output.includes('ORCA_BUN_FALLBACK_READY'), - reattached:again.isReattach === true && again.pid === first.pid, - retired:provider.getPtyProcess(first.id) === undefined + version:process.versions.bun, code, output:output.includes('ORCA_BUN_DAEMON_READY'), + reattached:again.isNew === false && again.pid === first.pid, + retired:host.listSessions().length === 0 })) - })().catch(error => { clearTimeout(deadline); provider.killAll(); console.error(error);process.exitCode=1 }) + await host.dispose() + })().catch(error => { clearTimeout(deadline); void host.dispose(); console.error(error);process.exitCode=1 }) `, resolveDir: process.cwd(), loader: 'ts' @@ -61,7 +64,7 @@ describe.skipIf(!existsSync(runtime))('isolated Bun in-process PTY artifact', () bundle: true, platform: 'node', format: 'cjs', - target: 'node18', + target: 'es2022', external, outfile: entry, logLevel: 'silent' @@ -81,7 +84,7 @@ describe.skipIf(!existsSync(runtime))('isolated Bun in-process PTY artifact', () await copyFile(runtime, isolatedRuntime) const result = await runProcess({ program: isolatedRuntime, - args: ['--no-install', entry], + args: ['--no-env-file', '--no-install', entry], cwd: directory, env: { ...process.env, diff --git a/src/main/daemon/daemon-bun-runtime-retention.test.ts b/src/main/daemon/daemon-bun-runtime-retention.test.ts new file mode 100644 index 000000000000..a02a39e23cb0 --- /dev/null +++ b/src/main/daemon/daemon-bun-runtime-retention.test.ts @@ -0,0 +1,322 @@ +import { + mkdtemp, + mkdir, + readdir, + rm, + symlink, + writeFile, + realpath, + readFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { + acquireDaemonRuntimeLaunchPin, + collectPinnedDaemonRuntimeDirectories, + MANAGED_DAEMON_RUNTIME_DIRECTORY, + pruneDaemonBunRuntimes +} from './daemon-bun-runtime-retention' + +const { snapshot, available, identities } = vi.hoisted(() => ({ + snapshot: vi.fn(), + available: vi.fn(), + identities: vi.fn() +})) +vi.mock('../windows/windows-process-table', () => ({ + readWindowsProcessTableFresh: snapshot, + isWindowsProcessTableAvailable: available, + isWindowsProcessStartTimeAvailable: identities, + readWindowsProcessCreationTime: () => null +})) +const roots: string[] = [] +beforeEach(() => { + snapshot.mockReset().mockResolvedValue([]) + available.mockReturnValue(true) + identities.mockReturnValue(true) +}) +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) +async function fixture(count = 8) { + const host = await realpath(await mkdtemp(join(tmpdir(), 'bun-retention-'))) + roots.push(host) + const root = join(host, MANAGED_DAEMON_RUNTIME_DIRECTORY) + await mkdir(root) + const paths: string[] = [] + for (let i = 0; i < count; i++) { + const path = join(root, `bun-${i.toString(16).padStart(64, '0')}`) + await mkdir(path) + await writeFile(join(path, 'daemon-entry.js'), 'entry') + paths.push(path) + } + return { host, root, paths } +} +async function generations(root: string) { + return (await readdir(root)).filter((name) => name.startsWith('bun-')) +} +describe('managed Windows daemon runtime retention', () => { + it('bounds each pass and retains two unused generations without touching legacy paths', async () => { + const { host, root } = await fixture() + await mkdir(join(host, 'legacy-version')) + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(4) + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(2) + expect(await readdir(host)).toContain('legacy-version') + }) + it('excludes pruning while any profile is resolving or launching', async () => { + const { host, root } = await fixture() + const first = await acquireDaemonRuntimeLaunchPin(root) + const second = await acquireDaemonRuntimeLaunchPin(root) + first.release() + await pruneDaemonBunRuntimes(host) + expect(snapshot).not.toHaveBeenCalled() + expect(await generations(root)).toHaveLength(8) + second.release() + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(4) + }) + it('a launch waits for the exclusive snapshot and deletion to finish', async () => { + const { host, root } = await fixture() + let finish: (() => void) | undefined + snapshot.mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve([]) + }) + ) + const pruning = pruneDaemonBunRuntimes(host) + await vi.waitFor(() => expect(snapshot).toHaveBeenCalled()) + let admitted = false + const launching = acquireDaemonRuntimeLaunchPin(root).then((pin) => { + admitted = true + return pin + }) + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(admitted).toBe(false) + finish?.() + await pruning + ;(await launching).release() + }) + it.each(['bun-runtime.exe', 'bun.exe', 'OpenConsole.exe'])( + 'retains live %s closures', + async (name) => { + const { host, root, paths } = await fixture(4) + snapshot.mockResolvedValue( + paths.map((path, index) => ({ + pid: index + 100, + ppid: 1, + name, + creationTimeMs: 100, + command: `"C:${path.replaceAll('/', '\\')}\\${name}"` + })) + ) + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(4) + } + ) + it.each([ + { command: '', creationTimeMs: 100 }, + { command: 'bun-runtime.exe daemon-entry.js', creationTimeMs: 100 }, + { command: '"C:\\elsewhere\\bun.exe"', creationTimeMs: undefined } + ])('preserves all files when a relevant owner is unverifiable: %j', async (row) => { + const { host, root } = await fixture() + snapshot.mockResolvedValue([{ pid: 100, ppid: 1, name: 'bun-runtime.exe', ...row }]) + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(8) + }) + it('preserves everything when the snapshot fails or creation-time support is missing', async () => { + const { host, root } = await fixture() + snapshot.mockRejectedValue(new Error('unavailable')) + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(8) + snapshot.mockClear() + identities.mockReturnValue(false) + await pruneDaemonBunRuntimes(host) + expect(snapshot).not.toHaveBeenCalled() + }) + it('cleans staging only under exclusive ownership, regardless of its age', async () => { + const { host, root } = await fixture(0) + const name = '.bun-staging-12345678-1234-1234-1234-123456789abc' + await mkdir(join(root, name)) + const pin = acquireProfileStateRuntimeAdmission(root) + await pruneDaemonBunRuntimes(host) + expect(await readdir(root)).toContain(name) + pin.release() + await pruneDaemonBunRuntimes(host) + expect(await readdir(root)).not.toContain(name) + }) + it('does not traverse symlinked generations or roots', async () => { + const { host, root } = await fixture(0) + const external = await mkdtemp(join(tmpdir(), 'bun-external-')) + roots.push(external) + await writeFile(join(external, 'precious'), 'live') + await symlink(external, join(root, `bun-${'a'.repeat(64)}`), 'dir') + await pruneDaemonBunRuntimes(host) + expect(await readdir(external)).toEqual(['precious']) + await rm(root, { recursive: true }) + await symlink(external, root, 'dir') + snapshot.mockClear() + await pruneDaemonBunRuntimes(host) + expect(snapshot).not.toHaveBeenCalled() + }) + it('reclaims a pin only when its recorded process has exited', async () => { + const { host, root } = await fixture() + acquireProfileStateRuntimeAdmission(root) + const participants = join(root, '.profile-state-access', 'participants') + const [owner] = await readdir(participants) + const path = join(participants, owner, `${owner}.owner`) + const record = JSON.parse(await readFile(path, 'utf8')) + await writeFile(path, JSON.stringify({ ...record, pid: 2147483647 })) + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(4) + expect(await readdir(participants)).toEqual([]) + }) + it('preserves malformed stale launch owners rather than stealing them by age', async () => { + const { host, root } = await fixture() + const pin = acquireProfileStateRuntimeAdmission(root) + const participants = join(root, '.profile-state-access', 'participants') + const [owner] = await readdir(participants) + await writeFile(join(participants, owner, `${owner}.owner`), '{}') + await pruneDaemonBunRuntimes(host) + expect(await generations(root)).toHaveLength(8) + // The malformed owner is retained deliberately; the fixture removes it after the test. + expect(pin).toBeDefined() + }) +}) + +describe('runtime executable aliases', () => { + const directory = 'C:\\Users\\Owner Name\\Orca\\managed-v1\\bun-generation' + function processRow(command: string) { + return { pid: 100, ppid: 1, name: 'bun-runtime.exe', creationTimeMs: 100, command } + } + it.each([ + 'C:\\Users\\OWNERN~1\\Orca\\managed-v1\\bun-generation\\bun-runtime.exe', + 'C:\\runtime-junction\\bun-runtime.exe' + ])('pins an executable reached through %s', async (alias) => { + const resolve = vi.fn().mockResolvedValue(`${directory}\\bun-runtime.exe`) + const pinned = await collectPinnedDaemonRuntimeDirectories( + [processRow(`"${alias}" daemon-entry.js`)], + [directory], + resolve + ) + expect(resolve).toHaveBeenCalledWith(alias) + expect(pinned).toEqual(new Set([directory])) + }) + it('vetoes deletion when a known executable cannot be canonicalized', async () => { + const resolve = vi.fn().mockRejectedValue(new Error('EACCES')) + expect( + await collectPinnedDaemonRuntimeDirectories( + [processRow('"C:\\unknown\\bun-runtime.exe"')], + [directory], + resolve + ) + ).toBeNull() + }) + it('still pins a gate script when its executable belongs to another generation', async () => { + const resolve = vi.fn().mockResolvedValue('C:\\other\\bun-runtime.exe') + const pinned = await collectPinnedDaemonRuntimeDirectories( + [processRow(`"C:\\other\\bun-runtime.exe" "${directory}\\windows-bun-pty-gate-entry.js"`)], + [directory], + resolve + ) + expect(pinned).toEqual(new Set([directory])) + }) + it('does not confuse a sibling prefix with an owned directory', async () => { + const resolve = vi + .fn() + .mockImplementation(async (path: string) => + path === join(directory, 'bun-runtime.exe') + ? `${directory}\\bun-runtime.exe` + : `${directory}.repair-1\\bun-runtime.exe` + ) + expect( + await collectPinnedDaemonRuntimeDirectories( + [processRow('"C:\\other\\bun-runtime.exe"')], + [directory], + resolve + ) + ).toEqual(new Set()) + }) +}) + +it.each(['bun-runtime.exe', 'OpenConsole.exe'])( + 'pins %s when file realpath expands an alias that directory realpath retains', + async (name) => { + const directory = 'C:\\Users\\RUNNER~1\\runtime\\bun-generation' + const executable = `C:\\runtime-junction\\${name}` + const candidateImage = + name === 'OpenConsole.exe' ? join(directory, 'conpty', name) : join(directory, name) + const expanded = `C:\\Users\\runneradmin\\runtime\\bun-generation\\${name}` + const resolve = vi.fn().mockImplementation(async (path: string) => { + if (path === executable || path === candidateImage) { + return expanded + } + throw new Error('unexpected path') + }) + expect( + await collectPinnedDaemonRuntimeDirectories( + [{ pid: 100, ppid: 1, name, creationTimeMs: 100, command: `"${executable}"` }], + [directory], + resolve + ) + ).toEqual(new Set([directory])) + expect(resolve).toHaveBeenCalledWith(candidateImage) + } +) + +it('vetoes deletion when candidate file identity cannot be compared', async () => { + const executable = 'C:\\other\\bun-runtime.exe' + const resolve = vi.fn().mockImplementation(async (path: string) => { + if (path === executable) { + return executable + } + throw new Error('EACCES') + }) + expect( + await collectPinnedDaemonRuntimeDirectories( + [ + { + pid: 100, + ppid: 1, + name: 'bun-runtime.exe', + creationTimeMs: 100, + command: `"${executable}"` + } + ], + ['C:\\runtime\\bun-generation'], + resolve + ) + ).toBeNull() +}) + +it('ignores absent staging images but vetoes unknown and published identities', async () => { + const staging = 'C:\\runtime\\.bun-staging-12345678-1234-1234-1234-123456789abc' + const executable = 'C:\\unrelated\\bun-runtime.exe' + const row = { + pid: 100, + ppid: 1, + name: 'bun-runtime.exe', + creationTimeMs: 100, + command: `"${executable}"` + } + let code = 'ENOENT' + const resolve = vi.fn().mockImplementation(async (path: string) => { + if (path === executable) { + return executable + } + throw Object.assign(new Error(code), { code }) + }) + expect(await collectPinnedDaemonRuntimeDirectories([row], [staging], resolve)).toEqual(new Set()) + expect( + await collectPinnedDaemonRuntimeDirectories( + [row], + [`C:\\runtime\\bun-${'a'.repeat(64)}`], + resolve + ) + ).toBeNull() + code = 'EACCES' + expect(await collectPinnedDaemonRuntimeDirectories([row], [staging], resolve)).toBeNull() +}) diff --git a/src/main/daemon/daemon-bun-runtime-retention.ts b/src/main/daemon/daemon-bun-runtime-retention.ts new file mode 100644 index 000000000000..b039e94c4399 --- /dev/null +++ b/src/main/daemon/daemon-bun-runtime-retention.ts @@ -0,0 +1,177 @@ +import { lstat, readdir, realpath, rm } from 'node:fs/promises' +import { join, win32 } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission, + ProfileStateAccessError, + type ProfileStateRuntimeAdmission +} from '../persistence/profile-state/profile-state-access' +import { + isWindowsProcessStartTimeAvailable, + isWindowsProcessTableAvailable, + readWindowsProcessTableFresh, + type WindowsProcessRow +} from '../windows/windows-process-table' + +export const MANAGED_DAEMON_RUNTIME_DIRECTORY = 'managed-v1' +const GENERATION = /^bun-[a-f0-9]{64}(?:\.repair-[1-9][0-9]*)?$/u +const STAGING = /^\.bun-staging-[a-f0-9-]{36}$/u +const RETAIN_UNUSED = 2 +const DELETE_LIMIT = 4 + +/** The profile admission protocol also provides host-wide shared/exclusive process ownership. */ +export async function acquireDaemonRuntimeLaunchPin( + root: string +): Promise { + for (let attempt = 0; ; attempt++) { + try { + return acquireProfileStateRuntimeAdmission(root) + } catch (error) { + if (!(error instanceof ProfileStateAccessError) || attempt >= 30) { + throw error + } + await delay(100) + } + } +} + +function normalized(path: string): string { + return path.replaceAll('/', '\\').toLowerCase() +} + +/** Unknown image paths veto removal; a basename alone cannot locate a loaded runtime. */ +export async function collectPinnedDaemonRuntimeDirectories( + rows: WindowsProcessRow[], + directories: string[], + resolveExecutablePath: (path: string) => Promise = realpath +): Promise | null> { + const pinned = new Set() + const canonicalImages = new Map() + for (const row of rows) { + if (!/^(?:bun(?:-runtime)?|openconsole)\.exe$/iu.test(row.name)) { + continue + } + if (!row.creationTimeMs || !Number.isSafeInteger(row.creationTimeMs) || !row.command) { + return null + } + const executable = row.command.startsWith('"') + ? /^"([^"\r\n]+)"/u.exec(row.command)?.[1] + : /^([^\s"]+)/u.exec(row.command)?.[1] + if ( + !executable || + !win32.isAbsolute(executable) || + !/^(?:[a-z]:\\|\\\\)/iu.test(normalized(executable)) + ) { + return null + } + let canonicalExecutable: string + try { + canonicalExecutable = normalized(await resolveExecutablePath(executable)) + } catch { + return null + } + const command = normalized(row.command) + for (const directory of directories) { + const prefix = `${normalized(directory)}\\` + if (canonicalExecutable.startsWith(prefix) || command.includes(prefix)) { + pinned.add(directory) + continue + } + const image = + row.name.toLowerCase() === 'openconsole.exe' + ? join(directory, 'conpty', 'OpenConsole.exe') + : join(directory, 'bun-runtime.exe') + let canonicalImage = canonicalImages.get(image) + if (canonicalImage === undefined) { + try { + canonicalImage = normalized(await resolveExecutablePath(image)) + canonicalImages.set(image, canonicalImage) + } catch (error) { + // Unpublished staging may lack images; unknown or published identities still veto deletion. + if ( + !STAGING.test(win32.basename(directory)) || + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) { + return null + } + canonicalImage = null + canonicalImages.set(image, null) + } + } + if (canonicalImage === canonicalExecutable) { + pinned.add(directory) + } + } + } + return pinned +} + +/** Only this namespace participates in launch pins; older runtimes are never candidates. */ +export async function pruneDaemonBunRuntimes(hostRoot: string): Promise { + if (!isWindowsProcessTableAvailable() || !isWindowsProcessStartTimeAvailable()) { + return + } + const requestedRoot = join(hostRoot, MANAGED_DAEMON_RUNTIME_DIRECTORY) + let admission: ProfileStateRuntimeAdmission | undefined + try { + if ((await lstat(requestedRoot)).isSymbolicLink()) { + return + } + const root = await realpath(requestedRoot) + admission = acquireProfileStateMaintenance(root) + const candidates: { path: string; modified: number; staging: boolean }[] = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + if (!entry.isDirectory() || (!GENERATION.test(entry.name) && !STAGING.test(entry.name))) { + continue + } + const path = join(root, entry.name) + const stat = await lstat(path) + if (!stat.isDirectory() || stat.isSymbolicLink() || (await realpath(path)) !== path) { + continue + } + candidates.push({ path, modified: stat.mtimeMs, staging: STAGING.test(entry.name) }) + } + const pinned = await collectPinnedDaemonRuntimeDirectories( + await readWindowsProcessTableFresh(), + candidates.map(({ path }) => path) + ) + if (!pinned) { + return + } + const unused = candidates + .filter(({ path, staging }) => !staging && !pinned.has(path)) + .sort((a, b) => b.modified - a.modified) + .slice(RETAIN_UNUSED) + const abandonedStaging = candidates.filter(({ path, staging }) => staging && !pinned.has(path)) + for (const candidate of [...abandonedStaging, ...unused].slice(0, DELETE_LIMIT)) { + admission.assertActive() + if ((await lstat(candidate.path)).isSymbolicLink()) { + continue + } + await rm(candidate.path, { recursive: true, force: true }) + } + } catch { + // Retention is optional; missing identity, busy admissions and disk errors preserve files. + } finally { + try { + admission?.release() + } catch (error) { + console.warn('[daemon] Could not release runtime retention ownership', error) + } + } +} + +const pendingPrunes = new Set() +export function scheduleDaemonBunRuntimePrune(hostRoot: string): void { + if (pendingPrunes.has(hostRoot)) { + return + } + pendingPrunes.add(hostRoot) + const timer = setTimeout(() => { + void pruneDaemonBunRuntimes(hostRoot).finally(() => pendingPrunes.delete(hostRoot)) + }, 0) + timer.unref() +} diff --git a/src/main/daemon/daemon-bun-runtime.test.ts b/src/main/daemon/daemon-bun-runtime.test.ts new file mode 100644 index 000000000000..ebd1b7e51609 --- /dev/null +++ b/src/main/daemon/daemon-bun-runtime.test.ts @@ -0,0 +1,291 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, writeFile, readFile, rm, rename, realpath } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const { renameMock, environment } = vi.hoisted(() => ({ + renameMock: vi.fn(), + environment: { + appPath: '', + localRoot: '', + getAppPath() { + return this.appPath + }, + getVersion: () => 'test-version', + getPath: () => '/unused-user-data' + } +})) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + renameMock.mockImplementation(actual.rename) + return { ...actual, rename: renameMock } +}) +vi.mock('../../shared/windows-conpty-release', async () => { + const { createHash } = await import('node:crypto') + const hashes = { + 'conpty.dll': createHash('sha256').update('conpty fixture').digest('hex'), + 'OpenConsole.exe': createHash('sha256').update('console fixture').digest('hex') + } + return { WINDOWS_CONPTY_FILES: { x64: hashes, arm64: hashes } } +}) +vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => environment })) +import { resolveDaemonBunRuntime, resolveDesktopDaemonBunRuntime } from './daemon-bun-runtime' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +const roots: string[] = [] +afterEach(async () => { + vi.unstubAllGlobals() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) +async function fixture(platform: 'darwin' | 'win32' = 'darwin') { + const root = await mkdtemp(join(tmpdir(), 'daemon-bun-test-')) + roots.push(root) + const bundleDir = join(root, 'bundle') + const runtimeDir = join(root, 'runtime') + const windowsProcessTreeDir = join(root, 'native') + for (const dir of [bundleDir, runtimeDir, windowsProcessTreeDir]) { + await mkdir(dir) + } + if (platform === 'win32') { + await mkdir(join(runtimeDir, 'conpty')) + await writeFile(join(runtimeDir, 'conpty', 'conpty.dll'), 'conpty fixture') + await writeFile(join(runtimeDir, 'conpty', 'OpenConsole.exe'), 'console fixture') + await writeFile(join(runtimeDir, 'conpty', 'LICENSE.txt'), 'license fixture') + } + await writeFile(join(bundleDir, 'daemon-entry.js'), 'daemon') + await writeFile(join(bundleDir, 'windows-bun-pty-gate-entry.js'), 'gate') + await writeFile(join(windowsProcessTreeDir, 'package.json'), '{}') + await mkdir(join(windowsProcessTreeDir, 'lib')) + await mkdir(join(windowsProcessTreeDir, 'build', 'Release'), { recursive: true }) + await writeFile(join(windowsProcessTreeDir, 'lib', 'index.js'), 'native loader') + await writeFile( + join(windowsProcessTreeDir, 'build', 'Release', 'windows_process_tree.node'), + 'native' + ) + await writeFile(join(runtimeDir, platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime'), 'bun') + await writeFile( + join(runtimeDir, 'runtime.json'), + JSON.stringify({ + target: `${platform}-arm64`, + version: ORCAD_BUN_VERSION, + sha256: createHash('sha256').update('bun').digest('hex') + }) + ) + return { root, bundleDir, runtimeDir, platform, arch: 'arm64', windowsProcessTreeDir } +} +describe('desktop Bun daemon runtime', () => { + it('uses shipped runtime directly outside Windows relocation', async () => { + const options = await fixture() + expect(await resolveDaemonBunRuntime(options)).toEqual({ + execPath: join(options.runtimeDir, 'bun-runtime'), + entryPath: join(options.bundleDir, 'daemon-entry.js') + }) + }) + it('selects the verified source provider for unpackaged Windows launches', async () => { + const options = await fixture('win32') + const runtime = await resolveDaemonBunRuntime(options) + expect(runtime.conptyLibraryPath).toBe(join(options.runtimeDir, 'conpty', 'conpty.dll')) + }) + + it('rejects corrupt runtime bytes before launching', async () => { + const options = await fixture() + await writeFile(join(options.runtimeDir, 'bun-runtime'), 'bad') + await expect(resolveDaemonBunRuntime(options)).rejects.toThrow('identity mismatch') + }) + it('copies Windows runtime and native closure outside install and repairs beside existing bytes', async () => { + const fixtureOptions = await fixture('win32') + const options = { + ...fixtureOptions, + relocationRoot: join(fixtureOptions.root, 'local', 'version') + } + const first = await resolveDaemonBunRuntime(options) + expect(first.execPath.startsWith(options.relocationRoot)).toBe(true) + expect(first.conptyLibraryPath).toBe(join(first.entryPath, '..', 'conpty', 'conpty.dll')) + expect(await resolveDaemonBunRuntime(options)).toEqual(first) + expect(await readFile(join(first.entryPath, '..', 'conpty', 'conpty.dll'), 'utf8')).toBe( + 'conpty fixture' + ) + expect(await readFile(join(first.entryPath, '..', 'conpty', 'OpenConsole.exe'), 'utf8')).toBe( + 'console fixture' + ) + expect(await readFile(join(first.entryPath, '..', 'conpty', 'LICENSE.txt'), 'utf8')).toBe( + 'license fixture' + ) + expect( + await readFile( + join( + first.entryPath, + '..', + 'node_modules', + '@vscode', + 'windows-process-tree', + 'package.json' + ), + 'utf8' + ) + ).toBe('{}') + await writeFile(first.execPath, 'old-live-bytes') + const second = await resolveDaemonBunRuntime(options) + expect(second.execPath).not.toBe(first.execPath) + expect(await readFile(first.execPath, 'utf8')).toBe('old-live-bytes') + expect(await readFile(second.execPath, 'utf8')).toBe('bun') + }) + it('retains process-table runtime siblings without copying native build intermediates', async () => { + const options = await fixture('win32') + await writeFile(join(options.windowsProcessTreeDir, 'lib', 'binding.js'), 'runtime sibling') + await writeFile( + join(options.windowsProcessTreeDir, 'build', 'Release', 'unused.pdb'), + 'symbols' + ) + await mkdir(join(options.windowsProcessTreeDir, 'src')) + await writeFile(join(options.windowsProcessTreeDir, 'src', 'unused.cc'), 'source') + const relocated = await resolveDaemonBunRuntime({ + ...options, + relocationRoot: join(options.root, 'cache') + }) + const nativeRoot = join( + relocated.entryPath, + '..', + 'node_modules', + '@vscode', + 'windows-process-tree' + ) + expect(await readFile(join(nativeRoot, 'lib', 'binding.js'), 'utf8')).toBe('runtime sibling') + await expect( + readFile(join(nativeRoot, 'build', 'Release', 'unused.pdb')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(join(nativeRoot, 'src', 'unused.cc'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it.each(['conpty.dll', 'OpenConsole.exe'])( + 'rejects corrupt Windows provider %s before relocation', + async (filename) => { + const options = await fixture('win32') + await writeFile(join(options.runtimeDir, 'conpty', filename), 'foreign provider') + await expect( + resolveDaemonBunRuntime({ ...options, relocationRoot: join(options.root, 'cache') }) + ).rejects.toThrow('ConPTY identity mismatch') + } + ) + + it('rejects a Windows payload missing its native process table before relocation', async () => { + const options = await fixture('win32') + await rm(join(options.windowsProcessTreeDir, 'build', 'Release', 'windows_process_tree.node')) + await expect( + resolveDaemonBunRuntime({ ...options, relocationRoot: join(options.root, 'cache') }) + ).rejects.toThrow('Windows terminal process inspection artifact is missing') + }) + + it('repairs a corrupt gate without replacing a runtime directory used by another owner', async () => { + const options = await fixture('win32') + const relocated = { ...options, relocationRoot: join(options.root, 'cache') } + const first = await resolveDaemonBunRuntime(relocated) + const firstGate = join(first.entryPath, '..', 'windows-bun-pty-gate-entry.js') + await writeFile(firstGate, 'foreign gate') + const second = await resolveDaemonBunRuntime(relocated) + expect(second.entryPath).not.toBe(first.entryPath) + expect(await readFile(firstGate, 'utf8')).toBe('foreign gate') + expect( + await readFile(join(second.entryPath, '..', 'windows-bun-pty-gate-entry.js'), 'utf8') + ).toBe('gate') + }) + it('repairs a changed ConPTY beside the previous owner and keeps the full provider after install removal', async () => { + const options = await fixture('win32') + const relocated = { ...options, relocationRoot: join(options.root, 'cache') } + const first = await resolveDaemonBunRuntime(relocated) + const oldLibrary = join(first.entryPath, '..', 'conpty', 'conpty.dll') + await writeFile(oldLibrary, 'old-owner bytes') + const second = await resolveDaemonBunRuntime(relocated) + expect(second.entryPath).not.toBe(first.entryPath) + expect(await readFile(oldLibrary, 'utf8')).toBe('old-owner bytes') + await rm(options.runtimeDir, { recursive: true }) + expect(await readFile(join(second.entryPath, '..', 'conpty', 'conpty.dll'), 'utf8')).toBe( + 'conpty fixture' + ) + expect(await readFile(join(second.entryPath, '..', 'conpty', 'OpenConsole.exe'), 'utf8')).toBe( + 'console fixture' + ) + }) + + it('retries a transient Windows publication lock without bypassing relocation', async () => { + const options = await fixture('win32') + vi.stubGlobal('process', { ...process, platform: 'win32' }) + renameMock.mockRejectedValueOnce(Object.assign(new Error('scanner lock'), { code: 'EPERM' })) + const runtime = await resolveDaemonBunRuntime({ + ...options, + relocationRoot: join(options.root, 'cache') + }) + expect(runtime.execPath.startsWith(join(options.root, 'cache'))).toBe(true) + expect(await readFile(runtime.execPath, 'utf8')).toBe('bun') + }) + + it('does not fall back to the install directory after permanent publication failure', async () => { + const options = await fixture('win32') + renameMock.mockRejectedValueOnce(Object.assign(new Error('disk full'), { code: 'ENOSPC' })) + await expect( + resolveDaemonBunRuntime({ + ...options, + relocationRoot: join(options.root, 'cache') + }) + ).rejects.toThrow('disk full') + }) + + it('surfaces a persistent Windows publication lock after bounded retries', async () => { + const options = await fixture('win32') + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const error = Object.assign(new Error('persistent scanner lock'), { code: 'EBUSY' }) + for (let attempt = 0; attempt < 6; attempt++) { + renameMock.mockRejectedValueOnce(error) + } + const callsBefore = renameMock.mock.calls.length + await expect( + resolveDaemonBunRuntime({ + ...options, + relocationRoot: join(options.root, 'cache') + }) + ).rejects.toThrow('persistent scanner lock') + expect(renameMock.mock.calls.length - callsBefore).toBe(6) + }) + + it('requires a managed local runtime root for packaged Windows', async () => { + environment.appPath = join('/resources', 'app.asar') + vi.stubGlobal('process', { + ...process, + platform: 'win32', + versions: { ...process.versions, electron: '43.7.0' }, + env: { ...process.env, LOCALAPPDATA: '' } + }) + await expect(resolveDesktopDaemonBunRuntime()).rejects.toThrow('LOCALAPPDATA is required') + }) + + it('keeps packaged Windows copies outside the legacy profile-scoped prune root', async () => { + const options = await fixture('win32') + const resources = join(options.root, 'resources') + await mkdir(join(resources, 'node_modules', '@vscode'), { recursive: true }) + await rename(options.bundleDir, join(resources, 'terminal-daemon')) + await rename(options.runtimeDir, join(resources, 'cli-runtime')) + await rename( + options.windowsProcessTreeDir, + join(resources, 'node_modules', '@vscode', 'windows-process-tree') + ) + environment.appPath = join(resources, 'app.asar') + const localAppData = join(options.root, "O'Connor Local Data") + vi.stubGlobal('process', { + ...process, + platform: 'win32', + arch: 'arm64', + versions: { ...process.versions, electron: '43.7.0' }, + env: { ...process.env, LOCALAPPDATA: localAppData } + }) + const runtime = await resolveDesktopDaemonBunRuntime() + expect( + runtime?.execPath.startsWith( + join(await realpath(localAppData), 'Orca', 'terminal-daemon-host', 'managed-v1') + ) + ).toBe(true) + expect(runtime?.execPath.startsWith(join(localAppData, 'Orca', 'daemon-host'))).toBe(false) + runtime?.releaseLaunchPin?.() + }) +}) diff --git a/src/main/daemon/daemon-bun-runtime.ts b/src/main/daemon/daemon-bun-runtime.ts new file mode 100644 index 000000000000..4619a9e70514 --- /dev/null +++ b/src/main/daemon/daemon-bun-runtime.ts @@ -0,0 +1,247 @@ +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { copyFile, mkdir, readFile, readdir, realpath, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { getAppEnvironment } from '../../shared/app-environment' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { WINDOWS_CONPTY_FILES } from '../../shared/windows-conpty-release' +import { + isRuntimeProcessTreePath, + WINDOWS_PROCESS_TREE_REQUIRED +} from '../windows/windows-process-tree-runtime-manifest' +import { renameFileWithWindowsRetryAsync } from '../codex-accounts/fs-utils' +import { findOrcadCachePath } from '../ssh/orcad-cache-path' +import { + acquireDaemonRuntimeLaunchPin, + MANAGED_DAEMON_RUNTIME_DIRECTORY, + scheduleDaemonBunRuntimePrune +} from './daemon-bun-runtime-retention' + +export type DaemonBunRuntime = { + execPath: string + entryPath: string + conptyLibraryPath?: string + releaseLaunchPin?: () => void +} +type RuntimeFile = { source: string; relative: string; sha256: string } +const Manifest = z.object({ + target: z.string(), + version: z.literal(ORCAD_BUN_VERSION), + sha256: z.string().regex(/^[a-f0-9]{64}$/u) +}) + +async function digest(path: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) { + hash.update(chunk) + } + return hash.digest('hex') +} + +async function bundleFiles( + root: string, + relative = '', + include: (relative: string) => boolean = () => true +): Promise { + const files: RuntimeFile[] = [] + for (const entry of await readdir(join(root, relative), { withFileTypes: true })) { + const name = join(relative, entry.name) + if (!include(name)) { + continue + } + if (entry.isDirectory()) { + files.push(...(await bundleFiles(root, name, include))) + } else if (entry.isFile()) { + files.push({ + source: join(root, name), + relative: name, + sha256: await digest(join(root, name)) + }) + } else { + throw new Error(`Unsupported terminal daemon artifact: ${name}`) + } + } + return files.sort((a, b) => a.relative.localeCompare(b.relative)) +} + +export async function resolveDaemonBunRuntime(options: { + bundleDir: string + runtimeDir: string + platform: NodeJS.Platform + arch: string + relocationRoot?: string + windowsProcessTreeDir?: string +}): Promise { + const filename = options.platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime' + const runtime = join(options.runtimeDir, filename) + const manifest = Manifest.parse( + JSON.parse(await readFile(join(options.runtimeDir, 'runtime.json'), 'utf8')) + ) + const target = `${options.platform}-${options.arch}${options.platform === 'linux' ? '-glibc' : ''}` + if (manifest.target !== target || (await digest(runtime)) !== manifest.sha256) { + throw new Error('Terminal daemon Bun runtime identity mismatch') + } + const files = await bundleFiles(options.bundleDir) + if (!files.some((file) => file.relative === 'daemon-entry.js')) { + throw new Error('Terminal daemon entry is missing') + } + if (options.platform === 'win32') { + if (!options.windowsProcessTreeDir) { + throw new Error('Windows terminal process inspection artifacts are missing') + } + if (!files.some((file) => file.relative === 'windows-bun-pty-gate-entry.js')) { + throw new Error('Windows terminal gate is missing') + } + if (options.arch !== 'x64' && options.arch !== 'arm64') { + throw new Error(`Unsupported Windows terminal architecture: ${options.arch}`) + } + const conptyFiles = await bundleFiles(join(options.runtimeDir, 'conpty')) + for (const [filename, hash] of Object.entries(WINDOWS_CONPTY_FILES[options.arch])) { + if (!conptyFiles.some((file) => file.relative === filename && file.sha256 === hash)) { + throw new Error(`Windows terminal ConPTY identity mismatch: ${filename}`) + } + } + for (const file of conptyFiles) { + files.push({ ...file, relative: join('conpty', file.relative) }) + } + const nativeFiles = await bundleFiles(options.windowsProcessTreeDir, '', (relative) => + isRuntimeProcessTreePath(relative.replaceAll('\\', '/')) + ) + for (const required of WINDOWS_PROCESS_TREE_REQUIRED) { + if (!nativeFiles.some((file) => file.relative === join(...required.split('/')))) { + throw new Error(`Windows terminal process inspection artifact is missing: ${required}`) + } + } + for (const file of nativeFiles) { + files.push({ + ...file, + relative: join('node_modules', '@vscode', 'windows-process-tree', file.relative) + }) + } + } + if (!options.relocationRoot) { + return { + execPath: runtime, + entryPath: join(options.bundleDir, 'daemon-entry.js'), + ...(options.platform === 'win32' + ? { conptyLibraryPath: join(options.runtimeDir, 'conpty', 'conpty.dll') } + : {}) + } + } + files.push({ source: runtime, relative: filename, sha256: manifest.sha256 }) + const identity = createHash('sha256') + .update(JSON.stringify(files.map(({ relative, sha256 }) => [relative, sha256]))) + .digest('hex') + const verify = async (root: string): Promise => { + try { + for (const file of files) { + if ((await digest(join(root, file.relative))) !== file.sha256) { + return false + } + } + return true + } catch { + return false + } + } + const cached = await findOrcadCachePath( + (attempt) => + join(options.relocationRoot!, `bun-${identity}${attempt ? `.repair-${attempt}` : ''}`), + verify + ) + if (!cached.verified) { + const staging = join(options.relocationRoot, `.bun-staging-${randomUUID()}`) + try { + for (const file of files) { + const destination = join(staging, file.relative) + await mkdir(dirname(destination), { recursive: true }) + await copyFile(file.source, destination) + } + if (!(await verify(staging))) { + throw new Error('Terminal daemon artifacts changed while copying') + } + try { + await renameFileWithWindowsRetryAsync(staging, cached.path) + } catch (error) { + if (!(await verify(cached.path))) { + throw error + } + } + } finally { + await rm(staging, { recursive: true, force: true }) + } + } + return { + execPath: join(cached.path, filename), + entryPath: join(cached.path, 'daemon-entry.js'), + ...(options.platform === 'win32' + ? { conptyLibraryPath: join(cached.path, 'conpty', 'conpty.dll') } + : {}) + } +} + +export function desktopDaemonBundleDir(): string { + const appPath = getAppEnvironment().getAppPath() + return appPath.includes('app.asar') + ? join(dirname(appPath), 'terminal-daemon') + : join(appPath, 'out', 'terminal-daemon') +} + +export async function resolveDesktopDaemonBunRuntime(): Promise { + if (!process.versions.electron) { + return null + } + const environment = getAppEnvironment() + const appPath = environment.getAppPath() + const packaged = appPath.includes('app.asar') + if (process.platform === 'win32' && packaged && !process.env.LOCALAPPDATA) { + throw new Error('LOCALAPPDATA is required to preserve terminal sessions across app updates') + } + const hostRoot = + process.platform === 'win32' && packaged && process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, 'Orca', 'terminal-daemon-host') + : undefined + const managedRoot = hostRoot ? join(hostRoot, MANAGED_DAEMON_RUNTIME_DIRECTORY) : undefined + const pin = managedRoot ? await acquireDaemonRuntimeLaunchPin(managedRoot) : undefined + let released = false + const releaseLaunchPin = (): void => { + if (released) { + return + } + released = true + try { + pin?.release() + } catch (error) { + console.warn('[daemon] Could not release runtime launch pin', error) + } + if (hostRoot) { + scheduleDaemonBunRuntimePrune(hostRoot) + } + } + try { + const runtime = await resolveDaemonBunRuntime({ + bundleDir: desktopDaemonBundleDir(), + runtimeDir: packaged + ? join(dirname(appPath), 'cli-runtime') + : join(appPath, 'out', 'cli-runtime', `${process.platform}-${process.arch}`), + platform: process.platform, + arch: process.arch, + ...(process.platform === 'win32' + ? { + windowsProcessTreeDir: join( + packaged ? dirname(appPath) : appPath, + 'node_modules', + '@vscode', + 'windows-process-tree' + ) + } + : {}), + ...(managedRoot ? { relocationRoot: await realpath(managedRoot) } : {}) + }) + return pin ? { ...runtime, releaseLaunchPin } : runtime + } catch (error) { + releaseLaunchPin() + throw error + } +} diff --git a/src/main/daemon/daemon-client-connection-attempt.ts b/src/main/daemon/daemon-client-connection-attempt.ts new file mode 100644 index 000000000000..80a43a091df4 --- /dev/null +++ b/src/main/daemon/daemon-client-connection-attempt.ts @@ -0,0 +1,67 @@ +import type { Duplex } from 'node:stream' +import { + awaitDaemonTransport, + destroyDaemonTransport, + type DaemonClientTransport +} from './daemon-client-transport' +import { DaemonProtocolError } from './types' + +export class DaemonConnectionAttempt { + readonly controller = new AbortController() + private deadline: number + readonly readerCleanups: (() => void)[] = [] + releaseReaders(): void { + for (const cleanup of this.readerCleanups.splice(0)) { + cleanup() + } + } + private guards: (() => void)[] = [] + constructor( + private transport: DaemonClientTransport, + private timeoutMs: number, + private sharedBudget: boolean + ) { + this.deadline = Date.now() + timeoutMs + } + remainingMs(): number { + return this.sharedBudget ? Math.max(1, this.deadline - Date.now()) : this.timeoutMs + } + wait(run: (signal: AbortSignal) => T | Promise, late?: (value: T) => void): Promise { + return awaitDaemonTransport( + run, + { timeoutMs: this.remainingMs(), signal: this.controller.signal }, + late + ) + } + readToken(): Promise { + return this.wait((signal) => + this.transport.readToken({ timeoutMs: this.remainingMs(), signal }) + ) + } + async connect(role: 'control' | 'stream'): Promise { + const stream = await this.wait( + (signal) => this.transport.connect(role, { timeoutMs: this.remainingMs(), signal }), + destroyDaemonTransport + ) + const fail = (): void => { + queueMicrotask(() => + this.controller.abort(new DaemonProtocolError('Connection closed during setup')) + ) + } + stream.on('error', fail) + stream.on('close', fail) + this.guards.push(() => { + stream.off('error', fail) + stream.off('close', fail) + }) + if (stream.destroyed || !stream.readable || !stream.writable) { + fail() + } + return stream + } + releaseGuards(): void { + for (const cleanup of this.guards.splice(0)) { + cleanup() + } + } +} diff --git a/src/main/daemon/daemon-client-duplex.test.ts b/src/main/daemon/daemon-client-duplex.test.ts new file mode 100644 index 000000000000..98e0f2d00cc9 --- /dev/null +++ b/src/main/daemon/daemon-client-duplex.test.ts @@ -0,0 +1,244 @@ +import { Duplex, PassThrough, Readable, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DaemonClient, type DaemonClientTransport } from './client' +import { encodeNdjson, createNdjsonParser } from './ndjson' + +const identity = { pid: 42, startedAtMs: 123, launchNonce: 'owner' } +const clients: DaemonClient[] = [] +const streams: Duplex[] = [] +afterEach(() => { + for (const client of clients.splice(0)) { + client.disconnect() + } + for (const stream of streams.splice(0)) { + stream.destroy() + } +}) +function pair() { + const toServer = new PassThrough() + const toClient = new PassThrough() + const client = Duplex.from({ + readable: Readable.toWeb(toClient), + writable: Writable.toWeb(toServer) + }) + const server = Duplex.from({ + readable: Readable.toWeb(toServer), + writable: Writable.toWeb(toClient) + }) + streams.push(client, server) + for (const stream of [client, server]) { + stream.on('error', () => {}) + } + return { client, server } +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} +function harness(otherIdentity = identity) { + const endpoints = new Map>() + const hellos: unknown[] = [] + const transport: DaemonClientTransport = { + readToken: async () => 'token', + connect: async (role) => { + const endpoint = pair() + endpoints.set(role, endpoint) + const parser = createNdjsonParser( + (message) => { + if (!message || typeof message !== 'object' || !('type' in message)) { + return + } + if (message.type === 'hello') { + hellos.push(message) + endpoint.server.write( + encodeNdjson({ + ok: true, + daemonIdentity: role === 'control' ? identity : otherIdentity + }) + ) + } else if ('id' in message && typeof message.id === 'string') { + endpoint.server.write( + encodeNdjson({ + id: message.id, + ok: true, + payload: { echoed: 'payload' in message ? message.payload : undefined } + }) + ) + } + }, + () => {} + ) + endpoint.server.on('data', (data: Buffer) => parser.feed(data.toString())) + return endpoint.client + } + } + const client = new DaemonClient({ transport }) + clients.push(client) + return { client, transport, endpoints, hellos } +} + +describe('daemon ordered duplex transport', () => { + it('authenticates both roles, handles RPC/events and settles notify writes', async () => { + const { client, endpoints, hellos } = harness() + const event = vi.fn() + client.onEvent(event) + await client.ensureConnected() + expect(hellos).toEqual([ + expect.objectContaining({ role: 'control', token: 'token', clientId: expect.any(String) }), + expect.objectContaining({ role: 'stream', token: 'token', clientId: expect.any(String) }) + ]) + expect(await client.request('echo', 'hello')).toEqual({ echoed: 'hello' }) + const payload = { type: 'event', event: 'output', data: '界' } + endpoints.get('stream')!.server.write(encodeNdjson(payload)) + await vi.waitFor(() => expect(event).toHaveBeenCalledWith(payload)) + expect(await client.notifyWithSettlement('input', 'x')).toEqual({ outcome: 'accepted' }) + expect(client.getDaemonIdentity()).toEqual(identity) + }) + + it('preserves an event coalesced with the stream hello reply', async () => { + const event = vi.fn() + const payload = { type: 'event', event: 'output', data: '界' } + const transport: DaemonClientTransport = { + readToken: () => 'token', + connect: async (role) => { + const endpoint = pair() + endpoint.server.once('data', () => + endpoint.server.write( + encodeNdjson({ ok: true, daemonIdentity: identity }) + + (role === 'stream' ? encodeNdjson(payload) : '') + ) + ) + return endpoint.client + } + } + const client = new DaemonClient({ transport }) + clients.push(client) + client.onEvent(event) + await client.ensureConnected() + await vi.waitFor(() => expect(event).toHaveBeenCalledExactlyOnceWith(payload)) + }) + + it('refuses distinct owners on paired connections and destroys both', async () => { + const { client, endpoints } = harness({ ...identity, launchNonce: 'other' }) + await expect(client.ensureConnected()).rejects.toThrow('identity changed') + expect(client.isConnected()).toBe(false) + expect([...endpoints.values()].every(({ client: stream }) => stream.destroyed)).toBe(true) + }) + + it('cancels an asynchronous token read without ever connecting', async () => { + const token = deferred() + const connect = vi.fn() + let signal: AbortSignal | undefined + const client = new DaemonClient({ + transport: { + readToken: (operation) => { + signal = operation.signal + return token.promise + }, + connect + } + }) + clients.push(client) + const pending = client.ensureConnected() + const rejection = expect(pending).rejects.toThrow('Disconnected') + await vi.waitFor(() => expect(signal).toBeDefined()) + client.disconnect() + await rejection + expect(signal?.aborted).toBe(true) + token.resolve('late-token') + await Promise.resolve() + expect(connect).not.toHaveBeenCalled() + }) + + it('destroys a connection that arrives after cancellation', async () => { + const connected = deferred() + const connect = vi.fn(() => connected.promise) + const client = new DaemonClient({ transport: { readToken: () => 'token', connect } }) + clients.push(client) + const pending = client.ensureConnected() + const rejection = expect(pending).rejects.toThrow('Disconnected') + await vi.waitFor(() => expect(connect).toHaveBeenCalled()) + client.disconnect() + await rejection + const late = pair().client + connected.resolve(late) + await vi.waitFor(() => expect(late.destroyed).toBe(true)) + }) + + it('closes authenticated control when stream connection fails', async () => { + const { client, transport, endpoints } = harness() + const connect = transport.connect + transport.connect = (role, operation) => + role === 'stream' ? Promise.reject(new Error('bridge failed')) : connect(role, operation) + await expect(client.ensureConnected()).rejects.toThrow('bridge failed') + expect(endpoints.get('control')?.client.destroyed).toBe(true) + expect(client.isConnected()).toBe(false) + }) + + it('aborts a pending stream bridge if authenticated control closes', async () => { + const { client, transport, endpoints } = harness() + const connect = transport.connect + const pendingStream = deferred() + transport.connect = (role, operation) => + role === 'stream' ? pendingStream.promise : connect(role, operation) + const pending = client.ensureConnected() + const rejection = expect(pending).rejects.toThrow('closed during setup') + await vi.waitFor(() => expect(endpoints.get('control')).toBeDefined()) + endpoints.get('control')!.client.destroy() + await rejection + const late = pair().client + pendingStream.resolve(late) + await vi.waitFor(() => expect(late.destroyed).toBe(true)) + }) + + it('waits for the writable callback under bridge backpressure', async () => { + let releaseWrite: (() => void) | undefined + const { client, transport } = harness() + const connect = transport.connect + transport.connect = async (role, operation) => { + if (role !== 'control') { + return connect(role, operation) + } + const stream = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + const message: unknown = JSON.parse(chunk.toString()) + if ( + message && + typeof message === 'object' && + 'type' in message && + message.type === 'hello' + ) { + this.push(encodeNdjson({ ok: true, daemonIdentity: identity })) + callback() + } else { + releaseWrite = () => callback() + } + } + }) + streams.push(stream) + return stream + } + await client.ensureConnected() + let settled = false + const write = client.notifyWithSettlement('input', 'x').then((value) => { + settled = true + return value + }) + await vi.waitFor(() => expect(releaseWrite).toBeDefined()) + expect(settled).toBe(false) + releaseWrite?.() + await expect(write).resolves.toEqual({ outcome: 'accepted' }) + }) + + it('bounds asynchronous token work by the shared connection deadline', async () => { + const client = new DaemonClient({ + transport: { readToken: () => new Promise(() => {}), connect: vi.fn() } + }) + clients.push(client) + await expect(client.ensureConnectedWithin(15)).rejects.toThrow('timed out') + }) +}) diff --git a/src/main/daemon/daemon-client-hello-handshake.ts b/src/main/daemon/daemon-client-hello-handshake.ts index 681177c28c5b..c57db6d14cd4 100644 --- a/src/main/daemon/daemon-client-hello-handshake.ts +++ b/src/main/daemon/daemon-client-hello-handshake.ts @@ -1,15 +1,15 @@ -import type { Socket } from 'node:net' -import { StringDecoder } from 'node:string_decoder' +import type { Duplex } from 'node:stream' import { encodeNdjson } from './ndjson' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, DaemonProtocolError } from './types' import type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' export type DaemonHelloRequest = { - socket: Socket + socket: Duplex token: string role: 'control' | 'stream' timeoutMs: number + signal?: AbortSignal protocolVersion: number clientId: string } @@ -17,7 +17,7 @@ export type DaemonHelloRequest = { export function sendDaemonHello( request: DaemonHelloRequest ): Promise { - const { socket, token, role, timeoutMs, protocolVersion, clientId } = request + const { socket, token, role, timeoutMs, protocolVersion, clientId, signal } = request return new Promise((resolve, reject) => { const hello: HelloMessage = { type: 'hello', @@ -27,7 +27,7 @@ export function sendDaemonHello( role } - let buffer = '' + let buffer = Buffer.alloc(0) let settled = false let timer: ReturnType | null = null const cleanup = (): void => { @@ -38,6 +38,7 @@ export function sendDaemonHello( socket.removeListener('data', onData) socket.removeListener('error', onError) socket.removeListener('close', onClose) + signal?.removeEventListener('abort', onAbort) } const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => { if (settled) { @@ -51,17 +52,14 @@ export function sendDaemonHello( } resolve(identity) } - // Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes. - // Decoding each Buffer independently would permanently inject U+FFFD. - const decoder = new StringDecoder('utf8') const onData = (chunk: Buffer): void => { - buffer += decoder.write(chunk) - const newlineIdx = buffer.indexOf('\n') + buffer = Buffer.concat([buffer, chunk]) + const newlineIdx = buffer.indexOf(10) if (newlineIdx === -1) { return } - const line = buffer.slice(0, newlineIdx) + const line = buffer.subarray(0, newlineIdx).toString('utf8') try { const response = JSON.parse(line) as HelloResponse if (response.ok) { @@ -73,6 +71,12 @@ export function sendDaemonHello( finish(new DaemonProtocolError('Invalid daemon identity')) return } + // Preserve frames coalesced with hello until the role reader is installed. + socket.pause() + const remainder = buffer.subarray(newlineIdx + 1) + if (remainder.length) { + socket.unshift(remainder) + } finish(undefined, identity) } else { finish( @@ -87,6 +91,12 @@ export function sendDaemonHello( const onClose = (): void => finish(new DaemonProtocolError('Connection closed before hello response')) + const onAbort = (): void => finish(new DaemonProtocolError('Disconnected')) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + return + } timer = setTimeout(() => { // Why: a stale daemon can accept the socket but never answer hello; // without a handshake timeout, startup waits forever on ensureConnected(). diff --git a/src/main/daemon/daemon-client-ndjson-readers.ts b/src/main/daemon/daemon-client-ndjson-readers.ts index 5a04098e6e1d..7ae799cd2a9e 100644 --- a/src/main/daemon/daemon-client-ndjson-readers.ts +++ b/src/main/daemon/daemon-client-ndjson-readers.ts @@ -1,10 +1,10 @@ -import type { Socket } from 'node:net' +import type { Duplex } from 'node:stream' import { StringDecoder } from 'node:string_decoder' import { createNdjsonParser } from './ndjson' import type { DaemonEvent, RpcResponse } from './types' export function attachControlResponseReader( - socket: Socket, + socket: Duplex, onResponse: (response: RpcResponse) => void ): () => void { // Why: control responses may contain terminal/startup data with multibyte @@ -17,11 +17,12 @@ export function attachControlResponseReader( const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk)) socket.on('data', onData) + socket.resume() return () => socket.off('data', onData) } export function attachStreamEventReader( - socket: Socket, + socket: Duplex, onEvent: (event: DaemonEvent) => void ): () => void { // Why: PTY output streams include emoji/box-drawing tables; socket chunks @@ -39,5 +40,6 @@ export function attachStreamEventReader( const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk)) socket.on('data', onData) + socket.resume() return () => socket.off('data', onData) } diff --git a/src/main/daemon/daemon-client-notify-settlement.ts b/src/main/daemon/daemon-client-notify-settlement.ts index 4fe0d3693a11..68e8466e8aea 100644 --- a/src/main/daemon/daemon-client-notify-settlement.ts +++ b/src/main/daemon/daemon-client-notify-settlement.ts @@ -1,4 +1,4 @@ -import type { Socket } from 'node:net' +import type { Duplex } from 'node:stream' import { encodeNdjson } from './ndjson' import { WRITE_ACCEPTED, @@ -8,7 +8,7 @@ import { } from '../../shared/pty-write-settlement' export type NotifySettlementRequest = { - socket: Socket + socket: Duplex message: unknown timeoutMs: number // Why: a notify that never drains is the only local evidence a dead endpoint leaves. diff --git a/src/main/daemon/daemon-client-rpc-request.ts b/src/main/daemon/daemon-client-rpc-request.ts index 2bd4f6741de9..442f4dfaef69 100644 --- a/src/main/daemon/daemon-client-rpc-request.ts +++ b/src/main/daemon/daemon-client-rpc-request.ts @@ -1,4 +1,4 @@ -import type { Socket } from 'node:net' +import type { Duplex } from 'node:stream' import { encodeNdjson } from './ndjson' import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE, @@ -9,7 +9,7 @@ import { isTerminalAttachCanceledMessage } from './daemon-errors' import type { DaemonPendingRequests } from './daemon-client-pending-requests' type DaemonRpcRequestOptions = { - socket: Socket + socket: Duplex pendingRequests: DaemonPendingRequests id: string type: string diff --git a/src/main/daemon/daemon-client-socket-connect.ts b/src/main/daemon/daemon-client-socket-connect.ts index e6a2f615e17f..54feec2a8bf9 100644 --- a/src/main/daemon/daemon-client-socket-connect.ts +++ b/src/main/daemon/daemon-client-socket-connect.ts @@ -1,4 +1,5 @@ import { connect, type Socket } from 'node:net' +import type { Duplex } from 'node:stream' import { DaemonProtocolError } from './types' export function connectDaemonSocket(socketPath: string, timeoutMs: number): Promise { @@ -31,8 +32,8 @@ export function connectDaemonSocket(socketPath: string, timeoutMs: number): Prom // Why: capture both sockets by value — the fields they came from are nulled on teardown, // so a cleanup reading them back would unhook nothing. export function armDaemonSocketCloseHandlers( - controlSocket: Socket, - streamSocket: Socket, + controlSocket: Duplex, + streamSocket: Duplex, handleClose: () => void ): () => void { controlSocket.on('close', handleClose) diff --git a/src/main/daemon/daemon-client-transport.ts b/src/main/daemon/daemon-client-transport.ts new file mode 100644 index 000000000000..8bdb40fb7246 --- /dev/null +++ b/src/main/daemon/daemon-client-transport.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import type { Duplex } from 'node:stream' +import { connectDaemonSocket } from './daemon-client-socket-connect' +import { DaemonProtocolError } from './types' + +export type DaemonTransportOperation = { timeoutMs: number; signal: AbortSignal } +export type DaemonClientTransport = { + readToken: (operation: DaemonTransportOperation) => string | Promise + /** Return a connected ordered byte stream; destroy must release its bridge resources. */ + connect: (role: 'control' | 'stream', operation: DaemonTransportOperation) => Promise +} +export type DaemonClientOptions = { protocolVersion?: number } & ( + | { socketPath: string; tokenPath: string; transport?: never } + | { transport: DaemonClientTransport; socketPath?: never; tokenPath?: never } +) + +export function resolveDaemonTransport(options: DaemonClientOptions): DaemonClientTransport { + if (options.transport) { + return options.transport + } + return { + readToken: () => { + try { + return readFileSync(options.tokenPath, 'utf-8').trim() + } catch (error) { + // Missing auth must not preempt the connection that proves endpoint absence. + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return '' + } + throw error + } + }, + connect: (_role, { timeoutMs }) => connectDaemonSocket(options.socketPath, timeoutMs) + } +} + +/** Bound transports that ignore cancellation, disposing streams that arrive too late. */ +export function awaitDaemonTransport( + run: (signal: AbortSignal) => T | Promise, + operation: DaemonTransportOperation, + disposeLate?: (value: T) => void +): Promise { + const controller = new AbortController() + return new Promise((resolve, reject) => { + let settled = false + const settle = (complete: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + operation.signal.removeEventListener('abort', abort) + complete() + } + const fail = (error: unknown): void => + settle(() => { + controller.abort(error) + reject(error) + }) + const abort = (): void => + fail(operation.signal.reason ?? new DaemonProtocolError('Disconnected')) + const timer = setTimeout( + () => fail(new DaemonProtocolError('Connection timed out')), + operation.timeoutMs + ) + operation.signal.addEventListener('abort', abort, { once: true }) + if (operation.signal.aborted) { + abort() + return + } + Promise.resolve() + .then(() => { + if (controller.signal.aborted) { + throw controller.signal.reason + } + return run(controller.signal) + }) + .then( + (value) => (settled ? disposeLate?.(value) : settle(() => resolve(value))), + (error) => fail(error) + ) + }) +} + +export function destroyDaemonTransport(stream: Duplex): void { + // A bridge can report its exit asynchronously after destroy. + stream.once('error', () => {}) + stream.destroy() +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 8d409a9dc871..5958742124c9 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -1,11 +1,4 @@ -/** - * Daemon entry point — runs as a standalone Node.js process. - * - * Usage: node daemon-entry.js --socket /path/to/sock --token /path/to/token - * - * Signals readiness to parent via IPC: { type: 'ready' } - * Shuts down cleanly on SIGTERM. - */ +/** Standalone Bun daemon; reports readiness over IPC and drains sessions on SIGTERM. */ import { readFileSync } from 'node:fs' import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/daemon-folder-access-probe.test.ts b/src/main/daemon/daemon-folder-access-probe.test.ts index b7f18f7c2166..e52f435a6967 100644 --- a/src/main/daemon/daemon-folder-access-probe.test.ts +++ b/src/main/daemon/daemon-folder-access-probe.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) +const { runProcessMock, runtimeMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + runtimeMock: vi.fn() +})) +vi.mock('./daemon-bun-runtime', () => ({ resolveDesktopDaemonBunRuntime: runtimeMock })) vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) import { probeFolderAccessForFreshDaemon } from './daemon-folder-access-probe' @@ -11,6 +15,7 @@ type RunProcessSpec = { env: NodeJS.ProcessEnv timeoutMs: number maxOutputBytes: number + onChildTerminated?: () => void } function settled(stdout: string, overrides: Record = {}): void { @@ -33,6 +38,7 @@ const DOCUMENTS = '/Users/alice/Documents/repo' beforeEach(() => { runProcessMock.mockReset() + runtimeMock.mockResolvedValue(null) }) describe('probeFolderAccessForFreshDaemon', () => { @@ -72,7 +78,8 @@ describe('probeFolderAccessForFreshDaemon', () => { settled('{"outcome":"ok"}\n') await probeFolderAccessForFreshDaemon(DOCUMENTS) - expect(lastSpec().timeoutMs).toBe(3_000) + expect(lastSpec().timeoutMs).toBeGreaterThan(0) + expect(lastSpec().timeoutMs).toBeLessThanOrEqual(3_000) expect(lastSpec().maxOutputBytes).toBe(1024) }) @@ -120,3 +127,134 @@ describe('probeFolderAccessForFreshDaemon', () => { await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe('unknown') }) }) + +it('uses the same Bun executable as a fresh desktop daemon', async () => { + runtimeMock.mockResolvedValue({ + execPath: '/app/cli-runtime/bun-runtime', + entryPath: '/app/terminal-daemon/daemon-entry.js' + }) + settled('{"outcome":"ok"}\n') + await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe('ok') + expect(lastSpec().program).toBe('/app/cli-runtime/bun-runtime') + expect(lastSpec().env.ELECTRON_RUN_AS_NODE).toBeUndefined() +}) + +it('bounds runtime resolution and never launches a child after the deadline', async () => { + vi.useFakeTimers() + try { + let finish!: (value: null) => void + runtimeMock.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const result = probeFolderAccessForFreshDaemon(DOCUMENTS) + await vi.advanceTimersByTimeAsync(3_000) + await expect(result).resolves.toBe('unknown') + finish(null) + await vi.advanceTimersByTimeAsync(1) + expect(runProcessMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } +}) + +it('gives the child only the budget left after runtime resolution', async () => { + vi.useFakeTimers() + try { + runtimeMock.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(null), 2_000)) + ) + settled('{"outcome":"ok"}\n') + const result = probeFolderAccessForFreshDaemon(DOCUMENTS) + await vi.advanceTimersByTimeAsync(2_000) + await expect(result).resolves.toBe('ok') + expect(lastSpec().timeoutMs).toBe(1_000) + } finally { + vi.useRealTimers() + } +}) + +it('releases a launch pin that resolves after the caller deadline', async () => { + vi.useFakeTimers() + try { + const releaseLaunchPin = vi.fn() + let finish: + | ((value: { execPath: string; entryPath: string; releaseLaunchPin: () => void }) => void) + | undefined + runtimeMock.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const result = probeFolderAccessForFreshDaemon(DOCUMENTS) + await vi.advanceTimersByTimeAsync(3_000) + await expect(result).resolves.toBe('unknown') + finish?.({ execPath: 'bun', entryPath: 'daemon-entry.js', releaseLaunchPin }) + await vi.advanceTimersByTimeAsync(1) + expect(releaseLaunchPin).toHaveBeenCalledTimes(1) + expect(runProcessMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } +}) + +it.each([false, true])( + 'releases the launch pin when child termination is confirmed (failure=%s)', + async (failure) => { + const releaseLaunchPin = vi.fn() + runtimeMock.mockResolvedValue({ + execPath: 'bun', + entryPath: 'daemon-entry.js', + releaseLaunchPin + }) + runProcessMock.mockImplementation(async (spec: RunProcessSpec) => { + expect(releaseLaunchPin).not.toHaveBeenCalled() + spec.onChildTerminated?.() + if (failure) { + throw new Error('spawn failed') + } + return { code: 0, stdout: '{"outcome":"ok"}', timedOut: false } + }) + await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe( + failure ? 'unknown' : 'ok' + ) + expect(releaseLaunchPin).toHaveBeenCalledTimes(1) + } +) + +it.each(['resolve', 'reject'])( + 'retains the runtime after abort and process promise %s until confirmed termination', + async (outcome) => { + vi.useFakeTimers() + try { + const releaseLaunchPin = vi.fn() + runtimeMock.mockResolvedValue({ + execPath: 'bun', + entryPath: 'daemon-entry.js', + releaseLaunchPin + }) + let settle: (() => void) | undefined + runProcessMock.mockImplementation( + () => + new Promise((resolve, reject) => { + settle = () => + outcome === 'resolve' + ? resolve({ code: null, stdout: '', timedOut: true }) + : reject(new Error('could not kill child')) + }) + ) + const result = probeFolderAccessForFreshDaemon(DOCUMENTS) + await vi.advanceTimersByTimeAsync(3_000) + await expect(result).resolves.toBe('unknown') + expect(releaseLaunchPin).not.toHaveBeenCalled() + settle?.() + await vi.advanceTimersByTimeAsync(1) + expect(releaseLaunchPin).not.toHaveBeenCalled() + lastSpec().onChildTerminated?.() + expect(releaseLaunchPin).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + } +) diff --git a/src/main/daemon/daemon-folder-access-probe.ts b/src/main/daemon/daemon-folder-access-probe.ts index 716158a38595..a799dbb44c1c 100644 --- a/src/main/daemon/daemon-folder-access-probe.ts +++ b/src/main/daemon/daemon-folder-access-probe.ts @@ -1,9 +1,11 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' // Answers the one question the running daemon cannot (STA-7948): would a daemon forked by THIS // app, right now, be able to list this folder? macOS attributes a TCC grant to the process that // forked the child, so only a fresh child of the current app binary can tell the user whether // restarting the terminal service is the remedy or whether they must re-allow Orca first. -import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { resolveDesktopDaemonBunRuntime, type DaemonBunRuntime } from './daemon-bun-runtime' import { isAbsolute } from 'node:path' import { runProcess } from '../../shared/child-process/run-process' import type { DirectoryEnumerationOutcome } from './directory-enumeration-probe' @@ -16,13 +18,11 @@ const PROBE_MAX_OUTPUT_BYTES = 1024 /** Everything the child needs; a scrubbed env keeps app-only state out of the probe's TCC context. */ const INHERITED_ENV_NAMES = ['PATH', 'HOME', 'TMPDIR'] as const -// Mirrors enumerateDirectoryOnce's errno mapping. Inlined rather than imported because the child -// runs as plain Node against argv only — it can load nothing from the app bundle. +// Match enumerateDirectoryOnce without importing application code into the probe runtime. const PROBE_SCRIPT = `const fs=require('node:fs');let d;let o;try{d=fs.opendirSync(process.argv[1]);d.readSync();o='ok'}catch(e){const c=e&&e.code;o=c==='EPERM'||c==='EACCES'?'denied':c==='ENOENT'||c==='ENOTDIR'?'missing':'other'}finally{try{if(d)d.closeSync()}catch(_){}}process.stdout.write(JSON.stringify({outcome:o})+'\\n')` -function probeEnvironment(): NodeJS.ProcessEnv { - // Why ELECTRON_RUN_AS_NODE: the app binary is Electron; the daemon is forked the same way. - const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1' } +function probeEnvironment(bunRuntime: boolean): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = bunRuntime ? {} : { ELECTRON_RUN_AS_NODE: '1' } for (const name of INHERITED_ENV_NAMES) { const value = process.env[name] if (value !== undefined) { @@ -70,19 +70,52 @@ export async function probeFolderAccessForFreshDaemon( if (!isAbsolute(path)) { return 'unknown' } + const deadline = Date.now() + PROBE_DEADLINE_MS + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), PROBE_DEADLINE_MS) + let runtime: DaemonBunRuntime | null = null + let finished = false + let childOwnsPin = false try { - const result = await runProcess({ - program: process.execPath, - args: [...(process.versions.bun ? bunOwnedRuntimeArgs() : []), '-e', PROBE_SCRIPT, path], - env: probeEnvironment(), - timeoutMs: PROBE_DEADLINE_MS, - maxOutputBytes: PROBE_MAX_OUTPUT_BYTES - }) + runtime = await waitForPromiseWithSignal( + resolveDesktopDaemonBunRuntime().then((resolved) => { + if (finished || controller.signal.aborted) { + resolved?.releaseLaunchPin?.() + } + return resolved + }), + controller.signal + ) + const remainingMs = deadline - Date.now() + if (controller.signal.aborted || remainingMs <= 0) { + return 'unknown' + } + const usesBun = runtime !== null || Boolean(process.versions.bun) + childOwnsPin = true + const result = await waitForPromiseWithSignal( + runProcess({ + program: runtime?.execPath ?? process.execPath, + args: [...(usesBun ? bunOwnedRuntimeArgs() : []), '-e', PROBE_SCRIPT, path], + env: probeEnvironment(usesBun), + timeoutMs: remainingMs, + signal: controller.signal, + maxOutputBytes: PROBE_MAX_OUTPUT_BYTES, + onChildTerminated: runtime?.releaseLaunchPin + }), + controller.signal + ) if (result.timedOut || result.code !== 0 || result.outputTruncated === true) { return 'unknown' } return parseProbeOutcome(result.stdout) } catch { return 'unknown' + } finally { + finished = true + // The caller deadline does not prove the child has stopped using its runtime. + if (!childOwnsPin) { + runtime?.releaseLaunchPin?.() + } + clearTimeout(timer) } } diff --git a/src/main/daemon/daemon-fresh-spawn-admission.test.ts b/src/main/daemon/daemon-fresh-spawn-admission.test.ts new file mode 100644 index 000000000000..2ca203185d00 --- /dev/null +++ b/src/main/daemon/daemon-fresh-spawn-admission.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DaemonFreshSpawnAdmission } from './daemon-fresh-spawn-admission' + +afterEach(() => vi.useRealTimers()) + +describe('daemon fresh spawn admission', () => { + it('throttles failed health probes and recovers without replacing the service', async () => { + vi.useFakeTimers() + const probe = vi.fn().mockRejectedValueOnce(new Error('unreachable')).mockResolvedValue(true) + const admission = new DaemonFreshSpawnAdmission(probe) + await expect(admission.recover()).resolves.toBe(false) + await expect(admission.recover()).resolves.toBe(false) + expect(probe).toHaveBeenCalledOnce() + expect(admission.unavailable).toBe(true) + await vi.advanceTimersByTimeAsync(30_000) + await expect(admission.recover()).resolves.toBe(true) + expect(admission.unavailable).toBe(false) + await expect(admission.recover()).resolves.toBe(true) + expect(probe).toHaveBeenCalledTimes(2) + }) + it('lets an explicit retry bypass the automatic cooldown', async () => { + const probe = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + const admission = new DaemonFreshSpawnAdmission(probe) + await expect(admission.recover()).resolves.toBe(false) + await expect(admission.recover(true)).resolves.toBe(true) + expect(probe).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/daemon/daemon-fresh-spawn-admission.ts b/src/main/daemon/daemon-fresh-spawn-admission.ts new file mode 100644 index 000000000000..af1ef3f79b9a --- /dev/null +++ b/src/main/daemon/daemon-fresh-spawn-admission.ts @@ -0,0 +1,37 @@ +export class DaemonFreshSpawnAdmission { + private recovery: Promise | null = null + private retryAfter = 0 + + constructor(private probe: (() => Promise) | null) {} + + get unavailable(): boolean { + return this.probe !== null + } + + recover(force = false): Promise { + if (!this.probe) { + return Promise.resolve(true) + } + if (this.recovery) { + return this.recovery + } + if (!force && Date.now() < this.retryAfter) { + return Promise.resolve(false) + } + this.recovery = Promise.resolve() + .then(this.probe) + .catch(() => false) + .then((healthy) => { + if (healthy) { + this.probe = null + } else { + this.retryAfter = Date.now() + 30_000 + } + return healthy + }) + .finally(() => { + this.recovery = null + }) + return this.recovery + } +} diff --git a/src/main/daemon/daemon-guest-spawn-options.ts b/src/main/daemon/daemon-guest-spawn-options.ts new file mode 100644 index 000000000000..5b8d514bebd3 --- /dev/null +++ b/src/main/daemon/daemon-guest-spawn-options.ts @@ -0,0 +1,24 @@ +import type { PtySpawnOptions } from '../providers/types' +import type { GuestDaemonExecution } from './daemon-pty-runtime-state' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' + +export function guestDaemonSpawnOptions( + guest: GuestDaemonExecution, + options: PtySpawnOptions +): PtySpawnOptions { + const cwd = normalizeWslColdRestoreCwd({ + recoveredCwd: options.cwd ?? guest.defaultCwd, + wslDistro: guest.distro, + guestExecution: true + }) + if (!cwd) { + throw new Error('Guest daemon spawn requires a path in its selected distro') + } + return { + ...options, + cwd, + shellOverride: options.shellOverride || guest.defaultShell, + terminalWindowsWslDistro: undefined, + terminalWindowsPowerShellImplementation: undefined + } +} diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts index 5d9d031cb9d3..4f2fd9828ec1 100644 --- a/src/main/daemon/daemon-hello-protocol.ts +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -22,3 +22,8 @@ export type HelloResponse = { error?: string daemonIdentity?: DaemonEndpointIdentity } + +export type PingRequest = { + id: string + type: 'ping' +} diff --git a/src/main/daemon/daemon-host-manifest.ts b/src/main/daemon/daemon-host-manifest.ts deleted file mode 100644 index 83d6d4984da7..000000000000 --- a/src/main/daemon/daemon-host-manifest.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { cpSync, existsSync, mkdirSync } from 'node:fs' -import { dirname, join, win32 as winPath } from 'node:path' - -// What the relocated host is made of: which files are mirrored, where each lands, -// and which of a package's files are runtime rather than bulk. The lifecycle -// around it -- when to materialize, what keeps a host valid, pruning -- is in -// daemon-host-relocation.ts. - -/** - * The host exe keeps the app exe's own file name, so the relocated image is a byte-for-byte, - * name-included copy of a signed binary — nothing for EDR to read as a renamed image (MITRE T1036). - * Survival comes from the path (see daemon-host-relocation.ts). The one name-sensitive updater path is the - * no-PowerShell `taskkill /IM` fallback, where the daemon is killed and terminals cold-restore — - * the documented pre-relocation outcome, not a failure. - */ -export const daemonHostExeName = (execPath: string): string => winPath.basename(execPath) - -// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe. -const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] - -/** - * Everything `require('@vscode/windows-process-tree')` walks, package-relative. - * - * One list, read three times: the copy plan mirrors the package containing them, - * materialization refuses to start without them, and a materialized host is only - * valid while it still has them. Keeping those three in one place is what stops - * "what we copy" and "what we accept" from drifting apart -- drift there means a - * host that is copied, published, refused, and copied again on every launch. - */ -export const WINDOWS_PROCESS_TREE_REQUIRED = [ - 'package.json', - 'lib/index.js', - 'build/Release/windows_process_tree.node' -] as const - -export type CopyOp = { - sourcePath: string - /** Destination path relative to the host root, posix-separated. */ - destRel: string - kind: 'file' | 'dir' - /** When true, a missing source is skipped rather than failing the copy. */ - optional?: boolean - /** Per-source-path predicate for dir copies: return false to skip a path. */ - filter?: (sourcePath: string) => boolean -} - -export type DaemonHostSources = { - appDir: string - execPath: string - resourcesPath: string - entrySourcePath: string - entryRelPath: string - /** The addon's package; without it the daemon forks a shell per poll (#16905). */ - windowsProcessTreeDir: string -} - -/** A required file, a directory on the way to one, or anything in lib/ (index.js requires its siblings). */ -function isRuntimeProcessTreePath(packageRel: string): boolean { - return ( - packageRel === '' || - packageRel.startsWith('lib/') || - WINDOWS_PROCESS_TREE_REQUIRED.some( - (required) => required === packageRel || required.startsWith(`${packageRel}/`) - ) - ) -} - -// win32 path semantics so Windows paths decompose correctly off-win32 in cross-platform unit tests; production runs on win32 only. -export function toPosixRelative(fromDir: string, absPath: string): string { - return winPath.relative(fromDir, absPath).split(winPath.sep).join('/') -} - -export function destPath(root: string, destRel: string): string { - return join(root, ...destRel.split('/')) -} - -// Drop node-pty's .pdb symbols and non-host-arch prebuilds (its bulk); keyed on host arch so a future win32-arm64 build keeps the prebuild it needs. -const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase() - -function isRuntimeNodePtyPath(sourcePath: string): boolean { - const p = sourcePath.toLowerCase() - if (p.endsWith('.pdb')) { - return false - } - // Keep only the host arch's win32 prebuild; drop any other win32- dir. - const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/) - return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR -} - -/** - * The ordered copy plan. Every destRel mirrors the source's win-unpacked relative path so require() - * and node-pty's loader resolve the mirror identically to the packaged app. Pure so tests can assert layout. - */ -export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { - const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources - const ops: CopyOp[] = [] - - // Host exe (verbatim name) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved). - ops.push({ sourcePath: execPath, destRel: daemonHostExeName(execPath), kind: 'file' }) - for (const name of RUNTIME_DATA_FILES) { - ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true }) - } - - // Daemon bundle: entry + sibling chunks/ + out/package.json (CJS/ESM loader resolution), mirrored verbatim. - ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' }) - const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks') - ops.push({ - sourcePath: chunksDir, - destRel: toPosixRelative(appDir, chunksDir), - kind: 'dir', - optional: true - }) - const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') - ops.push({ - sourcePath: pkgJson, - destRel: toPosixRelative(appDir, pkgJson), - kind: 'file', - optional: true - }) - - // @vscode/windows-process-tree, mirrored so the daemon's require() resolves it; without it every snapshot forks a powershell.exe (#16905). - // Filtered to the runtime files: the installed package is ~25MB of gyp intermediates (.obj/.pdb) around a ~150KB addon. - const { windowsProcessTreeDir } = sources - ops.push({ - sourcePath: windowsProcessTreeDir, - destRel: toPosixRelative(appDir, windowsProcessTreeDir), - kind: 'dir', - filter: (sourcePath) => - isRuntimeProcessTreePath(toPosixRelative(windowsProcessTreeDir, sourcePath)) - }) - // node-pty tree, mirrored so require('node-pty') resolves it; filtered to drop unused .pdb/other-arch prebuilds. - const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty') - ops.push({ - sourcePath: nodePtyDir, - destRel: toPosixRelative(appDir, nodePtyDir), - kind: 'dir', - filter: isRuntimeNodePtyPath - }) - - return ops -} - -export function executeManifest(ops: CopyOp[], stagingRoot: string): void { - for (const op of ops) { - if (!existsSync(op.sourcePath)) { - if (op.optional) { - continue - } - throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`) - } - const dest = destPath(stagingRoot, op.destRel) - mkdirSync(dirname(dest), { recursive: true }) - const { filter } = op - // Dereference symlinks so the copy holds no link back into the install dir. - cpSync(op.sourcePath, dest, { - recursive: op.kind === 'dir', - dereference: true, - force: true, - ...(filter ? { filter: (src: string) => filter(src) } : {}) - }) - } -} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 7b81bdcfa1e0..b9f935b6a734 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -4,16 +4,12 @@ import { mkdirSync, mkdtempSync, readFileSync, - readdirSync, - realpathSync, - renameSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import os from 'node:os' -import { createRequire } from 'node:module' -import { basename, dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' @@ -39,11 +35,8 @@ function installHostApp(): void { } as AppEnvironment) } -import { buildDaemonHostManifest } from './daemon-host-manifest' import { collectPinnedDaemonVersions, - getRelocatedDaemonHost, - materializeRelocatedDaemonHost, pruneOldDaemonHosts, reclaimUnownedDaemonHostDir } from './daemon-host-relocation' @@ -62,67 +55,6 @@ function setProcessProp(key: string, value: unknown): void { Object.defineProperty(process, key, { value, configurable: true, writable: true }) } -// Build a win-unpacked fixture: exe + blobs + DLLs at root, daemon bundle and -// node-pty under resources, mirroring the packaged layout the copy expects. -function buildInstallFixture(root: string): void { - mkdirSync(root, { recursive: true }) - writeFileSync(join(root, 'Orca.exe'), 'exe-bytes') - for (const name of ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']) { - writeFileSync(join(root, name), name) - } - writeFileSync(join(root, 'ffmpeg.dll'), 'dll') - writeFileSync(join(root, 'libEGL.dll'), 'dll') - const mainDir = join(root, 'resources', 'app.asar.unpacked', 'out', 'main') - mkdirSync(join(mainDir, 'chunks'), { recursive: true }) - writeFileSync(join(mainDir, 'daemon-entry.js'), 'entry') - writeFileSync(join(mainDir, 'chunks', 'a.js'), 'chunk') - writeFileSync(join(root, 'resources', 'app.asar.unpacked', 'out', 'package.json'), '{}') - const nativeDir = join(root, 'resources', 'node_modules', 'node-pty', 'build', 'Release') - mkdirSync(nativeDir, { recursive: true }) - writeFileSync(join(nativeDir, 'conpty.node'), 'native') - writeFileSync(join(nativeDir, 'conpty.pdb'), 'debug-symbols') - mkdirSync(join(nativeDir, 'conpty'), { recursive: true }) - writeFileSync(join(nativeDir, 'conpty', 'conpty.dll'), 'conpty-dll') - // Both win32 prebuilds exist in the packaged tree (build-time prune keeps the - // `win32-` prefix); the copy filter keeps the host arch's and drops the other. - const prebuildsRoot = join(root, 'resources', 'node_modules', 'node-pty', 'prebuilds') - for (const arch of ['win32-x64', 'win32-arm64']) { - mkdirSync(join(prebuildsRoot, arch), { recursive: true }) - writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`) - } - const processTreeDir = join(root, 'resources', 'node_modules', '@vscode', 'windows-process-tree') - mkdirSync(join(processTreeDir, 'build', 'Release'), { recursive: true }) - mkdirSync(join(processTreeDir, 'lib'), { recursive: true }) - mkdirSync(join(processTreeDir, 'src'), { recursive: true }) - writeFileSync(join(processTreeDir, 'package.json'), '{"main":"lib/index.js"}') - writeFileSync(join(processTreeDir, 'lib', 'index.js'), 'module.exports = {}') - writeFileSync( - join(processTreeDir, 'build', 'Release', 'windows_process_tree.node'), - 'process-tree-native' - ) - writeFileSync(join(processTreeDir, 'build', 'Release', 'windows_process_tree.pdb'), 'symbols') - writeFileSync(join(processTreeDir, 'src', 'process.cc'), 'source') - writeFileSync(join(processTreeDir, 'lib', 'promises.js'), 'require("./index")') - mkdirSync(join(processTreeDir, 'build', 'Release', 'obj'), { recursive: true }) - writeFileSync(join(processTreeDir, 'build', 'Release', 'obj', 'addon.obj'), 'intermediate') -} - -const PROCESS_TREE_DIR_REL = join('resources', 'node_modules', '@vscode', 'windows-process-tree') - -const PROCESS_TREE_ADDON_REL = join( - 'resources', - 'node_modules', - '@vscode', - 'windows-process-tree', - 'build', - 'Release', - 'windows_process_tree.node' -) - -// The win32 prebuild dir the running host arch loads vs. the one that is pruned. -const HOST_PREBUILD = `win32-${process.arch}` -const OTHER_PREBUILD = HOST_PREBUILD === 'win32-arm64' ? 'win32-x64' : 'win32-arm64' - beforeEach(() => { tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-')) installDir = join(tempDir, 'app') @@ -131,7 +63,6 @@ beforeEach(() => { localAppDataDir = join(tempDir, 'localAppData') mkdirSync(localAppDataDir, { recursive: true }) process.env.LOCALAPPDATA = localAppDataDir - buildInstallFixture(installDir) hostApp.isPackaged = true hostApp.userDataPath = userDataDir hostApp.appPath = join(installDir, 'resources', 'app.asar') @@ -160,271 +91,6 @@ afterEach(() => { } }) -describe('buildDaemonHostManifest', () => { - it('mirrors the win-unpacked layout: exe + data blobs + resources tree, no GPU DLLs', () => { - const appDir = 'C:\\app' - const ops = buildDaemonHostManifest({ - appDir, - execPath: 'C:\\app\\Orca.exe', - resourcesPath: 'C:\\app\\resources', - entrySourcePath: 'C:\\app\\resources\\app.asar.unpacked\\out\\main\\daemon-entry.js', - entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js', - windowsProcessTreeDir: 'C:\\app\\resources\\node_modules\\@vscode\\windows-process-tree' - }) - const byDest = new Map(ops.map((op) => [op.destRel, op])) - // The host exe keeps the source basename: a verbatim, signature-preserving copy with no - // image-name mismatch. What escapes the updater's sweep is the path, not the name. - expect(byDest.get('Orca.exe')?.kind).toBe('file') - const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe') - expect(exeOp?.destRel).toBe('Orca.exe') - // Without this op the relocated daemon cannot resolve the native process - // table and falls back to a powershell.exe scan per snapshot (#16905). - expect(byDest.get('resources/node_modules/@vscode/windows-process-tree')?.kind).toBe('dir') - // V8/ICU data blobs are read by the Electron bootstrap and kept. - expect(byDest.has('icudtl.dat')).toBe(true) - // GPU/graphics DLLs are never loaded by the windowless host, so not copied. - expect(byDest.has('ffmpeg.dll')).toBe(false) - expect(byDest.has('libEGL.dll')).toBe(false) - // Daemon bundle + node-pty mirrored at their real resources-relative paths. - expect(byDest.get('resources/app.asar.unpacked/out/main/daemon-entry.js')?.kind).toBe('file') - expect(byDest.get('resources/app.asar.unpacked/out/main/chunks')?.kind).toBe('dir') - // node-pty is copied with a filter dropping .pdb + non-host-arch prebuilds. - const nodePtyOp = byDest.get('resources/node_modules/node-pty') - expect(nodePtyOp?.kind).toBe('dir') - expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.node')).toBe(true) - expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.pdb')).toBe(false) - expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${HOST_PREBUILD}/pty.node`)).toBe(true) - expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${OTHER_PREBUILD}/pty.node`)).toBe(false) - }) -}) - -describe('materializeRelocatedDaemonHost', () => { - it('copies the tree, writes the marker, and returns mirrored fork paths', () => { - const result = materializeRelocatedDaemonHost() - expect(result).not.toBeNull() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - expect(result?.execPath).toBe(join(dest, 'Orca.exe')) - expect(result?.entryPath).toBe( - join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') - ) - expect(existsSync(result!.execPath)).toBe(true) - expect(existsSync(result!.entryPath)).toBe(true) - // node-pty native + conpty runtime copied at the require-resolvable path. - expect( - existsSync( - join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node') - ) - ).toBe(true) - expect( - existsSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js')) - ).toBe(true) - // Trim: GPU DLLs, .pdb debug symbols, and non-host-arch prebuilds excluded; - // the host arch's prebuild is retained so node-pty resolves its native addon. - expect(existsSync(join(dest, 'ffmpeg.dll'))).toBe(false) - expect(existsSync(join(dest, 'libEGL.dll'))).toBe(false) - expect( - existsSync( - join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.pdb') - ) - ).toBe(false) - const prebuildsDest = join(dest, 'resources', 'node_modules', 'node-pty', 'prebuilds') - expect(existsSync(join(prebuildsDest, HOST_PREBUILD, 'pty.node'))).toBe(true) - expect(existsSync(join(prebuildsDest, OTHER_PREBUILD, 'pty.node'))).toBe(false) - // Marker records the version + entry rel path, written into the published dir. - const marker = JSON.parse(readFileSync(join(dest, '.materialized.json'), 'utf8')) - expect(marker.version).toBe('9.9.9') - expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js') - // The whole package is mirrored, so require() finds every hop it walks. - const processTreeDest = join( - dest, - 'resources', - 'node_modules', - '@vscode', - 'windows-process-tree' - ) - expect(existsSync(join(processTreeDest, 'build', 'Release', 'windows_process_tree.node'))).toBe( - true - ) - expect(existsSync(join(processTreeDest, 'lib', 'index.js'))).toBe(true) - expect(existsSync(join(processTreeDest, 'lib', 'promises.js'))).toBe(true) - // Build intermediates are ~25MB of the installed package; the loader never reads them. - expect(existsSync(join(processTreeDest, 'build', 'Release', 'windows_process_tree.pdb'))).toBe( - false - ) - expect(existsSync(join(processTreeDest, 'build', 'Release', 'obj'))).toBe(false) - expect(existsSync(join(processTreeDest, 'src'))).toBe(false) - // The loader requires the BARE package, so resolution runs through the copied - // package.json's `main`. Asserting the .node subpath instead would still pass - // with package.json dropped from the filter, while the daemon's own require - // failed and it silently went back to the CIM scan. - expect( - createRequire( - join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js') - ).resolve('@vscode/windows-process-tree') - ).toBe(realpathSync(join(processTreeDest, 'lib', 'index.js'))) - }) - - it('copies the exe verbatim: same file name and same bytes as the install-dir exe', () => { - const result = materializeRelocatedDaemonHost() - const sourceExe = join(installDir, 'Orca.exe') - // Byte-for-byte under the same name is what preserves the Authenticode signature and leaves - // no renamed-image signal for endpoint detection to read as masquerading. - expect(basename(result!.execPath)).toBe(basename(sourceExe)) - expect(readFileSync(result!.execPath)).toEqual(readFileSync(sourceExe)) - }) - - it('tracks a differently-named app exe rather than pinning an image name of its own', () => { - // A dev-channel or rebranded build ships a different executableName; the host copy must follow - // it, which is what keeps the copy verbatim instead of reintroducing a name mismatch. - renameSync(join(installDir, 'Orca.exe'), join(installDir, 'Orca Nightly.exe')) - setProcessProp('execPath', join(installDir, 'Orca Nightly.exe')) - const result = materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - expect(result?.execPath).toBe(join(dest, 'Orca Nightly.exe')) - expect(existsSync(join(dest, 'orca-terminal-daemon.exe'))).toBe(false) - // Re-resolution must agree with materialization or the fork would target a missing exe. - expect(getRelocatedDaemonHost()?.execPath).toBe(join(dest, 'Orca Nightly.exe')) - }) - - it.each([ - ['the binary', PROCESS_TREE_ADDON_REL], - ['package.json', join(PROCESS_TREE_DIR_REL, 'package.json')], - ['lib/index.js', join(PROCESS_TREE_DIR_REL, 'lib', 'index.js')] - ])('refuses a mirror that lost %s', (_label, relativePath) => { - // Any one of them missing means require() cannot reach the addon, and a host - // that cannot load it runs anyway -- forking a shell per snapshot (#16905). - materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - expect(getRelocatedDaemonHost()).not.toBeNull() - - rmSync(join(dest, relativePath)) - - expect(getRelocatedDaemonHost()).toBeNull() - }) - - it('rematerializes a host whose copied addon went missing', () => { - materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - const relocatedAddon = join(dest, PROCESS_TREE_ADDON_REL) - - rmSync(relocatedAddon) - // A sentinel proves the host was rebuilt rather than reused. - const sentinel = join(dest, 'sentinel.txt') - writeFileSync(sentinel, 'remove') - - expect(materializeRelocatedDaemonHost()).not.toBeNull() - expect(readFileSync(relocatedAddon, 'utf8')).toBe('process-tree-native') - expect(existsSync(sentinel)).toBe(false) - }) - - it('does not read the install dir to decide an existing host is still good', () => { - // Relocation exists to outlive the install dir, so validity cannot depend on - // it: an updater mid-copy would otherwise condemn an intact host. A real - // upgrade changes the version keying this directory, which already forces a - // rebuild, and nothing else in the mirror is source-verified either. - materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - const sentinel = join(dest, 'sentinel.txt') - writeFileSync(sentinel, 'keep') - - rmSync(join(installDir, 'resources', 'node_modules', '@vscode'), { - recursive: true, - force: true - }) - - expect(getRelocatedDaemonHost()).not.toBeNull() - expect(existsSync(sentinel)).toBe(true) - }) - - it.each([ - ['the binary', PROCESS_TREE_ADDON_REL], - ['package.json', join(PROCESS_TREE_DIR_REL, 'package.json')], - ['lib/index.js', join(PROCESS_TREE_DIR_REL, 'lib', 'index.js')] - ])('refuses to copy anything when the install lost %s', (_label, relativePath) => { - // Each of these would be accepted by the copy plan and then refused by the - // mirror check, which is a ~260MB copy per launch to reach a verdict the - // source could have given for free. Sharing one list is what prevents that. - rmSync(join(installDir, relativePath)) - - expect(materializeRelocatedDaemonHost()).toBeNull() - // Not even the host root: the source is checked before any directory is made. - expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) - }) - - it('is idempotent: a valid marker short-circuits without recopying', () => { - materializeRelocatedDaemonHost() - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - // A recopy would rm the dest; a sentinel inside it must survive the 2nd call. - const sentinel = join(dest, 'sentinel.txt') - writeFileSync(sentinel, 'keep') - const result = materializeRelocatedDaemonHost() - expect(result?.execPath).toBe(join(dest, 'Orca.exe')) - expect(existsSync(sentinel)).toBe(true) - }) - - it('fails open on a missing required input, leaving no dest or staging dir', () => { - rmSync(join(installDir, 'resources', 'node_modules', 'node-pty'), { - recursive: true, - force: true - }) - const result = materializeRelocatedDaemonHost() - expect(result).toBeNull() - const hostRoot = join(localAppDataDir, 'Orca', 'daemon-host') - // Neither the published dest nor any leftover staging dir remains. - const remaining = existsSync(hostRoot) ? readdirSync(hostRoot) : [] - expect(remaining).toEqual([]) - }) - - it('returns null off win32', () => { - setProcessProp('platform', 'darwin') - expect(materializeRelocatedDaemonHost()).toBeNull() - expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) - }) - - it('does nothing for a packaged host with no asar root (orcad on win32)', () => { - // orcad answers isPackaged() true — it is a shipped build — but it is plain Node: no - // asar, no resourcesPath, and no NSIS updater to escape. Relocation staging a copy of - // an Electron tree that is not there is the isPackaged-honesty defect, and it would - // silently produce a null host on a path whose failures are meant to be visible. - hostApp.appPath = join(installDir, 'resources', 'app') - installHostApp() - expect(materializeRelocatedDaemonHost()).toBeNull() - expect(getRelocatedDaemonHost()).toBeNull() - expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) - }) -}) - -describe('getRelocatedDaemonHost', () => { - it('returns null when the marker version does not match the current version', () => { - const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - mkdirSync(dirname(join(dest, 'x')), { recursive: true }) - writeFileSync(join(dest, 'Orca.exe'), 'exe') - mkdirSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main'), { recursive: true }) - writeFileSync( - join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'), - 'e' - ) - // The process-table files too, or this passes because the mirror has no addon - // and never exercises the version comparison it is named for. - mkdirSync(join(dest, PROCESS_TREE_DIR_REL, 'lib'), { recursive: true }) - mkdirSync(dirname(join(dest, PROCESS_TREE_ADDON_REL)), { recursive: true }) - writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'package.json'), '{}') - writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'lib', 'index.js'), 'm') - writeFileSync(join(dest, PROCESS_TREE_ADDON_REL), 'n') - writeFileSync( - join(dest, '.materialized.json'), - JSON.stringify({ - version: '8.8.8', - completedAt: '', - entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' - }) - ) - expect(getRelocatedDaemonHost()).toBeNull() - }) -}) - -// Why: quarantine refuses records written in the last minute, because an in-flight publish is -// indistinguishable from a torn one. Age a record so it stands for a settled corrupt record. function ageRecordPastQuarantineFloor(recordPath: string): void { const aged = new Date(Date.now() - 5 * 60_000) utimesSync(recordPath, aged, aged) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 51fed83530af..9eb1372cef46 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -1,79 +1,15 @@ -import { randomBytes } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' -import { join, win32 as winPath } from 'node:path' +import { readFileSync, readdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { - buildDaemonHostManifest, - daemonHostExeName, - destPath, - executeManifest, - toPosixRelative, - WINDOWS_PROCESS_TREE_REQUIRED, - type DaemonHostSources -} from './daemon-host-manifest' import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' import { parseDaemonPidFile } from './daemon-pid-file-parse' import { quarantineCorruptDaemonPidRecord } from './daemon-pid-record-quarantine' import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-process-inspection' -/** - * Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it - * survives Windows auto-updates: the NSIS installer deletes the old install and force-kills every process - * imaged under it, which would otherwise kill the daemon and its live terminals. The relocated exe is a - * run-as-node Orca.exe copy (not node.exe) so there's no console flash and asar still resolves. Fail-open: - * any failure returns null and the caller forks the install-dir host (pre-relocation behavior). - * - * What escapes the updater is the PATH, not the file name: electron-builder's kill sweep selects - * processes whose image path sits under $INSTDIR. See docs/reference/windows-daemon-host-relocation.md - * for the survival contract and why the exe is copied verbatim rather than renamed. - */ - -export type RelocatedDaemonHost = { - /** The relocated host exe to fork the daemon from (run as node). */ - execPath: string - /** The copied daemon-entry.js, mirrored under the relocated resources tree. */ - entryPath: string -} - +// Legacy Electron hosts remain protected until their original owners have exited. const HOST_SUBDIR = 'daemon-host' -const MARKER_NAME = '.materialized.json' - -// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' -type MaterializeMarker = { - version: string - completedAt: string - entryRelPath: string -} - -// Mirror getDaemonEntryPath()'s resolution order so the copied entry is the exact file the in-dir fork would run. -function resolveEntrySourcePath(resourcesPath: string): string { - const unpackedRoot = join(resourcesPath, 'app.asar.unpacked') - const direct = join(unpackedRoot, 'daemon-entry.js') - if (existsSync(direct)) { - return direct - } - return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') -} - -/** - * Whether this process is a packaged ELECTRON app on win32 — the only shape relocation - * addresses, because what it escapes is the NSIS updater's kill zone. - * - * Why asar and not isPackaged alone: orcad answers isPackaged() true (it is a shipped build, - * not a dev checkout) while having no asar, no resourcesPath and no NSIS installer. Asking - * whether the app root is an asar archive is the same honesty fix the watcher path uses, and - * it keeps a Node host from staging a copy of an Electron tree it does not have. - */ function isPackagedElectronWin32(): boolean { const environment = getAppEnvironment() return ( @@ -83,57 +19,6 @@ function isPackagedElectronWin32(): boolean { ) } -// Relocation inputs from the live packaged process, or null when it doesn't apply (non-win32, dev, or missing resourcesPath). -function collectDaemonHostSources(): DaemonHostSources | null { - if (!isPackagedElectronWin32()) { - return null - } - const resourcesPath = process.resourcesPath - if (typeof resourcesPath !== 'string' || resourcesPath.length === 0) { - return null - } - const execPath = process.execPath - const appDir = winPath.dirname(execPath) - const entrySourcePath = resolveEntrySourcePath(resourcesPath) - return { - appDir, - execPath, - resourcesPath, - entrySourcePath, - entryRelPath: toPosixRelative(appDir, entrySourcePath), - windowsProcessTreeDir: join(resourcesPath, 'node_modules', '@vscode', 'windows-process-tree') - } -} - -function readMarker(dir: string): MaterializeMarker | null { - try { - const parsed = JSON.parse( - readFileSync(join(dir, MARKER_NAME), 'utf8') - ) as Partial - if (typeof parsed.version === 'string' && typeof parsed.entryRelPath === 'string') { - return { - version: parsed.version, - completedAt: typeof parsed.completedAt === 'string' ? parsed.completedAt : '', - entryRelPath: parsed.entryRelPath - } - } - } catch { - // Missing/corrupt marker — treat as not materialized. - } - return null -} - -function processTreeRelDir(sources: DaemonHostSources): string { - return toPosixRelative(sources.appDir, sources.windowsProcessTreeDir) -} - -/** True when any file require() needs is absent from a copy of the package. */ -function missingProcessTreeFiles(packageDir: string): boolean { - return WINDOWS_PROCESS_TREE_REQUIRED.some( - (relative) => !existsSync(join(packageDir, ...relative.split('/'))) - ) -} - function hostRootDir(): string { // Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME); fall back to userData only if LOCALAPPDATA is unset. const localAppData = process.env.LOCALAPPDATA @@ -144,86 +29,6 @@ function hostRootDir(): string { return join(base, HOST_SUBDIR) } -/** - * The relocated host for the current version, or null. Valid only when the marker matches this version - * AND the exe + entry exist, so a partial or stale copy never reports ready. - */ -export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { - const sources = collectDaemonHostSources() - if (!sources) { - return null - } - const version = getAppEnvironment().getVersion() - const dest = join(hostRootDir(), version) - const marker = readMarker(dest) - if (!marker || marker.version !== version) { - return null - } - const execPath = join(dest, daemonHostExeName(sources.execPath)) - const entryPath = destPath(dest, marker.entryRelPath) - if (!existsSync(execPath) || !existsSync(entryPath)) { - return null - } - // A mirror the daemon cannot load the addon from still runs -- it just forks a - // shell per snapshot (#16905) -- so treat it as unmaterialized and rebuild. Hosts - // from before this shipped have none of these files. Checked in the mirror, never - // in the install dir, which is the thing relocation exists to outlive. - if (missingProcessTreeFiles(destPath(dest, processTreeRelDir(sources)))) { - return null - } - return { execPath, entryPath } -} - -/** - * Materialize the current version's daemon host, returning its fork paths or null (fail-open). Idempotent - * via marker; stages into a temp sibling and publishes by atomic rename, so a crash mid-copy never leaves a half-populated dest. - */ -export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { - const existing = getRelocatedDaemonHost() - if (existing) { - return existing - } - const sources = collectDaemonHostSources() - if (!sources) { - return null - } - // Checked against the source before copying: the mirror check below would refuse - // the result anyway, and re-copying ~260MB on every launch to reach that verdict - // is the loop this shares its list with the copy plan to prevent. - if (missingProcessTreeFiles(sources.windowsProcessTreeDir)) { - return null - } - const version = getAppEnvironment().getVersion() - const root = hostRootDir() - const dest = join(root, version) - const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) - try { - mkdirSync(root, { recursive: true }) - rmSync(staging, { recursive: true, force: true }) - executeManifest(buildDaemonHostManifest(sources), staging) - // Marker written LAST so an interrupted copy leaves a marker-less staging dir the next launch discards. - const marker: MaterializeMarker = { - version, - completedAt: new Date().toISOString(), - entryRelPath: sources.entryRelPath - } - writeFileSync(join(staging, MARKER_NAME), JSON.stringify(marker)) - // Replace any stale/partial dest, then publish atomically. Windows refuses to delete a running - // image, so a live daemon already hosted in THIS version's dir (same-version reinstall, or a dev - // channel reusing a version) throws here and materialization fails open to the install-dir host. - rmSync(dest, { recursive: true, force: true }) - renameSync(staging, dest) - } catch { - try { - rmSync(staging, { recursive: true, force: true }) - } catch { - // Best-effort staging cleanup. - } - return null - } - return getRelocatedDaemonHost() -} - export type PinnedDaemonVersionsEvidence = | { status: 'complete'; versionLiveness: ReadonlyMap } | { status: 'unverifiable'; reason: string } diff --git a/src/main/daemon/daemon-init-endpoint-adoption.test.ts b/src/main/daemon/daemon-init-endpoint-adoption.test.ts index 2222cec7e361..92cdc5c118a1 100644 --- a/src/main/daemon/daemon-init-endpoint-adoption.test.ts +++ b/src/main/daemon/daemon-init-endpoint-adoption.test.ts @@ -123,7 +123,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('adopts the winner when a launched daemon loses the endpoint race', async () => { // Why: losing the publish race is an expected outcome, not a crash. Reporting it as a - // startup failure strands this app on local non-persistent PTYs beside a healthy daemon. + // startup failure blocks new terminals beside a healthy daemon. const mod = await importFresh() await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) @@ -458,7 +458,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { try { const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') - expect(handle.mode).toBe('degraded-new-pty-fallback') + expect(handle.mode).toBe('fresh-spawns-unavailable') expect(forkMock.mock.calls.length).toBe(forkCallsBefore) handle.releaseAdoptionLease?.() } finally { @@ -563,7 +563,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { try { const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') - expect(handle.mode).toBe('degraded-new-pty-fallback') + expect(handle.mode).toBe('fresh-spawns-unavailable') handle.releaseAdoptionLease?.() } finally { warn.mockRestore() diff --git a/src/main/daemon/daemon-init-live-session-preservation.test.ts b/src/main/daemon/daemon-init-live-session-preservation.test.ts index d8f988112cb8..4413e0d85903 100644 --- a/src/main/daemon/daemon-init-live-session-preservation.test.ts +++ b/src/main/daemon/daemon-init-live-session-preservation.test.ts @@ -76,6 +76,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string @@ -117,6 +118,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string @@ -135,6 +137,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const mod = await importFresh() await mod.initDaemonPtyProvider() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string @@ -216,6 +219,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string @@ -255,6 +259,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string @@ -292,6 +297,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string @@ -328,11 +334,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string ) => Promise<{ - mode?: 'degraded-new-pty-fallback' + mode?: 'fresh-spawns-unavailable' shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') @@ -340,7 +347,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const handle = await launcher('/fake/socket', '/fake/token') expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) - expect(handle.mode).toBe('degraded-new-pty-fallback') + expect(handle.mode).toBe('fresh-spawns-unavailable') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) @@ -362,6 +369,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string @@ -399,6 +407,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const mod = await importFresh() await mod.initDaemonPtyProvider() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spawner fixture captures the launcher passed by initDaemonPtyProvider. const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index 341fcd26df7b..b0175412f86e 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -46,7 +46,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA /** Handle the fake spawner hands back from ensureRunning/getHandle. */ export type MockSpawnerHandle = { - mode?: 'degraded-new-pty-fallback' + mode?: 'fresh-spawns-unavailable' adopted?: true releaseAdoptionLease?: () => void shutdown: () => Promise @@ -95,7 +95,7 @@ export type MockLocalPtyProvider = { export type EnsureRunningOverride = () => Promise<{ socketPath: string tokenPath: string - mode?: 'degraded-new-pty-fallback' + mode?: 'fresh-spawns-unavailable' adopted?: true }> diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 423ee9ee34f2..6a0aa772293f 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -145,7 +145,39 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) - it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => { + it('installs a healthy daemon that completes after the startup gate deadline', async () => { + const mod = await importFresh() + const { startFirstWindowStartupServices, LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS } = + await import('../startup/first-window-startup-services') + let finish!: (value: { socketPath: string; tokenPath: string }) => void + ensureRunningOverrides.push( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + vi.useFakeTimers() + try { + const services = startFirstWindowStartupServices({ + startDaemonPtyProvider: (signal) => mod.initDaemonPtyProvider(signal), + startAgentHookServer: async () => {}, + onDaemonError: vi.fn(), + onAgentHookServerError: vi.fn() + }) + await vi.advanceTimersByTimeAsync(LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS) + await services.localPtyReady + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() + finish({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' }) + await vi.advanceTimersByTimeAsync(0) + expect(setLocalPtyProviderMock).toHaveBeenCalledOnce() + expect(rebindLocalProviderListenersMock).toHaveBeenCalledOnce() + expect(mod.getDaemonProvider()).not.toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('does not install a late daemon provider after explicit cancellation', async () => { const mod = await importFresh() let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void ensureRunningOverrides.push( @@ -273,29 +305,27 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { vi.restoreAllMocks() }) - it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { + it('refuses fresh PTYs when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ socketPath: '/fake/degraded-socket', tokenPath: '/fake/degraded-token', - mode: 'degraded-new-pty-fallback' + mode: 'fresh-spawns-unavailable' })) await mod.initDaemonPtyProvider() - const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + const { DaemonPtyRouter } = await import('./daemon-pty-router') const provider = mod.getDaemonProvider() - expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) - expect(getLocalPtyProviderMock).toHaveBeenCalledOnce() + expect(provider).toBeInstanceOf(DaemonPtyRouter) + expect(getLocalPtyProviderMock).not.toHaveBeenCalled() expect(setLocalPtyProviderMock).toHaveBeenCalledWith(provider) - const result = await provider!.spawn({ cols: 80, rows: 24 }) - - expect(result.id).toBe('local-fallback-pty') - expect(localFallbackProvider.spawn).toHaveBeenCalledWith({ - cols: 80, - rows: 24 - }) + checkDaemonHealthMock.mockResolvedValue('unhealthy') + await expect(provider!.spawn({ cols: 80, rows: 24 })).rejects.toThrow( + 'Terminal service unavailable' + ) + expect(localFallbackProvider.spawn).not.toHaveBeenCalled() expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) @@ -313,11 +343,11 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ensureRunningOverrides.push(async () => ({ socketPath: '/fake/degraded-socket', tokenPath: '/fake/degraded-token', - mode: 'degraded-new-pty-fallback' + mode: 'fresh-spawns-unavailable' })) await degraded.initDaemonPtyProvider() - const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') - expect(degraded.getDaemonProvider()).toBeInstanceOf(DegradedDaemonPtyProvider) + const { DaemonPtyRouter } = await import('./daemon-pty-router') + expect(degraded.getDaemonProvider()).toBeInstanceOf(DaemonPtyRouter) expect(degraded.daemonOwnsFreshPersistentPtys()).toBe(false) }) @@ -326,22 +356,39 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ensureRunningOverrides.push(async () => ({ socketPath: '/fake/degraded-socket', tokenPath: '/fake/degraded-token', - mode: 'degraded-new-pty-fallback' + mode: 'fresh-spawns-unavailable' })) await mod.initDaemonPtyProvider() checkDaemonHealthMock.mockClear() - const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + const { DaemonPtyRouter } = await import('./daemon-pty-router') const provider = mod.getDaemonProvider() - expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) - const degradedProvider = provider as InstanceType + expect(provider).toBeInstanceOf(DaemonPtyRouter) + if (!(provider instanceof DaemonPtyRouter)) { + throw new Error('Expected daemon router') + } + const degradedProvider = provider await expect(degradedProvider.recoverFreshSpawnRouting()).resolves.toBe(true) expect(checkDaemonHealthMock).toHaveBeenCalledWith( '/fake/degraded-socket', '/fake/degraded-token' ) - expect(degradedProvider.routesFreshSpawnsToLocalProvider).toBeUndefined() + expect(degradedProvider.freshSpawnsUnavailable).toBe(false) + }) + + it('coalesces initial failure retries without stopping an existing service', async () => { + const mod = await importFresh() + ensureRunningOverrides.push(async () => { + throw new Error('runtime unavailable') + }) + await expect(mod.initDaemonPtyProvider()).rejects.toThrow('runtime unavailable') + await Promise.all([mod.retryDaemonPtyProvider(), mod.retryDaemonPtyProvider()]) + expect(spawnerInstances).toHaveLength(2) + expect(setLocalPtyProviderMock).toHaveBeenCalledOnce() + expect(mod.daemonOwnsFreshPersistentPtys()).toBe(true) + await mod.retryDaemonPtyProvider() + expect(spawnerInstances).toHaveLength(2) }) it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { diff --git a/src/main/daemon/daemon-init-restart-sequence.test.ts b/src/main/daemon/daemon-init-restart-sequence.test.ts index 71417db986c6..0623c1347c87 100644 --- a/src/main/daemon/daemon-init-restart-sequence.test.ts +++ b/src/main/daemon/daemon-init-restart-sequence.test.ts @@ -103,20 +103,23 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ensureRunningOverrides.push(async () => ({ socketPath: '/fake/degraded-socket', tokenPath: '/fake/degraded-token', - mode: 'degraded-new-pty-fallback' + mode: 'fresh-spawns-unavailable' })) await mod.initDaemonPtyProvider() - const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + const { DaemonPtyRouter } = await import('./daemon-pty-router') const provider = mod.getDaemonProvider() - expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) - const degradedProvider = provider as InstanceType + expect(provider).toBeInstanceOf(DaemonPtyRouter) + if (!(provider instanceof DaemonPtyRouter)) { + throw new Error('Expected daemon router') + } + const degradedProvider = provider const originalAdapter = adapterInstances[0] originalAdapter.listProcesses.mockResolvedValueOnce([ { id: 'preserved-current-session', cwd: '/repo', title: 'shell' } ]) - await degradedProvider.discoverDaemonSessions() + await degradedProvider.discoverLegacySessions() const order: string[] = [] degradedProvider.onExit((payload) => { diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 918547d8b79d..466aa42840cc 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -1,5 +1,5 @@ export { createLegacyDaemonAdapters } from './daemon-legacy-adapters' -export { initDaemonPtyProvider } from './daemon-provider-init' +export { initDaemonPtyProvider, retryDaemonPtyProvider } from './daemon-provider-init' export { restartDaemon, type RestartDaemonResult } from './daemon-provider-restart' export { daemonOwnsFreshPersistentPtys, diff --git a/src/main/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 532bc75c574d..b05b1db71805 100644 --- a/src/main/daemon/daemon-launch-paths.ts +++ b/src/main/daemon/daemon-launch-paths.ts @@ -1,3 +1,4 @@ +import { desktopDaemonBundleDir } from './daemon-bun-runtime' import { existsSync } from 'node:fs' import { connect } from 'node:net' import { join } from 'node:path' @@ -27,6 +28,9 @@ export function getDaemonHistoryDir(): string { } export function getDaemonEntryPath(): string { + if (process.versions.electron) { + return join(desktopDaemonBundleDir(), 'daemon-entry.js') + } const appPath = getAppEnvironment().getAppPath() // Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable. // Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index 1b636261fa11..fc48a92a238c 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { spawnDaemonChildProcess } from './daemon-launched-child-spawn' const { spawn, fork } = vi.hoisted(() => ({ spawn: vi.fn(), fork: vi.fn() })) @@ -23,8 +22,8 @@ const options = { afterEach(() => { vi.clearAllMocks() + vi.unstubAllGlobals() vi.unstubAllEnvs() - vi.restoreAllMocks() }) describe('daemon launch scope ownership', () => { @@ -55,46 +54,164 @@ describe('daemon launch scope ownership', () => { }) }) -describe('headless Bun daemon launch', () => { - function useBun(): void { - vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: '1.4.2' }) - vi.stubEnv('NODE_OPTIONS', '--require=untrusted.js') - vi.stubEnv('NODE_PATH', '/untrusted') - vi.stubEnv('BUN_OPTIONS', '--preload=untrusted.js') - } - - it('isolates scoped Bun launches before the daemon entry', () => { - useBun() - spawnDaemonChildProcess(options, true) +it('launches the Bun payload directly without inherited Electron preload options', () => { + vi.stubEnv('NODE_OPTIONS', '--require=/user/preload.js') + vi.stubEnv('BUN_OPTIONS', '--preload=/user/preload.js') + try { + spawnDaemonChildProcess( + { + ...options, + bunRuntime: true, + relocatedExecPath: '/runtime/bun', + conptyLibraryPath: 'C:\\runtime\\conpty\\conpty.dll' + }, + false + ) + expect(fork).not.toHaveBeenCalled() const call = spawn.mock.calls[0][0] - const executableIndex = call.args.indexOf(process.execPath) - expect(call.args.slice(executableIndex + 1, executableIndex + 5)).toEqual([ - ...bunOwnedRuntimeArgs(), + expect(call.program).toBe('/runtime/bun') + expect(call.args.slice(0, 4)).toEqual([ + '--no-env-file', + process.platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install', options.forkEntryPath ]) - for (const key of ['ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', 'NODE_PATH', 'BUN_OPTIONS']) { - expect(call.env[key]).toBeUndefined() - } + expect(call.stdio).toContain('ipc') + expect(call.env.NODE_OPTIONS).toBeUndefined() + expect(call.env.NODE_PATH).toBeUndefined() + expect(call.env.BUN_OPTIONS).toBeUndefined() + expect(call.env.ELECTRON_RUN_AS_NODE).toBeUndefined() + expect(call.args).toContain('--spawner-exec-path') + } finally { + vi.unstubAllEnvs() + } +}) + +it('selects only the verified Windows provider instead of an inherited override', () => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: { ...process.env, BUN_CONPTY_LIBRARY: 'C:\\foreign\\conpty.dll' } }) + const library = 'C:\\Orca Runtime\\conpty\\conpty.dll' + spawnDaemonChildProcess( + { + ...options, + bunRuntime: true, + relocatedExecPath: 'C:\\Orca Runtime\\bun-runtime.exe', + conptyLibraryPath: library + }, + false + ) + expect(spawn.mock.calls[0][0].env.BUN_CONPTY_LIBRARY).toBe(library) +}) - it('scrubs direct Bun forks while preserving the selected ConPTY library', () => { - useBun() - vi.stubEnv('BUN_CONPTY_LIBRARY', '/verified/conpty.dll') - spawnDaemonChildProcess(options, false) - expect(fork.mock.calls[0][0].env).toEqual( - expect.objectContaining({ - BUN_CONPTY_LIBRARY: '/verified/conpty.dll', - ORCA_USER_DATA_PATH: options.userDataPath - }) - ) - expect(fork.mock.calls[0][0].env.NODE_OPTIONS).toBeUndefined() +it.each([undefined, 'conpty.dll'])( + 'refuses a Windows Bun launch without an absolute provider: %s', + (conptyLibraryPath) => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + expect(() => + spawnDaemonChildProcess( + { + ...options, + bunRuntime: true, + relocatedExecPath: 'C:\\runtime\\bun-runtime.exe', + conptyLibraryPath + }, + false + ) + ).toThrow('ConPTY library path must be absolute') + expect(spawn).not.toHaveBeenCalled() + } +) + +it('does not pass an inherited ConPTY selector to a POSIX Bun daemon', () => { + vi.stubGlobal('process', { + ...process, + platform: 'linux', + env: { ...process.env, BUN_CONPTY_LIBRARY: '/foreign/conpty.dll' } }) + spawnDaemonChildProcess( + { ...options, bunRuntime: true, relocatedExecPath: '/runtime/bun' }, + false + ) + expect(spawn.mock.calls[0][0].env.BUN_CONPTY_LIBRARY).toBeUndefined() +}) - it('preserves an explicitly selected Node executable', () => { - useBun() - spawnDaemonChildProcess({ ...options, relocatedExecPath: '/alternate/node' }, true) - const call = spawn.mock.calls[0][0] - expect(call.args).not.toContain('--no-install') - expect(call.env.ELECTRON_RUN_AS_NODE).toBe('1') +it('places the Bun dotenv guard before the entry in a durable scope', () => { + spawnDaemonChildProcess( + { + ...options, + bunRuntime: true, + relocatedExecPath: '/runtime/bun', + conptyLibraryPath: 'C:\\runtime\\conpty\\conpty.dll' + }, + true + ) + const args: string[] = spawn.mock.calls[0][0].args + const runtimeIndex = args.indexOf('/runtime/bun') + expect(runtimeIndex).toBeGreaterThan(-1) + expect(args.slice(runtimeIndex + 1, runtimeIndex + 5)).toEqual([ + '--no-env-file', + process.platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install', + options.forkEntryPath + ]) +}) + +it.each([false, true])( + 'isolates the current headless Bun runtime with durable scope=%s', + (scoped) => { + vi.stubGlobal('process', { + ...process, + platform: 'linux', + versions: { ...process.versions, electron: undefined, bun: '1.4.2' }, + env: { + ...process.env, + BUN_OPTIONS: '--preload=/foreign/preload.js', + NODE_OPTIONS: '--require=/foreign/preload.js', + NODE_PATH: '/foreign/modules' + } + }) + spawnDaemonChildProcess(options, scoped) + const call = scoped ? spawn.mock.calls[0][0] : fork.mock.calls[0][0] + expect(call.env.BUN_OPTIONS).toBeUndefined() + expect(call.env.NODE_OPTIONS).toBeUndefined() + expect(call.env.NODE_PATH).toBeUndefined() + expect(call.env.ELECTRON_RUN_AS_NODE).toBeUndefined() + if (scoped) { + const index = call.args.indexOf(process.execPath) + expect(index).toBeGreaterThan(-1) + expect(call.args.slice(index + 1, index + 5)).toEqual([ + '--no-env-file', + '--config=/dev/null', + '--no-install', + options.forkEntryPath + ]) + } + } +) + +it('keeps verified ConPTY selection when headless Bun forks itself on Windows', () => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + versions: { ...process.versions, electron: undefined, bun: '1.4.2' }, + env: { ...process.env, BUN_CONPTY_LIBRARY: 'C:\\verified\\conpty.dll' } }) + spawnDaemonChildProcess(options, false) + expect(fork.mock.calls[0][0].env.BUN_CONPTY_LIBRARY).toBe('C:\\verified\\conpty.dll') +}) + +it('does not add Bun flags when a Bun parent explicitly selects another runtime', () => { + vi.stubGlobal('process', { ...process, versions: { ...process.versions, bun: '1.4.2' } }) + spawnDaemonChildProcess({ ...options, relocatedExecPath: '/other/node' }, true) + expect(spawn.mock.calls[0][0].args).not.toContain('--no-env-file') + expect(spawn.mock.calls[0][0].env.ELECTRON_RUN_AS_NODE).toBe('1') +}) + +it.each([false, true])('requires the selected desktop Bun runtime with scope=%s', (scoped) => { + expect(() => spawnDaemonChildProcess({ ...options, bunRuntime: true }, scoped)).toThrow() + expect(spawn).not.toHaveBeenCalled() + expect(fork).not.toHaveBeenCalled() }) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 7dbae59c330f..c1b280f0fcde 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -1,3 +1,4 @@ +import { win32 } from 'node:path' import { forkProcess, type ForkSpec } from '../../shared/child-process/fork-process' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' @@ -9,6 +10,8 @@ export type DaemonChildSpawnOptions = { entryPath: string forkEntryPath: string relocatedExecPath?: string + bunRuntime?: boolean + conptyLibraryPath?: string userDataPath: string socketPath: string tokenPath: string @@ -39,19 +42,7 @@ function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { ] } -/** - * The two ways the daemon's OS process comes into being: the long-standing direct `fork()`, and - * (`useDurableScope`) the identical command line wrapped in `systemd-run --user --scope` so the - * daemon lands in a sibling cgroup instead of the caller's — see daemon-cgroup-scope.ts. The - * wrapper needs `spawn()` because it is not a Node script, but it takes the same `'ipc'` stdio - * contract and the process it execs into inherits the channel, so the readiness handshake is - * unchanged. Do not read the daemon's PID off the returned child; the daemon reports its own - * (see daemon-ready-identity.ts). - * - * Both arms go through the shared child-process chokepoint (`src/shared/child-process`), which - * is what every caller outside that directory must use — `forkProcess` for the Node-module arm, - * `spawnProcess` for the program arm. - */ +/** Preserve detached lifetime and readiness IPC, including the optional sibling systemd scope. */ export function spawnDaemonChildProcess( options: DaemonChildSpawnOptions, useDurableScope: boolean @@ -59,13 +50,14 @@ export function spawnDaemonChildProcess( const { forkEntryPath, relocatedExecPath, userDataPath, launchNonce } = options const scriptArgs = buildDaemonScriptArgs(options) const usesBun = Boolean( - process.versions.bun && (!relocatedExecPath || relocatedExecPath === process.execPath) + options.bunRuntime || + (process.versions.bun && (!relocatedExecPath || relocatedExecPath === process.execPath)) ) - // Why: run as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper. + // Desktop launches select Bun explicitly; orcad forks its current Bun runtime. const daemonEnv: NodeJS.ProcessEnv = { ...process.env, ELECTRON_RUN_AS_NODE: '1', - // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. + // Detached daemons need shell rcfiles outside swept temporary directories. ORCA_USER_DATA_PATH: userDataPath } if (usesBun) { @@ -74,6 +66,15 @@ export function spawnDaemonChildProcess( delete daemonEnv.NODE_PATH delete daemonEnv.BUN_OPTIONS } + if (options.bunRuntime) { + delete daemonEnv.BUN_CONPTY_LIBRARY + if (process.platform === 'win32') { + if (!options.conptyLibraryPath || !win32.isAbsolute(options.conptyLibraryPath)) { + throw new Error('Windows Bun daemon ConPTY library path must be absolute') + } + daemonEnv.BUN_CONPTY_LIBRARY = options.conptyLibraryPath + } + } // Why cwd: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted. // Why detached/stdio: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1. const childOptions: Pick = { @@ -81,6 +82,17 @@ export function spawnDaemonChildProcess( detached: true, stdio: ['ignore', 'ignore', 'pipe', 'ipc'] } + if (options.bunRuntime && !relocatedExecPath) { + throw new Error('Bun daemon runtime path is missing') + } + if (!useDurableScope && options.bunRuntime && relocatedExecPath) { + return spawnProcess({ + ...childOptions, + program: relocatedExecPath, + args: [...bunOwnedRuntimeArgs(), forkEntryPath, ...scriptArgs], + env: daemonEnv + }) + } if (!useDurableScope) { return forkProcess({ ...childOptions, diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 80a77fe5f884..0f359eb8278c 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -13,7 +13,7 @@ import { terminateLaunchedDaemonChild } from './daemon-launched-child' import { getDaemonEntryPath, probeDaemonSocket as probeSocket } from './daemon-launch-paths' -import { materializeRelocatedDaemonHost } from './daemon-host-relocation' +import { resolveDesktopDaemonBunRuntime, type DaemonBunRuntime } from './daemon-bun-runtime' import { DAEMON_RECOVERY_BUDGET_MS, daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget' import { cleanupDaemonForProtocol } from './daemon-protocol-cleanup' import { @@ -38,7 +38,7 @@ export function attributeNextDaemonReplacement(reason: DaemonReplaceReason): voi function createPreservedDaemonHandle( runtimeDir: string, protocolVersion = PROTOCOL_VERSION, - mode?: 'degraded-new-pty-fallback' + mode?: 'fresh-spawns-unavailable' ): DaemonProcessHandle { const handle: DaemonProcessHandle = { adopted: true, @@ -87,7 +87,7 @@ export function createOutOfProcessLauncher( adoptionClient = null } const preserveDaemon = async ( - mode?: 'degraded-new-pty-fallback' + mode?: 'fresh-spawns-unavailable' ): Promise => { const connectedClient = adoptionClient ?? undefined adoptionClient = null @@ -100,6 +100,7 @@ export function createOutOfProcessLauncher( pidPath ) } + let bunHost: DaemonBunRuntime | null = null try { const preservedHandle = await prepareDaemonReplacement({ runtimeDir, @@ -117,16 +118,17 @@ export function createOutOfProcessLauncher( } const userDataPath = getAppEnvironment().getPath('userData') - // Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host. - const relocatedHost = materializeRelocatedDaemonHost() - // Fork the relocated entry when available; otherwise the install-dir entry. - const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath + // Resolve only after adoption: an existing owner must not depend on new runtime files. + bunHost = await resolveDesktopDaemonBunRuntime() + // Windows Bun artifacts are immutable copies outside the installer kill zone. + const forkEntryPath = bunHost?.entryPath ?? entryPath let launched try { launched = await launchDaemonChild({ entryPath, forkEntryPath, - relocatedExecPath: relocatedHost?.execPath, + relocatedExecPath: bunHost?.execPath, + ...(bunHost ? { bunRuntime: true, conptyLibraryPath: bunHost.conptyLibraryPath } : {}), userDataPath, socketPath, tokenPath, @@ -140,7 +142,7 @@ export function createOutOfProcessLauncher( } // Why adopt rather than retry: another daemon proved it owns the endpoint and is // answering on it. Forking again would lose the same race, and reporting a startup - // failure strands this app on local non-persistent PTYs beside a healthy daemon. + // failure blocks new terminals beside a healthy daemon. console.warn( '[daemon] Endpoint was taken by another daemon during startup — adopting it instead' ) @@ -196,27 +198,24 @@ export function createOutOfProcessLauncher( } } catch (error) { releaseAdoptionClient() - // Why: the launcher may now fork onto an endpoint it could not classify, because the - // publisher is the real guard — and that guard works by refusing to overwrite what it - // cannot prove dead, so the child exits instead of splitting the brain. Correct, but - // giving up here costs the user every persistent session for the whole run. Something - // answering the endpoint now is a daemon worth adopting, not a reason to fall back to - // local PTYs. + // A failed replacement may have lost to another live owner; preserve it before reporting failure. // Why unbudgeted: the recovery deadline bounds the adopt-or-replace decision, and this runs // after it — past the kill, the fork and the lease. Clamping to the remainder yields a 1ms // probe that loses to its own timer against a live socket, turning the rescue into the total // daemon loss it exists to prevent. if (await probeSocket(socketPath)) { console.warn( - '[daemon] DEGRADED MODE: adopting the daemon that owns the endpoint after a replacement could not publish onto it. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).' + '[daemon] DEGRADED MODE: adopting the daemon that owns the endpoint after a replacement could not publish onto it. Existing sessions keep their owner; new terminals require a successful terminal-service health check. Retry after the service recovers.' ) try { - return await preserveDaemon('degraded-new-pty-fallback') + return await preserveDaemon('fresh-spawns-unavailable') } catch { // It stopped answering between the probe and the adoption; report the launch failure. } } throw error + } finally { + bunHost?.releaseLaunchPin?.() } } } diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index c1d73d95a277..abef69471631 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -1,4 +1,4 @@ -import { getLocalPtyProvider, rebindLocalProviderListeners } from '../ipc/pty' +import { rebindLocalProviderListeners } from '../ipc/pty' import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys @@ -22,8 +22,7 @@ import { createOutOfProcessLauncher } from './daemon-out-of-process-launcher' import type { DaemonProvider } from './daemon-provider-routing' -import { installDaemonProvider } from './daemon-provider-state' -import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { getDaemonProvider, installDaemonProvider } from './daemon-provider-state' import { trackDaemonAdopted } from './daemon-adoption-telemetry-event' import { readDaemonPidRecord } from './daemon-endpoint-incarnation' import { trackDaemonRetired } from './daemon-lifecycle-event' @@ -44,7 +43,38 @@ function logDaemonMilestone(event: string, details: Record = {} } } -export async function initDaemonPtyProvider( +let pendingInitialization: Promise | null = null +let initializationOptions: { macosLoginSessionWatch?: boolean } = {} + +export function initDaemonPtyProvider( + signal?: AbortSignal, + options: { macosLoginSessionWatch?: boolean } = {} +): Promise { + if (pendingInitialization) { + return pendingInitialization + } + initializationOptions = { ...options } + pendingInitialization = initializeDaemonPtyProvider(signal, options).finally(() => { + pendingInitialization = null + }) + return pendingInitialization +} + +export async function retryDaemonPtyProvider(): Promise { + await pendingInitialization?.catch(() => {}) + const provider = getDaemonProvider() + if (provider) { + if (provider instanceof DaemonPtyRouter && !(await provider.recoverFreshSpawnRouting(true))) { + throw new Error( + 'Terminal service is still unable to start new terminals. Existing terminals remain running.' + ) + } + return + } + await initDaemonPtyProvider(undefined, initializationOptions) +} + +async function initializeDaemonPtyProvider( signal?: AbortSignal, options: { macosLoginSessionWatch?: boolean } = {} ): Promise { @@ -68,7 +98,7 @@ export async function initDaemonPtyProvider( const launchMode = newSpawner.getHandle()?.mode logDaemonMilestone('daemon-current-ready') if (signal?.aborted) { - // Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive). + // Explicit cancellation releases adoption without installing a provider or killing live sessions. const abortedStartupAdapter = new DaemonPtyAdapter({ socketPath: info.socketPath, tokenPath: info.tokenPath, @@ -120,28 +150,23 @@ export async function initDaemonPtyProvider( legacyAdapters = await createLegacyDaemonAdapters(runtimeDir) routedAdapter = - launchMode === 'degraded-new-pty-fallback' - ? new DegradedDaemonPtyProvider({ + legacyAdapters.length > 0 || launchMode === 'fresh-spawns-unavailable' + ? new DaemonPtyRouter({ current: newAdapter, legacy: legacyAdapters, - fallback: getLocalPtyProvider(), - probeCurrentDaemonSpawn: async () => - (await checkDaemonHealth(info.socketPath, info.tokenPath)) === 'healthy' + ...(launchMode === 'fresh-spawns-unavailable' + ? { + probeFreshSpawn: async () => + (await checkDaemonHealth(info.socketPath, info.tokenPath)) === 'healthy' + } + : {}) }) - : legacyAdapters.length > 0 - ? new DaemonPtyRouter({ - current: newAdapter, - legacy: legacyAdapters - }) - : newAdapter - if (routedAdapter instanceof DegradedDaemonPtyProvider) { - // Why: preserved daemon can't create fresh terminals; discover its live session ids so only they route to it (fresh panes fall back locally). - await routedAdapter.discoverDaemonSessions() - } else if (routedAdapter instanceof DaemonPtyRouter) { + : newAdapter + if (routedAdapter instanceof DaemonPtyRouter) { await routedAdapter.discoverLegacySessions() } if (signal?.aborted) { - // Why: same late-swap guard after legacy discovery; release uninstalled adapter leases without killing live sessions. + // Cancellation can also arrive while discovering legacy sessions. await routedAdapter.disconnectOnly() return } @@ -196,10 +221,7 @@ async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise< return } try { - const adapters = - provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider - ? provider.getAllAdapters() - : [provider] + const adapters = provider instanceof DaemonPtyRouter ? provider.getAllAdapters() : [provider] const results = await Promise.allSettled(adapters.map((entry) => entry.listSessions())) if (results.some((result) => result.status === 'rejected')) { console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed') diff --git a/src/main/daemon/daemon-provider-restart.ts b/src/main/daemon/daemon-provider-restart.ts index 7cc2ffa7fd1e..c217c11bccd5 100644 --- a/src/main/daemon/daemon-provider-restart.ts +++ b/src/main/daemon/daemon-provider-restart.ts @@ -1,5 +1,4 @@ import { rebindLocalProviderListeners, unbindLocalProviderListeners } from '../ipc/pty' -import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { cleanupFailedDaemonAdoption, releaseDaemonAdoptionLease, @@ -53,19 +52,12 @@ async function runRestartDaemon(): Promise { const legacyAdapters = getLegacyDaemonAdapters(currentAdapter) // Step 1: synthesize pty:exit for every active session BEFORE teardown — the daemon's shutdown path never fans onExit to clients (session.ts:246-252), so the renderer would otherwise never see exits. - const fallbackKilledCount = - currentAdapter instanceof DegradedDaemonPtyProvider - ? await currentAdapter.shutdownFallbackSessions() - : 0 const currentDaemonSessionIds = - currentAdapter instanceof DegradedDaemonPtyProvider - ? currentAdapter.getCurrentDaemonSessionIds() - : [] - const killedCount = - new Set([...currentOnly.getActiveSessionIds(), ...currentDaemonSessionIds]).size + - fallbackKilledCount + currentAdapter instanceof DaemonPtyRouter ? currentAdapter.getCurrentDaemonSessionIds() : [] + const killedCount = new Set([...currentOnly.getActiveSessionIds(), ...currentDaemonSessionIds]) + .size currentOnly.fanoutSyntheticExits(-1) - if (currentAdapter instanceof DegradedDaemonPtyProvider) { + if (currentAdapter instanceof DaemonPtyRouter) { currentAdapter.fanoutCurrentDaemonSyntheticExits(-1) } diff --git a/src/main/daemon/daemon-provider-routing.ts b/src/main/daemon/daemon-provider-routing.ts index 0198bf2893c7..196533849325 100644 --- a/src/main/daemon/daemon-provider-routing.ts +++ b/src/main/daemon/daemon-provider-routing.ts @@ -1,18 +1,17 @@ -import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' -export type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider +export type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter export function getCurrentDaemonAdapter(provider: DaemonProvider): DaemonPtyAdapter { - if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) { + if (provider instanceof DaemonPtyRouter) { return provider.getCurrentAdapter() } return provider } export function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdapter[] { - if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) { + if (provider instanceof DaemonPtyRouter) { return [...provider.getLegacyAdapters()] } return [] @@ -21,9 +20,5 @@ export function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdap export function disposeProviderSubscriptionsOnly(provider: DaemonProvider): void { if (provider instanceof DaemonPtyRouter) { provider.disposeRouterOnly() - return - } - if (provider instanceof DegradedDaemonPtyProvider) { - provider.disposeProviderOnly() } } diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index 412dda3af83e..68185f7a0910 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -1,6 +1,5 @@ import { readFileSync } from 'node:fs' import { setLocalPtyProvider } from '../ipc/pty' -import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getDaemonRuntimeDir as getRuntimeDir } from './daemon-launch-paths' import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' import type { DaemonProvider } from './daemon-provider-routing' @@ -25,17 +24,9 @@ export function getDaemonSpawner(): DaemonSpawner | null { return spawner } -// Why: a narrow getter (not a raw export) keeps the "swap on restart" invariant in one place (replaceDaemonProvider). -/** - * Whether the installed provider is a daemon that will own FRESH terminals too. - * - * Why not `getDaemonProvider() !== null`: DegradedDaemonPtyProvider routes the daemon's - * EXISTING sessions to the daemon but spawns new ones on the in-process local provider, so - * those die with this process. A host that answered "I can recover persistent local PTYs" - * from that state would be advertising recovery for terminals that cannot be recovered. - */ +/** Existing sessions can remain attached while an unhealthy service refuses new terminals. */ export function daemonOwnsFreshPersistentPtys(): boolean { - return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider) + return adapter !== null && !(adapter instanceof DaemonPtyRouter && adapter.freshSpawnsUnavailable) } /** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */ @@ -100,10 +91,7 @@ export async function listLiveDaemonPtyIds(): Promise { if (!adapter) { return null } - const adapters = - adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider - ? adapter.getAllAdapters() - : [adapter] + const adapters = adapter instanceof DaemonPtyRouter ? adapter.getAllAdapters() : [adapter] const inventories = await Promise.allSettled( adapters.map((daemonAdapter) => daemonAdapter.listProcesses()) ) diff --git a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts index 1d82ef70b7ba..bf7c40f162e3 100644 --- a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts +++ b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts @@ -69,6 +69,13 @@ export class DaemonPtyAdapterSubscriptionFanout { } } + publishExit(payload: DaemonPtyRouterExitEvent): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- listeners may unsubscribe during exit delivery + for (const listener of [...this.exitListeners]) { + listener(payload) + } + } + dispose(): void { for (const unsubscribe of this.unsubscribers.splice(0)) { unsubscribe() diff --git a/src/main/daemon/daemon-pty-connection-lifecycle.ts b/src/main/daemon/daemon-pty-connection-lifecycle.ts index fa8153ad47a7..cce48777e987 100644 --- a/src/main/daemon/daemon-pty-connection-lifecycle.ts +++ b/src/main/daemon/daemon-pty-connection-lifecycle.ts @@ -53,7 +53,9 @@ export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscri this.getSizeUnsupported = false } this.lastAuthenticatedIdentity = { ...current } - this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) + this.exactDaemonIncarnation = this.guest + ? null + : exactDaemonIncarnationForPidRecord(current, this.pidRecord) if (!previous) { return } @@ -123,6 +125,10 @@ export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscri additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], endpointGoneProof?: 'windows_named_pipe_missing' ): void { + // Guest PIDs and endpoints have no meaning in the desktop OS namespace. + if (this.guest) { + return + } void this.resolveExactDaemonIncarnation(exactIncarnation) .then((resolvedIncarnation) => { this.cacheExactDaemonIncarnation(resolvedIncarnation) @@ -136,6 +142,9 @@ export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscri } protected publishAuditObservation(observation: DaemonAuditObservation): void { + if (this.guest) { + return + } this.lastAuditObservation = observation this.trackAuditEligibility(observation) notifyDaemonAuditListeners(this.auditObservationListeners, observation) diff --git a/src/main/daemon/daemon-pty-daemon-recovery.ts b/src/main/daemon/daemon-pty-daemon-recovery.ts index 374c39d1c214..ce1c91f4b59d 100644 --- a/src/main/daemon/daemon-pty-daemon-recovery.ts +++ b/src/main/daemon/daemon-pty-daemon-recovery.ts @@ -12,7 +12,9 @@ import type { PtyBackgroundStreamEvent } from '../providers/types' export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence { // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. protected isRetiredEndpointTokenMissing(): boolean { - return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) + return ( + !this.guest && this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) + ) } protected async withDaemonRetry(fn: () => Promise): Promise { @@ -118,7 +120,7 @@ export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersist } protected async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { - if (!this.respawnFn) { + if (this.guest || !this.respawnFn) { return } @@ -157,7 +159,7 @@ export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersist /** Replace a stale packaged daemon only after its live sessions drain. */ protected async replaceStaleBundleDaemonBeforeNewPty(): Promise { - if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { + if (this.guest || !this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { return } if (!this.staleBundleReplacementPromise) { @@ -212,7 +214,7 @@ export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersist /** Replace a TCC-severed daemon only after its live sessions drain. */ protected async replaceSeveredMacTccDaemonBeforeNewPty(): Promise { // Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS. - if (!this.respawnFn || !this.runtimeDir) { + if (this.guest || !this.respawnFn || !this.runtimeDir) { return } diff --git a/src/main/daemon/daemon-pty-guest-execution.test.ts b/src/main/daemon/daemon-pty-guest-execution.test.ts new file mode 100644 index 000000000000..604049c7f156 --- /dev/null +++ b/src/main/daemon/daemon-pty-guest-execution.test.ts @@ -0,0 +1,210 @@ +import type { Socket } from 'node:net' +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { connectDaemonSocket } from './daemon-client-socket-connect' +import { + createMockSubprocess, + startDaemonAdapterHarness, + waitFor, + type DaemonAdapterHarness +} from './daemon-pty-adapter-test-harness' +import { WslDaemonPtyProvider } from '../wsl/wsl-daemon-pty-provider' +import { toAppWslPtyId, toRelayWslPtyId } from '../../shared/wsl-pty-id' +import { HistoryReader } from './history-reader' +import type { DaemonPtyRouterDataEvent } from './daemon-pty-router-events' + +const { reportCwd } = vi.hoisted(() => ({ reportCwd: vi.fn() })) +vi.mock('./daemon-adoption-telemetry-event', () => ({ reportDaemonPtyCwdVerdict: reportCwd })) + +const owner = { distro: 'Ubuntu', relayBuildId: 'profile-user-endpoint' } + +describe('guest daemon execution boundary', () => { + let harness: DaemonAdapterHarness + let subprocess: ReturnType + let adapters: DaemonPtyAdapter[] + const spawn = vi.fn() + const connectRoles: string[] = [] + const connections: Socket[] = [] + const guest = () => ({ + distro: owner.distro, + defaultShell: '/guest-only/bin/bash', + defaultCwd: '/home/guest', + profiles: [{ name: 'Guest bash', path: '/guest-only/bin/bash' }], + transport: { + readToken: () => readFileSync(harness.tokenPath, 'utf8').trim(), + connect: async (role: 'control' | 'stream', operation: { timeoutMs: number }) => { + connectRoles.push(role) + const socket = await connectDaemonSocket(harness.socketPath, operation.timeoutMs) + connections.push(socket) + return socket + } + } + }) + function provider(historyPath?: string) { + const adapter = new DaemonPtyAdapter({ guest: guest(), historyPath }) + adapters.push(adapter) + return { adapter, provider: new WslDaemonPtyProvider(owner, adapter) } + } + beforeEach(async () => { + adapters = [] + connectRoles.length = 0 + connections.length = 0 + spawn.mockReset() + reportCwd.mockReset() + subprocess = createMockSubprocess() + harness = await startDaemonAdapterHarness((options) => { + spawn(options) + return subprocess + }) + }) + afterEach(async () => { + adapters.forEach((adapter) => adapter.dispose()) + harness.adapter.dispose() + await harness.server.shutdown() + rmSync(harness.dir, { recursive: true, force: true }) + }) + + it('uses guest defaults and both authenticated channels without desktop paths', async () => { + const { provider: p, adapter } = provider() + const result = await p.spawn({ cols: 80, rows: 24, isNewSession: true }) + expect(result.id).toMatch(/^wsl:/) + expect(result.wslDistro).toBe('Ubuntu') + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/home/guest', + shellOverride: '/guest-only/bin/bash' + }) + ) + expect(connectRoles).toEqual(['control', 'stream']) + expect(await p.getDefaultShell()).toBe('/guest-only/bin/bash') + expect(await p.getProfiles()).toEqual(guest().profiles) + expect(adapter.getLastAuthenticatedDaemonIdentity()).not.toBeNull() + await p.listProcesses() + expect(adapter.getLastAuditObservation()).toBeNull() + expect(reportCwd).not.toHaveBeenCalled() + }) + + it('rejects desktop endpoint mixing and foreign paths before spawning', async () => { + expect(() => new DaemonPtyAdapter({ guest: guest(), tokenPath: '/desktop/token' })).toThrow( + 'cannot use desktop' + ) + const { provider: p } = provider() + await expect(p.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\guest' })).rejects.toThrow( + 'selected distro' + ) + expect(spawn).not.toHaveBeenCalled() + }) + + it('preserves snapshot authority, event coordinates and owner identity across detach', async () => { + const historyPath = join(harness.dir, 'guest-history') + const { provider: first, adapter } = provider(historyPath) + const result = await first.spawn({ cols: 91, rows: 31, isNewSession: true }) + const events: DaemonPtyRouterDataEvent[] = [] + const sourceEvents: DaemonPtyRouterDataEvent[] = [] + adapter.onData((event) => sourceEvents.push(event)) + first.onData((event) => events.push(event)) + const text = `IMPORTANT HEADER\r\n${'\rprogress'.repeat(14000)}` + subprocess._simulateData(text) + await waitFor(() => events.length > 0) + const before = await first.getBufferSnapshot(result.id) + expect(before).toMatchObject({ + data: expect.stringContaining('IMPORTANT HEADER'), + cols: 91, + rows: 31, + seq: text.length, + source: 'headless' + }) + expect(events.every((event) => event.id === result.id)).toBe(true) + expect(events).toEqual(sourceEvents.map((event) => ({ ...event, id: result.id }))) + expect(first.canProvideAuthoritativeBufferSnapshot(result.id)).toBe(true) + await first.disconnectOnly() + expect(subprocess.kill).not.toHaveBeenCalled() + expect(await first.hasChildProcesses(result.id)).toBe(true) + expect(await first.probePtyLiveness(result.id)).toBeNull() + expect( + await new HistoryReader(historyPath).detectColdRestoreState( + toRelayWslPtyId(owner, result.id), + { wslDistro: owner.distro } + ) + ).toMatchObject({ + status: 'restored', + restoreInfo: { cols: 91, rows: 31, cwd: '/home/guest' } + }) + const { provider: second } = provider(historyPath) + const attached = await second.spawn({ + cols: 91, + rows: 31, + sessionId: result.id, + attachOnly: true + }) + expect(attached.id).toBe(result.id) + expect(spawn).toHaveBeenCalledTimes(1) + expect(await second.getBufferSnapshot(result.id)).toEqual({ ...before, cwd: '/home/guest' }) + expect(await second.listProcesses()).toEqual([ + expect.objectContaining({ id: result.id, wslDistro: 'Ubuntu' }) + ]) + }) + + it('reports connector loss for every pane without declaring exit or replacing shells', async () => { + const { provider: p } = provider() + const first = await p.spawn({ cols: 80, rows: 24, isNewSession: true }) + const second = await p.spawn({ cols: 80, rows: 24, isNewSession: true }) + const unavailable = vi.fn() + const exited = vi.fn() + p.onWriteUnavailable(unavailable) + p.onExit(exited) + connections[0]!.destroy() + await waitFor(() => unavailable.mock.calls.length === 2) + expect(unavailable.mock.calls.map(([event]) => event.id).sort()).toEqual( + [first.id, second.id].sort() + ) + expect(exited).not.toHaveBeenCalled() + expect(subprocess.kill).not.toHaveBeenCalled() + const attached = await p.spawn({ + cols: 80, + rows: 24, + sessionId: first.id, + attachOnly: true + }) + expect(attached.id).toBe(first.id) + expect(spawn).toHaveBeenCalledTimes(2) + await p.disconnectOnly() + expect(unavailable).toHaveBeenCalledTimes(2) + }) + + it('publishes guest exit identity without losing the incarnation', async () => { + const { provider: p } = provider() + const result = await p.spawn({ cols: 80, rows: 24, isNewSession: true }) + const exited = vi.fn() + p.onExit(exited) + subprocess._simulateExit(17) + await waitFor(() => exited.mock.calls.length === 1) + expect(exited).toHaveBeenCalledWith( + expect.objectContaining({ + id: result.id, + code: 17, + incarnationId: result.incarnationId + }) + ) + expect(p.hasPty(result.id)).toBe(false) + }) + + it('rejects raw and foreign-owner attach IDs without contacting the owner', async () => { + const { provider: p } = provider() + await expect( + p.spawn({ cols: 80, rows: 24, sessionId: 'raw', attachOnly: true }) + ).rejects.toThrow('encoded WSL') + await expect( + p.spawn({ + cols: 80, + rows: 24, + attachOnly: true, + sessionId: toAppWslPtyId({ ...owner, relayBuildId: 'other-user' }, 'pty') + }) + ).rejects.toThrow('different distro or relay build') + expect(connectRoles).toEqual([]) + }) +}) diff --git a/src/main/daemon/daemon-pty-router-test-adapter.ts b/src/main/daemon/daemon-pty-router-test-adapter.ts new file mode 100644 index 000000000000..f826eb281f83 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-test-adapter.ts @@ -0,0 +1,164 @@ +import { vi } from 'vitest' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION +} from './types' +import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +type AdapterMock = DaemonPtyAdapter & { + emitData: (id: string, data: string, sequenceChars?: number) => void + emitBackground: (event: PtyBackgroundStreamEvent) => void + emitExit: (id: string, code: number, incarnationId?: string) => void + emitIdentityChange: () => void + triggerWriteUnavailable: (id: string) => void +} + +export function createAdapter( + label: string, + sessions: string[] = [], + reconcileResult?: { alive: string[]; killed: string[] }, + protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION +): AdapterMock { + const writes: { id: string; data: string }[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] + const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] + const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = + [] + const identityChangeListeners: (() => void)[] = [] + return { + protocolVersion, + supportsGitCredentialGuardHost: () => + protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + supportsAgentSessionClaims: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + supportsAgentSessionCreateOperations: () => + protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + providesAgentSessionOwnerListings: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + canProvideAuthoritativeBufferSnapshot: () => + protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, + spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { + const id = opts.sessionId ?? `${label}-new` + sessions.push(id) + return { id } + }), + listProcesses: vi.fn(async () => + sessions.map((id) => ({ + id, + cwd: '', + title: label + })) + ), + hasPty: vi.fn((id: string) => sessions.includes(id)), + probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), + write: vi.fn((id: string, data: string) => { + writes.push({ id, data }) + }), + writeWithSettlement: vi.fn(settledWriteStub()), + resize: vi.fn(), + setPtyBackgrounded: vi.fn(), + getBufferSnapshot: vi.fn(async () => null), + shutdown: vi.fn(async (id: string) => { + const idx = sessions.indexOf(id) + if (idx !== -1) { + sessions.splice(idx, 1) + } + }), + attach: vi.fn(async () => {}), + sendSignal: vi.fn(async () => {}), + getCwd: vi.fn(async () => ''), + getInitialCwd: vi.fn(async () => ''), + clearBuffer: vi.fn(async () => {}), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(async () => false), + getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), + serialize: vi.fn(async () => '{}'), + revive: vi.fn(async () => {}), + getDefaultShell: vi.fn(async () => '/bin/zsh'), + getProfiles: vi.fn(async () => []), + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } + } + } + ), + onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { + backgroundListeners.push(callback) + return () => { + const idx = backgroundListeners.indexOf(callback) + if (idx !== -1) { + backgroundListeners.splice(idx, 1) + } + } + }), + onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { + writeUnavailableListeners.push(callback) + return () => { + const idx = writeUnavailableListeners.indexOf(callback) + if (idx !== -1) { + writeUnavailableListeners.splice(idx, 1) + } + } + }), + onExit: vi.fn( + (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { + exitListeners.push(callback) + return () => { + const idx = exitListeners.indexOf(callback) + if (idx !== -1) { + exitListeners.splice(idx, 1) + } + } + } + ), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityChangeListeners.push(callback) + return () => { + const idx = identityChangeListeners.indexOf(callback) + if (idx !== -1) { + identityChangeListeners.splice(idx, 1) + } + } + }), + ackColdRestore: vi.fn(), + clearTombstone: vi.fn(), + reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), + dispose: vi.fn(), + disconnectOnly: vi.fn(async () => {}), + emitData: (id: string, data: string, sequenceChars?: number) => { + for (const listener of dataListeners) { + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) + } + }, + emitBackground: (event: PtyBackgroundStreamEvent) => { + for (const listener of backgroundListeners) { + listener(event) + } + }, + emitExit: (id: string, code: number, incarnationId?: string) => { + for (const listener of exitListeners) { + listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) + } + }, + emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), + triggerWriteUnavailable: (id: string) => { + for (const listener of writeUnavailableListeners) { + listener({ id }) + } + }, + _writes: writes + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test adapter supplies every method exercised by router tests. + } as unknown as AdapterMock +} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 61db990b21c6..63b647454e57 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,29 +1,20 @@ +import { createAdapter } from './daemon-pty-router-test-adapter' import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub' -import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { stubWriteSettlement } from '../providers/settled-pty-write-stub' +import type { PtySpawnResult } from '../providers/types' import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION } from './types' import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, PROTOCOL_VERSION, - SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' -type AdapterMock = DaemonPtyAdapter & { - emitData: (id: string, data: string, sequenceChars?: number) => void - emitBackground: (event: PtyBackgroundStreamEvent) => void - emitExit: (id: string, code: number, incarnationId?: string) => void - emitIdentityChange: () => void - triggerWriteUnavailable: (id: string) => void -} - const LARGE_RECONCILE_SESSION_COUNT = 150_000 function buildSessionIds(prefix: string, count: number): string[] { @@ -34,152 +25,6 @@ function buildSessionIds(prefix: string, count: number): string[] { return ids } -function createAdapter( - label: string, - sessions: string[] = [], - reconcileResult?: { alive: string[]; killed: string[] }, - protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION -): AdapterMock { - const writes: { id: string; data: string }[] = [] - const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = - [] - const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = - [] - const identityChangeListeners: (() => void)[] = [] - return { - protocolVersion, - supportsGitCredentialGuardHost: () => - protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, - supportsAgentSessionClaims: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - supportsAgentSessionCreateOperations: () => - protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - providesAgentSessionOwnerListings: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - canProvideAuthoritativeBufferSnapshot: () => - protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { - const id = opts.sessionId ?? `${label}-new` - sessions.push(id) - return { id } - }), - listProcesses: vi.fn(async () => - sessions.map((id) => ({ - id, - cwd: '', - title: label - })) - ), - hasPty: vi.fn((id: string) => sessions.includes(id)), - probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), - write: vi.fn((id: string, data: string) => { - writes.push({ id, data }) - }), - writeWithSettlement: vi.fn(settledWriteStub()), - resize: vi.fn(), - setPtyBackgrounded: vi.fn(), - getBufferSnapshot: vi.fn(async () => null), - shutdown: vi.fn(async (id: string) => { - const idx = sessions.indexOf(id) - if (idx !== -1) { - sessions.splice(idx, 1) - } - }), - attach: vi.fn(async () => {}), - sendSignal: vi.fn(async () => {}), - getCwd: vi.fn(async () => ''), - getInitialCwd: vi.fn(async () => ''), - clearBuffer: vi.fn(async () => {}), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(async () => false), - getForegroundProcess: vi.fn(async () => null), - inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), - confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), - serialize: vi.fn(async () => '{}'), - revive: vi.fn(async () => {}), - getDefaultShell: vi.fn(async () => '/bin/zsh'), - getProfiles: vi.fn(async () => []), - onData: vi.fn( - (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) - } - } - } - ), - onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { - backgroundListeners.push(callback) - return () => { - const idx = backgroundListeners.indexOf(callback) - if (idx !== -1) { - backgroundListeners.splice(idx, 1) - } - } - }), - onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { - writeUnavailableListeners.push(callback) - return () => { - const idx = writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - writeUnavailableListeners.splice(idx, 1) - } - } - }), - onExit: vi.fn( - (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { - exitListeners.push(callback) - return () => { - const idx = exitListeners.indexOf(callback) - if (idx !== -1) { - exitListeners.splice(idx, 1) - } - } - } - ), - onDaemonIdentityChanged: vi.fn((callback: () => void) => { - identityChangeListeners.push(callback) - return () => { - const idx = identityChangeListeners.indexOf(callback) - if (idx !== -1) { - identityChangeListeners.splice(idx, 1) - } - } - }), - ackColdRestore: vi.fn(), - clearTombstone: vi.fn(), - reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), - dispose: vi.fn(), - disconnectOnly: vi.fn(async () => {}), - emitData: (id: string, data: string, sequenceChars?: number) => { - for (const listener of dataListeners) { - listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) - } - }, - emitBackground: (event: PtyBackgroundStreamEvent) => { - for (const listener of backgroundListeners) { - listener(event) - } - }, - emitExit: (id: string, code: number, incarnationId?: string) => { - for (const listener of exitListeners) { - listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) - } - }, - emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), - triggerWriteUnavailable: (id: string) => { - for (const listener of writeUnavailableListeners) { - listener({ id }) - } - }, - _writes: writes - } as unknown as AdapterMock -} - it('forwards dead-endpoint write-unavailable signals from every routed adapter', () => { // Why revert-sensitive: main subscribes on the ROUTED provider, so if the router // does not forward this the STA-2373 fan-out never reaches the renderer and only @@ -924,3 +769,50 @@ describe('DaemonPtyRouter', () => { }) }) }) + +describe('DaemonPtyRouter fresh spawn admission', () => { + it('preserves existing routes while refusing new terminals and coalesces recovery', async () => { + const current = createAdapter('current', ['current-existing']) + const legacy = createAdapter('legacy', ['legacy-existing']) + let finishProbe!: (healthy: boolean) => void + const probe = vi.fn( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + const router = new DaemonPtyRouter({ current, legacy: [legacy], probeFreshSpawn: probe }) + await router.discoverLegacySessions() + router.write('legacy-existing', 'hello') + router.resize('current-existing', 90, 30) + expect(legacy.write).toHaveBeenCalledWith('legacy-existing', 'hello') + expect(current.resize).toHaveBeenCalledWith('current-existing', 90, 30) + const first = router.spawn({ cols: 80, rows: 24 }) + const second = router.spawn({ cols: 80, rows: 24 }) + await Promise.resolve() + expect(probe).toHaveBeenCalledOnce() + finishProbe(false) + await expect(first).rejects.toThrow('Terminal service unavailable') + await expect(second).rejects.toThrow('Terminal service unavailable') + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.shutdown).not.toHaveBeenCalled() + expect(current.shutdown).not.toHaveBeenCalled() + await expect( + router.spawn({ cols: 80, rows: 24, sessionId: 'legacy-existing', attachOnly: true }) + ).resolves.toMatchObject({ id: 'legacy-existing' }) + expect(router.freshSpawnsUnavailable).toBe(true) + router.disposeRouterOnly() + }) + + it('admits new terminals after a successful probe without replacing the owner', async () => { + const current = createAdapter('current') + const probe = vi.fn(async () => true) + const router = new DaemonPtyRouter({ current, legacy: [], probeFreshSpawn: probe }) + await expect(router.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({ id: 'current-new' }) + await router.spawn({ cols: 80, rows: 24 }) + expect(probe).toHaveBeenCalledOnce() + expect(router.freshSpawnsUnavailable).toBe(false) + expect(current.shutdown).not.toHaveBeenCalled() + router.disposeRouterOnly() + }) +}) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2fbfd17a0391..30c998efeeb6 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,3 +1,5 @@ +import { DaemonFreshSpawnAdmission } from './daemon-fresh-spawn-admission' +import { terminalServiceUnavailable } from '../providers/unavailable-pty-provider' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { @@ -21,7 +23,14 @@ export class DaemonPtyRouter implements IPtyProvider { private readonly ownerResolver: DaemonSessionOwnerResolver private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout - constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { + private readonly freshSpawns: DaemonFreshSpawnAdmission + + constructor(opts: { + current: DaemonPtyAdapter + legacy: DaemonPtyAdapter[] + probeFreshSpawn?: () => Promise + }) { + this.freshSpawns = new DaemonFreshSpawnAdmission(opts.probeFreshSpawn ?? null) this.current = opts.current this.legacy = opts.legacy this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters) @@ -38,12 +47,21 @@ export class DaemonPtyRouter implements IPtyProvider { await this.ownerResolver.discoverRoutes() } + get freshSpawnsUnavailable(): boolean { + return this.freshSpawns.unavailable + } + + recoverFreshSpawnRouting = (force = false): Promise => this.freshSpawns.recover(force) + async spawn(opts: PtySpawnOptions): Promise { if (opts.attachOnly && opts.sessionId) { return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) } const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined const target = adapter ?? this.current + if (!adapter && this.freshSpawnsUnavailable && !(await this.recoverFreshSpawnRouting())) { + throw terminalServiceUnavailable() + } const result = await target.spawn(opts) // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. if (!result.exitedBeforeSpawnReply) { @@ -309,6 +327,17 @@ export class DaemonPtyRouter implements IPtyProvider { // (pre-#1323) the legacy list is set once at construction and never mutated, // so returning the internal array by reference is safe for the intended // read-only use. + getCurrentDaemonSessionIds(): string[] { + return [...this.sessionAdapters].filter(([, owner]) => owner === this.current).map(([id]) => id) + } + + fanoutCurrentDaemonSyntheticExits(code: number): void { + for (const id of this.getCurrentDaemonSessionIds()) { + this.ownerResolver.forgetRoute(id) + this.subscriptions.publishExit({ id, code }) + } + } + getCurrentAdapter(): DaemonPtyAdapter { return this.current } diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts index 38480af1489c..da9d63c52877 100644 --- a/src/main/daemon/daemon-pty-runtime-state.ts +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -1,4 +1,4 @@ -import { DaemonClient } from './client' +import { DaemonClient, type DaemonClientOptions } from './client' import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' import type { DaemonAuditContext, @@ -48,10 +48,19 @@ export type SnapshotCheckpointResult = { snapshot: NonNullable | null } +export type GuestDaemonExecution = { + distro: string + transport: NonNullable + defaultShell: string + defaultCwd: string + profiles: readonly { name: string; path: string }[] +} + export type DaemonPtyAdapterOptions = { - socketPath: string - tokenPath: string + socketPath?: string + tokenPath?: string pidPath?: string + guest?: GuestDaemonExecution profileScope?: string protocolVersion?: number historyPath?: string @@ -73,6 +82,7 @@ export type DaemonIdentityChangeEvent = { export abstract class DaemonPtyRuntimeState { readonly protocolVersion: number + protected readonly guest: GuestDaemonExecution | null protected socketPath: string protected tokenPath: string protected pidPath: string | null @@ -149,6 +159,7 @@ export abstract class DaemonPtyRuntimeState { additionalEvidenceSources?: readonly DaemonEvidenceSource[], endpointGoneProof?: 'windows_named_pipe_missing' ): void + protected abstract notifyActiveSessionsWriteUnavailable(): void protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void protected abstract stopCheckpointTimerIfIdle(): void @@ -190,27 +201,42 @@ export abstract class DaemonPtyRuntimeState { constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath - this.pidPath = opts.pidPath ?? null + this.guest = opts.guest ?? null + if ( + this.guest && + (opts.socketPath || + opts.tokenPath || + opts.pidPath || + opts.runtimeDir || + opts.packagedAppVersion) + ) { + throw new Error('Guest daemon execution cannot use desktop endpoint or runtime paths') + } + if (!this.guest && (!opts.socketPath || !opts.tokenPath)) { + throw new Error('Local daemon execution requires socket and token paths') + } + this.socketPath = opts.socketPath ?? '' + this.tokenPath = opts.tokenPath ?? '' + this.pidPath = this.guest ? null : (opts.pidPath ?? null) this.pidRecord = readDaemonPidRecord(this.pidPath) this.auditContext = { protocolGeneration: this.protocolVersion, provider: 'local-daemon', - endpoint: opts.socketPath, - tokenPath: opts.tokenPath, + endpoint: this.socketPath, + tokenPath: this.tokenPath, endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', profileScope: opts.profileScope ?? '' } this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath, + ...(this.guest + ? { transport: this.guest.transport } + : { socketPath: this.socketPath, tokenPath: this.tokenPath }), protocolVersion: opts.protocolVersion }) this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null this.respawnFn = opts.respawn ?? null - this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null + this.runtimeDir = this.guest ? null : (opts.runtimeDir ?? opts.profileScope ?? null) this.packagedAppVersion = opts.packagedAppVersion ?? null this.supportsCheckpoints = this.protocolVersion >= 4 this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 @@ -230,6 +256,9 @@ export abstract class DaemonPtyRuntimeState { } this.pausedProducerSessionIds.clear() this.observeAuditFailure('transport_closed') + if (this.guest && !this.respawnAdoptionClosed) { + this.notifyActiveSessionsWriteUnavailable() + } }) } diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index 610b2e1407ca..0b683a32a245 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -142,7 +142,9 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { cwd: normalizeWslColdRestoreCwd({ recoveredCwd: detected.cwd, - requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), + requestedCwd: + this.initialCwds.get(id) ?? this.guest?.defaultCwd ?? resolveSafePtyDefaultCwd(), + guestExecution: this.guest !== null, wslDistro }) ?? '' } diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index b08c34830b4a..f1afe1705849 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -165,6 +165,9 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti } async getDefaultShell(): Promise { + if (this.guest) { + return this.guest.defaultShell + } if (process.platform === 'win32') { return process.env.COMSPEC || 'powershell.exe' } @@ -172,6 +175,9 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti } async getProfiles(): Promise<{ name: string; path: string }[]> { + if (this.guest) { + return this.guest.profiles.map((profile) => ({ ...profile })) + } if (process.platform === 'win32') { return [ { name: 'PowerShell', path: 'powershell.exe' }, diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 388919dd2e30..8ad9a2e4f67e 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -17,9 +17,10 @@ import { shellReadyMarkerComesFromLineEditor } from '../../shared/shell-ready-ma import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' +import { guestDaemonSpawnOptions } from './daemon-guest-spawn-options' import { resolveWslSessionContext } from './wsl-session-context' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { resolveUnixShellPath } from '../providers/local-pty-utils' +import { resolveUnixShellPath } from '../providers/pty-spawn-validation' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' import { addWslEnvKeys } from '../wsl-env' @@ -63,6 +64,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { + if (this.guest) { + return guestDaemonSpawnOptions(this.guest, opts) + } const wslContext = resolveWslSessionContext({ cwd: opts.cwd, sessionId: opts.sessionId, @@ -117,12 +121,14 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { const emulateLegacyAttachOnly = attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION let sessionId = requestedSessionId - let wslDistro = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - })?.distro + let wslDistro = + this.guest?.distro ?? + resolveWslSessionContext({ + cwd: opts.cwd, + sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + })?.distro let activeSpawnContext: DaemonPtySpawnContext | null = null const freezeHistory = async (): Promise => { if (!this.historyManager) { @@ -167,8 +173,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { cwd: normalizeWslColdRestoreCwd({ recoveredCwd: restoreInfo.cwd, - requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), - wslDistro: recoveryWslDistro + requestedCwd: opts.cwd ?? this.guest?.defaultCwd ?? resolveSafePtyDefaultCwd(), + wslDistro: recoveryWslDistro, + guestExecution: this.guest !== null }) ?? '' } } @@ -213,8 +220,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { let effectiveCols = restoreInfo?.cols ?? opts.cols let effectiveRows = restoreInfo?.rows ?? opts.rows - const effectiveShellPath = - process.platform !== 'win32' && opts.command + const effectiveShellPath = this.guest + ? opts.shellOverride || this.guest.defaultShell + : process.platform !== 'win32' && opts.command ? resolveUnixShellPath(opts.shellOverride || resolvePtyShellPath(opts.env ?? {})) : '' const shellReadySupported = shellPathSupportsPtyStartupBarrier(effectiveShellPath) @@ -252,7 +260,7 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } activeSpawnContext = context const result = await this.createOrAttachSpawn(context, context.historySeedSegments) - if (result.isNew && !attachOnly) { + if (result.isNew && !attachOnly && !this.guest) { // Not awaited: the app-side read behind it can sit on an unanswered macOS folder prompt. void reportDaemonPtyCwdVerdict({ cwd: effectiveCwd, diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index 5e799230fa1f..345805bc2082 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -82,7 +82,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} const incarnationResult = (): Pick | Record => result.incarnationId ? { incarnationId: result.incarnationId } : {} - let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + let providerWslDistro = + this.guest?.distro ?? (result.wslDistro === undefined ? wslDistro : result.wslDistro) // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. wslDistro = providerWslDistro ?? undefined context.wslDistro = wslDistro @@ -151,7 +152,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { if (result.incarnationId) { this.sessionIncarnations.set(sessionId, result.incarnationId) } - providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + providerWslDistro = + this.guest?.distro ?? (result.wslDistro === undefined ? wslDistro : result.wslDistro) wslDistro = providerWslDistro ?? undefined context.wslDistro = wslDistro if (wslDistro) { @@ -288,7 +290,6 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { const isAltScreen = reattachSnapshot.modes.alternateScreen const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences const snapshotFrame = reattachSnapshot.snapshotAnsi - const snapshotPayload = snapshotPrefix + snapshotFrame // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). // Why known `0` is no longer dropped: the pane tracker must be able to tell // "the app negotiated nothing" from "this reattach proved nothing". @@ -302,7 +303,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { ...claimResult(), ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - snapshot: snapshotPayload, + snapshot: snapshotPrefix + snapshotFrame, snapshotCols: reattachSnapshot.cols, snapshotRows: reattachSnapshot.rows, // Why only for an alt frame: normal history remains safe to replay at its capture grid. diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index 4b72070bb425..60c808cc38a2 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rmSync } from 'node:fs' import { basename, join } from 'node:path' -import * as localPtyUtils from '../providers/local-pty-utils' +import * as localPtyUtils from '../providers/pty-spawn-validation' import { createMockSubprocess, startDaemonAdapterHarness, diff --git a/src/main/daemon/daemon-replacement-preflight.ts b/src/main/daemon/daemon-replacement-preflight.ts index 1573755efef8..e445ebc566a3 100644 --- a/src/main/daemon/daemon-replacement-preflight.ts +++ b/src/main/daemon/daemon-replacement-preflight.ts @@ -23,7 +23,7 @@ import { PROTOCOL_VERSION } from './types' // would spin hot for the whole budget. export const WEDGED_DAEMON_GRACE_RETRIES = 11 -type PreserveDaemon = (mode?: 'degraded-new-pty-fallback') => Promise +type PreserveDaemon = (mode?: 'fresh-spawns-unavailable') => Promise type ReplacementPreflightOptions = { runtimeDir: string @@ -183,9 +183,9 @@ export async function prepareDaemonReplacement( if (liveSessionCount !== null && liveSessionCount > 0) { if (health === 'pty-spawn-unhealthy') { console.warn( - `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).` + `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep their owner; new terminals require a successful terminal-service health check. Retry after the service recovers.` ) - return preserveDaemon('degraded-new-pty-fallback') + return preserveDaemon('fresh-spawns-unavailable') } console.warn( `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` @@ -220,10 +220,10 @@ export async function prepareDaemonReplacement( // no daemon at all, and we have just proved something still answers the endpoint — // so adopt it in degraded mode: existing sessions keep working, new PTYs run locally. console.warn( - '[daemon] DEGRADED MODE: adopting a daemon that could not be confirmed stopped. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).' + '[daemon] DEGRADED MODE: adopting a daemon that could not be confirmed stopped. Existing sessions keep their owner; new terminals require a successful terminal-service health check. Retry after the service recovers.' ) try { - return await preserveDaemon('degraded-new-pty-fallback') + return await preserveDaemon('fresh-spawns-unavailable') } catch { // It died between the probe and the adoption; the endpoint is genuinely free now. throw new DaemonEndpointOwnershipError( diff --git a/src/main/daemon/daemon-request-router-transient.test.ts b/src/main/daemon/daemon-request-router-transient.test.ts new file mode 100644 index 000000000000..524cad5a5cb5 --- /dev/null +++ b/src/main/daemon/daemon-request-router-transient.test.ts @@ -0,0 +1,25 @@ +import { expect, it, vi } from 'vitest' +import { DaemonRequestRouter } from './daemon-request-router' + +it('refuses fresh transient probes during retirement but permits owner cleanup', async () => { + const transientRoute = vi.fn(() => ({})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only transient routing is exercised, using these three dependencies exclusively. + const options = { + connections: { get: () => ({ authenticatedPairEstablished: true, streamSocket: {} }) }, + lifecycle: { isAcceptingWork: () => false }, + transientPtys: { route: transientRoute } + } as unknown as ConstructorParameters[0] + const router = new DaemonRequestRouter(options) + await expect( + router.route('owner', { + id: 'create', + type: 'createTransientPty', + payload: { id: 'probe', file: '/tool', args: [], env: {}, cwd: '/tmp', cols: 80, rows: 24 } + }) + ).rejects.toThrow('shutting down') + expect(transientRoute).not.toHaveBeenCalled() + await expect( + router.route('owner', { id: 'close', type: 'closeTransientPty', payload: { id: 'probe' } }) + ).resolves.toEqual({}) + expect(transientRoute).toHaveBeenCalledOnce() +}) diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index ae007366dd02..fe91958c0f3d 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -1,3 +1,4 @@ +import type { DaemonTransientPtys } from './daemon-transient-pty' import { performance } from 'node:perf_hooks' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections' @@ -14,6 +15,7 @@ import type { TerminalHost } from './terminal-host' import { SessionNotFoundError, type DaemonRequest } from './types' type DaemonRequestRouterOptions = { + transientPtys: DaemonTransientPtys host: TerminalHost connections: DaemonClientConnections lifecycle: DaemonServerLifecycle @@ -32,9 +34,17 @@ export class DaemonRequestRouter { async route(clientId: string, request: DaemonRequest): Promise { const client = this.options.connections.get(clientId) + const paired = Boolean(client?.authenticatedPairEstablished && client.streamSocket) switch (request.type) { + case 'createTransientPty': + case 'writeTransientPty': + case 'closeTransientPty': + if (request.type === 'createTransientPty' && !this.options.lifecycle.isAcceptingWork()) { + throw new Error('Terminal service is shutting down') + } + return this.options.transientPtys.route(clientId, request, paired) case 'startHistorySeedTransfer': { - if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + if (!paired) { throw new Error('Daemon client connection is incomplete; reconnect') } return { @@ -144,7 +154,7 @@ export class DaemonRequestRouter { { teardownSnapshot: request.payload.teardownSnapshot === true } ) case 'ping': - return { pong: true } + return this.options.transientPtys.ping() case 'systemResolverHealth': return { health: await readCurrentProcessMacSystemResolverHealth() } case 'ptySpawnHealth': diff --git a/src/main/daemon/daemon-runtime-launch-pin.test.ts b/src/main/daemon/daemon-runtime-launch-pin.test.ts new file mode 100644 index 000000000000..44095dec33c3 --- /dev/null +++ b/src/main/daemon/daemon-runtime-launch-pin.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ + resolve: vi.fn(), + launch: vi.fn(), + lease: vi.fn(), + replace: vi.fn(), + release: vi.fn(), + terminate: vi.fn() +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/profile' }) +})) +vi.mock('./client', () => ({ + DaemonClient: class { + ensureConnectedWithin() { + return Promise.reject(new Error('no incumbent')) + } + disconnect() {} + } +})) +vi.mock('./daemon-endpoint-adoption', () => ({ + DaemonEndpointOwnershipError: class extends Error {}, + holdDaemonAdoptionLease: mocks.lease, + reconcileDaemonPidOwnership: vi.fn() +})) +vi.mock('./daemon-launched-child', () => ({ + DaemonEndpointUnavailableError: class extends Error {}, + launchDaemonChild: mocks.launch, + terminateLaunchedDaemonChild: mocks.terminate +})) +vi.mock('./daemon-launch-paths', () => ({ + getDaemonEntryPath: () => '/entry', + probeDaemonSocket: async () => false +})) +vi.mock('./daemon-bun-runtime', () => ({ resolveDesktopDaemonBunRuntime: mocks.resolve })) +vi.mock('./daemon-protocol-cleanup', () => ({ cleanupDaemonForProtocol: vi.fn() })) +vi.mock('./daemon-spawner', () => ({ + getDaemonPidPath: () => '/pid', + unlinkOwnedDaemonPidFile: vi.fn() +})) +vi.mock('./daemon-replacement-preflight', () => ({ prepareDaemonReplacement: mocks.replace })) +import { createOutOfProcessLauncher } from './daemon-out-of-process-launcher' + +beforeEach(() => { + vi.resetAllMocks() + mocks.replace.mockResolvedValue(null) + mocks.resolve.mockResolvedValue({ + execPath: '/bun', + entryPath: '/entry', + releaseLaunchPin: mocks.release + }) + mocks.launch.mockResolvedValue({ + child: { once: vi.fn(), off: vi.fn(), exitCode: null, signalCode: null }, + identity: { pid: 10 } + }) +}) +afterEach(() => { + vi.restoreAllMocks() +}) +describe('daemon runtime launch pin', () => { + it('holds the pin through launch and authenticated readiness', async () => { + mocks.launch.mockImplementation(async () => { + expect(mocks.release).not.toHaveBeenCalled() + return { child: {}, identity: { pid: 10 } } + }) + mocks.lease.mockImplementation(async () => { + expect(mocks.release).not.toHaveBeenCalled() + return { shutdown: vi.fn() } + }) + await createOutOfProcessLauncher('/profile')('/socket', '/token') + expect(mocks.release).toHaveBeenCalledTimes(1) + }) + it.each(['launch', 'lease'])('releases after %s failure', async (phase) => { + mocks[phase === 'launch' ? 'launch' : 'lease'].mockRejectedValue(new Error('failed')) + await expect(createOutOfProcessLauncher('/profile')('/socket', '/token')).rejects.toThrow( + 'failed' + ) + expect(mocks.release).toHaveBeenCalledTimes(1) + }) + it('never resolves or pins runtime files when adopting a live daemon', async () => { + const handle = { adopted: true, shutdown: vi.fn() } + mocks.replace.mockResolvedValue(handle) + expect(await createOutOfProcessLauncher('/profile')('/socket', '/token')).toBe(handle) + expect(mocks.resolve).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index ab22af241001..4c4bf401041e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,3 +1,4 @@ +import { DaemonTransientPtys } from './daemon-transient-pty' import { randomUUID } from 'node:crypto' import type { Socket } from 'node:net' import { setImmediate as waitForImmediate } from 'node:timers/promises' @@ -33,6 +34,20 @@ export class DaemonServer { private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 private readonly log: DaemonFileLog + private readonly transientPtys = new DaemonTransientPtys((owner, event) => { + const socket = this.connections.get(owner)?.streamSocket + if (!socket || socket.destroyed || socket.writableLength > 256 * 1024) { + socket?.destroy() + return false + } + try { + socket.write(encodeNdjson(event)) + return true + } catch { + socket.destroy() + return false + } + }) private readonly host: TerminalHost private readonly transientFactRelay: BackgroundTransientFactRelay private readonly streamDataBatcher: DaemonStreamDataBatcher @@ -134,17 +149,20 @@ export class DaemonServer { onControlRequest: (socket, clientId, request) => void this.handleRequest(socket, clientId, request), onControlReplaced: (clientId) => { + this.transientPtys.disconnect(clientId) this.preparations.cancelForClient(clientId) this.historySeedTransfers.clearOwner(clientId) this.streamDataBatcher.clear(clientId) }, onClientDisconnected: (clientId) => { + this.transientPtys.disconnect(clientId) this.preparations.cancelForClient(clientId) this.historySeedTransfers.clearOwner(clientId) this.streamDataBatcher.clear(clientId) this.attachments.detachClientSessions(clientId) }, onStreamDisconnected: (clientId) => { + this.transientPtys.disconnect(clientId) this.preparations.cancelForClient(clientId) this.streamDataBatcher.clear(clientId) this.attachments.detachClientSessions(clientId) @@ -197,6 +215,7 @@ export class DaemonServer { reevaluateIdleShutdown: () => this.lifecycle.reevaluateIdleShutdown() }) this.requestRouter = new DaemonRequestRouter({ + transientPtys: this.transientPtys, host: this.host, connections: this.connections, lifecycle: this.lifecycle, @@ -245,6 +264,7 @@ export class DaemonServer { private async disposeResources(): Promise { this.endpoint.stopOwnershipWatch() this.stopStreamBacklogProbe() + this.transientPtys.dispose() this.transientFactRelay.dispose() this.preparations.cancelAll() try { diff --git a/src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts b/src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts index 2fb980f18708..4590e8fc57f3 100644 --- a/src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts +++ b/src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { tmpdir } from 'node:os' import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' -import { LocalPtyProvider } from '../providers/local-pty-provider' +import { DaemonPtyRouter } from './daemon-pty-router' import type { PtyProcessInfo } from '../providers/types' import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' @@ -59,17 +58,14 @@ function adapterFixture(label: string, pid: number) { async function fixture() { const current = adapterFixture('current', 999_999_997) const legacy = adapterFixture('legacy', 999_999_998) - const fallback = new LocalPtyProvider() - vi.spyOn(fallback, 'listProcesses').mockResolvedValue([]) - const provider = new DegradedDaemonPtyProvider({ + const provider = new DaemonPtyRouter({ current: current.adapter, - legacy: [legacy.adapter], - fallback + legacy: [legacy.adapter] }) cleanups.push(() => provider.dispose()) await current.publishIdentity(0) await legacy.publishIdentity(0) - const recovery = provider['ownerRecovery'] + const recovery = provider['ownerResolver'] return { current, legacy, provider, recovery } } @@ -78,44 +74,40 @@ function processInfo(id: string, incarnationId: string): PtyProcessInfo { } describe('shared daemon owner incarnation retirement', () => { - it('releases both private indexes after repeated authenticated daemon replacements', async () => { + it('releases the owner index after repeated authenticated daemon replacements', async () => { const { current, legacy, provider, recovery } = await fixture() legacy.setProcesses([processInfo('legacy-live', 'legacy-incarnation')]) for (let generation = 0; generation < 16; generation++) { const id = `retired-${generation}` current.setProcesses([processInfo(id, `incarnation-${generation}`)]) - await provider.discoverDaemonSessions() + await provider.discoverLegacySessions() await expect(provider.probePtyLiveness(`unmapped-${generation}`)).resolves.toBe(false) - expect(recovery['livenessResolver']['routeIncarnations'].get(id)).toBe( - `incarnation-${generation}` - ) + expect(recovery['routeIncarnations'].get(id)).toBe(`incarnation-${generation}`) current.setProcesses([]) await current.publishIdentity(generation + 1) - expect([...provider['sessionProviders'].keys()]).toEqual(['legacy-live']) - expect([...recovery['attachResolver']['routeIncarnations'].keys()]).toEqual(['legacy-live']) - expect([...recovery['livenessResolver']['routeIncarnations'].keys()]).toEqual(['legacy-live']) + expect([...provider['sessionAdapters'].keys()]).toEqual(['legacy-live']) + expect([...recovery['routeIncarnations'].keys()]).toEqual(['legacy-live']) } }) it('preserves the same session ID after another provider publishes its successor', async () => { const { current, legacy, provider, recovery } = await fixture() current.setProcesses([processInfo('same-id', 'old-incarnation')]) - await provider.discoverDaemonSessions() + await provider.discoverLegacySessions() await provider.probePtyLiveness('unmapped-old') current.setProcesses([]) legacy.setProcesses([processInfo('same-id', 'new-incarnation')]) - await provider.discoverDaemonSessions() + await provider.discoverLegacySessions() await provider.probePtyLiveness('unmapped-new') await current.publishIdentity(1) - expect(provider['sessionProviders'].get('same-id')).toBe(legacy.adapter) - expect(recovery['attachResolver']['routeIncarnations'].get('same-id')).toBe('new-incarnation') - expect(recovery['livenessResolver']['routeIncarnations'].get('same-id')).toBe('new-incarnation') + expect(provider['sessionAdapters'].get('same-id')).toBe(legacy.adapter) + expect(recovery['routeIncarnations'].get('same-id')).toBe('new-incarnation') }) it('keeps matching-incarnation direct attach without consulting another inventory', async () => { const { current, legacy, provider } = await fixture() legacy.setProcesses([processInfo('live', 'live-incarnation')]) - await provider.discoverDaemonSessions() + await provider.discoverLegacySessions() await provider.probePtyLiveness('unmapped') await current.publishIdentity(1) current.request.mockClear() @@ -139,7 +131,7 @@ describe('shared daemon owner incarnation retirement', () => { it('retains authoritative incarnation mismatch refusal after another daemon changes', async () => { const { current, legacy, provider } = await fixture() legacy.setProcesses([processInfo('live', 'current-incarnation')]) - await provider.discoverDaemonSessions() + await provider.discoverLegacySessions() await current.publishIdentity(1) await expect( provider.spawn({ diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 717bef90d7aa..ef7baae531da 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -34,7 +34,7 @@ export type DaemonPidFile = { } export type DaemonProcessHandle = { - mode?: 'degraded-new-pty-fallback' + mode?: 'fresh-spawns-unavailable' /** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */ adopted?: true releaseAdoptionLease?(): void diff --git a/src/main/daemon/daemon-transient-pty-protocol.ts b/src/main/daemon/daemon-transient-pty-protocol.ts new file mode 100644 index 000000000000..19ef50f58efb --- /dev/null +++ b/src/main/daemon/daemon-transient-pty-protocol.ts @@ -0,0 +1,9 @@ +import type { TransientPtyCommand } from './daemon-transient-pty' +export type TransientPtyRequest = + | { + id: string + type: 'createTransientPty' + payload: TransientPtyCommand + } + | { id: string; type: 'writeTransientPty'; payload: { id: string; data: string } } + | { id: string; type: 'closeTransientPty'; payload: { id: string } } diff --git a/src/main/daemon/daemon-transient-pty.test.ts b/src/main/daemon/daemon-transient-pty.test.ts new file mode 100644 index 000000000000..5d5eba737029 --- /dev/null +++ b/src/main/daemon/daemon-transient-pty.test.ts @@ -0,0 +1,190 @@ +import type { DaemonEvent } from './types' +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { BunPtyProcess } from './pty-subprocess/bun-pty-process-contract' +import { DaemonTransientPtys } from './daemon-transient-pty' + +vi.mock('../pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: (_pid: number, fallback: () => void) => fallback() +})) + +function fixture(waitForSpawn?: () => Promise) { + let output: (data: string) => void = () => {} + let exit: (event: { exitCode: number }) => void = () => {} + const disposeData = vi.fn() + const disposeExit = vi.fn() + const process = { + pid: 123, + kill: vi.fn(() => exit({ exitCode: 137 })), + destroy: vi.fn(), + write: vi.fn(), + waitForSpawn, + onData: vi.fn((listener: typeof output) => { + output = listener + return { dispose: disposeData } + }), + onExit: vi.fn((listener: typeof exit) => { + exit = listener + return { dispose: disposeExit } + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Registry only accesses the explicitly supplied process members. + const spawn = vi.fn(() => process as unknown as BunPtyProcess) + const publish = vi.fn((_owner: string, _event: DaemonEvent) => true) + const registry = new DaemonTransientPtys(publish, spawn, true) + const command = { + id: randomUUID(), + file: '/synthetic/tool', + args: ['a b', '$HOME'], + cwd: '/tmp', + env: { AUTH: 'fixture' }, + cols: 120, + rows: 40 + } + return { + registry, + command, + process, + spawn, + publish, + output: (data: string) => output(data), + exit: (code: number) => exit({ exitCode: code }), + disposeData, + disposeExit + } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('transient daemon PTYs', () => { + it('rejects unsupported runtimes and unauthenticated pairs before spawning', async () => { + const f = fixture() + const unavailable = new DaemonTransientPtys(f.publish, f.spawn, false) + expect(unavailable.ping()).toEqual({ pong: true }) + await expect(unavailable.create('owner', f.command)).rejects.toThrow('unavailable') + expect(() => + f.registry.route( + 'owner', + { + id: 'request', + type: 'createTransientPty', + payload: f.command + }, + false + ) + ).toThrow('authenticated') + expect(f.spawn).not.toHaveBeenCalled() + }) + + it('passes exact argv and replacement environment, with owner-only input and cleanup', async () => { + const f = fixture() + await f.registry.create('owner', f.command) + expect(f.spawn).toHaveBeenCalledWith({ ...f.command, windowsJobKillOnClose: true }) + expect(() => f.registry.write('other', f.command.id, 'secret')).toThrow('not owned') + f.registry.write('owner', f.command.id, '/status') + expect(f.process.write).toHaveBeenCalledWith('/status') + f.registry.disconnect('other') + expect(f.process.destroy).not.toHaveBeenCalled() + f.registry.disconnect('owner') + expect(f.process.kill).toHaveBeenCalledOnce() + expect(f.process.destroy).toHaveBeenCalledOnce() + expect(f.disposeData).toHaveBeenCalledOnce() + expect(f.disposeExit).toHaveBeenCalledOnce() + }) + + it('kills a pending Windows spawn when its connection disappears', async () => { + let finish: () => void = () => {} + const f = fixture( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const creation = f.registry.create('owner', f.command) + f.registry.disconnect('owner') + finish() + await expect(creation).rejects.toThrow('canceled') + expect(f.process.kill).toHaveBeenCalledOnce() + expect(f.process.destroy).toHaveBeenCalledOnce() + }) + + it('orders output before exit and does not kill a naturally exited process', async () => { + const f = fixture() + await f.registry.create('owner', f.command) + f.output('hello') + f.exit(0) + expect(f.publish.mock.calls).toEqual([ + [ + 'owner', + { type: 'event', event: 'data', sessionId: f.command.id, payload: { data: 'hello' } } + ], + ['owner', { type: 'event', event: 'exit', sessionId: f.command.id, payload: { code: 0 } }] + ]) + expect(f.process.kill).not.toHaveBeenCalled() + expect(f.process.destroy).toHaveBeenCalledOnce() + }) + + it('retains ownership after failed signals until an actual exit is observed', async () => { + vi.useFakeTimers() + const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const f = fixture() + f.process.kill.mockImplementation(() => { + throw new Error('EPERM') + }) + await f.registry.create('owner', f.command) + f.registry.disconnect('owner') + await vi.advanceTimersByTimeAsync(2_000) + expect(f.process.kill).toHaveBeenCalledTimes(3) + expect(f.process.destroy).not.toHaveBeenCalled() + expect(f.publish.mock.calls.some(([, event]) => event.event === 'exit')).toBe(false) + await expect(f.registry.create('owner', f.command)).rejects.toThrow('already exists') + f.exit(137) + expect(f.process.destroy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + warnings.mockRestore() + }) + + it('terminates on overflow and bounds unattended lifetime', async () => { + vi.useFakeTimers() + const f = fixture() + await f.registry.create('owner', f.command) + f.publish.mockReturnValue(false) + f.output('flood') + expect(f.process.destroy).toHaveBeenCalledOnce() + const g = fixture() + await g.registry.create('owner', g.command) + await vi.advanceTimersByTimeAsync(30_000) + expect(g.process.destroy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + it('rejects new probes after shutdown starts', async () => { + const f = fixture() + f.registry.dispose() + await expect(f.registry.create('owner', f.command)).rejects.toThrow('shutting down') + expect(f.spawn).not.toHaveBeenCalled() + }) + + it('does not kill only the Windows gate after job termination fails', async () => { + vi.useFakeTimers() + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const f = fixture() + const terminateOwnedTree = vi.fn(() => 'unavailable') + Object.assign(f.process, { terminateOwnedTree }) + await f.registry.create('owner', f.command) + f.registry.disconnect('owner') + await vi.advanceTimersByTimeAsync(2_000) + expect(terminateOwnedTree).toHaveBeenCalledTimes(3) + expect(f.process.kill).not.toHaveBeenCalled() + expect(f.process.destroy).not.toHaveBeenCalled() + await expect(f.registry.create('owner', f.command)).rejects.toThrow('already exists') + f.exit(1) + } finally { + warning.mockRestore() + } + }) +}) diff --git a/src/main/daemon/daemon-transient-pty.ts b/src/main/daemon/daemon-transient-pty.ts new file mode 100644 index 000000000000..0907b07b06b5 --- /dev/null +++ b/src/main/daemon/daemon-transient-pty.ts @@ -0,0 +1,239 @@ +import type { TransientPtyRequest } from './daemon-transient-pty-protocol' +import { z } from 'zod' +import { spawnBunPty, canUseBunPty } from './pty-subprocess/bun-pty-process' +import type { BunPtyProcess } from './pty-subprocess/bun-pty-process-contract' +import type { DaemonEvent } from './types' +import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' + +const text = z + .string() + .max(32_768) + .refine((value) => !value.includes('\0')) +export const transientPtyCommand = z.object({ + id: z.string().uuid(), + file: text.min(1), + args: z.array(text).max(256), + cwd: text.min(1), + env: z.record(z.string(), text), + cols: z.number().int().min(1).max(500), + rows: z.number().int().min(1).max(500) +}) +export type TransientPtyCommand = z.infer +type Entry = { + owner: string + process: BunPtyProcess | null + ended: boolean + closing: boolean + terminationAttempts: number + timer: ReturnType + listeners: { dispose(): void }[] +} + +/** Client-leased probes never enter terminal sessions, history or persistence. */ +export class DaemonTransientPtys { + private disposed = false + private readonly entries = new Map() + constructor( + private readonly publish: (owner: string, event: DaemonEvent) => boolean, + private readonly spawn = spawnBunPty, + readonly available = canUseBunPty() + ) {} + + ping(): { pong: true; capabilities?: { transientPty: 1 } } { + return this.available ? { pong: true, capabilities: { transientPty: 1 } } : { pong: true } + } + + route(owner: string, request: TransientPtyRequest, authenticated: boolean): unknown { + if (!authenticated) { + throw new Error('Transient PTY requires an authenticated stream') + } + switch (request.type) { + case 'createTransientPty': + return this.create(owner, request.payload) + case 'writeTransientPty': + this.write(owner, request.payload.id, request.payload.data) + return {} + case 'closeTransientPty': + this.close(owner, request.payload.id) + return {} + } + } + + async create(owner: string, input: unknown): Promise<{ pid: number }> { + if (this.disposed) { + throw new Error('Transient PTY service is shutting down') + } + if (!this.available) { + throw new Error('Transient PTY runtime unavailable') + } + const command = transientPtyCommand.parse(input) + if (this.entries.has(command.id) || this.entries.size >= 8) { + throw new Error('Transient PTY capacity exceeded or ID already exists') + } + const entry: Entry = { + owner, + process: null, + ended: false, + closing: false, + terminationAttempts: 0, + listeners: [], + timer: setTimeout(() => this.expire(command.id, entry), 30_000) + } + this.entries.set(command.id, entry) + try { + const proc = this.spawn({ ...command, windowsJobKillOnClose: true }) + entry.process = proc + // Register before waiting for Windows spawn receipt so early output remains ordered. + const data = proc.onData((output) => { + if (entry.ended || entry.closing) { + return + } + for (let offset = 0; offset < output.length; offset += 16_384) { + if ( + !this.publish(owner, { + type: 'event', + event: 'data', + sessionId: command.id, + payload: { data: output.slice(offset, offset + 16_384) } + }) + ) { + this.end(command.id, entry, true) + return + } + } + }) + entry.listeners.push(data) + const exit = proc.onExit(({ exitCode }) => { + if (!entry.ended) { + this.publish(owner, { + type: 'event', + event: 'exit', + sessionId: command.id, + payload: { code: exitCode } + }) + this.end(command.id, entry, false) + } + }) + entry.listeners.push(exit) + await proc.waitForSpawn?.() + if (entry.ended) { + data.dispose() + exit.dispose() + } + if (entry.closing) { + throw new Error('Transient PTY canceled during startup') + } + return { pid: proc.pid } + } catch (error) { + this.end(command.id, entry, true) + throw error + } + } + + write(owner: string, id: string, data: string): void { + const entry = this.requireOwned(owner, id) + if (data.length > 32_768) { + throw new Error('Transient PTY input too large') + } + entry.process?.write(data) + } + + close(owner: string, id: string): void { + const entry = this.entries.get(id) + if (!entry) { + return + } + this.requireOwned(owner, id) + this.end(id, entry, true) + } + + disconnect(owner: string): void { + for (const [id, entry] of this.entries) { + if (entry.owner === owner) { + this.end(id, entry, true) + } + } + } + + dispose(): void { + this.disposed = true + for (const [id, entry] of this.entries) { + this.end(id, entry, true) + } + } + + private expire(id: string, entry: Entry): void { + if (entry.ended) { + return + } + this.end(id, entry, true) + } + + private requireOwned(owner: string, id: string): Entry { + const entry = this.entries.get(id) + if (!entry || entry.owner !== owner || entry.ended || entry.closing) { + throw new Error('Transient PTY not owned') + } + return entry + } + + private end(id: string, entry: Entry, kill: boolean): void { + if (entry.ended) { + return + } + if (kill && entry.process) { + if (entry.closing) { + return + } + entry.closing = true + clearTimeout(entry.timer) + this.terminate(id, entry) + return + } + entry.ended = true + clearTimeout(entry.timer) + this.entries.delete(id) + for (const listener of entry.listeners.splice(0)) { + listener.dispose() + } + try { + entry.process?.destroy() + } catch (error) { + console.warn('[daemon] Exited transient PTY disposal failed', error) + } + } + + private terminate(id: string, entry: Entry): void { + const proc = entry.process + if (!proc || entry.ended) { + return + } + entry.terminationAttempts++ + try { + if (process.platform === 'win32') { + if (proc.terminateOwnedTree?.() !== 'terminated') { + throw new Error('Transient PTY job termination is unavailable') + } + } else { + forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) + } + } catch (error) { + console.warn('[daemon] Transient PTY termination failed', error) + } + if (entry.ended) { + return + } + if (entry.terminationAttempts < 3) { + entry.timer = setTimeout(() => this.terminate(id, entry), 1_000) + } else { + // Keep ownership until onExit proves termination; failed signals must not free capacity. + console.warn('[daemon] Transient PTY exit remains unverifiable', { id }) + this.publish(entry.owner, { + type: 'event', + event: 'terminalError', + sessionId: id, + payload: { message: 'Usage probe termination remains unverifiable' } + }) + } + } +} diff --git a/src/main/daemon/degraded-daemon-fallback-shutdown.ts b/src/main/daemon/degraded-daemon-fallback-shutdown.ts deleted file mode 100644 index 0d4932d6c20a..000000000000 --- a/src/main/daemon/degraded-daemon-fallback-shutdown.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { IPtyProvider } from '../providers/types' - -export async function shutdownDegradedFallbackSessions( - sessionProviders: Map, - fallback: T -): Promise { - const ids = [...sessionProviders] - .filter(([, provider]) => provider === fallback) - .map(([id]) => id) - const results = await Promise.allSettled( - ids.map(async (id) => { - await fallback.shutdown(id, { immediate: true }) - sessionProviders.delete(id) - }) - ) - // Why: fallback cleanup must not abort the user's daemon-restart recovery path. - const failed = results.filter((result) => result.status === 'rejected') - if (failed.length > 0) { - console.warn( - `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, - ...failed.map((result) => (result as PromiseRejectedResult).reason) - ) - } - return results.length - failed.length -} diff --git a/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts b/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts deleted file mode 100644 index b1d4661dc93b..000000000000 --- a/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' - -export const DEGRADED_DAEMON_RECOVERY_RETRY_MS = 30_000 - -export class DegradedDaemonFreshSpawnRouter { - private target: IPtyProvider - private recovery: Promise | null = null - private retryAfterMs = 0 - - constructor( - private readonly current: IPtyProvider, - private readonly fallback: IPtyProvider, - private readonly sessionProviders: Map, - private readonly probeCurrent: (() => Promise) | null - ) { - this.target = fallback - } - - get routesToFallback(): true | undefined { - return this.target === this.fallback ? true : undefined - } - - supportsGitGuardHost(sessionId?: string): boolean { - const provider = (sessionId ? this.sessionProviders.get(sessionId) : undefined) ?? this.target - return provider.supportsGitCredentialGuardHost?.(sessionId) === true - } - - canProvideSnapshot(sessionId: string): boolean { - return ( - this.sessionProviders.get(sessionId)?.canProvideAuthoritativeBufferSnapshot?.(sessionId) === - true - ) - } - - async recover(): Promise { - if (this.target === this.current) { - return true - } - if (!this.probeCurrent) { - return false - } - if (Date.now() < this.retryAfterMs) { - return false - } - if (this.recovery) { - return this.recovery - } - const recovery = this.probeCurrent() - .catch(() => false) - .then((healthy) => { - if (healthy) { - this.target = this.current - console.info('[daemon] PTY spawn health recovered; fresh terminals are daemon-backed') - } else { - this.retryAfterMs = Date.now() + DEGRADED_DAEMON_RECOVERY_RETRY_MS - } - return healthy - }) - .finally(() => { - if (this.recovery === recovery) { - this.recovery = null - } - }) - this.recovery = recovery - return recovery - } - - async spawn(opts: PtySpawnOptions): Promise { - const mapped = opts.sessionId ? this.sessionProviders.get(opts.sessionId) : undefined - const target = mapped ?? this.target - const result = await target.spawn(opts) - if (!result.exitedBeforeSpawnReply) { - this.sessionProviders.set(result.id, target) - } - return result - } -} diff --git a/src/main/daemon/degraded-daemon-owner-recovery.ts b/src/main/daemon/degraded-daemon-owner-recovery.ts deleted file mode 100644 index ec8798a5ce35..000000000000 --- a/src/main/daemon/degraded-daemon-owner-recovery.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' - -export class DegradedDaemonOwnerRecovery { - private readonly attachResolver: DaemonSessionOwnerResolver - private readonly livenessResolver: DaemonSessionOwnerResolver - - constructor( - providers: readonly IPtyProvider[], - private readonly daemonAdapters: readonly DaemonPtyAdapter[], - routes: Map, - private readonly spawnFresh: (opts: PtySpawnOptions) => Promise - ) { - this.attachResolver = new DaemonSessionOwnerResolver(providers, routes) - this.livenessResolver = new DaemonSessionOwnerResolver(daemonAdapters, routes) - } - - spawn = async (opts: PtySpawnOptions): Promise => { - return opts.attachOnly && opts.sessionId - ? await this.attachResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) - : await this.spawnFresh(opts) - } - - probe = async (sessionId: string): Promise => - await this.livenessResolver.probe(sessionId) - - discoverRoutes = async (): Promise => await this.attachResolver.discoverRoutes() - - async reconcileOnStartup(validWorktreeIds: Set): Promise<{ - alive: string[] - killed: string[] - }> { - const alive: string[] = [] - const killed: string[] = [] - const aliveProviders = new Map>() - for (const adapter of this.daemonAdapters) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - for (const id of result.alive) { - alive.push(id) - const providers = aliveProviders.get(id) ?? new Set() - providers.add(adapter) - aliveProviders.set(id, providers) - } - killed.push(...result.killed) - } - for (const id of new Set([...alive, ...killed])) { - const providers = aliveProviders.get(id) - if (providers?.size === 1) { - this.recordRoute(id, providers.values().next().value!) - } else { - this.forgetRoute(id) - } - } - return { alive, killed } - } - - recordRoute(sessionId: string, provider: IPtyProvider): void { - this.attachResolver.recordRoute(sessionId, provider) - if (this.daemonAdapters.includes(provider as DaemonPtyAdapter)) { - this.livenessResolver.recordRoute(sessionId, provider as DaemonPtyAdapter) - } - } - - forgetRoute(sessionId: string): void { - this.attachResolver.forgetRoute(sessionId) - this.livenessResolver.forgetRoute(sessionId) - } - - subscribeIdentityChanges(): (() => void)[] { - return this.daemonAdapters.flatMap((adapter) => - typeof adapter.onDaemonIdentityChanged === 'function' - ? [ - adapter.onDaemonIdentityChanged(() => { - this.attachResolver.invalidateProvider(adapter) - this.livenessResolver.invalidateProvider(adapter) - }) - ] - : [] - ) - } -} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts deleted file mode 100644 index f2e86abab280..000000000000 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ /dev/null @@ -1,686 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' -import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn-routing' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' - -type ProviderMock = IPtyProvider & { - probePtyLiveness: (id: string) => Promise - inspectProcess: (id: string) => Promise - emitData: (id: string, data: string, sequenceChars?: number) => void - emitReplay: (id: string, data: string) => void - emitExit: (id: string, code: number) => void - triggerWriteUnavailable: (id: string) => void - onWriteUnavailable: (callback: (payload: { id: string }) => void) => () => void -} - -function createProvider( - label: string, - sessions: string[] = [], - authoritativeOwnerListings = false -): ProviderMock { - const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = - [] - const replayListeners: ((payload: { id: string; data: string }) => void)[] = [] - const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] - const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - return { - spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { - const id = opts.sessionId ?? `${label}-new` - sessions.push(id) - return { id } - }), - attach: vi.fn(async () => {}), - hasPty: vi.fn((id: string) => sessions.includes(id)), - probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), - providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings), - write: vi.fn(), - writeWithSettlement: vi.fn(settledWriteStub()), - resize: vi.fn(), - shutdown: vi.fn(async (id: string) => { - const idx = sessions.indexOf(id) - if (idx !== -1) { - sessions.splice(idx, 1) - } - }), - sendSignal: vi.fn(async () => {}), - getCwd: vi.fn(async () => ''), - getInitialCwd: vi.fn(async () => ''), - clearBuffer: vi.fn(async () => {}), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(async () => false), - getForegroundProcess: vi.fn(async () => null), - inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), - confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), - serialize: vi.fn(async () => '{}'), - revive: vi.fn(async () => {}), - listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), - getDefaultShell: vi.fn(async () => '/bin/zsh'), - getProfiles: vi.fn(async () => []), - onData: vi.fn( - (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) - } - } - } - ), - onReplay: vi.fn((callback: (payload: { id: string; data: string }) => void) => { - replayListeners.push(callback) - return () => { - const idx = replayListeners.indexOf(callback) - if (idx !== -1) { - replayListeners.splice(idx, 1) - } - } - }), - onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => { - exitListeners.push(callback) - return () => { - const idx = exitListeners.indexOf(callback) - if (idx !== -1) { - exitListeners.splice(idx, 1) - } - } - }), - emitData: (id: string, data: string, sequenceChars?: number) => { - for (const listener of dataListeners) { - listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) - } - }, - emitReplay: (id: string, data: string) => { - for (const listener of replayListeners) { - listener({ id, data }) - } - }, - emitExit: (id: string, code: number) => { - for (const listener of exitListeners) { - listener({ id, code }) - } - }, - onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { - writeUnavailableListeners.push(callback) - return () => { - const idx = writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - writeUnavailableListeners.splice(idx, 1) - } - } - }), - triggerWriteUnavailable: (id: string) => { - for (const listener of writeUnavailableListeners) { - listener({ id }) - } - } - } -} - -function createDaemonAdapter( - label: string, - sessions: string[] = [] -): DaemonPtyAdapter & ProviderMock { - return { - ...createProvider(label, sessions, true), - protocolVersion: 13, - supportsGitCredentialGuardHost: vi.fn(() => true), - canProvideAuthoritativeBufferSnapshot: vi.fn(() => true), - listSessions: vi.fn(async () => []), - ackColdRestore: vi.fn(), - clearTombstone: vi.fn(), - reconcileOnStartup: vi.fn(async () => ({ alive: sessions, killed: [] })), - dispose: vi.fn(), - disconnectOnly: vi.fn(async () => {}), - getActiveSessionIds: vi.fn(() => []), - fanoutSyntheticExits: vi.fn() - } as unknown as DaemonPtyAdapter & ProviderMock -} - -it('forwards dead-endpoint write-unavailable signals from the daemon adapters', () => { - // Why revert-sensitive: this provider is the live localProvider in degraded launch - // mode and main subscribes on it, so without forwarding the STA-2373 fan-out reaches - // no listener and sibling panes stay frozen. - const current = createDaemonAdapter('daemon') - const legacy = createDaemonAdapter('legacy') - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - const recovered: string[] = [] - - const unsubscribe = provider.onWriteUnavailable(({ id }) => recovered.push(id)) - current.triggerWriteUnavailable('daemon-pane') - legacy.triggerWriteUnavailable('legacy-pane') - expect(recovered).toEqual(['daemon-pane', 'legacy-pane']) - - unsubscribe() - current.triggerWriteUnavailable('after-unsubscribe') - expect(recovered).toEqual(['daemon-pane', 'legacy-pane']) -}) - -it('routes attach-only to a legacy session created after startup inventory', async () => { - const current = createDaemonAdapter('daemon') - const legacySessions = ['legacy-at-startup'] - const legacy = createDaemonAdapter('legacy', legacySessions) - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ - current, - legacy: [legacy], - fallback - }) - await provider.discoverDaemonSessions() - legacySessions.push('legacy-created-later') - - await provider.spawn({ - sessionId: 'legacy-created-later', - attachOnly: true, - cols: 80, - rows: 24 - }) - - expect(legacy.spawn).toHaveBeenCalledOnce() - expect(current.spawn).not.toHaveBeenCalled() - expect(fallback.spawn).not.toHaveBeenCalled() -}) - -it('keeps an attach unresolved when a legacy inventory listing fails', async () => { - const current = createDaemonAdapter('daemon') - const legacy = createDaemonAdapter('legacy') - vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged')) - const provider = new DegradedDaemonPtyProvider({ - current, - legacy: [legacy], - fallback: createProvider('fallback') - }) - - await expect( - provider.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) - expect(current.spawn).not.toHaveBeenCalled() -}) - -it('confirms repeated stale-binding absence without falling back or spawning', async () => { - const current = createDaemonAdapter('daemon-current') - const legacy = createDaemonAdapter('daemon-legacy') - const fallback = createProvider('local-fallback') - vi.mocked(fallback.spawn).mockImplementation(async (opts) => - opts.attachOnly - ? { - id: opts.sessionId!, - incarnationId: 'incarnation-fresh-local', - isReattach: true - } - : { id: 'pty-fresh-local', incarnationId: 'incarnation-fresh-local' } - ) - const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - const attach = { - sessionId: 'pty-persisted-missing', - expectedIncarnationId: 'incarnation-persisted-missing', - attachOnly: true, - cols: 80, - rows: 24 - } as const - - await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) - await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) - - expect(fallback.listProcesses).toHaveBeenCalledTimes(2) - expect(current.listProcesses).toHaveBeenCalledTimes(2) - expect(legacy.listProcesses).toHaveBeenCalledTimes(2) - expect(fallback.spawn).not.toHaveBeenCalled() - expect(current.spawn).not.toHaveBeenCalled() - expect(legacy.spawn).not.toHaveBeenCalled() - - const fresh = await provider.spawn({ cols: 80, rows: 24 }) - await expect( - provider.spawn({ - sessionId: fresh.id, - expectedIncarnationId: fresh.incarnationId, - attachOnly: true, - cols: 80, - rows: 24 - }) - ).resolves.toMatchObject({ - id: 'pty-fresh-local', - incarnationId: 'incarnation-fresh-local', - isReattach: true - }) - - expect(fallback.spawn).toHaveBeenCalledTimes(2) - expect(vi.mocked(fallback.spawn).mock.calls[0]?.[0]).not.toHaveProperty('sessionId') - expect(vi.mocked(fallback.spawn).mock.calls[1]?.[0]).toMatchObject({ - sessionId: 'pty-fresh-local', - attachOnly: true - }) - expect(current.spawn).not.toHaveBeenCalled() - expect(legacy.spawn).not.toHaveBeenCalled() - expect(fallback.listProcesses).toHaveBeenCalledTimes(2) - expect(current.listProcesses).toHaveBeenCalledTimes(2) - expect(legacy.listProcesses).toHaveBeenCalledTimes(2) -}) - -it('rejects completion inspection instead of borrowing the fallback provider', async () => { - const provider = new DegradedDaemonPtyProvider({ - current: createDaemonAdapter('daemon'), - legacy: [], - fallback: createProvider('fallback') - }) - - await expect(provider.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone') -}) - -it('preserves client-only unverifiable inspection from an owning daemon', async () => { - const daemon = createDaemonAdapter('daemon', ['daemon-session']) - vi.mocked(daemon.inspectProcess).mockResolvedValue({ - foregroundProcess: null, - hasChildProcesses: false, - verdict: 'unverifiable', - reason: 'old_host' - }) - const provider = new DegradedDaemonPtyProvider({ - current: daemon, - legacy: [], - fallback: createProvider('fallback') - }) - await provider.discoverDaemonSessions() - - await expect(provider.inspectProcess('daemon-session')).resolves.toEqual({ - foregroundProcess: null, - hasChildProcesses: false, - verdict: 'unverifiable', - reason: 'old_host' - }) -}) - -describe('DegradedDaemonPtyProvider', () => { - it('refuses attach for ids no daemon adapter owns instead of the fallback no-op', async () => { - const daemonSessions: string[] = [] - const current = createDaemonAdapter('daemon', daemonSessions) - const fallback = createProvider('fallback', ['fresh-fallback-session']) - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - - // Unknown id: the fallback's resolving no-op attach must never read as - // success — the runtime would pin a blank stream as attached. - await expect(provider.attach('wt-1@@unknown')).rejects.toThrow( - 'Session not found: wt-1@@unknown' - ) - // Fallback-owned fresh sessions stream in-process; attach is refused too. - await expect(provider.attach('fresh-fallback-session')).rejects.toThrow( - 'Session not found: fresh-fallback-session' - ) - expect(fallback.attach).not.toHaveBeenCalled() - expect(current.attach).not.toHaveBeenCalled() - - // Once a daemon adapter owns the id, attach routes to that adapter. - daemonSessions.push('wt-1@@learned') - await expect(provider.attach('wt-1@@learned')).resolves.toBeUndefined() - expect(current.attach).toHaveBeenCalledWith('wt-1@@learned') - expect(fallback.attach).not.toHaveBeenCalled() - }) - - it('forwards the owning daemon sequence from attach', async () => { - const legacy = createDaemonAdapter('legacy', ['daemon-session']) - const providerSequence = { value: 204, generation: 'continued' as const } - vi.mocked(legacy.attach).mockResolvedValueOnce({ providerSequence }) - const provider = new DegradedDaemonPtyProvider({ - current: createDaemonAdapter('current'), - legacy: [legacy], - fallback: createProvider('fallback') - }) - await provider.discoverDaemonSessions() - - await expect(provider.attach('daemon-session')).resolves.toEqual({ providerSequence }) - }) - - it('only delegates owner-listing authority to the provider that owns the id', async () => { - const current = createDaemonAdapter('daemon', ['daemon-session']) - const fallback = createProvider('fallback', [], true) - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - await provider.discoverDaemonSessions() - - expect(provider.providesAgentSessionOwnerListings('daemon-session')).toBe(true) - expect(provider.providesAgentSessionOwnerListings('unknown-session')).toBe(false) - }) - - it('routes fresh foreground confirmation to the session owner', async () => { - const current = createDaemonAdapter('daemon', ['daemon-session']) - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - await provider.discoverDaemonSessions() - const fresh = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.confirmForegroundProcess('daemon-session')).resolves.toBe( - 'daemon-confirmed' - ) - await expect(provider.confirmForegroundProcess(fresh.id)).resolves.toBe('fallback-confirmed') - }) - - it('routes discovered daemon sessions to the daemon and fresh PTYs to the fallback', async () => { - const current = createDaemonAdapter('daemon', ['daemon-session']) - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - - await provider.discoverDaemonSessions() - - await provider.spawn({ sessionId: 'daemon-session', cols: 80, rows: 24 }) - const fresh = await provider.spawn({ cols: 80, rows: 24 }) - provider.write('daemon-session', 'old\n') - provider.write(fresh.id, 'new\n') - - expect(current.spawn).toHaveBeenCalledWith({ sessionId: 'daemon-session', cols: 80, rows: 24 }) - expect(fallback.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) - expect(current.write).toHaveBeenCalledWith('daemon-session', 'old\n') - expect(fallback.write).toHaveBeenCalledWith(fresh.id, 'new\n') - }) - - it('preserves settlement through daemon and fallback routes', async () => { - const current = createDaemonAdapter('daemon', ['daemon-session']) - const fallback = createProvider('fallback') - vi.mocked(current.writeWithSettlement).mockResolvedValue(stubWriteSettlement(false)) - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - await provider.discoverDaemonSessions() - const fresh = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.writeWithSettlement('daemon-session', 'old')).resolves.toEqual( - stubWriteSettlement(false) - ) - await expect(provider.writeWithSettlement(fresh.id, 'new')).resolves.toEqual( - stubWriteSettlement(true) - ) - expect(current.writeWithSettlement).toHaveBeenCalledWith('daemon-session', 'old') - expect(fallback.writeWithSettlement).toHaveBeenCalledWith(fresh.id, 'new') - }) - - it('routes later fresh PTYs to the daemon after spawn health recovers', async () => { - const current = createDaemonAdapter('daemon') - const fallback = createProvider('fallback') - const probeCurrentDaemonSpawn = vi - .fn<() => Promise>() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true) - const provider = new DegradedDaemonPtyProvider({ - current, - legacy: [], - fallback, - probeCurrentDaemonSpawn - }) - const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) - - try { - await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(false) - await provider.spawn({ cols: 80, rows: 24 }) - await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(false) - expect(probeCurrentDaemonSpawn).toHaveBeenCalledOnce() - now.mockReturnValue(1_000 + DEGRADED_DAEMON_RECOVERY_RETRY_MS) - await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(true) - const recovered = await provider.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) - - expect(provider.routesFreshSpawnsToLocalProvider).toBeUndefined() - expect(provider.isDegraded).toBe(true) - expect(provider.supportsGitCredentialGuardHost()).toBe(true) - expect(fallback.spawn).toHaveBeenCalledOnce() - expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24, worktreeId: 'wt-1' }) - expect(recovered.id).toBe('daemon-new') - expect(provider.canProvideAuthoritativeBufferSnapshot(recovered.id)).toBe(true) - } finally { - now.mockRestore() - } - }) - - it('coalesces concurrent fresh-spawn recovery probes', async () => { - let resolveProbe: ((healthy: boolean) => void) | undefined - const probeCurrentDaemonSpawn = vi.fn( - () => - new Promise((resolve) => { - resolveProbe = resolve - }) - ) - const provider = new DegradedDaemonPtyProvider({ - current: createDaemonAdapter('daemon'), - legacy: [], - fallback: createProvider('fallback'), - probeCurrentDaemonSpawn - }) - - const first = provider.recoverFreshSpawnRouting() - const second = provider.recoverFreshSpawnRouting() - expect(probeCurrentDaemonSpawn).toHaveBeenCalledOnce() - resolveProbe?.(true) - - await expect(Promise.all([first, second])).resolves.toEqual([true, true]) - await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(true) - expect(probeCurrentDaemonSpawn).toHaveBeenCalledOnce() - }) - - it('does not retain recovered daemon ownership after exit beats the spawn reply', async () => { - const current = createDaemonAdapter('daemon') - const provider = new DegradedDaemonPtyProvider({ - current, - legacy: [], - fallback: createProvider('fallback'), - probeCurrentDaemonSpawn: vi.fn(async () => true) - }) - vi.mocked(current.spawn).mockImplementation(async () => { - current.emitExit('daemon-fast-exit', 0) - return { id: 'daemon-fast-exit', exitedBeforeSpawnReply: true } - }) - - await provider.recoverFreshSpawnRouting() - await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({ - id: 'daemon-fast-exit', - exitedBeforeSpawnReply: true - }) - - expect(provider.getCurrentDaemonSessionIds()).toEqual([]) - }) - - it('routes a previously daemon-backed id to fallback after daemon exit removes the mapping', async () => { - const current = createDaemonAdapter('daemon', ['daemon-session']) - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - - await provider.discoverDaemonSessions() - current.emitExit('daemon-session', 0) - await provider.spawn({ sessionId: 'daemon-session', cols: 80, rows: 24 }) - - expect(fallback.spawn).toHaveBeenCalledWith({ - sessionId: 'daemon-session', - cols: 80, - rows: 24 - }) - }) - - it('caches a provider discovered by hasPty before routing later operations', () => { - const current = createDaemonAdapter('daemon', ['daemon-session']) - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - - expect(provider.hasPty('daemon-session')).toBe(true) - provider.write('daemon-session', 'kept-on-daemon\n') - - expect(current.write).toHaveBeenCalledWith('daemon-session', 'kept-on-daemon\n') - expect(fallback.write).not.toHaveBeenCalled() - }) - - it('probes daemon owners without borrowing fallback liveness', async () => { - const currentSessions: string[] = [] - const current = createDaemonAdapter('current', currentSessions) - const legacy = createDaemonAdapter('legacy') - const fallback = createProvider('fallback', ['unknown-session']) - const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy inventory unavailable')) - - await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull() - expect(fallback.probePtyLiveness).not.toHaveBeenCalled() - - currentSessions.push('unknown-session') - await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true) - }) - - it('reports a routed fallback PTY live before probing daemon absence', async () => { - const current = createDaemonAdapter('current') - const legacy = createDaemonAdapter('legacy') - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - const fresh = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.probePtyLiveness(fresh.id)).resolves.toBe(true) - expect(fallback.hasPty).toHaveBeenCalledWith(fresh.id) - expect(current.listProcesses).not.toHaveBeenCalled() - expect(legacy.listProcesses).not.toHaveBeenCalled() - }) - - it('routes authoritative recovery snapshots to the owning daemon', async () => { - const current = createDaemonAdapter('daemon', ['daemon-session']) - const fallback = createProvider('fallback') - const snapshot = { - data: 'alt frame', - scrollbackAnsi: 'normal history', - cols: 80, - rows: 24, - seq: 42, - source: 'headless' as const - } - current.getBufferSnapshot = vi.fn(async () => snapshot) - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - - await provider.discoverDaemonSessions() - - await expect( - provider.getBufferSnapshot('daemon-session', { scrollbackRows: 50_000 }) - ).resolves.toEqual(snapshot) - expect(current.getBufferSnapshot).toHaveBeenCalledWith('daemon-session', { - scrollbackRows: 50_000 - }) - }) - - it('forwards replay output from fallback and daemon providers', () => { - const current = createDaemonAdapter('daemon') - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - const replaySpy = vi.fn() - - const unsubscribe = provider.onReplay(replaySpy) - current.emitReplay('daemon-session', 'daemon replay') - fallback.emitReplay('fallback-session', 'fallback replay') - unsubscribe() - current.emitReplay('daemon-session', 'after unsubscribe') - - expect(replaySpy).toHaveBeenCalledTimes(2) - expect(replaySpy).toHaveBeenNthCalledWith(1, { - id: 'daemon-session', - data: 'daemon replay' - }) - expect(replaySpy).toHaveBeenNthCalledWith(2, { - id: 'fallback-session', - data: 'fallback replay' - }) - }) - - it('preserves explicit sequence accounting on daemon data events', () => { - const current = createDaemonAdapter('daemon') - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - const dataSpy = vi.fn() - provider.onData(dataSpy) - - current.emitData('daemon-session', '\x1b[6n', 0) - - expect(dataSpy).toHaveBeenCalledWith({ - id: 'daemon-session', - data: '\x1b[6n', - sequenceChars: 0 - }) - }) - - it('detaches provider subscriptions without disposing the underlying providers', () => { - const current = createDaemonAdapter('daemon') - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - const dataSpy = vi.fn() - const exitSpy = vi.fn() - provider.onData(dataSpy) - provider.onExit(exitSpy) - - provider.disposeProviderOnly() - current.emitData('daemon-session', 'data') - fallback.emitExit('fallback-session', 0) - - expect(dataSpy).not.toHaveBeenCalled() - expect(exitSpy).not.toHaveBeenCalled() - expect(current.dispose).not.toHaveBeenCalled() - }) - - it('shuts down fallback sessions before a daemon-provider swap', async () => { - const current = createDaemonAdapter('daemon') - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - - const fresh = await provider.spawn({ cols: 80, rows: 24 }) - const killedCount = await provider.shutdownFallbackSessions() - - expect(killedCount).toBe(1) - expect(fallback.shutdown).toHaveBeenCalledWith(fresh.id, { immediate: true }) - expect(provider.hasPty(fresh.id)).toBe(false) - }) - - it('is best-effort: counts only successful shutdowns and never throws (keeps restart alive)', async () => { - const current = createDaemonAdapter('daemon') - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) - const stuck = await provider.spawn({ sessionId: 'stuck', cols: 80, rows: 24 }) - await provider.spawn({ sessionId: 'ok', cols: 80, rows: 24 }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(fallback.shutdown).mockImplementation(async (id: string) => { - if (id === stuck.id) { - throw new Error('still alive') - } - }) - - // Why: a single un-killable local PTY must not abort the daemon restart. - const killedCount = await provider.shutdownFallbackSessions() - - // Best-effort: the one that shut down is counted, the stuck one is not, and - // crucially it does not throw — so the daemon restart sequence proceeds. - expect(killedCount).toBe(1) - expect(warn).toHaveBeenCalled() - expect(fallback.shutdown).toHaveBeenCalledWith('stuck', { immediate: true }) - expect(fallback.shutdown).toHaveBeenCalledWith('ok', { immediate: true }) - warn.mockRestore() - }) - - it('fans synthetic exits for discovered current-daemon sessions only', async () => { - const current = createDaemonAdapter('daemon', ['current-session']) - const legacy = createDaemonAdapter('legacy', ['legacy-session']) - const fallback = createProvider('fallback') - const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - const exitSpy = vi.fn() - provider.onExit(exitSpy) - - await provider.discoverDaemonSessions() - provider.fanoutCurrentDaemonSyntheticExits(-1) - - expect(exitSpy).toHaveBeenCalledOnce() - expect(exitSpy).toHaveBeenCalledWith({ id: 'current-session', code: -1 }) - expect(provider.getCurrentDaemonSessionIds()).toEqual([]) - expect(provider.hasPty('legacy-session')).toBe(true) - }) - - it('keeps an exited legacy daemon poisoning listProcesses after construction', async () => { - const current = createDaemonAdapter('daemon', ['current-session']) - const legacy = createDaemonAdapter('legacy', ['legacy-session']) - const fallback = createProvider('fallback', ['fallback-session']) - const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - await provider.discoverDaemonSessions() - vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited')) - - await expect(provider.listProcesses()).rejects.toThrow('legacy exited') - await expect(provider.listProcesses()).rejects.toThrow('legacy exited') - expect(provider.getLegacyAdapters()).toEqual([legacy]) - expect(current.listProcesses).toHaveBeenCalledTimes(3) - expect(fallback.listProcesses).toHaveBeenCalledTimes(3) - }) -}) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts deleted file mode 100644 index 2b50424ae830..000000000000 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ /dev/null @@ -1,360 +0,0 @@ -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { combineUnsubscribes } from './combine-unsubscribes' -import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' -import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' -import type { - IPtyProvider, - PtyBackgroundStreamEvent, - PtyDataEvent, - PtyProviderBufferSnapshot, - PtyProcessInfo, - PtySpawnOptions, - PtySpawnResult -} from '../providers/types' -import { - adoptOwningProvider, - attachDaemonOwnedSession, - findDaemonAdapter, - listProviderSessionIds -} from './degraded-daemon-session-routing' -import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing' -import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery' -import type { WriteSettlement } from '../../shared/pty-write-settlement' - -export class DegradedDaemonPtyProvider implements IPtyProvider { - readonly isDegraded = true - - private current: DaemonPtyAdapter - private legacy: DaemonPtyAdapter[] - private fallback: IPtyProvider - private sessionProviders = new Map() - private freshSpawns: DegradedDaemonFreshSpawnRouter - private ownerRecovery: DegradedDaemonOwnerRecovery - private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: PtyDataEvent) => void)[] = [] - private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] - - constructor(opts: { - current: DaemonPtyAdapter - legacy: DaemonPtyAdapter[] - fallback: IPtyProvider - probeCurrentDaemonSpawn?: () => Promise - }) { - this.current = opts.current - this.legacy = opts.legacy - this.fallback = opts.fallback - this.freshSpawns = new DegradedDaemonFreshSpawnRouter( - opts.current, - opts.fallback, - this.sessionProviders, - opts.probeCurrentDaemonSpawn ?? null - ) - this.ownerRecovery = new DegradedDaemonOwnerRecovery( - this.allProviders(), - this.allDaemonAdapters(), - this.sessionProviders, - (spawnOpts) => this.freshSpawns.spawn(spawnOpts) - ) - - for (const provider of this.allProviders()) { - this.unsubscribers.push( - provider.onData((payload) => this.dataListeners.forEach((listener) => listener(payload))), - provider.onExit((payload) => { - this.ownerRecovery.forgetRoute(payload.id) - this.exitListeners.forEach((listener) => listener(payload)) - }) - ) - } - this.unsubscribers.push(...this.ownerRecovery.subscribeIdentityChanges()) - } - - async discoverDaemonSessions(): Promise { - await this.ownerRecovery.discoverRoutes() - } - - get routesFreshSpawnsToLocalProvider(): true | undefined { - return this.freshSpawns.routesToFallback - } - - recoverFreshSpawnRouting = (): Promise => this.freshSpawns.recover() - - supportsGitCredentialGuardHost = (id?: string): boolean => - this.freshSpawns.supportsGitGuardHost(id) - - canProvideAuthoritativeBufferSnapshot = (id: string): boolean => - this.freshSpawns.canProvideSnapshot(id) - - spawn = (opts: PtySpawnOptions): Promise => this.ownerRecovery.spawn(opts) - - // Why refuse the fallback route (unknown ids resolve to it): see attachDaemonOwnedSession. - attach = (id: string): ReturnType => - attachDaemonOwnedSession(this.providerFor(id), this.fallback, id) - - hasPty(id: string): boolean { - const mapped = this.sessionProviders.get(id) - return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null - } - - async probePtyLiveness(id: string): Promise { - const mapped = this.sessionProviders.get(id) - if (mapped && (mapped.hasPty?.(id) ?? true)) { - return true - } - return await this.ownerRecovery.probe(id) - } - - // Why: an unknown id cannot borrow listing authority from the fresh-spawn provider. - providesAgentSessionOwnerListings = (ptyId: string): boolean => - ( - this.sessionProviders.get(ptyId) ?? this.findProviderForExistingSession(ptyId) - )?.providesAgentSessionOwnerListings?.(ptyId) === true - - write(id: string, data: string): boolean | void { - return this.providerFor(id).write(id, data) - } - - async writeWithSettlement(id: string, data: string): Promise { - return await this.providerFor(id).writeWithSettlement(id, data) - } - - resize(id: string, cols: number, rows: number): void { - this.providerFor(id).resize(id, cols, rows) - } - - pauseProducer(id: string): void { - this.providerFor(id).pauseProducer?.(id) - } - - resumeProducer(id: string): void { - this.providerFor(id).resumeProducer?.(id) - } - - setPtyBackgrounded(id: string, background: boolean): void { - this.providerFor(id).setPtyBackgrounded?.(id, background) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - await this.providerFor(id).shutdown(id, opts) - if (!opts.keepHistory) { - this.sessionProviders.delete(id) - } - } - - async sendSignal(id: string, signal: string): Promise { - await this.providerFor(id).sendSignal(id, signal) - } - - async getCwd(id: string): Promise { - return this.providerFor(id).getCwd(id) - } - - async getInitialCwd(id: string): Promise { - return this.providerFor(id).getInitialCwd(id) - } - - async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { - return (await this.providerFor(id).getAppliedSize?.(id)) ?? null - } - - async getBufferSnapshot( - id: string, - opts?: { scrollbackRows?: number } - ): Promise { - // Why: recovery must reach the legacy adapter that owns the thinned session model. - return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null - } - - clearBuffer = (id: string): Promise => this.providerFor(id).clearBuffer(id) - - async closeStartupQueryAuthority(id: string): Promise { - return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0 - } - - acknowledgeDataEvent(id: string, charCount: number): void { - this.providerFor(id).acknowledgeDataEvent(id, charCount) - } - - async hasChildProcesses(id: string): Promise { - return this.providerFor(id).hasChildProcesses(id) - } - - async getForegroundProcess(id: string): Promise { - return this.providerFor(id).getForegroundProcess(id) - } - inspectProcess(id: string) { - return this.hasPty(id) - ? inspectPtyProviderProcess(this.providerFor(id), id) - : Promise.reject(new Error('terminal_gone')) - } - async confirmForegroundProcess(id: string): Promise { - return this.providerFor(id).confirmForegroundProcess?.(id) ?? null - } - async confirmShellForeground(id: string): Promise { - return (await this.providerFor(id).confirmShellForeground?.(id)) ?? false - } - - async serialize(ids: string[]): Promise { - return this.fallback.serialize(ids) - } - - async revive(state: string): Promise { - await this.fallback.revive(state) - } - - async listProcesses(opts?: { deadlineMs?: number }): Promise { - const results = await Promise.all( - this.allProviders().map((provider) => provider.listProcesses(opts)) - ) - return results.flat() - } - - async getDefaultShell(): Promise { - return this.fallback.getDefaultShell() - } - - async getProfiles(): Promise<{ name: string; path: string }[]> { - return this.fallback.getProfiles() - } - - onData(callback: (payload: PtyDataEvent) => void): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { - return combineUnsubscribes( - this.allProviders().flatMap((provider) => provider.onBackgroundStreamEvent?.(callback) ?? []) - ) - } - - // Why: main subscribes on the routed provider, so without this the dead-endpoint - // fan-out reaches no listener and only the written pane recovers (STA-2373). Daemon - // adapters only — the local fallback has no dead-socket problem. - onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { - return combineUnsubscribes( - this.allDaemonAdapters().map((adapter) => adapter.onWriteUnavailable(callback)) - ) - } - - onReplay(callback: (payload: { id: string; data: string }) => void): () => void { - const unsubscribes = this.allProviders().map((provider) => provider.onReplay(callback)) - let active = true - const trackedUnsubscribe = (): void => { - if (!active) { - return - } - active = false - const idx = this.unsubscribers.indexOf(trackedUnsubscribe) - if (idx !== -1) { - this.unsubscribers.splice(idx, 1) - } - combineUnsubscribes(unsubscribes)() - } - this.unsubscribers.push(trackedUnsubscribe) - return trackedUnsubscribe - } - - onExit(callback: (payload: { id: string; code: number }) => void): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - ackColdRestore(sessionId: string): void { - findDaemonAdapter(this.sessionProviders, this.allDaemonAdapters(), sessionId)?.ackColdRestore( - sessionId - ) - } - - clearTombstone(sessionId: string): void { - findDaemonAdapter(this.sessionProviders, this.allDaemonAdapters(), sessionId)?.clearTombstone( - sessionId - ) - } - - async reconcileOnStartup(validWorktreeIds: Set): Promise<{ - alive: string[] - killed: string[] - }> { - return await this.ownerRecovery.reconcileOnStartup(validWorktreeIds) - } - - dispose(): void { - this.disposeProviderOnly() - for (const adapter of this.allDaemonAdapters()) { - adapter.dispose() - } - } - - disposeProviderOnly(): void { - combineUnsubscribes(this.unsubscribers.splice(0))() - } - - async shutdownFallbackSessions(): Promise { - return shutdownDegradedFallbackSessions(this.sessionProviders, this.fallback) - } - - getCurrentDaemonSessionIds(): string[] { - return listProviderSessionIds(this.sessionProviders, this.current) - } - - fanoutCurrentDaemonSyntheticExits(code: number): void { - for (const id of this.getCurrentDaemonSessionIds()) { - this.sessionProviders.delete(id) - // Why: restart kills listed sessions even when the adapter did not track them active. - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.exitListeners]) { - listener({ id, code }) - } - } - } - - async disconnectOnly(): Promise { - this.disposeProviderOnly() - await Promise.all(this.allDaemonAdapters().map((adapter) => adapter.disconnectOnly())) - } - - getCurrentAdapter(): DaemonPtyAdapter { - return this.current - } - - getLegacyAdapters(): readonly DaemonPtyAdapter[] { - return this.legacy - } - - getAllAdapters(): readonly DaemonPtyAdapter[] { - return this.allDaemonAdapters() - } - - private providerFor(sessionId: string): IPtyProvider { - return ( - this.sessionProviders.get(sessionId) ?? - this.findProviderForExistingSession(sessionId) ?? - this.fallback - ) - } - - private findProviderForExistingSession(sessionId: string): IPtyProvider | null { - return adoptOwningProvider(this.sessionProviders, this.allProviders(), sessionId) - } - - private allProviders(): IPtyProvider[] { - return [this.fallback, ...this.allDaemonAdapters()] - } - - private allDaemonAdapters(): DaemonPtyAdapter[] { - return [this.current, ...this.legacy] - } -} diff --git a/src/main/daemon/degraded-daemon-session-routing.ts b/src/main/daemon/degraded-daemon-session-routing.ts deleted file mode 100644 index 7fb5a9a00c8c..000000000000 --- a/src/main/daemon/degraded-daemon-session-routing.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { IPtyProvider } from '../providers/types' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { SessionNotFoundError } from './daemon-errors' - -export function listProviderSessionIds( - sessionProviders: ReadonlyMap, - provider: IPtyProvider -): string[] { - return [...sessionProviders] - .filter(([, mappedProvider]) => mappedProvider === provider) - .map(([id]) => id) -} - -/** Attach-only session adoption: refuses the in-process fallback route. A - * fallback pty cannot own a daemon-surviving session by definition, and its - * no-op attach resolving would pin a subscriber-driven attach as succeeded - * while the stream stays blank. */ -export async function attachDaemonOwnedSession( - owner: IPtyProvider, - fallback: IPtyProvider, - sessionId: string -): ReturnType { - if (owner === fallback) { - throw new SessionNotFoundError(sessionId) - } - return await owner.attach(sessionId) -} - -/** Probes providers for an id absent from the routing map and adopts the - * first proven owner into the map. */ -export function adoptOwningProvider( - sessionProviders: Map, - providers: readonly IPtyProvider[], - sessionId: string -): IPtyProvider | null { - for (const provider of providers) { - if (provider.hasPty?.(sessionId) === true) { - sessionProviders.set(sessionId, provider) - return provider - } - } - return null -} - -export function findDaemonAdapter( - sessionProviders: ReadonlyMap, - daemonAdapters: readonly DaemonPtyAdapter[], - sessionId: string -): DaemonPtyAdapter | null { - const provider = sessionProviders.get(sessionId) - return provider && daemonAdapters.includes(provider as DaemonPtyAdapter) - ? (provider as DaemonPtyAdapter) - : null -} diff --git a/src/main/daemon/headless-emulator-wide-char-repaint.test.ts b/src/main/daemon/headless-emulator-wide-char-repaint.test.ts index e183c8735f34..db6b25f8a7d8 100644 --- a/src/main/daemon/headless-emulator-wide-char-repaint.test.ts +++ b/src/main/daemon/headless-emulator-wide-char-repaint.test.ts @@ -5,7 +5,7 @@ * The reporter's decisive run had the command echo doubled while that same * command's output was clean, so the shell never received doubled bytes; and the * doubled text pasted doubled into Notepad, so it was really in the buffer. A - * stream-level assertion (windows-conpty-wide-char-duplication.node-pty.test.ts) + * stream-level assertion (windows-conpty-wide-char-duplication.bun.test.ts) * cannot see that: every byte of a bad repaint is legitimate — cursor * positioning plus text — and only the cells it lands on are wrong. * diff --git a/src/main/daemon/linux-packaged-terminal-floor-fixture.ts b/src/main/daemon/linux-packaged-terminal-floor-fixture.ts new file mode 100644 index 000000000000..e0363b321232 --- /dev/null +++ b/src/main/daemon/linux-packaged-terminal-floor-fixture.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import { createHash, randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +export { runProcess } from '../../shared/child-process/run-process' +import { setAppEnvironment } from '../../shared/app-environment' +import { DaemonClient } from './client' +import { launchDaemonChild, terminateLaunchedDaemonChild } from './daemon-launched-child' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { HistoryReader } from './history-reader' + +export async function qualifyPackagedTerminal(resourcesDirectory: string) { + const resources = resolve(resourcesDirectory) + const runtime = join(resources, 'cli-runtime', 'bun-runtime') + const entry = join(resources, 'terminal-daemon', 'daemon-entry.js') + for (const path of [runtime, entry]) { + assert(existsSync(path), `Missing packaged terminal artifact: ${path}`) + } + const root = mkdtempSync(join(tmpdir(), 'orca-terminal-floor-')) + setAppEnvironment({ + getPath: () => root, + getAppPath: () => resources, + getVersion: () => 'floor-qualification', + isPackaged: () => true, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + process.env.ORCA_DIAGNOSTICS_DISABLED = '1' + process.env.ORCA_BACKGROUND_LAUNCH = '1' + const socketPath = join(root, 'daemon.sock') + const tokenPath = join(root, 'daemon.token') + const historyPath = join(root, 'history') + const options = { socketPath, tokenPath, historyPath, profileScope: root } + const control = new DaemonClient({ socketPath, tokenPath }) + let adapter: DaemonPtyAdapter | undefined + let launched: Awaited> | undefined + let daemonExited = false + let output = '' + const exits: { id: string; code: number }[] = [] + const waitFor = async (predicate: () => boolean, label: string) => { + const deadline = Date.now() + 15_000 + while (!predicate()) { + assert(Date.now() < deadline, `Timed out: ${label}; daemonExited=${daemonExited}; ${output}`) + await delay(20) + } + } + const connectAdapter = () => { + const next = new DaemonPtyAdapter(options) + next.onData(({ data }) => { + output = (output + data).slice(-64 * 1024) + }) + next.onExit(({ id, code }) => { + exits.push({ id, code }) + }) + return next + } + try { + launched = await launchDaemonChild({ + entryPath: entry, + forkEntryPath: entry, + relocatedExecPath: runtime, + bunRuntime: true, + userDataPath: root, + socketPath, + tokenPath, + pidPath: join(root, 'daemon.pid'), + launchNonce: randomUUID(), + macosLoginSessionWatch: false + }) + launched.child.on('exit', () => { + daemonExited = true + }) + await control.ensureConnected() + const identity = control.getDaemonIdentity() + adapter = connectAdapter() + const spawned = await adapter.spawn({ + cols: 80, + rows: 24, + cwd: root, + shellOverride: '/bin/sh', + env: { HOME: root, SHELL: '/bin/sh', PS1: '' } + }) + adapter.write( + spawned.id, + 'stty -echo; ORCA_FLOOR_MEMORY=retained; printf \'FLOOR_%s_PID_%s\\n\' ready "$$"\r' + ) + await waitFor(() => /FLOOR_ready_PID_([0-9]+)/.test(output), 'initial shell output') + const shellPid = output.match(/FLOOR_ready_PID_([0-9]+)/)?.[1] + assert(shellPid) + adapter.resize(spawned.id, 103, 37) + assert.equal(daemonExited, false) + await control.request('ping', {}) + // Read the kernel size repeatedly because adapter resize delivery is asynchronous. + for (let attempt = 0; !output.includes('FLOOR_SIZE_37 103') && attempt < 50; attempt++) { + adapter.write(spawned.id, "printf 'FLOOR_SIZE_'; stty size\r") + await delay(50) + } + assert(output.includes('FLOOR_SIZE_37 103'), 'PTY kernel did not receive resize') + const snapshot = await adapter.getBufferSnapshot(spawned.id) + assert(snapshot?.data.includes(`FLOOR_ready_PID_${shellPid}`)) + await adapter.disconnectOnly() + adapter = undefined + const history = await new HistoryReader(historyPath).detectColdRestore(spawned.id) + assert( + history && + (history.snapshotAnsi + history.scrollbackAnsi).includes(`FLOOR_ready_PID_${shellPid}`) + ) + adapter = connectAdapter() + const attached = await adapter.spawn({ + sessionId: spawned.id, + attachOnly: true, + cols: 103, + rows: 37 + }) + assert.equal(attached.id, spawned.id) + assert.deepEqual(adapter.getDaemonIdentity(), identity) + adapter.write(spawned.id, 'printf \'FLOOR_RECONNECT_%s_PID_%s\\n\' "$ORCA_FLOOR_MEMORY" "$$"\r') + await waitFor( + () => output.includes(`FLOOR_RECONNECT_retained_PID_${shellPid}`), + 'retained shell after reconnect' + ) + adapter.write(spawned.id, 'exit 17\r') + await waitFor(() => exits.some((event) => event.id === spawned.id), 'observed shell exit') + assert.deepEqual(exits, [{ id: spawned.id, code: 17 }]) + await adapter.disconnectOnly() + adapter = undefined + await control.request('shutdown', { killSessions: true }) + await waitFor(() => daemonExited, 'daemon graceful shutdown') + return { + passed: true, + platform: process.platform, + arch: process.arch, + clientRuntime: process.version, + electronVersion: process.versions.electron, + runtimeSha256: createHash('sha256').update(readFileSync(runtime)).digest('hex'), + entrySha256: createHash('sha256').update(readFileSync(entry)).digest('hex'), + shellPid: Number(shellPid), + resize: true, + reconnect: true, + history: true, + exitCode: 17 + } + } finally { + adapter?.dispose() + control.disconnect() + if (launched && !daemonExited) { + await terminateLaunchedDaemonChild(launched.child) + } + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/src/main/daemon/node-pty-fd-leak.test.ts b/src/main/daemon/node-pty-fd-leak.test.ts deleted file mode 100644 index f021f7d48df4..000000000000 --- a/src/main/daemon/node-pty-fd-leak.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { execFileSync, spawn } from 'node:child_process' -import { once } from 'node:events' -import { existsSync, readdirSync, readFileSync, readlinkSync, renameSync } from 'node:fs' -import { setTimeout as delay } from 'node:timers/promises' -import * as pty from 'node-pty' -import { describe, expect, it } from 'vitest' -import { getNodePtySpawnHelperCandidates } from '../providers/local-pty-utils' - -function currentRevokedFdCount(): number { - return execFileSync('lsof', ['-p', String(process.pid)], { encoding: 'utf8' }) - .split('\n') - .filter((line) => line.includes('(revoked)')).length -} - -function currentOpenFdCount(): number { - return execFileSync('lsof', ['-p', String(process.pid)], { encoding: 'utf8' }) - .split('\n') - .filter((line) => line.trim().length > 0).length -} - -function getExistingSpawnHelper(): string { - const helperPath = getNodePtySpawnHelperCandidates().find((candidate) => existsSync(candidate)) - expect(helperPath).toBeTruthy() - return helperPath as string -} - -async function spawnExitingPty(index: number): Promise { - const proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: process.cwd(), - env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(index) } - }) - - await new Promise((resolve) => { - proc.onExit(() => resolve()) - }) - ;(proc as unknown as { destroy?: () => void }).destroy?.() -} - -const describeOnDarwin = process.platform === 'darwin' ? describe : describe.skip - -describeOnDarwin('node-pty macOS spawn fd handling', () => { - it('does not leak revoked slave tty fds across exited pty spawns', async () => { - const before = currentRevokedFdCount() - - for (let i = 0; i < 50; i++) { - await spawnExitingPty(i) - } - - await delay(500) - const after = currentRevokedFdCount() - - expect(after - before).toBe(0) - }, 15000) - - it('does not leak fds when native posix_spawn setup fails', async () => { - const helperPath = getExistingSpawnHelper() - const hiddenHelperPath = `${helperPath}.orca-test-hidden` - expect(existsSync(hiddenHelperPath)).toBe(false) - - const before = currentOpenFdCount() - renameSync(helperPath, hiddenHelperPath) - const restoreHelper = (): void => { - if (existsSync(hiddenHelperPath) && !existsSync(helperPath)) { - renameSync(hiddenHelperPath, helperPath) - } - } - process.on('exit', restoreHelper) - try { - for (let i = 0; i < 20; i++) { - expect(() => - pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: process.cwd(), - env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(i) } - }) - ).toThrow(/node-pty: posix_spawn failed: ENOENT/) - } - } finally { - restoreHelper() - process.off('exit', restoreHelper) - } - - await delay(500) - const after = currentOpenFdCount() - - expect(after - before).toBe(0) - }, 15000) -}) - -// Linux is the only platform where node-pty takes the forkpty() path, which has no atomic -// O_CLOEXEC. /proc is what makes the inheritance observable, so the assertions live here. -const describeOnLinux = process.platform === 'linux' ? describe : describe.skip - -const O_CLOEXEC = 0o2000000 - -const LISTING_READY = '__fd_listing_ready__' - -function ptyMasterFd(term: pty.IPty): number { - return (term as unknown as { fd: number }).fd -} - -function isCloseOnExec(fd: number): boolean { - const flags = /flags:\s*(\d+)/.exec(readFileSync(`/proc/self/fdinfo/${fd}`, 'utf8')) - expect(flags).toBeTruthy() - return (Number.parseInt(flags![1]!, 8) & O_CLOEXEC) !== 0 -} - -function openFdTargets(pid: number): string[] { - return readdirSync(`/proc/${pid}/fd`).map((entry) => { - try { - return readlinkSync(`/proc/${pid}/fd/${entry}`) - } catch { - return '' - } - }) -} - -describeOnLinux('node-pty Linux forkpty fd handling', () => { - it('marks pty masters close-on-exec so later children cannot inherit them', async () => { - const terms: pty.IPty[] = [] - let child: ReturnType | null = null - try { - for (let i = 0; i < 3; i++) { - terms.push( - pty.spawn('/bin/sh', ['-c', 'sleep 30'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: process.cwd(), - env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(i) } - }) - ) - } - - // The masters this process owns must not survive an exec in any child it forks later. - expect(terms.map((term) => isCloseOnExec(ptyMasterFd(term)))).toEqual([true, true, true]) - - child = spawn('/bin/sh', ['-c', 'sleep 5'], { stdio: 'ignore' }) - await once(child, 'spawn') - const inherited = openFdTargets(child.pid!).filter((target) => target.includes('ptmx')) - expect(inherited).toEqual([]) - } finally { - child?.kill() - for (const term of terms) { - term.kill() - } - } - }, 15000) - - it('does not hand an earlier pty master to a later pty child', async () => { - const first = pty.spawn('/bin/sh', ['-c', 'sleep 30'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: process.cwd(), - env: { ...process.env } - }) - try { - // Why the read: the child must not be able to run its listing before onData is armed, or an - // empty capture would satisfy the negative assertion without inspecting a single fd. - const second = pty.spawn( - '/bin/sh', - ['-c', `IFS= read -r _; printf '${LISTING_READY}\\n'; ls -l /proc/self/fd; exit 0`], - { - name: 'xterm-256color', - cols: 200, - rows: 24, - cwd: process.cwd(), - env: { ...process.env } - } - ) - let output = '' - second.onData((data) => { - output += data - }) - second.write('go\n') - await new Promise((resolve) => { - second.onExit(() => resolve()) - }) - await delay(100) - - // The listing is the evidence; assert it arrived before reading anything into its absence. - expect(output).toContain(LISTING_READY) - expect(output).toMatch(/\d+ -> \/dev\/pts\//) - expect(output).not.toMatch(/ptmx/) - } finally { - first.kill() - } - }, 15000) -}) diff --git a/src/main/daemon/node-pty-windows-input-error.win32.test.ts b/src/main/daemon/node-pty-windows-input-error.win32.test.ts deleted file mode 100644 index dd7a0e798379..000000000000 --- a/src/main/daemon/node-pty-windows-input-error.win32.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { Socket } from 'node:net' -import { spawn, type IPty } from 'node-pty' -import { describe, expect, it } from 'vitest' - -type WindowsPtyInternals = IPty & { - _agent: { inSocket: Socket } - _socket: Socket -} - -function waitForOutput(terminal: IPty, marker: string): Promise { - return new Promise((resolve, reject) => { - let output = '' - const timeout = setTimeout( - () => reject(new Error(`Timed out waiting for ${marker}; got ${output}`)), - 10_000 - ) - terminal.onData((chunk) => { - output += chunk - if (output.includes(marker)) { - clearTimeout(timeout) - resolve() - } - }) - }) -} - -function waitForExit(terminal: IPty): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error('Timed out waiting for the failed PTY to exit')), - 10_000 - ) - terminal.onExit(() => { - clearTimeout(timeout) - resolve() - }) - }) -} - -describe.skipIf(process.platform !== 'win32')('node-pty Windows input errors', () => { - it('retires only the failed PTY and keeps a witness writable after ConPTY EAGAIN', async () => { - const uncaught: unknown[] = [] - const uncaughtListener = (error: unknown): void => { - uncaught.push(error) - } - process.on('uncaughtException', uncaughtListener) - - let terminal: IPty | undefined - let witness: IPty | undefined - - try { - const options = { - cwd: process.cwd(), - env: process.env, - useConptyDll: false - } - terminal = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], options) - witness = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], options) - const input = (terminal as WindowsPtyInternals)._agent.inSocket - expect(input.listenerCount('error')).toBeGreaterThan(0) - expect(() => - input.emit('error', Object.assign(new Error('write EAGAIN'), { code: 'EAGAIN' })) - ).not.toThrow() - await waitForExit(terminal) - await new Promise((resolve) => setTimeout(resolve, 1_500)) - witness.write('echo ORCA_CONPTY_WITNESS\r') - await waitForOutput(witness, 'ORCA_CONPTY_WITNESS') - expect(uncaught).toEqual([]) - } finally { - try { - terminal?.kill() - } catch {} - try { - witness?.kill() - } catch {} - // ConPTY's worker drains asynchronously; keep the guard installed through - // the delayed close so cleanup cannot reintroduce an unhandled error. - await new Promise((resolve) => setTimeout(resolve, 1_500)) - process.off('uncaughtException', uncaughtListener) - } - }, 20_000) - - it('ignores a late output EPIPE after the PTY has closed', async () => { - const uncaught: unknown[] = [] - const uncaughtListener = (error: unknown): void => { - uncaught.push(error) - } - process.on('uncaughtException', uncaughtListener) - - let terminal: IPty | undefined - let exited = false - try { - terminal = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], { - cwd: process.cwd(), - env: process.env, - useConptyDll: false - }) - const output = (terminal as WindowsPtyInternals)._socket - const exit = waitForExit(terminal).then(() => { - exited = true - }) - terminal.kill() - await exit - - expect(() => { - output.emit( - 'error', - Object.assign(new Error('This socket has been ended by the other party'), { - code: 'EPIPE' - }) - ) - }).not.toThrow() - expect(uncaught).toEqual([]) - } finally { - if (!exited) { - try { - terminal?.kill() - } catch {} - } - await new Promise((resolve) => setTimeout(resolve, 1_500)) - process.off('uncaughtException', uncaughtListener) - } - }, 20_000) - - it('contains an output EPIPE that races with PTY shutdown', async () => { - const uncaught: unknown[] = [] - const uncaughtListener = (error: unknown): void => { - uncaught.push(error) - } - process.on('uncaughtException', uncaughtListener) - - let terminal: IPty | undefined - try { - terminal = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], { - cwd: process.cwd(), - env: process.env, - useConptyDll: false - }) - const output = (terminal as WindowsPtyInternals)._socket - const exit = waitForExit(terminal) - expect(() => { - output.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) - terminal?.kill() - }).not.toThrow() - await exit - expect(uncaught).toEqual([]) - } finally { - try { - terminal?.kill() - } catch {} - await new Promise((resolve) => setTimeout(resolve, 1_500)) - process.off('uncaughtException', uncaughtListener) - } - }, 20_000) -}) diff --git a/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts b/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts index 7f8131f7fcca..b66efe37b1ec 100644 --- a/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts +++ b/src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts @@ -1,8 +1,9 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // A canceled cwd probe must leave the daemon as the one cancellation identity // the wire carries. Clients key recovery off it, and an unrecognized message // takes the rollback branch that closes a terminal the user still has (#7718). import { describe, expect, it, vi } from 'vitest' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -20,11 +21,18 @@ const { validateWorkingDirectoryAsyncMock: vi.fn() })) -vi.mock('node-pty', () => ({ spawn: spawnMock })) -vi.mock('../pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) +})) +vi.mock('../pwsh', () => ({ + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock +})) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, resolveUnixShellPath: resolveUnixShellPathMock, diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 636b53d70a1f..075bfb3ff14b 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -1,7 +1,8 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // Which daemon process env reaches the spawned shell, and how PATH is composed. import { describe, expect, it, vi } from 'vitest' import { delimiter } from 'node:path' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -23,12 +24,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -47,8 +51,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, resolveUnixShellPath: resolveUnixShellPathMock, diff --git a/src/main/daemon/pty-subprocess-env-retention.test.ts b/src/main/daemon/pty-subprocess-env-retention.test.ts index c936fe28d308..e223641feb2f 100644 --- a/src/main/daemon/pty-subprocess-env-retention.test.ts +++ b/src/main/daemon/pty-subprocess-env-retention.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' -import type * as pty from 'node-pty' +import type { TerminalProcess } from '../../shared/terminal-process' import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' vi.mock('../pty/posix-pty-process-groups', () => ({ @@ -22,7 +22,6 @@ function nativePort() { cols: 80, rows: 24, process: 'audit-shell', - handleFlowControl: false, onData(listener: (value: string) => void) { data.on('data', listener) return { dispose: () => data.off('data', listener) } @@ -38,7 +37,7 @@ function nativePort() { clear: vi.fn(), kill: vi.fn(), destroy: vi.fn() - } satisfies pty.IPty & { destroy: () => void } + } satisfies TerminalProcess & { destroy: () => void } return { process: process_, emitData: (value: string) => data.emit('data', value), diff --git a/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts index 8d37578bcf30..63356c439347 100644 --- a/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts @@ -1,3 +1,4 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // Guards the daemon foreground identity against Windows scan degradation: a // timed-out CIM scan (available:false) or an incomplete snapshot (available:true // with the agent row missing) must not retire a still-working agent and make @@ -22,8 +23,15 @@ const { jobReadableMock: vi.fn() })) -vi.mock('node-pty', () => ({ spawn: spawnMock })) -vi.mock('../pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) +})) +vi.mock('../pwsh', () => ({ + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock +})) const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' diff --git a/src/main/daemon/pty-subprocess-foreground-identity.test.ts b/src/main/daemon/pty-subprocess-foreground-identity.test.ts index 76793cdbab20..b10320c339f1 100644 --- a/src/main/daemon/pty-subprocess-foreground-identity.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-identity.test.ts @@ -1,6 +1,7 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // Foreground process reporting: node-pty names, agent enrichment and its cache. import { describe, expect, it, vi } from 'vitest' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -22,12 +23,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -46,8 +50,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, getNodePtySpawnHelperCandidates: () => [import.meta.filename], diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts index 47513702b7ac..b8c020313b4b 100644 --- a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts @@ -1,3 +1,4 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // Regression guard: bound the volume of whole-process-table scans the daemon // schedules for persisted sessions. On Windows each agent-foreground refresh // forks a powershell.exe/CIM whole-table scan (the daemon-side analogue of @@ -10,19 +11,22 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn() })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -40,8 +44,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, getNodePtySpawnHelperCandidates: () => [import.meta.filename] diff --git a/src/main/daemon/pty-subprocess-git-credential-guard.test.ts b/src/main/daemon/pty-subprocess-git-credential-guard.test.ts index a073ee195093..8f0ec69bb956 100644 --- a/src/main/daemon/pty-subprocess-git-credential-guard.test.ts +++ b/src/main/daemon/pty-subprocess-git-credential-guard.test.ts @@ -1,6 +1,7 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // Git prompt/credential guard env layered onto the inherited daemon Git config. import { describe, expect, it, vi } from 'vitest' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -22,12 +23,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -46,8 +50,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, resolveUnixShellPath: resolveUnixShellPathMock, diff --git a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts index 6ab328400102..bd10ad12f995 100644 --- a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts +++ b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts @@ -1,6 +1,7 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // SubprocessHandle io forwarding plus kill/dispose neutralization contracts. import { describe, expect, it, vi } from 'vitest' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -22,12 +23,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -46,8 +50,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, getNodePtySpawnHelperCandidates: () => [import.meta.filename], @@ -77,7 +81,6 @@ vi.mock('../providers/windows-pty-job-membership', () => ({ import { createPtySubprocess } from './pty-subprocess' import { mockPtyProcess, useDaemonPtySubprocessEnv } from './pty-subprocess-test-harness' -import { __setConptyJobNativeForTests } from '../windows/windows-pty-job' describe('createPtySubprocess', () => { useDaemonPtySubprocessEnv({ @@ -420,14 +423,8 @@ describe('createPtySubprocess', () => { // but it made forceKill a permanent no-op, so a wedged ConPTY -- which // never fires onExit -- could not be escalated at all (#9854). The job // terminates the tree without touching the handle node-pty owns. - const terminateJob = vi.fn().mockReturnValue(true) - __setConptyJobNativeForTests(() => ({ - terminateJob, - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - const proc = mockPtyProcess(123456) as ReturnType & { _pty: number } - proc._pty = 11 + const terminateJob = vi.fn(() => 'terminated' as const) + const proc = { ...mockPtyProcess(123456), terminateOwnedTree: terminateJob } spawnMock.mockReturnValue(proc) const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) @@ -436,11 +433,10 @@ describe('createPtySubprocess', () => { handle.kill() handle.forceKill() - expect(terminateJob).toHaveBeenCalledWith(11, 123456) + expect(terminateJob).toHaveBeenCalledOnce() // Still exactly one native kill: the escalation must not double-close. expect(proc.kill).toHaveBeenCalledOnce() } finally { - __setConptyJobNativeForTests() restorePlatform(origPlatform) } }) diff --git a/src/main/daemon/pty-subprocess-io-failure-bun-fixture.ts b/src/main/daemon/pty-subprocess-io-failure-bun-fixture.ts new file mode 100644 index 000000000000..56bd348584f3 --- /dev/null +++ b/src/main/daemon/pty-subprocess-io-failure-bun-fixture.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { readdirSync, readlinkSync } from 'node:fs' +import { runProcessSync } from '../../shared/child-process/run-process' +import { spawnBunPty } from './pty-subprocess/bun-pty-process' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' +import { TerminalHost } from './terminal-host' + +function terminalDescriptors(): number { + if (process.platform === 'linux') { + return readdirSync('/proc/self/fd') + .flatMap((fd) => { + try { + return [readlinkSync(`/proc/self/fd/${fd}`)] + } catch { + return [] + } + }) + .filter((path) => /\/dev\/(ptmx|pts\/)/.test(path)).length + } + const result = runProcessSync({ + program: 'lsof', + args: ['-p', String(process.pid)], + timeoutMs: 5000 + }) + assert.equal(result.code, 0, result.stderr) + return result.stdout + .split('\n') + .filter((line) => /\/dev\/(ptmx|ttys[0-9])|\(revoked\)/.test(line)).length +} + +async function waitUntil(predicate: () => boolean): Promise { + const deadline = Date.now() + 3000 + while (!predicate()) { + assert.ok(Date.now() < deadline, 'PTY lifecycle deadline exceeded') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +export async function runNativeIoFailureFixture({ + operation, + immediate +}: { + operation: 'write' | 'resize' + immediate: boolean +}): Promise { + const before = terminalDescriptors() + for (let cycle = 0; cycle < 4; cycle++) { + const native = spawnBunPty({ + file: '/bin/sh', + args: [ + '-c', + 'printf "orca-cleanup-ready\\n"; while IFS= read -r line; do printf "reply:%s\\n" "$line"; done' + ], + cwd: process.cwd(), + cols: 80, + rows: 24, + env: { TERM: 'xterm-256color', PATH: '/usr/bin:/bin' } + }) + let exited = false + native.onExit(() => { + exited = true + }) + const handle = createDaemonPtySubprocessHandle({ + process: native, + shellPath: '/bin/sh', + spawnCwd: process.cwd(), + env: {}, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: 'native-io-failure', + startupAgentRecognition: null + }) + const host = new TerminalHost({ spawnSubprocess: () => handle }) + let output = '' + let exitEvents = 0 + try { + await host.createOrAttach({ + sessionId: 'native-io-failure', + cols: 80, + rows: 24, + streamClient: { + onData: (data) => { + output += data + }, + onExit: () => { + exitEvents++ + } + } + }) + await waitUntil(() => output.includes('orca-cleanup-ready')) + handle.resize(100, 30) + handle.write('roundtrip\n') + await waitUntil(() => output.includes('reply:roundtrip')) + host.pauseProducer('native-io-failure') + assert.equal(process.kill(native.pid, 0), true) + const originalWrite = native.write + const originalResize = native.resize + native[operation] = () => { + throw new Error('injected I/O failure') + } + if (operation === 'write') { + handle.write('ignored') + } else { + handle.resize(100, 30) + } + native.write = originalWrite + native.resize = originalResize + await host.kill('native-io-failure', { immediate }) + await waitUntil(() => exitEvents === 1) + assert.equal(host.listSessions().length, 0) + assert.throws(() => process.kill(native.pid, 0), { code: 'ESRCH' }) + } finally { + if (!exited) { + native.kill('SIGKILL') + } + await waitUntil(() => exited) + await host.dispose() + native.destroy() + } + assert.equal(terminalDescriptors(), before, 'native terminal descriptor leaked') + } + return 4 +} diff --git a/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts b/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts index b7ce586668b5..4f5bbbbf92d8 100644 --- a/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts +++ b/src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as pty from 'node-pty' import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' import { mockPtyProcess } from './pty-subprocess-test-harness' import { TerminalHost } from './terminal-host' @@ -23,13 +22,15 @@ vi.mock('../pty-descendant-termination', () => ({ function createFixture() { const proc = { ...mockPtyProcess(4242), + cols: 80, + rows: 24, destroy: vi.fn(), pause: vi.fn(), resume: vi.fn(), clear: vi.fn() } const handle = createDaemonPtySubprocessHandle({ - process: proc as unknown as pty.IPty, + process: proc, shellPath: 'bash', spawnCwd: process.cwd(), env: {}, diff --git a/src/main/daemon/pty-subprocess-io-failure-native.test.ts b/src/main/daemon/pty-subprocess-io-failure-native.test.ts index b1835c068a79..56711de4b635 100644 --- a/src/main/daemon/pty-subprocess-io-failure-native.test.ts +++ b/src/main/daemon/pty-subprocess-io-failure-native.test.ts @@ -1,12 +1,10 @@ -import { fstatSync } from 'node:fs' -import * as pty from 'node-pty' -import { describe, expect, it, vi } from 'vitest' -import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' -import { TerminalHost } from './terminal-host' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runBundledBunFixture } from '../bundled-bun-test-execution' const describePosix = process.platform === 'win32' ? describe.skip : describe -describePosix('failed-I/O teardown with a real native PTY', () => { +describePosix('failed-I/O teardown with a real bundled Bun PTY', () => { it.each([ ['write', false], ['write', true], @@ -15,83 +13,15 @@ describePosix('failed-I/O teardown with a real native PTY', () => { ] as const)( 'reaps real shells and master fds after %s failure (immediate=%s)', async (operation, immediate) => { - for (let cycle = 0; cycle < 4; cycle++) { - const native = pty.spawn( - '/bin/sh', - [ - '-c', - 'printf "orca-cleanup-ready\\n"; while IFS= read -r line; do printf "reply:%s\\n" "$line"; done' - ], - { - cwd: process.cwd(), - cols: 80, - rows: 24, - env: { TERM: 'xterm-256color', PATH: '/usr/bin:/bin' } - } + expect( + await runBundledBunFixture( + join(__dirname, 'pty-subprocess-io-failure-bun-fixture.ts'), + 'runNativeIoFailureFixture', + { operation, immediate }, + 15_000 ) - const fd = (native as pty.IPty & { fd: number }).fd - let exited = false - native.onExit(() => { - exited = true - }) - const handle = createDaemonPtySubprocessHandle({ - process: native, - shellPath: '/bin/sh', - spawnCwd: process.cwd(), - env: {}, - startupCommandDeliveredInShellArgs: false, - reportsChildExitStatus: true, - sessionId: 'native-io-failure', - startupAgentRecognition: null - }) - const host = new TerminalHost({ spawnSubprocess: () => handle }) - let output = '' - const onExit = vi.fn() - try { - await host.createOrAttach({ - sessionId: 'native-io-failure', - cols: 80, - rows: 24, - streamClient: { - onData: (data) => { - output += data - }, - onExit - } - }) - await vi.waitFor(() => expect(output).toContain('orca-cleanup-ready'), { timeout: 3000 }) - handle.resize(100, 30) - handle.write('roundtrip\n') - await vi.waitFor(() => expect(output).toContain('reply:roundtrip'), { timeout: 3000 }) - host.pauseProducer('native-io-failure') - expect(process.kill(native.pid, 0)).toBe(true) - const failure = vi.spyOn(native, operation).mockImplementation(() => { - throw new Error('injected I/O failure') - }) - if (operation === 'write') { - handle.write('ignored') - } else { - handle.resize(100, 30) - } - failure.mockRestore() - - await host.kill('native-io-failure', { immediate }) - await vi.waitFor(() => expect(onExit).toHaveBeenCalledOnce(), { timeout: 3000 }) - expect(host.listSessions()).toHaveLength(0) - expect(() => process.kill(native.pid, 0)).toThrow( - expect.objectContaining({ code: 'ESRCH' }) - ) - expect(() => fstatSync(fd)).toThrow(expect.objectContaining({ code: 'EBADF' })) - } finally { - // Only this test's still-owned native child is eligible for emergency cleanup. - if (!exited) { - native.kill('SIGKILL') - } - await vi.waitFor(() => expect(exited).toBe(true), { timeout: 3000 }) - await host.dispose() - } - } + ).toBe(4) }, - 15000 + 20_000 ) }) diff --git a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts index d5dbc33246c2..419a88a02dee 100644 --- a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts +++ b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts @@ -1,6 +1,7 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // Daemon-managed agent env: shell wrapper survival and overlay deletion. import { describe, expect, it, vi } from 'vitest' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -22,12 +23,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -46,8 +50,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, resolveUnixShellPath: resolveUnixShellPathMock, diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts index 94e352ce6954..1dca7b2176e1 100644 --- a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -1,4 +1,4 @@ -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../../shared/terminal-process' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ProcessTableRow } from '../../shared/process-table-snapshot' import type * as SnapshotReader from '../../shared/process-table-snapshot-reader' @@ -68,11 +68,10 @@ function table(command: string | null, loginWrapper = false): ProcessTableRow[] } function createHandle(loginWrapper = false) { - const proc: IPty & { processNameIsSpawnFile: true } = { + const proc: TerminalProcess & { processNameIsSpawnFile: true } = { pid: 100, cols: 80, rows: 24, - handleFlowControl: false, process: loginWrapper ? '/Applications/Orca shell login' : '/bin/zsh', processNameIsSpawnFile: true, onData: () => ({ dispose() {} }), diff --git a/src/main/daemon/pty-subprocess-test-harness.ts b/src/main/daemon/pty-subprocess-test-harness.ts index 623dd20bf632..697362fc9687 100644 --- a/src/main/daemon/pty-subprocess-test-harness.ts +++ b/src/main/daemon/pty-subprocess-test-harness.ts @@ -1,4 +1,4 @@ -// Shared node-pty stub, PowerShell path stubs and daemon env lifecycle for the +// Shared terminal stub, PowerShell path stubs and daemon env lifecycle for the // pty-subprocess test files. `vi.mock` calls must stay in each test file. import { afterEach, beforeEach, vi } from 'vitest' import type { Mock } from 'vitest' diff --git a/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts b/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts index 5831274a9c6f..4be768f707cf 100644 --- a/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts +++ b/src/main/daemon/pty-subprocess-windows-shell-launch.test.ts @@ -1,6 +1,7 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // Native Windows shell launch: PowerShell implementations, cmd.exe and Git Bash. import { describe, expect, it, vi } from 'vitest' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -22,12 +23,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -46,8 +50,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, resolveUnixShellPath: resolveUnixShellPathMock, diff --git a/src/main/daemon/pty-subprocess-windows-shell-probe.test.ts b/src/main/daemon/pty-subprocess-windows-shell-probe.test.ts new file mode 100644 index 000000000000..fe15e13ae2e7 --- /dev/null +++ b/src/main/daemon/pty-subprocess-windows-shell-probe.test.ts @@ -0,0 +1,165 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' +// Native Windows shell launch: PowerShell implementations, cmd.exe and Git Bash. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' + +const { + spawnMock, + isPwshAvailableMock, + validateWorkingDirectoryMock, + resolveUnixShellPathMock, + resolveAgentForegroundProcessMock +} = vi.hoisted(() => ({ + spawnMock: vi.fn(), + isPwshAvailableMock: vi.fn(), + resolveUnixShellPathMock: vi.fn((shellPath: string) => shellPath), + resolveAgentForegroundProcessMock: vi.fn(), + validateWorkingDirectoryMock: vi.fn((cwd: string) => { + if (cwd.includes('definitely-missing')) { + throw new Error( + `Working directory "${cwd}" does not exist. It may have been deleted or is on an unmounted volume.` + ) + } + }) +})) + +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) +})) + +vi.mock('../pwsh', () => ({ + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock +})) + +// Resolve PowerShell family names to deterministic absolute paths so these +// tests run on non-Windows CI. The real resolver (which skips the Store App +// Execution Alias stub) is exercised in windows-powershell-executable.test.ts. +const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' +const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' +const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' +vi.mock('../providers/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('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveUnixShellPath: resolveUnixShellPathMock, + validateWorkingDirectory: validateWorkingDirectoryMock, + validateWorkingDirectoryAsync: validateWorkingDirectoryMock + } +}) + +vi.mock('../providers/agent-foreground-process', () => ({ + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => { + const value = await resolveAgentForegroundProcessMock(...args) + return value && typeof value === 'object' && 'available' in value + ? value + : { available: true, processName: value } + } +})) + +// Console-membership reads run a real node-pty fork that never settles under +// fake timers; default to "shell-only" so the degraded-scan guard falls through +// to its existing retirement logic (the degraded-scan behavior itself is +// covered in pty-subprocess-foreground-degraded-scan.test.ts). +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: () => new Set([12345]), + isWindowsPtyJobReadable: () => true +})) + +import { createPtySubprocess } from './pty-subprocess' +import { mockPtyProcess, useDaemonPtySubprocessEnv } from './pty-subprocess-test-harness' + +const POWERSHELL_OSC133_COMMAND_ARGS = ['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)] + +describe('createPtySubprocess', () => { + useDaemonPtySubprocessEnv({ + spawnMock, + isPwshAvailableMock, + resolveUnixShellPathMock, + resolveAgentForegroundProcessMock, + validateWorkingDirectoryMock + }) + + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + beforeEach(() => + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + ) + afterEach(() => Object.defineProperty(process, 'platform', platform)) + + it('waits for the async automatic-shell probe before selecting PowerShell 7', async () => { + let release!: (available: boolean) => void + isPwshAvailableMock.mockReturnValue( + new Promise((resolve) => { + release = resolve + }) + ) + spawnMock.mockReturnValue(mockPtyProcess()) + const pending = createPtySubprocess({ + sessionId: 'automatic-shell', + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + await vi.waitFor(() => expect(isPwshAvailableMock).toHaveBeenCalledOnce()) + expect(spawnMock).not.toHaveBeenCalled() + release(true) + await pending + expect(spawnMock).toHaveBeenCalledWith( + PWSH7_ABS, + POWERSHELL_OSC133_COMMAND_ARGS, + expect.any(Object) + ) + }) + + it('cancels the caller waiting for a shell probe without publishing a later native spawn', async () => { + let release!: (available: boolean) => void + isPwshAvailableMock.mockReturnValue( + new Promise((resolve) => { + release = resolve + }) + ) + const abort = new AbortController() + const pending = createPtySubprocess({ + sessionId: 'canceled-shell', + cols: 80, + rows: 24, + shellOverride: 'powershell.exe', + cancelSignal: abort.signal + }) + const rejected = expect(pending).rejects.toThrow('Attach canceled') + await vi.waitFor(() => expect(isPwshAvailableMock).toHaveBeenCalledOnce()) + abort.abort() + await rejected + release(true) + await new Promise((resolve) => setImmediate(resolve)) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('does not wait on availability for an explicit PowerShell 7 selection', async () => { + spawnMock.mockReturnValue(mockPtyProcess()) + await createPtySubprocess({ + sessionId: 'explicit-shell', + cols: 80, + rows: 24, + shellOverride: 'pwsh.exe' + }) + expect(isPwshAvailableMock).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledWith( + PWSH7_ABS, + POWERSHELL_OSC133_COMMAND_ARGS, + expect.any(Object) + ) + }) +}) diff --git a/src/main/daemon/pty-subprocess-wsl-launch.test.ts b/src/main/daemon/pty-subprocess-wsl-launch.test.ts index 067dc729aadb..7161f1a7640b 100644 --- a/src/main/daemon/pty-subprocess-wsl-launch.test.ts +++ b/src/main/daemon/pty-subprocess-wsl-launch.test.ts @@ -1,9 +1,10 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' // WSL routing: distro selection, cwd translation and env import marking. import { describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -25,12 +26,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -49,8 +53,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, resolveUnixShellPath: resolveUnixShellPathMock, diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 100adaafaf58..cc4b4b8f9bb0 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1,9 +1,10 @@ -// Spawn setup: node-pty launch options, Unix shell resolution and daemon cwd repair. +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' +// Spawn setup: Bun PTY launch options, Unix shell resolution and daemon cwd repair. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type * as LocalPtyUtils from '../providers/local-pty-utils' +import type * as PtySpawnValidation from '../providers/pty-spawn-validation' const { spawnMock, @@ -25,12 +26,15 @@ const { }) })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: ({ file, args, ...options }: BunPtySpawnArgs) => + spawnMock(file, args, { ...options, name: options.env.TERM ?? 'xterm-256color' }) })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailable: isPwshAvailableMock, + isPwshAvailableAsync: isPwshAvailableMock })) // Resolve PowerShell family names to deterministic absolute paths so these @@ -49,8 +53,8 @@ vi.mock('../providers/windows-powershell-executable', () => ({ getWindowsCmdPath: () => CMD_ABS })) -vi.mock('../providers/local-pty-utils', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../providers/pty-spawn-validation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, resolveUnixShellPath: resolveUnixShellPathMock, @@ -68,7 +72,7 @@ vi.mock('../providers/agent-foreground-process', () => ({ } })) -// Console-membership reads run a real node-pty fork that never settles under +// Console-membership reads run a real Bun PTY fork that never settles under // fake timers; default to "shell-only" so the degraded-scan guard falls through // to its existing retirement logic (the degraded-scan behavior itself is // covered in pty-subprocess-foreground-degraded-scan.test.ts). @@ -98,7 +102,7 @@ describe('createPtySubprocess', () => { validateWorkingDirectoryMock }) - it('spawns node-pty with correct options', async () => { + it('spawns Bun PTY with correct options', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const onMacosTccSpawnStrategy = vi.fn() @@ -156,7 +160,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('does not report a spawn strategy when node-pty fails before launch', async () => { + it('does not report a spawn strategy when Bun PTY fails before launch', async () => { spawnMock.mockImplementationOnce(() => { throw new Error('spawn failed') }) @@ -193,7 +197,7 @@ describe('createPtySubprocess', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) }) - it('resolves a missing Unix default before spawning node-pty', async () => { + it('resolves a missing Unix default before spawning Bun PTY', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) resolveUnixShellPathMock.mockReturnValue('/bin/sh') @@ -285,7 +289,7 @@ describe('createPtySubprocess', () => { } }) - it('surfaces the no-executable-shell error before node-pty forks', async () => { + it('surfaces the no-executable-shell error before Bun PTY forks', async () => { resolveUnixShellPathMock.mockImplementation(() => { throw new Error('No executable Unix shell found (tried: /bin/zsh, /bin/bash, /bin/sh)') }) @@ -304,7 +308,7 @@ describe('createPtySubprocess', () => { } }) - it('uses bundled ConPTY for native Windows daemon terminals', async () => { + it('passes Windows dimensions to the Bun terminal backend', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -327,7 +331,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( expect.any(String), expect.any(Array), - expect.objectContaining({ useConptyDll: true }) + expect.objectContaining({ cols: 80, rows: 24 }) ) }) @@ -354,7 +358,7 @@ describe('createPtySubprocess', () => { expect(spawnCall[2].env[POWERLEVEL10K_WIZARD_DISABLE_ENV]).toBe('true') }) - itOnMacHost('repairs a deleted macOS daemon cwd before spawning node-pty', async () => { + itOnMacHost('repairs a deleted macOS daemon cwd before spawning Bun PTY', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -388,7 +392,7 @@ describe('createPtySubprocess', () => { ) }) - itOnPosixHost('repairs a deleted POSIX daemon cwd before Linux node-pty spawn', async () => { + itOnPosixHost('repairs a deleted POSIX daemon cwd before Linux Bun PTY spawn', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -515,7 +519,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('rejects a missing explicit POSIX cwd before node-pty spawn', async () => { + it('rejects a missing explicit POSIX cwd before Bun PTY spawn', async () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'linux' }) spawnMock.mockClear() diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 1b5bc3d11021..d913d72e45e1 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,6 +1,7 @@ import type { SubprocessHandle } from './session-subprocess-handle' import { normalizePtySize } from './daemon-pty-size' import { TerminalAttachCanceledError } from './daemon-errors' +import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import { createDaemonPtyEnvironment } from './pty-subprocess/spawn-environment' import { createPtyShellLaunchPlan } from './pty-subprocess/shell-launch-plan' import { spawnNativeDaemonPty, type SpawnedDaemonPty } from './pty-subprocess/native-pty-spawn' @@ -70,7 +71,11 @@ export async function checkPtySpawnHealth(): Promise { export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { const size = normalizePtySize(opts.cols, opts.rows) const env = createDaemonPtyEnvironment(opts) - const launch = createPtyShellLaunchPlan(opts, env) + const launch = await waitForTerminalAttachOperation( + createPtyShellLaunchPlan(opts, env), + opts.cancelSignal, + opts.sessionId + ) await preflightPtySpawn({ validationCwd: launch.validationCwd, diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts b/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts index b43dd57a659d..b20d52a9a3f7 100644 --- a/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts +++ b/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts @@ -1,5 +1,6 @@ -import type * as pty from 'node-pty' +import type { TerminalProcess } from '../../../shared/terminal-process' import type { JobTerminationOutcome } from '../../windows/windows-pty-job' +import type { WindowsBunPtyJobNative } from './windows-bun-pty-native' import type { WindowsBunPtyJob } from './windows-bun-pty-job' import type { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' @@ -41,7 +42,8 @@ export type BunRuntime = { ): BunSubprocess } -export type BunPtyProcess = pty.IPty & { +export type BunPtyProcess = TerminalProcess & { + handleFlowControl: boolean destroy(): void processNameIsSpawnFile?: true jobRootProcessIsWrapper?: true @@ -59,13 +61,18 @@ export type BunPtySpawnArgs = { env: Record cols: number rows: number + windowsJobKillOnClose?: boolean } export type SpawnBunPtyDeps = { platform?: NodeJS.Platform runtime?: BunRuntime assignHostJob?: () => boolean - createJob?: (pid: number) => WindowsBunPtyJob | null + createJob?: ( + pid: number, + native?: WindowsBunPtyJobNative | null, + killOnClose?: boolean + ) => WindowsBunPtyJob | null createWindowsLaunch?: typeof createWindowsBunPtyLaunch readProcessTable?: () => string signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts index 5b7dfd7a3133..f3ca7c538c54 100644 --- a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts +++ b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts @@ -117,7 +117,7 @@ export function createBunPtyProducerFlowControl( // Partial signals require a fresh transition even if the requested state changes again. state = 'uncertain' if (nextPaused) { - // Signal delivery is asynchronous; prove the shell stopped before suspending its jobs. + // Confirm root signal delivery; a login wrapper may still have a separately running shell. if (!isPosixPtyRootStopped(table, options.processHandle.pid)) { retryTransition() return diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts b/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts index b64097950433..7cde797340fc 100644 --- a/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts +++ b/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts @@ -1,4 +1,5 @@ import { constants } from 'node:os' +import { createBunPtyTerminalIo } from './bun-pty-terminal-io' import { assignCurrentProcessToBunPtyHostJob, createWindowsBunPtyJob, @@ -34,10 +35,6 @@ export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): let exited = false let exitCode = 0 let exitSignal: number | undefined - // Keep a closed Bun native handle from escaping as a daemon RPC failure. - let terminalUnavailable = false - let appliedCols = args.cols - let appliedRows = args.rows const emitData = (data: string): void => { if (dataListeners.size === 0) { @@ -138,7 +135,11 @@ export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): } if (windowsLaunch) { try { - windowsJob = (deps.createJob ?? createWindowsBunPtyJob)(processHandle.pid) + windowsJob = (deps.createJob ?? createWindowsBunPtyJob)( + processHandle.pid, + undefined, + args.windowsJobKillOnClose === true + ) if (!windowsJob) { throw new Error('Windows Bun PTY job ownership is unavailable') } @@ -233,6 +234,13 @@ export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): } } + const terminalIo = createBunPtyTerminalIo( + processHandle.terminal, + args, + () => exited, + () => terminate('SIGKILL') + ) + return { pid: processHandle.pid, get shellProcessId() { @@ -243,11 +251,13 @@ export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): clear() {}, process: args.file, get cols() { - return appliedCols + return terminalIo.cols }, get rows() { - return appliedRows + return terminalIo.rows }, + write: terminalIo.write, + resize: terminalIo.resize, onData(listener) { if (pendingData) { const data = pendingData @@ -268,28 +278,6 @@ export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): exitListeners.add(listener) return { dispose: () => exitListeners.delete(listener) } }, - write(data) { - if (exited || terminalUnavailable || processHandle.terminal.closed) { - return - } - try { - processHandle.terminal.write(data) - } catch { - terminalUnavailable = true - } - }, - resize(cols, rows) { - if (exited || terminalUnavailable || processHandle.terminal.closed) { - return - } - try { - processHandle.terminal.resize(cols, rows) - appliedCols = cols - appliedRows = rows - } catch { - terminalUnavailable = true - } - }, ...clearCapability, ...producerFlowControl, ...windowsCapabilities, diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts index 1c4f0436d6f6..929d5894a009 100644 --- a/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts +++ b/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts @@ -4,7 +4,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' +import { ORCAD_BUN_VERSION, orcadBunVersionProbeArgs } from '../../../shared/orcad-bun-runtime' import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' const runtimePath = @@ -12,9 +12,9 @@ const runtimePath = resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) async function runTerminalScript(script: string): Promise { - expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( - ORCAD_BUN_VERSION - ) + expect( + runProcessSync({ program: runtimePath, args: [...orcadBunVersionProbeArgs()] }).stdout.trim() + ).toBe(ORCAD_BUN_VERSION) const directory = mkdtempSync(join(tmpdir(), 'orca-bun-terminal-')) try { const entry = join(directory, 'terminal.cjs') diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.test.ts index 6726524f53d3..61018ba15753 100644 --- a/src/main/daemon/pty-subprocess/bun-pty-process.test.ts +++ b/src/main/daemon/pty-subprocess/bun-pty-process.test.ts @@ -72,9 +72,10 @@ function createBunHarness({ closeImmediately = true } = {}) { } } -function spawn(deps?: Parameters[1]) { +function spawn(deps?: Parameters[1], windowsJobKillOnClose = false) { return spawnBunPty( { + windowsJobKillOnClose, file: '/bin/sh', args: ['-l'], cwd: '/tmp', @@ -284,30 +285,129 @@ describe('Bun.Terminal PTY adapter', () => { expect(harness.terminal.close).not.toHaveBeenCalled() }) - it('contains a native terminal write failure and suppresses later writes', () => { - const harness = createBunHarness() - harness.terminal.write = vi.fn(() => { - throw new Error('terminal closed') + it('retires only the failed writer and waits for observed termination', async () => { + const failed = createBunHarness() + failed.terminal.write = vi.fn(() => { + throw new Error('native write failed') }) const proc = spawn() + const onExit = vi.fn() + proc.onExit(onExit) + const witness = createBunHarness() + const witnessProc = spawn() expect(() => proc.write('first')).not.toThrow() proc.write('second') + proc.resize(100, 30) + witnessProc.write('still live') + witnessProc.resize(90, 30) - expect(harness.terminal.write).toHaveBeenCalledOnce() + expect(failed.terminal.write).toHaveBeenCalledOnce() + expect(failed.processHandle.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL') + expect(failed.terminal.resize).not.toHaveBeenCalled() + expect(onExit).not.toHaveBeenCalled() + expect(witness.processHandle.kill).not.toHaveBeenCalled() + expect(witness.terminal.write).toHaveBeenCalledWith('still live') + expect(witness.terminal.resize).toHaveBeenCalledWith(90, 30) + + failed.resolveExit(137) + await failed.processHandle.exited + expect(onExit).toHaveBeenCalledExactlyOnceWith({ exitCode: 137 }) }) - it('contains a native terminal resize failure and suppresses later resizes', () => { + it('keeps input and future resize usable after a failed resize on an open terminal', () => { const harness = createBunHarness() - harness.terminal.resize = vi.fn(() => { - throw new Error('terminal closed') + vi.mocked(harness.terminal.resize).mockImplementationOnce(() => { + throw new Error('Failed to resize terminal') }) const proc = spawn() expect(() => proc.resize(120, 40)).not.toThrow() + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 80, rows: 24 }) + proc.write('still live') proc.resize(100, 30) - expect(harness.terminal.resize).toHaveBeenCalledOnce() + expect(harness.terminal.resize).toHaveBeenCalledTimes(2) + expect(harness.terminal.write).toHaveBeenCalledWith('still live') + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 100, rows: 30 }) + expect(harness.processHandle.kill).not.toHaveBeenCalled() + }) + + it('retires a terminal that closes during resize without inventing its exit', async () => { + const harness = createBunHarness() + vi.mocked(harness.terminal.resize).mockImplementationOnce(() => { + harness.terminal.closed = true + throw new Error('Terminal is closed') + }) + const proc = spawn() + const onExit = vi.fn() + proc.onExit(onExit) + expect(() => proc.resize(120, 40)).not.toThrow() + expect(harness.processHandle.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL') + expect(onExit).not.toHaveBeenCalled() + harness.resolveExit(137) + await harness.processHandle.exited + expect(onExit).toHaveBeenCalledExactlyOnceWith({ exitCode: 137 }) + }) + + it('retries a failed kill even when the native terminal closed during the write', () => { + const harness = createBunHarness() + harness.terminal.write = vi.fn(() => { + harness.terminal.closed = true + throw new Error('native write failed') + }) + harness.processHandle.kill.mockImplementationOnce(() => { + throw new Error('kill failed') + }) + const proc = spawn() + expect(() => proc.write('first')).not.toThrow() + expect(() => proc.write('second')).not.toThrow() + expect(harness.processHandle.kill).toHaveBeenCalledTimes(2) + expect(harness.terminal.write).toHaveBeenCalledOnce() + }) + + it('retires the owned Windows job after write failure and drains output before exit', async () => { + const harness = createBunHarness({ closeImmediately: false }) + harness.terminal.write = vi.fn(() => { + throw new Error('broken pipe') + }) + const job = { + listProcessIds: () => [4321], + pause: () => true, + resume: () => true, + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + const proc = spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => job, + createWindowsLaunch: () => ({ + command: ['cmd.exe'], + clearCommand: ['cmd.exe'], + env: {}, + windowsVerbatimArguments: true, + release() {}, + dispose() {}, + async waitForSpawn() {}, + readShellProcessId: () => 4321 + }) + }) + const onExit = vi.fn() + const onData = vi.fn() + proc.onExit(onExit) + proc.onData(onData) + proc.write('failed') + expect(job.terminate).toHaveBeenCalledOnce() + expect(onExit).not.toHaveBeenCalled() + harness.resolveExit(1) + await harness.processHandle.exited + expect(onExit).not.toHaveBeenCalled() + harness.emitData(new TextEncoder().encode('last output')) + harness.finishTerminal() + expect(onData).toHaveBeenCalledWith('last output') + expect(onExit).toHaveBeenCalledExactlyOnceWith({ exitCode: 1 }) + expect(job.close).toHaveBeenCalledOnce() }) it('pauses and resumes the POSIX producer process group once per transition', async () => { @@ -376,111 +476,114 @@ describe('Bun.Terminal PTY adapter', () => { ]) }) - it('gates a Windows shell behind exact job ownership and exposes owned capabilities', async () => { - const harness = createBunHarness({ closeImmediately: false }) - const assignHostJob = vi.fn(() => true) - const release = vi.fn() - const dispose = vi.fn() - const waitForSpawn = vi.fn(async () => {}) - let reportedShellPid: number | undefined - const job = { - listProcessIds: vi.fn(() => [4321, 4322]), - pause: vi.fn(() => true), - resume: vi.fn(() => true), - terminate: vi.fn(() => 'terminated' as const), - close: vi.fn() - } - const createJob = vi.fn(() => job) - const createWindowsLaunch = vi.fn(() => ({ - command: ['cmd.exe', '/d /c launch.cmd'], - clearCommand: ['cmd.exe', '/d /c clear.cmd'], - env: { TERM: 'xterm-256color', ORCA_BUN_PTY_JOB_GATE: 'gate' }, - windowsVerbatimArguments: true as const, - release, - dispose, - waitForSpawn, - readShellProcessId: () => reportedShellPid - })) - const proc = spawn({ - platform: 'win32', - assignHostJob, - createJob, - createWindowsLaunch - }) - - expect(harness.spawn.mock.calls[0]?.[0]).toEqual(['cmd.exe', '/d /c launch.cmd']) - expect(harness.spawn.mock.calls[0]?.[1]).toMatchObject({ - windowsVerbatimArguments: true, - env: { ORCA_BUN_PTY_JOB_GATE: 'gate' }, - terminal: harness.terminal - }) - expect(assignHostJob.mock.invocationCallOrder[0]).toBeLessThan( - harness.spawn.mock.invocationCallOrder[0] - ) - expect(createJob).toHaveBeenCalledWith(4321) - expect(createJob.mock.invocationCallOrder[0]).toBeLessThan(release.mock.invocationCallOrder[0]) - await proc.waitForSpawn?.() - expect(waitForSpawn).toHaveBeenCalledWith(harness.processHandle.exited) - - proc.pause() - proc.pause() - proc.resume() - proc.resume() - expect(job.pause).toHaveBeenCalledOnce() - expect(job.resume).toHaveBeenCalledOnce() - expect(proc.jobRootProcessIsWrapper).toBe(true) - expect(readWindowsPtyJobProcessIds(proc)).toBeNull() - expect(harness.spawn.mock.calls[0]?.[1]).not.toHaveProperty('ipc') - reportedShellPid = 4322 - expect(proc.shellProcessId).toBe(4322) - expect(readWindowsPtyJobProcessIds(proc)).toEqual(new Set([4322])) - job.listProcessIds.mockReturnValueOnce([4321, 4323]) - expect(readWindowsPtyJobProcessIds(proc)).toBeNull() - expect(proc.shellProcessId).toBe(4322) - expect(proc.listOwnedProcessIds?.()).toEqual([4321, 4322]) - expect(proc.terminateOwnedTree?.()).toBe('terminated') - - job.terminate.mockClear() - proc.signalProcess?.('SIGINT') - expect(job.terminate).toHaveBeenCalledOnce() - expect(harness.processHandle.kill).not.toHaveBeenCalled() - - proc.clear() - proc.clear() - expect(harness.spawn.mock.calls[1]?.[0]).toEqual(['cmd.exe', '/d /c clear.cmd']) - expect(harness.spawn.mock.calls[1]?.[1]).toMatchObject({ - terminal: harness.terminal, - windowsVerbatimArguments: true - }) - expect(harness.spawn).toHaveBeenCalledTimes(2) + it.each([false, true])( + 'gates Windows job ownership with kill-on-close=%s', + async (killOnClose) => { + const harness = createBunHarness({ closeImmediately: false }) + const assignHostJob = vi.fn(() => true) + const release = vi.fn() + const dispose = vi.fn() + const waitForSpawn = vi.fn(async () => {}) + let reportedShellPid: number | undefined + const job = { + listProcessIds: vi.fn(() => [4321, 4322]), + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + const createJob = vi.fn(() => job) + const createWindowsLaunch = vi.fn(() => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: { TERM: 'xterm-256color', ORCA_BUN_PTY_JOB_GATE: 'gate' }, + windowsVerbatimArguments: true as const, + release, + dispose, + waitForSpawn, + readShellProcessId: () => reportedShellPid + })) + const proc = spawn( + { platform: 'win32', assignHostJob, createJob, createWindowsLaunch }, + killOnClose + ) + + expect(harness.spawn.mock.calls[0]?.[0]).toEqual(['cmd.exe', '/d /c launch.cmd']) + expect(harness.spawn.mock.calls[0]?.[1]).toMatchObject({ + windowsVerbatimArguments: true, + env: { ORCA_BUN_PTY_JOB_GATE: 'gate' }, + terminal: harness.terminal + }) + expect(assignHostJob.mock.invocationCallOrder[0]).toBeLessThan( + harness.spawn.mock.invocationCallOrder[0] + ) + expect(createJob).toHaveBeenCalledWith(4321, undefined, killOnClose) + expect(createJob.mock.invocationCallOrder[0]).toBeLessThan( + release.mock.invocationCallOrder[0] + ) + await proc.waitForSpawn?.() + expect(waitForSpawn).toHaveBeenCalledWith(harness.processHandle.exited) - const lastOutput = vi.fn() - const onExit = vi.fn() - proc.onData(lastOutput) - proc.onExit(onExit) - harness.resolveExit(0) - await harness.processHandle.exited - await Promise.resolve() - expect(onExit).not.toHaveBeenCalled() - expect(job.close).not.toHaveBeenCalled() - harness.emitData(new TextEncoder().encode('final ConPTY frame')) - harness.finishTerminal() - expect(lastOutput).toHaveBeenCalledWith('final ConPTY frame') - expect(onExit).toHaveBeenCalledOnce() - expect(job.close).toHaveBeenCalledOnce() - expect(dispose).toHaveBeenCalledOnce() - expect(job.resume).toHaveBeenCalledOnce() - job.resume.mockImplementation(() => { - throw new Error('job already closed') - }) - expect(() => { + proc.pause() proc.pause() proc.resume() - proc.kill() - proc.destroy() - }).not.toThrow() - expect(job.resume).toHaveBeenCalledOnce() - }) + proc.resume() + expect(job.pause).toHaveBeenCalledOnce() + expect(job.resume).toHaveBeenCalledOnce() + expect(proc.jobRootProcessIsWrapper).toBe(true) + expect(readWindowsPtyJobProcessIds(proc)).toBeNull() + expect(harness.spawn.mock.calls[0]?.[1]).not.toHaveProperty('ipc') + reportedShellPid = 4322 + expect(proc.shellProcessId).toBe(4322) + expect(readWindowsPtyJobProcessIds(proc)).toEqual(new Set([4322])) + job.listProcessIds.mockReturnValueOnce([4321, 4323]) + expect(readWindowsPtyJobProcessIds(proc)).toBeNull() + expect(proc.shellProcessId).toBe(4322) + expect(proc.listOwnedProcessIds?.()).toEqual([4321, 4322]) + expect(proc.terminateOwnedTree?.()).toBe('terminated') + + job.terminate.mockClear() + proc.signalProcess?.('SIGINT') + expect(job.terminate).toHaveBeenCalledOnce() + expect(harness.processHandle.kill).not.toHaveBeenCalled() + + proc.clear() + proc.clear() + expect(harness.spawn.mock.calls[1]?.[0]).toEqual(['cmd.exe', '/d /c clear.cmd']) + expect(harness.spawn.mock.calls[1]?.[1]).toMatchObject({ + terminal: harness.terminal, + windowsVerbatimArguments: true + }) + expect(harness.spawn).toHaveBeenCalledTimes(2) + + const lastOutput = vi.fn() + const onExit = vi.fn() + proc.onData(lastOutput) + proc.onExit(onExit) + harness.resolveExit(0) + await harness.processHandle.exited + await Promise.resolve() + expect(onExit).not.toHaveBeenCalled() + expect(job.close).not.toHaveBeenCalled() + harness.emitData(new TextEncoder().encode('final ConPTY frame')) + harness.finishTerminal() + expect(lastOutput).toHaveBeenCalledWith('final ConPTY frame') + expect(onExit).toHaveBeenCalledOnce() + expect(job.close).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(job.resume).toHaveBeenCalledOnce() + job.resume.mockImplementation(() => { + throw new Error('job already closed') + }) + expect(() => { + proc.pause() + proc.resume() + proc.kill() + proc.destroy() + }).not.toThrow() + expect(job.resume).toHaveBeenCalledOnce() + } + ) it('does not release a Windows gate without exact job ownership', async () => { const harness = createBunHarness() diff --git a/src/main/daemon/pty-subprocess/bun-pty-terminal-io.ts b/src/main/daemon/pty-subprocess/bun-pty-terminal-io.ts new file mode 100644 index 000000000000..139c1fcf7f98 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-terminal-io.ts @@ -0,0 +1,71 @@ +import type { BunTerminal } from './bun-pty-process-contract' + +export function createBunPtyTerminalIo( + terminal: BunTerminal, + dimensions: { cols: number; rows: number }, + isExited: () => boolean, + terminate: () => void +) { + let cols = dimensions.cols + let rows = dimensions.rows + let state: 'active' | 'failed' | 'retiring' = 'active' + + const retire = (): void => { + try { + terminate() + state = 'retiring' + } catch (error) { + console.warn('[daemon/pty] Failed to retire PTY:', error) + } + } + const canUseTerminal = (): boolean => { + if (isExited()) { + return false + } + if (state === 'failed') { + retire() + } + return state === 'active' && !terminal.closed + } + const fail = (error: unknown): void => { + console.warn('[daemon/pty] Native terminal I/O failed; retiring PTY:', error) + state = 'failed' + retire() + } + + return { + get cols() { + return cols + }, + get rows() { + return rows + }, + write(data: string | Buffer) { + if (!canUseTerminal()) { + return + } + try { + terminal.write(data) + } catch (error) { + // Bun buffers backpressure internally; replaying a failed write can duplicate its prefix. + fail(error) + } + }, + resize(nextCols: number, nextRows: number) { + if (!canUseTerminal()) { + return + } + try { + terminal.resize(nextCols, nextRows) + cols = nextCols + rows = nextRows + } catch (error) { + if (terminal.closed) { + fail(error) + } else { + console.warn('[daemon/pty] Native terminal resize failed:', error) + } + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/bun-tests/bash-reply-echo-bun-fixture.ts b/src/main/daemon/pty-subprocess/bun-tests/bash-reply-echo-bun-fixture.ts new file mode 100644 index 000000000000..94e1291c1d0a --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-tests/bash-reply-echo-bun-fixture.ts @@ -0,0 +1,61 @@ +import { spawnBunPty } from '../bun-pty-process' +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline && !predicate()) { + await sleep(25) + } +} + +/** + * Writes `reply` to the master once bash is settled, and returns what came back. + * `discipline: 'cooked'` parks bash in `read` first, which restores ICANON+ECHO. + */ +export async function captureBashReplyEcho({ + reply, + discipline +}: { + reply: string + discipline: 'readline' | 'cooked' +}): Promise { + let output = '' + const pty = spawnBunPty({ + file: '/bin/bash', + args: ['--norc', '--noprofile', '-i'], + cwd: process.cwd(), + cols: 80, + rows: 24, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), + PS1: 'ORCA16542> ', + TERM: 'xterm-256color' + } + }) + const exited = new Promise((resolve) => pty.onExit(() => resolve())) + try { + pty.onData((data) => { + output += data + }) + + await waitFor(() => output.includes('ORCA16542> '), 10_000) + if (discipline === 'cooked') { + pty.write('read -r ORCA_LINE\r') + await sleep(400) + } + output = '' + pty.write(reply) + // No marker to wait on: the echo is all this produces, so settle instead. + await waitFor(() => output.length > 0, 5_000) + await sleep(250) + return output + } finally { + pty.kill() + await exited + pty.destroy() + } +} diff --git a/src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-fixture.ts b/src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-fixture.ts new file mode 100644 index 000000000000..8e0a4203cd8f --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-fixture.ts @@ -0,0 +1,59 @@ +import { createServer, type Socket } from 'node:net' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { runBundledBunFixture } from '../../../bundled-bun-test-execution' +import type { BunPtySpawnArgs } from '../bun-pty-process-contract' + +/** Keeps live observers in Vitest while their POSIX PTY runs under pinned Bun. */ +export async function startBundledBunPty(args: BunPtySpawnArgs) { + const socketPath = join(args.cwd, 'terminal.sock') + const server = createServer() + const connected = new Promise((resolve) => server.once('connection', resolve)) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + const fixture = runBundledBunFixture( + join(__dirname, 'bundled-bun-pty-host-fixture.ts'), + 'runBundledBunPty', + { socketPath, args }, + 35_000 + ) + try { + const socket = await Promise.race([ + connected, + fixture.then(() => { + throw new Error('Bun PTY fixture exited before connecting') + }) + ]) + const pid = Number(readFileSync(`${socketPath}.pid`, 'utf8')) + if (!Number.isSafeInteger(pid) || pid <= 0) { + socket.destroy() + throw new Error('Invalid Bun PTY fixture process ID') + } + socket.setEncoding('utf8') + // Retain asynchronous fixture failures for dispose(), without an unhandled rejection. + void fixture.catch(() => {}) + return { + pid, + kill: () => { + socket.destroy() + }, + write: (data: string) => { + socket.write(data) + }, + onData: (listener: (data: string) => void) => { + socket.on('data', listener) + }, + onExit: (listener: () => void) => { + socket.on('end', listener) + }, + dispose: async () => { + socket.destroy() + await fixture + } + } + } finally { + server.close() + } +} diff --git a/src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-host-fixture.ts b/src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-host-fixture.ts new file mode 100644 index 000000000000..95981bc3a5b6 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-tests/bundled-bun-pty-host-fixture.ts @@ -0,0 +1,39 @@ +import { createConnection } from 'node:net' +import { writeFileSync } from 'node:fs' +import { spawnBunPty } from '../bun-pty-process' +import type { BunPtySpawnArgs } from '../bun-pty-process-contract' + +export async function runBundledBunPty({ + socketPath, + args +}: { + socketPath: string + args: BunPtySpawnArgs +}): Promise { + const term = spawnBunPty(args) + writeFileSync(`${socketPath}.pid`, String(term.pid), { mode: 0o600 }) + const socket = createConnection(socketPath) + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', (error) => { + term.destroy() + reject(error) + }) + }) + socket.setEncoding('utf8') + const exited = new Promise((resolve) => term.onExit(({ exitCode }) => resolve(exitCode))) + socket.on('data', (data: string) => term.write(data)) + socket.on('close', () => term.kill()) + socket.on('error', () => term.kill()) + term.onData((data) => { + if (!socket.destroyed) { + socket.write(data) + } + }) + try { + return await exited + } finally { + term.destroy() + socket.end() + } +} diff --git a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts b/src/main/daemon/pty-subprocess/bun-tests/fish-color-scheme-child-stdin.bun.test.ts similarity index 93% rename from src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts rename to src/main/daemon/pty-subprocess/bun-tests/fish-color-scheme-child-stdin.bun.test.ts index 36768745bd61..7fffb886f743 100644 --- a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts +++ b/src/main/daemon/pty-subprocess/bun-tests/fish-color-scheme-child-stdin.bun.test.ts @@ -21,6 +21,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import type * as React from 'react' +import { z } from 'zod' +import { startBundledBunPty } from './bundled-bun-pty-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fishRequirementViolation, @@ -60,12 +62,14 @@ let storeSubscribers: ((state: MutableState) => void)[] = [] let transportFactoryQueue: unknown[] = [] vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) -vi.mock('./terminal-webgl-atlas-recovery', () => ({ +vi.mock('@/components/terminal-pane/terminal-webgl-atlas-recovery', () => ({ scheduleTerminalWebglAtlasRecovery: vi.fn() })) vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: vi.fn() } })) -vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false) })) +vi.mock('@/components/terminal-pane/cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false) +})) vi.mock('@/store', () => ({ useAppStore: { getState: () => mockStoreState, @@ -82,7 +86,7 @@ vi.mock('react', async (importOriginal) => ({ ...(await importOriginal()), useCallback: (fn: T): T => fn })) -vi.mock('./pty-transport', () => ({ +vi.mock('@/components/terminal-pane/pty-transport', () => ({ createIpcPtyTransport: vi.fn(() => { const next = transportFactoryQueue.shift() if (!next) { @@ -161,7 +165,7 @@ function createPane(paneId: number): Record { } } -function createDeps(): Record { +function createDeps() { return { tabId: 'tab-1', worktreeId: 'wt-1', @@ -409,7 +413,7 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( markTerminalPaneUnread: vi.fn(), markAgentCompletionPaneUnread: vi.fn() } - ;(globalThis as unknown as { window: unknown }).window = { + vi.stubGlobal('window', { api: { pty: { kill: vi.fn(), @@ -447,18 +451,19 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( dispatchEvent: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() - } - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - callback(0) - return 1 }) - globalThis.cancelAnimationFrame = vi.fn() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) }) afterEach(() => { - delete (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame - delete (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame - delete (globalThis as { window?: unknown }).window + vi.unstubAllGlobals() if (configHome) { rmSync(configHome, { recursive: true, force: true }) configHome = null @@ -468,8 +473,15 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( itWithFish( 'writes no CSI 997 while fish arms mode 2031 around its prompt, so a child reads clean stdin', async () => { - const { connectPanePty } = await import('./pty-connection') - const nodePty = await import('node-pty') + // The renderer module is typechecked by tc:web, separately from this native PTY harness. + const rendererModule = '../../../../renderer/src/components/terminal-pane/pty-connection' + const { connectPanePty } = z + .object({ + connectPanePty: z.custom< + (pane: never, manager: never, deps: never) => { dispose(): void } + >((value) => typeof value === 'function') + }) + .parse(await import(rendererModule)) configHome = mkdtempSync(path.join(tmpdir(), 'orca-fish-2031-')) mkdirSync(path.join(configHome, 'fish'), { recursive: true }) @@ -500,8 +512,12 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( "process.stdout.write('CHILD-READY\\n')\n" ) - const term = nodePty.spawn(FISH_BIN as string, ['-l', '-i'], { - name: 'xterm-256color', + if (!FISH_BIN) { + throw new Error('Fish binary missing') + } + const term = await startBundledBunPty({ + file: FISH_BIN, + args: ['-l', '-i'], cols: 120, rows: 30, cwd: configHome, @@ -527,7 +543,7 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( // on DA1 and re-probes every prompt. These are harness bytes, never renderer output. term.onData((chunk) => { rendered += chunk - // Maximal fragmentation keeps query handling independent of node-pty chunk boundaries. + // Maximal fragmentation keeps query handling independent of PTY chunk boundaries. for (const fragment of chunk) { answerTerminalQueries.accept(fragment) } @@ -542,16 +558,16 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( try { const pane = createPane(1) const deps = createDeps() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the pane, manager and dependency members used by this real PTY binding. const binding = connectPanePty(pane as never, createManager(pane) as never, deps as never) - expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true) + expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000), rendered).toBe(true) // The arm is the settle signal. - expect(await waitUntil(() => rendered.includes(ARM_2031), 5_000)).toBe(true) + expect(await waitUntil(() => rendered.includes(ARM_2031), 5_000), rendered).toBe(true) // Vacuity guard: the renderer observer must actually have seen the subscribe, // otherwise "sent nothing" is trivially true. Checked here and not at the end, // because the last decision races between fish's subscribe and its withdrawal. - const paneMode2031 = (deps as { paneMode2031Ref: { current: Map } }) - .paneMode2031Ref.current + const paneMode2031 = deps.paneMode2031Ref.current expect(await waitUntil(() => paneMode2031.get(1) === true, 5_000)).toBe(true) // Type-ahead is the deterministic leak shape: queue the child command while an @@ -595,11 +611,7 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( } finally { term.write('exit\r') await waitUntil(() => exited, 3_000) - try { - term.kill() - } catch { - // already gone - } + await term.dispose() } }, 30_000 diff --git a/src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin-bun-fixture.ts b/src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin-bun-fixture.ts new file mode 100644 index 000000000000..0c861c26229a --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin-bun-fixture.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { spawnBunPty } from '../bun-pty-process' +import { PtyStartupIngress } from '../../../../shared/pty-startup-ingress' +const PROMPT_MARK = 'ORCA13892> ' + +/* oxlint-disable no-control-regex -- terminal query grammars are control sequences */ +/** Anchored at an ESC, first match wins; reply values match xterm.js's. */ +const QUERY_GRAMMARS = [ + { + re: /^\x1b\]1[012];\?(\x07|\x1b\\)/, + reply: (m: RegExpExecArray) => `\x1b]11;rgb:1e1e/1e1e/1e1e${m[1]}` + }, + { re: /^\x1b\[\?6n/, reply: () => '\x1b[?1;1;1R' }, + { re: /^\x1b\[6n/, reply: () => '\x1b[1;1R' }, + { re: /^\x1b\[\?996n/, reply: () => '\x1b[?997;1n' }, + { re: /^\x1b\[>0?c/, reply: () => '\x1b[>0;276;0c' }, + { re: /^\x1b\[0?c/, reply: () => '\x1b[?1;2c' }, + { re: /^\x1b\[>0?q/, reply: () => '\x1bP>|Orca\x1b\\' }, + { re: /^\x1b\[\?u/, reply: () => '\x1b[?0u' } +] as const +/** Still accumulating: no CSI final byte and no OSC/DCS terminator yet. */ +const PARTIAL_QUERY_RE = + /^(?:\x1b|\x1b\[[?>=]?[0-9;]*|\x1b\][0-9]*(?:;[^\x07\x1b]*)?\x1b?|\x1bP[^\x1b]*\x1b?)$/ +/* oxlint-enable no-control-regex */ + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) { + return true + } + await sleep(10) + } + return false +} + +export async function runFishQueryReplyFixture({ + binary, + configHome, + childScript, + nodeBinary +}: { + binary: string + configHome: string + childScript: string + nodeBinary: string +}): Promise { + const term = spawnBunPty({ + file: binary, + args: ['-l', '-i'], + cols: 120, + rows: 30, + cwd: configHome, + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: configHome, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + LANG: 'en_US.UTF-8', + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: path.join(configHome, 'data'), + ORCA_NODE_BIN: nodeBinary, + ORCA_CHILD_SCRIPT: childScript + } + }) + + let rendered = '' + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + write: (data) => term.write(data), + onEmission: (emission) => { + rendered += emission.data + answerQueriesInOrder(emission.data) + } + }) + + // The host gate: cooked-echo-risk replies are written by the ingress with their + // echo shapes armed; DA1/CPR stay on the host's own path, in call order. + const hostWrite = (data: string): void => { + if (ingress.answerLiveQueryReply(data)) { + return + } + term.write(data) + } + + let tail = '' + let oscQueryCount = 0 + function answerQueriesInOrder(chunk: string): void { + let buffer = tail + chunk + tail = '' + let index = 0 + while (index < buffer.length) { + const at = buffer.indexOf('\x1b', index) + if (at === -1) { + return + } + const rest = buffer.slice(at) + const grammar = QUERY_GRAMMARS.map((candidate) => ({ + candidate, + match: candidate.re.exec(rest) + })).find((entry) => entry.match) + if (grammar?.match) { + if (grammar.candidate === QUERY_GRAMMARS[0]) { + oscQueryCount += 1 + } + hostWrite(grammar.candidate.reply(grammar.match)) + index = at + grammar.match[0].length + continue + } + if (PARTIAL_QUERY_RE.test(rest)) { + tail = rest + return + } + index = at + 1 + } + } + + let exited = false + term.onExit(() => { + exited = true + }) + term.onData((data) => ingress.accept(data)) + + try { + assert.equal(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000), true) + await sleep(500) + const oscQueriesBeforeHandoff = oscQueryCount + + // Type-ahead is the deterministic shape: queue the child's command while an + // external command still owns the tty, so fish repaints its prompt (re-querying + // OSC 11) and hands the tty over in the same breath. + term.write('sleep 0.4\r') + await sleep(150) + term.write('"$ORCA_NODE_BIN" "$ORCA_CHILD_SCRIPT"\r') + await sleep(1_500) + assert.ok(oscQueryCount > oscQueriesBeforeHandoff) + + const renderedBeforeChildInput = rendered.length + term.write('hello\r') + assert.equal( + await waitUntil( + () => rendered.slice(renderedBeforeChildInput).includes('CHILD-READ:'), + 10_000 + ), + true + ) + + const childRead = + rendered.slice(renderedBeforeChildInput).match(/CHILD-READ:[^\r\n]*/)?.[0] ?? '' + // The merge-blocking assertion: the child's first LINE is what the user typed, + // with no escape byte in front of it. Pre-fix this reads + // `\u001b]11;rgb:1e1e/1e1e/1e1e\u001b\\hello\n`. + assert.equal(childRead, 'CHILD-READ:"hello\\n"') + } finally { + term.write('exit\r') + await waitUntil(() => exited, 3_000) + if (!exited) { + term.kill() + } + assert.equal(await waitUntil(() => exited, 3_000), true) + term.destroy() + } +} diff --git a/src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin.bun.test.ts b/src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin.bun.test.ts new file mode 100644 index 000000000000..88b7daf468d8 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-tests/fish-query-reply-child-stdin.bun.test.ts @@ -0,0 +1,83 @@ +/** + * Real-fish regression for #13892: a terminal query reply Orca held back is overtaken + * by the DA1 answer written later in the same turn, so fish's read sentinel hands the + * tty to the child while the OSC 11 reply is still queued — and the CHILD READS IT. + * + * What is real here: bundled Bun running fish, `PtyStartupIngress`, `PtyStartupReplyDelivery` + * and both echo probes, plus the host's own write gate + * (`answerLiveQueryReply` models the historical write gate that deferred replies). Only the renderer is modelled: it + * answers queries strictly in the order they appear in the stream, so any inversion the + * child sees was produced by the delivery split and nothing else. + * + * The assertion is about a CHILD PROCESS'S STDIN, not the screen: a rendered-output check + * passes while the bytes are still being eaten by the next `npx` / `brew` confirm prompt. + * The child reads a full LINE because a leaked reply carries no newline, so a + * once('data') child would report it alone whenever it happened to land in its own read. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fishRequirementViolation, resolveFishBinary } from '../../../../shared/fish-binary-requirement' +import { runBundledBunFixture } from '../../../bundled-bun-test-execution' + +// Why fish 4: the DA1-sentinel handoff this measures lives in the 4.0 Rust tty_handoff. +const FISH = resolveFishBinary(4) +const itWithFish = FISH.available ? it : it.skip + +const PROMPT_MARK = 'ORCA13892> ' + +describe('a held query reply never reaches the next child process (#13892)', () => { + let configHome: string | null = null + + // Always runs, so the CI lane cannot report green with the regression below skipped. + it('has the fish this suite needs when CI requires one', () => { + expect(fishRequirementViolation(FISH)).toBeNull() + }) + + afterEach(() => { + if (configHome) { + rmSync(configHome, { recursive: true, force: true }) + configHome = null + } + }) + + itWithFish( + 'answers OSC 11 in the query turn so the reply cannot land in the child’s stdin', + async () => { + configHome = mkdtempSync(path.join(tmpdir(), 'orca-fish-13892-')) + mkdirSync(path.join(configHome, 'fish'), { recursive: true }) + writeFileSync( + path.join(configHome, 'fish/config.fish'), + [ + 'set -g fish_greeting ""', + `function fish_prompt; printf '${PROMPT_MARK}'; end`, + 'function fish_right_prompt; end', + '' + ].join('\n') + ) + const childScript = path.join(configHome, 'read-stdin.mjs') + writeFileSync( + childScript, + "let buffered = ''\n" + + "process.stdin.on('data', (d) => {\n" + + " buffered += d.toString('utf8')\n" + + " if (!buffered.includes('\\n')) return\n" + + " process.stdout.write('CHILD-READ:' + JSON.stringify(buffered) + '\\n')\n" + + ' process.exit(0)\n' + + '})\n' + ) + + if (!FISH.path) { + throw new Error('Fish binary missing') + } + await runBundledBunFixture( + path.join(__dirname, 'fish-query-reply-child-stdin-bun-fixture.ts'), + 'runFishQueryReplyFixture', + { binary: FISH.path, configHome, childScript, nodeBinary: process.execPath }, + 40_000 + ) + }, + 45_000 + ) +}) diff --git a/src/shared/pty-reply-echo-shapes.node-pty.test.ts b/src/main/daemon/pty-subprocess/bun-tests/pty-reply-echo-shapes.bun.test.ts similarity index 65% rename from src/shared/pty-reply-echo-shapes.node-pty.test.ts rename to src/main/daemon/pty-subprocess/bun-tests/pty-reply-echo-shapes.bun.test.ts index 0fe06c7cae5f..96a93a6d27e2 100644 --- a/src/shared/pty-reply-echo-shapes.node-pty.test.ts +++ b/src/main/daemon/pty-subprocess/bun-tests/pty-reply-echo-shapes.bun.test.ts @@ -1,3 +1,6 @@ +import { z } from 'zod' +import { join } from 'node:path' +import { runBundledBunFixture } from '../../../bundled-bun-test-execution' /** * Keeps the transcript in pty-startup-reply-echo-shapes.test.ts honest. * @@ -12,9 +15,9 @@ * cooked — under `read`, the kernel echoes via ECHOCTL */ import { existsSync } from 'node:fs' -import { afterEach, describe, expect, it } from 'vitest' -import { locateEcho, replyEchoProjections } from './pty-startup-reply-echo-shapes' -import { mode2031SequenceFor } from './terminal-color-scheme-protocol' +import { describe, expect, it } from 'vitest' +import { locateEcho, replyEchoProjections } from '../../../../shared/pty-startup-reply-echo-shapes' +import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' const BASH = '/bin/bash' const itWithBash = process.platform !== 'win32' && existsSync(BASH) ? it : it.skip @@ -22,53 +25,17 @@ const itWithBash = process.platform !== 'win32' && existsSync(BASH) ? it : it.sk const COLOR_SCHEME_REPLY = mode2031SequenceFor('dark') const OSC_COLOR_REPLY_ST = '\x1b]11;rgb:2e2e/3434/3434\x1b\\' -type Pty = { write: (data: string) => void; kill: () => void } - -let live: Pty | null = null - -afterEach(() => { - live?.kill() - live = null -}) - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline && !predicate()) { - await sleep(25) - } -} - -/** - * Writes `reply` to the master once bash is settled, and returns what came back. - * `discipline: 'cooked'` parks bash in `read` first, which restores ICANON+ECHO. - */ async function echoOf(reply: string, discipline: 'readline' | 'cooked'): Promise { - const { spawn } = await import('node-pty') - let output = '' - const pty = spawn(BASH, ['--norc', '--noprofile', '-i'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - env: { ...process.env, PS1: 'ORCA16542> ', TERM: 'xterm-256color' } - }) - live = { write: (data) => pty.write(data), kill: () => pty.kill() } - pty.onData((data) => { - output += data - }) - - await waitFor(() => output.includes('ORCA16542> '), 10_000) - if (discipline === 'cooked') { - pty.write('read -r ORCA_LINE\r') - await sleep(400) - } - output = '' - pty.write(reply) - // No marker to wait on: the echo is all this produces, so settle instead. - await waitFor(() => output.length > 0, 5_000) - await sleep(250) - return output + return z + .string() + .parse( + await runBundledBunFixture( + join(__dirname, 'bash-reply-echo-bun-fixture.ts'), + 'captureBashReplyEcho', + { reply, discipline }, + 20_000 + ) + ) } describe('replyEchoProjections against a real bash pty', () => { diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 7a96877c204f..b955608b2171 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -1,4 +1,4 @@ -import type * as pty from 'node-pty' +import type { TerminalProcess } from '../../../shared/terminal-process' import { ptyShellProcessId } from '../../windows/windows-pty-job' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation' @@ -49,7 +49,7 @@ export type PtyForegroundProcessTracker = { } export function createPtyForegroundProcessTracker(args: { - process: pty.IPty + process: TerminalProcess shellPath: string cwd?: string sessionId: string diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts index 26a3fcda7dc7..8f033bab73ba 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts @@ -1,11 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -const { nodePtyFactory, wrapShellSpawnMock } = vi.hoisted(() => ({ - nodePtyFactory: vi.fn(() => ({ spawn: vi.fn() })), +const { wrapShellSpawnMock } = vi.hoisted(() => ({ wrapShellSpawnMock: vi.fn((file: string, args: string[]) => ({ file, args })) })) -vi.mock('node-pty', nodePtyFactory) vi.mock('../../providers/macos-tcc-login-shell', () => ({ hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login', wrapShellSpawnForMacosTccAttribution: wrapShellSpawnMock @@ -14,6 +12,25 @@ vi.mock('../../providers/macos-tcc-login-shell', () => ({ import { spawnNativeDaemonPty } from './native-pty-spawn' describe('native PTY runtime selection', () => { + it('refuses an unsupported runtime before attempting any shell', async () => { + const spawnBunPty = vi.fn() + await expect( + spawnNativeDaemonPty( + { + shellPath: '/bin/sh', + shellArgs: [], + spawnCwd: '/tmp', + env: {}, + cols: 80, + rows: 24, + windowsFallbackAttempts: [] + }, + { canUseBunPty: () => false, spawnBunPty } + ) + ).rejects.toThrow('requires the bundled Bun') + expect(spawnBunPty).not.toHaveBeenCalled() + }) + it('spawns with Bun.Terminal without loading node-pty', async () => { const dispose = vi.fn() const spawnBunPty = vi.fn(() => ({ @@ -48,7 +65,6 @@ describe('native PTY runtime selection', () => { expect(result.process.pid).toBe(9876) expect(spawnBunPty).toHaveBeenCalledOnce() - expect(nodePtyFactory).not.toHaveBeenCalled() }) it('applies the macOS login wrapper before selecting the Bun PTY runtime', async () => { diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index e89e8a5aac42..6f30fe3faf72 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -1,20 +1,16 @@ -import type * as pty from 'node-pty' +import type { TerminalProcess } from '../../../shared/terminal-process' import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution } from '../../providers/macos-tcc-login-shell' import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' -import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' import { canUseBunPty, spawnBunPty } from './bun-pty-process' import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt' -async function loadNodePty(): Promise { - return import('node-pty') -} export type SpawnedDaemonPty = { - process: pty.IPty + process: TerminalProcess shellPath: string spawnCwd: string startupCommandDeliveredInShellArgs?: boolean @@ -42,55 +38,40 @@ export async function spawnNativeDaemonPty( }, runtime: NativePtyRuntime = { canUseBunPty, spawnBunPty } ): Promise { + args.signal?.throwIfAborted() + if (!runtime.canUseBunPty()) { + throw new Error('Terminal service requires the bundled Bun runtime') + } let reportsChildExitStatus = true const spawnAt = async ( shellPath: string, shellArgs: string[], cwd: string - ): Promise => { + ): Promise => { args.signal?.throwIfAborted() const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) - if (runtime.canUseBunPty()) { - const proc = runtime.spawnBunPty({ - file: wrapped.file, - args: wrapped.args, - cwd, - env: args.env, - cols: args.cols, - rows: args.rows - }) - try { - if (proc.waitForSpawn) { - await waitForPromiseWithSignal(proc.waitForSpawn(), args.signal) - } - args.signal?.throwIfAborted() - } catch (error) { - try { - proc.destroy() - } catch (cleanupError) { - console.warn('[daemon/pty] Failed shell launch cleanup failed:', cleanupError) - } - throw error - } - args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') - return proc - } - const nodePty = await loadNodePty() - // Why: children inherit job membership, so the host job must exist before the first Windows PTY. - if (process.platform === 'win32') { - assignHostProcessToKillOnCloseJob() - } - const proc = nodePty.spawn(wrapped.file, wrapped.args, { - name: args.env.TERM ?? 'xterm-256color', - cols: args.cols, - rows: args.rows, + const proc = runtime.spawnBunPty({ + file: wrapped.file, + args: wrapped.args, cwd, env: args.env, - // Why: bundled ConPTY has the wrap-marker behavior xterm expects. - ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + cols: args.cols, + rows: args.rows }) - reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) + try { + if (proc.waitForSpawn) { + await waitForPromiseWithSignal(proc.waitForSpawn(), args.signal) + } + args.signal?.throwIfAborted() + } catch (error) { + try { + proc.destroy() + } catch (cleanupError) { + console.warn('[daemon/pty] Failed shell launch cleanup failed:', cleanupError) + } + throw error + } args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') return proc } diff --git a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts index b4f6aa502f80..92558be92b1a 100644 --- a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts +++ b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts @@ -1,4 +1,4 @@ -import type * as pty from 'node-pty' +import type { TerminalProcess } from '../../../shared/terminal-process' import { ptyShellProcessId } from '../../windows/windows-pty-job' import { confirmShellForegroundProcess } from '../../providers/agent-foreground-process' import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' @@ -7,7 +7,7 @@ import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-mem * a post-request process inspection (POSIX `ps`, Windows job membership), * never cached state. */ export async function confirmPtyShellForeground(args: { - process: pty.IPty + process: TerminalProcess shellPath: string isDead: () => boolean }): Promise { diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index b50ef12baac2..1d87fa61acaf 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -1,7 +1,7 @@ import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' import { win32 as pathWin32 } from 'node:path' import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash' -import { isPwshAvailable } from '../../pwsh' +import { isPwshAvailableAsync } from '../../pwsh' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env' import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env' import { @@ -25,7 +25,7 @@ import { ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, resolveWindowsShellLaunchArgs } from '../../providers/windows-shell-args' -import { resolveUnixShellPath } from '../../providers/local-pty-utils' +import { resolveUnixShellPath } from '../../providers/pty-spawn-validation' import { selectShellStartupFeatures } from '../../shell-startup-features' import { parseWslPath } from '../../wsl' import { addWslEnvKeys } from '../../wsl-env' @@ -50,10 +50,10 @@ export type PtyShellLaunchPlan = { startupAgentRecognition: RecognizedAgentProcess | null } -export function createPtyShellLaunchPlan( +export async function createPtyShellLaunchPlan( opts: PtySubprocessOptions, env: Record -): PtyShellLaunchPlan { +): Promise { const resolvedWslContext = resolveWslSessionContext(opts) let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] @@ -92,7 +92,7 @@ export function createPtyShellLaunchPlan( ? (resolveEffectiveWindowsPowerShell({ shellFamily: resolvedShellFamily, implementation: opts.terminalWindowsPowerShellImplementation, - pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false + pwshAvailable: shouldProbePwsh ? await isPwshAvailableAsync() : false }) ?? shellPath) : shellPath } diff --git a/src/main/daemon/pty-subprocess/shell-launch-profile.test.ts b/src/main/daemon/pty-subprocess/shell-launch-profile.test.ts new file mode 100644 index 000000000000..01738d1c5e2e --- /dev/null +++ b/src/main/daemon/pty-subprocess/shell-launch-profile.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPtyShellLaunchPlan } from './shell-launch-plan' + +vi.mock('../../providers/pty-spawn-validation', () => ({ + resolveUnixShellPath: (shell: string) => shell +})) +vi.mock('../shell-ready', () => ({ + resolvePtyShellPath: (env: Record) => env.SHELL || '/bin/zsh', + getShellLaunchConfig: () => ({ args: ['-l'], env: {} }) +})) +afterEach(() => vi.restoreAllMocks()) + +const base = { sessionId: 'profile-shell', cwd: '/tmp', cols: 80, rows: 24 } +describe.skipIf(process.platform === 'win32')('daemon Unix shell profiles', () => { + it.each(['/bin/bash', '/bin/zsh', '/usr/bin/fish', '/usr/bin/nu'])( + 'uses the configured executable %s ahead of the ambient shell', + async (shell) => { + expect( + await createPtyShellLaunchPlan({ ...base, shellOverride: shell }, { SHELL: '/bin/sh' }) + ).toMatchObject({ shellPath: shell, shellArgs: ['-l'] }) + } + ) + it('uses the environment shell without a configured override', async () => { + expect(await createPtyShellLaunchPlan(base, { SHELL: '/bin/zsh' })).toMatchObject({ + shellPath: '/bin/zsh' + }) + }) + it.each([{ args: [] }, { args: ['--rcfile', '/tmp/orca rc'] }])( + 'preserves configured argument vector %j', + async ({ args }) => { + expect( + await createPtyShellLaunchPlan( + { ...base, shellOverride: '/bin/bash', terminalShellArgs: args }, + {} + ) + ).toMatchObject({ shellPath: '/bin/bash', shellArgs: args }) + } + ) + it.each([{ command: 'codex' }, { launchAgent: 'codex' as const }])( + 'keeps controlled login arguments for agent launches %o', + async (overrides) => { + expect( + await createPtyShellLaunchPlan( + { + ...base, + shellOverride: '/bin/zsh', + terminalShellArgs: ['--rcfile', '/tmp/orca'], + ...overrides + }, + {} + ) + ).toMatchObject({ shellArgs: ['-l'] }) + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-environment-client-overrides.test.ts b/src/main/daemon/pty-subprocess/spawn-environment-client-overrides.test.ts new file mode 100644 index 000000000000..26c61e7110fc --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment-client-overrides.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDaemonPtyEnvironment, finalizeDaemonPtyEnvironment } from './spawn-environment' + +const spawn = { sessionId: 'override-test', cols: 80, rows: 24 } +afterEach(() => vi.unstubAllEnvs()) + +describe('daemon client environment authority', () => { + it('preserves an explicit wizard preference and honors deletion of its default', () => { + expect( + createDaemonPtyEnvironment({ + ...spawn, + env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'already-set' } + }).POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD + ).toBe('already-set') + expect( + createDaemonPtyEnvironment({ + ...spawn, + envToDelete: ['POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'] + }).POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD + ).toBeUndefined() + }) + + it('does not resurrect stale inherited Git config slots behind an explicit smaller count', () => { + vi.stubEnv('GIT_CONFIG_COUNT', '2') + vi.stubEnv('GIT_CONFIG_KEY_0', 'base.zero') + vi.stubEnv('GIT_CONFIG_VALUE_0', 'zero') + vi.stubEnv('GIT_CONFIG_KEY_1', 'base.one') + vi.stubEnv('GIT_CONFIG_VALUE_1', 'one') + const env = createDaemonPtyEnvironment({ + ...spawn, + env: { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'override.zero', + GIT_CONFIG_VALUE_0: 'override' + } + }) + expect(env.GIT_CONFIG_COUNT).toBe('1') + expect(env.GIT_CONFIG_KEY_0).toBe('override.zero') + expect(env.GIT_CONFIG_VALUE_0).toBe('override') + expect(env.GIT_CONFIG_KEY_1).toBeUndefined() + expect(env.GIT_CONFIG_VALUE_1).toBeUndefined() + }) + + it('drops the activation sentinel after the client removes CONDA_PREFIX', () => { + vi.stubEnv('CONDA_SHLVL', '1') + vi.stubEnv('CONDA_PREFIX', '/opt/miniconda3') + vi.stubEnv('CONDA_DEFAULT_ENV', 'base') + const env = createDaemonPtyEnvironment({ ...spawn, envToDelete: ['CONDA_PREFIX'] }) + finalizeDaemonPtyEnvironment(env, undefined) + expect(env.CONDA_PREFIX).toBeUndefined() + expect(env.CONDA_SHLVL).toBeUndefined() + expect(env.CONDA_DEFAULT_ENV).toBeUndefined() + }) + + it('does not re-promote a legacy attribution path for Agent Teams', () => { + const env = createDaemonPtyEnvironment({ + ...spawn, + env: { + PATH: '/tmp/orca-terminal-attribution/posix:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + } + }) + expect(env.PATH).toBe('/usr/bin') + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts b/src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts new file mode 100644 index 000000000000..d834abc9b63b --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDaemonPtyEnvironment, finalizeDaemonPtyEnvironment } from './spawn-environment' + +const owners = { + ORCA_PI_STATUS_OWNED: '1234', + ORCA_PRIME_AGENT_STATUS_OWNED: '1234', + ORCA_PI_TITLE_MARKER_OWNED: '1234' +} +const terminal = { + ORCA_PANE_KEY: 'new-tab:new-leaf', + ORCA_AGENT_LAUNCH_TOKEN: 'new-launch', + ORCA_AGENT_HOOK_TOKEN: 'receiver-token', + KEEP_ME: 'terminal-value' +} +afterEach(() => vi.unstubAllEnvs()) + +describe('independent daemon terminal Pi ownership', () => { + it.each(['host', 'request'] as const)('does not inherit the %s process owner', (source) => { + if (source === 'host') { + for (const [key, value] of Object.entries(owners)) { + vi.stubEnv(key, value) + } + } + const env = createDaemonPtyEnvironment({ + sessionId: 'new-terminal', + cols: 80, + rows: 24, + env: { ...terminal, ...(source === 'request' ? owners : {}) } + }) + for (const key of Object.keys(owners)) { + expect(env[key]).toBeUndefined() + } + expect(env).toMatchObject(terminal) + }) + it('scrubs owners reintroduced by shell launch preparation', () => { + const env = { ...terminal, ...owners, APP_ENV: 'kept' } + finalizeDaemonPtyEnvironment(env, terminal) + for (const key of Object.keys(owners)) { + expect(Reflect.get(env, key)).toBeUndefined() + } + expect(env).toMatchObject({ ...terminal, APP_ENV: 'kept' }) + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-environment-preflight.test.ts b/src/main/daemon/pty-subprocess/spawn-environment-preflight.test.ts new file mode 100644 index 000000000000..b69b60801824 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment-preflight.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDaemonPtyEnvironment } from './spawn-environment' + +afterEach(() => vi.unstubAllEnvs()) + +describe('daemon Codex preflight environment', () => { + it('does not resurrect a launcher omitted by the current client', () => { + vi.stubEnv('ORCA_CODEX_LAUNCH_PREFLIGHT', '/old-build/bin/orca') + const env = createDaemonPtyEnvironment({ sessionId: 'test', cols: 80, rows: 24, env: {} }) + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + }) + + it('preserves the launcher explicitly verified by the current client', () => { + vi.stubEnv('ORCA_CODEX_LAUNCH_PREFLIGHT', '/old-build/bin/orca') + const env = createDaemonPtyEnvironment({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { ORCA_CODEX_LAUNCH_PREFLIGHT: '/current-build/bin/orca' } + }) + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe('/current-build/bin/orca') + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 32281f386187..5673d841c071 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -30,7 +30,8 @@ const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_WORKTREE_ID', 'ORCA_AGENT_LAUNCH_TOKEN', // Not identity but equally per-spawn: an inherited copy names another launch's CLI. - 'ORCA_WSL_CLI_DIR' + 'ORCA_WSL_CLI_DIR', + 'ORCA_CODEX_LAUNCH_PREFLIGHT' ] as const const WINDOWS_PATH_ENV_KEY_RE = /^path$/i @@ -172,6 +173,9 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record 1 ? 'children' : 'no-children' } diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts index 37e748677955..caa83357ecdb 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts @@ -1,4 +1,4 @@ -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../../../shared/terminal-process' import { isShellProcess } from '../../../shared/shell-process-detection' import { getCommandTokenPathBasename, @@ -28,12 +28,12 @@ import { readWindowsProcessIdentityTableFresh } from '../../windows/windows-process-table' -export function ptyProcessNameIsSpawnFile(proc: IPty): boolean { +export function ptyProcessNameIsSpawnFile(proc: TerminalProcess): boolean { return 'processNameIsSpawnFile' in proc && proc.processNameIsSpawnFile === true } export function createPtyForegroundResolver( - proc: IPty + proc: TerminalProcess ): typeof resolveAgentForegroundProcessWithAvailability { return ptyProcessNameIsSpawnFile(proc) ? (_pid, fallback, options) => resolveSpawnFileForegroundProcess(proc, fallback, options) @@ -79,7 +79,7 @@ export function resolveSpawnFileForegroundFromRows( } export async function resolveSpawnFileForegroundProcess( - proc: IPty, + proc: TerminalProcess, fallbackProcess: string | null, options: AgentForegroundResolutionOptions = {} ): Promise { diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts index 3ec912300aba..8ef6ded22f27 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts @@ -1,4 +1,4 @@ -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../../../shared/terminal-process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ProcessTableRow } from '../../../shared/process-table-snapshot' import { __setWindowsProcessTreeLoaderForTests } from '../../windows/windows-process-table' @@ -12,11 +12,10 @@ vi.mock('../../providers/windows-pty-job-membership', () => ({ readWindowsPtyJobProcessIds: members })) -const proc: IPty = { +const proc: TerminalProcess = { pid: 100, cols: 80, rows: 24, - handleFlowControl: false, process: 'powershell.exe', onData: () => ({ dispose() {} }), onExit: () => ({ dispose() {} }), diff --git a/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts b/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts index d40b30e2b324..20035701d6ec 100644 --- a/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts +++ b/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts @@ -1,21 +1,68 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' -const fixture = vi.hoisted((): { shellPid?: number } => ({})) +const fixture = vi.hoisted( + (): { + shellPid?: number + available: boolean + immediateExit: boolean + dispose: ReturnType + dataDispose: ReturnType + write: ReturnType + waitForDa1: boolean + writeFails: boolean + hang: boolean + kill: ReturnType + } => ({ + shellPid: Number.NaN, + available: true, + immediateExit: false, + dispose: vi.fn(), + dataDispose: vi.fn(), + write: vi.fn(), + waitForDa1: false, + writeFails: false, + hang: false, + kill: vi.fn() + }) +) vi.mock('./bun-pty-process', () => ({ - canUseBunPty: () => true, + canUseBunPty: () => fixture.available, spawnBunPty: () => ({ pid: 41, get shellProcessId() { return fixture.shellPid }, + write: fixture.write, + kill: fixture.kill, + onData(callback: (data: string) => void) { + if (fixture.waitForDa1) { + callback('\x1b[') + callback('0c') + } + return { dispose: fixture.dataDispose } + }, onExit(callback: (event: { exitCode: number }) => void) { - queueMicrotask(() => callback({ exitCode: 0 })) - return { dispose() {} } + if (fixture.hang) { + return { dispose: fixture.dispose } + } + if (fixture.waitForDa1) { + fixture.write.mockImplementation(() => { + if (fixture.writeFails) { + throw new Error('probe write failed') + } + callback({ exitCode: 0 }) + }) + } else if (fixture.immediateExit) { + callback({ exitCode: 0 }) + } else { + queueMicrotask(() => callback({ exitCode: 0 })) + } + return { dispose: fixture.dispose } } }) })) -import { runPtySpawnHealthProbe } from './spawn-preflight' +import { preflightPtySpawnHealth, runPtySpawnHealthProbe } from './spawn-preflight' beforeEach(() => vi.stubGlobal( @@ -37,5 +84,82 @@ it.each([undefined, 41, 0])( it('accepts successful exit with the separate original shell identity', async () => { fixture.shellPid = 42 + fixture.dispose.mockClear() await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() + expect(fixture.dispose).toHaveBeenCalledOnce() +}) + +it('rejects health preflight outside the bundled runtime', async () => { + fixture.available = false + try { + expect(() => preflightPtySpawnHealth()).toThrow('requires the bundled Bun') + await expect(runPtySpawnHealthProbe()).rejects.toThrow('requires the bundled Bun') + } finally { + fixture.available = true + } +}) + +it('disposes the subscription when a probe has already exited before registration', async () => { + fixture.shellPid = 42 + fixture.immediateExit = true + fixture.dispose.mockClear() + try { + await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() + expect(fixture.dispose).toHaveBeenCalledOnce() + } finally { + fixture.immediateExit = false + } +}) + +it('answers split DA1 queries and releases probe resources', async () => { + fixture.shellPid = 42 + fixture.waitForDa1 = true + fixture.write.mockReset() + fixture.dataDispose.mockClear() + try { + await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() + expect(fixture.write).toHaveBeenCalledExactlyOnceWith('\x1b[?1;2c') + expect(fixture.dataDispose).toHaveBeenCalledOnce() + } finally { + fixture.waitForDa1 = false + fixture.write.mockReset() + } +}) + +it('kills the probe and releases subscriptions if the DA1 reply fails', async () => { + fixture.shellPid = 42 + fixture.waitForDa1 = true + fixture.writeFails = true + fixture.kill.mockClear() + fixture.dispose.mockClear() + fixture.dataDispose.mockClear() + try { + await expect(runPtySpawnHealthProbe()).rejects.toThrow('probe write failed') + expect(fixture.kill).toHaveBeenCalledOnce() + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.dataDispose).toHaveBeenCalledOnce() + } finally { + fixture.waitForDa1 = false + fixture.writeFails = false + fixture.write.mockReset() + } +}) + +it('kills a timed-out probe and releases both subscriptions', async () => { + vi.useFakeTimers() + fixture.hang = true + fixture.kill.mockClear() + fixture.dispose.mockClear() + fixture.dataDispose.mockClear() + try { + const rejected = expect(runPtySpawnHealthProbe()).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(4_000) + await rejected + expect(fixture.kill).toHaveBeenCalledOnce() + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.dataDispose).toHaveBeenCalledOnce() + } finally { + fixture.hang = false + vi.useRealTimers() + } }) diff --git a/src/main/daemon/pty-subprocess/spawn-preflight.ts b/src/main/daemon/pty-subprocess/spawn-preflight.ts index 404f5b258f04..1109eaae5f6d 100644 --- a/src/main/daemon/pty-subprocess/spawn-preflight.ts +++ b/src/main/daemon/pty-subprocess/spawn-preflight.ts @@ -1,13 +1,18 @@ -import type * as pty from 'node-pty' +import '../xterm-env-polyfill' +import { Terminal } from '@xterm/headless' +import { + installDeviceAttributesResponder, + STARTUP_DA1_RESPONSE +} from '../startup-device-attributes-responder' +import type { BunPtyProcess } from './bun-pty-process-contract' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { statSync } from 'node:fs' import { release } from 'node:os' import { getCmdExePath } from '../../../shared/windows-batch-spawn' import { - ensureNodePtySpawnHelperExecutable, - getNodePtySpawnHelperCandidates, validateWorkingDirectoryAsync, WorkingDirectoryValidationAbortedError -} from '../../providers/local-pty-utils' +} from '../../providers/pty-spawn-validation' import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd' import { TerminalAttachCanceledError } from '../daemon-errors' import { DaemonProtocolError } from '../types' @@ -15,10 +20,6 @@ import { canUseBunPty, spawnBunPty } from './bun-pty-process' const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 -async function loadNodePty(): Promise { - return import('node-pty') -} - function daemonEnvironmentDiagSuffix(): string { const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' const systemVersion = @@ -27,15 +28,6 @@ function daemonEnvironmentDiagSuffix(): string { return ` (orca: ${orca}, arch: ${process.arch}, platform: ${process.platform} ${systemVersion})` } -function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { - const detailName = kind === 'helper' ? 'helper' : 'cwd' - const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' - const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' - return new DaemonProtocolError( - `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${daemonEnvironmentDiagSuffix()}` - ) -} - function isExistingDirectory(path: string | undefined): path is string { if (!path) { return false @@ -81,29 +73,9 @@ function preflightDaemonCwd(): void { if (repairDaemonCwd()) { return } - throw formatMissingDaemonPathError('cwd', daemonCwd) -} - -function preflightMacNodePtySpawnEnvironment(): void { - if (process.platform !== 'darwin' || canUseBunPty()) { - return - } - let candidates: string[] - try { - candidates = getNodePtySpawnHelperCandidates() - } catch { - throw formatMissingDaemonPathError('helper', '') - } - for (const candidate of candidates) { - try { - if (statSync(candidate).isFile()) { - return - } - } catch { - // Try the next node-pty native location. - } - } - throw formatMissingDaemonPathError('helper', candidates[0] ?? '') + throw new DaemonProtocolError( + `Daemon working directory is unavailable: '${daemonCwd}'. Restart Orca.${daemonEnvironmentDiagSuffix()}` + ) } function preflightUnixPtySpawnEnvironment(): void { @@ -112,11 +84,6 @@ function preflightUnixPtySpawnEnvironment(): void { } // Why: detached daemons can outlive their launch cwd; repair before every spawn. preflightDaemonCwd() - preflightMacNodePtySpawnEnvironment() -} - -function isNativeWindowsPath(path: string): boolean { - return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') } export async function preflightPtySpawn(args: { @@ -126,12 +93,12 @@ export async function preflightPtySpawn(args: { signal?: AbortSignal }): Promise { if (!canUseBunPty()) { - ensureNodePtySpawnHelperExecutable() + throw new DaemonProtocolError('Terminal service requires the bundled Bun runtime') } preflightUnixPtySpawnEnvironment() try { if (process.platform === 'win32') { - if (args.cwdWasExplicit && isNativeWindowsPath(args.validationCwd)) { + if (args.cwdWasExplicit && isWindowsAbsolutePathLike(args.validationCwd)) { await validateWorkingDirectoryAsync( args.validationCwd, args.signal ? { signal: args.signal } : {} @@ -163,7 +130,10 @@ export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: s } export async function runPtySpawnHealthProbe(): Promise { - const requiresShellIdentity = process.platform === 'win32' && canUseBunPty() + if (!canUseBunPty()) { + throw new DaemonProtocolError('Terminal service requires the bundled Bun runtime') + } + const requiresShellIdentity = process.platform === 'win32' const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) ? process.env.ORCA_USER_DATA_PATH : resolveSafePtyDefaultCwd() @@ -171,7 +141,7 @@ export async function runPtySpawnHealthProbe(): Promise { process.platform === 'win32' ? { file: getCmdExePath(), args: ['/d', '/c', 'exit', '0'] } : { file: '/bin/sh', args: ['-c', 'exit 0'] } - let proc: pty.IPty + let proc: BunPtyProcess try { const env: Record = { TERM: 'xterm-256color' } for (const [key, value] of Object.entries(process.env)) { @@ -179,21 +149,29 @@ export async function runPtySpawnHealthProbe(): Promise { env[key] = value } } - proc = canUseBunPty() - ? spawnBunPty({ ...command, cols: 2, rows: 1, cwd, env }) - : (await loadNodePty()).spawn(command.file, command.args, { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env - }) + proc = spawnBunPty({ ...command, cols: 2, rows: 1, cwd, env, windowsJobKillOnClose: true }) } catch (err) { throw formatPtySpawnError(err, command.file, cwd) } return new Promise((resolve, reject) => { let settled = false + const terminal = new Terminal({ cols: 2, rows: 1, scrollback: 0 }) + const releaseResponder = installDeviceAttributesResponder({ + parser: terminal.parser, + response: STARTUP_DA1_RESPONSE, + reply: (data) => { + if (settled) { + return + } + try { + proc.write(data) + } catch (error) { + finish(formatPtySpawnError(error, command.file, cwd), { kill: true }) + } + } + }) + let dataDisposable: { dispose(): void } | undefined let exitDisposable: { dispose(): void } | undefined const finish = (error?: Error, opts?: { kill?: boolean }): void => { if (settled) { @@ -202,6 +180,9 @@ export async function runPtySpawnHealthProbe(): Promise { settled = true clearTimeout(timer) exitDisposable?.dispose() + dataDisposable?.dispose() + releaseResponder() + terminal.dispose() if (opts?.kill) { try { proc.kill() @@ -220,9 +201,15 @@ export async function runPtySpawnHealthProbe(): Promise { kill: true }) }, PTY_SPAWN_HEALTH_TIMEOUT_MS) + // ConPTY waits for DA1 even when the probe shell immediately exits. + dataDisposable = proc.onData((data) => { + if (!settled) { + terminal.write(data) + } + }) exitDisposable = proc.onExit(({ exitCode }) => { if (exitCode === 0) { - const shellPid = 'shellProcessId' in proc ? proc.shellProcessId : undefined + const shellPid = proc.shellProcessId if ( requiresShellIdentity && (typeof shellPid !== 'number' || @@ -238,15 +225,15 @@ export async function runPtySpawnHealthProbe(): Promise { finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) } }) + if (settled) { + exitDisposable.dispose() + } }) } export function preflightPtySpawnHealth(): boolean { - if (process.platform === 'win32' && !canUseBunPty()) { - return false - } if (!canUseBunPty()) { - ensureNodePtySpawnHelperExecutable() + throw new DaemonProtocolError('Terminal service requires the bundled Bun runtime') } preflightUnixPtySpawnEnvironment() return true diff --git a/src/main/daemon/pty-subprocess/subprocess-handle-subscriptions.test.ts b/src/main/daemon/pty-subprocess/subprocess-handle-subscriptions.test.ts new file mode 100644 index 000000000000..f903466dfed8 --- /dev/null +++ b/src/main/daemon/pty-subprocess/subprocess-handle-subscriptions.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalProcess } from '../../../shared/terminal-process' +import { createDaemonPtySubprocessHandle } from './subprocess-handle' + +function createFixture(options: { synchronousExit?: boolean; synchronousData?: string } = {}) { + let physicalExit: (event: { exitCode: number; signal?: number }) => void = () => {} + const dataDispose = vi.fn() + const exitDispose = vi.fn() + const process: TerminalProcess = { + pid: 999_999_999, + process: 'zsh', + cols: 80, + rows: 24, + clear: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + onData: (listener) => { + if (options.synchronousData) { + listener(options.synchronousData) + } + return { dispose: dataDispose } + }, + onExit: (listener) => { + physicalExit = listener + if (options.synchronousExit) { + listener({ exitCode: 0 }) + } + return { dispose: exitDispose } + } + } + const handle = createDaemonPtySubprocessHandle({ + process, + shellPath: '/bin/zsh', + spawnCwd: '/tmp', + env: {}, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: 'subscription-test', + startupAgentRecognition: null + }) + return { + handle, + physicalExit: (event: { exitCode: number; signal?: number } = { exitCode: 0 }) => + physicalExit(event), + dataDispose, + exitDispose + } +} + +describe('daemon native PTY subscription ownership', () => { + it('retains native exit observation until physical exit, even when disposal was requested', () => { + const fixture = createFixture() + fixture.handle.dispose() + expect(fixture.exitDispose).not.toHaveBeenCalled() + fixture.physicalExit() + expect(fixture.dataDispose).toHaveBeenCalledOnce() + expect(fixture.exitDispose).toHaveBeenCalledOnce() + fixture.handle.dispose() + expect(fixture.exitDispose).toHaveBeenCalledOnce() + }) + + it('releases both native subscriptions when an exit listener disposes reentrantly', () => { + const fixture = createFixture() + fixture.handle.onExit(() => fixture.handle.dispose()) + fixture.physicalExit() + expect(fixture.dataDispose).toHaveBeenCalledOnce() + expect(fixture.exitDispose).toHaveBeenCalledOnce() + }) + + it('buffers synchronous registration events and still releases each subscription once', () => { + const fixture = createFixture({ synchronousData: 'last output', synchronousExit: true }) + const data = vi.fn() + const exit = vi.fn(() => fixture.handle.dispose()) + fixture.handle.onData(data) + fixture.handle.onExit(exit) + expect(data).toHaveBeenCalledWith('last output') + expect(exit).toHaveBeenCalledOnce() + expect(fixture.dataDispose).toHaveBeenCalledOnce() + expect(fixture.exitDispose).toHaveBeenCalledOnce() + }) + it('preserves signal provenance when a native exit also carries a zero exit code', () => { + const fixture = createFixture() + const onExit = vi.fn(() => fixture.handle.dispose()) + fixture.handle.onExit(onExit) + fixture.physicalExit({ exitCode: 0, signal: 9 }) + expect(onExit).toHaveBeenCalledWith(0, { kind: 'signaled', signal: 9 }) + }) +}) diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index 56b739bfccc0..aaa36dc34241 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -1,10 +1,10 @@ -import type * as pty from 'node-pty' +import type { TerminalProcess } from '../../../shared/terminal-process' import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition' import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo' import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups' import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group' -import { readPtsName } from '../../pty/node-pty-pts-name' -import { terminatePtyJob } from '../../windows/windows-pty-job' +import { readPtsName } from '../../pty/terminal-slave-device' +import { ptyShellProcessId, terminatePtyJob } from '../../windows/windows-pty-job' import { isValidPtySize } from '../daemon-pty-size' import type { SubprocessHandle } from '../session-subprocess-handle' import { createPtyForegroundProcessTracker } from './foreground-process-tracker' @@ -12,13 +12,13 @@ import { PtyPreListenerEvents } from './pre-listener-events' import { ptyProcessNameIsSpawnFile } from './spawn-file-foreground-process' import { inspectSpawnFileWindowsChildProcesses } from './spawn-file-child-processes' -type DisposableNativePty = pty.IPty & { +type DisposableNativePty = TerminalProcess & { destroy?: () => void signalProcess?: (signal: string) => void } export function createDaemonPtySubprocessHandle(args: { - process: pty.IPty + process: TerminalProcess shellPath: string spawnCwd: string env: Record @@ -38,6 +38,23 @@ export function createDaemonPtySubprocessHandle(args: { let ioFailed = false let disposed = false let nodePtyKillIssued = false + let physicalExitObserved = false + let dataSubscription: { dispose(): void } | null = null + let exitSubscription: { dispose(): void } | null = null + const releaseNativeSubscriptions = (): void => { + if (!disposed || !physicalExitObserved) { + return + } + const data = dataSubscription + const exit = exitSubscription + dataSubscription = null + exitSubscription = null + try { + data?.dispose() + } finally { + exit?.dispose() + } + } const foreground = createPtyForegroundProcessTracker({ process: proc, shellPath: args.shellPath, @@ -47,11 +64,12 @@ export function createDaemonPtySubprocessHandle(args: { isDead: () => dead }) - proc.onData((data) => { + dataSubscription = proc.onData((data) => { foreground.recordOutput(data) events.acceptData(data) }) - proc.onExit(({ exitCode, signal }) => { + exitSubscription = proc.onExit(({ exitCode, signal }) => { + physicalExitObserved = true // Exit listeners may re-enter cleanup; retire signal authority before notifying them. dead = true foreground.markDead() @@ -59,16 +77,22 @@ export function createDaemonPtySubprocessHandle(args: { if (process.platform !== 'win32') { nativeProc.kill = () => {} } - events.acceptExit({ - exitCode, - signal, - hostReportsChildExitStatus: reportsChildExitStatus - }) + try { + events.acceptExit({ + exitCode, + signal, + hostReportsChildExitStatus: reportsChildExitStatus + }) + } finally { + releaseNativeSubscriptions() + } }) + releaseNativeSubscriptions() const slavePath = readPtySlavePath(proc) return { pid: proc.pid, + getShellProcessId: () => ptyShellProcessId(proc), processNameIsSpawnFile: ptyProcessNameIsSpawnFile(proc), ...(process.platform === 'win32' ? { inspectChildProcesses: () => inspectSpawnFileWindowsChildProcesses(proc) } @@ -206,6 +230,7 @@ export function createDaemonPtySubprocessHandle(args: { disposed = true dead = true events.clear() + releaseNativeSubscriptions() // POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY. if (process.platform !== 'win32') { nativeProc.kill = () => {} diff --git a/src/main/daemon/pty-subprocess/subprocess-windows-shell-identity.test.ts b/src/main/daemon/pty-subprocess/subprocess-windows-shell-identity.test.ts new file mode 100644 index 000000000000..72bb8c8483db --- /dev/null +++ b/src/main/daemon/pty-subprocess/subprocess-windows-shell-identity.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalProcess } from '../../../shared/terminal-process' +import { Session } from '../session' +import { resolveTerminalHostSessionCwd } from '../terminal-host-session-cwd' +import { createDaemonPtySubprocessHandle } from './subprocess-handle' + +const { cwd, confirm, membership } = vi.hoisted(() => ({ + cwd: vi.fn(), + confirm: vi.fn(), + membership: vi.fn() +})) +vi.mock('../../providers/process-cwd', () => ({ resolveProcessCwd: cwd })) +vi.mock('../../providers/agent-foreground-process', () => ({ + confirmShellForegroundProcess: confirm, + resolveAgentForegroundProcessWithAvailability: vi.fn() +})) +vi.mock('../../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: membership, + isWindowsPtyJobReadable: () => true +})) +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() +}) + +describe('daemon gated Windows shell identity', () => { + it('resolves the child shell cwd without changing the owned process root', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + cwd.mockResolvedValue('C:\\work') + let exit: (event: { exitCode: number }) => void = () => {} + const proc: TerminalProcess & { + shellProcessId?: number + jobRootProcessIsWrapper: true + signalProcess: ReturnType + } = { + pid: 999_999_999, + shellProcessId: 999_999_998, + jobRootProcessIsWrapper: true, + signalProcess: vi.fn(), + process: 'cmd.exe', + cols: 80, + rows: 24, + clear: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + onData: () => ({ dispose() {} }), + onExit: (listener) => { + exit = listener + return { dispose() {} } + } + } + const subprocess = createDaemonPtySubprocessHandle({ + process: proc, + shellPath: 'cmd.exe', + spawnCwd: '', + env: {}, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: 'gated-shell', + startupAgentRecognition: null + }) + const session = new Session({ + sessionId: 'gated-shell', + cols: 80, + rows: 24, + subprocess, + shellReadySupported: false + }) + try { + expect(session.pid).toBe(999_999_999) + expect(await resolveTerminalHostSessionCwd(session)).toBe('C:\\work') + expect(cwd).toHaveBeenCalledWith(999_999_998) + confirm.mockResolvedValue(true) + expect(await subprocess.confirmShellForeground?.()).toBe(true) + expect(confirm).toHaveBeenCalledWith(999_999_998, 'cmd.exe', expect.any(Object)) + const kill = vi.spyOn(process, 'kill') + subprocess.signal('SIGTERM') + expect(proc.signalProcess).toHaveBeenCalledWith('SIGTERM') + expect(kill).not.toHaveBeenCalled() + membership.mockReturnValue(new Set([999_999_998])) + expect(session.inspectChildProcesses()).toBe('no-children') + membership.mockReturnValue(new Set([999_999_998, 999_999_997])) + expect(session.inspectChildProcesses()).toBe('children') + membership.mockReturnValue(null) + expect(session.inspectChildProcesses()).toBe('unverifiable') + delete proc.shellProcessId + cwd.mockClear() + expect(await resolveTerminalHostSessionCwd(session)).toBeNull() + expect(cwd).not.toHaveBeenCalled() + } finally { + exit({ exitCode: 0 }) + session.dispose() + } + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts index bb1c3d88a478..22a62508d5e9 100644 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' +import { ORCAD_BUN_VERSION, orcadBunVersionProbeArgs } from '../../../shared/orcad-bun-runtime' import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' const runtimePath = @@ -15,9 +15,12 @@ const available = existsSync(runtimePath) describe.skipIf(!available)('bundled Windows job gate under Bun', () => { it('executes the worker with real Bun flags and preserves long executable argv', async () => { - expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( - ORCAD_BUN_VERSION - ) + expect( + runProcessSync({ + program: runtimePath, + args: [...orcadBunVersionProbeArgs()] + }).stdout.trim() + ).toBe(ORCAD_BUN_VERSION) const directory = mkdtempSync(join(tmpdir(), 'orca-gate-contract-')) const workerPath = join(directory, 'windows-bun-pty-gate-entry.js') try { diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts index ccc3ebc6d1ad..56c95a63cf42 100644 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts @@ -156,3 +156,12 @@ describe('Windows Bun PTY job ownership', () => { expect(warn).toHaveBeenCalledOnce() }) }) + +it('arms kill-on-close only for explicitly transient PTY jobs', () => { + const native = createNative() + const job = createWindowsBunPtyJob(11, native, true) + expect(job).not.toBeNull() + expect(native.configureJob).toHaveBeenCalledWith(7, 0x2000) + job?.close() + expect(native.closeHandle).toHaveBeenCalledWith(7) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts index 5f0a7065c85e..cf7229a08010 100644 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts @@ -180,13 +180,17 @@ class BunPtyJob implements WindowsBunPtyJob { export function createWindowsBunPtyJob( rootPid: number, - native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative() + native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative(), + killOnClose = false ): WindowsBunPtyJob | null { if (!native || !Number.isInteger(rootPid) || rootPid <= 0) { return null } const job = native.createJob() - if (job === null || !native.configureJob(job, 0)) { + if ( + job === null || + !native.configureJob(job, killOnClose ? JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE : 0) + ) { if (job !== null) { native.closeHandle(job) } diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts index 1f4df4ef749f..5f4d23a03681 100644 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts @@ -69,6 +69,7 @@ describe('Windows Bun PTY gated launch', () => { expect(child.env).not.toHaveProperty('ORCA_BUN_PTY_JOB_GATE') expect(launch.windowsVerbatimArguments).toBe(false) expect(launch.command).toContain('--no-env-file') + expect(launch.command).toContain('--no-install') expect(launch.command).toContain(`--config=${join(directory, 'bunfig.toml')}`) expect(launch.command).toContain(`--cwd=${directory}`) expect(launch.command.join(' ').length).toBeLessThan(8191) diff --git a/src/main/daemon/session-output-snapshot-fidelity.test.ts b/src/main/daemon/session-output-snapshot-fidelity.test.ts new file mode 100644 index 000000000000..1f44d515f0c9 --- /dev/null +++ b/src/main/daemon/session-output-snapshot-fidelity.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' +import { SessionOutputPlane } from './session-output-plane' +import { RecentPtyOutputBuffer } from '../runtime/recent-pty-output-buffer' + +it('retains a persistent header after redraw traffic outgrows the relay replay tail', async () => { + const dimensions = { cols: 80, rows: 24 } + const plane = new SessionOutputPlane(dimensions) + const rawTail = new RecentPtyOutputBuffer({ limit: 100 * 1024 }) + const fromTail = new HeadlessEmulator(dimensions) + const fromSnapshot = new HeadlessEmulator(dimensions) + let sequence = 0 + const emit = (data: string): void => { + const start = sequence + sequence += data.length + rawTail.append(data) + plane.emit({ data, rawStartSeq: start, rawEndSeq: sequence, transformed: false }) + } + try { + emit('\x1b[2J\x1b[HIMPORTANT HEADER\x1b[2;1H') + for (let frame = 0; frame < 12_000; frame++) { + emit(`\r\x1b[2Kprogress ${frame}`) + } + expect(sequence).toBeGreaterThan(100 * 1024) + await plane.flushParsedWrites() + const snapshot = plane.getSnapshot() + expect(snapshot).not.toBeNull() + if (!snapshot) { + throw new Error('Expected an owner snapshot') + } + await fromTail.write(rawTail.read()) + await fromSnapshot.write( + snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi + ) + expect(fromTail.getVisibleLines().join('\n')).not.toContain('IMPORTANT HEADER') + expect(fromSnapshot.getVisibleLines()[0]).toContain('IMPORTANT HEADER') + expect(fromSnapshot.getVisibleLines()[1]).toContain('progress 11999') + expect(snapshot.outputSequence).toBe(sequence) + emit(' LIVE') + await plane.flushParsedWrites() + await fromSnapshot.write(' LIVE') + expect(fromSnapshot.getVisibleLines()[1]).toContain('progress 11999 LIVE') + expect(plane.getSnapshot()?.outputSequence).toBe(sequence) + } finally { + plane.markDisposed() + plane.disposeEmulator() + fromTail.dispose() + fromSnapshot.dispose() + } +}) diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index be336c998f2a..9ee09374e232 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -5,6 +5,7 @@ import type { JobTerminationOutcome } from '../windows/windows-pty-job' export type SubprocessHandle = { pid: number + getShellProcessId?(): number | undefined processNameIsSpawnFile?: boolean inspectChildProcesses?(): PtyChildProcessVerdict /** Live foreground process name of the PTY (node-pty's `.process`), e.g. diff --git a/src/main/daemon/session-termination-controller.ts b/src/main/daemon/session-termination-controller.ts index 9ccee58af650..5169822b10d6 100644 --- a/src/main/daemon/session-termination-controller.ts +++ b/src/main/daemon/session-termination-controller.ts @@ -38,6 +38,15 @@ export class SessionTerminationController { this.physicalExit.markExited() } + forceKillForDisposal(): void { + try { + this.deps.subprocess.forceKill() + } catch { + /* child may already be gone */ + } + this.clearTerminating() + } + clearTerminating(): void { this._isTerminating = false } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index e69bec7c5b1c..ef7953377841 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -130,6 +130,11 @@ export class Session { return this.termination.beginTermination() } + get shellProcessId(): number | undefined { + const handle = this.subprocess + return handle.getShellProcessId ? handle.getShellProcessId() : handle.pid + } + get pid(): number { return this.subprocess.pid } @@ -303,13 +308,8 @@ export class Session { const wasTerminating = this.termination.isTerminating && this._state !== 'exited' const clientsToNotify = wasTerminating ? this.output.snapshotClients() : [] if (wasTerminating) { - try { - this.subprocess.forceKill() - } catch { - /* child may already be gone */ - } + this.termination.forceKillForDisposal() this._exitCode = -1 - this.termination.clearTerminating() } this.#teardownSubprocess() diff --git a/src/main/daemon/shell-ready-bun-fixture.ts b/src/main/daemon/shell-ready-bun-fixture.ts new file mode 100644 index 000000000000..b9a2a08a4c80 --- /dev/null +++ b/src/main/daemon/shell-ready-bun-fixture.ts @@ -0,0 +1,196 @@ +import { existsSync } from 'node:fs' +import { spawnBunPty } from './pty-subprocess/bun-pty-process' +import { + createShellStartupOutputScanState, + drainShellStartupOutputScanState, + scanShellStartupOutput +} from '../shell-startup-output-scanner' +const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07' +/** Minimal xterm.js-shaped answers to the capability queries fish emits at startup + * and again around every prompt. */ +const TERMINAL_QUERY_REPLIES: readonly (readonly [string, string])[] = [ + ['\x1b[0c', '\x1b[?6c'], // primary device attributes + ['\x1b[?u', '\x1b[?0u'], // kitty keyboard flags + ['\x1b[6n', '\x1b[1;1R'], // cursor position report + ['\x1b]11;?', '\x1b]11;rgb:0000/0000/0000\x1b\\'], // background colour + ['\x1bP+q', '\x1bP0+r\x1b\\'] // XTGETTCAP (unsupported) +] + +/** Derived, not hardcoded: a shorter carry than the longest query would silently + * stop matching sequences split across two PTY chunks. */ +const QUERY_CARRY_LEN = Math.max(...TERMINAL_QUERY_REPLIES.map(([query]) => query.length)) + +// Why: the shell-ready marker fires from zle-line-init only on a real TTY, so spawn through a terminal rather than a pipe. +export async function runInteractiveZshLogin(args: { + tempHome: string + wrapperZdotdir: string + expected: string[] +}): Promise { + // Why: -o noglobalrcs skips /etc/zsh/*, whose insecure fpath dirs make compinit block on a [y/n] prompt before the marker fires. + const proc = spawnBunPty({ + file: 'zsh', + args: ['-o', 'noglobalrcs', '-l'], + cols: 80, + rows: 24, + cwd: args.tempHome, + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: args.tempHome, + TERM: 'xterm-256color', + ZDOTDIR: args.wrapperZdotdir, + ORCA_ORIG_ZDOTDIR: args.tempHome, + ORCA_ZSHENV_SOURCE_DIR: args.tempHome, + ORCA_SHELL_FEATURES: 'ready' + } + }) + let output = '' + let settle = (): void => {} + const done = new Promise((resolve) => { + settle = resolve + }) + const deadline = setTimeout(settle, 10_000) + proc.onData((chunk) => { + output += chunk + if (args.expected.every((value) => output.includes(value))) { + settle() + } + }) + await done + clearTimeout(deadline) + const exited = new Promise((resolve) => proc.onExit(() => resolve())) + proc.kill() + await exited + proc.destroy() + return output +} + +// Why: exercise an arbitrary interactive zsh rc (own ZDOTDIR, no wrapper) so a test can source the marker block directly. +export async function runInteractiveZshRc(args: { + zdotdir: string + expected: string[] +}): Promise { + // Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit can't block on an insecure-directory [y/n] prompt. + const proc = spawnBunPty({ + file: 'zsh', + args: ['-o', 'noglobalrcs', '-i'], + cols: 80, + rows: 24, + cwd: args.zdotdir, + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: args.zdotdir, + TERM: 'xterm-256color', + ZDOTDIR: args.zdotdir, + ORCA_SHELL_FEATURES: 'ready' + } + }) + let output = '' + let settle = (): void => {} + const done = new Promise((resolve) => { + settle = resolve + }) + const deadline = setTimeout(settle, 10_000) + proc.onData((chunk) => { + output += chunk + if (args.expected.every((value) => output.includes(value))) { + settle() + } + }) + await done + clearTimeout(deadline) + const exited = new Promise((resolve) => proc.onExit(() => resolve())) + proc.kill() + await exited + proc.destroy() + return output +} + +export async function runFishShellReadyFixture({ + binary, + tempHome, + config, + sentinel, + erased, + stillRegistered +}: { + binary: string + tempHome: string + config: { args: string[] | null; env: Record } + sentinel: string + erased: string + stillRegistered: string +}): Promise<{ output: string; scannedOutput: string }> { + const proc = spawnBunPty({ + file: binary, + args: config.args ?? [], + cols: 80, + rows: 24, + cwd: tempHome, + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: tempHome, + TERM: 'xterm-256color', + ...config.env + } + }) + let output = '' + let scannedOutput = '' + const startupScanState = createShellStartupOutputScanState() + let commandWritten = false + let erasureProbeWritten = false + let queryCarry = '' + let settle = (): void => {} + const done = new Promise((resolve) => { + settle = resolve + }) + const deadline = setTimeout(settle, 10_000) + // Why: settling on the first sentinel observes only one post-marker prompt, + // so a marker that never erased itself still looks single. Drive a second + // command and settle on its result, which also probes the erase directly. + const sentinelPoll = setInterval(() => { + if (commandWritten && !erasureProbeWritten && existsSync(sentinel)) { + erasureProbeWritten = true + proc.write( + `functions -q __orca_shell_ready_marker; and touch ${stillRegistered}; or touch ${erased}\n` + ) + return + } + if (erasureProbeWritten && (existsSync(erased) || existsSync(stillRegistered))) { + settle() + } + }, 50) + proc.onData((chunk) => { + output += chunk + scannedOutput += scanShellStartupOutput(startupScanState, chunk).output + // Why: fish stalls its first prompt 10s waiting on these and re-queries + // each prompt, so answer every occurrence — an unanswered query makes + // fish swallow the post-marker command as its reply. + const carriedLength = queryCarry.length + const scan = queryCarry + chunk + queryCarry = scan.slice(-QUERY_CARRY_LEN) + for (const [query, reply] of TERMINAL_QUERY_REPLIES) { + for (let at = scan.indexOf(query); at !== -1; at = scan.indexOf(query, at + query.length)) { + // Why: a query wholly inside the carry was answered on the previous + // chunk; replying again would land in fish's stdin as typed input. + if (at + query.length > carriedLength) { + proc.write(reply) + } + } + } + if (!commandWritten && output.includes(SHELL_READY_MARKER_OUTPUT)) { + commandWritten = true + // Why: mirror PostReadyFlushGate — flush shortly after the post-marker prompt draw. + setTimeout(() => proc.write(`touch ${sentinel}\n`), 50) + } + }) + await done + clearTimeout(deadline) + clearInterval(sentinelPoll) + const exited = new Promise((resolve) => proc.onExit(() => resolve())) + proc.kill() + await exited + proc.destroy() + scannedOutput += drainShellStartupOutputScanState(startupScanState) + + return { output, scannedOutput } +} diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 01cdad10be92..e4ffa307ea2c 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' +import { runBundledBunFixture } from '../bundled-bun-test-execution' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' @@ -10,11 +12,6 @@ import { import { makeUserZdotdir } from '../zsh-user-config-dir-fixture' import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates' import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement' -import { - createShellStartupOutputScanState, - drainShellStartupOutputScanState, - scanShellStartupOutput -} from '../shell-startup-output-scanner' import { HeadlessEmulator } from './headless-emulator' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. import { getShellReadyWrapperRoot } from './shell-ready' @@ -39,97 +36,33 @@ const itWithFish = FISH.available ? it : it.skip const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07' -/** Minimal xterm.js-shaped answers to the capability queries fish emits at startup - * and again around every prompt. */ -const TERMINAL_QUERY_REPLIES: readonly (readonly [string, string])[] = [ - ['\x1b[0c', '\x1b[?6c'], // primary device attributes - ['\x1b[?u', '\x1b[?0u'], // kitty keyboard flags - ['\x1b[6n', '\x1b[1;1R'], // cursor position report - ['\x1b]11;?', '\x1b]11;rgb:0000/0000/0000\x1b\\'], // background colour - ['\x1bP+q', '\x1bP0+r\x1b\\'] // XTGETTCAP (unsupported) -] - -/** Derived, not hardcoded: a shorter carry than the longest query would silently - * stop matching sequences split across two PTY chunks. */ -const QUERY_CARRY_LEN = Math.max(...TERMINAL_QUERY_REPLIES.map(([query]) => query.length)) - -// Why: the shell-ready marker fires from zle-line-init only on a real TTY, so spawn through node-pty not spawnSync. async function runInteractiveZshLogin(args: { tempHome: string wrapperZdotdir: string - isDone: (output: string) => boolean + expected: string[] }): Promise { - const pty = await import('node-pty') - // Why: -o noglobalrcs skips /etc/zsh/*, whose insecure fpath dirs make compinit block on a [y/n] prompt before the marker fires. - const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-l'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: args.tempHome, - env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: args.tempHome, - TERM: 'xterm-256color', - ZDOTDIR: args.wrapperZdotdir, - ORCA_ORIG_ZDOTDIR: args.tempHome, - ORCA_ZSHENV_SOURCE_DIR: args.tempHome, - ORCA_SHELL_FEATURES: 'ready' - } - }) - let output = '' - let settle = (): void => {} - const done = new Promise((resolve) => { - settle = resolve - }) - const deadline = setTimeout(settle, 10_000) - proc.onData((chunk) => { - output += chunk - if (args.isDone(output)) { - settle() - } - }) - await done - clearTimeout(deadline) - proc.kill() - return output + return z + .string() + .parse( + await runBundledBunFixture( + join(__dirname, 'shell-ready-bun-fixture.ts'), + 'runInteractiveZshLogin', + args, + 12_000 + ) + ) } - -// Why: exercise an arbitrary interactive zsh rc (own ZDOTDIR, no wrapper) so a test can source the marker block directly. -async function runInteractiveZshRc(args: { - zdotdir: string - isDone: (output: string) => boolean -}): Promise { - const pty = await import('node-pty') - // Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit can't block on an insecure-directory [y/n] prompt. - const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-i'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: args.zdotdir, - env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: args.zdotdir, - TERM: 'xterm-256color', - ZDOTDIR: args.zdotdir, - ORCA_SHELL_FEATURES: 'ready' - } - }) - let output = '' - let settle = (): void => {} - const done = new Promise((resolve) => { - settle = resolve - }) - const deadline = setTimeout(settle, 10_000) - proc.onData((chunk) => { - output += chunk - if (args.isDone(output)) { - settle() - } - }) - await done - clearTimeout(deadline) - proc.kill() - return output +async function runInteractiveZshRc(args: { zdotdir: string; expected: string[] }): Promise { + return z + .string() + .parse( + await runBundledBunFixture( + join(__dirname, 'shell-ready-bun-fixture.ts'), + 'runInteractiveZshRc', + args, + 12_000 + ) + ) } describePosix('daemon shell-ready launch config', () => { @@ -248,78 +181,15 @@ describePosix('daemon shell-ready launch config', () => { join(tempHome, '.config', 'fish', 'config.fish'), 'command sleep 0.2\nfunction fish_prompt\n printf "> "\nend\n' ) - const pty = await import('node-pty') - const proc = pty.spawn('fish', config.args ?? [], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: tempHome, - env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: tempHome, - TERM: 'xterm-256color', - ...config.env - } - }) - let output = '' - let scannedOutput = '' - const startupScanState = createShellStartupOutputScanState() - let commandWritten = false - let erasureProbeWritten = false - let queryCarry = '' - let settle = (): void => {} - const done = new Promise((resolve) => { - settle = resolve - }) - const deadline = setTimeout(settle, 10_000) - // Why: settling on the first sentinel observes only one post-marker prompt, - // so a marker that never erased itself still looks single. Drive a second - // command and settle on its result, which also probes the erase directly. - const sentinelPoll = setInterval(() => { - if (commandWritten && !erasureProbeWritten && existsSync(sentinel)) { - erasureProbeWritten = true - proc.write( - `functions -q __orca_shell_ready_marker; and touch ${stillRegistered}; or touch ${erased}\n` - ) - return - } - if (erasureProbeWritten && (existsSync(erased) || existsSync(stillRegistered))) { - settle() - } - }, 50) - proc.onData((chunk) => { - output += chunk - scannedOutput += scanShellStartupOutput(startupScanState, chunk).output - // Why: fish stalls its first prompt 10s waiting on these and re-queries - // each prompt, so answer every occurrence — an unanswered query makes - // fish swallow the post-marker command as its reply. - const carriedLength = queryCarry.length - const scan = queryCarry + chunk - queryCarry = scan.slice(-QUERY_CARRY_LEN) - for (const [query, reply] of TERMINAL_QUERY_REPLIES) { - for ( - let at = scan.indexOf(query); - at !== -1; - at = scan.indexOf(query, at + query.length) - ) { - // Why: a query wholly inside the carry was answered on the previous - // chunk; replying again would land in fish's stdin as typed input. - if (at + query.length > carriedLength) { - proc.write(reply) - } - } - } - if (!commandWritten && output.includes(SHELL_READY_MARKER_OUTPUT)) { - commandWritten = true - // Why: mirror PostReadyFlushGate — flush shortly after the post-marker prompt draw. - setTimeout(() => proc.write(`touch ${sentinel}\n`), 50) - } - }) - await done - clearTimeout(deadline) - clearInterval(sentinelPoll) - proc.kill() - scannedOutput += drainShellStartupOutputScanState(startupScanState) + const result = await runBundledBunFixture( + join(__dirname, 'shell-ready-bun-fixture.ts'), + 'runFishShellReadyFixture', + { binary: FISH.path, tempHome, config, sentinel, erased, stillRegistered }, + 12_000 + ) + const { output, scannedOutput } = z + .object({ output: z.string(), scannedOutput: z.string() }) + .parse(result) expect(output).toContain(SHELL_READY_MARKER_OUTPUT) expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2) @@ -482,7 +352,7 @@ describePosix('daemon shell-ready launch config', () => { const output = await runInteractiveZshLogin({ tempHome, wrapperZdotdir: config.env.ZDOTDIR, - isDone: (current) => current.includes(SHELL_READY_MARKER_OUTPUT) + expected: [SHELL_READY_MARKER_OUTPUT] }) expect(output).toContain(SHELL_READY_MARKER_OUTPUT) } finally { @@ -513,8 +383,7 @@ describePosix('daemon shell-ready launch config', () => { const output = await runInteractiveZshLogin({ tempHome, wrapperZdotdir: config.env.ZDOTDIR, - isDone: (current) => - current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput) + expected: [SHELL_READY_MARKER_OUTPUT, userHookOutput] }) // Why: the marker widget chains to the prior widget, so a user-registered azhw dispatcher must keep dispatching. expect(output).toContain(SHELL_READY_MARKER_OUTPUT) @@ -551,8 +420,7 @@ describePosix('daemon shell-ready launch config', () => { try { const output = await runInteractiveZshRc({ zdotdir, - isDone: (current) => - current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput) + expected: [SHELL_READY_MARKER_OUTPUT, userHookOutput] }) expect(output).toContain(SHELL_READY_MARKER_OUTPUT) expect(output).toContain(userHookOutput) diff --git a/src/main/daemon/terminal-host-bun-posix-inspection.test.ts b/src/main/daemon/terminal-host-bun-posix-inspection.test.ts new file mode 100644 index 000000000000..4f81b20aa5a2 --- /dev/null +++ b/src/main/daemon/terminal-host-bun-posix-inspection.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import type { TerminalProcess } from '../../shared/terminal-process' +import { TerminalHost } from './terminal-host' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' + +const scans = vi.hoisted(() => ({ full: vi.fn(), fresh: vi.fn(), strict: vi.fn() })) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getProcessTableSnapshot: scans.full, + getFreshProcessTableSnapshot: scans.fresh, + getStrictProcessTableSnapshotWithAge: scans.strict +})) +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +const id = 'bun-pane' +let rows: ProcessTableRow[] +let host: TerminalHost | undefined +let physicalExit: () => void + +function row(pid: number, ppid: number, command: string, foregroundPid: number): ProcessTableRow { + return { + pid, + ppid, + command, + pgid: pid, + tpgid: foregroundPid, + stat: pid === foregroundPid ? 'Ss+' : 'Ss', + tty: 'ttys002' + } +} + +async function createPane(name = '/bin/zsh', pid = 100) { + host ??= new TerminalHost({ + spawnSubprocess: () => { + const proc: TerminalProcess & { processNameIsSpawnFile: true } = { + pid, + process: name, + processNameIsSpawnFile: true, + cols: 80, + rows: 24, + onData: () => ({ dispose() {} }), + onExit: (listener) => { + physicalExit = () => listener({ exitCode: 0 }) + return { dispose() {} } + }, + resize() {}, + clear() {}, + write() {}, + kill() {}, + pause() {}, + resume() {} + } + return createDaemonPtySubprocessHandle({ + process: proc, + shellPath: '/bin/zsh', + spawnCwd: '/tmp', + env: {}, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: id, + startupAgentRecognition: null + }) + } + }) + return host.createOrAttach({ + sessionId: id, + cols: 80, + rows: 24, + streamClient: { onData() {}, onExit() {} } + }) +} + +beforeEach(() => { + vi.resetAllMocks() + scans.full.mockImplementation(async () => rows) + scans.fresh.mockImplementation(async () => rows) + scans.strict.mockImplementation(async () => ({ rows, capturedAgeMs: 0 })) +}) +afterEach(async () => { + physicalExit?.() + await host?.dispose() + // Each test owns a distinct host incarnation. + host = undefined + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() +}) + +describe.each(['darwin', 'linux'])('Bun daemon %s inspection', (hostPlatform) => { + beforeEach(() => + Object.defineProperty(process, 'platform', { value: hostPlatform, configurable: true }) + ) + + it('reports the actual foreground command and warns before closing a busy pane', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] + await createPane() + expect(await host!.inspectProcess(id)).toMatchObject({ + foregroundProcess: 'vim', + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + expect(await host!.confirmForegroundProcess(id)).toBe('vim') + expect(scans.fresh).toHaveBeenCalledOnce() + }) + + it('proves an idle shell has no children', async () => { + rows = [row(100, 1, '-zsh', 100)] + await createPane() + expect(await host!.inspectProcess(id)).toMatchObject({ + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + }) + + it('does not mistake the login wrapper for a running user job', async () => { + rows = [row(100, 1, '/usr/bin/login -fp test', 101), row(101, 100, '-zsh', 101)] + await createPane('/usr/bin/login') + expect(await host!.inspectProcess(id)).toMatchObject({ + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + }) + + it('preserves a confirmed agent when a later foreground scan cannot verify it', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'node /usr/local/bin/claude', 101)] + await createPane() + expect(await host!.confirmForegroundProcess(id)).toBe('claude') + scans.full.mockRejectedValue(new Error('process table unavailable')) + scans.strict.mockRejectedValue(new Error('process table unavailable')) + scans.fresh.mockRejectedValue(new Error('process table unavailable')) + expect(await host!.inspectProcess(id)).toMatchObject({ + foregroundProcess: 'claude', + hasChildProcesses: true, + childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: { verdict: 'unverifiable' } + }) + expect(await host!.confirmForegroundProcess(id)).toBeNull() + }) + + it('returns uncertainty when the process table has no pane root', async () => { + rows = [row(900, 1, '-zsh', 900)] + await createPane() + expect(await host!.inspectProcess(id)).toMatchObject({ + hasChildProcesses: true, + childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: { verdict: 'unverifiable' } + }) + }) + + it('does not publish live evidence after physical exit during a child scan', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] + await createPane() + scans.strict.mockImplementationOnce(async () => { + physicalExit() + return { rows, capturedAgeMs: 0 } + }) + expect(await host!.inspectProcess(id)).toMatchObject({ + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: { verdict: 'unverifiable' } + }) + }) + it('does not cache an agent in a replacement incarnation after a delayed confirmation', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'node /usr/local/bin/claude', 101)] + const original = await createPane() + scans.fresh.mockImplementationOnce(async () => { + const observed = rows + physicalExit() + rows = [row(100, 1, '-zsh', 100)] + const replacement = await createPane() + expect(replacement.incarnationId).not.toBe(original.incarnationId) + return observed + }) + expect(await host!.confirmForegroundProcess(id)).toBeNull() + expect(await host!.confirmForegroundProcess(id)).toBe('zsh') + }) +}) diff --git a/src/main/daemon/terminal-host-process-inspection.ts b/src/main/daemon/terminal-host-process-inspection.ts index e149179c1aa8..c937cbc1da9e 100644 --- a/src/main/daemon/terminal-host-process-inspection.ts +++ b/src/main/daemon/terminal-host-process-inspection.ts @@ -71,7 +71,7 @@ export async function inspectTerminalHostProcess(args: { !expectedIncarnationId || expectedIncarnationId === session.incarnationId if (args.steadyState === true && incarnationMatches) { const anchored = await readAnchoredForeground(session) - if (anchored !== null) { + if (anchored !== null && session.isAlive) { args.onTier?.('cheap') // No evidence member on purpose: a tty-less capture cannot fence anything, and a // fabricated fence would be read by remote/restore consumers as an observation. @@ -124,6 +124,19 @@ export async function inspectTerminalHostProcess(args: { clearSteadyStateAnchor(session) } } + if (!session.isAlive) { + clearSteadyStateAnchor(session) + return { + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: unverifiableEvidence( + args, + session, + 'session_exited_during_inspection' + ) + } + } const nonShellForeground = foregroundProcess !== null && !isShellProcess(foregroundProcess) // Evidence names recognized agents only, so its null must not erase an ordinary command (#18078). const ordinaryForeground = diff --git a/src/main/daemon/terminal-host-session-cwd.ts b/src/main/daemon/terminal-host-session-cwd.ts index 6de0df3a8154..4b5b54c1e0cb 100644 --- a/src/main/daemon/terminal-host-session-cwd.ts +++ b/src/main/daemon/terminal-host-session-cwd.ts @@ -6,6 +6,10 @@ export async function resolveTerminalHostSessionCwd(session: Session): Promise method.name === 'terminal.wait')! + +const running: BunPtyProcess[] = [] + +async function cleanup() { + while (running.length > 0) { + try { + const child = running.pop() + if (child) { + const exited = new Promise((resolve) => child.onExit(() => resolve())) + child.kill() + await exited + child.destroy() + } + } catch { + // The fixture may already be gone. + } + } +} + +async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: number) { + const child = spawnBunPty({ + file: process.execPath, + args: [FIXTURE, mode, String(workMs)], + env: Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), + cols: 120, + rows: 30, + cwd: '/tmp' + }) + running.push(child) + + // Real foreground read against the real pty: the same helper the relay serves + // `pty.getForegroundProcess` with, so corroboration is host-produced here too. + const runtime = makeTuiIdleRuntime({ + repoPath: '/tmp/tui-idle-real-pty', + getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null) + }) + runtime.attachWindow(1) + const graph: RuntimeSyncWindowGraph = { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Agent', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] + } + runtime.syncWindowGraph(1, graph) + + const transcript: string[] = [] + child.onData((data) => { + transcript.push(data) + runtime.onPtyData(PTY_ID, data, Date.now()) + }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + return { runtime, transcript, handle: terminals[0].handle } +} + +/** Exactly what `orca terminal wait --terminal --for tui-idle` reaches over RPC. */ +async function terminalWait( + runtime: OrcaRuntimeService, + terminal: string, + timeoutMs: number +): Promise<{ satisfied: boolean; elapsedMs: number }> { + const startedAt = Date.now() + try { + const result = await waitMethod.handler( + { terminal, for: 'tui-idle', timeoutMs }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.wait reads only `runtime` off its context; the rest is request plumbing this fixture has no use for. + { runtime } as Parameters[1] + ) + return { satisfied: result.wait.satisfied === true, elapsedMs: Date.now() - startedAt } + } catch (error) { + // Why only `timeout`: an unsatisfied wait is the outcome under test, but any other + // failure means the harness broke and must not read as a passing refusal. + if ((error instanceof Error ? error.message : String(error)) !== 'timeout') { + throw error + } + return { satisfied: false, elapsedMs: Date.now() - startedAt } + } +} + +export async function runTuiIdleAgentWait(options: { + mode: 'explicit-idle' | 'quiet' + workMs: number + timeoutMs: number + waitForTitle: boolean +}): Promise<{ satisfied: boolean; elapsedMs: number; transcript: string }> { + try { + const { runtime, transcript, handle } = await startRealAgentPane(options.mode, options.workMs) + if (options.waitForTitle) { + const deadline = Date.now() + 5_000 + while (!transcript.join('').includes('\x1b]0;Codex\x07')) { + if (Date.now() >= deadline) { + throw new Error('Agent did not emit its name-only title') + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } + } else { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + return { + ...(await terminalWait(runtime, handle, options.timeoutMs)), + transcript: transcript.join('') + } + } finally { + await cleanup() + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index d8d88fab0333..8ff74dda5ae2 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,3 +1,5 @@ +import type { PingRequest } from './daemon-hello-protocol' +import type { TransientPtyRequest } from './daemon-transient-pty-protocol' import type { ConfirmForegroundProcessRequest, ConfirmShellForegroundRequest, @@ -54,7 +56,7 @@ export type { TerminalCheckpointFile } from './daemon-checkpoint-file' // ─── NDJSON Protocol Messages ─────────────────────────────────────── // Hello handshake (first message on each socket) -export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daemon-hello-protocol' +export type { DaemonEndpointIdentity, HelloMessage, HelloResponse, PingRequest } from './daemon-hello-protocol' // ─── RPC Requests (Client → Daemon, on control socket) ───────────── @@ -222,11 +224,6 @@ export type ShutdownRequest = { } } -export type PingRequest = { - id: string - type: 'ping' -} - export type SystemResolverHealthRequest = { id: string type: 'systemResolverHealth' @@ -303,6 +300,7 @@ export type TakePendingOutputResult = { } export type DaemonRequest = + | TransientPtyRequest | CreateOrAttachRequest | HistorySeedProtocol.TerminalHistorySeedTransferRequest | CancelCreateOrAttachRequest diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts index a10d054926cb..b306159c4f7e 100644 --- a/src/main/daemon/windows-conpty-warmup.test.ts +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -1,11 +1,6 @@ +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as pty from 'node-pty' import { warmWindowsConptyOnce } from './windows-conpty-warmup' -import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' - -vi.mock('../windows/windows-pty-job', () => ({ - assignHostProcessToKillOnCloseJob: vi.fn(() => true) -})) function setPlatform(platform: NodeJS.Platform): () => void { const original = process.platform @@ -25,48 +20,48 @@ afterEach(() => { vi.clearAllMocks() }) -function makeFakePty(): { proc: pty.IPty; fireExit: () => void } { +function makeFakePty() { let exitListener: (() => void) | null = null + const dispose = vi.fn() const proc = { pid: 4321, kill: vi.fn(), onExit: vi.fn((listener: () => void) => { exitListener = listener - return { dispose: () => undefined } + return { dispose } }) - } as unknown as pty.IPty - return { proc, fireExit: () => exitListener?.() } + } + return { proc, dispose, fireExit: () => exitListener?.() } } describe('warmWindowsConptyOnce', () => { it('is a no-op off Windows', async () => { restorePlatform = setPlatform('darwin') - const spawnPty = vi.fn() as unknown as typeof pty.spawn + const spawnPty = vi.fn() warmWindowsConptyOnce(spawnPty) await flushImmediates() expect(spawnPty).not.toHaveBeenCalled() - expect(assignHostProcessToKillOnCloseJob).not.toHaveBeenCalled() }) it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => { restorePlatform = setPlatform('win32') - const { proc, fireExit } = makeFakePty() - const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + const { proc, fireExit, dispose } = makeFakePty() + const spawnPty = vi.fn((_args: BunPtySpawnArgs) => proc) warmWindowsConptyOnce(spawnPty) await flushImmediates() expect(spawnPty).toHaveBeenCalledTimes(1) - expect(assignHostProcessToKillOnCloseJob).toHaveBeenCalledBefore(vi.mocked(spawnPty)) - const [file, args, options] = vi.mocked(spawnPty).mock.calls[0] - expect(String(file).toLowerCase()).toContain('cmd') - expect(args).toEqual(['/c', 'exit']) - expect(options).toMatchObject({ useConptyDll: true, cols: 2, rows: 1 }) + const options = vi.mocked(spawnPty).mock.calls[0][0] + expect(String(options.file).toLowerCase()).toContain('cmd') + expect(options.args).toEqual(['/d', '/c', 'exit', '0']) + expect(options).toMatchObject({ windowsJobKillOnClose: true, cols: 2, rows: 1 }) // A clean exit must not leave the kill timer to fire later. fireExit() + expect(dispose).toHaveBeenCalledOnce() expect(proc.kill).not.toHaveBeenCalled() }) @@ -75,7 +70,7 @@ describe('warmWindowsConptyOnce', () => { vi.useFakeTimers() try { const { proc } = makeFakePty() - const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + const spawnPty = vi.fn((_args: BunPtySpawnArgs) => proc) warmWindowsConptyOnce(spawnPty) await vi.runOnlyPendingTimersAsync() @@ -91,9 +86,31 @@ describe('warmWindowsConptyOnce', () => { restorePlatform = setPlatform('win32') const spawnPty = vi.fn(() => { throw new Error('conpty unavailable') - }) as unknown as typeof pty.spawn + }) expect(() => warmWindowsConptyOnce(spawnPty)).not.toThrow() await flushImmediates() }) + + it('disposes an already-exited probe and clears its kill timer', async () => { + restorePlatform = setPlatform('win32') + vi.useFakeTimers() + const dispose = vi.fn() + const kill = vi.fn() + try { + warmWindowsConptyOnce(() => ({ + kill, + onExit(listener) { + listener({ exitCode: 0 }) + return { dispose } + } + })) + await vi.runOnlyPendingTimersAsync() + expect(dispose).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + expect(kill).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts index 98c3144380ce..7f09372bcf90 100644 --- a/src/main/daemon/windows-conpty-warmup.ts +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -1,78 +1,54 @@ import os from 'node:os' -import type * as pty from 'node-pty' -import { createRequire } from 'node:module' -import { canUseBunPty, spawnBunPty } from './pty-subprocess/bun-pty-process' -import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' +import type { TerminalProcess } from '../../shared/terminal-process' +import type { BunPtySpawnArgs } from './pty-subprocess/bun-pty-process-contract' +import { spawnBunPty } from './pty-subprocess/bun-pty-process' +import { getCmdExePath } from '../../shared/windows-batch-spawn' const WARMUP_KILL_TIMEOUT_MS = 10_000 -const requireFromMain = createRequire(__filename) +type WarmupSpawn = (args: BunPtySpawnArgs) => Pick -const spawnWarmupPty: typeof pty.spawn = (file, args, options) => { - if (canUseBunPty()) { - if (!Array.isArray(args)) { - throw new Error('Bun PTY requires argument arrays') - } - const env: Record = {} - for (const [key, value] of Object.entries(options.env ?? process.env)) { - if (value !== undefined) { - env[key] = value - } - } - return spawnBunPty({ - file, - args, - cwd: options.cwd ?? os.homedir(), - env, - cols: options.cols ?? 2, - rows: options.rows ?? 1 - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty's installed package implements the declared spawn contract. - const nodePty = requireFromMain('node-pty') as typeof pty - return nodePty.spawn(file, args, options) -} - -/** - * Pays the one-time cost of the first ConPTY spawn (conpty native module - * load, bundled conpty.dll + OpenConsole.exe first launch, Defender scans of - * those binaries) at daemon boot instead of on the user's first terminal. - * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after. - */ -export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPty): void { +/** Pays first ConPTY startup costs without delaying the daemon handshake. */ +export function warmWindowsConptyOnce(spawnPty: WarmupSpawn = spawnBunPty): void { if (process.platform !== 'win32') { return } - // Why: setImmediate keeps the ready/handshake path ahead of the warm-up; a - // real spawn arriving first simply does the warming itself. setImmediate(() => { try { - // Warm-up children must die with the daemon, even before its first real terminal. - if (!canUseBunPty()) { - assignHostProcessToKillOnCloseJob() + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value + } } - const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { - name: 'xterm-256color', + const proc = spawnPty({ + file: getCmdExePath(), + args: ['/d', '/c', 'exit', '0'], cols: 2, rows: 1, cwd: os.homedir(), - env: process.env as Record, - // Match real terminal spawns so the bundled ConPTY binaries are the - // ones warmed, not the legacy system ConPTY. - useConptyDll: true + env, + windowsJobKillOnClose: true }) const killTimer = setTimeout(() => { try { proc.kill() } catch { - /* best-effort cleanup of a stuck warm-up shell */ + // Best-effort cleanup of a stuck warm-up shell. } }, WARMUP_KILL_TIMEOUT_MS) killTimer.unref?.() - proc.onExit(() => { + let exited = false + let subscription: { dispose(): void } | undefined + subscription = proc.onExit(() => { + exited = true clearTimeout(killTimer) + subscription?.dispose() }) + if (exited) { + subscription.dispose() + } } catch { - /* warm-up is best-effort; real spawns surface their own errors */ + // Warm-up is best-effort; real spawns surface their own errors. } }) } diff --git a/src/main/daemon/windows-shell-preflight-bun-fixture.ts b/src/main/daemon/windows-shell-preflight-bun-fixture.ts new file mode 100644 index 000000000000..80ee69c6205f --- /dev/null +++ b/src/main/daemon/windows-shell-preflight-bun-fixture.ts @@ -0,0 +1,53 @@ +import { spawnBunPty } from './pty-subprocess/bun-pty-process' + +export async function runWindowsShellPreflight(options: { + shellPath: string + shellArgs: string[] + cwd: string + env: NodeJS.ProcessEnv + input?: string + timeoutMs?: number +}): Promise<{ output: string; exitCode: number }> { + const env: Record = {} + for (const [key, value] of Object.entries(options.env)) { + if (value !== undefined) { + env[key] = value + } + } + const proc = spawnBunPty({ + file: options.shellPath, + args: options.shellArgs, + cols: 100, + rows: 30, + cwd: options.cwd, + env + }) + let output = '' + proc.onData((data) => { + output += data + }) + const exited = new Promise((resolve) => { + proc.onExit(({ exitCode }) => resolve(exitCode)) + }) + let timeout: ReturnType | undefined + try { + if (options.input) { + proc.write(options.input.replaceAll('\n', '\r')) + } + const exitCode = await Promise.race([ + exited, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`timed out waiting for Windows shell PTY:\n${output}`)), + options.timeoutMs ?? 10_000 + ) + }) + ]) + return { output, exitCode } + } finally { + clearTimeout(timeout) + proc.kill() + await exited + proc.destroy() + } +} diff --git a/src/main/daemon/wsl-cold-restore-cwd.test.ts b/src/main/daemon/wsl-cold-restore-cwd.test.ts index d9c5cf54770b..2346dbca1ff2 100644 --- a/src/main/daemon/wsl-cold-restore-cwd.test.ts +++ b/src/main/daemon/wsl-cold-restore-cwd.test.ts @@ -38,3 +38,24 @@ describe('normalizeWslColdRestoreCwd', () => { ).toBe('\\\\server\\x') }) }) + +describe('guest-owned history paths', () => { + it.each([ + ['/home/jin', '/home/jin'], + ['\\\\wsl.localhost\\Ubuntu\\home\\jin', '/home/jin'], + ['\\\\wsl$\\ubuntu\\home\\jin', '/home/jin'], + ['\\\\wsl.localhost\\Debian\\home\\jin', '/home/guest'], + ['C:\\Users\\jin', '/home/guest'], + ['relative/path', '/home/guest'], + ['//foreign/share', '/home/guest'] + ])('keeps %s inside the selected Linux guest', (recoveredCwd, expected) => { + expect( + normalizeWslColdRestoreCwd({ + ...base, + guestExecution: true, + requestedCwd: '/home/guest', + recoveredCwd + }) + ).toBe(expected) + }) +}) diff --git a/src/main/daemon/wsl-cold-restore-cwd.ts b/src/main/daemon/wsl-cold-restore-cwd.ts index b3f921106dc3..10fcd3f18cf3 100644 --- a/src/main/daemon/wsl-cold-restore-cwd.ts +++ b/src/main/daemon/wsl-cold-restore-cwd.ts @@ -7,7 +7,17 @@ export function normalizeWslColdRestoreCwd(args: { wslDistro?: string platform?: NodeJS.Platform hostname?: string + guestExecution?: boolean }): string | undefined { + if (args.guestExecution) { + const path = parseWslUncPath(args.recoveredCwd) + if (path) { + return path.distro.toLowerCase() === args.wslDistro?.toLowerCase() + ? path.linuxPath + : args.requestedCwd + } + return /^\/(?!\/)/.test(args.recoveredCwd) ? args.recoveredCwd : args.requestedCwd + } if ((args.platform ?? process.platform) !== 'win32' || !args.wslDistro) { return args.recoveredCwd } diff --git a/src/main/ipc/pty-activation-inventory-scope.test.ts b/src/main/ipc/pty-activation-inventory-scope.test.ts index a6cb5dabad58..b59c577e2510 100644 --- a/src/main/ipc/pty-activation-inventory-scope.test.ts +++ b/src/main/ipc/pty-activation-inventory-scope.test.ts @@ -1,3 +1,5 @@ +import { registerWslPtyProvider } from './pty/provider/registry' +import { toAppWslPtyId } from '../../shared/wsl-pty-id' import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerSshPtyProvider, getLocalPtyProvider } from './pty' @@ -76,6 +78,45 @@ describe('scoped activation PTY inventory', () => { return { localList, remoteLists, startup, list } } + it('includes both guest owners in local scope and refuses partial guest inventory', async () => { + const { list, remoteLists } = install() + const firstOwner = { distro: 'Ubuntu', relayBuildId: 'build-a' } + const secondOwner = { distro: 'Debian', relayBuildId: 'build-b' } + const firstId = toAppWslPtyId(firstOwner, 'pty2:guest:1') + const secondId = toAppWslPtyId(secondOwner, 'pty2:guest:1') + const first = vi.fn(async () => [ + { id: firstId, cwd: '/folder one', title: 'guest', worktreeId: 'folder-project' } + ]) + const second = vi.fn(async () => [{ id: secondId, cwd: '/folder two', title: 'guest' }]) + const releaseFirst = registerWslPtyProvider(firstOwner, { + ...getLocalPtyProvider(), + listProcesses: first + }) + const releaseSecond = registerWslPtyProvider(secondOwner, { + ...getLocalPtyProvider(), + listProcesses: second + }) + try { + const rows = await list({ connectionId: null }) + if (!Array.isArray(rows)) { + throw new Error('Expected session inventory') + } + expect(rows.map((row: { id: string }) => row.id)).toEqual(['local', firstId, secondId]) + expect(rows[1]).toMatchObject({ cwd: '/folder one', worktreeId: 'folder-project' }) + expect(remoteLists.every((remote) => remote.mock.calls.length === 0)).toBe(true) + second.mockRejectedValue(new Error('disconnected')) + await expect(list({ connectionId: null })).rejects.toThrow('unverifiable') + releaseSecond() + await expect(list({ connectionId: null })).rejects.toThrow('unverifiable') + expect(await list({ connectionId: 'host-17' })).toHaveLength(1) + } finally { + releaseFirst() + releaseSecond() + ptyOwnership.delete(firstId) + ptyOwnership.delete(secondId) + } + }) + it('queries only the chosen SSH provider and preserves workspace and ownership evidence', async () => { const { list, localList, remoteLists, startup } = install() expect(await list({ connectionId: 'host-17' })).toEqual([ diff --git a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts index 5be4194bc76c..70e890ff9dfb 100644 --- a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts +++ b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts @@ -147,6 +147,9 @@ describe('registerPtyHandlers', () => { function setup(): { listener: SerializeListener; controller: SerializeController } { const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -305,6 +308,9 @@ describe('registerPtyHandlers', () => { describe('main buffer snapshot dispatch', () => { it('returns a hidden-output recovery snapshot with clamped scrollback', async () => { const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn(), getPtyOutputSequence: vi.fn(() => 42), serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ @@ -348,6 +354,9 @@ describe('registerPtyHandlers', () => { source: 'headless' }) const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn(), getPtyOutputSequence: vi.fn(() => 640), notePtyDataGap: vi.fn(), @@ -389,6 +398,9 @@ describe('registerPtyHandlers', () => { const provider = installObservableDaemonTestProvider() provider.getBufferSnapshot.mockResolvedValue(null) const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn(), getPtyOutputSequence: vi.fn(() => 640), notePtyDataGap: vi.fn(), @@ -422,12 +434,14 @@ describe('registerPtyHandlers', () => { const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPreAllocatedHandleForPty: vi.fn(), setPtyController: vi.fn(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn(), + onPtyData: vi.fn(() => 2_472), preAllocateHandleForPty: vi.fn(() => null), getPtyOutputSequence: vi.fn(() => 2_472), hasRemoteTerminalViewSubscriber: vi.fn(() => false), diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index cf61b71c9e55..5969382a11cf 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { LocalPtyProvider } from '../providers/local-pty-provider' +import { createUnavailablePtyProvider } from '../providers/unavailable-pty-provider' import { registerPtyHandlers, registerSshPtyProvider, @@ -159,8 +159,7 @@ describe('registerPtyHandlers', () => { expect(provider.write).toHaveBeenCalledWith('pty-refused', 'input') }) describe('controller probePtyLiveness routing', () => { - it('proves absence for an id the in-process local provider never owned', async () => { - setLocalPtyProvider(new LocalPtyProvider()) + it('proves absence only after the daemon answers for a previously unseen id', async () => { const controller = registerAgentClaimController() await expect(controller.probePtyLiveness('pty-from-prior-run')).resolves.toBe(false) @@ -190,7 +189,6 @@ describe('registerPtyHandlers', () => { it('answers unknown for SSH-owned ids whose provider has no probe', async () => { const connectionId = 'ssh-probe-1' const ptyId = `ssh:${connectionId}@@remote-pty` - setLocalPtyProvider(new LocalPtyProvider()) registerSshPtyProvider(connectionId, createAgentClaimProvider({}) as never) setPtyOwnership(ptyId, connectionId) const controller = registerAgentClaimController() @@ -210,7 +208,6 @@ describe('registerPtyHandlers', () => { it('answers unknown for remote-scoped ids without consulting local providers', async () => { // Why: a locally routed provider would answer confidently — and wrongly — // for a PTY that lives on a remote Orca host. - setLocalPtyProvider(new LocalPtyProvider()) const controller = registerAgentClaimController() await expect(controller.probePtyLiveness('remote:some-remote-pty')).resolves.toBeNull() @@ -252,12 +249,12 @@ describe('registerPtyHandlers', () => { localProvider.attach.mockRejectedValueOnce(new Error('Session not found')) await expect(controller.attach(daemonPtyId)).resolves.toBe(false) - // The in-process local provider streams without attach; never called. - const inProcess = new LocalPtyProvider() - const inProcessAttach = vi.spyOn(inProcess, 'attach') - setLocalPtyProvider(inProcess) + const unavailable = createUnavailablePtyProvider() + const unavailableAttach = vi.spyOn(unavailable, 'attach') + setLocalPtyProvider(unavailable) await expect(controller.attach(daemonPtyId)).resolves.toBe(false) - expect(inProcessAttach).not.toHaveBeenCalled() + expect(unavailableAttach).toHaveBeenCalledWith(daemonPtyId) + await expect(controller.probePtyLiveness(daemonPtyId)).resolves.toBeNull() } finally { unregisterSshPtyProvider('ssh-attach') clearPtyOwnershipForConnection('ssh-attach') diff --git a/src/main/ipc/pty-cumulative-ack-accounting.test.ts b/src/main/ipc/pty-cumulative-ack-accounting.test.ts index 4761588606b6..bf35e64df098 100644 --- a/src/main/ipc/pty-cumulative-ack-accounting.test.ts +++ b/src/main/ipc/pty-cumulative-ack-accounting.test.ts @@ -86,7 +86,7 @@ describe('registerPtyHandlers', () => { rendererInFlightPtyCount: 0 }) - vi.runOnlyPendingTimers() + vi.advanceTimersByTime(0) expect(getPtyDataSendCalls()).toHaveLength(33) } finally { vi.useRealTimers() @@ -131,22 +131,22 @@ describe('registerPtyHandlers', () => { const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) const ackData = getPtyAckDataListener() expect(getPtyDataSendCalls()).toHaveLength(32) - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(1) ackData(null, { id: spawnResult.id, processedChars: 0 }) expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(true) - expect(vi.getTimerCount()).toBe(1) + expect(vi.getTimerCount()).toBe(2) ackData(null, { id: spawnResult.id, processedChars: 0 }) ackData(null, { id: spawnResult.id, processedChars: -1 }) - expect(vi.getTimerCount()).toBe(1) + expect(vi.getTimerCount()).toBe(2) - vi.runOnlyPendingTimers() + vi.advanceTimersByTime(1) expect(getPtyDataSendCalls()).toHaveLength(32) expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(false) - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(1) ackData(null, { id: spawnResult.id, processedChars: 16 * 1024 }) - vi.runOnlyPendingTimers() + vi.advanceTimersByTime(1) expect(getPtyDataSendCalls()).toHaveLength(33) } finally { vi.useRealTimers() diff --git a/src/main/ipc/pty-delivery-health-heal.test.ts b/src/main/ipc/pty-delivery-health-heal.test.ts index 934a5bcdd374..fa07eccae631 100644 --- a/src/main/ipc/pty-delivery-health-heal.test.ts +++ b/src/main/ipc/pty-delivery-health-heal.test.ts @@ -86,8 +86,8 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } mockProc.emitData('stuck-output') - // Only the probe's hygiene timeout remains; the dispatcher-ready handshake already drained the pending flush. - expect(vi.getTimerCount()).toBe(1) + // The daemon's pause failsafe and the delivery probe have independent lifetimes. + expect(vi.getTimerCount()).toBe(2) destroyed = true mockProc.emitData('post-destroy output') diff --git a/src/main/ipc/pty-global-renderer-credit.test.ts b/src/main/ipc/pty-global-renderer-credit.test.ts index caeccfc572ed..666e6cfc9047 100644 --- a/src/main/ipc/pty-global-renderer-credit.test.ts +++ b/src/main/ipc/pty-global-renderer-credit.test.ts @@ -227,7 +227,7 @@ describe('registerPtyHandlers', () => { pendingPtyCount: 1, flushScheduled: false }) - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(1) proc.emitExit(0) diff --git a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts index 240793eda1d0..8c0e3cd91bc7 100644 --- a/src/main/ipc/pty-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-hidden-at-spawn-mark.test.ts @@ -7,7 +7,7 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime' import { registerPtyHandlers } from './pty' import { join } from 'node:path' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' +import { getShellReadyWrapperRoot } from '../daemon/shell-ready' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -365,7 +365,10 @@ describe('registerPtyHandlers', () => { expect(result).toEqual({ id: expect.any(String), - pid: 12345, + pid: 999_999_999, + providerSequence: { generation: 'reset', value: 0 }, + snapshotKittyKeyboardFlags: undefined, + wslDistro: null, incarnationId: expect.any(String) }) expect(spawnMock).toHaveBeenCalledTimes(1) @@ -375,7 +378,7 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ cwd: '/tmp' }) ) expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed') + expect.stringContaining('Preferred shell "/opt/homebrew/bin/bash" is unavailable') ) } finally { warnSpy.mockRestore() @@ -422,7 +425,7 @@ describe('registerPtyHandlers', () => { }) ) expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Shell "/opt/homebrew/bin/bash" is not executable') + expect.stringContaining('Preferred shell "/opt/homebrew/bin/bash" is unavailable') ) } finally { warnSpy.mockRestore() diff --git a/src/main/ipc/pty-ipc-daemon-test-provider.ts b/src/main/ipc/pty-ipc-daemon-test-provider.ts new file mode 100644 index 000000000000..9ef771b8de52 --- /dev/null +++ b/src/main/ipc/pty-ipc-daemon-test-provider.ts @@ -0,0 +1,136 @@ +import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { TerminalHost } from '../daemon/terminal-host' +import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell' +import { createPtySubprocess } from '../daemon/pty-subprocess' +import type { DaemonEvent, DaemonRequest } from '../daemon/types' +import { WRITE_ACCEPTED } from '../../shared/pty-write-settlement' + +/** Replaces transport only; IPC tests exercise real daemon launch, session and exit behavior. */ +export class PtyIpcDaemonTestProvider extends DaemonPtyAdapter { + private readonly host = new TerminalHost({ + spawnSubprocess: async (options) => { + await prepareMacosTccLoginShell() + return createPtySubprocess(options) + } + }) + private readonly eventHandlers = new Set<(event: unknown) => void>() + private readonly attachments = new Map() + + constructor() { + super({ socketPath: '/test/pty-ipc.sock', tokenPath: '/test/pty-ipc.token' }) + this.client.ensureConnected = async () => {} + this.client.ensureConnectedWithin = async () => {} + this.client.isConnected = () => true + this.client.onEvent = (handler) => { + this.eventHandlers.add(handler) + return () => this.eventHandlers.delete(handler) + } + this.client.request = async (type: string, payload: unknown): Promise => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the real adapter's typed requests enter this test transport. + const request = { id: 'ipc-test', type, payload } as DaemonRequest + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each route returns the host result expected by the real adapter's RPC call. + return (await this.route(request)) as T + } + this.client.notify = (type, payload) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Notifications originate from the real adapter, with its protocol payloads. + this.route({ id: 'ipc-test-notify', type, payload } as DaemonRequest) + return true + } + this.client.notifyWithSettlement = async (type, payload) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Notifications originate from the real adapter, with its protocol payloads. + await this.route({ id: 'ipc-test-notify', type, payload } as DaemonRequest) + return WRITE_ACCEPTED + } + } + + private emit(event: DaemonEvent): void { + for (const handler of this.eventHandlers) { + handler(event) + } + } + + private route(request: DaemonRequest): unknown { + switch (request.type) { + case 'createOrAttach': { + const { historySeed, ...payload } = request.payload + return this.host + .createOrAttach({ + ...payload, + ...(historySeed ? { historySeedChunks: [historySeed] } : {}), + streamClient: { + onData: (data, rawLength, transformed, seq) => + this.emit({ + type: 'event', + event: 'data', + sessionId: payload.sessionId, + payload: { data, rawLength, transformed, seq } + }), + onExit: (code, incarnationId, cause) => + this.emit({ + type: 'event', + event: 'exit', + sessionId: payload.sessionId, + payload: { code, incarnationId, ...(cause ? { cause } : {}) } + }) + } + }) + .then((result) => { + this.attachments.set(payload.sessionId, result.attachToken) + return result + }) + } + case 'listSessions': + return { sessions: this.host.listSessions() } + case 'getSize': + return { size: this.host.getAppliedSize(request.payload.sessionId) } + case 'write': + return this.host.write(request.payload.sessionId, request.payload.data) + case 'resize': + return this.host.resize( + request.payload.sessionId, + request.payload.cols, + request.payload.rows + ) + case 'pausePty': + return this.host.pauseProducer(request.payload.sessionId) + case 'resumePty': + return this.host.resumeProducer(request.payload.sessionId) + case 'kill': + return this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) + case 'signal': + return this.host.signal(request.payload.sessionId, request.payload.signal) + case 'getCwd': + return this.host.getCwd(request.payload.sessionId).then((cwd) => ({ cwd })) + case 'getForegroundProcess': + return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) } + case 'getSnapshot': + return { snapshot: this.host.getSnapshot(request.payload.sessionId) } + case 'takePendingOutput': + return this.host.takePendingOutput( + request.payload.sessionId, + request.payload.includeSnapshot ?? false + ) + case 'clearScrollback': + return this.host.clearScrollback(request.payload.sessionId) + case 'closeStartupQueryAuthority': + return { appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId) } + case 'detach': { + const token = this.attachments.get(request.payload.sessionId) + if (token) { + this.host.detach(request.payload.sessionId, token) + } + this.attachments.delete(request.payload.sessionId) + return {} + } + case 'setSessionBackground': + return {} + default: + throw new Error(`IPC daemon test transport does not implement ${request.type}`) + } + } + + async disposeHost(): Promise { + this.dispose() + await this.host.dispose() + } +} diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 71b77621b755..4bd79e7d5bcf 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -142,6 +142,7 @@ export const piTitlebarExtensionModuleMock = () => ({ }) export const pwshModuleMock = () => ({ + isPwshAvailable: isPwshAvailableMock, isPwshAvailableAsync: isPwshAvailableMock }) diff --git a/src/main/ipc/pty-ipc-native-runtime-mock.ts b/src/main/ipc/pty-ipc-native-runtime-mock.ts new file mode 100644 index 000000000000..f2c6d2943914 --- /dev/null +++ b/src/main/ipc/pty-ipc-native-runtime-mock.ts @@ -0,0 +1,43 @@ +import { vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' +import { existsSyncMock, statSyncMock, spawnMock } from './pty-ipc-mock-registry' + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: async (...args: unknown[]) => { + if (!existsSyncMock(args[0])) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return statSyncMock(...args) + } +})) + +vi.mock('../daemon/pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: (args: { + file: string + args: string[] + cwd: string + env: Record + cols: number + rows: number + }) => + spawnMock(args.file, args.args, { + cwd: args.cwd, + env: args.env, + cols: args.cols, + rows: args.rows, + name: args.env.TERM + }) +})) + +export function finishMockNativePtyProcesses(): void { + for (const result of spawnMock.mock.results) { + if (result.type !== 'return') { + continue + } + for (const [onExit] of result.value?.onExit?.mock?.calls ?? []) { + onExit({ exitCode: 0 }) + } + } +} diff --git a/src/main/ipc/pty-ipc-process-env-scope.ts b/src/main/ipc/pty-ipc-process-env-scope.ts index 2f6e6bd7c409..63a489ff3c37 100644 --- a/src/main/ipc/pty-ipc-process-env-scope.ts +++ b/src/main/ipc/pty-ipc-process-env-scope.ts @@ -7,6 +7,7 @@ export function createPtyIpcProcessEnvScope() { const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR const savedOrcaPiSourceAgentDir = process.env.ORCA_PI_SOURCE_AGENT_DIR + const savedCodexHome = process.env.CODEX_HOME const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME const savedOrcaOmpAgentDir = process.env.ORCA_OMP_CODING_AGENT_DIR const savedOrcaOmpSourceAgentDir = process.env.ORCA_OMP_SOURCE_AGENT_DIR @@ -35,6 +36,7 @@ export function createPtyIpcProcessEnvScope() { delete process.env.PI_CODING_AGENT_DIR delete process.env.ORCA_PI_SOURCE_AGENT_DIR delete process.env.ORCA_PI_CODING_AGENT_DIR + delete process.env.CODEX_HOME delete process.env.ORCA_CODEX_HOME delete process.env.ORCA_OMP_SOURCE_AGENT_DIR delete process.env.ORCA_OMP_CODING_AGENT_DIR @@ -45,6 +47,11 @@ export function createPtyIpcProcessEnvScope() { } function restoreProcessEnv() { + if (savedCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = savedCodexHome + } if (savedProcessPlatform) { Object.defineProperty(process, 'platform', savedProcessPlatform) } diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index c7256a57f3e8..f1bb2ac61965 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -1,3 +1,4 @@ +import { finishMockNativePtyProcesses } from './pty-ipc-native-runtime-mock' import { afterEach, beforeEach, vi } from 'vitest' import * as electron from 'electron' import { join } from 'node:path' @@ -47,10 +48,8 @@ import { } from './pty-ipc-mock-registry' import { makeDisposable } from './pty-ipc-test-constants' import { createPtyIpcProcessEnvScope } from './pty-ipc-process-env-scope' -import { - LocalPtyProvider, - _resetLocalPtyProviderStateForTest -} from '../providers/local-pty-provider' +import { PtyIpcDaemonTestProvider } from './pty-ipc-daemon-test-provider' +import { createUnavailablePtyProvider } from '../providers/unavailable-pty-provider' import { setLocalPtyProvider, unregisterSshPtyProvider } from './pty' import { _resetHiddenRendererPtyDeliveryGateForTest } from './pty-hidden-delivery-gate' import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' @@ -108,6 +107,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { } } const envScope = createPtyIpcProcessEnvScope() + let provider: PtyIpcDaemonTestProvider beforeEach(() => { // Why here: pty.ts registers against injected surfaces now, so the mocked ipcMain @@ -280,12 +280,18 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { resize: vi.fn(), kill: vi.fn(), process: 'zsh', - pid: 12345 + pid: 999_999_999, + pause: vi.fn(), + resume: vi.fn(), + destroy: vi.fn() }) + provider = new PtyIpcDaemonTestProvider() + setLocalPtyProvider(provider) }) - afterEach(() => { - _resetLocalPtyProviderStateForTest() + afterEach(async () => { + finishMockNativePtyProcesses() + await provider.disposeHost() _resetWslCachesForTests() vi.useRealTimers() // Why: sshProviders is module-level state; a leftover id leaks into later tests (pty:listSessions sweeps every provider). @@ -303,7 +309,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { ]) { unregisterSshPtyProvider(leakedConnectionId) } - setLocalPtyProvider(new LocalPtyProvider()) + setLocalPtyProvider(createUnavailablePtyProvider()) envScope.restoreProcessEnv() }) diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 7e4178f35d3e..9f120cc30fcd 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -12,7 +12,7 @@ import * as livePtyGate from '../claude-accounts/live-pty-gate' import { registerPtyHandlers, setLocalPtyProvider, getLocalPtyProvider } from './pty' import { join } from 'node:path' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' +import { getShellReadyWrapperRoot } from '../daemon/shell-ready' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -96,7 +96,7 @@ describe('registerPtyHandlers', () => { }) ) expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed') + expect.stringContaining('Preferred shell "/opt/homebrew/bin/bash" is unavailable') ) } finally { warnSpy.mockRestore() @@ -209,6 +209,10 @@ describe('registerPtyHandlers', () => { pid: 12345 } const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + markPtyStopRequested: vi.fn(), setPtyController: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -236,7 +240,7 @@ describe('registerPtyHandlers', () => { await vi.waitFor(() => expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1)) expect(onDataDisposable.dispose).toHaveBeenCalledTimes(1) }) - it('retains the PTY exit listener through did-finish-load orphan cleanup', async () => { + it('retains daemon PTY listeners across renderer reload until physical exit', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() let exitCb: ((info: { exitCode: number }) => void) | undefined @@ -254,6 +258,10 @@ describe('registerPtyHandlers', () => { pid: 12345 } const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + markPtyStopRequested: vi.fn(), setPtyController: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -276,19 +284,18 @@ describe('registerPtyHandlers', () => { } await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) - // First load after spawn only advances generation; the second sees this PTY as from a prior load and kills it as orphaned. + // Renderer reload does not transfer termination authority from the daemon. didFinishLoad() didFinishLoad() - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] - ) + expect(killSpy).not.toHaveBeenCalled() + expect(onDataDisposable.dispose).not.toHaveBeenCalled() expect(onExitDisposable.dispose).not.toHaveBeenCalled() exitCb?.({ exitCode: -1 }) expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1) }) - it('removes the previous orphan-cleanup listener from its original webContents', () => { + it('removes the previous renderer-reset listener from its original webContents', () => { const firstWindow = { isDestroyed: () => false, isFocused: () => true, @@ -313,11 +320,11 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(firstWindow as never) - // Two listeners on the first (LocalPtyProvider) window: the renderer-gate reset and the orphan cleanup. + // The daemon owns terminal lifetime; only renderer delivery resets on load. const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' ) - expect(firstWindowLoadHandlers).toHaveLength(2) + expect(firstWindowLoadHandlers).toHaveLength(1) setLocalPtyProvider({ spawn: vi.fn(), @@ -339,7 +346,7 @@ describe('registerPtyHandlers', () => { handler ) } - // The non-Local provider keeps orphan cleanup off the second window — only the renderer-gate reset listener remains. + // Re-registering keeps exactly one delivery-reset listener. expect( secondWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' @@ -359,6 +366,10 @@ describe('registerPtyHandlers', () => { pid: 12345 } const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + markPtyStopRequested: vi.fn(), setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -402,8 +413,7 @@ describe('registerPtyHandlers', () => { markClaudePtyExitedSpy.mockRestore() }) - // Why: guard against over-suppression — with no recovery reload in flight the sweep MUST still reclaim genuinely orphaned local PTYs. - it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { + it('preserves daemon terminals across ordinary reload and reports subsequent physical exit', async () => { let exitCb: ((info: { exitCode: number }) => void) | undefined const killSpy = vi.fn(() => { queueMicrotask(() => exitCb?.({ exitCode: -1 })) @@ -421,6 +431,10 @@ describe('registerPtyHandlers', () => { pid: 12345 } const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + markPtyStopRequested: vi.fn(), setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -455,7 +469,12 @@ describe('registerPtyHandlers', () => { didFinishLoad() await Promise.resolve() - expect(killSpy).toHaveBeenCalled() + expect(killSpy).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect( + (await getLocalPtyProvider().listProcesses()).some((info) => info.id === spawnResult.id) + ).toBe(true) + exitCb?.({ exitCode: -1 }) expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId, { providerExitObserved: true, cause: { kind: 'unknown', reason: 'stop_unverified' } @@ -468,6 +487,10 @@ describe('registerPtyHandlers', () => { const killSpyA = vi.fn() const killSpyB = vi.fn() const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + markPtyStopRequested: vi.fn(), setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -549,7 +572,7 @@ describe('registerPtyHandlers', () => { })) as { id: string } await expect(handlers.get('pty:kill')!(null, { id: spawnResult.id })).rejects.toThrow( - 'already dead' + 'kill ESRCH' ) expect((await getLocalPtyProvider().listProcesses()).map(({ id }) => id)).toContain( diff --git a/src/main/ipc/pty-login-shell-startup-commands.test.ts b/src/main/ipc/pty-login-shell-startup-commands.test.ts index 7ccb0386b9fe..88e2aeed9ddf 100644 --- a/src/main/ipc/pty-login-shell-startup-commands.test.ts +++ b/src/main/ipc/pty-login-shell-startup-commands.test.ts @@ -12,7 +12,7 @@ import { registerPtyHandlers } from './pty' import { join } from 'node:path' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' +import { getShellReadyWrapperRoot } from '../daemon/shell-ready' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -227,10 +227,10 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).not.toHaveBeenCalled() mockProc.emitData('last login: today\r\n') - vi.runOnlyPendingTimers() + vi.advanceTimersByTime(1) expect(mockProc.proc.write).not.toHaveBeenCalled() - mockProc.emitData('\x1b]133;A\x07% ') + mockProc.emitData('\x1b]777;orca-shell-ready\x07% ') await Promise.resolve() vi.runAllTimers() expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n') @@ -240,7 +240,7 @@ describe('registerPtyHandlers', () => { } ) posixOnlyIt( - 'uses the no-marker wrapper for Codex startup commands without a PTY write', + 'delivers Codex once after the daemon observes the zsh line-editor marker', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -256,18 +256,18 @@ describe('registerPtyHandlers', () => { }) const [, , options] = spawnMock.mock.calls[0]! - expect(options.env.ORCA_SHELL_FEATURES).not.toContain('ready') - expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe('codex') + expect(options.env.ORCA_SHELL_FEATURES).toContain('ready') + expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() await Promise.resolve() vi.advanceTimersByTime(49) await Promise.resolve() expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - await Promise.resolve() + mockProc.emitData('\x1b]777;orca-shell-ready\x07% ') + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith('codex\n') vi.runAllTimers() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledOnce() } finally { vi.useRealTimers() } @@ -290,7 +290,7 @@ describe('registerPtyHandlers', () => { const [, , options] = spawnMock.mock.calls[0]! expect(options.env.ORCA_SHELL_FEATURES).toContain('ready') - expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe("codex 'linked issue context'") + expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() expect(mockProc.proc.write).not.toHaveBeenCalled() mockProc.emitData('last login: today\r\n') @@ -302,17 +302,17 @@ describe('registerPtyHandlers', () => { await Promise.resolve() vi.advanceTimersByTime(50) await Promise.resolve() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith("codex 'linked issue context'\n") vi.advanceTimersByTime(150) await Promise.resolve() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith("codex 'linked issue context'\n") } finally { vi.useRealTimers() } }) posixOnlyIt( - 'uses the short settle path for delivery-hinted Codex when prompt follows the marker', + 'delivers delivery-hinted Codex once when the zsh marker and prompt arrive together', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -329,17 +329,21 @@ describe('registerPtyHandlers', () => { }) const [, , options] = spawnMock.mock.calls[0]! - expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe("codex 'linked issue context'") + expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() mockProc.emitData('\x1b]777;orca-shell-ready\x07\r\nuser@host % ') await Promise.resolve() vi.advanceTimersByTime(29) await Promise.resolve() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith( + "codex 'linked issue context'\n" + ) vi.advanceTimersByTime(1) await Promise.resolve() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith( + "codex 'linked issue context'\n" + ) } finally { vi.useRealTimers() } @@ -361,16 +365,16 @@ describe('registerPtyHandlers', () => { const [, , options] = spawnMock.mock.calls[0]! expect(options.env.ORCA_SHELL_FEATURES).toContain('ready') - expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe( - "codex --prefill 'linked issue context'" - ) + expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() expect(mockProc.proc.write).not.toHaveBeenCalled() mockProc.emitData('\x1b]777;orca-shell-ready\x07') await Promise.resolve() vi.runAllTimers() await Promise.resolve() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith( + "codex --prefill 'linked issue context'\n" + ) } finally { vi.useRealTimers() } diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index c279a239f2ad..f8d7453b8a0d 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -13,6 +13,7 @@ const { removeHandlerMock, getDaemonProviderMock, restartDaemonMock, + retryDaemonPtyProviderMock, getCurrentDaemonMacTccAttributionHealthMock, getDaemonFolderAccessMismatchMock, refreshDaemonFolderAccessProbeMock, @@ -22,6 +23,7 @@ const { removeHandlerMock: vi.fn(), getDaemonProviderMock: vi.fn(), restartDaemonMock: vi.fn(), + retryDaemonPtyProviderMock: vi.fn(), getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown'), getDaemonFolderAccessMismatchMock: vi.fn< () => { daemonScope: string; cwdClass: string; freshDaemonAccess: string } | null @@ -48,6 +50,7 @@ vi.mock('../daemon/daemon-folder-access-reset', () => ({ vi.mock('../daemon/daemon-init', () => ({ getDaemonProvider: getDaemonProviderMock, restartDaemon: restartDaemonMock, + retryDaemonPtyProvider: retryDaemonPtyProviderMock, getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock })) @@ -62,8 +65,14 @@ vi.mock('../daemon/daemon-pty-router', () => { class DaemonPtyRouter { private allAdapters: unknown[] private current: unknown - constructor(opts: { current: unknown; legacy: unknown[] }) { + freshSpawnsUnavailable = false + async recoverFreshSpawnRouting(): Promise { + this.freshSpawnsUnavailable = false + return true + } + constructor(opts: { current: unknown; legacy: unknown[]; probeFreshSpawn?: unknown }) { this.current = opts.current + this.freshSpawnsUnavailable = Boolean(opts.probeFreshSpawn) this.allAdapters = [opts.current, ...opts.legacy] } getAllAdapters() { @@ -80,38 +89,6 @@ vi.mock('../daemon/daemon-pty-router', () => { return { DaemonPtyRouter } }) -// Why: the handler also branches on `provider instanceof DegradedDaemonPtyProvider` -// (for getAllAdapters) and reports `degraded` from it. The real constructor -// subscribes to adapter events, so keep only the accessors pty-management uses. -vi.mock('../daemon/degraded-daemon-pty-provider', () => { - class DegradedDaemonPtyProvider { - private allAdapters: unknown[] - private current: unknown - private routesFreshToFallback = true - constructor(opts: { current: unknown; legacy: unknown[] }) { - this.current = opts.current - this.allAdapters = [opts.current, ...opts.legacy] - } - getCurrentAdapter() { - return this.current - } - getLegacyAdapters() { - return this.allAdapters.slice(1) - } - get routesFreshSpawnsToLocalProvider(): true | undefined { - return this.routesFreshToFallback ? true : undefined - } - async recoverFreshSpawnRouting(): Promise { - this.routesFreshToFallback = false - return true - } - getAllAdapters() { - return this.allAdapters - } - } - return { DegradedDaemonPtyProvider } -}) - type HandlerMap = Record unknown> function buildHandlerMap(): HandlerMap { @@ -179,11 +156,11 @@ async function makeRouter(current: MockAdapter, legacy: MockAdapter[] = []) { } async function makeDegradedProvider(current: MockAdapter, legacy: MockAdapter[] = []) { - const { DegradedDaemonPtyProvider } = await import('../daemon/degraded-daemon-pty-provider') - return new DegradedDaemonPtyProvider({ + const { DaemonPtyRouter } = await import('../daemon/daemon-pty-router') + return new DaemonPtyRouter({ current: current as never, legacy: legacy as never, - fallback: undefined as never + probeFreshSpawn: async () => false }) } @@ -191,6 +168,7 @@ describe('pty:management IPC handlers', () => { beforeEach(() => { getDaemonProviderMock.mockReset() restartDaemonMock.mockReset() + retryDaemonPtyProviderMock.mockReset() getCurrentDaemonMacTccAttributionHealthMock.mockReset() getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('unknown') getDaemonFolderAccessMismatchMock.mockReset().mockReturnValue(null) @@ -638,6 +616,28 @@ describe('pty:management IPC handlers', () => { }) }) + describe('retry', () => { + it('retries initialization without stopping sessions', async () => { + retryDaemonPtyProviderMock.mockResolvedValue(undefined) + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + await expect(buildHandlerMap()['pty:management:retry']({})).resolves.toEqual({ + success: true + }) + expect(retryDaemonPtyProviderMock).toHaveBeenCalledOnce() + expect(restartDaemonMock).not.toHaveBeenCalled() + }) + it('reports failure without invoking destructive restart', async () => { + retryDaemonPtyProviderMock.mockRejectedValue(new Error('service unavailable')) + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + await expect(buildHandlerMap()['pty:management:retry']({})).resolves.toEqual({ + success: false + }) + expect(restartDaemonMock).not.toHaveBeenCalled() + }) + }) + describe('restart', () => { it('delegates to restartDaemon and reports success', async () => { restartDaemonMock.mockResolvedValue({ killedCount: 2 }) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 34f990ac1617..d4cacdca2fc0 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -1,11 +1,11 @@ import { ipcMain } from 'electron' import { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' import { getCurrentDaemonMacTccAttributionHealth, getDaemonProvider, - restartDaemon + restartDaemon, + retryDaemonPtyProvider } from '../daemon/daemon-init' import { getCurrentDaemonAdapter } from '../daemon/daemon-provider-routing' import { @@ -34,7 +34,7 @@ function getDaemonAdapters(): DaemonPtyAdapter[] { if (!provider) { return [] } - if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) { + if (provider instanceof DaemonPtyRouter) { return [...provider.getAllAdapters()] } return [provider] @@ -43,10 +43,7 @@ function getDaemonAdapters(): DaemonPtyAdapter[] { // Why: surface degraded mode (daemon alive but cannot spawn fresh PTYs) so the UI can warn new terminals lack persistence. function isDaemonDegraded(): boolean { const provider = getDaemonProvider() - return ( - provider instanceof DegradedDaemonPtyProvider && - provider.routesFreshSpawnsToLocalProvider === true - ) + return provider instanceof DaemonPtyRouter && provider.freshSpawnsUnavailable } // Why the current adapter only: evidence is keyed to the daemon now spawning terminals, so a @@ -74,6 +71,7 @@ export function registerDaemonManagementHandlers(): void { ipcMain.removeHandler('pty:management:killAll') ipcMain.removeHandler('pty:management:killOne') ipcMain.removeHandler('pty:management:restart') + ipcMain.removeHandler('pty:management:retry') ipcMain.removeHandler('pty:management:macTccAttribution') ipcMain.removeHandler('pty:management:resetFolderAccess') @@ -113,9 +111,13 @@ export function registerDaemonManagementHandlers(): void { ipcMain.handle( 'pty:management:listSessions', - async (): Promise<{ sessions: DaemonSessionInfo[]; degraded: boolean }> => { + async (): Promise<{ + sessions: DaemonSessionInfo[] + degraded: boolean + unavailable: boolean + }> => { const sessions = await collectSessions(getDaemonAdapters()) - return { sessions, degraded: isDaemonDegraded() } + return { sessions, degraded: isDaemonDegraded(), unavailable: getDaemonProvider() === null } } ) @@ -203,6 +205,16 @@ export function registerDaemonManagementHandlers(): void { } ) + ipcMain.handle('pty:management:retry', async (): Promise<{ success: boolean }> => { + try { + await retryDaemonPtyProvider() + return { success: true } + } catch (error) { + console.warn('[pty:management] retry failed', error) + return { success: false } + } + }) + ipcMain.handle('pty:management:restart', async (): Promise<{ success: boolean }> => { try { await restartDaemon() diff --git a/src/main/ipc/pty-output-drain-rounds.test.ts b/src/main/ipc/pty-output-drain-rounds.test.ts index 29e0a7ffeba2..208b27851acd 100644 --- a/src/main/ipc/pty-output-drain-rounds.test.ts +++ b/src/main/ipc/pty-output-drain-rounds.test.ts @@ -305,7 +305,8 @@ describe('registerPtyHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) - expect(vi.getTimerCount()).toBe(0) + // The daemon retains its producer-pause failsafe. + expect(vi.getTimerCount()).toBe(1) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, pendingChars: 88 * 1024, diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 86fe3381f2bf..6a659de082c3 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -70,17 +70,6 @@ describe('registerPtyHandlers', () => { getSize(ptyId: string): { cols: number; rows: number } | null } let controller: RuntimeResizeController | null = null - const proc = { - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(() => { - throw new Error('resize failed') - }), - kill: vi.fn(), - process: 'zsh', - pid: 12345 - } const runtime = { setPtyController: vi.fn((value) => { controller = value @@ -94,7 +83,11 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), onPtyData: vi.fn() } - spawnMock.mockReturnValue(proc) + const provider = createAgentClaimProvider({ spawn: vi.fn(async () => ({ id: 'resize-pty' })) }) + provider.resize.mockImplementation(() => { + throw new Error('resize failed') + }) + setLocalPtyProvider(provider as never) registerPtyHandlers(mainWindow as never, runtime as never) const resizeController = controller as unknown as RuntimeResizeController diff --git a/src/main/ipc/pty-renderer-inflight-credit.test.ts b/src/main/ipc/pty-renderer-inflight-credit.test.ts index 6629b7d3a09a..85a4adae8055 100644 --- a/src/main/ipc/pty-renderer-inflight-credit.test.ts +++ b/src/main/ipc/pty-renderer-inflight-credit.test.ts @@ -150,7 +150,7 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512) - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(16) writeListener(mainWindowIpcEvent, { id: interactiveSpawn.id, @@ -254,7 +254,7 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } expect(getPtyDataSendCalls()).toHaveLength(512) - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(17) mainWindow.webContents.send.mockClear() procs[0]!.emitExit(0) diff --git a/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts b/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts index 544795e1f67f..e61049154c49 100644 --- a/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts +++ b/src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts @@ -199,7 +199,8 @@ describe('registerPtyHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) - expect(vi.getTimerCount()).toBe(0) + // The daemon retains its producer-pause failsafe independently of renderer scheduling. + expect(vi.getTimerCount()).toBe(1) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightChars: 512 * 1024, pendingChars: 88 * 1024, diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 70e358a80140..16152ecd824c 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -287,6 +287,7 @@ describe('registerPtyHandlers', () => { } let controller: RuntimeSpawnController | null = null const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'), setPtyController: vi.fn((value) => { controller = value }), @@ -353,6 +354,8 @@ describe('registerPtyHandlers', () => { } as never) let controller: RuntimeSpawnController | null = null const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPreAllocatedHandleForPty: vi.fn(), setPtyController: vi.fn((value) => { controller = value }), @@ -401,6 +404,9 @@ describe('registerPtyHandlers', () => { } let controller: RuntimeSpawnController | null = null const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_test'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn((value) => { controller = value }) @@ -454,6 +460,9 @@ describe('registerPtyHandlers', () => { }) it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn(), noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), @@ -473,7 +482,11 @@ describe('registerPtyHandlers', () => { const env = spawnCall[2].env as Record expect(env.ORCA_TERMINAL_HANDLE).toBe('term_trusted') expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() - expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String)) + expect(runtime.createPreAllocatedTerminalHandle).toHaveBeenCalledOnce() + expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith( + expect.any(String), + 'term_trusted' + ) }) it('forwards the trusted Orca terminal handle into managed WSL terminals', async () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -482,6 +495,9 @@ describe('registerPtyHandlers', () => { value: 'win32' }) const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_wsl'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn(), noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_wsl'), @@ -535,6 +551,9 @@ describe('registerPtyHandlers', () => { value: 'win32' }) const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_wsl'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), setPtyController: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_wsl'), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index a2a6153e99a1..5e7db0c9d4a2 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -169,7 +169,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() vi.runAllTimers() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith('codex\n') expect(mockProc.proc.write).not.toHaveBeenCalledWith( expect.stringContaining(RESUME_SESSION_ID) ) @@ -177,7 +177,7 @@ describe('registerPtyHandlers', () => { // The pane still runs under the selected account — but with nothing to resume. const env = spawnMock.mock.calls.at(-1)![2].env as Record expect(env.CODEX_HOME).toBe(OTHER_HOME) - expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe('codex') + expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() expect(selectedHome).toHaveBeenCalled() } finally { vi.useRealTimers() @@ -213,11 +213,13 @@ describe('registerPtyHandlers', () => { await Promise.resolve() vi.runAllTimers() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith( + `codex 'resume' '${RESUME_SESSION_ID}'\n` + ) expect(spawned.agentResumeUnavailable).toBeUndefined() const env = spawnMock.mock.calls.at(-1)![2].env as Record expect(env.CODEX_HOME).toBe(ORIGIN_HOME) - expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(`codex 'resume' '${RESUME_SESSION_ID}'`) + expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() expect(selectedHome).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -239,9 +241,9 @@ describe('registerPtyHandlers', () => { await Promise.resolve() vi.runAllTimers() - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).toHaveBeenCalledExactlyOnceWith('codex\n') const env = spawnMock.mock.calls.at(-1)![2].env as Record - expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe('codex') + expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined() expect(spawned.agentResumeUnavailable).toBe(true) } finally { vi.useRealTimers() diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index c36fa3e40f5e..8d7044611da4 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,10 +1,10 @@ +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { piBuildPtyEnvMock, spawnMock } from './pty-ipc-mock-registry' import { BUNDLED_CLI_PATH, TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { delimiter } from 'node:path' -import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' @@ -237,18 +237,11 @@ describe('registerPtyHandlers', () => { __resetPersistedWindowsPathCacheForTests() try { - const provider = new LocalPtyProvider({ - buildSpawnEnv: (id, baseEnv, context) => - buildPtyHostEnv(id, baseEnv, { - isPackaged: true, - userDataPath: '/tmp/orca-user-data', - selectedCodexHomePath: null, - agentStatusHooksEnabled: false, - isWsl: context?.isWsl, - wslDistro: context?.wslDistro - }) - }) - await provider.spawn({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This IPC fixture supplies the BrowserWindow methods used during handler registration. + registerPtyHandlers(mainWindow as never, undefined, undefined, () => + createGlobalSettingsFixture({ agentStatusHooksEnabled: false }) + ) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index 73c6e906d5e3..0ce777843bf0 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -84,6 +84,7 @@ describe('registerPtyHandlers', () => { acknowledgeDataEvent: vi.fn() } as never) const runtime = { + registerPty: vi.fn(), setPtyController: vi.fn(), noteTerminalSpawnCommand: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), @@ -200,6 +201,7 @@ describe('registerPtyHandlers', () => { it('threads the validated pane identity into registerPty for a renderer PTY spawn (#7587)', async () => { const leafId = '88888888-8888-4888-8888-888888888888' const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_seam'), setPtyController: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_seam'), registerPreAllocatedHandleForPty: vi.fn(), @@ -226,7 +228,7 @@ describe('registerPtyHandlers', () => { expect.any(String), 'wt-1', null, - { tabId: 'tab-1', leafId, incarnationId: expect.any(String) }, + { tabId: 'tab-1', leafId, terminalHandle: 'term_seam', incarnationId: expect.any(String) }, false ) }) @@ -339,6 +341,7 @@ describe('registerPtyHandlers', () => { }) it('omits the pane identity from registerPty when the leafId is not a terminal leaf (#7587)', async () => { const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_seam'), setPtyController: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_seam'), registerPreAllocatedHandleForPty: vi.fn(), @@ -502,6 +505,7 @@ describe('registerPtyHandlers', () => { } let controller: RuntimeSpawnController | null = null const runtime = { + createPreAllocatedTerminalHandle: vi.fn(() => 'term_wrong'), setPtyController: vi.fn((value) => { controller = value }), diff --git a/src/main/ipc/pty-windows-shell-selection.test.ts b/src/main/ipc/pty-windows-shell-selection.test.ts index 6d42f7d0bed3..9dfff9b2627e 100644 --- a/src/main/ipc/pty-windows-shell-selection.test.ts +++ b/src/main/ipc/pty-windows-shell-selection.test.ts @@ -118,7 +118,11 @@ describe('registerPtyHandlers', () => { process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: process.env.COMSPEC + }) expect(spawnMock).toHaveBeenCalledWith( 'C:\\Windows\\system32\\cmd.exe', @@ -130,7 +134,11 @@ describe('registerPtyHandlers', () => { process.env.COMSPEC = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: process.env.COMSPEC + }) expect(spawnMock).toHaveBeenCalledWith( 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', @@ -142,7 +150,11 @@ describe('registerPtyHandlers', () => { process.env.COMSPEC = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: process.env.COMSPEC + }) expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', @@ -154,7 +166,11 @@ describe('registerPtyHandlers', () => { process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: process.env.COMSPEC + }) const spawnCall = spawnMock.mock.calls.at(-1)! const env = spawnCall[2].env as Record @@ -165,17 +181,25 @@ describe('registerPtyHandlers', () => { process.env.PYTHONUTF8 = '0' registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: process.env.COMSPEC + }) const spawnCall = spawnMock.mock.calls.at(-1)! const env = spawnCall[2].env as Record expect(env.PYTHONUTF8).toBe('0') }) - it('launches Git Bash from COMSPEC as an interactive login shell', async () => { + it('launches explicitly selected Git Bash as an interactive login shell', async () => { process.env.COMSPEC = 'C:\\Program Files\\Git\\bin\\bash.exe' registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: process.env.COMSPEC + }) expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', diff --git a/src/main/ipc/pty-wsl-cwd-validation.test.ts b/src/main/ipc/pty-wsl-cwd-validation.test.ts index cfdb693d228b..15c7fd2b6e60 100644 --- a/src/main/ipc/pty-wsl-cwd-validation.test.ts +++ b/src/main/ipc/pty-wsl-cwd-validation.test.ts @@ -10,7 +10,7 @@ import { _setWslCachesForTests } from '../wsl' import { registerPtyHandlers } from './pty' import { join } from 'node:path' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' +import { getShellReadyWrapperRoot } from '../daemon/shell-ready' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -298,7 +298,7 @@ describe('registerPtyHandlers', () => { // Why: the startup-cwd guard normalizes separators, so the provider sees the forward-slash UNC form. existsSyncMock.mockImplementation((targetPath: string) => { - if (targetPath === '//wsl.localhost/Ubuntu/home/jin/missing') { + if (targetPath.replaceAll('\\', '/') === '//wsl.localhost/Ubuntu/home/jin/missing') { return false } return true diff --git a/src/main/ipc/pty-wsl-stable-adoption.test.ts b/src/main/ipc/pty-wsl-stable-adoption.test.ts new file mode 100644 index 000000000000..8f8a9966234d --- /dev/null +++ b/src/main/ipc/pty-wsl-stable-adoption.test.ts @@ -0,0 +1,90 @@ +import type * as StableOwner from './pty/pane/stable-owner' +import { afterEach, expect, it, vi } from 'vitest' +import { registerPtyHandlers } from './pty/register-handlers' +import { installPtySpawnIpcHandler } from './pty/ipc/spawn' +import { resolveStablePaneOwner } from './pty/pane/stable-owner' +import { registerWslPtyProvider } from './pty/provider/registry' +import { WslDaemonSessions } from '../wsl/wsl-daemon-sessions' +import { WslDaemonPtyProvider } from '../wsl/wsl-daemon-pty-provider' +import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { toAppWslPtyId } from '../../shared/wsl-pty-id' + +vi.mock('./pty/ipc/spawn', () => ({ installPtySpawnIpcHandler: vi.fn() })) +vi.mock('./pty/pane/stable-owner', async (original) => ({ + ...(await original()), + resolveStablePaneOwner: vi.fn() +})) +const releases: (() => void)[] = [] +afterEach(() => { + releases.splice(0).forEach((release) => release()) + vi.restoreAllMocks() +}) + +it('reconnects the persisted guest before the bound renderer adoption consults its registry', async () => { + const owner = { distro: 'Ubuntu', relayBuildId: 'daemon+profile-user' } + const id = toAppWslPtyId(owner, 'existing-shell') + const binding = { + ptyId: id, + tabId: 'tab-restore', + leafId: '44444444-4444-4444-8444-444444444444' + } + vi.mocked(resolveStablePaneOwner).mockReturnValue(binding) + const endpoint = { + distro: 'Ubuntu', + distributionId: 'registration', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/bun', + entry: '/daemon.js', + envBinary: '/usr/bin/env', + socket: '/socket', + tokenPath: '/token', + serverBuildId: 'artifact' + } + const adapter = new DaemonPtyAdapter({ + guest: { + distro: 'Ubuntu', + defaultShell: '/bin/bash', + defaultCwd: '/home/alice', + profiles: [], + transport: { + readToken: () => 'unused', + connect: async () => { + throw new Error('unexpected connector') + } + } + } + }) + const provider = new WslDaemonPtyProvider(owner, adapter) + const spawn = vi.spyOn(provider, 'spawn').mockResolvedValue({ id, isReattach: true }) + const sessions = new WslDaemonSessions({ + profileScope: '/profile', + historyRoot: '/history', + store: { + getWslDaemonRecovery: () => null, + upsertWslDaemonRecovery: async () => {} + } + }) + const reconnect = vi.spyOn(sessions, 'reconnect').mockImplementation(async () => { + releases.push(registerWslPtyProvider(owner, provider)) + return { owner, endpoint, provider } + }) + registerPtyHandlers(undefined, undefined, undefined, undefined, undefined, undefined, { + wslDaemonSessions: sessions + }) + const deps = vi.mocked(installPtySpawnIpcHandler).mock.calls.at(-1)?.[0] + expect(deps).toBeDefined() + const result = await deps?.adoptStablePane({ + ...binding, + worktreeId: 'repo::/workspace', + cols: 91, + rows: 31, + ownsPaneSpawnReservation: true + }) + expect(reconnect).toHaveBeenCalledWith(expect.objectContaining(owner)) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: id, attachOnly: true })) + expect(reconnect.mock.invocationCallOrder[0]).toBeLessThan(spawn.mock.invocationCallOrder[0]) + expect(result?.result.id).toBe(id) + adapter.dispose() +}) diff --git a/src/main/ipc/pty/delivery/ssh-intake.ts b/src/main/ipc/pty/delivery/ssh-intake.ts index 2f86608fb82f..3f68eed16a76 100644 --- a/src/main/ipc/pty/delivery/ssh-intake.ts +++ b/src/main/ipc/pty/delivery/ssh-intake.ts @@ -5,7 +5,7 @@ import { installSshPtyOutputIntake, publishSshPtySourceAck } from '../../ssh-pty-output-intake-registry' -import { sshProvidersByGeneration } from '../provider/registry' +import { relayProvidersByGeneration } from '../../../providers/relay-pty-generation-registry' import { setSshOutputIntakeCleanup, sshOutputIntakeCleanup } from '../provider/listener-lifecycle' import type { PtyIpcSession } from '../session' @@ -51,9 +51,7 @@ export function installSessionSshOutputIntake(session: PtyIpcSession): void { session.finalizePtyExitForRenderer(event) }, pauseProvider: (generation, id) => { - const provider = sshProvidersByGeneration.get(generation) as - | (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean }) - | undefined + const provider = relayProvidersByGeneration.get(generation) if (!provider?.hasPtyDeliveryPauseAdapter?.()) { return false } @@ -61,9 +59,9 @@ export function installSessionSshOutputIntake(session: PtyIpcSession): void { return true }, resumeProvider: (generation, id) => - sshProvidersByGeneration.get(generation)?.resumeProducer?.(id), + relayProvidersByGeneration.get(generation)?.resumeProducer?.(id), closeProvider: (generation, reason) => { - const provider = sshProvidersByGeneration.get(generation) + const provider = relayProvidersByGeneration.get(generation) ;( provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined )?.closeOutputIntake?.(reason) @@ -85,7 +83,7 @@ export function installSessionSshOutputIntake(session: PtyIpcSession): void { session.pendingOverflowMarkedPtys.delete(id) } } - sshProvidersByGeneration.delete(providerGeneration) + relayProvidersByGeneration.delete(providerGeneration) }, publishSourceAck: publishSshPtySourceAck, cancelSourceDelivery: cancelSshPtySourceDelivery diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index b5daa5bed7f0..d6dcb0e88056 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -142,13 +142,25 @@ export function buildPtyHostEnv( explicitPiAgentKind === 'pi' || explicitPiAgentKind === 'omp' ? explicitPiAgentKind : undefined - wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath, wslLaunchKind) - const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) + wslHookRelayManager.ensureForDistro( + distro, + opts.selectedCodexHomePath, + wslLaunchKind, + ...(opts.wslUser ? [opts.wslUser] : []) + ) + const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath( + distro, + ...(opts.wslUser ? [opts.wslUser] : []) + ) if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint } // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. - const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro, openCodeAgent) + const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir( + distro, + openCodeAgent, + ...(opts.wslUser ? [opts.wslUser] : []) + ) if (opencodeOverlayDir) { baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir @@ -231,12 +243,20 @@ export function buildPtyHostEnv( if (opts.isWsl && opts.agentStatusHooksEnabled) { const distro = opts.wslDistro ?? null if (explicitPiAgentKind === 'pi') { - const guestPiDir = wslHookRelayManager.getGuestAgentPath(distro, 'pi') + const guestPiDir = wslHookRelayManager.getGuestAgentPath( + distro, + 'pi', + ...(opts.wslUser ? [opts.wslUser] : []) + ) if (guestPiDir) { baseEnv.ORCA_PI_SOURCE_AGENT_DIR = guestPiDir } } else if (explicitPiAgentKind === 'omp') { - const guestOmpExtension = wslHookRelayManager.getGuestAgentPath(distro, 'omp') + const guestOmpExtension = wslHookRelayManager.getGuestAgentPath( + distro, + 'omp', + ...(opts.wslUser ? [opts.wslUser] : []) + ) if (guestOmpExtension) { baseEnv.ORCA_OMP_STATUS_EXTENSION = guestOmpExtension } diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index 62e199e09c3f..ff23b92d74da 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -1,3 +1,4 @@ +import type { WslAccountExecutionContext } from '../../../wsl/wsl-account-execution-context' import type { TuiAgent } from '../../../../shared/tui-agent' import { normalizeAgentProviderSession, @@ -25,6 +26,7 @@ export type PreparedCodexResumeHome = { } export type PrepareCodexResumeHomeArgs = { + wslExecution?: WslAccountExecutionContext connectionId?: string | null launchAgent?: TuiAgent providerSession?: AgentProviderSessionMetadata @@ -50,7 +52,8 @@ export function prepareCodexResumeHome( providerSession, target: args.target, launchEnv: args.launchEnv, - workspacePath: args.workspacePath + workspacePath: args.workspacePath, + ...(args.wslExecution ? { wslExecution: args.wslExecution } : {}) }) } } diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 37ef0614a504..2e37437c1839 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -1,3 +1,4 @@ +import type { WslAccountExecutionContext } from '../../../wsl/wsl-account-execution-context' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { NetworkProxySettings } from '../../../../shared/network-proxy' @@ -26,6 +27,8 @@ export type BuildPtyHostEnvOptions = { isWsl?: boolean /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ wslDistro?: string | null + /** Persisted guest owner; omitted for legacy Windows-owned WSL panes. */ + wslUser?: string agentStatusHooksEnabled: boolean /** Per-agent opt-out; disabled agents must not receive managed extensions. */ disabledTuiAgents?: Iterable | null @@ -39,6 +42,7 @@ export type BuildPtyHostEnvOptions = { export type CodexHomeLaunchContext = { workspacePath?: string + wslExecution?: WslAccountExecutionContext launchAgent?: TuiAgent unavailableManagedHomePath?: string } @@ -53,6 +57,7 @@ export type GetSelectedCodexHomePath = ( ) => string | null | Promise export type PrepareCodexSessionResume = (args: { + wslExecution?: WslAccountExecutionContext providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv @@ -76,5 +81,6 @@ export function allocatePtyLifecycleSequence(): number { } export type PrepareClaudeAuth = ( - target?: ClaudeAccountSelectionTarget + target?: ClaudeAccountSelectionTarget, + execution?: WslAccountExecutionContext ) => Promise diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 37226017b8f3..ae715410a23d 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -1,3 +1,7 @@ +import { + createLocalInventoryAuthority, + type LocalInventoryAuthorityDeps +} from '../provider/local-inventory-authority' import { getPtyIpc } from '../../pty-host-bindings' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { inspectPtyProviderProcessForRenderer } from '../../../providers/pty-process-inspection' @@ -13,6 +17,7 @@ import { getProvider, hasPtyProviderForInspection, registeredPtyProviders, + localProvider, sshProviders, tryGetProviderForPty } from '../provider/registry' @@ -25,9 +30,11 @@ import { settlePendingPaneSerializer } from '../pane/serializer-state' -export function installPtyInspectIpcHandlers(deps: { - getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined -}): void { +export function installPtyInspectIpcHandlers( + deps: LocalInventoryAuthorityDeps & { + getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined + } +): void { const ipcMain = getPtyIpc() const { getLocalPtyProviderStartupPromise } = deps @@ -57,16 +64,31 @@ export function installPtyInspectIpcHandlers(deps: { await getLocalPtyProviderStartupPromise() } } + const localAuthority = createLocalInventoryAuthority( + scope === undefined || scope.connectionId === null ? deps : {} + ) const deduped = new Map() const admission = new PtyProcessListAdmission() await visitPtyProcessListingsInBatches( - scope === undefined - ? registeredPtyProviders() + scope === undefined || scope.connectionId === null + ? registeredPtyProviders().filter( + ({ connectionId }) => scope === undefined || connectionId === null + ) : [{ provider: getProvider(scope.connectionId), connectionId: scope.connectionId }], - ({ provider, connectionId }) => - connectionId === null || scope !== undefined - ? provider.listProcesses() - : provider.listProcesses().catch(() => []), + async ({ provider, connectionId }) => { + try { + return await provider.listProcesses() + } catch (error) { + if (connectionId === null && provider !== localProvider) { + localAuthority.failed(provider, error) + return [] + } + if (connectionId !== null && scope === undefined) { + return [] + } + throw error + } + }, ({ provider, connectionId }, sessions) => { for (const rawSession of sessions) { const session = admission.admit(rawSession) @@ -90,6 +112,9 @@ export function installPtyInspectIpcHandlers(deps: { } } ) + if (scope === undefined || scope.connectionId === null) { + localAuthority.assertComplete() + } return Array.from(deduped.values()) } ) diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 50173f93c3d2..f11404be2040 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -1,3 +1,4 @@ +import type { BuildPtyHostEnvOptions } from '../host-env/types' import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' import { getAppEnvironment } from '../../../../shared/app-environment' import { isTuiAgent } from '../../../../shared/tui-agent-config' @@ -23,16 +24,20 @@ import type { PtyIpcSpawnState } from './spawn-state' export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promise { const args = ctx.args ctx.effectiveShellOverride = ctx.terminalRuntimeOptions.shellOverride - ctx.nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: ctx.effectiveShellOverride - }) - ctx.codexSelectionTarget = getCodexSelectionTargetForPty( - ctx.effectiveShellOverride, - ctx.cwd, - ctx.expectedWslDistro - ) + ctx.nativeWindowsConptySpawn = + !ctx.wslGuest && + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.effectiveShellOverride + }) + ctx.codexSelectionTarget = ctx.wslGuest + ? { runtime: 'wsl', wslDistro: ctx.wslGuest.execution.distro } + : getCodexSelectionTargetForPty(ctx.effectiveShellOverride, ctx.cwd, ctx.expectedWslDistro) + if (ctx.wslGuest && !ctx.wslGuest.fresh) { + ctx.env = ctx.baseEnv + return + } const codexResumePreparation = ctx.preAdoptedStablePane ? null : ctx.deps.prepareCodexResumeHome({ @@ -41,7 +46,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, launchEnv: ctx.baseEnv, - workspacePath: ctx.cwd + workspacePath: ctx.cwd, + ...(ctx.wslGuest ? { wslExecution: ctx.wslGuest.execution } : {}) }) ctx.codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) @@ -59,6 +65,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis const selectLaunchCodexHome = async (): Promise => (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { workspacePath: ctx.cwd, + ...(ctx.wslGuest ? { wslExecution: ctx.wslGuest.execution } : {}), launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined })) ?? null ctx.selectedCodexHomePath = @@ -86,6 +93,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { workspacePath: ctx.cwd, + ...(ctx.wslGuest ? { wslExecution: ctx.wslGuest.execution } : {}), launchAgent: 'codex' })) ?? null ), @@ -94,6 +102,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { workspacePath: ctx.cwd, + ...(ctx.wslGuest ? { wslExecution: ctx.wslGuest.execution } : {}), launchAgent: 'codex', unavailableManagedHomePath })) ?? null @@ -101,7 +110,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis }) ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution } - if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + if (!ctx.wslGuest && args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) } ctx.spawnTiming.mark('codex_home') @@ -135,11 +144,13 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.env = { ...ctx.baseEnv } try { await inheritOmpLaunchEnvironment(ctx.env, { - isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), + isWsl: + Boolean(ctx.wslGuest) || + shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), launchAgent: args.launchAgent, launchCommand: ctx.launchCommand }) - buildPtyHostEnv(sessionIdForEnv, ctx.env, { + const policy: BuildPtyHostEnvOptions = { isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, userDataPath: getAppEnvironment().getPath('userData'), @@ -148,7 +159,9 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, launchCommand: ctx.launchCommand, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), + isWsl: + Boolean(ctx.wslGuest) || + shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), disabledTuiAgents: ptySettings?.disabledTuiAgents, @@ -157,7 +170,12 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: ctx.provider.supportsGitCredentialGuardHost?.(ctx.effectiveSessionId) === true - }) + } + if (ctx.wslGuest) { + ctx.guestHostEnvPolicy = policy + } else { + buildPtyHostEnv(sessionIdForEnv, ctx.env, policy) + } stampWslOrchestrationCompatibilityHost( ctx.env, ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index 94f1acf363e1..7cbdcec04969 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -12,7 +12,6 @@ import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' -import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' @@ -72,6 +71,7 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { args.worktreeId, args.connectionId ) + const stableGuest = stablePaneOwnerCandidate && parseAppWslPtyId(stablePaneOwnerCandidate.ptyId) + if (stableGuest) { + const sessions = ctx.deps.options?.wslDaemonSessions + if (!sessions) { + throw new Error('WSL terminal owner is unavailable on this host') + } + await sessions.reconnect(stableGuest) + } const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId if (expectedPtyId) { @@ -101,7 +112,14 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { ctx.snapshotKittyFlagsCoverReconciledSeq = false } } - ensureWslHookRelayForReattach(ctx.result, args.connectionId) + const guestHooks = await prepareWslDaemonReattachHooks({ + result: ctx.result, + sessions: ctx.deps.options?.wslDaemonSessions, + hooksEnabled: isAgentStatusHooksEnabled(ctx.deps.getSettings?.()) + }) + if (!guestHooks) { + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + } ctx.deps.runtime?.preparePtyExecutionContext?.( ctx.result.id, args.connectionId diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index a2adf48771b6..d4f0adabfe60 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,3 +1,4 @@ +import { preparePtyIpcWslOptions } from './spawn-wsl-options' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' @@ -42,7 +43,9 @@ export async function buildPtyIpcSpawnOptions( ctx.agentTeamsEnvToDelete ?? [], // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], + ctx.isDaemonHostSpawn && !ctx.wslGuest + ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) + : [], getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; @@ -193,5 +196,6 @@ export async function buildPtyIpcSpawnOptions( ctx.releaseWorktreeSpawn = acquireWorktreeSpawn ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) : undefined + await preparePtyIpcWslOptions(ctx) return null } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 07fbbf942b87..7e2d22e1fc96 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -1,3 +1,4 @@ +import { preparePtyIpcWslRoute } from './spawn-wsl-route' import { isWslShellName, resolveLocalWindowsTerminalRuntimeOptions @@ -7,7 +8,6 @@ import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty- import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' -import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { normalizeWindowsTerminalCwd } from '../../../providers/windows-shell-args' import { wslUncDirectoryExistsAsync } from '../../../wsl' import { getCodexSelectionTargetForPty } from '../host-env/codex-home' @@ -23,10 +23,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis const args = ctx.args // Establish daemon identity before the first await so hidden delivery is gated before byte zero. ctx.provider = getProvider(args.connectionId) - ctx.isDaemonHostSpawn = - !args.connectionId && - !(ctx.provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isDaemonHostSpawn = !args.connectionId && !routesFreshSpawnsToLocalProvider(ctx.provider) ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn ctx.effectiveSessionId = args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) @@ -156,17 +153,41 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.startupCwdFallback = didFallbackToWorkspaceRootCwd && ctx.cwd ? { kind: 'worktree', cwd: ctx.cwd } : undefined } + ctx.terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: ctx.deps.getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { + shellOverride: + args.shellOverride ?? + (ctx.deps.getSettings?.()?.terminalDefaultShell?.trim() || undefined), + terminalWindowsWslDistro: null + } + // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). + // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. + // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. + ctx.expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd: ctx.cwd, + sessionId: ctx.effectiveSessionId, + shellOverride: ctx.terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + await preparePtyIpcWslRoute(ctx) ctx.spawnTiming.mark('preflight') - const freshSpawnRecovery = ctx.preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting(ctx.provider, args.connectionId, args.sessionId) + const freshSpawnRecovery = + ctx.preAdoptedStablePane || ctx.wslGuest + ? undefined + : recoverFreshSpawnProviderRouting(ctx.provider, args.connectionId, args.sessionId) if (freshSpawnRecovery) { await freshSpawnRecovery const previousHiddenMarkId = ctx.preSpawnHiddenMarkId - ctx.isDaemonHostSpawn = - !args.connectionId && - !(ctx.provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isDaemonHostSpawn = !args.connectionId && !routesFreshSpawnsToLocalProvider(ctx.provider) ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn ctx.effectiveSessionId = args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) @@ -192,44 +213,25 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } } ctx.isClaudeLaunch = - !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + !ctx.preAdoptedStablePane && + !args.connectionId && + (!ctx.wslGuest || ctx.wslGuest.fresh) && + isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } - ctx.terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: ctx.deps.getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { - shellOverride: - args.shellOverride ?? - (ctx.deps.getSettings?.()?.terminalDefaultShell?.trim() || undefined), - terminalWindowsWslDistro: null - } - const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride - // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). - // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. - // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - ctx.expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd: ctx.cwd, - sessionId: ctx.effectiveSessionId, - shellOverride: ctx.terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const initialSelectionTarget = getCodexSelectionTargetForPty( - initialShellOverride, - ctx.cwd, - ctx.expectedWslDistro - ) + const initialSelectionTarget = ctx.wslGuest + ? { runtime: 'wsl' as const, wslDistro: ctx.wslGuest.execution.distro } + : getCodexSelectionTargetForPty( + ctx.terminalRuntimeOptions.shellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) + ? ctx.wslGuest + ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget, ctx.wslGuest.execution) + : await ctx.deps.prepareClaudeAuth(initialSelectionTarget) : null ctx.spawnTiming.mark('auth') } diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index b1240c4ec6e7..a0a61d5adf0e 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -1,4 +1,6 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import type { WslDaemonSpawnRoute } from '../../../wsl/wsl-daemon-spawn-route' +import type { BuildPtyHostEnvOptions } from '../host-env/types' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -36,6 +38,8 @@ export type PtyIpcSpawnState = { preparedProvisionalExecutionContext: boolean releaseWorktreeSpawn: (() => void) | undefined provider: IPtyProvider + wslGuest: WslDaemonSpawnRoute | null + guestHostEnvPolicy: BuildPtyHostEnvOptions | null preAdoptedStablePane: AdoptStablePaneResult | null isClaudeLaunch: boolean claudeAuth: ClaudeRuntimeAuthPreparation | null @@ -114,6 +118,8 @@ export function createPtyIpcSpawnState( preparedProvisionalExecutionContext: false, releaseWorktreeSpawn: undefined, provider: localProvider, + wslGuest: null, + guestHostEnvPolicy: null, preAdoptedStablePane: null, isClaudeLaunch: false, claudeAuth: null, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index e14b469d1fb0..61dba036bb96 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -1,3 +1,5 @@ +import type { WslAccountExecutionContext } from '../../../wsl/wsl-account-execution-context' +import type { WslDaemonSessions } from '../../../wsl/wsl-daemon-sessions' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata, @@ -86,6 +88,7 @@ export type PtySpawnIpcDeps = { getSelectedCodexHomePath?: GetSelectedCodexHomePath prepareClaudeAuth?: PrepareClaudeAuth options?: { + wslDaemonSessions?: WslDaemonSessions prepareCodexSessionResume?: PrepareCodexSessionResume onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void } @@ -105,6 +108,7 @@ export type PtySpawnIpcDeps = { target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv workspacePath?: string + wslExecution?: WslAccountExecutionContext }) => PreparedCodexResumeHome | null noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch resolveCodexResumeLaunch: ( diff --git a/src/main/ipc/pty/ipc/spawn-wsl-options.ts b/src/main/ipc/pty/ipc/spawn-wsl-options.ts new file mode 100644 index 000000000000..b816bb3b69e7 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-wsl-options.ts @@ -0,0 +1,27 @@ +import { prepareWslGuestTerminalSpawn } from '../../../wsl/wsl-guest-terminal-preparation' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function preparePtyIpcWslOptions(ctx: PtyIpcSpawnState): Promise { + if (!ctx.wslGuest || ctx.preAdoptedStablePane) { + return + } + if (!ctx.wslGuest.fresh) { + ctx.spawnOptions.sessionId = ctx.effectiveSessionAppId + ctx.spawnOptions.attachOnly = true + return + } + if (!ctx.guestHostEnvPolicy || !ctx.effectiveSessionId) { + throw new Error('Guest terminal launch policy is unavailable') + } + try { + ctx.spawnOptions = await prepareWslGuestTerminalSpawn( + ctx.wslGuest.prepared, + ctx.spawnOptions, + ctx.guestHostEnvPolicy + ) + } catch (error) { + clearProviderPtyState(ctx.effectiveSessionId) + throw error + } +} diff --git a/src/main/ipc/pty/ipc/spawn-wsl-route.ts b/src/main/ipc/pty/ipc/spawn-wsl-route.ts new file mode 100644 index 000000000000..5a7bd8512de3 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-wsl-route.ts @@ -0,0 +1,39 @@ +import { toAppWslPtyId, toRelayWslPtyId } from '../../../../shared/wsl-pty-id' +import { mintPtySessionId } from '../../../daemon/pty-session-id' +import { prepareWslDaemonSpawnRoute } from '../../../wsl/wsl-daemon-spawn-route' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function preparePtyIpcWslRoute(ctx: PtyIpcSpawnState): Promise { + if (ctx.preAdoptedStablePane) { + return + } + const route = await prepareWslDaemonSpawnRoute({ + sessions: ctx.deps.options?.wslDaemonSessions, + connectionId: ctx.args.connectionId, + sessionId: ctx.args.sessionId, + distro: ctx.expectedWslDistro + }) + if (!route) { + return + } + ctx.wslGuest = route + ctx.provider = route.connection.provider + ctx.expectedWslDistro = route.connection.owner.distro + ctx.isDaemonHostSpawn = true + ctx.isMintedSessionId = route.fresh + ctx.effectiveSessionId = route.fresh + ? (ctx.effectiveSessionId ?? mintPtySessionId(ctx.args.worktreeId)) + : toRelayWslPtyId(route.connection.owner, ctx.args.sessionId!) + ctx.effectiveSessionAppId = toAppWslPtyId(route.connection.owner, ctx.effectiveSessionId) + ctx.effectiveSessionRelayId = ctx.effectiveSessionId + const previousHidden = ctx.preSpawnHiddenMarkId + ctx.preSpawnHiddenMarkId = ctx.initiallyHidden ? ctx.effectiveSessionAppId : null + if (previousHidden !== ctx.preSpawnHiddenMarkId) { + if (previousHidden) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(previousHidden, false) + } + if (ctx.preSpawnHiddenMarkId) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + } +} diff --git a/src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts b/src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts new file mode 100644 index 000000000000..9cb4d86ce228 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts @@ -0,0 +1,286 @@ +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import type * as GuestSpawnOptions from '../../../wsl/wsl-guest-spawn-options' +import type * as GuestTerminalPreparation from '../../../wsl/wsl-guest-terminal-preparation' +import { prepareWslDaemonSpawnRoute } from '../../../wsl/wsl-daemon-spawn-route' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { toAppWslPtyId } from '../../../../shared/wsl-pty-id' +import { DaemonPtyAdapter } from '../../../daemon/daemon-pty-adapter' +import { WslDaemonPtyProvider } from '../../../wsl/wsl-daemon-pty-provider' +import { WslDaemonSessions } from '../../../wsl/wsl-daemon-sessions' +import { prepareWslGuestTerminalSpawn } from '../../../wsl/wsl-guest-terminal-preparation' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + noCodexResumeLaunch, + resolveCodexResumeLaunch, + stripSequencedStartupResumeArgv +} from '../host-env/codex-resume' +import { createPtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import { preparePtyIpcSpawnPreflight } from './spawn-preflight' +import { assemblePtyIpcSpawnEnv } from './spawn-env' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { executePtyIpcSpawn } from './spawn-execute' +import { ptySizes } from '../delivery/visibility-state' +import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' + +vi.mock('../../../codex/codex-state-db-backfill-recovery', () => ({ + ensureCodexStateDbBackfillRecoveryStarted: vi.fn() +})) +vi.mock('../host-env/assembly', () => ({ buildPtyHostEnv: vi.fn((_id, env) => env) })) +vi.mock('../../../wsl/wsl-guest-spawn-options', async (original) => ({ + ...(await original()), + prepareWslGuestSpawnOptions: vi.fn(async (_owner, options) => options) +})) +vi.mock('../../../agent-hooks/wsl-hook-relay-manager', () => ({ + wslHookRelayManager: { + ensureForDistro: vi.fn(async () => {}), + getGuestEndpointFilePath: vi.fn(() => '/home/alice/.orca-wsl/agent-hooks/endpoint') + } +})) +vi.mock('../../../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', parseWslPath: () => null })) +vi.mock('../../../wsl/wsl-guest-terminal-preparation', async (original) => { + const actual = await original() + return { ...actual, prepareWslGuestTerminalSpawn: vi.fn(actual.prepareWslGuestTerminalSpawn) } +}) +const platform = process.platform +const owner = { distro: 'Ubuntu', relayBuildId: 'daemon+artifact+alice' } +const endpoint = { + distro: 'Ubuntu', + distributionId: 'registration', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/bin/bun', + entry: '/daemon.js', + envBinary: '/usr/bin/env', + socket: '/socket', + tokenPath: '/token', + serverBuildId: 'artifact' +} +const execution = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } +const prepared = { owner, endpoint, entry: '/daemon.js', path: '/bin', artifactId: 'artifact' } + +function fixture(args: Partial = {}) { + const adapter = new DaemonPtyAdapter({ + guest: { + distro: 'Ubuntu', + defaultShell: '/bin/bash', + defaultCwd: '/home/alice', + profiles: [], + transport: { + readToken: () => 'unused', + connect: async () => { + throw new Error('unexpected connection') + } + } + } + }) + const provider = new WslDaemonPtyProvider(owner, adapter) + const connection = { owner, endpoint, provider } + const sessions = new WslDaemonSessions({ + profileScope: '/profile', + historyRoot: '/history', + store: { + getWslDaemonRecovery: () => null, + upsertWslDaemonRecovery: async () => {} + } + }) + const fresh = vi + .spyOn(sessions, 'prepareFresh') + .mockResolvedValue({ prepared, execution, connection }) + const reconnect = vi.spyOn(sessions, 'reconnect').mockResolvedValue(connection) + const auth = vi.fn(async () => ({ + envPatch: {}, + stripAuthEnv: false, + configDir: '/home/alice/.claude', + provenance: 'captured-user' + })) + const selected = vi.fn>(async () => null) + const prepareResume = vi.fn(() => null) + const deps: PtySpawnIpcDeps = { + options: { wslDaemonSessions: sessions }, + getSettings: () => ({ ...getDefaultSettings('/profile'), terminalWindowsDefaultShell: 'wsl' }), + prepareClaudeAuth: auth, + getSelectedCodexHomePath: selected, + getLocalPtyStartupPromise: () => undefined, + adoptStablePane: async () => null, + assertFolderWorkspacePtyPathUsable: () => {}, + resolvePtySpawnStartupCwd: (_id, cwd) => cwd, + localStartupCwdDirectoryExists: () => true, + prepareCodexResumeHome: prepareResume, + noCodexResumeLaunch, + resolveCodexResumeLaunch, + stripSequencedStartupResumeArgv, + reconcileSharedRuntimeResumeHome: async (_home, resolve) => (await resolve()) ?? '', + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + trustedTerminalHandleEnv: new Set(), + sendPtySpawnedToRenderer: () => {}, + syncPtyBackgroundedDelivery: () => {}, + stopReplacedPty: async () => {} + } + const ctx = createPtyIpcSpawnState(deps, { + cols: 91, + rows: 31, + shellOverride: 'wsl.exe', + cwd: '/home/alice', + initiallyHidden: true, + ...args + }) + const spawn = vi.spyOn(provider, 'spawn').mockImplementation(async (options) => ({ + id: options.sessionId?.startsWith('wsl:') + ? options.sessionId + : toAppWslPtyId(owner, options.sessionId!), + wslDistro: 'Ubuntu', + ...(options.attachOnly ? { isReattach: true } : {}) + })) + return { ctx, deps, provider, fresh, reconnect, auth, selected, prepareResume, spawn, adapter } +} +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + vi.clearAllMocks() +}) +afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + ptySizes.clear() + vi.restoreAllMocks() +}) + +it('captures the owner before auth and builds guest host-env exactly once with raw ID', async () => { + const { ctx, deps, fresh, auth, selected, prepareResume, spawn, adapter } = fixture({ + command: 'claude' + }) + await preparePtyIpcSpawnPreflight(ctx) + expect(fresh).toHaveBeenCalledWith('Ubuntu', undefined) + expect(auth).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }, execution) + expect(fresh.mock.invocationCallOrder[0]).toBeLessThan(auth.mock.invocationCallOrder[0]) + expect(ctx.effectiveSessionId).not.toMatch(/^wsl:/) + expect(ctx.effectiveSessionAppId).toBe(toAppWslPtyId(owner, ctx.effectiveSessionId!)) + await assemblePtyIpcSpawnEnv(ctx) + expect(selected).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ wslExecution: execution }) + ) + expect(prepareResume).toHaveBeenCalledWith(expect.objectContaining({ wslExecution: execution })) + expect(buildPtyHostEnv).not.toHaveBeenCalled() + await buildPtyIpcSpawnOptions(ctx) + expect(buildPtyHostEnv).toHaveBeenCalledOnce() + expect(buildPtyHostEnv).toHaveBeenCalledWith( + ctx.effectiveSessionId, + expect.anything(), + expect.objectContaining({ isWsl: true, wslUser: 'alice' }) + ) + expect(prepareWslGuestTerminalSpawn).toHaveBeenCalledOnce() + expect(ptySizes.get(ctx.effectiveSessionAppId!)).toEqual({ cols: 91, rows: 31 }) + expect(ptySizes.has(ctx.effectiveSessionId!)).toBe(false) + expect(deps.transitionSpawnHiddenRendererPtyDeliveryState).toHaveBeenLastCalledWith( + ctx.effectiveSessionAppId, + true + ) + await executePtyIpcSpawn(ctx) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: ctx.effectiveSessionId, isNewSession: true }) + ) + expect(ctx.result.id).toBe(ctx.effectiveSessionAppId) + adapter.dispose() +}) + +it('reattaches the persisted encoded owner without auth, host-env or default-user hooks', async () => { + const id = toAppWslPtyId(owner, 'guest-pty') + const { ctx, fresh, reconnect, auth, selected, spawn, adapter } = fixture({ + sessionId: id, + command: 'claude' + }) + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + await buildPtyIpcSpawnOptions(ctx) + expect(fresh).not.toHaveBeenCalled() + expect(reconnect).toHaveBeenCalledWith(expect.objectContaining(owner), undefined) + expect(auth).not.toHaveBeenCalled() + expect(selected).not.toHaveBeenCalled() + expect(buildPtyHostEnv).not.toHaveBeenCalled() + await executePtyIpcSpawn(ctx) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: id, attachOnly: true })) + expect(wslHookRelayManager.ensureForDistro).toHaveBeenCalledWith( + 'Ubuntu', + undefined, + undefined, + 'alice' + ) + adapter.dispose() +}) + +it('keeps legacy raw session IDs on their original provider', async () => { + const { ctx, provider, fresh, reconnect, adapter } = fixture({ sessionId: 'legacy-session' }) + await preparePtyIpcSpawnPreflight(ctx) + expect(ctx.wslGuest).toBeNull() + expect(ctx.provider).not.toBe(provider) + expect(fresh).not.toHaveBeenCalled() + expect(reconnect).not.toHaveBeenCalled() + adapter.dispose() +}) + +it('limits fresh routing to Windows WSL and distinguishes explicitly fresh runtime IDs', async () => { + const { fresh, reconnect, adapter } = fixture() + const sessions = { prepareFresh: fresh, reconnect } + await expect( + prepareWslDaemonSpawnRoute({ sessions, distro: 'Ubuntu', platform: 'linux' }) + ).resolves.toBeNull() + await expect( + prepareWslDaemonSpawnRoute({ + sessions, + distro: 'Ubuntu', + connectionId: 'ssh', + platform: 'win32' + }) + ).resolves.toBeNull() + await expect( + prepareWslDaemonSpawnRoute({ + sessions, + distro: 'Ubuntu', + sessionId: 'raw-existing', + platform: 'win32' + }) + ).resolves.toBeNull() + expect(fresh).not.toHaveBeenCalled() + await expect( + prepareWslDaemonSpawnRoute({ + sessions, + distro: 'Ubuntu', + sessionId: 'raw-fresh', + isNewSession: true, + platform: 'win32' + }) + ).resolves.toMatchObject({ fresh: true }) + expect(fresh).toHaveBeenCalledOnce() + adapter.dispose() +}) + +it('fails closed when an encoded owner has no profile service', async () => { + await expect( + prepareWslDaemonSpawnRoute({ + sessionId: toAppWslPtyId(owner, 'existing'), + platform: 'win32' + }) + ).rejects.toThrow('unavailable') +}) + +it('passes captured user to Codex selection and avoids desktop backfill probes of guest paths', async () => { + const { ctx, selected, prepareResume, adapter } = fixture({ + command: 'codex', + launchAgent: 'codex' + }) + selected.mockResolvedValue('/home/alice/.codex') + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + expect(selected).toHaveBeenCalledWith( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + undefined, + expect.objectContaining({ wslExecution: execution, launchAgent: 'codex' }) + ) + expect(prepareResume).toHaveBeenCalledWith(expect.objectContaining({ wslExecution: execution })) + expect(ensureCodexStateDbBackfillRecoveryStarted).not.toHaveBeenCalled() + expect(buildPtyHostEnv).not.toHaveBeenCalled() + adapter.dispose() +}) diff --git a/src/main/ipc/pty/kill-all.ts b/src/main/ipc/pty/kill-all.ts index 307bfaeabb23..097643f271b7 100644 --- a/src/main/ipc/pty/kill-all.ts +++ b/src/main/ipc/pty/kill-all.ts @@ -1,11 +1,2 @@ -import { LocalPtyProvider } from '../../providers/local-pty-provider' -import { localProvider } from './provider/registry' - -/** - * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. - */ -export function killAllPty(): void { - if (localProvider instanceof LocalPtyProvider) { - localProvider.killAll() - } -} +/** Daemon-owned terminals survive app quit; shutdown is explicit through their owner. */ +export function killAllPty(): void {} diff --git a/src/main/ipc/pty/pane/adopt-stable-wsl.test.ts b/src/main/ipc/pty/pane/adopt-stable-wsl.test.ts new file mode 100644 index 000000000000..db30acde9383 --- /dev/null +++ b/src/main/ipc/pty/pane/adopt-stable-wsl.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { adoptStablePane } from './adopt-stable' +import { getProvider, getProviderForPty } from '../provider/registry' +import { attachStablePaneOwner, resolveStablePaneOwner } from './stable-owner' + +vi.mock('../provider/registry', () => ({ getProvider: vi.fn(), getProviderForPty: vi.fn() })) +vi.mock('./stable-owner', () => ({ + attachStablePaneOwner: vi.fn(), + resolveStablePaneOwner: vi.fn(), + stablePaneAdoptionsByOwnerKey: new Map() +})) +const args = { + cols: 80, + rows: 24, + worktreeId: 'folder', + tabId: 'tab', + leafId: '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e' +} +beforeEach(() => vi.clearAllMocks()) + +it('refuses an unavailable guest owner without attempting a host-native attach', async () => { + const id = 'wsl:Ubuntu@@build@@terminal' + vi.mocked(resolveStablePaneOwner).mockReturnValue({ + tabId: args.tabId, + leafId: args.leafId, + ptyId: id, + hasPersistedBinding: true + }) + vi.mocked(getProviderForPty).mockImplementation(() => { + throw new Error('WSL terminal owner is not connected') + }) + + await expect(adoptStablePane(undefined, undefined, args)).rejects.toThrow('not connected') + expect(getProviderForPty).toHaveBeenCalledWith(id) + expect(getProvider).not.toHaveBeenCalled() + expect(attachStablePaneOwner).not.toHaveBeenCalled() +}) diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts index 7c1a7d5c8185..89381b33879f 100644 --- a/src/main/ipc/pty/pane/adopt-stable.ts +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -1,7 +1,7 @@ import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { Store } from '../../../persistence' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { getProvider } from '../provider/registry' +import { getProvider, getProviderForPty } from '../provider/registry' import { makePaneSpawnReservationKey, paneSpawnReservationsByOwnerKey } from './spawn-reservation' import { attachStablePaneOwner, @@ -60,7 +60,9 @@ export async function adoptStablePane( const adoption = attachStablePaneOwner({ runtime, store, - provider: getProvider(args.connectionId), + provider: owner.ptyId.startsWith('wsl:') + ? getProviderForPty(owner.ptyId) + : getProvider(args.connectionId), spawnOptions: { cols: args.cols, rows: args.rows, diff --git a/src/main/ipc/pty/pane/stable-owner-wsl-routing.test.ts b/src/main/ipc/pty/pane/stable-owner-wsl-routing.test.ts new file mode 100644 index 000000000000..f1de98662a56 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-owner-wsl-routing.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { spawnForStablePane } from './stable-owner' +import { registerWslPtyProvider } from '../provider/registry' +import { createUnavailablePtyProvider } from '../../../providers/unavailable-pty-provider' +import { toAppWslPtyId } from '../../../../shared/wsl-pty-id' +import type { PtySpawnResult } from '../../../providers/types' + +const guestOwner = { distro: 'Ubuntu', relayBuildId: 'build+user' } +const id = toAppWslPtyId(guestOwner, 'pty2:guest:1') +const owner = { tabId: 'tab', leafId: 'leaf', ptyId: id } +const releases: (() => void)[] = [] +afterEach(() => releases.splice(0).forEach((release) => release())) +const native = () => ({ ...createUnavailablePtyProvider(), spawn: vi.fn() }) + +it('uses the late guest owner instead of the provider selected for a fresh native spawn', async () => { + const host = native() + const spawn = vi.fn(async () => ({ id, isReattach: true })) + releases.push(registerWslPtyProvider(guestOwner, { ...native(), spawn })) + const result = await spawnForStablePane({ + runtime: undefined, + provider: host, + owner, + spawnOptions: { cols: 80, rows: 24 } + }) + expect(result.result.id).toBe(id) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: id, attachOnly: true })) + expect(host.spawn).not.toHaveBeenCalled() +}) + +it('does not replace a disconnected guest with a fresh host terminal', async () => { + const host = native() + await expect( + spawnForStablePane({ + runtime: undefined, + provider: host, + owner, + spawnOptions: { cols: 80, rows: 24 } + }) + ).rejects.toThrow('not connected') + expect(host.spawn).not.toHaveBeenCalled() +}) + +it.each(['reply', 'failure'] as const)( + 'refuses a retired provider whose pending attach returns %s', + async (outcome) => { + const host = native() + let finish = () => {} + const spawn = vi.fn( + () => + new Promise((resolve, reject) => { + finish = () => + outcome === 'reply' + ? resolve({ id, isReattach: true }) + : reject(new Error('retired connection failure')) + }) + ) + const release = registerWslPtyProvider(guestOwner, { ...native(), spawn }) + releases.push(release) + const pending = spawnForStablePane({ + runtime: undefined, + provider: host, + owner, + spawnOptions: { cols: 80, rows: 24 } + }) + release() + finish() + await expect(pending).rejects.toThrow('owner changed during attach') + expect(host.spawn).not.toHaveBeenCalled() + } +) diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 83ebdd50364d..0b78a727725c 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -13,10 +13,14 @@ import { TerminalHostGoneError, TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors' +import { stablePaneAttachProvider } from './stable-pane-attach-provider' import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { isHostReportedPtyAbsenceError, isObservedPtyExitEvidence } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' -import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' +export { + stablePanePersistenceFence, + persistAdmittedStablePaneBinding +} from './stable-pane-binding-persistence' export type StablePaneOwner = { handle?: string @@ -180,52 +184,11 @@ export type StablePaneSpawnContext = { onFreshSpawn?: (result: PtySpawnResult) => void } -export function stablePanePersistenceFence( - owner: StablePaneOwner | null -): { ptyId: string; incarnationId?: string } | undefined { - return owner?.hasPersistedBinding - ? { - ptyId: owner.ptyId, - ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) - } - : undefined -} - -export async function persistAdmittedStablePaneBinding(args: { - store: Store | undefined - owner: StablePaneOwner | null - result: PtySpawnResult - worktreeId: string | undefined - startupCwd: string | undefined - connectionId: string | null | undefined -}): Promise { - const expectedBinding = stablePanePersistenceFence(args.owner) - if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { - return false - } - const persisted = await args.store.persistPtyBinding( - { - worktreeId: args.worktreeId, - tabId: args.owner.tabId, - leafId: args.owner.leafId, - ptyId: args.result.id, - ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - expectedBinding, - origin: spawnCommitBindingOrigin(args.result) - }, - args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined - ) - if (persisted === false) { - throw new Error('terminal_pane_owner_changed') - } - return true -} - export async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { - const { owner, provider, runtime, spawnOptions } = args + const { owner, runtime, spawnOptions } = args + const { provider, assertOwnerConnected } = stablePaneAttachProvider(owner.ptyId, args.provider) let result: PtySpawnResult try { result = await provider.spawn({ @@ -245,6 +208,7 @@ export async function attachStablePaneOwner( onPtySpawnCommitted: undefined }) } catch (error) { + assertOwnerConnected() if (error instanceof TerminalSessionOwnerUnverifiedError) { throw new Error('terminal_pane_owner_unverified') } @@ -265,11 +229,7 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } - // `pty.attach` answers absent both for a pid the relay probed and found gone and for an id its - // session map never had — every id minted before a relay restart, checked against nothing. Only - // the marked half observed the process, so only it may certify a death; the rest publishes the - // stop sentinel its sibling handlePtyReattachFailure publishes, which every reader resolves to - // `stop_unverified` (docs/reference/ssh-execution-boundary.md). + // Only observed exit evidence certifies death; relay-map absence remains unverifiable. runtime?.onPtyExit( owner.ptyId, UNVERIFIED_PROCESS_EXIT_CODE, @@ -289,6 +249,7 @@ export async function attachStablePaneOwner( } return null } + assertOwnerConnected() if ( result.id !== owner.ptyId || result.isReattach !== true || diff --git a/src/main/ipc/pty/pane/stable-pane-attach-provider.ts b/src/main/ipc/pty/pane/stable-pane-attach-provider.ts new file mode 100644 index 000000000000..c3576b299b1b --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-attach-provider.ts @@ -0,0 +1,16 @@ +import type { IPtyProvider } from '../../../providers/types' +import { getProviderForPty, tryGetProviderForPty } from '../provider/registry' + +/** A late guest owner overrides the fresh-spawn provider and fences asynchronous replies. */ +export function stablePaneAttachProvider(id: string, freshProvider: IPtyProvider) { + const guestOwner = id.startsWith('wsl:') + const provider = guestOwner ? getProviderForPty(id) : freshProvider + return { + provider, + assertOwnerConnected() { + if (guestOwner && tryGetProviderForPty(id) !== provider) { + throw new Error('WSL terminal owner changed during attach') + } + } + } +} diff --git a/src/main/ipc/pty/pane/stable-pane-binding-persistence.ts b/src/main/ipc/pty/pane/stable-pane-binding-persistence.ts new file mode 100644 index 000000000000..00560eecc7db --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-binding-persistence.ts @@ -0,0 +1,47 @@ +import type { Store } from '../../../persistence' +import type { PtySpawnResult } from '../../../providers/types' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' +import type { StablePaneOwner } from './stable-owner' + +export function stablePanePersistenceFence( + owner: StablePaneOwner | null +): { ptyId: string; incarnationId?: string } | undefined { + return owner?.hasPersistedBinding + ? { + ptyId: owner.ptyId, + ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) + } + : undefined +} + +export async function persistAdmittedStablePaneBinding(args: { + store: Store | undefined + owner: StablePaneOwner | null + result: PtySpawnResult + worktreeId: string | undefined + startupCwd: string | undefined + connectionId: string | null | undefined +}): Promise { + const expectedBinding = stablePanePersistenceFence(args.owner) + if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { + return false + } + const persisted = await args.store.persistPtyBinding( + { + worktreeId: args.worktreeId, + tabId: args.owner.tabId, + leafId: args.owner.leafId, + ptyId: args.result.id, + ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + expectedBinding, + origin: spawnCommitBindingOrigin(args.result) + }, + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + return true +} diff --git a/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts b/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts new file mode 100644 index 000000000000..e2580dec1b5c --- /dev/null +++ b/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts @@ -0,0 +1,69 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { bindProviderListeners } from './bind-listeners' +import { setRebindProviderListeners, unbindLocalProviderListeners } from './listener-lifecycle' +import { + getLocalPtyProvider, + setLocalPtyProvider, + registerWslPtyProvider, + registerSshPtyProvider, + unregisterSshPtyProvider +} from './registry' +import { createUnavailablePtyProvider } from '../../../providers/unavailable-pty-provider' +import type { IPtyProvider } from '../../../providers/types' + +const original = getLocalPtyProvider() +const releases: (() => void)[] = [] +afterEach(() => { + setRebindProviderListeners(null) + unbindLocalProviderListeners() + releases.splice(0).forEach((release) => release()) + unregisterSshPtyProvider('remote') + setLocalPtyProvider(original) +}) +function provider() { + const listeners = new Set[0]>() + return { + value: { + ...createUnavailablePtyProvider(), + onData(listener: Parameters[0]) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + }, + listeners, + emit: (id: string) => listeners.forEach((listener) => listener({ id, data: 'output' })) + } +} +it('binds added guests once, excludes SSH and removes listeners across reload and disconnect', () => { + const native = provider(), + guest = provider(), + remote = provider() + setLocalPtyProvider(native.value) + registerSshPtyProvider('remote', remote.value) + const session = { + acceptPtyDataForRenderer: vi.fn(), + sendModelRestoreNeededMarker: vi.fn(), + consumeSyntheticKillExit: vi.fn(), + sendPtyExitToRenderer: vi.fn() + } + setRebindProviderListeners(() => bindProviderListeners(session)) + bindProviderListeners(session) + const release = registerWslPtyProvider({ distro: 'Ubuntu', relayBuildId: 'owner' }, guest.value) + releases.push(release) + bindProviderListeners(session) + expect(native.listeners.size).toBe(1) + expect(guest.listeners.size).toBe(1) + expect(remote.listeners.size).toBe(0) + native.emit('native') + guest.emit('guest') + expect(session.acceptPtyDataForRenderer).toHaveBeenCalledTimes(2) + release() + expect(guest.listeners.size).toBe(0) + expect(native.listeners.size).toBe(1) + guest.emit('retired') + expect(session.acceptPtyDataForRenderer).toHaveBeenCalledTimes(2) + unbindLocalProviderListeners() + expect(native.listeners.size).toBe(0) +}) diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 31bacec82f7f..03d3bc2b1299 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -1,5 +1,4 @@ import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' -import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isCurrentPtyExit, ptyOwnership } from './ownership-state' import { localBackgroundStreamUnsub, @@ -11,12 +10,30 @@ import { setLocalExitUnsub, setLocalWriteUnavailableUnsub } from './listener-lifecycle' -import { localProvider } from './registry' +import { registeredPtyProviders } from './registry' +import type { IPtyProvider } from '../../../providers/types' import { clearProviderPtyState } from './state-cleanup' import { providerSnapshotRequiredPtys } from '../delivery/visibility-state' import type { PtyIpcSession } from '../session' -export function bindProviderListeners(session: PtyIpcSession): void { +function subscribeLocalProviders(subscribe: (provider: IPtyProvider) => (() => void) | undefined) { + const cleanups = registeredPtyProviders() + .filter(({ connectionId }) => connectionId === null) + .map(({ provider }) => subscribe(provider)) + return () => cleanups.forEach((cleanup) => cleanup?.()) +} + +export function bindProviderListeners( + session: Pick< + PtyIpcSession, + | 'mainWindow' + | 'runtime' + | 'sendModelRestoreNeededMarker' + | 'acceptPtyDataForRenderer' + | 'consumeSyntheticKillExit' + | 'sendPtyExitToRenderer' + > +): void { localDataUnsub?.() localExitUnsub?.() localBackgroundStreamUnsub?.() @@ -26,92 +43,97 @@ export function bindProviderListeners(session: PtyIpcSession): void { // each affected pane here so background panes remount + re-attach too, not // just the pane whose write happened to detect the dead endpoint (STA-2373). setLocalWriteUnavailableUnsub( - localProvider.onWriteUnavailable?.((payload) => { - if ( - !session.mainWindow || - session.mainWindow.isDestroyed() || - (typeof session.mainWindow.webContents.isDestroyed === 'function' && - session.mainWindow.webContents.isDestroyed()) - ) { - return - } - session.mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id }) - }) ?? null + subscribeLocalProviders((provider) => + provider.onWriteUnavailable?.((payload) => { + if ( + !session.mainWindow || + session.mainWindow.isDestroyed() || + (typeof session.mainWindow.webContents.isDestroyed === 'function' && + session.mainWindow.webContents.isDestroyed()) + ) { + return + } + session.mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id }) + }) + ) ) // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. setLocalBackgroundStreamUnsub( - localProvider.onBackgroundStreamEvent?.((payload) => { - if (payload.kind === 'backgroundMarker') { - session.runtime?.setPtyTransientFactDelegation( - payload.id, - payload.background, - payload.scanSeedAnsi, - payload.mode2031PendingSubscribe - ) - return - } - if (payload.kind === 'dataGap') { - providerSnapshotRequiredPtys.add(payload.id) - session.runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) - session.sendModelRestoreNeededMarker( - payload.id, - 'hidden-drop', - session.runtime?.getPtyOutputSequence(payload.id) - ) - return - } - session.runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) - }) ?? null + subscribeLocalProviders((provider) => + provider.onBackgroundStreamEvent?.((payload) => { + if (payload.kind === 'backgroundMarker') { + session.runtime?.setPtyTransientFactDelegation( + payload.id, + payload.background, + payload.scanSeedAnsi, + payload.mode2031PendingSubscribe + ) + return + } + if (payload.kind === 'dataGap') { + providerSnapshotRequiredPtys.add(payload.id) + session.runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) + session.sendModelRestoreNeededMarker( + payload.id, + 'hidden-drop', + session.runtime?.getPtyOutputSequence(payload.id) + ) + return + } + session.runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + }) + ) ) // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. - const isLocalProvider = localProvider instanceof LocalPtyProvider setLocalDataUnsub( - localProvider.onData((payload) => { - const rawLength = payload.sequenceChars ?? payload.data.length - const outputSeq = isLocalProvider - ? session.runtime?.getPtyOutputSequence(payload.id) - : session.runtime?.onPtyData( - payload.id, - payload.data, - Date.now(), - rawLength, - payload.transformed - ) - session.acceptPtyDataForRenderer(payload, outputSeq) - }) + subscribeLocalProviders((provider) => + provider.onData((payload) => { + const rawLength = payload.sequenceChars ?? payload.data.length + const outputSeq = session.runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + rawLength, + payload.transformed + ) + session.acceptPtyDataForRenderer(payload, outputSeq) + }) + ) ) setLocalExitUnsub( - localProvider.onExit((payload) => { - if (!isCurrentPtyExit(payload)) { - return - } - const syntheticExit = session.consumeSyntheticKillExit(payload.id, payload.incarnationId) - if (!isLocalProvider) { - clearProviderPtyState(payload.id) - ptyOwnership.delete(payload.id) - markClaudePtyExited(payload.id) + subscribeLocalProviders((provider) => + provider.onExit((payload) => { + if (!isCurrentPtyExit(payload)) { + return + } + const syntheticExit = session.consumeSyntheticKillExit(payload.id, payload.incarnationId) + { + clearProviderPtyState(payload.id) + ptyOwnership.delete(payload.id) + markClaudePtyExited(payload.id) + if (syntheticExit) { + session.runtime?.markPtyStopRequested(payload.id) + } + session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { + providerExitObserved: true, + ...(payload.cause ? { cause: payload.cause } : {}) + }) + } + // The control reply can overtake stream data; the physical exit must retire that late output. if (syntheticExit) { - session.runtime?.markPtyStopRequested(payload.id) + return } - session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { - providerExitObserved: true, - ...(payload.cause ? { cause: payload.cause } : {}) + // Why not the whole payload: the exit cause is a main-process fact for the + // runtime's records; the renderer's pty:exit contract stays as it was. + session.sendPtyExitToRenderer({ + id: payload.id, + code: payload.code, + ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) }) - } - // The control reply can overtake stream data; the physical exit must retire that late output. - if (syntheticExit) { - return - } - // Why not the whole payload: the exit cause is a main-process fact for the - // runtime's records; the renderer's pty:exit contract stays as it was. - session.sendPtyExitToRenderer({ - id: payload.id, - code: payload.code, - ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) }) - }) + ) ) } diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts deleted file mode 100644 index bf07eddf0bbb..000000000000 --- a/src/main/ipc/pty/provider/local-configure.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' -import { getAppEnvironment } from '../../../../shared/app-environment' -import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' -import { isPwshAvailableAsync } from '../../../pwsh' -import { LocalPtyProvider } from '../../../providers/local-pty-provider' -import { - addOrcaWslInteropEnv, - stampWslOrchestrationCompatibilityHost -} from '../../../pty/wsl-orca-env' -import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' -import { buildPtyHostEnv } from '../host-env/assembly' -import { - getCompatibleSelectedCodexHomePath, - isCodexStatusHooksEnabled, - shouldStripInheritedOrcaCodexHome -} from '../host-env/codex-home' -import type { GetSelectedCodexHomePath } from '../host-env/types' -import { isCurrentPtyExit, ptyOwnership } from './ownership-state' -import { localProvider } from './registry' -import { clearProviderPtyState } from './state-cleanup' -import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' - -export function configureLocalPtyProvider(args: { - runtime?: OrcaRuntimeService - getSettings?: () => GlobalSettings - getSelectedCodexHomePath?: GetSelectedCodexHomePath - trustedTerminalHandleEnv: Set -}): void { - // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. - if (!(localProvider instanceof LocalPtyProvider)) { - return - } - const { runtime, getSettings, getSelectedCodexHomePath, trustedTerminalHandleEnv } = args - localProvider.configure({ - isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, - getWindowsShell: () => getSettings?.()?.terminalWindowsShell, - getDefaultShell: () => getSettings?.()?.terminalDefaultShell, - getWindowsPowerShellImplementation: () => - getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, - pwshAvailable: () => isPwshAvailableAsync(), - buildSpawnEnv: async (id, baseEnv, ctx) => { - const codexSelectionTarget: CodexAccountSelectionTarget = - ctx?.isWsl === true - ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } - : { runtime: 'host' } - const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - ctx?.codexHomePathOverride - ? ctx.codexHomePathOverride.value - : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - })) ?? null) - ) - const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath - const ptySettings = getSettings?.() - await inheritOmpLaunchEnvironment(baseEnv, { - shellPath: ctx?.shellPath, - explicitEnv: ctx?.explicitEnv, - isWsl: ctx?.isWsl, - launchAgent: ctx?.launchAgent, - launchCommand: ctx?.command - }) - await awaitExplicitPiOmpGuestReadiness({ - isWsl: ctx?.isWsl === true, - distro: ctx?.wslDistro, - codexHomePath: selectedCodexHomePath, - launchAgent: ctx?.launchAgent, - launchCommand: ctx?.command - }) - const env = buildPtyHostEnv(id, baseEnv, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }), - launchCommand: ctx?.command, - launchAgent: ctx?.launchAgent, - isWsl: ctx?.isWsl, - wslDistro: ctx?.wslDistro ?? null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - routeBrowserOpensToClient: runtime?.shouldRelayTerminalBrowserOpens?.() - }) - // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. - const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE - const preAllocatedHandle = - requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) - ? requestedHandle - : runtime?.preAllocateHandleForPty(id) - if (requestedHandle && requestedHandle !== preAllocatedHandle) { - delete env.ORCA_TERMINAL_HANDLE - } - if (preAllocatedHandle) { - env.ORCA_TERMINAL_HANDLE = preAllocatedHandle - } - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - ctx?.isWsl === true ? ctx.wslDistro : null - ) - if (ctx?.isWsl === true) { - addOrcaWslInteropEnv(env) - } - return env - }, - onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), - onExit: (id, code, incarnationId, cause) => { - if (!isCurrentPtyExit({ id, incarnationId })) { - return - } - clearProviderPtyState(id) - ptyOwnership.delete(id) - markClaudePtyExited(id) - runtime?.onPtyExit(id, code, incarnationId, { - providerExitObserved: true, - ...(cause ? { cause } : {}) - }) - }, - onData: (id, data, timestamp, sequenceChars, transformed) => - runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) - }) -} diff --git a/src/main/ipc/pty/provider/local-inventory-authority.ts b/src/main/ipc/pty/provider/local-inventory-authority.ts new file mode 100644 index 000000000000..02b03e5ff3a4 --- /dev/null +++ b/src/main/ipc/pty/provider/local-inventory-authority.ts @@ -0,0 +1,86 @@ +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider } from '../../../providers/types' +import { ptyOwnership } from './ownership-state' +import { registeredPtyProviders, tryGetProviderForPty } from './registry' + +export type LocalInventoryAuthorityDeps = { + store?: Pick + runtime?: Pick +} + +/** Persisted bindings still name an owner when its connector has left the registry. */ +export function createLocalInventoryAuthority(deps: LocalInventoryAuthorityDeps) { + const known = new Set(ptyOwnership.keys()) + const session = deps.store?.getWorkspaceSession() + for (const tabs of Object.values(session?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (tab.ptyId) { + known.add(tab.ptyId) + } + } + } + for (const id of Object.values(session?.remoteSessionIdsByTabId ?? {})) { + if (id) { + known.add(id) + } + } + for (const layout of Object.values(session?.terminalLayoutsByTabId ?? {})) { + for (const id of Object.values(layout.ptyIdsByLeafId ?? {})) { + if (id) { + known.add(id) + } + } + } + const guestIds = [...known].filter((id) => id.startsWith('wsl:')) + const owners = new Map(guestIds.map((id) => [id, tryGetProviderForPty(id)])) + const localProviders = () => + new Set( + registeredPtyProviders() + .filter((entry) => entry.connectionId === null) + .map((entry) => entry.provider) + ) + const providers = localProviders() + let complete = true + const mark = (id: string, reason: string) => { + complete = false + deps.runtime?.markPtyLivenessUnverifiable?.(id, reason) + } + for (const [id, provider] of owners) { + if (!provider) { + mark(id, 'WSL terminal owner is not connected') + } + } + const isComplete = () => { + const current = localProviders() + if ( + current.size !== providers.size || + [...providers].some((provider) => !current.has(provider)) + ) { + complete = false + } + for (const [id, provider] of owners) { + if (provider !== tryGetProviderForPty(id)) { + mark(id, 'WSL terminal owner changed during inventory') + } + } + return complete + } + return { + failed(provider: IPtyProvider, error: unknown) { + complete = false + const reason = error instanceof Error ? error.message : String(error) + for (const id of guestIds) { + if (owners.get(id) === provider) { + mark(id, reason) + } + } + }, + isComplete, + assertComplete() { + if (!isComplete()) { + throw new Error('Local terminal inventory is unverifiable: a WSL owner is unavailable') + } + } + } +} diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts index 2a3d3b162fd4..65d689df13b2 100644 --- a/src/main/ipc/pty/provider/registry.ts +++ b/src/main/ipc/pty/provider/registry.ts @@ -1,14 +1,18 @@ -import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { parseAppWslPtyId, type WslPtyOwner } from '../../../../shared/wsl-pty-id' +import { wslPtyOwnerKey } from '../../../../shared/wsl-pty-consumer-recovery' +import { relayProvidersByGeneration } from '../../../providers/relay-pty-generation-registry' +import { createUnavailablePtyProvider } from '../../../providers/unavailable-pty-provider' import type { IPtyProvider } from '../../../providers/types' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership } from './ownership-state' +import { rebindLocalProviderListeners } from './listener-lifecycle' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). -export let localProvider: IPtyProvider = new LocalPtyProvider() +export let localProvider: IPtyProvider = createUnavailablePtyProvider() export const sshProviders = new Map() -export const sshProvidersByGeneration = new Map() +const wslProviders = new Map() export type RegisteredPtyProvider = { provider: IPtyProvider @@ -18,7 +22,8 @@ export type RegisteredPtyProvider = { export function registeredPtyProviders(): RegisteredPtyProvider[] { return [ { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })), + ...Array.from(wslProviders.values(), (provider) => ({ provider, connectionId: null })) ] } @@ -38,7 +43,34 @@ export function getProvider(connectionId: string | null | undefined): IPtyProvid return provider } +export function registerWslPtyProvider(owner: WslPtyOwner, provider: IPtyProvider): () => void { + const key = wslPtyOwnerKey(owner) + const previous = wslProviders.get(key) + if (previous && previous !== provider) { + throw new Error('WSL terminal owner already registered') + } + wslProviders.set(key, provider) + rebindLocalProviderListeners() + return () => { + if (wslProviders.get(key) === provider) { + wslProviders.delete(key) + rebindLocalProviderListeners() + } + } +} + export function getProviderForPty(ptyId: string): IPtyProvider { + const wslOwner = parseAppWslPtyId(ptyId) + if (wslOwner) { + const provider = wslProviders.get(wslPtyOwnerKey(wslOwner)) + if (!provider) { + throw new Error('WSL terminal owner is not connected; reconnect its owning distro') + } + return provider + } + if (ptyId.startsWith('wsl:')) { + throw new Error('Invalid WSL terminal identity') + } const connectionId = ptyOwnership.get(ptyId) if (connectionId === undefined) { const parsedSshId = parseAppSshPtyId(ptyId) @@ -52,6 +84,13 @@ export function getProviderForPty(ptyId: string): IPtyProvider { } export function hasPtyProviderForInspection(ptyId: string): boolean { + const wslOwner = parseAppWslPtyId(ptyId) + if (wslOwner) { + return wslProviders.has(wslPtyOwnerKey(wslOwner)) + } + if (ptyId.startsWith('wsl:')) { + return false + } // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. const connectionId = ptyOwnership.get(ptyId) if (connectionId === undefined) { @@ -89,6 +128,9 @@ export function closeStartupQueryAuthorityForPty(ptyId: string): void { } export function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { + if (ptyId.startsWith('wsl:')) { + return tryGetProviderForPty(ptyId) + } const ownedConnectionId = ptyOwnership.get(ptyId) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null try { @@ -103,7 +145,7 @@ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvi sshProviders.set(connectionId, provider) const generation = (provider as { providerGeneration?: number }).providerGeneration if (Number.isSafeInteger(generation) && generation! > 0) { - sshProvidersByGeneration.set(generation!, provider) + relayProvidersByGeneration.set(generation!, provider) } } @@ -111,8 +153,8 @@ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvi export function unregisterSshPtyProvider(connectionId: string): void { const provider = sshProviders.get(connectionId) const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration - if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) { - sshProvidersByGeneration.delete(generation) + if (generation !== undefined && relayProvidersByGeneration.get(generation) === provider) { + relayProvidersByGeneration.delete(generation) } sshProviders.delete(connectionId) } diff --git a/src/main/ipc/pty/provider/wsl-registry.test.ts b/src/main/ipc/pty/provider/wsl-registry.test.ts new file mode 100644 index 000000000000..89d33e6c7c35 --- /dev/null +++ b/src/main/ipc/pty/provider/wsl-registry.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, it } from 'vitest' +import { createUnavailablePtyProvider } from '../../../providers/unavailable-pty-provider' +import { toAppWslPtyId } from '../../../../shared/wsl-pty-id' +import { + getLocalPtyProvider, + getProviderForPty, + hasPtyProviderForInspection, + registerWslPtyProvider, + tryGetProviderForAgentSessionOwner +} from './registry' +const releases: (() => void)[] = [] +afterEach(() => { + for (const release of releases.splice(0)) { + release() + } +}) +it('routes exact guest identity while retaining legacy Windows daemon IDs', () => { + const owner = { distro: 'Ubuntu', relayBuildId: 'version+hash' } + const provider = createUnavailablePtyProvider() + releases.push(registerWslPtyProvider(owner, provider)) + const id = toAppWslPtyId(owner, 'pty2:guest:1') + expect(getProviderForPty(id)).toBe(provider) + expect(tryGetProviderForAgentSessionOwner(id)).toBe(provider) + expect(hasPtyProviderForInspection(id)).toBe(true) + expect(getProviderForPty('pty2:windows:1')).toBe(getLocalPtyProvider()) + releases.pop()?.() + expect(() => getProviderForPty(id)).toThrow('not connected') + expect(tryGetProviderForAgentSessionOwner(id)).toBeUndefined() + expect(hasPtyProviderForInspection(id)).toBe(false) +}) +it('never falls through to Windows for malformed or foreign guest identity', () => { + expect(() => getProviderForPty('wsl:broken')).toThrow('Invalid WSL') + expect(() => + getProviderForPty(toAppWslPtyId({ distro: 'Debian', relayBuildId: 'old' }, 'pty2:guest:1')) + ).toThrow('not connected') +}) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index aaace9fac370..a9ada6b96368 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -1,14 +1,15 @@ +import { parseAppWslPtyId } from '../../../shared/wsl-pty-id' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { resolveStablePaneOwner } from './pane/stable-owner' import { getAppEnvironment } from '../../../shared/app-environment' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' -import { LocalPtyProvider } from '../../providers/local-pty-provider' import type { TerminalStartupCwdMissingDirFallback } from '../../../shared/terminal-startup-cwd' import { getHiddenRendererPtyDeliveryDebug, resetRendererScopedHiddenPtyDeliveryState } from '../pty-hidden-delivery-gate' -import { localProvider } from './provider/registry' import { finishPtyShutdown } from './provider/liveness' import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' import { installPtyInspectIpcHandlers } from './ipc/inspect' @@ -34,7 +35,6 @@ import { } from './delivery/visibility-state' import { setRebindProviderListeners, - setDidFinishLoadHandler, setRendererGateResetState } from './provider/listener-lifecycle' import { @@ -44,12 +44,10 @@ import { } from './delivery/debug' import { registerRendererLifecycleResetHandlers, - clearRendererGateResetHandlers, - clearDidFinishLoadHandler + clearRendererGateResetHandlers } from './delivery/lifecycle-reset' import { createPtyIpcSession, type PtyIpcSessionOptions, type PtyRendererDelivery } from './session' import { wirePtyIpcSession } from './delivery/wire-session' -import { configureLocalPtyProvider } from './provider/local-configure' import { bindProviderListeners } from './provider/bind-listeners' import { installSessionSshOutputIntake } from './delivery/ssh-intake' import { installPtySerializeBufferIpc } from './ipc/serialize-buffer' @@ -146,12 +144,6 @@ export function registerPtyHandlers( options }) wirePtyIpcSession(session) - configureLocalPtyProvider({ - runtime, - getSettings, - getSelectedCodexHomePath, - trustedTerminalHandleEnv: session.trustedTerminalHandleEnv - }) installSessionSshOutputIntake(session) bindProviderListeners(session) setRebindProviderListeners(() => bindProviderListeners(session)) @@ -178,23 +170,6 @@ export function registerPtyHandlers( mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) } - // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. - clearDidFinishLoadHandler() - if (mainWindow && localProvider instanceof LocalPtyProvider) { - const lp = localProvider - const finishLoadHandler = () => { - // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). - const generation = lp.advanceGeneration() - if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { - return - } - // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. - lp.killOrphanedPtys(generation - 1) - } - setDidFinishLoadHandler(finishLoadHandler, mainWindow.webContents) - mainWindow.webContents.on('did-finish-load', finishLoadHandler) - } - const assertFolderWorkspacePtyPathUsable = ( worktreeId: string | undefined ): Promise | void => assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) @@ -207,8 +182,22 @@ export function registerPtyHandlers( args: Parameters[1] ): ReturnType => prepareCodexResumeHome(options?.prepareCodexSessionResume, args) - const adoptStablePaneBound = (args: Parameters[2]) => - adoptStablePane(runtime, store, args) + const adoptStablePaneBound = async (args: Parameters[2]) => { + if (!args.connectionId && options?.wslDaemonSessions) { + const binding = resolveStablePaneOwner( + runtime, + store, + makePaneKey(args.tabId, args.leafId), + args.worktreeId, + args.connectionId + ) + const owner = binding?.ptyId ? parseAppWslPtyId(binding.ptyId) : null + if (owner) { + await options.wslDaemonSessions.reconnect(owner) + } + } + return adoptStablePane(runtime, store, args) + } // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. installPtyRuntimeController({ @@ -280,6 +269,6 @@ export function registerPtyHandlers( }) installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) - installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) + installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise, store, runtime }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts index 424cb40facdb..8ba180547ca3 100644 --- a/src/main/ipc/pty/register-headless-runtime.ts +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -1,3 +1,4 @@ +import type { WslDaemonSessions } from '../../wsl/wsl-daemon-sessions' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -18,6 +19,7 @@ export function registerHeadlessPtyRuntime( store?: Store, prepareCodexSessionResume?: PrepareCodexSessionResume, lifecycle?: { + wslDaemonSessions?: WslDaemonSessions onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void onPtyExit?: (id: string, exitSequence: number) => void } diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 8f20245505ca..f79765ead498 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -1,3 +1,5 @@ +import type { WslAccountExecutionContext } from '../../../wsl/wsl-account-execution-context' +import type { WslDaemonSessions } from '../../../wsl/wsl-daemon-sessions' import type { PtyRendererDelivery } from '../session' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { Store } from '../../../persistence' @@ -26,6 +28,7 @@ export type PtyRuntimeControllerDeps = { getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined prepareCodexResumeHome: (args: { + wslExecution?: WslAccountExecutionContext connectionId?: string | null launchAgent?: TuiAgent providerSession?: AgentProviderSessionMetadata @@ -77,6 +80,7 @@ export type PtyRuntimeControllerDeps = { getSelectedCodexHomePath?: GetSelectedCodexHomePath prepareClaudeAuth?: PrepareClaudeAuth options?: { + wslDaemonSessions?: WslDaemonSessions onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void prepareCodexSessionResume?: PrepareCodexSessionResume } diff --git a/src/main/ipc/pty/runtime/foreground-process-evidence-capability.ts b/src/main/ipc/pty/runtime/foreground-process-evidence-capability.ts index 0cbf4377d0d1..a2bd0b60aee2 100644 --- a/src/main/ipc/pty/runtime/foreground-process-evidence-capability.ts +++ b/src/main/ipc/pty/runtime/foreground-process-evidence-capability.ts @@ -1,19 +1,20 @@ -import { getProvider, registeredPtyProviders } from '../provider/registry' +import { getProvider, registeredPtyProviders, localProvider } from '../provider/registry' /** Probe the owning provider before opting into the no-process-table inventory projection. */ export async function supportsForegroundProcessEvidenceFromRuntimeController( connectionId?: string | null ): Promise { - if (connectionId === null) { - return true - } - if (connectionId === undefined) { - const providers = registeredPtyProviders() + if (connectionId === undefined || connectionId === null) { + const providers = registeredPtyProviders().filter( + (entry) => connectionId === undefined || entry.connectionId === null + ) const supported = await Promise.all( - providers.map(async ({ provider, connectionId: providerConnectionId }) => - providerConnectionId === null + providers.map(async ({ provider }) => + provider === localProvider ? true - : ((await provider.supportsForegroundProcessEvidence?.()) ?? false) + : ((await Promise.resolve() + .then(() => provider.supportsForegroundProcessEvidence?.()) + .catch(() => false)) ?? false) ) ) return supported.every(Boolean) diff --git a/src/main/ipc/pty/runtime/inventory-operations.ts b/src/main/ipc/pty/runtime/inventory-operations.ts index 882908a926e1..9c09602ef170 100644 --- a/src/main/ipc/pty/runtime/inventory-operations.ts +++ b/src/main/ipc/pty/runtime/inventory-operations.ts @@ -1,3 +1,7 @@ +import { + createLocalInventoryAuthority, + type LocalInventoryAuthorityDeps +} from '../provider/local-inventory-authority' import { ptyOwnership } from '../provider/ownership-state' import { getProvider, localProvider, registeredPtyProviders } from '../provider/registry' import { @@ -6,10 +10,9 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import type { PtyProcessInfo } from '../../../providers/pty-process-info' -import type { PtyRuntimeControllerDeps } from './controller-deps' function markSshInventoryUnverifiable( - runtime: PtyRuntimeControllerDeps['runtime'], + runtime: LocalInventoryAuthorityDeps['runtime'], connectionId: string, error: unknown ): void { @@ -22,9 +25,10 @@ function markSshInventoryUnverifiable( } export async function listProcessesWithHostScopeFromRuntimeController( - deps: PtyRuntimeControllerDeps, + deps: LocalInventoryAuthorityDeps, opts?: { deadlineMs?: number; includeForegroundProcessEvidence?: boolean } ): Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }> { + const localAuthority = createLocalInventoryAuthority(deps) const providerSessions = await Promise.all( registeredPtyProviders().map(async ({ provider, connectionId }) => { const hostId: ExecutionHostId = connectionId @@ -32,12 +36,18 @@ export async function listProcessesWithHostScopeFromRuntimeController( : LOCAL_EXECUTION_HOST_ID try { return { - processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()), + processes: await (provider === localProvider + ? provider.listProcesses() + : provider.listProcesses(opts)), hostId } } catch (error) { if (!connectionId) { - throw error + if (provider === localProvider) { + throw error + } + localAuthority.failed(provider, error) + return null } markSshInventoryUnverifiable(deps.runtime, connectionId, error) return null @@ -47,17 +57,38 @@ export async function listProcessesWithHostScopeFromRuntimeController( const respondingSessions = providerSessions.filter((session) => session !== null) return { processes: respondingSessions.flatMap((session) => session.processes), - hostIds: respondingSessions.map((session) => session.hostId) + hostIds: [...new Set(respondingSessions.map((session) => session.hostId))].filter( + (hostId) => hostId !== LOCAL_EXECUTION_HOST_ID || localAuthority.isComplete() + ) } } export async function listProcessesFromRuntimeController( - deps: PtyRuntimeControllerDeps, + deps: LocalInventoryAuthorityDeps, connectionId?: string | null, opts?: { deadlineMs?: number; includeForegroundProcessEvidence?: boolean } ) { if (connectionId === null) { - return localProvider.listProcesses() + const localAuthority = createLocalInventoryAuthority(deps) + const rows = await Promise.all( + registeredPtyProviders() + .filter(({ connectionId }) => connectionId === null) + .map(async ({ provider }) => { + try { + return await (provider === localProvider + ? provider.listProcesses() + : provider.listProcesses(opts)) + } catch (error) { + if (provider === localProvider) { + throw error + } + localAuthority.failed(provider, error) + return [] + } + }) + ) + localAuthority.assertComplete() + return rows.flat() } if (connectionId !== undefined) { try { diff --git a/src/main/ipc/pty/runtime/local-guest-inventory.test.ts b/src/main/ipc/pty/runtime/local-guest-inventory.test.ts new file mode 100644 index 000000000000..d284de34beb5 --- /dev/null +++ b/src/main/ipc/pty/runtime/local-guest-inventory.test.ts @@ -0,0 +1,171 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createUnavailablePtyProvider } from '../../../providers/unavailable-pty-provider' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { toAppWslPtyId } from '../../../../shared/wsl-pty-id' +import { + getLocalPtyProvider, + registerSshPtyProvider, + registerWslPtyProvider, + setLocalPtyProvider, + unregisterSshPtyProvider +} from '../provider/registry' +import { ptyOwnership } from '../provider/ownership-state' +import { + listProcessesFromRuntimeController, + listProcessesWithHostScopeFromRuntimeController +} from './inventory-operations' +import { supportsForegroundProcessEvidenceFromRuntimeController } from './foreground-process-evidence-capability' + +const releases: (() => void)[] = [] +const original = getLocalPtyProvider() +afterEach(() => { + for (const release of releases.splice(0)) { + release() + } + unregisterSshPtyProvider('remote') + setLocalPtyProvider(original) + ptyOwnership.clear() +}) +const ubuntu = { distro: 'Ubuntu', relayBuildId: 'build+user1' } +const debian = { distro: 'Debian', relayBuildId: 'build+user2' } +const row = (id: string) => ({ + id, + cwd: '/folder with spaces', + title: 'shell', + worktreeId: 'folder-project' +}) +function setup() { + const nativeList = vi.fn(async () => [row('pty2:windows:1')]) + const firstList = vi.fn(async () => [row(toAppWslPtyId(ubuntu, 'pty2:guest:1'))]) + const secondList = vi.fn(async () => [row(toAppWslPtyId(debian, 'pty2:guest:1'))]) + const provider = (listProcesses: typeof nativeList) => ({ + ...createUnavailablePtyProvider(), + listProcesses, + supportsForegroundProcessEvidence: vi.fn(async () => true) + }) + setLocalPtyProvider(provider(nativeList)) + releases.push(registerWslPtyProvider(ubuntu, provider(firstList))) + const second = provider(secondList) + releases.push(registerWslPtyProvider(debian, second)) + const sshList = vi.fn(async () => [row('ssh:remote@@pty2:remote:1')]) + registerSshPtyProvider('remote', provider(sshList)) + return { nativeList, firstList, secondList, sshList, second } +} + +it('local scope includes native and both guest owners without touching SSH or rewriting folder identity', async () => { + const { sshList } = setup() + const rows = await listProcessesFromRuntimeController({}, null) + expect(rows.map((entry) => entry.id)).toEqual([ + 'pty2:windows:1', + toAppWslPtyId(ubuntu, 'pty2:guest:1'), + toAppWslPtyId(debian, 'pty2:guest:1') + ]) + expect( + rows.every( + (entry) => entry.cwd === '/folder with spaces' && entry.worktreeId === 'folder-project' + ) + ).toBe(true) + expect(sshList).not.toHaveBeenCalled() +}) + +it('failed guest removes local absence authority while retaining successful rows and SSH authority', async () => { + const { secondList } = setup() + secondList.mockRejectedValue(new Error('guest disconnected')) + const id = toAppWslPtyId(debian, 'pty2:guest:1') + ptyOwnership.set(id, null) + const runtime = { markPtyLivenessUnverifiable: vi.fn() } + const snapshot = await listProcessesWithHostScopeFromRuntimeController({ runtime }) + expect(snapshot.processes.map((entry) => entry.id)).toEqual([ + 'pty2:windows:1', + 'ssh:remote@@pty2:remote:1', + toAppWslPtyId(ubuntu, 'pty2:guest:1') + ]) + expect(snapshot.hostIds).toEqual(['ssh:remote']) + expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(id, 'guest disconnected') + await expect(listProcessesFromRuntimeController({ runtime }, null)).rejects.toThrow( + 'unverifiable' + ) +}) + +it('persisted split binding fences local authority even after its disconnected provider leaves the registry', async () => { + setup() + releases.pop()?.() + const id = toAppWslPtyId(debian, 'pty2:guest:1') + const session = getDefaultWorkspaceSession() + session.terminalLayoutsByTabId.tab = { + root: { type: 'leaf', leafId: 'leaf' }, + activeLeafId: 'leaf', + expandedLeafId: null, + ptyIdsByLeafId: { leaf: id } + } + const store = { getWorkspaceSession: () => session } + const runtime = { markPtyLivenessUnverifiable: vi.fn() } + const snapshot = await listProcessesWithHostScopeFromRuntimeController({ store, runtime }) + expect(snapshot.hostIds).not.toContain('local') + expect(snapshot.processes).toHaveLength(3) + expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith( + id, + expect.stringContaining('not connected') + ) + await expect(listProcessesFromRuntimeController({ store, runtime }, null)).rejects.toThrow( + 'unverifiable' + ) + expect(await listProcessesFromRuntimeController({ store, runtime }, 'remote')).toHaveLength(1) +}) + +it('guest capabilities participate in local projection and failures return false', async () => { + const { second } = setup() + expect(await supportsForegroundProcessEvidenceFromRuntimeController(null)).toBe(true) + second.supportsForegroundProcessEvidence.mockRejectedValue(new Error('offline')) + expect(await supportsForegroundProcessEvidenceFromRuntimeController(null)).toBe(false) + expect(await supportsForegroundProcessEvidenceFromRuntimeController()).toBe(false) +}) + +it.each(['disconnect', 'replace', 'reject'] as const)( + 'does not grant local absence authority when a guest changes during inventory: %s', + async (transition) => { + const { secondList } = setup() + const id = toAppWslPtyId(debian, 'pty2:guest:1') + ptyOwnership.set(id, null) + const runtime = { markPtyLivenessUnverifiable: vi.fn() } + let finish = () => {} + secondList.mockImplementation( + () => + new Promise((resolve, reject) => { + finish = () => + transition === 'reject' + ? reject(new Error('query lost after disconnect')) + : resolve([row(id)]) + }) + ) + const pending = listProcessesWithHostScopeFromRuntimeController({ runtime }) + releases.pop()?.() + if (transition === 'replace') { + releases.push(registerWslPtyProvider(debian, createUnavailablePtyProvider())) + } + finish() + const snapshot = await pending + expect(snapshot.hostIds).toEqual(['ssh:remote']) + expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(id, expect.any(String)) + } +) + +it('rejects array-only local inventory if a new guest registers while queries are pending', async () => { + const { secondList } = setup() + let finish = () => {} + secondList.mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve([]) + }) + ) + const pending = listProcessesFromRuntimeController({}, null) + releases.push( + registerWslPtyProvider( + { distro: 'Fedora', relayBuildId: 'build+user3' }, + createUnavailablePtyProvider() + ) + ) + finish() + await expect(pending).rejects.toThrow('unverifiable') +}) diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index f03892a6e31d..72cb1c84af59 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -1,5 +1,4 @@ import type { IPtyProvider } from '../../../providers/types' -import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' @@ -71,11 +70,6 @@ export async function probePtyLivenessFromRuntimeController( if (provider.probePtyLiveness) { return await provider.probePtyLiveness(ptyId) } - // Why: the in-process provider is its own sole owner (#12393), so its - // refusal is authoritative; every other probe-less provider is doubt. - if (provider instanceof LocalPtyProvider) { - return provider.hasPty(ptyId) - } return null } catch { return null @@ -95,7 +89,7 @@ export async function attachPtyFromRuntimeController( } catch { return false } - if (provider !== localProvider || provider instanceof LocalPtyProvider) { + if (provider !== localProvider) { return false } try { diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index 575f89b22e79..57853c988696 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -1,3 +1,6 @@ +import { parseAppWslPtyId } from '../../../../shared/wsl-pty-id' +import { prepareWslDaemonReattachHooks } from '../../../wsl/wsl-daemon-reattach-hooks' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' import type { PtySpawnResult } from '../../../providers/types' import { getAppPtyId } from '../provider/registry' import { allocatePtyLifecycleSequence } from '../host-env/types' @@ -54,7 +57,7 @@ export function adoptMaterializedRuntimePtySpawn( } } ensureWslHookRelayForReattach( - { isReattach: true, wslDistro: ctx.preAdoptedStablePane.result.wslDistro }, + { id: ctx.result.id, isReattach: true, wslDistro: ctx.preAdoptedStablePane.result.wslDistro }, args.connectionId ) if (!args.connectionId) { @@ -67,5 +70,12 @@ export function adoptMaterializedRuntimePtySpawn( ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true) }) } + if (parseAppWslPtyId(ctx.result.id)) { + return prepareWslDaemonReattachHooks({ + result: { ...ctx.result, isReattach: true }, + sessions: ctx.deps.options?.wslDaemonSessions, + hooksEnabled: isAgentStatusHooksEnabled(ctx.deps.getSettings?.()) + }).then(() => ctx.result) + } return ctx.result } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 8228175a2cf1..cf18a932b933 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -1,3 +1,6 @@ +import { prepareWslDaemonReattachHooks } from '../../../wsl/wsl-daemon-reattach-hooks' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { parseAppWslPtyId } from '../../../../shared/wsl-pty-id' import type { PtySpawnResult } from '../../../providers/types' import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' @@ -164,6 +167,13 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ctx.rejectedRegistrationCandidate = ctx.result assertSpawnReplyWasLive(ctx.result) } + if (parseAppWslPtyId(ctx.result.id)) { + const actualOwner = tryGetProviderForAgentSessionOwner(ctx.result.id) + if (!actualOwner) { + throw new Error('execution_owner_unavailable') + } + ctx.provider = actualOwner + } ctx.rejectedRegistrationCandidate ??= ctx.result if (ctx.pendingRegistrationPtyId !== ctx.result.id) { if (ctx.pendingRegistrationPtyId) { @@ -190,7 +200,14 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ctx.snapshotKittyFlagsCoverReconciledSeq = false } } - ensureWslHookRelayForReattach(ctx.result, args.connectionId) + const guestHooks = await prepareWslDaemonReattachHooks({ + result: ctx.result, + sessions: ctx.deps.options?.wslDaemonSessions, + hooksEnabled: isAgentStatusHooksEnabled(ctx.deps.getSettings?.()) + }) + if (!guestHooks) { + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + } ctx.deps.runtime?.preparePtyExecutionContext?.( ctx.result.id, args.connectionId @@ -269,7 +286,7 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise } } if (ctx.isNewDaemonSession && ctx.sessionId !== undefined) { - clearProviderPtyState(ctx.sessionId) + clearProviderPtyState(ctx.effectiveSessionAppId ?? ctx.sessionId) } throw spawnError } finally { diff --git a/src/main/ipc/pty/runtime/spawn-guest-environment.ts b/src/main/ipc/pty/runtime/spawn-guest-environment.ts new file mode 100644 index 000000000000..740c146c33a3 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-guest-environment.ts @@ -0,0 +1,44 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { prepareWslGuestTerminalSpawn } from '../../../wsl/wsl-guest-terminal-preparation' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { isCodexStatusHooksEnabled } from '../host-env/codex-home' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function prepareRuntimeGuestSpawnOptions(ctx: RuntimePtySpawnState): Promise { + if (!ctx.wslGuest) { + return + } + const settings = ctx.deps.getSettings?.() + const environment = getAppEnvironment() + ctx.spawnOptions = await prepareWslGuestTerminalSpawn( + ctx.wslGuest.prepared, + ctx.spawnOptions, + { + isPackaged: environment.isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: environment.getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(ctx.args.launchAgent) ? ctx.args.launchAgent : undefined, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(settings), + disabledTuiAgents: settings?.disabledTuiAgents, + codexStatusHooksEnabled: isCodexStatusHooksEnabled(settings), + networkProxySettings: settings, + routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), + deferGitConfigGuardToDaemon: true + }, + ctx.args.signal + ) + ctx.env = ctx.spawnOptions.env + if (ctx.env) { + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.expectedWslDistro + ) + } +} diff --git a/src/main/ipc/pty/runtime/spawn-host-environment.ts b/src/main/ipc/pty/runtime/spawn-host-environment.ts new file mode 100644 index 000000000000..dd9d70790167 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-host-environment.ts @@ -0,0 +1,71 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' +import { buildPtyHostEnv } from '../host-env/assembly' +import { isSafePtySessionId } from '../../../daemon/pty-session-id' +import { + shouldSkipCodexHomeEnvForWindowsShell, + isCodexStatusHooksEnabled +} from '../host-env/codex-home' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' +import { promoteAgentTeamsShimPath } from '../host-env/path' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function prepareRuntimeHostSpawnEnvironment(ctx: RuntimePtySpawnState): Promise { + const args = ctx.args + const ptySettings = ctx.deps.getSettings?.() + if (!ctx.wslGuest && ctx.isDaemonHostSpawn && ctx.sessionId && !ctx.preAdoptedStablePane) { + if (!isSafePtySessionId(ctx.sessionId, getAppEnvironment().getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + try { + ctx.env ??= {} + await inheritOmpLaunchEnvironment(ctx.env, { + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + launchAgent: args.launchAgent, + launchCommand: ctx.launchCommand + }) + await awaitExplicitPiOmpGuestReadiness({ + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + distro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + codexHomePath: ctx.selectedCodexHomePath, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + launchCommand: ctx.launchCommand + }) + ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + disabledTuiAgents: ptySettings?.disabledTuiAgents, + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings, + routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), + deferGitConfigGuardToDaemon: + ctx.provider.supportsGitCredentialGuardHost?.(ctx.sessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null + ) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + } catch (error) { + // Why: host-env setup can materialize agent hooks/extensions before failing. + if (ctx.requestedSessionId === undefined) { + clearProviderPtyState(ctx.sessionId) + } + throw error + } + } +} diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 67c633560e97..529a37ffeaeb 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -1,5 +1,5 @@ +import { prepareRuntimeGuestSpawnOptions } from './spawn-guest-environment' import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' -import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { ptySizes } from '../delivery/visibility-state' @@ -12,10 +12,7 @@ import { getInheritedClaudeSessionStampEnvKeysToDelete } from '../host-env/pi-agent' import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' -import { - routesFreshSpawnsToLocalProvider, - beginPtySpawnForWorktree -} from '../host-env/fresh-spawn-routing' +import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' @@ -71,7 +68,7 @@ export async function buildRuntimePtySpawnOptions( PI_PROCESS_OWNER_ENV_KEYS, // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + ctx.isDaemonHostSpawn && !ctx.wslGuest ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) ) @@ -181,13 +178,8 @@ export async function buildRuntimePtySpawnOptions( if (args.signal) { ctx.spawnOptions.signal = args.signal } - if ( - args.onPtySpawnCommitted && - (ctx.provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(ctx.provider)) - ) { - // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. - ctx.spawnOptions.onPtySpawnCommitted = ctx.reportPtySpawnCommitted - } + + await prepareRuntimeGuestSpawnOptions(ctx) const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( args.worktreeId, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 2bd10feaab63..0f12ff28b153 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -1,21 +1,18 @@ -import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' -import { getAppEnvironment } from '../../../../shared/app-environment' +import { prepareRuntimeHostSpawnEnvironment } from './spawn-host-environment' +import { prepareWslDaemonSpawnRoute } from '../../../wsl/wsl-daemon-spawn-route' +import { toAppWslPtyId } from '../../../../shared/wsl-pty-id' import type { PtySpawnResult } from '../../../providers/types' -import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' -import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, getCodexSelectionTargetForPty, resolveCodexHomeAfterManagedAuthReadiness, shouldSkipCodexHomeEnvForWindowsShell, shouldStripInheritedOrcaCodexHome, - isCodexStatusHooksEnabled, codexHomePathsEqual } from '../host-env/codex-home' -import { promoteAgentTeamsShimPath } from '../host-env/path' import { isClaudeLaunchCommand, recoverFreshSpawnProviderRouting, @@ -30,44 +27,22 @@ import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' import { - isSafePtySessionId, mintPtySessionId, ptySessionIdForAgentCreateOperation } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' -import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project-runtime-resolution' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' -import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' -import { clearProviderPtyState } from '../provider/state-cleanup' -import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' import type { RuntimePtySpawnState } from './spawn-state' export async function prepareRuntimePtySpawn( ctx: RuntimePtySpawnState ): Promise { const args = ctx.args - if (!ctx.preAdoptedStablePane) { - const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) - if (pathUsable) { - await pathUsable - } - } ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) ctx.provider = getProvider(args.connectionId) - const freshSpawnRecovery = ctx.preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting( - ctx.provider, - args.connectionId, - args.sessionId, - args.isNewSession - ) - if (freshSpawnRecovery) { - await freshSpawnRecovery - } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -96,10 +71,7 @@ export async function prepareRuntimePtySpawn( terminalWindowsWslDistro: null } ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride - ctx.isDaemonHostSpawn = - !args.connectionId && - !(ctx.provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isDaemonHostSpawn = !args.connectionId && !routesFreshSpawnsToLocalProvider(ctx.provider) ctx.callerRequestedSessionId = args.sessionId?.trim() ctx.requestedSessionId = ctx.callerRequestedSessionId ?? @@ -125,21 +97,63 @@ export async function prepareRuntimePtySpawn( terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro })?.distro ?? null) : null + if (!ctx.preAdoptedStablePane) { + ctx.wslGuest = await prepareWslDaemonSpawnRoute({ + sessions: ctx.deps.options?.wslDaemonSessions, + connectionId: args.connectionId, + sessionId: args.sessionId, + isNewSession: args.isNewSession, + distro: ctx.expectedWslDistro, + signal: args.signal + }) + } + if (ctx.wslGuest) { + ctx.provider = ctx.wslGuest.connection.provider + ctx.expectedWslDistro = ctx.wslGuest.connection.owner.distro + ctx.isDaemonHostSpawn = true + ctx.isNewDaemonSession = ctx.wslGuest.fresh + ctx.isClaudeLaunch &&= ctx.wslGuest.fresh + ctx.sessionId ??= args.agentSessionCreateOperationId + ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) + : mintPtySessionId(args.worktreeId) + ctx.effectiveSessionAppId = toAppWslPtyId(ctx.wslGuest.connection.owner, ctx.sessionId) + ctx.effectiveSessionRelayId = ctx.sessionId + } else { + if (!ctx.preAdoptedStablePane) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + const freshSpawnRecovery = ctx.preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting( + ctx.provider, + args.connectionId, + args.sessionId, + args.isNewSession + ) + if (freshSpawnRecovery) { + await freshSpawnRecovery + } + } ctx.codexSelectionTarget = getCodexSelectionTargetForPty( ctx.daemonShellOverride, ctx.cwd, ctx.expectedWslDistro ) - const codexResumePreparation = ctx.preAdoptedStablePane - ? null - : ctx.deps.prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: ctx.codexSelectionTarget, - launchEnv: args.env, - workspacePath: ctx.cwd - }) + const codexResumePreparation = + ctx.preAdoptedStablePane || (ctx.wslGuest && !ctx.wslGuest.fresh) + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: args.env, + workspacePath: ctx.cwd, + wslExecution: ctx.wslGuest?.execution + }) const codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) @@ -149,7 +163,7 @@ export async function prepareRuntimePtySpawn( ctx.launchCommand = codexResumeLaunch.command ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) + ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget, ctx.wslGuest?.execution) : null if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) @@ -190,10 +204,11 @@ export async function prepareRuntimePtySpawn( const selectLaunchCodexHome = async (): Promise => (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { workspacePath: ctx.cwd, + wslExecution: ctx.wslGuest?.execution, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined })) ?? null ctx.selectedCodexHomePath = - !ctx.preAdoptedStablePane && !args.connectionId + !ctx.preAdoptedStablePane && !args.connectionId && (!ctx.wslGuest || ctx.wslGuest.fresh) ? getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, codexResumeHome @@ -221,6 +236,7 @@ export async function prepareRuntimePtySpawn( ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { workspacePath: ctx.cwd, + wslExecution: ctx.wslGuest?.execution, launchAgent: 'codex' })) ?? null ), @@ -229,6 +245,7 @@ export async function prepareRuntimePtySpawn( ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { workspacePath: ctx.cwd, + wslExecution: ctx.wslGuest?.execution, launchAgent: 'codex', unavailableManagedHomePath })) ?? null @@ -236,7 +253,7 @@ export async function prepareRuntimePtySpawn( }) ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution } - if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + if (!ctx.wslGuest && args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) } ctx.codexResumeHomeSelected = Boolean( @@ -255,57 +272,7 @@ export async function prepareRuntimePtySpawn( skipCodexHomeEnv: ctx.skipCodexHomeEnv, settings: ptySettings }) - if (ctx.isDaemonHostSpawn && ctx.sessionId && !ctx.preAdoptedStablePane) { - if (!isSafePtySessionId(ctx.sessionId, getAppEnvironment().getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - try { - ctx.env ??= {} - await inheritOmpLaunchEnvironment(ctx.env, { - isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), - launchAgent: args.launchAgent, - launchCommand: ctx.launchCommand - }) - await awaitExplicitPiOmpGuestReadiness({ - isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), - distro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, - codexHomePath: ctx.selectedCodexHomePath, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - launchCommand: ctx.launchCommand - }) - ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath: ctx.selectedCodexHomePath, - skipCodexHomeEnv: ctx.skipCodexHomeEnv, - stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, - launchCommand: ctx.launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), - wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), - deferGitConfigGuardToDaemon: - ctx.provider.supportsGitCredentialGuardHost?.(ctx.sessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - ctx.env, - ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), - ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null - ) - promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) - } catch (error) { - // Why: host-env setup can materialize agent hooks/extensions before failing. - if (ctx.requestedSessionId === undefined) { - clearProviderPtyState(ctx.sessionId) - } - throw error - } - } + await prepareRuntimeHostSpawnEnvironment(ctx) return null } diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 581d335ecdbc..82bc2c533b9a 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -1,3 +1,4 @@ +import type { WslDaemonSpawnRoute } from '../../../wsl/wsl-daemon-spawn-route' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' @@ -24,6 +25,7 @@ export type RuntimePtySpawnState = { preAdoptedStablePane: AdoptStablePaneResult | null reattachedCodexHomeRoutes: Map cwd: string | undefined + wslGuest: WslDaemonSpawnRoute | null provider: IPtyProvider isClaudeLaunch: boolean terminalRuntimeOptions: { shellOverride?: string; terminalWindowsWslDistro?: string | null } @@ -144,6 +146,7 @@ export function createRuntimePtySpawnState( reattachedCodexHomeRoutes: new Map(), cwd: undefined, provider: localProvider, + wslGuest: null, isClaudeLaunch: false, terminalRuntimeOptions: {}, daemonShellOverride: undefined, diff --git a/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts b/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts new file mode 100644 index 000000000000..ba3243f67246 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts @@ -0,0 +1,297 @@ +import { adoptMaterializedRuntimePtySpawn } from './spawn-early' +import { prepareWslDaemonReattachHooks } from '../../../wsl/wsl-daemon-reattach-hooks' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import * as Registry from '../provider/registry' +import { executeRuntimePtySpawn } from './spawn-execute' +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import { getDefaultSettings } from '../../../../shared/constants' +import { finishPtyShutdown } from '../provider/liveness' +import { localProvider } from '../provider/registry' +import { prepareRuntimePtySpawn } from './spawn-preflight' +import { buildRuntimePtySpawnOptions } from './spawn-options' +import { createRuntimePtySpawnState } from './spawn-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { + prepareWslDaemonSpawnRoute, + type WslDaemonSpawnRoute +} from '../../../wsl/wsl-daemon-spawn-route' +import { prepareWslGuestTerminalSpawn } from '../../../wsl/wsl-guest-terminal-preparation' +import { buildPtyHostEnv } from '../host-env/assembly' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import { toAppWslPtyId } from '../../../../shared/wsl-pty-id' +import { ptySizes } from '../delivery/visibility-state' + +vi.mock('../../../wsl/wsl-daemon-reattach-hooks', () => ({ + prepareWslDaemonReattachHooks: vi.fn(async () => true) +})) +vi.mock('../../../agent-hooks/wsl-hook-relay-reattach', () => ({ + ensureWslHookRelayForReattach: vi.fn() +})) +vi.mock('../../../wsl/wsl-daemon-spawn-route', () => ({ prepareWslDaemonSpawnRoute: vi.fn() })) +vi.mock('../../../wsl/wsl-guest-terminal-preparation', () => ({ + prepareWslGuestTerminalSpawn: vi.fn() +})) +vi.mock('../host-env/assembly', () => ({ + buildPtyHostEnv: vi.fn(() => { + throw new Error('host env must not run for guest') + }) +})) +vi.mock('../../../codex/codex-state-db-backfill-recovery', () => ({ + ensureCodexStateDbBackfillRecoveryStarted: vi.fn() +})) +const HOST_DEFAULT_SHELL = 'wsl.exe' +const hostPlatform = process.platform +function makeDeps(): PtyRuntimeControllerDeps { + const noCodexResumeLaunch: PtyRuntimeControllerDeps['noCodexResumeLaunch'] = (command) => ({ + codexResumeHome: null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession: null + }) + return { + store: undefined, + getSettings: () => ({ + ...getDefaultSettings('/tmp'), + terminalWindowsShell: HOST_DEFAULT_SHELL + }), + adoptStablePane: async () => null, + getLocalPtyStartupPromise: () => undefined, + getLocalPtyProviderStartupPromise: () => undefined, + prepareCodexResumeHome: () => null, + resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command), + noCodexResumeLaunch, + reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath, + stripSequencedStartupResumeArgv: (env) => env, + assertFolderWorkspacePtyPathUsable: () => undefined, + resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd, + requestSerializedBuffer: async () => null, + shutdownProviderAndDetectExit: async () => false, + rememberSyntheticKillExit: () => {}, + rememberRetiredRejectedPty: () => {}, + sendPtyExitToRenderer: () => {}, + sendPtySpawnedToRenderer: () => {}, + finishPtyShutdown, + trustedTerminalHandleEnv: new Set(), + retiredRejectedPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest. + mainWindow: {} as BrowserWindow + } +} + +const endpoint = { + distro: 'Ubuntu', + distributionId: 'registration', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/bun', + entry: '/daemon', + envBinary: '/usr/bin/env', + socket: '/socket', + tokenPath: '/token', + serverBuildId: 'build' +} +const owner = { distro: 'Ubuntu', relayBuildId: 'owner' } +function route(fresh = true): WslDaemonSpawnRoute { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These preflight tests never invoke provider operations; localProvider supplies the IPtyProvider surface consumed here. + const provider = localProvider as WslDaemonSpawnRoute['connection']['provider'] + return { + fresh, + connection: { owner, endpoint, provider }, + prepared: { owner, endpoint }, + execution: endpoint + } +} +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.mocked(prepareWslDaemonSpawnRoute).mockResolvedValue(route()) + vi.mocked(prepareWslGuestTerminalSpawn).mockImplementation(async (_prepared, options) => ({ + ...options, + env: { ...options.env, GUEST_PREPARED: '1' } + })) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + ptySizes.clear() + vi.restoreAllMocks() +}) + +describe('runtime guest daemon routing', () => { + it('captures guest identity before Claude/Codex preparation and builds guest env once', async () => { + const deps = makeDeps() + const order: string[] = [] + vi.mocked(prepareWslDaemonSpawnRoute).mockImplementation(async () => { + order.push('owner') + return route() + }) + deps.prepareClaudeAuth = vi.fn(async () => { + order.push('claude') + return { + envPatch: {}, + stripAuthEnv: false, + configDir: '/home/alice/.claude', + provenance: 'captured' + } + }) + deps.getSelectedCodexHomePath = vi.fn(async () => { + order.push('codex') + return '/home/alice/.codex' + }) + deps.prepareCodexResumeHome = vi.fn(() => null) + deps.assertFolderWorkspacePtyPathUsable = vi.fn(() => { + throw new Error('host path probe') + }) + const ctx = createRuntimePtySpawnState(deps, { + cols: 80, + rows: 24, + command: 'claude', + initiallyHidden: true + }) + await prepareRuntimePtySpawn(ctx) + expect(order).toEqual(['owner', 'claude', 'codex']) + expect(deps.prepareClaudeAuth).toHaveBeenCalledWith( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + endpoint + ) + expect(deps.prepareCodexResumeHome).toHaveBeenCalledWith( + expect.objectContaining({ wslExecution: endpoint }) + ) + expect(deps.getSelectedCodexHomePath).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ wslExecution: endpoint }) + ) + const raw = ctx.sessionId! + expect(raw).not.toMatch(/^wsl:/) + expect(ctx.effectiveSessionAppId).toBe(toAppWslPtyId(owner, raw)) + await buildRuntimePtySpawnOptions(ctx) + expect(ctx.spawnOptions.sessionId).toBe(raw) + expect(ctx.spawnOptions.isNewSession).toBe(true) + expect(ptySizes.get(ctx.effectiveSessionAppId!)).toEqual({ cols: 80, rows: 24 }) + expect(prepareWslGuestTerminalSpawn).toHaveBeenCalledOnce() + expect(buildPtyHostEnv).not.toHaveBeenCalled() + expect(ensureCodexStateDbBackfillRecoveryStarted).not.toHaveBeenCalled() + ctx.finishTerminalInstall() + }) + + it('retains encoded attach identity and skips account prep for a live guest owner', async () => { + vi.mocked(prepareWslDaemonSpawnRoute).mockResolvedValue(route(false)) + const deps = makeDeps() + deps.getSelectedCodexHomePath = vi.fn() + deps.prepareClaudeAuth = vi.fn() + deps.prepareCodexResumeHome = vi.fn(() => null) + const id = toAppWslPtyId(owner, 'pty2:kept:1') + const ctx = createRuntimePtySpawnState(deps, { + cols: 80, + rows: 24, + sessionId: id, + command: 'claude' + }) + await prepareRuntimePtySpawn(ctx) + await buildRuntimePtySpawnOptions(ctx) + expect(ctx.spawnOptions.sessionId).toBe(id) + expect(ctx.spawnOptions.isNewSession).toBeUndefined() + expect(ctx.effectiveSessionAppId).toBe(id) + expect(deps.prepareClaudeAuth).not.toHaveBeenCalled() + expect(deps.getSelectedCodexHomePath).not.toHaveBeenCalled() + expect(deps.prepareCodexResumeHome).not.toHaveBeenCalled() + ctx.finishTerminalInstall() + }) + + it('passes explicit fresh raw identities to route selection without changing their daemon ID', async () => { + const deps = makeDeps() + const ctx = createRuntimePtySpawnState(deps, { + cols: 80, + rows: 24, + sessionId: 'pty2:allocated:1', + isNewSession: true + }) + await prepareRuntimePtySpawn(ctx) + expect(prepareWslDaemonSpawnRoute).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'pty2:allocated:1', isNewSession: true }) + ) + expect(ctx.sessionId).toBe('pty2:allocated:1') + expect(ctx.effectiveSessionAppId).toBe(toAppWslPtyId(owner, 'pty2:allocated:1')) + }) +}) + +it('marks and registers the encoded guest ID before provider output can begin', async () => { + const deps = makeDeps() + const order: string[] = [] + deps.transitionSpawnHiddenRendererPtyDeliveryState = vi.fn((id) => { + order.push(`hidden:${id}`) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: With no pane/workspace metadata, execute only invokes these optional runtime hooks. + deps.runtime = { + beginPtyRegistration: (id: string) => { + order.push(`register:${id}`) + }, + preparePtyExecutionContext: () => false, + assertPtyRegistrationAllowed: () => {} + } as unknown as OrcaRuntimeService + const ctx = createRuntimePtySpawnState(deps, { cols: 80, rows: 24, initiallyHidden: true }) + await prepareRuntimePtySpawn(ctx) + await buildRuntimePtySpawnOptions(ctx) + const appId = ctx.effectiveSessionAppId! + vi.spyOn(Registry, 'tryGetProviderForAgentSessionOwner').mockReturnValue(ctx.provider) + vi.spyOn(ctx.provider, 'spawn').mockImplementation(async (options) => { + expect(options.sessionId).toBe(ctx.sessionId) + expect(order.slice(0, 2)).toEqual([`hidden:${appId}`, `register:${appId}`]) + order.push('output-can-start') + return { id: appId, wslDistro: 'Ubuntu' } + }) + try { + await executeRuntimePtySpawn(ctx) + expect(ctx.result.id).toBe(appId) + expect(ensureWslHookRelayForReattach).not.toHaveBeenCalled() + } finally { + ctx.finishTerminalInstall() + } +}) + +it('prepares captured-user hooks on runtime guest reattach', async () => { + const deps = makeDeps() + vi.mocked(prepareWslDaemonSpawnRoute).mockResolvedValue(route(false)) + const id = toAppWslPtyId(owner, 'retained') + const ctx = createRuntimePtySpawnState(deps, { cols: 80, rows: 24, sessionId: id }) + await prepareRuntimePtySpawn(ctx) + await buildRuntimePtySpawnOptions(ctx) + vi.spyOn(Registry, 'tryGetProviderForAgentSessionOwner').mockReturnValue(ctx.provider) + vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id, isReattach: true, wslDistro: 'Ubuntu' }) + try { + await executeRuntimePtySpawn(ctx) + expect(prepareWslDaemonReattachHooks).toHaveBeenCalledWith({ + result: expect.objectContaining({ id, isReattach: true }), + sessions: deps.options?.wslDaemonSessions, + hooksEnabled: true + }) + expect(ensureWslHookRelayForReattach).not.toHaveBeenCalled() + } finally { + ctx.finishTerminalInstall() + } +}) + +it('prepares captured-user hooks for an already-materialized runtime guest pane', async () => { + const deps = makeDeps() + const id = toAppWslPtyId(owner, 'materialized') + const ctx = createRuntimePtySpawnState(deps, { + cols: 80, + rows: 24, + adoptedStablePane: { + materialized: true, + owner: { ptyId: id, handle: 'handle', tabId: 'tab', leafId: 'leaf' }, + result: { id, isReattach: true, wslDistro: 'Ubuntu' } + } + }) + await adoptMaterializedRuntimePtySpawn(ctx) + expect(prepareWslDaemonReattachHooks).toHaveBeenCalledWith({ + result: expect.objectContaining({ id, isReattach: true }), + sessions: deps.options?.wslDaemonSessions, + hooksEnabled: true + }) +}) diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index e5c59d9b81a1..373b33a88693 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -1,3 +1,4 @@ +import type { WslDaemonSessions } from '../../wsl/wsl-daemon-sessions' import type { BrowserWindow, WebContents } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' @@ -49,6 +50,7 @@ export type SerializeResult = { } | null export type PtyIpcSessionOptions = { + wslDaemonSessions?: WslDaemonSessions prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise diff --git a/src/main/local-builds/local-build-compatibility.ts b/src/main/local-builds/local-build-compatibility.ts index 015377f9d5d6..422adb930e8e 100644 --- a/src/main/local-builds/local-build-compatibility.ts +++ b/src/main/local-builds/local-build-compatibility.ts @@ -5,10 +5,7 @@ import { } from '../../shared/local-build-compatibility' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' import { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' import { getDaemonProvider } from '../daemon/daemon-init' -import { getLocalPtyProvider } from '../ipc/pty' -import { LocalPtyProvider } from '../providers/local-pty-provider' export type LocalBuildCompatibilityResult = { liveTerminalCount: number @@ -21,23 +18,11 @@ async function getLiveDaemonProtocols(): Promise<{ }> { const provider = getDaemonProvider() if (!provider) { - const localProvider = getLocalPtyProvider() - if (!(localProvider instanceof LocalPtyProvider)) { - throw new Error('Could not verify terminal preservation. Restart Orca and try again.') - } - const localProcesses = await localProvider.listProcesses() - if (localProcesses.length > 0) { - throw new Error( - 'Local build switching is blocked while non-persistent fallback terminals are running.' - ) - } - throw new Error('The terminal service is still starting. Try again in a moment.') - } - if (provider instanceof DegradedDaemonPtyProvider) { throw new Error( - 'Local build switching is blocked while the terminal service is in fallback mode. Restart Orca first.' + 'The terminal service is unavailable. Retry starting it before switching builds.' ) } + const adapters = provider instanceof DaemonPtyRouter ? provider.getAllAdapters() : [provider as DaemonPtyAdapter] const sessions = await Promise.all( diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 4e5063b5fc14..66f205cdf0be 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -9,7 +9,6 @@ import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/ import { getDaemonProvider } from '../daemon/daemon-init' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' import type { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import type { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' import type { SessionInfo } from '../daemon/types' import { isFolderWorkspaceIdForRepo } from '../ipc/worktrees/folder-workspace-model' import type { Store } from '../persistence' @@ -251,7 +250,7 @@ function getVerifiedFolderWorktreeIds( } async function collectSessionInfos( - provider: DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider, + provider: DaemonPtyRouter | DaemonPtyAdapter, signal: AbortSignal ): Promise { const adapters = diff --git a/src/main/memory/pty-registry.test.ts b/src/main/memory/pty-registry.test.ts new file mode 100644 index 000000000000..8a5a47e34121 --- /dev/null +++ b/src/main/memory/pty-registry.test.ts @@ -0,0 +1,19 @@ +import { afterEach, expect, it } from 'vitest' +import { listRegisteredPtys, registerPty, unregisterPty } from './pty-registry' +import { toAppWslPtyId } from '../../shared/wsl-pty-id' + +const nativeId = 'native-memory-owner' +const guestId = toAppWslPtyId({ distro: 'Ubuntu', relayBuildId: 'build' }, 'guest-owner') +afterEach(() => { + unregisterPty(nativeId) + unregisterPty(guestId) +}) + +it('does not attribute a guest PID collision to the Windows host process', () => { + const entry = { pid: 1234, worktreeId: 'folder', sessionId: null, paneKey: null } + registerPty({ ...entry, ptyId: nativeId }) + registerPty({ ...entry, ptyId: guestId }) + + const rows = listRegisteredPtys().filter((row) => row.pid === 1234) + expect(rows).toEqual([{ ...entry, ptyId: nativeId }]) +}) diff --git a/src/main/memory/pty-registry.ts b/src/main/memory/pty-registry.ts index bb92081258f6..a70083cfd8b6 100644 --- a/src/main/memory/pty-registry.ts +++ b/src/main/memory/pty-registry.ts @@ -9,7 +9,7 @@ * handler. The write-side is just two calls — `register` on spawn, * `unregister` on teardown. * - * Scope: local PTYs only. SSH-backed PTYs execute on a remote host, so + * Scope: host-native PTYs only. SSH and guest WSL PTYs execute elsewhere, so * their memory does not contribute to Orca's process footprint and * cannot be queried with our local `ps` tree. */ @@ -21,7 +21,7 @@ export type PtyRegistration = { paneKey: string | null // Why number | null: captured at spawn time so the collector does not have // to reach back into the IPC module on every snapshot to resolve it. It is - // nullable because node-pty can return a process whose pid is briefly + // nullable because a PTY backend can return a process whose pid is briefly // unavailable (spawn succeeded but the OS hasn't published the pid yet); // storing null lets the collector render a zero-attribution row for that // PTY instead of throwing and dropping the whole snapshot. @@ -31,6 +31,10 @@ export type PtyRegistration = { const registry = new Map() export function registerPty(entry: PtyRegistration): void { + // Guest PIDs can collide with unrelated Windows processes. + if (entry.ptyId.startsWith('wsl:')) { + return + } registry.set(entry.ptyId, entry) } diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index 567267a113b7..873ff38212b6 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -1,23 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG, ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' -const { order, profileProbe } = vi.hoisted(() => { +const { order, profileProbe, windowsProbe } = vi.hoisted(() => { const order: string[] = [] - return { order, profileProbe: vi.fn(async () => {}) } + return { order, profileProbe: vi.fn(async () => {}), windowsProbe: vi.fn(async () => {}) } }) vi.mock('./orcad-profile-preflight', () => ({ preflightBundledOrcadStartup: async () => { order.push('profile-admission') }, - runOrcadProfilePreflight: profileProbe + runOrcadProfilePreflight: profileProbe, + runBundledWindowsProfilePreflight: windowsProbe })) beforeEach(() => { vi.resetModules() + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: 'test' }) order.length = 0 }) @@ -35,6 +38,7 @@ vi.mock('./orcad-entry', () => ({ describe('orcad entry', () => { it.each([ { flag: ORCAD_PROFILE_PREFLIGHT_FLAG, nativeFeatures: true }, + { flag: ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG, nativeFeatures: true }, { flag: ORCAD_STARTUP_PREFLIGHT_FLAG, nativeFeatures: false } ])( 'runs the selected disposable probe without starting a server: $flag', @@ -46,6 +50,23 @@ describe('orcad entry', () => { } ) + it.each([ + { flag: ORCAD_PROFILE_PREFLIGHT_FLAG, child: true }, + { flag: ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG, child: false } + ])('routes Windows native probe without recursion: $flag', async ({ flag, child }) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, 'nonce']) + await import('./orcad-app') + if (child) { + expect(windowsProbe).toHaveBeenCalledExactlyOnceWith('nonce') + expect(profileProbe).not.toHaveBeenCalled() + } else { + expect(profileProbe).toHaveBeenCalledExactlyOnceWith('nonce', { nativeFeatures: true }) + expect(windowsProbe).not.toHaveBeenCalled() + } + expect(order).toEqual([]) + }) + it('checks bundled runtime readiness before starting the server', async () => { await import('./orcad-app') await vi.waitFor(() => expect(order).toContain('main')) diff --git a/src/main/orcad/orcad-app.ts b/src/main/orcad/orcad-app.ts index 747d29307e3d..a8df1f8ab0e0 100644 --- a/src/main/orcad/orcad-app.ts +++ b/src/main/orcad/orcad-app.ts @@ -3,9 +3,14 @@ import process from 'node:process' import { main, resolveOrcadExitCode } from './orcad-entry' import { ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG, ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' -import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' +import { + preflightBundledOrcadStartup, + runOrcadProfilePreflight, + runBundledWindowsProfilePreflight +} from './orcad-profile-preflight' function failStartup(error: unknown): void { console.error('orcad: failed to start:', error) @@ -25,11 +30,19 @@ try { } const flag = process.argv[2] if ( - (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + flag === ORCAD_PROFILE_PREFLIGHT_FLAG && + process.platform === 'win32' && + process.argv.length === 4 + ) { + void runBundledWindowsProfilePreflight(process.argv[3]).catch(failStartup) + } else if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || + flag === ORCAD_STARTUP_PREFLIGHT_FLAG || + flag === ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG) && process.argv.length === 4 ) { void runOrcadProfilePreflight(process.argv[3], { - nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + nativeFeatures: flag !== ORCAD_STARTUP_PREFLIGHT_FLAG }).catch(failStartup) } else { void preflightBundledOrcadStartup() diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts index ff6212289d93..b8100f3afc88 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -6,7 +6,11 @@ import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' const fixture = vi.hoisted(() => ({ exists: vi.fn<(path: string) => boolean>(), realpath: vi.fn<(path: string) => string>(), - spawn: vi.fn() + spawn: vi.fn(), + provider: vi.fn() +})) +vi.mock('../windows/windows-conpty-provider', () => ({ + resolveWindowsConptyProvider: fixture.provider })) vi.mock('node:fs', () => ({ existsSync: fixture.exists, realpathSync: fixture.realpath })) vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: fixture.spawn })) @@ -16,6 +20,7 @@ beforeEach(() => { ...process.versions, bun: ORCAD_BUN_VERSION }) + fixture.provider.mockReturnValue('C:\\slot\\conpty\\conpty.dll') fixture.exists.mockReturnValue(true) fixture.realpath.mockImplementation((path) => path) vi.spyOn(process, 'exit').mockImplementation(() => { @@ -27,6 +32,8 @@ beforeEach(() => { }) afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() vi.restoreAllMocks() vi.clearAllMocks() }) @@ -85,3 +92,21 @@ describe('bundled Orca runtime validation', () => { expect(fixture.spawn).not.toHaveBeenCalled() }) }) + +it('selects the verified packaged Windows provider before application imports', () => { + vi.stubEnv('BUN_CONPTY_LIBRARY', 'C:\\foreign\\provider.dll') + vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'arm64' }) + fixture.realpath.mockReturnValue('/real/runtime') + assertBundledOrcadRuntime() + expect(fixture.provider).toHaveBeenCalledWith(expect.stringContaining('conpty'), 'arm64') + expect(process.env.BUN_CONPTY_LIBRARY).toBe('C:\\slot\\conpty\\conpty.dll') +}) + +it('reports a damaged Windows provider as a permanent install fault', () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + fixture.realpath.mockReturnValue('/real/runtime') + fixture.provider.mockImplementationOnce(() => { + throw new Error('bad hash') + }) + expect(() => assertBundledOrcadRuntime()).toThrow('bundled Windows terminal provider is invalid') +}) diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index 196f9866fba0..881c4ea7cd1c 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -6,6 +6,7 @@ import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { resolveWindowsConptyProvider } from '../windows/windows-conpty-provider' export class OrcadBundledRuntimeError extends Error {} @@ -38,6 +39,19 @@ export function assertBundledOrcadRuntime(): void { `The bundled Orca runtime must be Bun ${ORCAD_BUN_VERSION}` ) } + if (process.platform === 'win32') { + try { + // Only child launches consume this: Bun snapshots native environment before JS starts. + process.env.BUN_CONPTY_LIBRARY = resolveWindowsConptyProvider( + join(directory, 'conpty'), + process.arch + ) + } catch (cause) { + throw new OrcadBundledRuntimeError('The bundled Windows terminal provider is invalid', { + cause + }) + } + } return } throw new OrcadBundledRuntimeError(`Start orcad with its bundled runtime: ${runtime}`) diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts index 19616c9d5efa..e5337cd7a0d2 100644 --- a/src/main/orcad/orcad-daemon-supervision.test.ts +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -56,20 +56,19 @@ describe('startOrcadDaemon', () => { }) }) - it('reports degraded when fresh terminals would fall back to the local provider', async () => { + it('reports degraded when new terminals are unavailable', async () => { daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) const result = await startOrcadDaemon() expect(result.state).toBe('degraded') }) it('fails open when the daemon cannot start at all', async () => { - initDaemonPtyProviderMock.mockRejectedValue(new Error('node-pty is missing')) + initDaemonPtyProviderMock.mockRejectedValue(new Error('Bun runtime is missing')) daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) - // Fail-open, like the desktop: git, worktrees and non-persistent terminals must still - // serve. What must not happen is a thrown startup or a claim of persistence. + // Fail-open, like the desktop: git and worktrees must still serve. What must not happen is a thrown startup or a claim of persistence. await expect(startOrcadDaemon()).resolves.toEqual({ state: 'unavailable', - reason: 'node-pty is missing' + reason: 'Bun runtime is missing' }) }) }) diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts index d35b03048d32..9f5ec30d3aa8 100644 --- a/src/main/orcad/orcad-daemon-supervision.ts +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -27,7 +27,7 @@ export type OrcadDaemonStartup = * Bring the daemon up and install it as the local PTY provider. * * Fail-open, like the desktop: a host that cannot start a daemon must still serve git, - * worktrees and non-persistent terminals. What it must NOT do is keep claiming persistence + * worktrees and other non-terminal services. What it must NOT do is keep claiming persistence * — `daemonOwnsFreshPersistentPtys()` is what the runtime reads for that, and it answers * false here without any extra bookkeeping. */ @@ -40,15 +40,14 @@ export async function startOrcadDaemon(): Promise { const reason = error instanceof Error ? error.message : String(error) console.error( `[orcad] The terminal daemon did not start: ${reason}\n` + - '[orcad] Terminals will run in-process and WILL NOT survive an orcad restart.' + '[orcad] New terminals are unavailable until the terminal service recovers.' ) return { state: 'unavailable', reason } } if (!daemonOwnsFreshPersistentPtys()) { - const reason = 'daemon adopted in degraded mode; fresh terminals run on the local provider' + const reason = 'daemon adopted in degraded mode; new terminals are unavailable' console.warn( - `[orcad] ${reason}. Existing daemon sessions keep working, but new terminals will not ` + - 'survive an orcad restart until the daemon is restarted.' + `[orcad] ${reason}. Existing daemon sessions retain their owners; retry the terminal service to create new terminals.` ) return { state: 'degraded', reason } } diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts index 1b6952226e16..7fd07d0f9744 100644 --- a/src/main/orcad/orcad-profile-preflight.test.ts +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -2,10 +2,17 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' -import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' +import { + ORCAD_STARTUP_PREFLIGHT_FLAG, + ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG +} from '../../shared/orcad-profile-preflight' import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' -import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' +import { + preflightBundledOrcadStartup, + runOrcadProfilePreflight, + runBundledWindowsProfilePreflight +} from './orcad-profile-preflight' const fixture = vi.hoisted(() => ({ identity: vi.fn(), @@ -205,3 +212,39 @@ describe('bundled Orca startup readiness', () => { expect(output).toHaveBeenCalledWith(readyResult(nonce).stdout) }) }) + +it('runs explicit Windows native checks in a child with the selected initial environment', async () => { + vi.stubEnv('BUN_CONPTY_LIBRARY', 'C:\\slot\\conpty\\conpty.dll') + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await runBundledWindowsProfilePreflight(nonce) + expect(fixture.run).toHaveBeenCalledWith( + expect.objectContaining({ + program: join('/slot', 'bun-runtime.exe'), + args: [ + '--no-env-file', + '--config=NUL', + '--no-install', + join('/slot', 'orcad.js'), + ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG, + nonce + ], + env: expect.objectContaining({ BUN_CONPTY_LIBRARY: 'C:\\slot\\conpty\\conpty.dll' }), + terminationBarrier: true + }) + ) + expect(fixture.native).not.toHaveBeenCalled() + expect(fixture.sql).not.toHaveBeenCalled() + expect(output).toHaveBeenCalledWith(readyResult(nonce).stdout) + } finally { + vi.unstubAllEnvs() + } +}) + +it.each([{ code: 78 }, { timedOut: true }, { outputTruncated: true }])( + 'rejects a failed Windows native child: %j', + async (failure) => { + fixture.run.mockResolvedValue({ ...readyResult(nonce), ...failure }) + await expect(runBundledWindowsProfilePreflight(nonce)).rejects.toThrow('failed readiness') + } +) diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index 4a1bf1da68ae..31c1cbb5c357 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { preflightProfileStateRuntime } from '../persistence/profile-state/profile-state-runtime-preflight' import { ORCAD_STARTUP_PREFLIGHT_FLAG, + ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG, ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, parseOrcadProfilePreflight, orcadProfilePreflightResponseSchema, @@ -24,18 +25,20 @@ export async function preflightBundledOrcadStartup(): Promise { if (!process.versions.bun) { return } + await runBundledPreflightChild(ORCAD_STARTUP_PREFLIGHT_FLAG, randomUUID(), process.platform) +} + +async function runBundledPreflightChild( + flag: string, + nonce: string, + platform: NodeJS.Platform +): Promise { const directory = resolveOrcadInstallRoot() const identity = await readInstalledVersion(directory) - const nonce = randomUUID() // Keep disposable SQLite ownership and native state out of the serving process. const result = await runProcess({ - program: join(directory, orcadBunRuntimeFilename(process.platform)), - args: [ - ...bunOwnedRuntimeArgs(), - join(directory, 'orcad.js'), - ORCAD_STARTUP_PREFLIGHT_FLAG, - nonce - ], + program: join(directory, orcadBunRuntimeFilename(platform)), + args: [...bunOwnedRuntimeArgs(platform), join(directory, 'orcad.js'), flag, nonce], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, maxOutputBytes: 64 * 1024, @@ -46,7 +49,7 @@ export async function preflightBundledOrcadStartup(): Promise { throw new Failure(`The bundled Orca runtime failed readiness: ${result.stderr}`) } try { - parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION, identity) + return parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION, identity) } catch (cause) { throw new OrcadBundledRuntimeError('The bundled runtime returned invalid readiness identity', { cause @@ -97,3 +100,14 @@ async function readInstalledVersion(directory: string): Promise { ) } } + +/** Bun reads the native selector from its initial environment, not later JS assignments. */ +export async function runBundledWindowsProfilePreflight(nonce: string | undefined): Promise { + const checkedNonce = z.string().uuid().parse(nonce) + const response = await runBundledPreflightChild( + ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG, + checkedNonce, + 'win32' + ) + console.log(JSON.stringify(response)) +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index b93dc2515001..9c418f46daf5 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -1,3 +1,4 @@ +import { decryptWslConsumerRecoveries } from './wsl-consumer-recovery-secrets' import { homedir } from 'node:os' import { normalizeProxyUrl } from '../../../shared/network-proxy' import { normalizeKagiSessionLink } from '../../../shared/browser-url' @@ -152,6 +153,12 @@ export class LoadedStateParsingOperations { (value) => normalizeKagiSessionLink(value) !== null ) } + if (parsed.wslPtyConsumerRecoveries !== undefined) { + parsed.wslPtyConsumerRecoveries = decryptWslConsumerRecoveries( + parsed.wslPtyConsumerRecoveries, + this.runtime.protectedSecrets + ) + } parsed.sshPtyConsumerRecoveries = ( Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] ) diff --git a/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts b/src/main/persistence/loading-store/relay-lease-durable-mutation.ts similarity index 83% rename from src/main/persistence/loading-store/ssh-lease-durable-mutation.ts rename to src/main/persistence/loading-store/relay-lease-durable-mutation.ts index 28f361a12cd3..dbf51f5bf384 100644 --- a/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts +++ b/src/main/persistence/loading-store/relay-lease-durable-mutation.ts @@ -4,7 +4,7 @@ import { type WriteFlushBarrierOperations } from './write-flush-barriers' -export type SshLeaseDurableMutationRuntime = Pick< +export type RelayLeaseDurableMutationRuntime = Pick< StoreRuntimeState, | 'dirtyProfileStateDomains' | 'profileMaintenancePending' @@ -14,10 +14,10 @@ export type SshLeaseDurableMutationRuntime = Pick< | 'writesFrozen' > -export async function runSshLeaseDurableMutation( - runtime: SshLeaseDurableMutationRuntime, +export async function runRelayLeaseDurableMutation( + runtime: RelayLeaseDurableMutationRuntime, barriers: WriteFlushBarrierOperations, - domain: 'sshPtyConsumerRecoveries' | 'sshRemotePtyLeases', + domain: 'sshPtyConsumerRecoveries' | 'sshRemotePtyLeases' | 'wslPtyConsumerRecoveries', mutate: () => DurableProfileStateMutation ): Promise { const writeMutation = (): DurableProfileStateMutation => { diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 7188b4efb664..20389f82df64 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -45,11 +45,11 @@ import type { TerminalBindingRecoveryOperations } from './terminal-binding-recov import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' import { - runSshLeaseDurableMutation, - type SshLeaseDurableMutationRuntime -} from './ssh-lease-durable-mutation' + runRelayLeaseDurableMutation, + type RelayLeaseDurableMutationRuntime +} from './relay-lease-durable-mutation' -type SshLeaseRecoveryOperationsRuntime = SshLeaseDurableMutationRuntime & +type SshLeaseRecoveryOperationsRuntime = RelayLeaseDurableMutationRuntime & Pick const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') @@ -210,7 +210,7 @@ export function getSshPtyConsumerRecoveryOperations( state: owner[sshLeaseRecoveryOperationsContext].runtime.state, protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, runDurableMutation: (mutate) => - runSshLeaseDurableMutation( + runRelayLeaseDurableMutation( owner[sshLeaseRecoveryOperationsContext].runtime, owner[sshLeaseRecoveryOperationsContext].flushBarriers, 'sshPtyConsumerRecoveries', @@ -264,7 +264,7 @@ export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): Ssh owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush() }, runDurableMutation: (mutate) => - runSshLeaseDurableMutation( + runRelayLeaseDurableMutation( owner[sshLeaseRecoveryOperationsContext].runtime, owner[sshLeaseRecoveryOperationsContext].flushBarriers, 'sshRemotePtyLeases', diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index 40d40818343c..dd2109f939b4 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -1,3 +1,4 @@ +import { isWslDaemonRecovery } from '../../../shared/wsl-pty-consumer-recovery' import { serializeCompleteProfileStateDomains, serializeSelectiveProfileStateDomains @@ -9,6 +10,7 @@ import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff- import { PROTECTED_SECRET_SLOT, sshPtyOwnerLeaseSecretSlot, + wslPtyOwnerLeaseSecretSlot, type ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' @@ -106,6 +108,16 @@ export class StateSerializationSecretHandlingOperations { ) } break + case 'wslPtyConsumerRecoveries': + stateToSave[domain] = (this.runtime.state.wslPtyConsumerRecoveries ?? []).map((record) => + isWslDaemonRecovery(record) + ? record + : { + ...record, + ownerLease: encrypt(wslPtyOwnerLeaseSecretSlot(record), record.ownerLease) + } + ) + break case 'sshRemotePtyLeases': stateToSave[domain] = this.runtime.state.sshRemotePtyLeases break @@ -203,6 +215,21 @@ export class StateSerializationSecretHandlingOperations { folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments( this.runtime.state.folderWorkspaces ), + ...(this.runtime.state.wslPtyConsumerRecoveries !== undefined + ? { + wslPtyConsumerRecoveries: this.runtime.state.wslPtyConsumerRecoveries.map((record) => + isWslDaemonRecovery(record) + ? record + : { + ...record, + ownerLease: encryptToSentinel( + wslPtyOwnerLeaseSecretSlot(record), + record.ownerLease + ) + } + ) + } + : {}), sshPtyConsumerRecoveries: (this.runtime.state.sshPtyConsumerRecoveries ?? []).map( (record) => ({ ...record, diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 70f22e90c5c7..3bdf05efc8ed 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -1,3 +1,7 @@ +import { + WslConsumerRecoveryOperations, + installWslConsumerRecoveryOperationsContext +} from './wsl-consumer-recovery-operations' import type { StoreRuntimeState } from './store-runtime-state' import type { Store } from './store' import { LoadedStateAdaptationOperations } from './loaded-state-adaptation' @@ -79,6 +83,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & SshProfileOperations & RetiredWorktreeNamePersistence & SshLeaseRecoveryOperations & + WslConsumerRecoveryOperations & WriteFlushBarrierOperations export type StoreDomains = { @@ -103,6 +108,7 @@ export type StoreDomains = { sshProfiles: SshProfileOperations retiredWorktreeNames: RetiredWorktreeNamePersistence sshLeases: SshLeaseRecoveryOperations + wslConsumers: WslConsumerRecoveryOperations } export const STORE_DOMAIN_OPERATION_CLASSES = [ @@ -121,6 +127,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ SshProfileOperations, RetiredWorktreeNamePersistence, SshLeaseRecoveryOperations, + WslConsumerRecoveryOperations, WriteFlushBarrierOperations ] as const @@ -140,6 +147,7 @@ export function installStoreDomainContexts(target: Store, domains: StoreDomains) installSshProfileOperationsContext(target, domains.sshProfiles) installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) + installWslConsumerRecoveryOperationsContext(target, domains.wslConsumers) installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) } @@ -175,6 +183,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { bindingRecovery, scheduling ) + const wslConsumers = new WslConsumerRecoveryOperations(runtime, flushBarriers) return { adaptation, cohorts, @@ -196,6 +205,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { ptyBindings, sshProfiles, retiredWorktreeNames, - sshLeases + sshLeases, + wslConsumers } } diff --git a/src/main/persistence/loading-store/wsl-consumer-recovery-operations.test.ts b/src/main/persistence/loading-store/wsl-consumer-recovery-operations.test.ts new file mode 100644 index 000000000000..4df093cda4e3 --- /dev/null +++ b/src/main/persistence/loading-store/wsl-consumer-recovery-operations.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { Store } from './store' +import { setSecretStore } from '../../../shared/secret-store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const recovery = { + distro: 'Ubuntu', + relayBuildId: '0.1.0+build', + clientInstanceId: 'client', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'c5f48e88-0dbe-4894-90a2-876c0a3c0d8b' +} + +function installSecretStore(available = true) { + setSecretStore({ + isEncryptionAvailable: () => available, + encryptString: (value) => Buffer.from(`sealed:${value}`), + decryptString: (value) => value.toString().slice('sealed:'.length), + describeProtectionGap: () => null + }) +} + +describe('durable guest terminal owner recovery', () => { + it('reserves writes before mutation and fences removal by consumer identity', async () => { + installSecretStore() + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const flush = store.flushPendingOrThrowAsync() + await gate.started.promise + const first = store.upsertWslPtyConsumerRecovery(recovery) + const second = store.upsertWslPtyConsumerRecovery({ + ...recovery, + clientInstanceId: 'new-owner' + }) + const staleRemoval = store.removeWslPtyConsumerRecovery(recovery, 'client') + expect(store.getWslPtyConsumerRecovery(recovery)).toBeNull() + gate.finish.resolve() + await Promise.all([flush, first, second, staleRemoval]) + expect(store.getWslPtyConsumerRecovery(recovery)?.clientInstanceId).toBe('new-owner') + expect(readState().wslPtyConsumerRecoveries).toHaveLength(1) + expect( + authority.captures.flat().filter((row) => row.domain === 'wslPtyConsumerRecoveries') + ).toHaveLength(2) + }) + + it('retains separate leases for old builds and other distros', async () => { + installSecretStore() + const { store, readState } = await fixture() + const newer = { ...recovery, relayBuildId: 'new-build' } + const debian = { ...recovery, distro: 'Debian' } + await store.upsertWslPtyConsumerRecovery(recovery) + await store.upsertWslPtyConsumerRecovery(newer) + await store.upsertWslPtyConsumerRecovery(debian) + await store.removeWslPtyConsumerRecovery(newer, 'client') + expect(store.getWslPtyConsumerRecovery(recovery)).toEqual(recovery) + expect(store.getWslPtyConsumerRecovery(debian)).toEqual(debian) + expect(store.getWslPtyConsumerRecovery(newer)).toBeNull() + expect(readState().wslPtyConsumerRecoveries).toHaveLength(2) + }) + + it('encrypts both selective and final writes and restores the lease after restart', async () => { + installSecretStore() + const { store, readState } = await fixture() + await store.upsertWslPtyConsumerRecovery(recovery) + expect(JSON.stringify(readState())).not.toContain(recovery.ownerLease) + await store.flushPendingOrThrowAsync() + const serialized = JSON.stringify(readState()) + expect(serialized).not.toContain(recovery.ownerLease) + const loaded = new Store({ serializedState: serialized }) + expect(loaded.getWslPtyConsumerRecovery(recovery)).toEqual(recovery) + loaded.freezeWrites() + }) + + it('refuses to replace an unreadable owner lease with a new identity', async () => { + installSecretStore() + const { store, readState } = await fixture() + await store.upsertWslPtyConsumerRecovery(recovery) + installSecretStore(false) + const loaded = new Store({ serializedState: JSON.stringify(readState()) }) + expect(() => loaded.getWslPtyConsumerRecovery(recovery)).toThrow('secrets are locked') + loaded.freezeWrites() + installSecretStore() + }) + + it('does not acknowledge a write that the authority refused', async () => { + installSecretStore() + const { store, authority, readState } = await fixture() + await store.upsertWslPtyConsumerRecovery(recovery) + const gate = authority.pause() + const removal = expect(store.removeWslPtyConsumerRecovery(recovery, 'client')).rejects.toThrow( + 'disk refused' + ) + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await removal + expect(readState().wslPtyConsumerRecoveries).toHaveLength(1) + await store.removeWslPtyConsumerRecovery(recovery, 'client') + expect(readState().wslPtyConsumerRecoveries).toEqual([]) + }) +}) diff --git a/src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts b/src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts new file mode 100644 index 000000000000..c336490b0234 --- /dev/null +++ b/src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts @@ -0,0 +1,164 @@ +import { + normalizeWslDaemonRecovery, + type WslDaemonRecovery +} from '../../../shared/wsl-daemon-recovery' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { WslPtyOwner } from '../../../shared/wsl-pty-id' +import { + normalizeWslPtyConsumerRecovery, + isWslDaemonRecovery, + wslPtyOwnerKey, + type WslPtyConsumerRecovery +} from '../../../shared/wsl-pty-consumer-recovery' +import { wslPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' +import { runRelayLeaseDurableMutation } from './relay-lease-durable-mutation' + +const context = Symbol('WslConsumerRecoveryOperations') +type Context = { runtime: StoreRuntimeState; barriers: WriteFlushBarrierOperations } + +export class WslConsumerRecoveryOperations { + readonly [context]: Context + + constructor(runtime: StoreRuntimeState, barriers: WriteFlushBarrierOperations) { + this[context] = { runtime, barriers } + } + + getWslPtyConsumerRecovery(owner: WslPtyOwner): WslPtyConsumerRecovery | null { + const { runtime } = this[context] + const key = wslPtyOwnerKey(owner) + const record = runtime.state.wslPtyConsumerRecoveries?.find( + (row) => wslPtyOwnerKey(row) === key + ) + if (!record || isWslDaemonRecovery(record)) { + return null + } + if (runtime.protectedSecrets.isSealed(wslPtyOwnerLeaseSecretSlot(owner), record.ownerLease)) { + throw new Error('WSL terminal owner lease is unavailable while profile secrets are locked') + } + return structuredClone(record) + } + + getWslDaemonRecovery(owner: WslPtyOwner): WslDaemonRecovery | null { + const record = this[context].runtime.state.wslPtyConsumerRecoveries?.find( + (row) => wslPtyOwnerKey(row) === wslPtyOwnerKey(owner) && isWslDaemonRecovery(row) + ) + return record && isWslDaemonRecovery(record) ? structuredClone(record) : null + } + + listWslDaemonRecoveries(): WslDaemonRecovery[] { + return structuredClone( + (this[context].runtime.state.wslPtyConsumerRecoveries ?? []).filter(isWslDaemonRecovery) + ) + } + + async upsertWslDaemonRecovery(record: WslDaemonRecovery): Promise { + const normalized = normalizeWslDaemonRecovery(record) + if (!normalized) { + throw new Error('Invalid WSL daemon recovery record') + } + const { runtime, barriers } = this[context] + const key = wslPtyOwnerKey(normalized) + const refusal = await runRelayLeaseDurableMutation( + runtime, + barriers, + 'wslPtyConsumerRecoveries', + () => { + const previous = runtime.state.wslPtyConsumerRecoveries + const rows = previous ?? [] + if (rows.some((row) => wslPtyOwnerKey(row) === key && !isWslDaemonRecovery(row))) { + return { + value: new Error('WSL terminal identity already belongs to a relay consumer'), + persist: false + } + } + const current = rows.find((row) => wslPtyOwnerKey(row) === key) + if (current) { + if (JSON.stringify(current) !== JSON.stringify(normalized)) { + return { value: new Error('WSL daemon owner identity cannot change'), persist: false } + } + return { value: undefined, persist: false } + } + runtime.state.wslPtyConsumerRecoveries = [ + ...rows.filter((row) => wslPtyOwnerKey(row) !== key), + normalized + ] + return { + value: undefined, + rollback: () => { + runtime.state.wslPtyConsumerRecoveries = previous + } + } + } + ) + if (refusal) { + throw refusal + } + } + + async upsertWslPtyConsumerRecovery(record: WslPtyConsumerRecovery): Promise { + const normalized = normalizeWslPtyConsumerRecovery(record) + if (!normalized) { + throw new Error('Invalid WSL terminal consumer recovery record') + } + const { runtime, barriers } = this[context] + const key = wslPtyOwnerKey(normalized) + const refusal = await runRelayLeaseDurableMutation( + runtime, + barriers, + 'wslPtyConsumerRecoveries', + () => { + if ( + runtime.state.wslPtyConsumerRecoveries?.some( + (row) => wslPtyOwnerKey(row) === key && isWslDaemonRecovery(row) + ) + ) { + return { + value: new Error('WSL terminal identity already belongs to a daemon'), + persist: false + } + } + runtime.state.wslPtyConsumerRecoveries = [ + ...(runtime.state.wslPtyConsumerRecoveries ?? []).filter( + (row) => wslPtyOwnerKey(row) !== key + ), + normalized + ] + return { value: undefined } + } + ) + if (refusal) { + throw refusal + } + } + + async removeWslPtyConsumerRecovery( + owner: WslPtyOwner, + expectedClientInstanceId: string + ): Promise { + const { runtime, barriers } = this[context] + const key = wslPtyOwnerKey(owner) + await runRelayLeaseDurableMutation(runtime, barriers, 'wslPtyConsumerRecoveries', () => { + const records = runtime.state.wslPtyConsumerRecoveries ?? [] + const current = records.find((row) => wslPtyOwnerKey(row) === key) + if ( + current && + (isWslDaemonRecovery(current) || current.clientInstanceId !== expectedClientInstanceId) + ) { + return { value: undefined, persist: false } + } + runtime.state.wslPtyConsumerRecoveries = records.filter((row) => wslPtyOwnerKey(row) !== key) + return { value: undefined } + }) + if (!runtime.state.wslPtyConsumerRecoveries?.some((row) => wslPtyOwnerKey(row) === key)) { + runtime.protectedSecrets.removeRetainedBlob(wslPtyOwnerLeaseSecretSlot(owner)) + } + } +} + +export function installWslConsumerRecoveryOperationsContext( + target: WslConsumerRecoveryOperations, + source: WslConsumerRecoveryOperations +): void { + Object.defineProperty(target, context, { value: source[context] }) +} diff --git a/src/main/persistence/loading-store/wsl-consumer-recovery-secrets.ts b/src/main/persistence/loading-store/wsl-consumer-recovery-secrets.ts new file mode 100644 index 000000000000..50b9291748fa --- /dev/null +++ b/src/main/persistence/loading-store/wsl-consumer-recovery-secrets.ts @@ -0,0 +1,39 @@ +import { normalizeWslDaemonRecovery } from '../../../shared/wsl-daemon-recovery' +import { + normalizeWslPtyConsumerRecovery, + type WslPtyRecoveryRecord +} from '../../../shared/wsl-pty-consumer-recovery' +import { + wslPtyOwnerLeaseSecretSlot, + type ProtectedSecretPersistence +} from '../../protected-secret-persistence' +import { isLegacySshPtyOwnerLease } from '../leasing-ssh-ptys/secret-validation' + +export function decryptWslConsumerRecoveries( + value: unknown, + secrets: ProtectedSecretPersistence +): WslPtyRecoveryRecord[] { + if (!Array.isArray(value)) { + return [] + } + return value.flatMap((raw: unknown): WslPtyRecoveryRecord[] => { + if (raw && typeof raw === 'object' && 'kind' in raw) { + const daemon = normalizeWslDaemonRecovery(raw) + return daemon ? [daemon] : [] + } + const record = normalizeWslPtyConsumerRecovery(raw, true) + if (!record) { + return [] + } + const slot = wslPtyOwnerLeaseSecretSlot(record) + const decrypted = secrets.decryptWithStatus(slot, record.ownerLease, isLegacySshPtyOwnerLease) + const normalized = + decrypted.status === 'unavailable' || (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeWslPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + secrets.removeRetainedBlob(slot) + } + return normalized ? [normalized] : [] + }) +} diff --git a/src/main/persistence/loading-store/wsl-daemon-recovery.test.ts b/src/main/persistence/loading-store/wsl-daemon-recovery.test.ts new file mode 100644 index 000000000000..d03e4d48f513 --- /dev/null +++ b/src/main/persistence/loading-store/wsl-daemon-recovery.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { Store } from './store' +import { + normalizeWslDaemonRecovery, + type WslDaemonRecovery +} from '../../../shared/wsl-daemon-recovery' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const recovery: WslDaemonRecovery = { + kind: 'daemon', + distro: 'Ubuntu', + relayBuildId: 'profile-user-runtime-daemon-hash', + endpoint: { + distro: 'Ubuntu', + distributionId: 'registration-id', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/home/alice/.orca/runtime/bun', + entry: '/home/alice/.orca/daemon/daemon.js', + envBinary: '/usr/bin/env', + socket: '/home/alice/.orca/profile/control.sock', + tokenPath: '/home/alice/.orca/profile/control.token', + serverBuildId: 'daemon-build' + } +} + +describe('persisted guest daemon endpoint identity', () => { + it('roundtrips JSON without copying any guest authentication token', () => { + const loaded = new Store({ + serializedState: JSON.stringify({ wslPtyConsumerRecoveries: [recovery] }) + }) + expect(loaded.getWslDaemonRecovery(recovery)).toEqual(recovery) + expect(loaded.getWslPtyConsumerRecovery(recovery)).toBeNull() + const records = loaded.listWslDaemonRecoveries() + records[0]!.endpoint.userName = 'mutated-copy' + expect(loaded.getWslDaemonRecovery(recovery)?.endpoint.userName).toBe('alice') + loaded.freezeWrites() + }) + + it('selectively persists and reloads the confirmed endpoint with real SQLite', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + let acknowledged = false + const write = store.upsertWslDaemonRecovery(recovery).then(() => { + acknowledged = true + }) + await gate.started.promise + expect(acknowledged).toBe(false) + expect(readState().wslPtyConsumerRecoveries ?? []).toEqual([]) + gate.finish.resolve() + await write + expect(authority.captures.flat().map((row) => row.domain)).toEqual(['wslPtyConsumerRecoveries']) + expect(readState().wslPtyConsumerRecoveries).toEqual([recovery]) + const loaded = new Store({ serializedState: JSON.stringify(readState()) }) + expect(loaded.listWslDaemonRecoveries()).toEqual([recovery]) + loaded.freezeWrites() + }) + + it('isolates profiles and preserves distinct captured users/build owners', async () => { + const first = await fixture() + const second = await fixture() + const bob: WslDaemonRecovery = { + ...recovery, + relayBuildId: 'bob-owner', + endpoint: { ...recovery.endpoint, userName: 'bob', userId: '1001', home: '/home/bob' } + } + await first.store.upsertWslDaemonRecovery(recovery) + await first.store.upsertWslDaemonRecovery(bob) + expect(first.store.listWslDaemonRecoveries()).toHaveLength(2) + expect(second.store.listWslDaemonRecoveries()).toEqual([]) + expect(second.readState().wslPtyConsumerRecoveries ?? []).toEqual([]) + }) + + it.each([ + { ...recovery, endpoint: { ...recovery.endpoint, token: 'must-not-persist' } }, + { ...recovery, endpoint: { ...recovery.endpoint, userId: 'unknown' } }, + { ...recovery, endpoint: { ...recovery.endpoint, socket: '/home/alice/../bob/socket' } }, + { ...recovery, endpoint: { ...recovery.endpoint, tokenPath: 'relative' } }, + { ...recovery, endpoint: { ...recovery.endpoint, distro: 'Debian' } }, + { ...recovery, endpoint: { ...recovery.endpoint, entry: undefined } } + ])('rejects an invalid or secret-bearing record %#', (invalid) => { + expect(normalizeWslDaemonRecovery(invalid)).toBeNull() + const loaded = new Store({ + serializedState: JSON.stringify({ wslPtyConsumerRecoveries: [invalid] }) + }) + expect(loaded.listWslDaemonRecoveries()).toEqual([]) + loaded.freezeWrites() + }) + + it('refuses to retarget a retained owner to another user', async () => { + const { store, readState } = await fixture() + await store.upsertWslDaemonRecovery(recovery) + await expect( + store.upsertWslDaemonRecovery({ + ...recovery, + endpoint: { ...recovery.endpoint, userName: 'bob' } + }) + ).rejects.toThrow('owner identity cannot change') + expect(readState().wslPtyConsumerRecoveries).toEqual([recovery]) + await store.upsertWslDaemonRecovery({ ...recovery, relayBuildId: 'another-owner' }) + expect(store.listWslDaemonRecoveries()).toHaveLength(2) + }) + + it('does not acknowledge a refused disk write', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + const write = expect(store.upsertWslDaemonRecovery(recovery)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await write + expect(store.getWslDaemonRecovery(recovery)).toBeNull() + expect(readState().wslPtyConsumerRecoveries ?? []).toEqual([]) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts index b96d09d44f36..c35bd8058042 100644 --- a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest import { agentHookServer } from '../../agent-hooks/server' import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' import type { Store } from '../loading-store/store' +import type { WslDaemonRecovery } from '../../../shared/wsl-daemon-recovery' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { createLiveProfileStateStore } from './profile-state-live-store-factory' import { @@ -81,6 +82,44 @@ function readState(input: ReturnType) { } describe('live profile authority admission', () => { + it('recovers the exact guest daemon owner after the SQLite writer is closed and reopened', async () => { + const input = options() + const recovery: WslDaemonRecovery = { + kind: 'daemon', + distro: 'Ubuntu', + relayBuildId: 'captured-user-daemon', + endpoint: { + distro: 'Ubuntu', + distributionId: 'guest-registration', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/home/alice/.orca/bun', + entry: '/home/alice/.orca/daemon.js', + envBinary: '/usr/bin/env', + socket: '/home/alice/.orca/control.sock', + tokenPath: '/home/alice/.orca/control.token', + serverBuildId: 'captured-build' + } + } + const first = await open(input) + await first.store.upsertWslDaemonRecovery(recovery) + expect(readState(input).wslPtyConsumerRecoveries).toEqual([recovery]) + await first.store.freezeWritesAsync() + + const reopened = await open(input) + expect(reopened.store.getWslDaemonRecovery(recovery)).toEqual(recovery) + await expect( + reopened.store.upsertWslDaemonRecovery({ + ...recovery, + endpoint: { ...recovery.endpoint, userName: 'bob', userId: '1001' } + }) + ).rejects.toThrow('owner identity cannot change') + expect(readState(input).wslPtyConsumerRecoveries).toEqual([recovery]) + const otherProfile = await open(options()) + expect(otherProfile.store.getWslDaemonRecovery(recovery)).toBeNull() + }) + it.each([false, true])( 'hands unbound aliases to admitted startup only (worker refused=%s)', async (refused) => { diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index c72af0966197..fd5d6b090a6c 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -1,3 +1,5 @@ +import { wslPtyOwnerKey } from '../shared/wsl-pty-consumer-recovery' +import type { WslPtyOwner } from '../shared/wsl-pty-id' import { getSecretStore } from '../shared/secret-store' export const PROTECTED_SECRET_SLOT = { @@ -11,6 +13,10 @@ export function sshPtyOwnerLeaseSecretSlot(targetId: string): string { return `sshPtyConsumerRecoveries.ownerLease:${targetId}` } +export function wslPtyOwnerLeaseSecretSlot(owner: WslPtyOwner): string { + return `wslPtyConsumerRecoveries.ownerLease:${wslPtyOwnerKey(owner)}` +} + export type ProtectedSecretDecryption = { plaintext: string status: 'decrypted' | 'failed' | 'unavailable' diff --git a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts index f8fe5f2ac79d..568127bb5af4 100644 --- a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts +++ b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts @@ -51,7 +51,7 @@ describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => env: extraEnv } const env = createDaemonPtyEnvironment(opts) - const plan = createPtyShellLaunchPlan(opts, env) + const plan = await createPtyShellLaunchPlan(opts, env) expect(isGitForWindowsBashLauncherPath(plan.shellPath)).toBe(true) expect(plan.shellArgs.join(' ')).toContain('exec "$BASH"') const spawned = await spawnNativeDaemonPty( diff --git a/src/main/providers/bun-pty-transcript-fixture.ts b/src/main/providers/bun-pty-transcript-fixture.ts new file mode 100644 index 000000000000..14266478a546 --- /dev/null +++ b/src/main/providers/bun-pty-transcript-fixture.ts @@ -0,0 +1,87 @@ +import { join } from 'node:path' +import { z } from 'zod' +import { spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' +import { runBundledBunFixture } from '../bundled-bun-test-execution' + +const transcript = z.array( + z.union([z.object({ data: z.string() }), z.object({ resizeTo: z.number() })]) +) +export type PtyTranscriptEvent = z.infer[number] +type TranscriptOptions = { + script: string + cols: number + rows: number + resizes?: { atMs: number; cols: number }[] +} + +/** Captures real output and resize ordering for replay into the production emulator. */ +export async function recordBunPtyTranscript( + options: TranscriptOptions +): Promise { + if (!process.versions.bun) { + return transcript.parse( + await runBundledBunFixture(__filename, 'recordBunPtyTranscript', options, 25_000) + ) + } + const proc = spawnBunPty( + { + file: process.execPath, + args: ['-e', options.script], + cols: options.cols, + rows: options.rows, + cwd: process.cwd(), + env: Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) + }, + { + createWindowsLaunch: (args) => + createWindowsBunPtyLaunch(args, { + workerPath: join(__dirname, '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts') + }) + } + ) + const events: PtyTranscriptEvent[] = [] + const output = proc.onData((data) => events.push({ data })) + let exitSubscription: { dispose(): void } | undefined + const exited = new Promise((resolve, reject) => { + exitSubscription = proc.onExit(({ exitCode }) => + exitCode === 0 ? resolve() : reject(new Error(`Fixture exited with ${exitCode}`)) + ) + }) + void exited.catch(() => {}) + const timers: ReturnType[] = [] + try { + await proc.waitForSpawn?.() + for (const resize of options.resizes ?? []) { + timers.push( + setTimeout(() => { + events.push({ resizeTo: resize.cols }) + proc.resize(resize.cols, options.rows) + }, resize.atMs) + ) + } + let timeout: ReturnType | undefined + try { + await Promise.race([ + exited, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error('PTY transcript fixture timed out')), 20_000) + }) + ]) + } finally { + clearTimeout(timeout) + } + return events + } finally { + for (const timer of timers) { + clearTimeout(timer) + } + output.dispose() + exitSubscription?.dispose() + proc.destroy() + } +} diff --git a/src/main/providers/local-pty-bun-posix-inspection.test.ts b/src/main/providers/local-pty-bun-posix-inspection.test.ts deleted file mode 100644 index c71e4a3fa6c4..000000000000 --- a/src/main/providers/local-pty-bun-posix-inspection.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { IPty } from 'node-pty' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader' -import type { ProcessTableRow } from '../../shared/process-table-snapshot' -import { - confirmLocalPtyForegroundProcess, - getLocalPtyForegroundProcess, - hasLocalPtyChildProcesses, - inspectLocalPtyChildProcesses -} from './local-pty-foreground-inspection' -import { LocalPtyProvider } from './local-pty-provider' -import { ptyLastRecognizedForeground, ptyProcesses, ptyShellPath } from './local-pty-provider-state' - -const scans = vi.hoisted(() => ({ full: vi.fn(), fresh: vi.fn(), strict: vi.fn() })) -vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ - ...(await importOriginal()), - getProcessTableSnapshot: scans.full, - getFreshProcessTableSnapshot: scans.fresh, - getStrictProcessTableSnapshotWithAge: scans.strict -})) -const platform = Object.getOwnPropertyDescriptor(process, 'platform')! -const id = 'bun-pane' -const provider = new LocalPtyProvider() -let rows: ProcessTableRow[] - -function row(pid: number, ppid: number, command: string, foregroundPid: number): ProcessTableRow { - return { - pid, - ppid, - command, - pgid: pid, - tpgid: foregroundPid, - stat: pid === foregroundPid ? 'Ss+' : 'Ss', - tty: 'ttys002' - } -} - -function pane(): IPty & { processNameIsSpawnFile: true } { - return { - pid: 100, - process: '/bin/zsh', - processNameIsSpawnFile: true, - cols: 80, - rows: 24, - handleFlowControl: false, - onData: () => ({ dispose() {} }), - onExit: () => ({ dispose() {} }), - resize() {}, - clear() {}, - write() {}, - kill() {}, - pause() {}, - resume() {} - } -} - -beforeEach(() => { - vi.resetAllMocks() - scans.full.mockImplementation(async () => rows) - scans.fresh.mockImplementation(async () => rows) - scans.strict.mockImplementation(async () => ({ rows, capturedAgeMs: 0 })) - ptyProcesses.set(id, pane()) - ptyShellPath.set(id, '/bin/zsh') -}) -afterEach(() => { - ptyProcesses.clear() - ptyShellPath.clear() - ptyLastRecognizedForeground.clear() - Object.defineProperty(process, 'platform', platform) - vi.restoreAllMocks() -}) - -describe.each(['darwin', 'linux'])('Bun in-process %s inspection', (host) => { - beforeEach(() => Object.defineProperty(process, 'platform', { value: host, configurable: true })) - - it('reports the actual foreground command and warns before closing a busy pane', async () => { - rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'vim', - hasChildProcesses: true, - childProcessEvidence: 'children' - }) - expect(await hasLocalPtyChildProcesses(id)).toBe(true) - expect(await confirmLocalPtyForegroundProcess(id)).toBe('vim') - expect(scans.fresh).toHaveBeenCalledOnce() - }) - - it('proves an idle shell has no children', async () => { - rows = [row(100, 1, '-zsh', 100)] - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'zsh', - hasChildProcesses: false, - childProcessEvidence: 'no-children' - }) - expect(await hasLocalPtyChildProcesses(id)).toBe(false) - }) - - it('does not mistake the login wrapper for a running user job', async () => { - ptyProcesses.set(id, { ...pane(), process: '/usr/bin/login' }) - rows = [row(100, 1, '/usr/bin/login -fp test', 101), row(101, 100, '-zsh', 101)] - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'zsh', - hasChildProcesses: false, - childProcessEvidence: 'no-children' - }) - }) - - it('preserves a cached agent when the foreground scan cannot verify it', async () => { - rows = [] - scans.full.mockRejectedValue(new Error('process table unavailable')) - scans.strict.mockRejectedValue(new Error('process table unavailable')) - ptyLastRecognizedForeground.set(id, { name: 'claude', pid: 101, at: Date.now() }) - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'claude', - hasChildProcesses: true, - childProcessEvidence: 'unverifiable' - }) - expect(await hasLocalPtyChildProcesses(id)).toBe(true) - expect(await confirmLocalPtyForegroundProcess(id)).toBeNull() - }) - - it('returns uncertainty when the process table has no pane root', async () => { - rows = [row(900, 1, '-zsh', 900)] - expect(await getLocalPtyForegroundProcess(id)).toBeNull() - expect(await inspectLocalPtyChildProcesses(id)).toBe('unverifiable') - expect(await hasLocalPtyChildProcesses(id)).toBe(true) - }) - - it('does not resurrect an agent cache after replacement during fingerprint capture', async () => { - rows = [row(100, 1, '-zsh', 101), row(101, 100, 'node /usr/local/bin/claude', 101)] - scans.full - .mockImplementationOnce(async () => rows) - .mockImplementationOnce(async () => { - ptyProcesses.set(id, { ...pane(), pid: 999 }) - return rows - }) - expect(await getLocalPtyForegroundProcess(id)).toBeNull() - expect(ptyLastRecognizedForeground.has(id)).toBe(false) - }) - - it('does not combine an old foreground with a replacement pane during a child scan', async () => { - rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] - scans.strict.mockImplementationOnce(async () => { - ptyProcesses.set(id, { ...pane(), pid: 999 }) - return { rows, capturedAgeMs: 0 } - }) - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: null, - hasChildProcesses: true, - childProcessEvidence: 'unverifiable' - }) - }) -}) diff --git a/src/main/providers/local-pty-bun-windows-identity.test.ts b/src/main/providers/local-pty-bun-windows-identity.test.ts deleted file mode 100644 index 434445790462..000000000000 --- a/src/main/providers/local-pty-bun-windows-identity.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ptyProcesses, ptyShellPath } from './local-pty-provider-state' -import { - confirmLocalPtyShellForeground, - inspectLocalPtyChildProcesses -} from './local-pty-foreground-inspection' -import { getLocalPtyCwd, sendLocalPtySignal } from './local-pty-session-operations' - -const { confirm, cwd, membership } = vi.hoisted(() => ({ - confirm: vi.fn(), - cwd: vi.fn(), - membership: vi.fn() -})) -vi.mock('./agent-foreground-process', () => ({ - confirmShellForegroundProcess: confirm, - resolveAgentForegroundProcessWithAvailability: vi.fn() -})) -vi.mock('./process-cwd', () => ({ resolveProcessCwd: cwd })) -vi.mock('./windows-pty-job-membership', () => ({ - readWindowsPtyJobProcessIds: membership, - isWindowsPtyJobReadable: () => true -})) - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! -beforeEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - confirm.mockResolvedValue(true) - cwd.mockResolvedValue('C:\\work') -}) -afterEach(() => { - ptyProcesses.clear() - ptyShellPath.clear() - Object.defineProperty(process, 'platform', originalPlatform) - vi.restoreAllMocks() -}) - -describe('Bun in-process Windows shell identity', () => { - it('uses the child shell for cwd and foreground checks while signaling the owned job', async () => { - const proc = { - pid: 1200, - shellProcessId: 1201, - jobRootProcessIsWrapper: true as const, - processNameIsSpawnFile: true as const, - process: 'cmd.exe', - cols: 80, - rows: 24, - handleFlowControl: false, - onData: () => ({ dispose() {} }), - onExit: () => ({ dispose() {} }), - write() {}, - clear() {}, - pause() {}, - resume() {}, - resize() {}, - kill() {}, - signalProcess: vi.fn() - } - ptyProcesses.set('gated-shell', proc) - const shellPath = 'C:\\Windows\\System32\\cmd.exe' - ptyShellPath.set('gated-shell', shellPath) - const kill = vi.spyOn(process, 'kill').mockReturnValue(true) - expect(await getLocalPtyCwd('gated-shell')).toBe('C:\\work') - expect(cwd).toHaveBeenCalledWith(1201) - expect(await confirmLocalPtyShellForeground('gated-shell')).toBe(true) - expect(confirm).toHaveBeenCalledWith(1201, shellPath, expect.any(Object)) - await sendLocalPtySignal('gated-shell', 'SIGTERM') - expect(proc.signalProcess).toHaveBeenCalledWith('SIGTERM') - expect(kill).not.toHaveBeenCalled() - membership.mockReturnValue(new Set([1201])) - expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('no-children') - membership.mockReturnValue(new Set([1201, 1202])) - expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('children') - membership.mockReturnValue(null) - expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('unverifiable') - Reflect.deleteProperty(proc, 'shellProcessId') - cwd.mockClear() - expect(await getLocalPtyCwd('gated-shell')).toBe('') - expect(cwd).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty-child-process-verdict.test.ts deleted file mode 100644 index 857017b66710..000000000000 --- a/src/main/providers/local-pty-child-process-verdict.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import * as pty from 'node-pty' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { resolveForegroundMock } = vi.hoisted(() => ({ resolveForegroundMock: vi.fn() })) - -const { confirmShellForegroundMock } = vi.hoisted(() => ({ - confirmShellForegroundMock: vi.fn() -})) - -vi.mock('./agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: resolveForegroundMock, - confirmShellForegroundProcess: confirmShellForegroundMock -})) -import { isRetiredPtyMaster } from '../pty/node-pty-master-fd-retirement' -import { - confirmLocalPtyShellForeground, - hasLocalPtyChildProcesses, - inspectLocalPtyChildProcesses -} from './local-pty-foreground-inspection' -import { LocalPtyProvider } from './local-pty-provider' -import { ptyProcesses, ptyShellPath } from './local-pty-provider-state' -import { inspectPtyProviderProcess } from './pty-process-inspection' - -// Bare, so the retired pane's spawn file equals the recorded name (the path's basename). -const POSIX_SHELL = 'sh' - -function registerPane(id: string, foreground: string | (() => string), shell?: string): void { - const pane: pty.IPty = { - pid: 4242, - cols: 80, - rows: 24, - get process(): string { - return typeof foreground === 'function' ? foreground() : foreground - }, - handleFlowControl: false, - onData: () => ({ dispose() {} }), - onExit: () => ({ dispose() {} }), - resize() {}, - clear() {}, - write() {}, - kill() {}, - pause() {}, - resume() {} - } - ptyProcesses.set(id, pane) - if (shell) { - ptyShellPath.set(id, shell) - } -} - -/** - * A real node-pty whose master has been given up. The getter does not throw here -- it answers - * `POSIX_SHELL`, which is exactly the recorded shell name, so only the descriptor distinguishes - * this pane from an idle one. - */ -async function registerRetiredPane(id: string): Promise { - const term = pty.spawn(POSIX_SHELL, ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: process.cwd(), - env: { ...process.env } - }) - await new Promise((resolve) => { - term.onExit(() => resolve()) - }) - // `onExit` runs before node-pty's `_close()`, which is where the patch retires `_fd`. - await vi.waitFor(() => expect(isRetiredPtyMaster(term)).toBe(true), { - timeout: 10000, - interval: 10 - }) - ptyProcesses.set(id, term) - ptyShellPath.set(id, POSIX_SHELL) - return term -} - -beforeEach(() => { - resolveForegroundMock.mockReset() - resolveForegroundMock.mockResolvedValue({ available: true, processName: '/bin/zsh' }) -}) - -afterEach(() => { - ptyProcesses.clear() - ptyShellPath.clear() -}) - -describe('confirmLocalPtyShellForeground', () => { - it('proves against the spawned shell path, which tells the Git Bash launcher apart', async () => { - const launcher = 'C:\\Program Files\\Git\\bin\\bash.exe' - registerPane('pty-git-bash', 'bash.exe', launcher) - confirmShellForegroundMock.mockResolvedValueOnce(true) - - await expect(confirmLocalPtyShellForeground('pty-git-bash')).resolves.toBe(true) - expect(confirmShellForegroundMock).toHaveBeenCalledWith(4242, launcher, expect.any(Object)) - }) -}) - -// Windows has no master fd to retire, and `WindowsTerminal.process` answers from the spawn name. -const describeOnPosix = process.platform === 'win32' ? describe.skip : describe - -describe('inspectLocalPtyChildProcesses', () => { - it('reports unverifiable when the pty fd cannot be read', async () => { - registerPane( - 'pty-closed', - () => { - throw new Error('EBADF: bad file descriptor') - }, - '/bin/zsh' - ) - expect(await inspectLocalPtyChildProcesses('pty-closed')).toBe('unverifiable') - }) - - it('still answers no-children when the shell itself is in the foreground', async () => { - registerPane('pty-idle', 'zsh', '/bin/zsh') - expect(await inspectLocalPtyChildProcesses('pty-idle')).toBe('no-children') - }) - - it('answers children when something else is in the foreground', async () => { - registerPane('pty-busy', 'vim', '/bin/zsh') - expect(await inspectLocalPtyChildProcesses('pty-busy')).toBe('children') - }) - - it('treats a pane this provider does not hold as a real negative', async () => { - expect(await inspectLocalPtyChildProcesses('pty-absent')).toBe('no-children') - }) - - it('preserves uncertainty conservatively in the boolean adapter', async () => { - let reads = 0 - registerPane( - 'pty-closed', - () => { - reads += 1 - throw new Error('EBADF: bad file descriptor') - }, - '/bin/zsh' - ) - await expect(hasLocalPtyChildProcesses('pty-closed')).resolves.toBe(true) - // The result must come from the failed read, not from an earlier short-circuit. - expect(reads).toBe(1) - }) -}) - -describeOnPosix('inspectLocalPtyChildProcesses on a retired master', () => { - it('reports unverifiable rather than reading the spawn file as an idle shell', async () => { - const term = await registerRetiredPane('pty-retired') - - // The mechanism is silent: this is the same string an idle pane reports. - expect(term.process).toBe(POSIX_SHELL) - // Not `no-children`: the close guard reads that as "nothing is running here" and kills the pane. - expect(await inspectLocalPtyChildProcesses('pty-retired')).toBe('unverifiable') - }, 15000) - - it('preserves uncertainty conservatively in the boolean adapter', async () => { - await registerRetiredPane('pty-retired') - - // The adapter exists for `IPtyProvider.hasChildProcesses`, which has no third slot. - await expect(hasLocalPtyChildProcesses('pty-retired')).resolves.toBe(true) - }, 15000) -}) - -describe('inspectPtyProviderProcess child-process evidence', () => { - const provider = new LocalPtyProvider() - - 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('carries no-children evidence from the local inspectProcess operation', async () => { - registerPane('pty-idle', 'zsh', '/bin/zsh') - - const inspection = await inspectPtyProviderProcess(provider, 'pty-idle') - expect(inspection.hasChildProcesses).toBe(false) - expect(inspection.childProcessEvidence).toBe('no-children') - }) - - it('carries children evidence from the local inspectProcess operation', async () => { - registerPane('pty-busy', 'vim', '/bin/zsh') - - const inspection = await inspectPtyProviderProcess(provider, 'pty-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 () => { - // Cleanup plus reactivation lands a different IPty under the same id mid-read. - registerPane('pty-swapped', 'vim', '/bin/zsh') - return { available: true, processName: '/bin/zsh' } - }) - - await expect(inspectPtyProviderProcess(provider, 'pty-swapped')).resolves.toEqual({ - foregroundProcess: null, - hasChildProcesses: true, - childProcessEvidence: 'unverifiable' - }) - }) -}) - -describeOnPosix('inspectPtyProviderProcess on a retired master', () => { - const provider = new LocalPtyProvider() - - it('carries unverifiable child evidence beside the foreground it could still read', async () => { - await registerRetiredPane('pty-retired') - - const inspection = await inspectPtyProviderProcess(provider, 'pty-retired') - expect(inspection.hasChildProcesses).toBe(true) - expect(inspection.childProcessEvidence).toBe('unverifiable') - }, 15000) -}) diff --git a/src/main/providers/local-pty-default-shell.test.ts b/src/main/providers/local-pty-default-shell.test.ts deleted file mode 100644 index 8164749b0292..000000000000 --- a/src/main/providers/local-pty-default-shell.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createLocalPtyLaunchPlan } from './local-pty-launch-plan' - -vi.mock('./local-pty-utils', () => ({ - ensureNodePtySpawnHelperExecutable: vi.fn(), - validateWorkingDirectory: vi.fn() -})) - -afterEach(() => vi.unstubAllEnvs()) - -describe.skipIf(process.platform === 'win32')('default terminal shell', () => { - it.each(['/bin/bash', '/bin/zsh', '/usr/bin/fish', '/usr/bin/nu'])( - 'uses the configured executable %s', - (shell) => { - const plan = createLocalPtyLaunchPlan({ cwd: '/tmp', cols: 80, rows: 24 }, () => ({ - getDefaultShell: () => shell - })) - expect(plan).toMatchObject({ shellPath: shell, shellArgs: ['-l'] }) - } - ) - - it('keeps an explicit per-terminal shell ahead of the default', () => { - const plan = createLocalPtyLaunchPlan( - { cwd: '/tmp', cols: 80, rows: 24, shellOverride: '/bin/bash' }, - () => ({ - getDefaultShell: () => '/usr/bin/fish' - }) - ) - expect(plan).toMatchObject({ shellPath: '/bin/bash' }) - }) - - it('uses the environment shell when no default is configured', () => { - vi.stubEnv('SHELL', '/bin/zsh') - const plan = createLocalPtyLaunchPlan({ cwd: '/tmp', cols: 80, rows: 24 }, () => ({ - getDefaultShell: () => '' - })) - expect(plan).toMatchObject({ shellPath: '/bin/zsh' }) - }) - - it('uses explicit interactive args for the configured default shell', () => { - const plan = createLocalPtyLaunchPlan( - { cwd: '/tmp', cols: 80, rows: 24, terminalShellArgs: ['--rcfile', '/tmp/orca rc'] }, - () => ({ getDefaultShell: () => '/bin/bash' }) - ) - expect(plan).toMatchObject({ shellPath: '/bin/bash', shellArgs: ['--rcfile', '/tmp/orca rc'] }) - }) - - it('allows an explicit empty argument list for wrapper shells', () => { - const plan = createLocalPtyLaunchPlan( - { cwd: '/tmp', cols: 80, rows: 24, terminalShellArgs: [] }, - () => ({ getDefaultShell: () => '/bin/zsh' }) - ) - expect(plan).toMatchObject({ shellArgs: [] }) - }) - - it('applies profile args when the shell path was resolved from the setting', () => { - // Why: the spawn path fills shellOverride from terminalDefaultShell, so a - // populated override is the normal profile launch, not a one-off pick. - const plan = createLocalPtyLaunchPlan( - { cwd: '/tmp', cols: 80, rows: 24, shellOverride: '/bin/bash', terminalShellArgs: [] }, - () => ({ getDefaultShell: () => '/bin/bash' }) - ) - expect(plan).toMatchObject({ shellPath: '/bin/bash', shellArgs: [] }) - }) - - it.each([{ command: 'codex' }, { launchAgent: 'codex' as const }])( - 'keeps controlled login args for non-profile launches (%o)', - (overrides) => { - const plan = createLocalPtyLaunchPlan( - { - cwd: '/tmp', - cols: 80, - rows: 24, - terminalShellArgs: ['--rcfile', '/tmp/orca'], - ...overrides - }, - () => ({ getDefaultShell: () => '/bin/zsh' }) - ) - expect('shellArgs' in plan && plan.shellArgs).toEqual(['-l']) - } - ) -}) diff --git a/src/main/providers/local-pty-finalize-environment.ts b/src/main/providers/local-pty-finalize-environment.ts deleted file mode 100644 index 1b385639e94c..000000000000 --- a/src/main/providers/local-pty-finalize-environment.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { win32 as pathWin32 } from 'node:path' -import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' -import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' -import { dropInheritedOrcaFishHistory } from '../fish-history-session' -import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' -import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' -import { - POWERLEVEL10K_WIZARD_DISABLE_ENV, - seedPowerlevel10kWizardEnv -} from '../pty/powerlevel10k-wizard-env' -import { - POSIX_SHELL_STARTUP_COMMAND_ENV, - supportsPosixShellStartupCommand -} from '../pty/posix-shell-startup-command' -import { resolvePathEnvKey } from '../pty/windows-environment-path' -import { selectShellStartupFeatures } from '../shell-startup-features' -import { - injectHistoryEnv, - injectWslFishHistoryEnv, - logHistoryInjection, - type HistoryInjectionResult -} from '../terminal-history' -import { addWslEnvKeys } from '../wsl-env' -import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' -import { promoteAgentTeamsShimPath } from './local-pty-launch-helpers' -import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' -import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import { getShellLaunchConfig } from './local-pty-shell-ready' -import { finalizeWindowsLocalPtySpawnEnvironment } from './local-pty-windows-spawn-environment' -import type { PtySpawnOptions } from './types' - -export function finalizeLocalPtySpawnEnvironment(args: { - spawn: PtySpawnOptions - getOptions: () => LocalPtyProviderOptions - plan: LocalPtyLaunchPlan - env: Record -}): HistoryInjectionResult | null { - const { spawn, getOptions, plan, env } = args - if (process.platform === 'win32') { - finalizeWindowsLocalPtySpawnEnvironment({ spawn, plan, env }) - } - seedPowerlevel10kWizardEnv(env, { envToDelete: spawn.envToDelete }) - if ( - env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && - process.platform === 'win32' && - pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' - ) { - addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) - } - const requestedEnv = spawn.env - expandWindowsPathEnvironmentVariables(env) - promoteAgentTeamsShimPath( - env, - requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined - ) - // Why: raw requested PATH promotion runs after the host-env scrub. - stripLegacyTerminalShimEnv(env, process.platform) - // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. - dropIncoherentCondaActivationEnv(env, process.platform) - - // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). - const worktreeId = spawn.worktreeId - const historyEnabled = worktreeId && (getOptions().isHistoryEnabled?.() ?? true) - // Effective shell for history injection: WSL's outer exe is wsl.exe but the inner login shell is bash. - const isWslTerminal = - Boolean(plan.wslInfo || plan.worktreeWslContext || plan.preferredWslContext) || - pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' - const effectiveShellPath = isWslTerminal ? 'bash' : plan.shellPath - let historyResult: ReturnType | null = null - if (historyEnabled) { - historyResult = injectHistoryEnv(env, worktreeId, effectiveShellPath, plan.cwd, { - wslDistro: plan.launchWslDistro - }) - if (isWslTerminal && plan.launchWslDistro) { - injectWslFishHistoryEnv(env, worktreeId, plan.launchWslDistro) - addWslEnvKeys(env, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(worktreeId, historyResult) - } else { - // Why: injectHistoryEnv is what normally clears it, so when history is off - // an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146. - delete env.ORCA_HISTFILE - // Same for an exported `fish_history` from the fish pane that launched this - // Orca: history off means fish's own default, not another worktree's file. - dropInheritedOrcaFishHistory(env) - // And for an exported HISTFILE: history off means the shell's own default, - // not the history file of the worktree this Orca was launched from. - dropInheritedOrcaHistFile(env) - } - - if (!plan.wslInfo && process.platform !== 'win32') { - // Why after history injection: the wrapper is what repairs a worktree - // HISTFILE that the system zshrc clobbers, so the decision to wrap has to - // see whether this spawn actually injected one. - const isCodexStartupCommand = plan.startupAgentRecognition?.agent === 'codex' - const codexStartupCommand = isCodexStartupCommand ? spawn.command : undefined - const codexRequiresShellReady = - codexStartupCommand !== undefined && - shouldUseShellReadyStartupDelivery({ - command: codexStartupCommand, - startupCommandDelivery: spawn.startupCommandDelivery - }) - // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch - // config below may name features for this shell. - delete env.ORCA_SHELL_FEATURES - delete env[POSIX_SHELL_STARTUP_COMMAND_ENV] - plan.getFallbackShellReadyConfig = (shell) => { - const wrapperStartupCommand = - codexStartupCommand !== undefined && supportsPosixShellStartupCommand(shell) - ? codexStartupCommand - : undefined - // Why no line-editor widening here (unlike the daemon and relay): a Codex - // startup command this provider wraps is run by the wrapper's own prompt - // hook, never written into the PTY, so there is no early write to double-echo. - const waitsForShellReady = - Boolean(spawn.command) && (!isCodexStartupCommand || codexRequiresShellReady) - return getShellLaunchConfig( - shell, - selectShellStartupFeatures({ - shellPath: shell, - env, - hasStartupCommand: Boolean(spawn.command), - waitsForShellReady, - // Why identical: the identity marker exists so the readiness - // handshake can bind output to the right shell PID. - emitsStartupIdentity: waitsForShellReady - }), - wrapperStartupCommand - ) - } - const shellLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) - Object.assign(env, shellLaunch.env) - plan.shellArgs = shellLaunch.args ?? plan.shellArgs - plan.shellReadyLaunch = spawn.command ? shellLaunch : null - plan.primaryLaunchEnvKeys = Object.keys(shellLaunch.env) - } - return historyResult -} diff --git a/src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts b/src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts deleted file mode 100644 index 0d906dbd6610..000000000000 --- a/src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ProcessTableSnapshotReader from '../../shared/process-table-snapshot-reader' - -const { cheapSnapshotMock, fullSnapshotMock, resolveMock } = vi.hoisted(() => ({ - cheapSnapshotMock: vi.fn(), - fullSnapshotMock: vi.fn(), - resolveMock: vi.fn() -})) - -vi.mock('../../shared/cheap-process-table-snapshot-reader', () => ({ - getCheapProcessTableSnapshot: cheapSnapshotMock -})) -vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ - ...(await importOriginal()), - getProcessTableSnapshot: fullSnapshotMock -})) -vi.mock('./agent-foreground-process', () => ({ - resolveAgentForegroundProcessWithAvailability: resolveMock, - confirmShellForegroundProcess: vi.fn() -})) - -import { getLocalPtyForegroundProcess } from './local-pty-foreground-inspection' -import { ptyLastRecognizedForeground, ptyProcesses, ptyShellPath } from './local-pty-provider-state' - -const SHELL_PID = 4242 -const AGENT_PID = 4300 -const ID = 'pty-1' - -type Table = 'agent' | 'shell-only' -let table: Table = 'agent' - -function rows(): Record[] { - const tpgid = table === 'agent' ? AGENT_PID : SHELL_PID - const out: Record[] = [ - { - pid: SHELL_PID, - ppid: 1, - pgid: SHELL_PID, - tpgid, - stat: table === 'agent' ? 'Ss' : 'Ss+', - tty: 'ttys004', - startTime: 'Thu Sep 3 16:02:01 2026', - command: '-zsh' - } - ] - if (table === 'agent') { - out.push({ - pid: AGENT_PID, - ppid: SHELL_PID, - pgid: AGENT_PID, - tpgid, - stat: 'S+', - tty: 'ttys004', - startTime: 'Thu Sep 3 16:02:05 2026', - command: 'node /usr/local/bin/claude' - }) - } - return out -} - -describe('local POSIX provider cheap-tier revalidation', () => { - let platform: PropertyDescriptor | undefined - const proc = { pid: SHELL_PID, process: 'node' } - - beforeEach(() => { - platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - table = 'agent' - proc.process = 'node' - cheapSnapshotMock.mockReset() - cheapSnapshotMock.mockImplementation(async () => rows()) - fullSnapshotMock.mockReset() - fullSnapshotMock.mockImplementation(async () => rows()) - resolveMock.mockReset() - resolveMock.mockImplementation(async () => ({ - available: true, - processName: table === 'agent' ? 'claude' : 'zsh' - })) - ptyProcesses.set(ID, proc as never) - ptyShellPath.set(ID, '/bin/zsh') - ptyLastRecognizedForeground.delete(ID) - }) - - afterEach(() => { - ptyProcesses.delete(ID) - ptyShellPath.delete(ID) - ptyLastRecognizedForeground.delete(ID) - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - }) - - it('a pane with NO recognized anchor never consults the cheap tier', async () => { - table = 'shell-only' - proc.process = 'zsh' - for (let i = 0; i < 3; i += 1) { - expect(await getLocalPtyForegroundProcess(ID)).toBe('zsh') - } - expect(cheapSnapshotMock).not.toHaveBeenCalled() - expect(resolveMock).toHaveBeenCalledTimes(3) - expect(ptyLastRecognizedForeground.get(ID)).toBeUndefined() - }) - - it('once recognized, an unchanged pane re-proves the agent from the cheap tier without a full scan', async () => { - expect(await getLocalPtyForegroundProcess(ID)).toBe('claude') - expect(resolveMock).toHaveBeenCalledTimes(1) - expect(ptyLastRecognizedForeground.get(ID)?.steady?.fingerprint).toEqual(expect.any(String)) - for (let i = 0; i < 3; i += 1) { - expect(await getLocalPtyForegroundProcess(ID)).toBe('claude') - } - expect(cheapSnapshotMock).toHaveBeenCalledTimes(3) - expect(resolveMock).toHaveBeenCalledTimes(1) - }) - - it('an agent exit changes the fingerprint, escalates to the full scan, and clears the anchor', async () => { - expect(await getLocalPtyForegroundProcess(ID)).toBe('claude') - table = 'shell-only' - expect(await getLocalPtyForegroundProcess(ID)).toBe('zsh') - expect(resolveMock).toHaveBeenCalledTimes(2) - expect(ptyLastRecognizedForeground.get(ID)).toBeUndefined() - }) - - it('a changed node-pty foreground name escalates without consulting the cheap tier', async () => { - expect(await getLocalPtyForegroundProcess(ID)).toBe('claude') - proc.process = 'zsh' - table = 'shell-only' - expect(await getLocalPtyForegroundProcess(ID)).toBe('zsh') - expect(cheapSnapshotMock).not.toHaveBeenCalled() - expect(resolveMock).toHaveBeenCalledTimes(2) - }) - - it('a cheap capture failure falls through to the full scan', async () => { - expect(await getLocalPtyForegroundProcess(ID)).toBe('claude') - cheapSnapshotMock.mockRejectedValueOnce(new Error('ps died')) - expect(await getLocalPtyForegroundProcess(ID)).toBe('claude') - expect(resolveMock).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts deleted file mode 100644 index 8c3b0a6dccef..000000000000 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader' -import { - getProcessTableSnapshot, - getStrictProcessTableSnapshotWithAge -} from '../../shared/process-table-snapshot-reader' -import { confirmShellForegroundProcess } from './agent-foreground-process' -import { - createPtyForegroundResolver, - ptyProcessNameIsSpawnFile -} from '../daemon/pty-subprocess/spawn-file-foreground-process' -import { - inspectSpawnFileChildProcessesFromRows, - inspectSpawnFileWindowsChildProcesses -} from '../daemon/pty-subprocess/spawn-file-child-processes' -import { buildPaneProcessFingerprint } from './posix-pane-foreground-fingerprint' -import { isRetiredPtyMaster } from '../pty/node-pty-master-fd-retirement' -import { ptyShellProcessId } from '../windows/windows-pty-job' -import { resolveForegroundFallbackProcess } from './local-pty-launch-helpers' -import { - ptyAgentForegroundContextPaths, - ptyLastRecognizedForeground, - ptyProcesses, - getPtyShellName, - ptyShellPath -} from './local-pty-provider-state' -import { resolveStableForegroundProcess } from './stable-foreground-process' -import { - canRevalidateCachedAgentWithoutScan, - judgeCachedAgentJobEvidence -} from './windows-cached-agent-revalidation' -import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' -import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership' - -/** - * A retired master does not fail loudly: the `process` getter answers with the spawn file, which - * equals the recorded shell and would otherwise read as a real "nothing is running here". Ask the - * descriptor before the name, because an unreadable PTY is not evidence that its children exited. - */ -export async function inspectLocalPtyChildProcesses(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return 'no-children' - } - if (isRetiredPtyMaster(proc)) { - return 'unverifiable' - } - try { - if (ptyProcessNameIsSpawnFile(proc)) { - if (process.platform === 'win32') { - return inspectSpawnFileWindowsChildProcesses(proc) - } - const snapshot = await getStrictProcessTableSnapshotWithAge() - return ptyProcesses.get(id) === proc - ? inspectSpawnFileChildProcessesFromRows( - snapshot.rows, - proc.pid, - getPtyShellName(id) ?? null - ) - : 'unverifiable' - } - const foreground = proc.process - const shell = getPtyShellName(id) - if (!shell) { - return 'children' - } - return foreground === shell ? 'no-children' : 'children' - } catch { - // An unreadable PTY is not evidence that its children exited. - return 'unverifiable' - } -} - -export async function hasLocalPtyChildProcesses(id: string): Promise { - return (await inspectLocalPtyChildProcesses(id)) !== 'no-children' -} - -/** - * POSIX twin of the Windows job-membership short-circuit below: a pane that already holds a - * recognized agent re-proves it from the cheap `ps` tier when the subtree fingerprint is - * unchanged. Panes with no anchor never get here, so start discovery is untouched. - */ -async function revalidateCachedPosixAgent( - proc: { pid: number }, - cachedEntry: { - name: string - steady?: { fingerprint: string; fallbackProcess: string | null } | null - }, - fallbackProcess: string | null -): Promise { - const steady = cachedEntry.steady - if (!steady || steady.fallbackProcess !== fallbackProcess) { - return false - } - try { - const observed = await buildPaneProcessFingerprint( - await getCheapProcessTableSnapshot(), - proc.pid - ) - return observed !== null && observed === steady.fingerprint - } catch { - return false - } -} - -export async function getLocalPtyForegroundProcess(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - ptyLastRecognizedForeground.delete(id) - return null - } - const fallbackProcess = resolveForegroundFallbackProcess( - ptyProcessNameIsSpawnFile(proc) ? (getPtyShellName(id) ?? null) : proc.process || null, - getPtyShellName(id) - ) - const cachedEntry = ptyLastRecognizedForeground.get(id) - const cachedAgent = cachedEntry?.name ?? null - let paneMembershipUnavailable = false - let cachedAgentAliveInJob = false - // Why: job membership preserves a live cached agent without the whole-table - // scan (incomplete under Windows load). Job, not console: this asks "is - // anything besides the shell alive?", which needs no console attachment and - // so needs no forked helper (#10857). - if ( - process.platform === 'win32' && - canRevalidateCachedAgentWithoutScan(cachedAgent, fallbackProcess) - ) { - try { - const paneProcessIds = readWindowsPtyJobProcessIds(proc) - if (ptyProcesses.get(id) !== proc) { - return null - } - const verdict = judgeCachedAgentJobEvidence({ - jobProcessIds: paneProcessIds, - jobSupported: isWindowsPtyJobReadable(), - shellPid: ptyShellProcessId(proc) ?? proc.pid, - anchorProcessId: cachedEntry?.pid ?? null, - identityAgeMs: Date.now() - (cachedEntry?.at ?? 0) - }) - if (verdict === 'confirmed' || verdict === 'unproven') { - return cachedAgent - } - if (verdict === 'exited') { - // The shell stands alone in a complete, inescapable job list: no - // successor is possible, so the identity retires before the scan. - ptyLastRecognizedForeground.delete(id) - } else if (verdict === 'anchor-exited' && cachedEntry) { - // The recognized process died but another member remains -- a - // leftover, or a restarted successor. Keep the name as unanchored, - // age-bounded evidence and let this cycle's scan decide: deleting - // here made a degraded scan read a mid-restart agent as an exit. - ptyLastRecognizedForeground.set(id, { ...cachedEntry, pid: null }) - } - cachedAgentAliveInJob = verdict === 'recheck' - paneMembershipUnavailable = verdict === 'unavailable' - } catch { - paneMembershipUnavailable = true - } - } - if ( - process.platform !== 'win32' && - cachedEntry && - cachedAgent !== null && - (await revalidateCachedPosixAgent(proc, cachedEntry, fallbackProcess)) - ) { - if (ptyProcesses.get(id) !== proc) { - return null - } - ptyLastRecognizedForeground.set(id, { ...cachedEntry, at: Date.now() }) - return cachedAgent - } - try { - const resolution = await createPtyForegroundResolver(proc)(proc.pid, fallbackProcess, { - contextPaths: ptyAgentForegroundContextPaths.get(id), - ...(cachedEntry?.pid != null - ? { anchorProcessId: cachedEntry.pid, anchorProcessName: cachedEntry.name } - : {}) - }) - // Why: the scan can outlive PTY teardown/id reuse; stale results must not resurrect cache for a foreign id. - if (ptyProcesses.get(id) !== proc) { - return null - } - // Why: a degraded scan reporting shell-as-foreground fires a false "agent done"; keep last recognized agent instead. - const lastRecognizedAgent = ptyLastRecognizedForeground.get(id)?.name ?? null - const resolvedAgent = resolution.processName - ? recognizeAgentProcessFromCommandLine(resolution.processName) - : null - // A recycled anchor pid keeps job membership truthful but the identity - // dead; the scan proving the pid now runs a non-agent settles it. - const anchorContradicted = resolution.anchorPidForeign === true - // Why: incomplete snapshot + unavailable job read isn't exit proof; and an - // anchor pid still alive in the job outranks a snapshot that lost its row. - const stableResolution = - (paneMembershipUnavailable || cachedAgentAliveInJob) && - !anchorContradicted && - resolvedAgent === null - ? { ...resolution, available: false } - : resolution - const stable = resolveStableForegroundProcess(stableResolution, lastRecognizedAgent) - if (stable.lastRecognizedAgent && stableResolution.available) { - const steady = await readPosixSteadyState(proc.pid, fallbackProcess) - if (ptyProcesses.get(id) !== proc) { - return null - } - // Only a positive recognition restarts the age bound. - ptyLastRecognizedForeground.set(id, { - name: stable.lastRecognizedAgent, - pid: - stable.lastRecognizedAgent === resolution.processName - ? (resolution.processId ?? null) - : null, - at: Date.now(), - steady - }) - } else if (stable.lastRecognizedAgent && cachedAgentAliveInJob && !anchorContradicted) { - // The anchor pid in the job is proof of life; restamp so the - // short-circuit resumes instead of scanning on every call. - const entry = ptyLastRecognizedForeground.get(id) - if (entry) { - ptyLastRecognizedForeground.set(id, { ...entry, at: Date.now() }) - } - } else if (!stable.lastRecognizedAgent) { - ptyLastRecognizedForeground.delete(id) - } - return stable.processName - } catch { - if (ptyProcesses.get(id) !== proc) { - return null - } - // Why: an inspection error is a degraded read; fall back to last recognized agent (null reads as an exit). - return ptyLastRecognizedForeground.get(id)?.name ?? null - } -} - -/** The fingerprint of the TTL-shared capture the recognition just read; null on Windows or - * when the pane is unfenced, which simply means the next read pays for the full scan. */ -async function readPosixSteadyState( - shellPid: number, - fallbackProcess: string | null -): Promise<{ fingerprint: string; fallbackProcess: string | null } | null> { - if (process.platform === 'win32') { - return null - } - try { - const fingerprint = await buildPaneProcessFingerprint(await getProcessTableSnapshot(), shellPid) - return fingerprint === null ? null : { fingerprint, fallbackProcess } - } catch { - return null - } -} - -export async function confirmLocalPtyForegroundProcess(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return null - } - try { - const resolution = await createPtyForegroundResolver(proc)( - proc.pid, - resolveForegroundFallbackProcess( - ptyProcessNameIsSpawnFile(proc) ? (getPtyShellName(id) ?? null) : proc.process || null, - getPtyShellName(id) - ), - { - contextPaths: ptyAgentForegroundContextPaths.get(id), - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConsoleAttachedProcessIds: () => - readWindowsConsoleAttachedProcessIds(proc.pid) - } - : {}) - } - ) - // Why: a fresh scan can outlive this PTY id; never publish identity from an exited or same-id-reusing session. - if (ptyProcesses.get(id) !== proc) { - return null - } - return resolution.available ? resolution.processName : null - } catch { - return null - } -} - -export async function confirmLocalPtyShellForeground(id: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return false - } - const confirmed = await confirmShellForegroundProcess( - ptyShellProcessId(proc), - ptyShellPath.get(id), - process.platform === 'win32' - ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc) } - : {} - ) - return ptyProcesses.get(id) === proc && confirmed -} diff --git a/src/main/providers/local-pty-launch-helpers.ts b/src/main/providers/local-pty-launch-helpers.ts deleted file mode 100644 index 220669d7cb7a..000000000000 --- a/src/main/providers/local-pty-launch-helpers.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { basename, delimiter, win32 as pathWin32 } from 'node:path' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import { parseWslPath } from '../wsl' -import { resolvePathEnvKey } from '../pty/windows-environment-path' -import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' -import { resolveSafePtyDefaultCwd } from './pty-default-cwd' - -const PANE_IDENTITY_ENV_KEYS = [ - 'ORCA_PANE_KEY', - 'ORCA_TAB_ID', - 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN' -] as const - -export function getDefaultCwd(): string { - return resolveSafePtyDefaultCwd() -} - -export function removeUnspecifiedPaneIdentityEnv( - env: Record, - explicitEnv: Record | undefined -): void { - for (const key of PANE_IDENTITY_ENV_KEYS) { - if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { - delete env[key] - } - } -} - -export function promoteAgentTeamsShimPath( - env: Record, - requestedPath: string | undefined -): void { - if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { - return - } - const normalizedRequestedPath = - process.platform === 'win32' - ? expandWindowsEnvironmentVariables(requestedPath, env) - : requestedPath - const pathDelimiter = process.platform === 'win32' ? ';' : delimiter - const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] - if (!shimDir) { - return - } - const pathKey = resolvePathEnvKey(env, process.platform) - const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] - env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) -} - -export function getWslContextFromWorktreeId( - worktreeId: string | undefined -): { distro: string; treatPosixCwdAsWsl: true } | undefined { - // Why: strip any synthetic `::workspace:` suffix so WSL detection parses the real path, not a nonexistent identifier. - const worktreePath = worktreeId - ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - : undefined - const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined -} - -export function getWslContextFromPreferredDistro( - distro: string | null | undefined -): { distro: string; treatPosixCwdAsWsl: true } | undefined { - const trimmed = distro?.trim() - return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined -} - -export function normalizeLocalCallerSessionId( - sessionId: string | undefined, - allowNumeric = false -): string | null { - const requested = sessionId?.trim() - return !requested || (!allowNumeric && /^\d+$/.test(requested)) ? null : requested -} - -export function normalizeForegroundProcessName( - processName: string | null | undefined -): string | null { - const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' - return !trimmed || trimmed === 'xterm-256color' ? null : trimmed.split(/[\\/]/).pop() || null -} - -export function resolveForegroundFallbackProcess( - processName: string | null | undefined, - shellName: string | undefined -): string | null { - if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) { - return processName || null - } - return shellName ?? processName ?? null -} - -/** Basename of the spawned shell path, parsed for the *target* platform. - * Why: POSIX `basename` won't split a Windows `\` path (non-Windows host/CI), so it'd break the foreground comparison. */ -export function getSpawnedShellName(shellPath: string): string { - return process.platform === 'win32' ? pathWin32.basename(shellPath) : basename(shellPath) -} diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts deleted file mode 100644 index 17351325300d..000000000000 --- a/src/main/providers/local-pty-launch-plan.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { win32 as pathWin32 } from 'node:path' -import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveWindowsGitBashShellPath } from '../git-bash' -import { getDefaultWslDistro, parseWslPath } from '../wsl' -import { - getDefaultCwd, - getWslContextFromPreferredDistro, - getWslContextFromWorktreeId -} from './local-pty-launch-helpers' -import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import type { getShellLaunchConfig } from './local-pty-shell-ready' -import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory } from './local-pty-utils' -import { - resolveEffectiveWindowsPowerShell, - shouldProbeWindowsPowerShellAvailability, - type WindowsPowerShellShellFamily -} from './windows-powershell' -import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-chain' -import { resolveWindowsShellLaunchArgs } from './windows-shell-args' -import { assertSafeAgentStartupCwd } from './pty-default-cwd' -import type { PtySpawnOptions } from './types' - -type WslLaunchContext = { distro: string; treatPosixCwdAsWsl: true } -type LocalPtyLaunchSeed = { - args: PtySpawnOptions - startupAgentRecognition: ReturnType - defaultCwd: string - cwd: string - wslInfo: ReturnType - worktreeWslContext: WslLaunchContext | undefined - preferredWslContext: WslLaunchContext | undefined - launchWslContext: WslLaunchContext | undefined -} - -export type LocalPtyLaunchPlan = { - startupAgentRecognition: ReturnType - defaultCwd: string - cwd: string - wslInfo: ReturnType - worktreeWslContext: WslLaunchContext | undefined - preferredWslContext: WslLaunchContext | undefined - launchWslContext: WslLaunchContext | undefined - shellPath: string - shellArgs: string[] - effectiveCwd: string - validationCwd: string - startupCommandDeliveredInShellArgs: boolean - windowsFallbackAttempts: ReturnType - shellReadyLaunch: ReturnType | null - getFallbackShellReadyConfig: - | ((shell: string) => ReturnType) - | undefined - // Why hoisted: a fallback shell must drop the primary's launch env, and - // re-deriving the key names would re-run wrapper generation. - primaryLaunchEnvKeys: string[] - isWslShell: boolean - launchWslDistro: string | null -} - -export class DeferredLocalPtyLaunchPlan { - constructor( - readonly availability: boolean | Promise, - readonly finish: (available: boolean) => LocalPtyLaunchPlan - ) {} -} - -function finalizeLocalPtyLaunchPlan( - seed: LocalPtyLaunchSeed, - shell: { - shellPath: string - shellArgs: string[] - effectiveCwd: string - validationCwd: string - startupCommandDeliveredInShellArgs?: boolean - windowsFallbackAttempts?: ReturnType - } -): LocalPtyLaunchPlan { - if (!canUseBunPty()) { - ensureNodePtySpawnHelperExecutable() - } - if (seed.args.prevalidatedCwd !== shell.validationCwd) { - validateWorkingDirectory(shell.validationCwd) - } - const isWslShell = - Boolean(seed.wslInfo) || pathWin32.basename(shell.shellPath).toLowerCase() === 'wsl.exe' - return { - startupAgentRecognition: seed.startupAgentRecognition, - defaultCwd: seed.defaultCwd, - cwd: seed.cwd, - wslInfo: seed.wslInfo, - worktreeWslContext: seed.worktreeWslContext, - preferredWslContext: seed.preferredWslContext, - launchWslContext: seed.launchWslContext, - shellPath: shell.shellPath, - shellArgs: shell.shellArgs, - effectiveCwd: shell.effectiveCwd, - validationCwd: shell.validationCwd, - startupCommandDeliveredInShellArgs: shell.startupCommandDeliveredInShellArgs ?? false, - windowsFallbackAttempts: shell.windowsFallbackAttempts ?? [], - shellReadyLaunch: null, - getFallbackShellReadyConfig: undefined, - primaryLaunchEnvKeys: [], - isWslShell, - launchWslDistro: isWslShell ? (seed.launchWslContext?.distro ?? null) : null - } -} - -function createWindowsLocalPtyLaunchPlan( - seed: LocalPtyLaunchSeed, - getOptions: () => LocalPtyProviderOptions -): LocalPtyLaunchPlan | DeferredLocalPtyLaunchPlan { - const { args, cwd, defaultCwd, worktreeWslContext } = seed - // Why: shellOverride opens one tab in a non-default shell without changing the user's setting; it wins over the setting. - const requestedShellFamily = - args.shellOverride || - getOptions().getWindowsShell?.() || - process.env.COMSPEC || - 'powershell.exe' - const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily - if (!seed.launchWslContext && pathWin32.basename(shellFamily).toLowerCase() === 'wsl.exe') { - seed.launchWslContext = getWslContextFromPreferredDistro(getDefaultWslDistro()) - } - const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() - const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily) - // Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe. - const powerShellImplementation = getOptions().getWindowsPowerShellImplementation?.() - const resolvedShellFamily: WindowsPowerShellShellFamily = - normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' - ? normalizedShellFamily - : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' - ? normalizedShellFamily - : undefined - const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ - shellFamily: resolvedShellFamily, - implementation: powerShellImplementation - }) - const shouldResolvePowerShellFamily = - powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily - const finish = (pwshAvailable: boolean): LocalPtyLaunchPlan => { - let shellPath: string - if (resolvedGitBashPath) { - shellPath = resolvedGitBashPath - } else if (shellFamily === WINDOWS_GIT_BASH_SHELL) { - shellPath = 'powershell.exe' - } else { - shellPath = shouldResolvePowerShellFamily - ? (resolveEffectiveWindowsPowerShell({ - shellFamily: resolvedShellFamily, - implementation: powerShellImplementation, - pwshAvailable - }) ?? shellFamily) - : shellFamily - } - // Why: bare `pwsh.exe` resolves to the Store App Execution Alias stub whose spawn fails (code 5); use an absolute exe + cmd.exe fallback. - const windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ - shellPath, - cwd, - defaultCwd, - wslContext: seed.launchWslContext, - startupCommand: args.command - }) - const primaryAttempt = windowsFallbackAttempts[0] - if (primaryAttempt) { - return finalizeLocalPtyLaunchPlan(seed, { - shellPath: primaryAttempt.shellPath, - shellArgs: primaryAttempt.shellArgs, - effectiveCwd: primaryAttempt.effectiveCwd, - validationCwd: primaryAttempt.validationCwd, - startupCommandDeliveredInShellArgs: primaryAttempt.startupCommandDeliveredInShellArgs, - windowsFallbackAttempts - }) - } - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - cwd, - defaultCwd, - seed.launchWslContext, - args.command - ) - return finalizeLocalPtyLaunchPlan(seed, { - shellPath, - shellArgs: resolved.shellArgs, - effectiveCwd: resolved.effectiveCwd, - validationCwd: resolved.validationCwd, - startupCommandDeliveredInShellArgs: resolved.startupCommandDeliveredInShellArgs === true, - windowsFallbackAttempts - }) - } - return shouldProbePwsh - ? new DeferredLocalPtyLaunchPlan(getOptions().pwshAvailable?.() ?? false, finish) - : finish(false) -} - -export function createLocalPtyLaunchPlan( - args: PtySpawnOptions, - getOptions: () => LocalPtyProviderOptions -): LocalPtyLaunchPlan | DeferredLocalPtyLaunchPlan { - const startupAgentRecognition = args.command - ? recognizeAgentProcessFromCommandLine(args.command) - : null - - const defaultCwd = getDefaultCwd() - const cwd = args.cwd || defaultCwd - // Why: gate on the effective cwd, not raw args.cwd — an omitted cwd becomes a safe default and must not be rejected as root-like. - if (args.command && startupAgentRecognition) { - assertSafeAgentStartupCwd(cwd, args.command) - } - const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null - const worktreeWslContext = - process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined - const preferredWslContext = - process.platform === 'win32' - ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) - : undefined - const seed: LocalPtyLaunchSeed = { - args, - startupAgentRecognition, - defaultCwd, - cwd, - wslInfo, - worktreeWslContext, - preferredWslContext, - launchWslContext: - wslInfo !== null - ? getWslContextFromPreferredDistro(wslInfo.distro) - : (worktreeWslContext ?? preferredWslContext) - } - if (wslInfo) { - const shellPath = 'wsl.exe' - const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd) - return finalizeLocalPtyLaunchPlan(seed, { - shellPath, - shellArgs: resolved.shellArgs, - effectiveCwd: resolved.effectiveCwd, - validationCwd: resolved.validationCwd - }) - } - if (process.platform === 'win32') { - return createWindowsLocalPtyLaunchPlan(seed, getOptions) - } - const shellPath = - args.shellOverride || - getOptions().getDefaultShell?.()?.trim() || - args.env?.SHELL || - process.env.SHELL || - '/bin/zsh' - return finalizeLocalPtyLaunchPlan(seed, { - shellPath, - // Why: shellOverride here is already resolved from the setting, so it cannot - // distinguish a one-off shell pick; the spawn path only sends args for the profile. - shellArgs: - !args.command && !args.launchAgent && args.terminalShellArgs !== undefined - ? args.terminalShellArgs - : ['-l'], - effectiveCwd: cwd, - validationCwd: cwd - }) -} diff --git a/src/main/providers/local-pty-pending-native-spawn.test.ts b/src/main/providers/local-pty-pending-native-spawn.test.ts deleted file mode 100644 index 7f292e41273a..000000000000 --- a/src/main/providers/local-pty-pending-native-spawn.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { spawnLocalPty } from './local-pty-spawn' -import { cancelPendingLocalPtySpawns } from './local-pty-spawn-state' -import { pendingLocalPtySpawns, ptyProcesses } from './local-pty-provider-state' - -const { spawn, activate, destroy } = vi.hoisted(() => ({ - spawn: vi.fn(), - activate: vi.fn(), - destroy: vi.fn() -})) -vi.mock('./local-pty-runtime-spawn', () => ({ loadLocalPtyRuntimeSpawn: async () => spawn })) -vi.mock('./macos-tcc-login-shell', () => ({ prepareMacosTccLoginShell: async () => {} })) -vi.mock('./local-pty-finalize-environment', () => ({ - finalizeLocalPtySpawnEnvironment: () => null -})) -vi.mock('./local-pty-spawn-environment', () => ({ - buildLocalPtySpawnEnvironment: () => ({}), - enforceLocalPtySpawnEnvironmentOverrides() {} -})) -vi.mock('./local-pty-launch-plan', () => ({ - DeferredLocalPtyLaunchPlan: class {}, - createLocalPtyLaunchPlan: () => ({ - shellPath: '/bin/sh', - shellArgs: [], - effectiveCwd: '/tmp', - cwd: '/tmp', - windowsFallbackAttempts: [] - }) -})) -vi.mock('./local-pty-session-activation', () => ({ activateLocalPtySession: activate })) -vi.mock('./local-pty-termination', () => ({ destroyPtyProcess: destroy })) - -function createProcess() { - return { - pid: 12345, - process: '/bin/sh', - cols: 80, - rows: 24, - handleFlowControl: false, - onData: () => ({ dispose() {} }), - onExit: () => ({ dispose() {} }), - write() {}, - clear() {}, - pause() {}, - resume() {}, - resize: vi.fn(), - kill: vi.fn() - } -} - -function start(id = 'pending-bun-shell') { - return spawnLocalPty({ sessionId: id, cols: 80, rows: 24 }, () => ({})) -} - -beforeEach(() => { - vi.clearAllMocks() - activate.mockImplementation(({ id, proc }) => { - ptyProcesses.set(id, proc) - return { id, pid: proc.pid } - }) -}) -afterEach(() => { - ptyProcesses.clear() - expect(pendingLocalPtySpawns.size).toBe(0) -}) - -describe('local PTY native spawn admission', () => { - it('aborts a pending receipt when the requesting client disconnects', async () => { - const controller = new AbortController() - spawn.mockImplementationOnce( - ({ signal }: { signal: AbortSignal }) => - new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - ) - const result = spawnLocalPty( - { sessionId: 'disconnected-shell', cols: 80, rows: 24, signal: controller.signal }, - () => ({}) - ) - const rejected = expect(result).rejects.toThrow('client disconnected') - await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) - controller.abort(new Error('client disconnected')) - await rejected - expect(activate).not.toHaveBeenCalled() - }) - - it('reserves the same session until a delayed shell receipt is activated', async () => { - const proc = createProcess() - let release!: () => void - spawn.mockImplementationOnce( - () => - new Promise((resolve) => { - release = () => resolve({ process: proc, shellPath: '/bin/sh' }) - }) - ) - const first = start() - await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) - const second = start() - await new Promise((resolve) => setImmediate(resolve)) - expect(spawn).toHaveBeenCalledOnce() - release() - expect(await first).toEqual({ id: 'pending-bun-shell', pid: proc.pid }) - expect(await second).toMatchObject({ id: 'pending-bun-shell', pid: proc.pid, isReattach: true }) - expect(activate).toHaveBeenCalledOnce() - expect(spawn).toHaveBeenCalledOnce() - }) - - it('aborts a pending receipt on shutdown and cancels queued same-session launches', async () => { - spawn.mockImplementationOnce( - ({ signal }: { signal: AbortSignal }) => - new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - ) - const first = start() - await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) - const second = start() - const results = Promise.allSettled([first, second]) - cancelPendingLocalPtySpawns('pending-bun-shell') - expect(await results).toEqual([ - { status: 'rejected', reason: new Error('PTY spawn canceled: pending-bun-shell') }, - { status: 'rejected', reason: new Error('PTY spawn canceled: pending-bun-shell') } - ]) - expect(spawn).toHaveBeenCalledOnce() - expect(activate).not.toHaveBeenCalled() - }) - - it('cleans a confirmed process when shutdown races the receipt continuation', async () => { - const proc = createProcess() - let release!: () => void - spawn.mockImplementationOnce( - () => - new Promise((resolve) => { - release = () => resolve({ process: proc, shellPath: '/bin/sh' }) - }) - ) - const first = start() - const rejected = expect(first).rejects.toThrow('PTY spawn canceled: pending-bun-shell') - await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) - release() - cancelPendingLocalPtySpawns('pending-bun-shell') - await rejected - expect(proc.kill).toHaveBeenCalledWith('SIGKILL') - expect(destroy).toHaveBeenCalledWith(proc) - expect(activate).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/local-pty-pi-owner-env.test.ts b/src/main/providers/local-pty-pi-owner-env.test.ts deleted file mode 100644 index 42e9773c993c..000000000000 --- a/src/main/providers/local-pty-pi-owner-env.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - buildLocalPtySpawnEnvironment, - enforceLocalPtySpawnEnvironmentOverrides -} from './local-pty-spawn-environment' -import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' - -const ownerEnv = { - ORCA_PI_STATUS_OWNED: '1234', - ORCA_PRIME_AGENT_STATUS_OWNED: '1234', - ORCA_PI_TITLE_MARKER_OWNED: '1234' -} -const terminalEnv = { - ORCA_PANE_KEY: 'new-tab:new-leaf', - ORCA_AGENT_LAUNCH_TOKEN: 'new-launch', - ORCA_AGENT_HOOK_TOKEN: 'receiver-token', - KEEP_ME: 'terminal-value' -} -const plan: LocalPtyLaunchPlan = { - startupAgentRecognition: null, - defaultCwd: '', - cwd: '', - wslInfo: null, - worktreeWslContext: undefined, - preferredWslContext: undefined, - launchWslContext: undefined, - shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', - shellArgs: [], - effectiveCwd: '', - validationCwd: '', - startupCommandDeliveredInShellArgs: false, - windowsFallbackAttempts: [], - shellReadyLaunch: null, - getFallbackShellReadyConfig: undefined, - primaryLaunchEnvKeys: [], - isWslShell: false, - launchWslDistro: null -} - -afterEach(() => vi.unstubAllEnvs()) - -describe('independent local terminal Pi ownership', () => { - it.each(['host', 'request'] as const)('does not inherit the %s process owner', async (source) => { - if (source === 'host') { - for (const [key, value] of Object.entries(ownerEnv)) { - vi.stubEnv(key, value) - } - } - const requestedEnv = { ...terminalEnv, ...(source === 'request' ? ownerEnv : {}) } - const env = await buildLocalPtySpawnEnvironment({ - id: 'new-terminal', - spawn: { cols: 80, rows: 24, env: requestedEnv }, - getOptions: () => ({}), - plan - }) - for (const key of Object.keys(ownerEnv)) { - expect(env[key]).toBeUndefined() - } - expect(env).toMatchObject(terminalEnv) - }) - - it('scrubs owners supplied by a late app-level environment builder', async () => { - const spawn = { cols: 80, rows: 24, env: terminalEnv } - const env = await buildLocalPtySpawnEnvironment({ - id: 'new-terminal', - spawn, - getOptions: () => ({ - buildSpawnEnv: (_id, baseEnv) => ({ ...baseEnv, ...ownerEnv, APP_ENV: 'kept' }) - }), - plan - }) - enforceLocalPtySpawnEnvironmentOverrides(spawn, env) - for (const key of Object.keys(ownerEnv)) { - expect(env[key]).toBeUndefined() - } - expect(env).toMatchObject({ ...terminalEnv, APP_ENV: 'kept' }) - }) -}) diff --git a/src/main/providers/local-pty-provider-foreground-process.test.ts b/src/main/providers/local-pty-provider-foreground-process.test.ts deleted file mode 100644 index b9e65af4e926..000000000000 --- a/src/main/providers/local-pty-provider-foreground-process.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../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, - wslUncDirectoryExistsMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('hasChildProcesses', () => { - 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) - }) - }) - - 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 }) - - // First call establishes the agent identity via the scan. - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - // node-pty still only names the shell, but console presence confirms the - // agent — no second whole-table 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 () => { - // The daemon path is not the only one that short-circuited on `size > 1`. - // Here the early return skips the scan that is the ONLY thing able to - // clear ptyLastRecognizedForeground, so on a WSL pane -- whose job always - // holds console-detached plumbing -- the identity was pinned for good. - 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) - - // Past the bound, the superset answer stops standing in for a scan. - 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 () => { - // The age bound must mean "time since we last confirmed the agent", not - // "how long the agent has run". Stamping only on a CHANGE of name makes a - // steadily-recognized agent age out permanently: the short-circuit dies - // after 30s, every call runs the whole-table scan, and one available-but- - // agentless snapshot then deletes a LIVE agent -- the false "agent done" - // #9258 exists to prevent. - 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) - - // Past the bound: one revalidating scan is expected, and it re-confirms. - vi.setSystemTime(1_000_000 + 40_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - - // That confirmation must restart the clock, so the short-circuit resumes. - 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 () => { - // Fix for stale-identity-behind-a-leftover: with an anchor pid, a detached - // descendant surviving in the job no longer stands in for the dead agent - // until the age bound -- the missing anchor is proof of exit right now. - 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])) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - // Agent 999 exits; a detached leftover 777 keeps the job larger than the shell. - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 777])) - await expect(provider.getForegroundProcess(id)).resolves.toBeNull() - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - }) - - it('never expires an anchored agent the job still holds, even when scans miss it', async () => { - // Fix for false removal of a live agent: the anchor pid in the job is - // proof of life, so >30s of agentless-but-successful scans no longer - // retire a working agent -- and the confirmation restamps the clock. - 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') - - // Past the bound: one drift-recheck scan runs, finds nothing, and the - // live anchor outranks the incomplete snapshot. - vi.setSystemTime(1_000_000 + 40_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - - // The proof of life restamped the clock, so the short-circuit resumes. - vi.setSystemTime(1_000_000 + 45_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) - - // Much later again: recheck, still alive, still claude. - 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 () => { - // The anchor pid died but another job member remains -- possibly the - // agent's restarted successor under a new pid. A degraded scan at that - // instant must not fire a false "agent done"; the next available scan - // re-recognizes and 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') - // Restart: 999 exits, successor 1000 joins the job; scan degrades. - readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 1000])) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - // Downgraded evidence ages out; the recheck scan re-anchors the successor. - vi.setSystemTime(1_000_000 + 40_000) - await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') - expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3) - // Re-anchored on pid 1000: the short-circuit resumes. - 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 () => { - // The anchor pid stays in the job (a squatter reused it) but the scan - // proves the pid now runs a non-agent: proof of life must not apply. - 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, anchorPidForeign: true }) - 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.toBeNull() - // The recheck scan received the anchor to test against. - expect(resolveAgentForegroundProcessMock).toHaveBeenLastCalledWith( - mockProc.pid, - 'powershell.exe', - expect.objectContaining({ anchorProcessId: 999 }) - ) - } finally { - 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-provider-io-events.test.ts b/src/main/providers/local-pty-provider-io-events.test.ts deleted file mode 100644 index 488e203e7e12..000000000000 --- a/src/main/providers/local-pty-provider-io-events.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../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, - wslUncDirectoryExistsMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - 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 }) - - // Simulate node-pty data event - 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 }, - { - id, - data: '', - sequenceChars: echo.length, - seq: query.length + echo.length, - transformed: true - }, - { id, data: 'prompt' } - ]) - }) - - it('consumes a native Windows OSC color query 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) - - expect(dataHandler).toHaveBeenCalledWith({ - id, - data: '', - sequenceChars: query.length, - seq: query.length, - transformed: true - }) - expect(mockProc.write).not.toHaveBeenCalled() - }) - - 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 }) - - // Simulate node-pty exit event - 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 reports an OOM/SIGKILL as {exitCode: 0, signal: 9}; dropping - // the signal is what made a crash read as a clean finish (STA-4536). - exitCb?.({ exitCode: 0, signal: 9 }) - - expect(exitHandler).toHaveBeenCalledWith({ - id, - code: 0, - incarnationId, - cause: { kind: 'signaled', signal: 9 } - }) - }) - - it('allows unsubscribing from events', async () => { - 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-provider-session-inventory.test.ts b/src/main/providers/local-pty-provider-session-inventory.test.ts deleted file mode 100644 index 802695581229..000000000000 --- a/src/main/providers/local-pty-provider-session-inventory.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../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, - wslUncDirectoryExistsMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('listProcesses', () => { - it('returns spawned PTYs', async () => { - const before = await provider.listProcesses() - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '/tmp/owned-cwd', - worktreeId: 'repo::/tmp/owned-cwd' - }) - await provider.spawn({ cols: 80, rows: 24 }) - const after = await provider.listProcesses() - expect(after.length - before.length).toBe(2) - const newEntries = after.slice(before.length) - expect(newEntries[0]).toHaveProperty('id') - expect(newEntries[0]).toHaveProperty('title', 'zsh') - expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd') - expect(newEntries[0]).toHaveProperty('worktreeId', 'repo::/tmp/owned-cwd') - expect(newEntries[0]).not.toHaveProperty('wslDistro') - expect(newEntries[1]).not.toHaveProperty('wslDistro') - }) - - it('reports native and WSL ownership explicitly on Windows', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const native = await provider.spawn({ - cols: 80, - rows: 24, - cwd: 'C:\\repo', - shellOverride: 'powershell.exe' - }) - const wsl = await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - }) - - const processes = await provider.listProcesses() - - expect(processes.find((process) => process.id === native.id)?.wslDistro).toBeNull() - expect(processes.find((process) => process.id === wsl.id)?.wslDistro).toBe('Ubuntu') - }) - }) - - describe('getDefaultShell', () => { - it('returns SHELL env var on Unix', async () => { - const originalShell = process.env.SHELL - try { - process.env.SHELL = '/bin/bash' - expect(await provider.getDefaultShell()).toBe('/bin/bash') - } finally { - if (originalShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = originalShell - } - } - }) - }) - - describe('getProfiles', () => { - it('awaits asynchronous WSL availability on Windows', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - let resolveAvailability!: (available: boolean) => void - isWslAvailableAsyncMock.mockReturnValue( - new Promise((resolve) => { - resolveAvailability = resolve - }) - ) - - const profiles = provider.getProfiles() - let settled = false - void profiles.then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - - resolveAvailability(true) - await expect(profiles).resolves.toContainEqual({ name: 'WSL', path: 'wsl.exe' }) - expect(isWslAvailableAsyncMock).toHaveBeenCalledOnce() - }) - }) -}) diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts deleted file mode 100644 index 0457a6e170f0..000000000000 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: createShellPromptReadinessProbeMock -})) - -import { LocalPtyProvider } from './local-pty-provider' -import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/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, - wslUncDirectoryExistsMock, - 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}\n`) - } 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' }) - ) - }) - - // Order matters: this case reads spawnMock.mock.calls[0], which the preceding - // default-shell spawn populates (spawnMock accumulates calls 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'\n") - } finally { - vi.useRealTimers() - } - }) - - it.each([ - ['after the ready marker', ['\x1b]777;orca-shell-ready\x07', '\x1b[?2004hfish> ']], - ['after the ESC introducer', ['\x1b]777;orca-shell-ready\x07\x1b', '[?2004hfish> ']] - ])('preserves Fish bracketed-paste output split %s', async (_boundary, chunks) => { - process.env.SHELL = '/usr/bin/fish' - const received: string[] = [] - provider.configure({ onData: (_id, data) => received.push(data) }) - - await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) - const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void - for (const chunk of chunks) { - dataCallback(chunk) - } - - expect(received.join('')).toBe('\x1b[?2004hfish> ') - }) - - 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\n') - } 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-provider-shutdown.test.ts b/src/main/providers/local-pty-provider-shutdown.test.ts deleted file mode 100644 index 950a7e9cbae7..000000000000 --- a/src/main/providers/local-pty-provider-shutdown.test.ts +++ /dev/null @@ -1,617 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../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, - 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, - wslUncDirectoryExistsMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('shutdown', () => { - it('kills the PTY process', async () => { - // Why: capture the spy reference before shutdown triggers onExit → - // POSIX kill neutralization. After neutralization, mockProc.kill is - // replaced with a non-spy no-op to close the UnixTerminal.destroy() → - // socket-close → SIGHUP-to-recycled-pid race (see 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: 'unknown', - reason: 'stop_unverified' - }) - }) - - 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 - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - let settled = false - const shutdown = provider.shutdown(id, { immediate: true }).finally(() => { - settled = true - }) - await Promise.resolve() - - expect(killSpy).toHaveBeenCalledWith('SIGKILL') - expect(settled).toBe(false) - expect(provider.hasPty(id)).toBe(true) - exitCb?.({ exitCode: 137 }) - await shutdown - 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']]) - - // The original graceful deadline was replaced by the immediate - // escalation, so it cannot call the now-neutralized proc.kill later. - 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('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 { - const killSpy = vi.fn() - mockProc.kill = killSpy - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - const shutdown = provider.shutdown(id, { immediate: true }) - const rejected = expect(shutdown).rejects.toThrow('Timed out waiting for PTY process exit') - await vi.advanceTimersByTimeAsync(LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS) - await rejected - expect(provider.hasPty(id)).toBe(true) - - const retry = provider.shutdown(id, { immediate: true }) - expect(killSpy).toHaveBeenCalledTimes(1) - exitCb?.({ exitCode: 137 }) - await expect(retry).resolves.toBeUndefined() - expect(provider.hasPty(id)).toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('propagates kill failure without dropping the physical owner', async () => { - mockProc.kill = vi.fn(() => { - throw new Error('kill denied') - }) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - await expect(provider.shutdown(id, { immediate: true })).rejects.toThrow('kill denied') - expect(provider.hasPty(id)).toBe(true) - - mockProc.kill = vi.fn(() => exitCb?.({ exitCode: 137 })) - 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 }) - - // Why: an orphaned pnpm/node child otherwise keeps the ConPTY console alive and holds - // the worktree cwd; the sweep taskkill /T /F clears the tree so removal can proceed. - 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) }) - ) - }) - }) - - describe('killAll', () => { - it('kills all PTY processes', async () => { - // Why: each spawn needs its own proc so the onExit-triggered POSIX kill - // neutralization on one proc does not replace the kill function on the - // other (mockProc is shared by default in beforeEach). Each proc also - // needs its own exitCb holder — the default mockProc.onExit assigns to - // the shared `exitCb` variable, so the second spawn would overwrite the - // first's exit callback, and mock1Kill firing would trigger cleanup for - // id2 (removing it from the map before killAll iterates to it). - 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-provider-spawn-cwd-safety.test.ts b/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts deleted file mode 100644 index de7934138077..000000000000 --- a/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../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, - wslUncDirectoryExistsMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('spawn', () => { - it('calls node-pty spawn with correct args', async () => { - await provider.spawn({ cols: 120, rows: 40, cwd: '/tmp' }) - expect(spawnMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - cols: 120, - rows: 40, - cwd: '/tmp' - }) - ) - }) - - 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 HOME so we assert the exact resolved candidate, not just non-root-ness — - // catches regressions where resolveSafePtyDefaultCwd picks an unintended home. - process.env.HOME = '/home/testuser' - - try { - // Why: an omitted cwd resolves to a guaranteed-safe default home (the - // guard only rejects root-like paths), so the agent must still launch. - 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-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts deleted file mode 100644 index daf6286b56df..000000000000 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -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/legacy-terminal-shim-dir' - -const { - existsSyncMock, - statSyncMock, - accessSyncMock, - mkdirSyncMock, - writeFileSyncMock, - spawnMock, - prepareMacosTccLoginShellMock, - resolveAgentForegroundProcessMock, - readWindowsPtyJobProcessIdsMock, - killWithDescendantSweepMock, - isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../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, - wslUncDirectoryExistsMock, - 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([ - // fish EXPORTS fish_history, so an Orca launched from a fish pane hands every - // pane the LAUNCHING worktree's session — even with isolation 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 { - // No worktreeId: the history-disabled branch of spawn. - 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([ - // HISTFILE is exported, so an Orca launched from a pane in another worktree - // hands every pane that worktree's history file — isolation off included. - [ - '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 { - // No worktreeId: the history-disabled branch of spawn. - 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 () => { - // Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets - // `development` in dev runs); leaking it breaks `next build` and 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 () => { - // Why: only the ambient value is stripped; a caller-supplied NODE_ENV still wins. - 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 () => { - // Why: CONDA_SHLVL asserts CONDA_PREFIX exists; forwarding the sentinel - // alone makes the user's rc-file conda hook raise a TypeError (#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 () => { - // Why: envToDelete runs after the inherited-env scrub, so the coherence - // pass must run last or it re-creates the exact broken pair it prevents. - 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_HOME = '/tmp/orca-mimocode-overlay' - env.ORCA_MIMOCODE_HOME = '/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 - } - 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() - }) - - 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-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts deleted file mode 100644 index cf523a469fca..000000000000 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ /dev/null @@ -1,613 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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('../daemon/pty-subprocess/bun-pty-process-capabilities', () => ({ - canUseBunPty: () => false -})) - -vi.mock('./macos-tcc-login-shell', async (importOriginal) => ({ - ...(await importOriginal()), - prepareMacosTccLoginShell: prepareMacosTccLoginShellMock -})) - -vi.mock('../pty-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: createShellPromptReadinessProbeMock -})) - -import { LocalPtyProvider } from './local-pty-provider' -import { - pendingLocalPtySpawns, - ptyDisposables, - ptyExitDisposables, - ptyPhysicalExits, - startupIngressByPty -} from './local-pty-provider-state' -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, - wslUncDirectoryExistsMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('spawn', () => { - it('returns a unique PTY id', async () => { - const result = await provider.spawn({ cols: 80, rows: 24 }) - expect(result.id).toBeTruthy() - expect(typeof result.id).toBe('string') - }) - - it('retires buffered output and synchronous Bun exit before replying to spawn', async () => { - const disposeData = vi.fn() - const disposeExit = vi.fn() - const onExit = vi.fn() - provider.configure({ onExit }) - mockProc.onData.mockImplementation((listener: (data: string) => void) => { - listener('last output') - return { dispose: disposeData } - }) - mockProc.onExit.mockImplementation((listener: (event: { exitCode: number }) => void) => { - listener({ exitCode: 17 }) - return { dispose: disposeExit } - }) - const result = await provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'already-exited-bun-shell', - command: 'must-not-run' - }) - expect(result.exitedBeforeSpawnReply).toBe(true) - expect(provider.getPtyProcess(result.id)).toBeUndefined() - for (const map of [ - ptyDisposables, - ptyExitDisposables, - ptyPhysicalExits, - startupIngressByPty - ]) { - expect(map.has(result.id)).toBe(false) - } - expect(disposeData).toHaveBeenCalledOnce() - expect(disposeExit).toHaveBeenCalledOnce() - expect(onExit).toHaveBeenCalledOnce() - await Promise.resolve() - 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, - // Why published: this attach really moved the PTY, unlike daemon/relay attach, so main - // must record 120x40 rather than preserving the size it held for the session. - 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() - }) - - // Why (#16441): the Codex hook install and trust grant moved into - // buildSpawnEnv, so the env build is now the long await before node-pty - // exists — shutdown must be able to cancel the session id during it. - it('does not spawn after shutdown cancels a pending spawn during the env build', async () => { - spawnMock.mockClear() - let finishEnvBuild!: (env: Record) => void - const buildSpawnEnv = vi.fn( - (_id: string, baseEnv: Record) => - new Promise>((resolve) => { - finishEnvBuild = () => resolve(baseEnv) - }) - ) - const envProvider = new LocalPtyProvider({ buildSpawnEnv }) - - const spawn = envProvider.spawn({ cols: 80, rows: 24, sessionId: 'env-build-session' }) - const canceledSpawn = expect(spawn).rejects.toThrow('PTY spawn canceled: env-build-session') - await vi.waitFor(() => expect(buildSpawnEnv).toHaveBeenCalledOnce()) - - await envProvider.shutdown('env-build-session', { immediate: true }) - finishEnvBuild({}) - await canceledSpawn - expect(spawnMock).not.toHaveBeenCalled() - }) - - it.each([1, 2])( - 'keeps cancellation registered across %i environment-resume microtasks', - async (microtasks) => { - spawnMock.mockClear() - let finishEnvBuild!: () => void - const envProvider = new LocalPtyProvider({ - buildSpawnEnv: (_id, baseEnv) => - new Promise>((resolve) => { - finishEnvBuild = () => resolve(baseEnv) - }) - }) - const spawn = envProvider.spawn({ - cols: 80, - rows: 24, - sessionId: 'resolved-env-build-session' - }) - const canceledSpawn = expect(spawn).rejects.toThrow( - 'PTY spawn canceled: resolved-env-build-session' - ) - await vi.waitFor(() => expect(finishEnvBuild).toBeTypeOf('function')) - - finishEnvBuild() - const shutdown = new Promise((resolve, reject) => { - const shutdown = () => { - envProvider - .shutdown('resolved-env-build-session', { immediate: true }) - .then(resolve, reject) - } - queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown))) - }) - - await shutdown - await canceledSpawn - expect(spawnMock).not.toHaveBeenCalled() - } - ) - - 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 } - spawnMock.mockReturnValueOnce(procA) - - // Hold both spawns past the existence check and inside the preflight so they - // race to register the same id; release only after both are parked. - let releasePreflight!: () => void - prepareMacosTccLoginShellMock.mockReturnValue( - new Promise((resolve) => { - releasePreflight = resolve - }) - ) - - const spawnA = provider.spawn({ cols: 80, rows: 24, sessionId: 'race-session' }) - const spawnB = provider.spawn({ cols: 132, rows: 44, sessionId: 'race-session' }) - await vi.waitFor(() => expect(prepareMacosTccLoginShellMock).toHaveBeenCalledTimes(2)) - releasePreflight() - - const [a, b] = await Promise.all([spawnA, spawnB]) - // The winner owns the tracked PTY; the loser attaches to it, not a second one. - expect(a.isReattach).toBeUndefined() - expect(b.isReattach).toBe(true) - expect(b.pid).toBe(procA.pid) - expect(provider.getPtyProcess('race-session')).toBe(procA) - 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-provider-state.ts b/src/main/providers/local-pty-provider-state.ts deleted file mode 100644 index 10cef331a620..000000000000 --- a/src/main/providers/local-pty-provider-state.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type * as pty from 'node-pty' -import type { PhysicalExitTracker } from '../../shared/physical-exit-tracker' -import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import type { TerminalExitCause } from '../../shared/terminal-exit-cause' -import { getSpawnedShellName, normalizeLocalCallerSessionId } from './local-pty-launch-helpers' - -export type PtyShutdownOperation = { - promise: Promise - immediate: boolean - rootSignalled: boolean - proc: pty.IPty -} - -export type PendingLocalPtySpawn = { - cancellation: AbortController -} - -export type DataCallback = (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number -}) => void - -export type ExitCallback = (payload: { - id: string - code: number - incarnationId?: string - cause?: TerminalExitCause -}) => void - -let ptyCounter = 0 -export const ptyProcesses = new Map() -export const ptyIncarnations = new Map() -// Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown. -export const ptyAgentSessionIds = new Set() -// Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY. -export const ptyShutdownOperations = new Map() -export const pendingLocalPtySpawns = new Map>() -// Why the full path: the shell proof must tell Git Bash's `bin\bash.exe` launcher from `usr\bin\bash.exe`. -export const ptyShellPath = new Map() -export const ptyAgentForegroundContextPaths = new Map() -// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit. -// `pid` anchors the identity to the row that proved it (null when ambiguous); -// `at` is the last confirmation, so unanchored job evidence -- only a superset -- cannot hold it forever. -// `steady` (POSIX) is the pane fingerprint the recognizing capture proved plus node-pty's name at -// that moment; a cheap capture matching it re-proves the identity without the full table. -export const ptyLastRecognizedForeground = new Map< - string, - { - name: string - pid: number | null - at: number - steady?: { fingerprint: string; fallbackProcess: string | null } | null - } ->() -export const ptyTerminalHandle = new Map() -export const ptyWorktreeId = new Map() -export const ptyInitialCwd = new Map() -// Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity. -export const ptyWslDistroById = new Map() -// Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination. -export const ptyDisposables = new Map void }[]>() -export const ptyExitDisposables = new Map void }>() -export const ptyCleanupCallbacks = new Map void>() -export const ptyTerminationMode = new Map() -export const ptyPhysicalExits = new Map() -// Why: a wrapper spawn (macOS TCC login) reports its own status, never the -// shell's, so its exit numbers must not be read as the agent's (STA-4536). -export const ptyReportsChildExitStatus = new Map() -export const ptyForceKillTimers = new Map>() - -let loadGeneration = 0 -export const ptyLoadGeneration = new Map() - -export const dataListeners = new Set() -export const exitListeners = new Set() -export const startupIngressByPty = new Map() - -/** - * Disposes native node-pty listeners registered for a PTY id. - */ -export function disposePtyListeners(id: string): void { - const disposables = ptyDisposables.get(id) - if (disposables) { - for (const d of disposables) { - d.dispose() - } - ptyDisposables.delete(id) - } -} - -export function disposePtyExitListener(id: string): void { - ptyExitDisposables.get(id)?.dispose() - ptyExitDisposables.delete(id) -} - -export function clearLocalPtyForceKillTimer(id: string): void { - const timer = ptyForceKillTimers.get(id) - if (timer) { - clearTimeout(timer) - ptyForceKillTimers.delete(id) - } -} - -export function runPtyCleanup(id: string): void { - const cleanup = ptyCleanupCallbacks.get(id) - if (!cleanup) { - return - } - ptyCleanupCallbacks.delete(id) - cleanup() -} - -export function getPtyShellName(id: string): string | undefined { - const shellPath = ptyShellPath.get(id) - return shellPath === undefined ? undefined : getSpawnedShellName(shellPath) -} - -/** - * Removes all local tracking state for a PTY id after teardown. - */ -export function clearPtyState(id: string): void { - clearLocalPtyForceKillTimer(id) - runPtyCleanup(id) - disposePtyListeners(id) - disposePtyExitListener(id) - ptyProcesses.delete(id) - ptyIncarnations.delete(id) - ptyAgentSessionIds.delete(id) - ptyShellPath.delete(id) - ptyAgentForegroundContextPaths.delete(id) - ptyLastRecognizedForeground.delete(id) - ptyTerminalHandle.delete(id) - ptyWorktreeId.delete(id) - ptyInitialCwd.delete(id) - ptyWslDistroById.delete(id) - ptyLoadGeneration.delete(id) - ptyTerminationMode.delete(id) - ptyReportsChildExitStatus.delete(id) - ptyPhysicalExits.delete(id) -} - -/** - * Allocates either a stable caller-provided PTY id or a new numeric id. - */ -export function allocatePtyId(sessionId: string | undefined): string { - const requested = normalizeLocalCallerSessionId(sessionId) - if (requested) { - return requested - } - let id: string - do { - id = String(++ptyCounter) - } while (ptyProcesses.has(id)) - return id -} - -export function getLoadGeneration(): number { - return loadGeneration -} - -export function advanceLoadGeneration(): number { - return ++loadGeneration -} - -export function resetLoadGeneration(): void { - loadGeneration = 0 -} diff --git a/src/main/providers/local-pty-provider-test-harness.ts b/src/main/providers/local-pty-provider-test-harness.ts deleted file mode 100644 index cf95ae1d9627..000000000000 --- a/src/main/providers/local-pty-provider-test-harness.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, beforeEach, vi, type Mock } from 'vitest' -import { _resetLocalPtyProviderStateForTest } from './local-pty-provider' - -export type LocalPtyExitCallback = (info: { exitCode: number }) => void - -export type LocalPtyMockProcess = { - onData: ReturnType - onExit: ReturnType - write: ReturnType - resize: ReturnType - pause: ReturnType - resume: ReturnType - kill: ReturnType - process: string - pid: number -} - -export type LocalPtyProviderMocks = { - existsSyncMock: Mock - statSyncMock: Mock - accessSyncMock: Mock - mkdirSyncMock: Mock - writeFileSyncMock: Mock - prepareMacosTccLoginShellMock: Mock - resolveAgentForegroundProcessMock: Mock - readWindowsPtyJobProcessIdsMock: Mock - killWithDescendantSweepMock: Mock - isWslAvailableAsyncMock: Mock - wslUncDirectoryExistsMock: Mock - createShellPromptReadinessProbeMock: Mock -} - -/** Pins platform/shell env for every test and restores it plus provider module state after. */ -export function installLocalPtyProviderEnvSandbox(): void { - let origShell: string | undefined - let origPowerlevelWizardDisable: string | undefined - let origHistFile: string | undefined - let origPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - origShell = process.env.SHELL - origPowerlevelWizardDisable = process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD - origHistFile = process.env.HISTFILE - process.env.SHELL = '/bin/zsh' - delete process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD - // injectHistoryEnv preserves an inherited HISTFILE, so clear it for hermetic history assertions. - delete process.env.HISTFILE - }) - - afterEach(() => { - _resetLocalPtyProviderStateForTest() - if (origPlatform) { - Object.defineProperty(process, 'platform', origPlatform) - } - if (origShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = origShell - } - if (origPowerlevelWizardDisable === undefined) { - delete process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD - } else { - process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD = origPowerlevelWizardDisable - } - if (origHistFile === undefined) { - delete process.env.HISTFILE - } else { - process.env.HISTFILE = origHistFile - } - }) -} - -export function applyLocalPtyProviderMockDefaults(mocks: LocalPtyProviderMocks): void { - mocks.existsSyncMock.mockReturnValue(true) - // size: the wrapper writer verifies each generated file is non-empty. - mocks.statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755, size: 1 }) - mocks.accessSyncMock.mockReturnValue(undefined) - mocks.mkdirSyncMock.mockReset() - mocks.writeFileSyncMock.mockReset() - mocks.killWithDescendantSweepMock.mockReset() - // Default: no-op sweep that still runs killRoot (matches empty-snapshot degrade). - mocks.killWithDescendantSweepMock.mockImplementation( - async (_rootPid: number, killRoot: () => void, _deps?: { ownsRoot?: () => boolean }) => { - killRoot() - } - ) - mocks.prepareMacosTccLoginShellMock.mockReset() - mocks.prepareMacosTccLoginShellMock.mockResolvedValue(undefined) - mocks.resolveAgentForegroundProcessMock.mockReset() - mocks.resolveAgentForegroundProcessMock.mockImplementation( - async (_pid: number, fallbackProcess: string | null) => ({ - available: true, - processName: fallbackProcess - }) - ) - mocks.readWindowsPtyJobProcessIdsMock.mockReset() - mocks.readWindowsPtyJobProcessIdsMock.mockReturnValue(null) - mocks.isWslAvailableAsyncMock.mockReset() - mocks.isWslAvailableAsyncMock.mockResolvedValue(true) - mocks.wslUncDirectoryExistsMock.mockReset() - mocks.wslUncDirectoryExistsMock.mockReturnValue(true) - mocks.createShellPromptReadinessProbeMock.mockReset() -} - -/** node-pty stand-in; the exit callback lives in the test file so bodies can fire it directly. */ -export function createLocalPtyMockProcess(exitCallback: { - get: () => LocalPtyExitCallback | undefined - set: (cb: LocalPtyExitCallback | undefined) => void -}): LocalPtyMockProcess { - return { - onData: vi.fn(() => ({ dispose: vi.fn() })), - onExit: vi.fn((cb: LocalPtyExitCallback) => { - exitCallback.set(cb) - return { - dispose: () => { - if (exitCallback.get() === cb) { - exitCallback.set(undefined) - } - } - } - }), - write: vi.fn(), - resize: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - kill: vi.fn(() => { - exitCallback.get()?.({ exitCode: -1 }) - }), - process: 'zsh', - pid: 12345 - } -} diff --git a/src/main/providers/local-pty-provider-types.ts b/src/main/providers/local-pty-provider-types.ts deleted file mode 100644 index ebe90bbb049c..000000000000 --- a/src/main/providers/local-pty-provider-types.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { TerminalExitCause } from '../../shared/terminal-exit-cause' -import type { PtySpawnOptions } from './types' - -export type LocalPtyProviderOptions = { - /** Why: `ctx.command` (pi/omp/claude) must drive overlay source-dir selection — a disk-presence fallback shadows the other agent's extensions. */ - buildSpawnEnv?: ( - id: string, - baseEnv: Record, - ctx?: { - explicitEnv?: PtySpawnOptions['env'] - command?: string - launchAgent?: PtySpawnOptions['launchAgent'] - codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride'] - cwd?: string - shellPath?: string - isWsl?: boolean - wslDistro?: string | null - } - // Why (#16441): Codex launch prep grants hook trust through a codex - // app-server session. `spawn` already awaits, so returning a promise keeps - // the Electron main thread responsive instead of blocking on spawnSync. - ) => Record | Promise> - /** Whether worktree-scoped shell history is enabled; when true (or absent) with a worktreeId, HISTFILE is scoped per-worktree. */ - isHistoryEnabled?: () => boolean - /** Why: COMSPEC is always cmd.exe, so this callback injects the user's persisted shell preference. Undefined when none set. */ - getWindowsShell?: () => string | undefined - getDefaultShell?: () => string | undefined - getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined - pwshAvailable?: () => boolean | Promise - onSpawned?: (id: string, incarnationId: string) => void - onExit?: (id: string, code: number, incarnationId: string, cause?: TerminalExitCause) => void - onData?: ( - id: string, - data: string, - timestamp: number, - sequenceChars?: number, - transformed?: boolean - ) => void -} diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts deleted file mode 100644 index bc96cdb1d6af..000000000000 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -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, - wslUncDirectoryExistsMock, - 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(), - wslUncDirectoryExistsMock: vi.fn(), - createShellPromptReadinessProbeMock: vi.fn() -})) - -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-descendant-termination', () => ({ - killWithDescendantSweep: killWithDescendantSweepMock -})) - -// Resolve PowerShell family names to deterministic absolute paths (the fs mock -// above otherwise makes every probe miss). The real resolver — which skips the -// Store App Execution Alias stub — is covered in -// windows-powershell-executable.test.ts. -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, - 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', () => ({ - parseWslPath: (path: string) => { - const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) - if (!match) { - return null - } - return { - distro: match[1], - linuxPath: (match[2] || '').replace(/\\/g, '/') || '/' - } - }, - toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'), - toWindowsWslPath: (path: string, distro: string) => - `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`, - getDefaultWslDistro: () => 'Ubuntu', - isWslAvailableAsync: () => isWslAvailableAsyncMock(), - // Why: WSL worktree validation now asks the distro; these tests use WSL UNC - // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) -})) - -vi.mock('../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 { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env' - -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, - wslUncDirectoryExistsMock, - createShellPromptReadinessProbeMock - }) - - exitCb = undefined - mockProc = createLocalPtyMockProcess({ - get: () => exitCb, - set: (cb) => { - exitCb = cb - } - }) - spawnMock.mockReturnValue(mockProc) - - provider = new LocalPtyProvider() - }) - - describe('spawn', () => { - 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(wslUncDirectoryExistsMock).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(wslUncDirectoryExistsMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a') - }) - - 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: '"' - }) - }) - ) - }) - }) -}) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts deleted file mode 100644 index a8e2f9ce6094..000000000000 --- a/src/main/providers/local-pty-provider.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type * as pty from 'node-pty' -import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' -import { - WRITE_ACCEPTED, - writeRefused, - type WriteSettlement -} from '../../shared/pty-write-settlement' -import { - confirmLocalPtyForegroundProcess, - confirmLocalPtyShellForeground, - getLocalPtyForegroundProcess, - hasLocalPtyChildProcesses, - inspectLocalPtyChildProcesses -} from './local-pty-foreground-inspection' -import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import type { PtyProcessInspection } from './pty-process-inspection' -import { - advanceLoadGeneration, - clearPtyState, - pendingLocalPtySpawns, - ptyProcesses, - resetLoadGeneration, - type DataCallback, - type ExitCallback -} from './local-pty-provider-state' -import { - clearLocalPtyBuffer, - closeLocalPtyStartupQueryAuthority, - getDefaultLocalPtyShell, - getLocalPtyAppliedSize, - getLocalPtyCwd, - getLocalPtyProcess, - getLocalPtyProfiles, - listLocalPtyProcesses, - onLocalPtyData, - onLocalPtyExit, - pauseLocalPtyProducer, - resizeLocalPty, - resumeLocalPtyProducer, - sendLocalPtySignal, - writeLocalPty -} from './local-pty-session-operations' -import { spawnLocalPty } from './local-pty-spawn' -import { cancelAllPendingLocalPtySpawns } from './local-pty-spawn-state' -import { killAllLocalPtys, killOrphanedLocalPtys, shutdownLocalPty } from './local-pty-termination' - -export type { LocalPtyProviderOptions } from './local-pty-provider-types' -export { - LOCAL_PTY_FORCE_KILL_RETRY_MS, - LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS, - LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS -} from './local-pty-termination' - -export class LocalPtyProvider implements IPtyProvider { - private opts: LocalPtyProviderOptions - - constructor(opts: LocalPtyProviderOptions = {}) { - this.opts = opts - } - - /** Reconfigure the provider with new hooks (e.g. after window re-creation). */ - configure(opts: LocalPtyProviderOptions): void { - this.opts = opts - } - - /** - * Spawns or reattaches a local PTY session for the renderer process. - * - * Windows launches can pre-deliver startup commands in argv, so the stdin fallback only runs when needed. - */ - spawn(args: PtySpawnOptions): Promise { - return spawnLocalPty(args, () => this.opts) - } - - // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. - async attach(_id: string): Promise {} - hasPty(id: string): boolean { - return ptyProcesses.has(id) - } - write(id: string, data: string): boolean { - return writeLocalPty(id, data) - } - - // In-process node-pty is its own sole owner, so its synchronous answer is the settlement. - writeWithSettlement(id: string, data: string): WriteSettlement { - return writeLocalPty(id, data) ? WRITE_ACCEPTED : writeRefused('provider_refused_write') - } - resize(id: string, cols: number, rows: number): void { - resizeLocalPty(id, cols, rows) - } - - pauseProducer(id: string): void { - pauseLocalPtyProducer(id) - } - - resumeProducer(id: string): void { - resumeLocalPtyProducer(id) - } - - getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { - return getLocalPtyAppliedSize(id) - } - - shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - return shutdownLocalPty(id, opts) - } - - sendSignal(id: string, signal: string): Promise { - return sendLocalPtySignal(id, signal) - } - - getCwd(id: string): Promise { - return getLocalPtyCwd(id) - } - async getInitialCwd(_id: string): Promise { - return '' - } - clearBuffer(id: string): Promise { - return clearLocalPtyBuffer(id) - } - closeStartupQueryAuthority(id: string): number { - return closeLocalPtyStartupQueryAuthority(id) - } - acknowledgeDataEvent(_id: string, _charCount: number): void { - /* no flow control for local */ - } - - hasChildProcesses(id: string): Promise { - return hasLocalPtyChildProcesses(id) - } - - async inspectProcess(id: string): Promise { - const proc = ptyProcesses.get(id) - const foregroundProcess = await getLocalPtyForegroundProcess(id) - const childProcessEvidence = await inspectLocalPtyChildProcesses(id) - // Neither asynchronous inspection may publish a replacement pane's identity. - if (ptyProcesses.get(id) !== proc) { - return { - foregroundProcess: null, - hasChildProcesses: true, - childProcessEvidence: 'unverifiable' - } - } - return { - foregroundProcess, - hasChildProcesses: childProcessEvidence !== 'no-children', - childProcessEvidence - } - } - - getForegroundProcess(id: string): Promise { - return getLocalPtyForegroundProcess(id) - } - - confirmForegroundProcess(id: string): Promise { - return confirmLocalPtyForegroundProcess(id) - } - - confirmShellForeground(id: string): Promise { - return confirmLocalPtyShellForeground(id) - } - - async serialize(_ids: string[]): Promise { - return '{}' - } - async revive(_state: string): Promise { - /* re-spawning handles local revival */ - } - - listProcesses(): Promise { - return listLocalPtyProcesses() - } - - getDefaultShell(): Promise { - return getDefaultLocalPtyShell(() => this.opts) - } - - getProfiles(): Promise<{ name: string; path: string }[]> { - return getLocalPtyProfiles() - } - - onData(callback: DataCallback): () => void { - return onLocalPtyData(callback) - } - - // Local PTYs don't replay -- this is for remote reconnection - onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { - return () => {} - } - - onExit(callback: ExitCallback): () => void { - return onLocalPtyExit(callback) - } - - // ─── Local-only helpers (not part of IPtyProvider interface) ─────── - - /** Kill orphaned PTYs from previous page loads. */ - killOrphanedPtys(currentGeneration: number): { id: string }[] { - return killOrphanedLocalPtys(currentGeneration) - } - - /** Advance the load generation counter (called on renderer reload). */ - advanceGeneration(): number { - return advanceLoadGeneration() - } - - /** Get a writable reference to a PTY (for runtime controller). */ - getPtyProcess(id: string): pty.IPty | undefined { - return getLocalPtyProcess(id) - } - - /** Kill all in-process local PTYs. Call on app quit. */ - killAll(): void { - killAllLocalPtys() - } -} - -export function _resetLocalPtyProviderStateForTest(): void { - cancelAllPendingLocalPtySpawns() - pendingLocalPtySpawns.clear() - for (const id of ptyProcesses.keys()) { - clearPtyState(id) - } - resetLoadGeneration() -} diff --git a/src/main/providers/local-pty-runtime-spawn.ts b/src/main/providers/local-pty-runtime-spawn.ts deleted file mode 100644 index 7d3c26d90ade..000000000000 --- a/src/main/providers/local-pty-runtime-spawn.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' -import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' -import { - spawnShellWithFallback, - type ShellSpawnParams, - type ShellSpawnResult -} from './local-pty-utils' - -type LocalPtySpawn = ( - params: Omit & { signal?: AbortSignal } -) => ShellSpawnResult | Promise - -/** Degraded daemon routing uses the same runtime as the packaged host. */ -export async function loadLocalPtyRuntimeSpawn(): Promise { - if (!canUseBunPty()) { - const pty = await import('node-pty') - return (params) => spawnShellWithFallback({ ...params, ptySpawn: pty.spawn }) - } - if (process.platform === 'win32') { - return (params) => - spawnNativeDaemonPty({ - ...params, - spawnCwd: params.cwd, - windowsFallbackAttempts: params.windowsFallbackAttempts ?? [] - }) - } - return (params) => - spawnShellWithFallback({ - ...params, - ptySpawn(file, args = [], options = {}) { - if (!Array.isArray(args)) { - throw new Error('POSIX PTY arguments must be an array') - } - return spawnBunPty({ - file, - args, - cwd: options.cwd ?? params.cwd, - env: params.env, - cols: options.cols ?? params.cols, - rows: options.rows ?? params.rows - }) - } - }) -} diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts deleted file mode 100644 index e89f0c433162..000000000000 --- a/src/main/providers/local-pty-session-activation.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type * as pty from 'node-pty' -import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' -import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' -import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' -import { resolveProcessExitCause } from '../../shared/terminal-exit-cause' -import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' -import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' -import { getSpawnedShellName } from './local-pty-launch-helpers' -import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' -import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import { - clearPtyState, - dataListeners, - exitListeners, - getLoadGeneration, - ptyAgentForegroundContextPaths, - ptyAgentSessionIds, - ptyDisposables, - ptyExitDisposables, - ptyIncarnations, - ptyInitialCwd, - ptyLoadGeneration, - ptyPhysicalExits, - ptyProcesses, - ptyReportsChildExitStatus, - ptyShellPath, - ptyTerminalHandle, - ptyTerminationMode, - ptyWorktreeId, - ptyWslDistroById, - startupIngressByPty -} from './local-pty-provider-state' -import { createLocalPtyShellReadinessSession } from './local-pty-shell-readiness-session' -import { destroyPtyProcess, createPtyPhysicalExit } from './local-pty-termination' -import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready-startup-command' -import type { PtySpawnOptions, PtySpawnResult } from './types' - -export function activateLocalPtySession(args: { - id: string - incarnationId: string - spawn: PtySpawnOptions - getOptions: () => LocalPtyProviderOptions - plan: LocalPtyLaunchPlan - env: Record - proc: pty.IPty - reportsChildExitStatus: boolean - spawnedWslDistro: string | null | undefined -}): PtySpawnResult { - const { id, incarnationId, spawn, getOptions, plan, env, proc, spawnedWslDistro } = args - createPtyPhysicalExit(id) - ptyReportsChildExitStatus.set(id, args.reportsChildExitStatus) - ptyProcesses.set(id, proc) - ptyInitialCwd.set(id, plan.cwd) - if (spawnedWslDistro !== undefined) { - ptyWslDistroById.set(id, spawnedWslDistro) - } - // Why both: launchAgent is explicit intent that survives command rewrites; recognition catches bare agent command lines. - if (spawn.launchAgent || plan.startupAgentRecognition) { - ptyAgentSessionIds.add(id) - } - ptyShellPath.set(id, plan.shellPath) - if (env.ORCA_TERMINAL_HANDLE) { - ptyTerminalHandle.set(id, env.ORCA_TERMINAL_HANDLE) - } - if (spawn.worktreeId) { - ptyWorktreeId.set(id, spawn.worktreeId) - } - ptyAgentForegroundContextPaths.set( - id, - getAgentForegroundContextPaths({ cwd: spawn.cwd, worktreeId: spawn.worktreeId }) - ) - ptyLoadGeneration.set(id, getLoadGeneration()) - ptyIncarnations.set(id, incarnationId) - getOptions().onSpawned?.(id, incarnationId) - - const emitIngressData = (emission: PtyIngressEmission): void => { - const sequenceChars = emission.rawEndSeq - emission.rawStartSeq - if (emission.transformed || sequenceChars !== emission.data.length) { - getOptions().onData?.(id, emission.data, Date.now(), sequenceChars, true) - } else { - getOptions().onData?.(id, emission.data, Date.now()) - } - for (const cb of dataListeners) { - cb( - emission.transformed || sequenceChars !== emission.data.length - ? { - id, - data: emission.data, - sequenceChars, - seq: emission.rawEndSeq, - transformed: true - } - : { id, data: emission.data } - ) - } - } - const startupIngress = new PtyStartupIngress({ - ...(spawn.startupIngress ? { intent: spawn.startupIngress } : {}), - ownerBackend: resolvePtyOwnerBackend({ - platform: process.platform, - shellPath: plan.shellPath, - wslDistro: spawnedWslDistro - }), - write: (data) => proc.write(data), - onEmission: emitIngressData - }) - startupIngressByPty.set(id, startupIngress) - - // Shell-ready startup command support - const readiness = createLocalPtyShellReadinessSession({ - id, - spawn, - plan, - env, - proc, - startupIngress - }) - const disposables: { dispose: () => void }[] = [] - const onDataDisposable = proc.onData((rawData) => { - readiness.acceptData(rawData) - }) - if (onDataDisposable) { - disposables.push(onDataDisposable) - } - ptyDisposables.set(id, disposables) - - let exitedBeforeSpawnReply = false - const onExitDisposable = proc.onExit(({ exitCode, signal }) => { - exitedBeforeSpawnReply = true - // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the - // cause is built here, where the signal and the spawn's trustworthiness - // are both still in hand. - const cause = resolveProcessExitCause({ - exitCode, - signal, - hostReportsChildExitStatus: ptyReportsChildExitStatus.get(id) - }) - const wasTerminationRequested = ptyTerminationMode.has(id) - ptyPhysicalExits.get(id)?.markExited() - // Why: neutralize proc.kill before destroy — node-pty SIGHUPs on socket 'close', which can race here and signal a reaped/recycled pid. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - readiness.prepareForExit() - clearPtyState(id) - startupIngress.drainAndClose() - startupIngressByPty.delete(id) - // Why: release the master ptmx fd on natural exit, else a clean exit leaks the fd until GC. See docs/fix-pty-fd-leak.md. - destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested }) - ptyReportsChildExitStatus.delete(id) - getOptions().onExit?.(id, exitCode, incarnationId, cause) - for (const cb of exitListeners) { - cb({ id, code: exitCode, incarnationId, cause }) - } - }) - if (onExitDisposable) { - if (exitedBeforeSpawnReply) { - onExitDisposable.dispose() - } else { - ptyExitDisposables.set(id, onExitDisposable) - } - } - - const startupCommandDeliveredByWrapper = - spawn.command !== undefined && - plan.shellReadyLaunch?.env[POSIX_SHELL_STARTUP_COMMAND_ENV] === spawn.command - if ( - !exitedBeforeSpawnReply && - spawn.command && - !plan.startupCommandDeliveredInShellArgs && - !startupCommandDeliveredByWrapper - ) { - // Why: shells with bracketed paste armed take a multiline startup prompt literally; others use raw submit. - const spawnedShellName = getSpawnedShellName(plan.shellPath).toLowerCase() - const bracketedPasteSafe = - process.platform !== 'win32' && - isBracketedPasteSafeShell({ - shellName: spawnedShellName, - waitsForShellReady: plan.shellReadyLaunch?.supportsReadyMarker === true - }) - writeStartupCommandWhenShellReady( - readiness.shellReadyPromise, - proc, - spawn.command, - (cleanup) => { - readiness.setStartupCommandCleanup(cleanup) - }, - { - bracketedPasteSafe - } - ) - } - - // Why: publish the OS pid for the memory collector; proc.pid can be briefly 0/undefined before node-pty sees the child. - const rawPid = proc.pid - const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null - return { - id, - incarnationId, - pid, - ...(exitedBeforeSpawnReply ? { exitedBeforeSpawnReply: true } : {}), - ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) - } -} diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts deleted file mode 100644 index 27a151580ce8..000000000000 --- a/src/main/providers/local-pty-session-operations.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { existsSync } from 'node:fs' -import { basename } from 'node:path' -import type * as pty from 'node-pty' -import { readPtsName } from '../pty/node-pty-pts-name' -import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' -import { ptyShellProcessId } from '../windows/windows-pty-job' -import { isWslAvailableAsync } from '../wsl' -import { resolveGitBashPath } from '../git-bash' -import { resolveProcessCwd } from './process-cwd' -import { - dataListeners, - exitListeners, - ptyIncarnations, - ptyInitialCwd, - ptyProcesses, - getPtyShellName, - ptyTerminalHandle, - ptyWorktreeId, - ptyWslDistroById, - startupIngressByPty, - type DataCallback, - type ExitCallback -} from './local-pty-provider-state' -import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import type { PtyProcessInfo } from './types' - -export function writeLocalPty(id: string, data: string): boolean { - // Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of - // those is still held, which they must not overtake (#13137, #7329, #15559). - if (startupIngressByPty.get(id)?.answerLiveQueryReply(data)) { - return true - } - const proc = ptyProcesses.get(id) - if (!proc) { - return false - } - proc.write(data) - return true -} - -export function resizeLocalPty(id: string, cols: number, rows: number): void { - ptyProcesses.get(id)?.resize(cols, rows) -} - -// Why: node-pty pause() stops reading the master fd, so a flooding child blocks on write — true producer backpressure. -export function pauseLocalPtyProducer(id: string): void { - try { - ptyProcesses.get(id)?.pause() - } catch { - /* PTY already destroyed */ - } -} - -export function resumeLocalPtyProducer(id: string): void { - try { - ptyProcesses.get(id)?.resume() - } catch { - /* PTY already destroyed */ - } -} - -// Why: proc.cols/rows are node-pty's authoritative applied size (post-clamp/no-op), used by the renderer drift-check. -export async function getLocalPtyAppliedSize( - id: string -): Promise<{ cols: number; rows: number } | null> { - const proc = ptyProcesses.get(id) - if (!proc || proc.cols <= 0 || proc.rows <= 0) { - return null - } - return { cols: proc.cols, rows: proc.rows } -} - -export async function sendLocalPtySignal(id: string, signal: string): Promise { - const proc = ptyProcesses.get(id) - if (!proc) { - return - } - if ('signalProcess' in proc && typeof proc.signalProcess === 'function') { - proc.signalProcess(signal) - return - } - const signalRootPid = (): void => { - try { - process.kill(proc.pid, signal) - } catch { - /* Process may already be dead */ - } - } - // Why only SIGWINCH: see posix-pty-foreground-group — a real resize reaches the - // tty's foreground group, which proc.pid is never a member of. - if (signal === 'SIGWINCH') { - signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), signal, signalRootPid) - return - } - signalRootPid() -} - -export async function getLocalPtyCwd(id: string): Promise { - const proc = ptyProcesses.get(id) - // Why: '' not throw on unknown id — renderer reads empty as "try next fallback"; throwing is noisy for a normal case. - if (!proc) { - return '' - } - // Why: let resolveProcessCwd's '' surface for the renderer fallback chain; a fabricated cwd would short-circuit it. - const shellPid = ptyShellProcessId(proc) - return shellPid === undefined ? '' : resolveProcessCwd(shellPid) -} - -export async function clearLocalPtyBuffer(id: string): Promise { - // Why: ConPTY keeps its own screen buffer, so xterm clear() alone leaves a stale-cursor gap on the next prompt; POSIX no-op. - // No PSReadLine form-feed nudge here (unlike the daemon): safe only at an empty prompt, which this provider can't detect. - try { - startupIngressByPty.get(id)?.snapshotBarrier() - ptyProcesses.get(id)?.clear() - } catch { - /* PTY may have just exited */ - } -} - -export function closeLocalPtyStartupQueryAuthority(id: string): number { - return startupIngressByPty.get(id)?.closeQueryAuthority() ?? 0 -} - -export async function listLocalPtyProcesses(): Promise { - return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ - id, - ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), - cwd: ptyInitialCwd.get(id) ?? '', - title: proc.process || getPtyShellName(id) || 'shell', - ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), - ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), - ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}) - })) -} - -export async function getDefaultLocalPtyShell( - getOptions: () => LocalPtyProviderOptions -): Promise { - if (process.platform === 'win32') { - return getOptions().getWindowsShell?.() || process.env.COMSPEC || 'powershell.exe' - } - return process.env.SHELL?.trim() || '/bin/zsh' -} - -export async function getLocalPtyProfiles(): Promise<{ name: string; path: string }[]> { - if (process.platform === 'win32') { - const profiles: { name: string; path: string }[] = [ - { name: 'PowerShell', path: 'powershell.exe' }, - { name: 'Command Prompt', path: 'cmd.exe' } - ] - const gitBashPath = resolveGitBashPath() - if (gitBashPath) { - profiles.push({ name: 'Git Bash', path: gitBashPath }) - } - if (await isWslAvailableAsync()) { - profiles.push({ name: 'WSL', path: 'wsl.exe' }) - } - return profiles - } - const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] - return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) -} - -export function onLocalPtyData(callback: DataCallback): () => void { - dataListeners.add(callback) - return () => dataListeners.delete(callback) -} - -export function onLocalPtyExit(callback: ExitCallback): () => void { - exitListeners.add(callback) - return () => exitListeners.delete(callback) -} - -export function getLocalPtyProcess(id: string): pty.IPty | undefined { - return ptyProcesses.get(id) -} diff --git a/src/main/providers/local-pty-shell-readiness-session.ts b/src/main/providers/local-pty-shell-readiness-session.ts deleted file mode 100644 index d005a4ae8639..000000000000 --- a/src/main/providers/local-pty-shell-readiness-session.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type * as pty from 'node-pty' -import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' -import { - createShellPromptReadinessProbe, - type ShellPromptReadinessProbe -} from '../shell-prompt-readiness-probe' -import { - createShellStartupOutputScanState, - drainShellStartupOutputScanState, - scanShellStartupOutput -} from '../shell-startup-output-scanner' -import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' -import { ptyCleanupCallbacks } from './local-pty-provider-state' -import { - STARTUP_COMMAND_READY_MAX_WAIT_MS, - type ShellReadySignal -} from './local-pty-shell-ready-startup-command' -import type { PtySpawnOptions } from './types' - -export type LocalPtyShellReadinessSession = { - shellReadyPromise: Promise - acceptData(rawData: string): void - setStartupCommandCleanup(cleanup: () => void): void - prepareForExit(): void -} - -export function createLocalPtyShellReadinessSession(args: { - id: string - spawn: PtySpawnOptions - plan: LocalPtyLaunchPlan - env: Record - proc: pty.IPty - startupIngress: PtyStartupIngress -}): LocalPtyShellReadinessSession { - const { id, spawn, plan, env, proc, startupIngress } = args - let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null - let shellReadyTimeout: ReturnType | null = null - let shellStartupPid: number | null = null - let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null - let shellStartupOutputScanState = plan.shellReadyLaunch?.supportsReadyMarker - ? createShellStartupOutputScanState() - : null - const shellReadyPromise = spawn.command - ? new Promise((resolve) => { - resolveShellReady = resolve - }) - : Promise.resolve({ postMarkerBytesObserved: false }) - const finishShellReady = (signal: ShellReadySignal): void => { - if (!resolveShellReady) { - return - } - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - shellPromptReadinessProbe?.dispose() - shellPromptReadinessProbe = null - const resolve = resolveShellReady - resolveShellReady = null - resolve(signal) - } - const releaseHeldShellReadyBytes = (): void => { - if (!shellStartupOutputScanState) { - return - } - const heldBytes = drainShellStartupOutputScanState(shellStartupOutputScanState) - shellStartupOutputScanState = null - if (heldBytes.length === 0) { - return - } - startupIngress.accept(heldBytes) - } - if (shellStartupOutputScanState) { - shellPromptReadinessProbe = createShellPromptReadinessProbe({ - ptyPid: proc.pid, - slavePath: readPtySlavePath(proc), - shellPath: plan.shellPath, - shellCwd: plan.effectiveCwd, - shellPathEnv: env.PATH, - getShellPid: () => shellStartupPid, - onPromptReady: () => { - console.warn( - `[pty] ${id}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.` - ) - releaseHeldShellReadyBytes() - finishShellReady({ postMarkerBytesObserved: true }) - } - }) - } - if (spawn.command) { - if (plan.shellReadyLaunch?.supportsReadyMarker) { - shellReadyTimeout = setTimeout(() => { - releaseHeldShellReadyBytes() - finishShellReady({ postMarkerBytesObserved: false }) - }, STARTUP_COMMAND_READY_MAX_WAIT_MS) - } else { - finishShellReady({ postMarkerBytesObserved: false }) - } - } - let startupCommandCleanup: (() => void) | null = null - if (spawn.command) { - ptyCleanupCallbacks.set(id, () => { - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - releaseHeldShellReadyBytes() - startupCommandCleanup?.() - startupCommandCleanup = null - resolveShellReady = null - shellPromptReadinessProbe?.dispose() - shellPromptReadinessProbe = null - }) - } - - return { - shellReadyPromise, - acceptData: (rawData) => { - let data = rawData - if (shellStartupOutputScanState && resolveShellReady) { - const scanned = scanShellStartupOutput(shellStartupOutputScanState, data) - data = scanned.output - if (scanned.shellPid) { - shellStartupPid = scanned.shellPid - } - if (scanned.ready) { - finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved }) - } - } - startupIngress.accept(data) - if (resolveShellReady && data.length > 0) { - shellPromptReadinessProbe?.notifyOutput(data) - } - }, - setStartupCommandCleanup: (cleanup) => { - startupCommandCleanup = cleanup - }, - prepareForExit: () => { - if (shellReadyTimeout) { - clearTimeout(shellReadyTimeout) - shellReadyTimeout = null - } - startupCommandCleanup?.() - shellPromptReadinessProbe?.dispose() - shellPromptReadinessProbe = null - } - } -} diff --git a/src/main/providers/local-pty-shell-ready-startup-command.test.ts b/src/main/providers/local-pty-shell-ready-startup-command.test.ts deleted file mode 100644 index da843c6e42ad..000000000000 --- a/src/main/providers/local-pty-shell-ready-startup-command.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as pty from 'node-pty' -import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready-startup-command' - -type DataCb = (data: string) => void -type ExitCb = (info: { exitCode: number }) => void - -function createMockProc(): pty.IPty & { - _emitData: (data: string) => void - _writes: string[] -} { - let onDataCbs: DataCb[] = [] - const writes: string[] = [] - const fake = { - pid: 1, - cols: 80, - rows: 24, - process: 'bash', - handleFlowControl: false, - write: (data: string) => { - writes.push(data) - }, - resize: () => {}, - clear: () => {}, - kill: () => {}, - pause: () => {}, - resume: () => {}, - onData: (cb: DataCb) => { - onDataCbs.push(cb) - return { - dispose: () => { - onDataCbs = onDataCbs.filter((c) => c !== cb) - } - } - }, - onExit: (_cb: ExitCb) => ({ dispose: () => {} }), - _emitData: (data: string) => { - for (const cb of onDataCbs.slice()) { - cb(data) - } - }, - _writes: writes - } as unknown as pty.IPty & { _emitData: (data: string) => void; _writes: string[] } - - return fake -} - -describe('writeStartupCommandWhenShellReady', () => { - let origPlatform: NodeJS.Platform - - beforeEach(() => { - vi.useFakeTimers() - origPlatform = process.platform - }) - - afterEach(() => { - vi.useRealTimers() - Object.defineProperty(process, 'platform', { value: origPlatform }) - }) - - it('appends LF on POSIX so bash/zsh submit the line', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) - const proc = createMockProc() - const ready = Promise.resolve() - writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}) - - await ready - proc._emitData('\r\nuser@host % ') - vi.advanceTimersByTime(30) - await Promise.resolve() - - expect(proc._writes).toEqual(['claude\n']) - }) - - it('appends CR on Windows so PowerShell/cmd.exe submit the line', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - const proc = createMockProc() - const ready = Promise.resolve() - writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}) - - await ready - proc._emitData('\r\nPS> ') - vi.advanceTimersByTime(30) - await Promise.resolve() - - expect(proc._writes).toEqual(['claude\r']) - }) - - it('does not re-append a submit byte if the command already ends in CR or LF', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - const proc = createMockProc() - const ready = Promise.resolve() - writeStartupCommandWhenShellReady(ready, proc, 'claude\n', () => {}) - - await ready - proc._emitData('\r\nPS> ') - vi.advanceTimersByTime(30) - await Promise.resolve() - - expect(proc._writes).toEqual(['claude\n']) - }) - - it('keeps the no-prompt fallback conservative to avoid duplicate shell echo', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) - const proc = createMockProc() - const ready = Promise.resolve() - writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {}) - - await ready - vi.advanceTimersByTime(50) - await Promise.resolve() - - expect(proc._writes).toEqual([]) - - vi.advanceTimersByTime(150) - await Promise.resolve() - - expect(proc._writes).toEqual(['codex\n']) - }) - - it('uses the short settle delay when marker scan already observed post-marker bytes', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) - const proc = createMockProc() - const ready = Promise.resolve({ postMarkerBytesObserved: true }) - writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {}) - - await ready - vi.advanceTimersByTime(29) - await Promise.resolve() - expect(proc._writes).toEqual([]) - - vi.advanceTimersByTime(1) - await Promise.resolve() - expect(proc._writes).toEqual(['codex\n']) - }) - - // Why: multiline startup commands must be bracketed-paste wrapped (ESC[200~ … ESC[201~) so shells insert them literally instead of treating each LF as Enter. - it('wraps a multiline startup command in bracketed paste when the shell supports it', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) - const proc = createMockProc() - const ready = Promise.resolve() - const command = "claude '--dangerously-skip-permissions' 'line one\nline two'" - writeStartupCommandWhenShellReady(ready, proc, command, () => {}, { - bracketedPasteSafe: true - }) - - await ready - proc._emitData('\r\nuser@host % ') - vi.advanceTimersByTime(30) - await Promise.resolve() - - expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\n`]) - }) - - it('leaves a single-line command on the raw submit path even when bracketed paste is safe', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) - const proc = createMockProc() - const ready = Promise.resolve() - writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}, { - bracketedPasteSafe: true - }) - - await ready - proc._emitData('\r\nuser@host % ') - vi.advanceTimersByTime(30) - await Promise.resolve() - - expect(proc._writes).toEqual(['claude\n']) - }) - - it('does not bracket-wrap a multiline command when the shell lacks bracketed paste', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) - const proc = createMockProc() - const ready = Promise.resolve() - const command = 'echo one\necho two' - // Why: bracketedPasteSafe defaults false, so keep the raw path to avoid echoing ESC[200~ on shells without bracketed paste. - writeStartupCommandWhenShellReady(ready, proc, command, () => {}) - - await ready - proc._emitData('\r\nuser@host % ') - vi.advanceTimersByTime(30) - await Promise.resolve() - - expect(proc._writes).toEqual([`${command}\n`]) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready-startup-command.ts b/src/main/providers/local-pty-shell-ready-startup-command.ts deleted file mode 100644 index bf7f73c19d79..000000000000 --- a/src/main/providers/local-pty-shell-ready-startup-command.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Writes a startup command into a local PTY once the shell reports readiness. - */ -import type * as pty from 'node-pty' -import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' - -export const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500 -const POST_SHELL_READY_STARTUP_COMMAND_DELAY_MS = 30 -const POST_SHELL_READY_STARTUP_COMMAND_FALLBACK_MS = 200 - -export type ShellReadySignal = { - postMarkerBytesObserved: boolean -} - -export function writeStartupCommandWhenShellReady( - readyPromise: Promise, - proc: pty.IPty, - startupCommand: string, - onExit: (cleanup: () => void) => void, - // Why: only shells with bracketed-paste active (see isBracketedPasteSafeShell) accept the wrapper; others use the raw path so ESC[200~ isn't echoed. - options: { bracketedPasteSafe?: boolean } = {} -): void { - let sent = false - let postReadyTimer: ReturnType | null = null - let postReadyDataDisposable: { dispose: () => void } | null = null - - const cleanup = (): void => { - sent = true - if (postReadyTimer !== null) { - clearTimeout(postReadyTimer) - postReadyTimer = null - } - postReadyDataDisposable?.dispose() - postReadyDataDisposable = null - } - - const flush = (): void => { - if (sent) { - return - } - sent = true - postReadyDataDisposable?.dispose() - postReadyDataDisposable = null - if (postReadyTimer !== null) { - clearTimeout(postReadyTimer) - postReadyTimer = null - } - // Why: run in the same interactive shell (not `shell -c`) so the session survives after the agent exits. - // Why CR on Windows: PSReadLine/cmd.exe submit on `\r`, not LF; POSIX treats either as Enter under ICRNL. - const submit = process.platform === 'win32' ? '\r' : '\n' - // Why: single write after the ready barrier avoids incremental-paste char drops; multiline is bracketed-paste wrapped so newlines don't submit early. - proc.write( - buildStartupCommandSubmission(startupCommand, { - submit, - bracketedPasteSafe: options.bracketedPasteSafe === true - }) - ) - } - - const schedulePostReadyFlush = (): void => { - postReadyTimer = setTimeout(flush, POST_SHELL_READY_STARTUP_COMMAND_DELAY_MS) - } - - readyPromise.then((signal) => { - if (sent) { - return - } - // Why: marker fires from precmd before the line editor takes the PTY out of ECHO; writing now double-echoes the command, so settle first. - if (signal?.postMarkerBytesObserved === true) { - schedulePostReadyFlush() - return - } - postReadyDataDisposable = proc.onData(() => { - postReadyDataDisposable?.dispose() - postReadyDataDisposable = null - if (postReadyTimer !== null) { - clearTimeout(postReadyTimer) - } - schedulePostReadyFlush() - }) - postReadyTimer = setTimeout(() => { - postReadyDataDisposable?.dispose() - postReadyDataDisposable = null - postReadyTimer = null - flush() - }, POST_SHELL_READY_STARTUP_COMMAND_FALLBACK_MS) - }) - onExit(cleanup) -} diff --git a/src/main/providers/local-pty-shell-startup-command.node-pty.test.ts b/src/main/providers/local-pty-shell-startup-command.node-pty.test.ts deleted file mode 100644 index 936e10b2626c..000000000000 --- a/src/main/providers/local-pty-shell-startup-command.node-pty.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import * as pty from 'node-pty' -import { afterEach, describe, expect, it } from 'vitest' -import { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import type { TerminalViewRgb } from '../../shared/terminal-view-attributes' -import { HeadlessEmulator } from '../daemon/headless-emulator' -import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' -import { getShellLaunchConfig } from './local-pty-shell-ready' -import { setTestUserDataPath } from './local-pty-shell-ready-test-harness' - -const SHELLS = ['bash', 'zsh', 'fish'].filter( - (shell) => process.platform !== 'win32' && spawnSync(shell, ['--version']).status === 0 -) -const STARTUP_COMMAND = `bash -c 'read -r line; printf "__STARTUP_INPUT__:%s\\n" "$line"'` -const BLACK: TerminalViewRgb = [0, 0, 0] - -function afterCommand(shell: string): string { - if (shell === 'fish') { - return `if set -q ${POSIX_SHELL_STARTUP_COMMAND_ENV}; echo __AFTER_ENV__:present; else; echo __AFTER_ENV__:missing; end; exit\n` - } - return `printf '__AFTER_ENV__:%s\\n' "\${${POSIX_SHELL_STARTUP_COMMAND_ENV}-missing}"; exit\n` -} - -describe('local POSIX shell startup-command delivery', () => { - let testHome: string | undefined - - afterEach(() => { - if (testHome) { - rmSync(testHome, { recursive: true, force: true }) - testHome = undefined - } - }) - - it.each(SHELLS)( - '%s runs the command once, forwards stdin, and keeps the shell', - async (shell) => { - testHome = mkdtempSync(join(tmpdir(), `orca-${shell}-startup-command-`)) - setTestUserDataPath(testHome) - const launch = getShellLaunchConfig( - shell, - ['overlay', 'markers', 'ready', 'identity'], - STARTUP_COMMAND - ) - expect(launch.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(STARTUP_COMMAND) - - const output = await new Promise((resolve, reject) => { - let proc!: pty.IPty - const emulator = new HeadlessEmulator({ - cols: 120, - rows: 30, - onQueryReply: (reply) => { - if (!ingress.answerLiveQueryReply(reply)) { - proc.write(reply) - } - } - }) - emulator.installViewAttributeResponder(() => ({ - foreground: [255, 255, 255], - background: BLACK, - cursor: [255, 255, 255], - ansi: Array.from({ length: 256 }, () => BLACK), - colorSchemeMode: 'dark', - cursorStyle: 'block', - cursorBlink: false - })) - proc = pty.spawn(shell, launch.args ?? [], { - cols: 120, - rows: 30, - cwd: testHome, - env: { - ...process.env, - ...launch.env, - HOME: testHome, - ORCA_ORIG_ZDOTDIR: testHome, - ORCA_ZSHENV_SOURCE_DIR: testHome, - TERM: 'xterm-256color' - } - }) - let transcript = '' - let sentInput = false - let sentAfter = false - const ingress = new PtyStartupIngress({ - ownerBackend: 'posix-pty', - write: (data) => proc.write(data), - onEmission: (emission) => { - transcript += emission.data - void emulator.write(emission.data, { forwardQueryReplies: true }) - if (!sentInput && transcript.includes(STARTUP_COMMAND)) { - sentInput = true - proc.write('hello\n') - } - if (!sentAfter && transcript.includes('__STARTUP_INPUT__:hello')) { - sentAfter = true - proc.write(afterCommand(shell)) - } - } - }) - const timeout = setTimeout(() => { - proc.kill() - emulator.dispose() - reject(new Error(`${shell} startup command timed out: ${JSON.stringify(transcript)}`)) - }, 5_000) - - proc.onData((data) => ingress.accept(data)) - proc.onExit(() => { - clearTimeout(timeout) - ingress.drainAndClose() - emulator.dispose() - resolve(transcript) - }) - }) - - expect(output.split(STARTUP_COMMAND)).toHaveLength(2) - expect(output).toContain('__STARTUP_INPUT__:hello') - expect(output).toContain('__AFTER_ENV__:missing') - } - ) -}) diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts deleted file mode 100644 index 47aaac6cef0d..000000000000 --- a/src/main/providers/local-pty-spawn-environment.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' -import { - ORCA_IMAGE_PROTOCOL_ENV, - ORCA_IMAGE_PROTOCOL_VALUE -} from '../../shared/terminal-image-protocol' -import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' -import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' -import { stripPiProcessOwnerEnv } from '../pty/pi-process-owner-env' -import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { isWindowsGitBashShellPath } from '../git-bash' -import { removeUnspecifiedPaneIdentityEnv } from './local-pty-launch-helpers' -import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' -import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import type { PtySpawnOptions } from './types' - -/** Pane ownership must be fresh even when Orca itself was launched inside an agent. */ -export function buildLocalPtySpawnEnvironment(args: { - id: string - spawn: PtySpawnOptions - getOptions: () => LocalPtyProviderOptions - plan: LocalPtyLaunchPlan -}): Record | Promise> { - const { id, spawn, getOptions, plan } = args - const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), spawn.env), - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working. - TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', - // Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them. - FORCE_HYPERLINK: '1', - [ORCA_IMAGE_PROTOCOL_ENV]: ORCA_IMAGE_PROTOCOL_VALUE - } satisfies Record - removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env) - stripPiProcessOwnerEnv(spawnEnv) - removeAppImageRuntimeEnv(spawnEnv) - removeInheritedNoColor(spawnEnv) - for (const key of spawn.envToDelete ?? []) { - delete spawnEnv[key] - } - if (spawn.env?.TERM) { - spawnEnv.TERM = spawn.env.TERM - } - - spawnEnv.LANG ??= 'en_US.UTF-8' - - // Why: on Windows LANG doesn't set the console code page; PYTHONUTF8=1 forces Python UTF-8 stdio to avoid garbled CJK. - if (process.platform === 'win32') { - spawnEnv.PYTHONUTF8 ??= '1' - if (isWindowsGitBashShellPath(plan.shellPath)) { - // Why: Git for Windows login files otherwise cd to $HOME, ignoring node-pty's cwd for repo-scoped terminals. - spawnEnv.CHERE_INVOKING ??= '1' - } - } - - if (!getOptions().buildSpawnEnv) { - return spawnEnv - } - return getOptions().buildSpawnEnv!(id, spawnEnv, { - explicitEnv: spawn.env ?? {}, - command: spawn.command, - launchAgent: spawn.launchAgent, - codexHomePathOverride: spawn.codexHomePathOverride, - cwd: plan.cwd, - shellPath: plan.shellPath, - isWsl: plan.isWslShell, - wslDistro: plan.launchWslDistro - }) -} - -/** App-level env builders can reintroduce deleted keys; enforce isolation after they finish. */ -export function enforceLocalPtySpawnEnvironmentOverrides( - spawn: PtySpawnOptions, - finalEnv: Record -): void { - stripPiProcessOwnerEnv(finalEnv) - for (const key of spawn.envToDelete ?? []) { - delete finalEnv[key] - } - if (spawn.env?.TERM) { - finalEnv.TERM = spawn.env.TERM - } -} diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts deleted file mode 100644 index d73d230f68b5..000000000000 --- a/src/main/providers/local-pty-spawn-state.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { PtySpawnResult } from './types' -import { - pendingLocalPtySpawns, - ptyIncarnations, - ptyProcesses, - ptyWslDistroById, - type PendingLocalPtySpawn -} from './local-pty-provider-state' - -const spawnReservations = new Map>() - -/** A Windows shell receipt can arrive after another request reaches the same native spawn. */ -export async function reserveLocalPtySpawn(id: string, operation: () => Promise): Promise { - const previous = spawnReservations.get(id) - const pending = previous ? previous.catch(() => {}).then(operation) : operation() - spawnReservations.set(id, pending) - try { - return await pending - } finally { - if (spawnReservations.get(id) === pending) { - spawnReservations.delete(id) - } - } -} - -/** Keep shutdown visible between awaits until the native process is registered. */ -export async function runCancelableLocalPtySpawn( - id: string, - operation: (throwIfCanceled: () => void, signal: AbortSignal) => Promise -): Promise { - const cancellation = new AbortController() - const pendingSpawn: PendingLocalPtySpawn = { cancellation } - const pending = pendingLocalPtySpawns.get(id) ?? new Set() - pending.add(pendingSpawn) - pendingLocalPtySpawns.set(id, pending) - try { - return await operation(() => cancellation.signal.throwIfAborted(), cancellation.signal) - } finally { - pending.delete(pendingSpawn) - if (pending.size === 0) { - pendingLocalPtySpawns.delete(id) - } - } -} - -export function cancelPendingLocalPtySpawns(id: string): void { - const pending = pendingLocalPtySpawns.get(id) - if (!pending) { - return - } - for (const pendingSpawn of pending) { - pendingSpawn.cancellation.abort(new Error(`PTY spawn canceled: ${id}`)) - } -} - -export function cancelAllPendingLocalPtySpawns(): void { - for (const id of pendingLocalPtySpawns.keys()) { - cancelPendingLocalPtySpawns(id) - } -} - -export function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResult | null { - const existing = ptyProcesses.get(id) - if (!existing) { - return null - } - let resized = false - try { - existing.resize(cols, rows) - resized = true - } catch { - /* Existing PTY may reject resize during teardown; still return the live handle. */ - } - return { - id, - ...(ptyIncarnations.has(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), - pid: existing.pid, - ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), - isReattach: true, - // Why: unlike daemon/relay attach, this one really moved the live PTY to the caller's grid. - ...(resized ? { attachedGrid: { cols, rows } } : {}) - } -} diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts deleted file mode 100644 index cc3c39979a87..000000000000 --- a/src/main/providers/local-pty-spawn.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { win32 as pathWin32 } from 'node:path' -import { SessionNotFoundError } from '../daemon/daemon-errors' -import { prepareMacosTccLoginShell } from './macos-tcc-login-shell' -import { finalizeLocalPtySpawnEnvironment } from './local-pty-finalize-environment' -import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers' -import { createLocalPtyLaunchPlan, DeferredLocalPtyLaunchPlan } from './local-pty-launch-plan' -import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import { allocatePtyId, ptyShutdownOperations } from './local-pty-provider-state' -import { activateLocalPtySession } from './local-pty-session-activation' -import { - buildLocalPtySpawnEnvironment, - enforceLocalPtySpawnEnvironmentOverrides -} from './local-pty-spawn-environment' -import { - runCancelableLocalPtySpawn, - reattachLocalPty, - reserveLocalPtySpawn -} from './local-pty-spawn-state' -import { loadLocalPtyRuntimeSpawn } from './local-pty-runtime-spawn' -import { destroyPtyProcess } from './local-pty-termination' -import { updateHistoryEnvForFallback } from '../terminal-history' -import type { PtySpawnOptions, PtySpawnResult } from './types' - -export async function spawnLocalPty( - args: PtySpawnOptions, - getOptions: () => LocalPtyProviderOptions -): Promise { - const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true) - if (reattachId) { - const pendingShutdown = ptyShutdownOperations.get(reattachId) - if (pendingShutdown) { - await pendingShutdown.promise - } - const existing = reattachLocalPty(reattachId, args.cols, args.rows) - if (existing) { - return existing - } - } - if (args.attachOnly) { - throw new SessionNotFoundError(args.sessionId ?? '') - } - const id = allocatePtyId(reattachId ?? undefined) - return runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { - const incarnationId = randomUUID() - let plan = createLocalPtyLaunchPlan(args, getOptions) - if (plan instanceof DeferredLocalPtyLaunchPlan) { - const available = await plan.availability - throwIfCanceled() - plan = plan.finish(available) - } - throwIfCanceled() - const envResult = buildLocalPtySpawnEnvironment({ - id, - spawn: args, - getOptions, - plan - }) - const finalEnv = envResult instanceof Promise ? await envResult : envResult - throwIfCanceled() - enforceLocalPtySpawnEnvironmentOverrides(args, finalEnv) - const historyResult = finalizeLocalPtySpawnEnvironment({ - spawn: args, - getOptions, - plan, - env: finalEnv - }) - - const fallbackHistory = historyResult?.historyDir ? historyResult : undefined - const [spawn] = await Promise.all([loadLocalPtyRuntimeSpawn(), prepareMacosTccLoginShell()]) - return reserveLocalPtySpawn(id, async () => { - const checkCanceled = (): void => { - throwIfCanceled() - if (args.signal?.aborted) { - throw new Error('client_disconnected') - } - } - checkCanceled() - // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. - const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null - if (concurrentWinner) { - return concurrentWinner - } - const pendingSpawn = spawn({ - shellPath: plan.shellPath, - shellArgs: plan.shellArgs, - cols: args.cols, - rows: args.rows, - cwd: plan.effectiveCwd, - env: finalEnv, - termName: finalEnv.TERM, - signal: args.signal ? AbortSignal.any([args.signal, cancellation]) : cancellation, - getShellReadyConfig: plan.getFallbackShellReadyConfig, - launchEnvKeys: plan.primaryLaunchEnvKeys, - // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). - onBeforeFallbackSpawn: fallbackHistory - ? (env, fallbackShell) => updateHistoryEnvForFallback(env, fallbackShell, fallbackHistory) - : undefined, - windowsFallbackAttempts: plan.windowsFallbackAttempts - }) - const spawnResult = pendingSpawn instanceof Promise ? await pendingSpawn : pendingSpawn - try { - checkCanceled() - } catch (error) { - try { - spawnResult.process.kill('SIGKILL') - } finally { - destroyPtyProcess(spawnResult.process) - } - throw error - } - args.onPtySpawnCommitted?.() - plan.shellPath = spawnResult.shellPath - // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. - if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { - plan.startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs - } - if (args.command && plan.getFallbackShellReadyConfig) { - plan.shellReadyLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) - } - - if (process.platform !== 'win32') { - finalEnv.SHELL = plan.shellPath - } - - const proc = spawnResult.process - const spawnedShellIsWsl = - process.platform === 'win32' && - pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' - const spawnedWslDistro = spawnedShellIsWsl - ? (plan.launchWslDistro ?? undefined) - : process.platform === 'win32' - ? null - : undefined - return activateLocalPtySession({ - id, - incarnationId, - spawn: args, - getOptions, - plan, - env: finalEnv, - proc, - reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false, - spawnedWslDistro - }) - }) - }) -} diff --git a/src/main/providers/local-pty-termination.ts b/src/main/providers/local-pty-termination.ts deleted file mode 100644 index 426a7aad346e..000000000000 --- a/src/main/providers/local-pty-termination.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type * as pty from 'node-pty' -import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' -import { killWithDescendantSweep } from '../pty-descendant-termination' -import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' -import { terminatePtyJob } from '../windows/windows-pty-job' -import { - clearLocalPtyForceKillTimer, - clearPtyState, - disposePtyExitListener, - disposePtyListeners, - ptyAgentSessionIds, - ptyForceKillTimers, - ptyPhysicalExits, - ptyProcesses, - ptyShutdownOperations, - ptyTerminationMode, - ptyLoadGeneration, - runPtyCleanup, - type PtyShutdownOperation -} from './local-pty-provider-state' -import { - cancelAllPendingLocalPtySpawns, - cancelPendingLocalPtySpawns -} from './local-pty-spawn-state' - -export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 -export const LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS = 5_000 -export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250 - -export function createPtyPhysicalExit(id: string): void { - ptyPhysicalExits.set(id, new PhysicalExitTracker()) -} - -function waitForPtyPhysicalExit(id: string, physicalExit?: PhysicalExitTracker): Promise { - if (!physicalExit) { - return Promise.reject(new Error(`PTY "${id}" exit tracking unavailable`)) - } - return physicalExit.waitForExit( - LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS, - () => new Error(`Timed out waiting for PTY process exit: ${id}`) - ) -} - -function killLocalPtyProcess(proc: pty.IPty, immediate: boolean): void { - if (process.platform === 'win32') { - proc.kill() - return - } - if (!immediate) { - proc.kill('SIGTERM') - return - } - forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) -} - -function armLocalPtyForceKill( - id: string, - proc: pty.IPty, - options: { delayMs?: number; attemptsRemaining?: number } = {} -): void { - if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { - return - } - const attemptsRemaining = options.attemptsRemaining ?? 2 - const timer = setTimeout(() => { - ptyForceKillTimers.delete(id) - if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { - return - } - ptyTerminationMode.set(id, 'force') - try { - killLocalPtyProcess(proc, true) - } catch (error) { - ptyTerminationMode.set(id, 'graceful') - console.error('[pty] failed to force-kill PTY after graceful deadline', { id, error }) - // Why: a transient native rejection must not consume the only SIGKILL owner while shutdown still awaits physical exit. - if (attemptsRemaining > 1) { - armLocalPtyForceKill(id, proc, { - delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, - attemptsRemaining: attemptsRemaining - 1 - }) - } - } - }, options.delayMs ?? LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) - timer.unref?.() - ptyForceKillTimers.set(id, timer) -} - -/** - * Disposes the native PTY handle while avoiding recycled-pid signals on POSIX. - */ -export function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void { - // Why: neutralize proc.kill before destroy(), whose close-listener SIGHUPs a possibly-recycled POSIX pid; destroy() frees the ptmx fd (docs/fix-pty-fd-leak.md); on Windows destroy() is itself kill(). - if (process.platform === 'win32' && options.alreadyKilled) { - return - } - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - try { - ;(proc as unknown as { destroy?: () => void }).destroy?.() - } catch { - /* swallow — already torn down */ - } -} - -/** - * Requests local PTY termination while retaining physical-exit ownership. - */ -function requestPtyTermination(id: string, proc: pty.IPty): void { - runPtyCleanup(id) - disposePtyListeners(id) - const previousMode = ptyTerminationMode.get(id) - // Why: cleanup neutralizes proc.kill below, so escalate an outstanding graceful request before its deadline is disabled. - if (previousMode !== 'force') { - clearLocalPtyForceKillTimer(id) - ptyTerminationMode.set(id, 'force') - try { - killLocalPtyProcess(proc, true) - } catch { - if (previousMode === 'graceful') { - ptyTerminationMode.set(id, previousMode) - armLocalPtyForceKill(id, proc, { - delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, - attemptsRemaining: 1 - }) - } else { - ptyTerminationMode.delete(id) - } - /* Process may already be dead. */ - return - } - } - // Why: shutdown and orphan cleanup can race; keep onExit + tracker installed until the OS proves the child was reaped. - destroyPtyProcess(proc, { alreadyKilled: true }) -} - -function requestTrackedPtyShutdown(id: string, proc: pty.IPty, immediate: boolean): void { - const previousMode = ptyTerminationMode.get(id) - // Why: ConPTY has no graceful signal — its first bare kill closes the pseudoconsole, so treat it as a final force request. - const requestedMode = immediate || process.platform === 'win32' ? 'force' : 'graceful' - if (!previousMode || (requestedMode === 'force' && previousMode !== 'force')) { - ptyTerminationMode.set(id, requestedMode) - try { - killLocalPtyProcess(proc, immediate) - if (requestedMode === 'graceful') { - armLocalPtyForceKill(id, proc) - } else { - clearLocalPtyForceKillTimer(id) - } - } catch (error) { - if (previousMode) { - ptyTerminationMode.set(id, previousMode) - } else { - ptyTerminationMode.delete(id) - } - throw error - } - } -} - -async function shutdownTrackedPty( - id: string, - proc: pty.IPty, - operation: PtyShutdownOperation -): Promise { - const physicalExit = ptyPhysicalExits.get(id) - const signalRoot = (): void => { - // Why: natural exit can race the sweep — never signal after this PTY loses ownership. - if (ptyProcesses.get(id) !== proc) { - return - } - // Cancel startup delivery now, but keep the exit listener and ownership maps until node-pty reports physical exit. - runPtyCleanup(id) - operation.rootSignalled = true - requestTrackedPtyShutdown(id, proc, operation.immediate) - } - if (ptyAgentSessionIds.has(id) || operation.immediate) { - // Typed agents also detach tool process groups; immediate close must snapshot before root exit. - await killWithDescendantSweep(proc.pid, signalRoot, { - ownsRoot: () => ptyProcesses.get(id) === proc, - terminateOwnedTree: () => terminatePtyJob(proc) - }) - } else { - signalRoot() - } - await waitForPtyPhysicalExit(id, physicalExit) -} - -export async function shutdownLocalPty( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean } -): Promise { - cancelPendingLocalPtySpawns(id) - const pending = ptyShutdownOperations.get(id) - if (pending) { - if (opts.immediate === true) { - pending.immediate = true - if (pending.rootSignalled && ptyProcesses.get(id) === pending.proc) { - requestTrackedPtyShutdown(id, pending.proc, true) - } - } - await pending.promise - return - } - const proc = ptyProcesses.get(id) - if (!proc) { - return - } - const entry: PtyShutdownOperation = { - promise: Promise.resolve(), - immediate: opts.immediate === true, - rootSignalled: false, - proc - } - entry.promise = shutdownTrackedPty(id, proc, entry) - ptyShutdownOperations.set(id, entry) - try { - await entry.promise - } finally { - if (ptyShutdownOperations.get(id) === entry) { - ptyShutdownOperations.delete(id) - } - } -} - -export function killOrphanedLocalPtys(currentGeneration: number): { id: string }[] { - const killed: { id: string }[] = [] - for (const [id, proc] of ptyProcesses) { - if ((ptyLoadGeneration.get(id) ?? -1) < currentGeneration) { - requestPtyTermination(id, proc) - killed.push({ id }) - } - } - return killed -} - -export function killAllLocalPtys(): void { - cancelAllPendingLocalPtySpawns() - for (const [id, proc] of ptyProcesses) { - runPtyCleanup(id) - disposePtyListeners(id) - disposePtyExitListener(id) - if (!(process.platform === 'win32' && ptyTerminationMode.has(id))) { - try { - if (ptyAgentSessionIds.has(id) && process.platform !== 'win32') { - // App quit is synchronous, so sweep attached agent process groups before - // releasing node-pty; otherwise OMP workers can outlive the foreground PTY. - forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) - } else { - proc.kill() - } - } catch { - /* Process may already be dead. */ - } - } - // Why: app quit can't retain NAPI callbacks into FreeEnvironment; process exit is the final handle boundary here. - destroyPtyProcess(proc, { alreadyKilled: true }) - // Why: app quit replaces node-pty's onExit as final owner; overlapping shutdown waiters must join this boundary. - ptyPhysicalExits.get(id)?.markExited() - clearPtyState(id) - } -} diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts deleted file mode 100644 index a71537ba8b29..000000000000 --- a/src/main/providers/local-pty-utils-windows-fallback.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as pty from 'node-pty' -import { spawnShellWithFallback, type WindowsShellSpawnAttempt } from './local-pty-utils' - -function setPlatform(platform: NodeJS.Platform): () => void { - const original = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - return () => Object.defineProperty(process, 'platform', { configurable: true, value: original }) -} - -let restorePlatform: (() => void) | null = null -afterEach(() => { - restorePlatform?.() - restorePlatform = null - vi.restoreAllMocks() -}) - -const PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' -const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -const CMD = 'C:\\Windows\\System32\\cmd.exe' - -function makeFakePty(): pty.IPty { - return { pid: 1234 } as unknown as pty.IPty -} - -function makeAttempt( - shellPath: string, - overrides: Partial = {} -): WindowsShellSpawnAttempt { - return { - shellPath, - shellArgs: ['-NoLogo'], - effectiveCwd: 'C:\\repo', - validationCwd: 'C:\\repo', - startupCommandDeliveredInShellArgs: false, - ...overrides - } -} - -// error code 5 == ERROR_ACCESS_DENIED from CreateProcessW inside ConPTY when a -// bare/alias pwsh.exe is handed to node-pty. -const ACCESS_DENIED_5 = 'Cannot create process, error code: 5' - -describe('spawnShellWithFallback on Windows', () => { - it('repro: recovers when the primary PowerShell spawn fails with error code 5', () => { - restorePlatform = setPlatform('win32') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const attempts: WindowsShellSpawnAttempt[] = [ - makeAttempt(PWSH7), - makeAttempt(WINDOWS_POWERSHELL), - makeAttempt(CMD, { shellArgs: ['/K', 'chcp 65001 > nul'] }) - ] - - const ptySpawn = vi.fn((shellPath: string) => { - if (shellPath === PWSH7) { - throw new Error(ACCESS_DENIED_5) - } - return makeFakePty() - }) as unknown as typeof pty.spawn - - const result = spawnShellWithFallback({ - shellPath: PWSH7, - shellArgs: attempts[0].shellArgs, - cols: 80, - rows: 24, - cwd: 'C:\\repo', - env: {}, - ptySpawn, - windowsFallbackAttempts: attempts - }) - - // Falls back to the next real absolute executable instead of throwing. - expect(result.shellPath).toBe(WINDOWS_POWERSHELL) - expect(ptySpawn).toHaveBeenNthCalledWith( - 1, - PWSH7, - attempts[0].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) - ) - expect(ptySpawn).toHaveBeenNthCalledWith( - 2, - WINDOWS_POWERSHELL, - attempts[1].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) - ) - }) - - it('falls all the way through to cmd.exe and surfaces its argv-delivery flag', () => { - restorePlatform = setPlatform('win32') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const attempts: WindowsShellSpawnAttempt[] = [ - makeAttempt(PWSH7), - makeAttempt(WINDOWS_POWERSHELL), - makeAttempt(CMD, { - shellArgs: ['/K', 'chcp 65001 > nul & npm start'], - startupCommandDeliveredInShellArgs: true - }) - ] - - const ptySpawn = vi.fn((shellPath: string) => { - if (shellPath === CMD) { - return makeFakePty() - } - throw new Error(ACCESS_DENIED_5) - }) as unknown as typeof pty.spawn - - const result = spawnShellWithFallback({ - shellPath: PWSH7, - shellArgs: attempts[0].shellArgs, - cols: 80, - rows: 24, - cwd: 'C:\\repo', - env: {}, - ptySpawn, - windowsFallbackAttempts: attempts - }) - - expect(result.shellPath).toBe(CMD) - expect(result.startupCommandDeliveredInShellArgs).toBe(true) - }) - - it('throws a descriptive error when every Windows fallback fails', () => { - restorePlatform = setPlatform('win32') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const attempts: WindowsShellSpawnAttempt[] = [ - makeAttempt(PWSH7), - makeAttempt(WINDOWS_POWERSHELL), - makeAttempt(CMD) - ] - const ptySpawn = vi.fn(() => { - throw new Error(ACCESS_DENIED_5) - }) as unknown as typeof pty.spawn - const previousVersion = process.env.ORCA_APP_VERSION - process.env.ORCA_APP_VERSION = '1.4.178-test' - - try { - expect(() => - spawnShellWithFallback({ - shellPath: PWSH7, - shellArgs: attempts[0].shellArgs, - cols: 80, - rows: 24, - cwd: 'C:\\repo', - env: {}, - ptySpawn, - windowsFallbackAttempts: attempts - }) - ).toThrow(/Failed to spawn shell.*orca: 1\.4\.178-test/) - } finally { - if (previousVersion === undefined) { - delete process.env.ORCA_APP_VERSION - } else { - process.env.ORCA_APP_VERSION = previousVersion - } - } - }) -}) diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts deleted file mode 100644 index 032e85722633..000000000000 --- a/src/main/providers/local-pty-utils.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as fs from 'node:fs' -import type { Stats } from 'node:fs' - -const { existsSyncMock, statSyncMock, accessSyncMock, wslUncDirectoryExistsMock, wrapSpawnMock } = - vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - statSyncMock: vi.fn(), - accessSyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), - wrapSpawnMock: vi.fn() - })) - -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - existsSync: existsSyncMock, - statSync: statSyncMock, - accessSync: accessSyncMock - } -}) - -function dirStats(isDirectory: boolean): Stats { - return { isDirectory: () => isDirectory } as Stats -} - -vi.mock('../wsl', () => ({ - wslUncDirectoryExists: wslUncDirectoryExistsMock -})) - -vi.mock('./macos-tcc-login-shell', () => ({ - wrapShellSpawnForMacosTccAttribution: wrapSpawnMock, - hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login' -})) - -import { - resolveUnixShellPath, - spawnShellWithFallback, - validateWorkingDirectory -} from './local-pty-utils' - -const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' -const NATIVE_DIR = 'C:\\Users\\jin\\repo' - -describe('validateWorkingDirectory', () => { - beforeEach(() => { - existsSyncMock.mockReset() - statSyncMock.mockReset() - wslUncDirectoryExistsMock.mockReset() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', () => { - // Why: the Win32 9P stat is the exact path that falsely reported ENOENT and - // broke opening WSL worktrees. The distro answer must win. - wslUncDirectoryExistsMock.mockReturnValue(true) - existsSyncMock.mockReturnValue(false) - - expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() - expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) - // The fs fallback must not run when the distro confirmed existence. - expect(existsSyncMock).not.toHaveBeenCalled() - }) - - it('rejects a WSL UNC worktree that does not exist inside the distro', () => { - wslUncDirectoryExistsMock.mockReturnValue(false) - - expect(() => validateWorkingDirectory(WSL_UNC_DIR)).toThrow(/does not exist/) - expect(existsSyncMock).not.toHaveBeenCalled() - }) - - it('falls back to the fs check when the distro answer is inconclusive', () => { - wslUncDirectoryExistsMock.mockReturnValue(null) - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue(dirStats(true)) - - expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() - expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) - expect(existsSyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) - }) - - it('validates native Windows paths via fs without consulting the distro', () => { - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue(dirStats(true)) - - expect(() => validateWorkingDirectory(NATIVE_DIR)).not.toThrow() - expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() - expect(existsSyncMock).toHaveBeenCalledWith(NATIVE_DIR) - }) - - it('rejects a missing native Windows path', () => { - existsSyncMock.mockReturnValue(false) - const previousVersion = process.env.ORCA_APP_VERSION - process.env.ORCA_APP_VERSION = '1.4.178-test' - - try { - expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow( - /does not exist.*orca: 1\.4\.178-test/ - ) - expect(wslUncDirectoryExistsMock).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', () => { - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue(dirStats(false)) - - expect(() => validateWorkingDirectory(NATIVE_DIR)).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']) - }) -}) - -describe('spawnShellWithFallback macOS TCC login wrapping', () => { - let origPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue(dirStats(true)) - accessSyncMock.mockReturnValue(undefined) - wrapSpawnMock.mockImplementation((file: string, args: string[]) => ({ - file: '/wrapped/login', - args: ['wrapped', file, ...args] - })) - }) - - afterEach(() => { - if (origPlatform) { - Object.defineProperty(process, 'platform', origPlatform) - } - 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 }) - ) - // The reported shellPath stays the real shell so identity/name logic is intact. - 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 - }) - - // First fallback candidate after /bin/zsh is /bin/bash, also login-wrapped. - // The wrapper must see the fallback-corrected env so SHELL survives login(1). - 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', () => { - // Why: nothing in an unwrapped bash pane consumes the feature channel, so a - // leftover ZDOTDIR would point a nested zsh at Orca's wrapper and turn on - // features Orca never selected for it. - 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, - launchEnvKeys: Object.keys(zshLaunchEnv), - 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('drops the first fallback’s launch env when a second fallback takes over', () => { - // Why: the keys to scrub are the ones the LAST attempt wrote. Computed once - // from the primary, a wrapped first fallback leaks its own ZDOTDIR and - // feature channel into the shell that finally starts. - const bashLaunchEnv = { ORCA_SHELL_FEATURES: 'markers', BASH_ENV: '/userdata/bash/rcfile' } - const env: Record = { HOME: '/home/jin', ZDOTDIR: '/userdata/shell-ready/zsh' } - const ptySpawn = vi - .fn() - .mockImplementationOnce(() => { - throw new Error('primary boom') - }) - .mockImplementationOnce(() => { - throw new Error('first fallback boom') - }) - .mockReturnValue({ pid: 4 }) - - spawnShellWithFallback({ - shellPath: '/bin/zsh', - shellArgs: ['-l'], - cols: 80, - rows: 24, - cwd: '/work', - env, - ptySpawn: ptySpawn as never, - launchEnvKeys: ['ZDOTDIR'], - getShellReadyConfig: (shell) => - shell === '/bin/bash' ? { args: ['--rcfile', '/rc'], env: bashLaunchEnv } : null - }) - - expect(env.ORCA_SHELL_FEATURES).toBeUndefined() - expect(env.BASH_ENV).toBeUndefined() - expect(env.ZDOTDIR).toBeUndefined() - expect(env.HOME).toBe('/home/jin') - }) -}) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts deleted file mode 100644 index 735393449f25..000000000000 --- a/src/main/providers/local-pty-utils.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { basename, isAbsolute, join } from 'node:path' -import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' -import type * as pty from 'node-pty' -import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' -import { - hostReportsChildExitStatus, - wrapShellSpawnForMacosTccAttribution -} from './macos-tcc-login-shell' -import { formatLocalPtyEnvironmentDiag } from './working-directory-validation' - -export { - formatLocalPtyEnvironmentDiag, - validateWorkingDirectory, - validateWorkingDirectoryAsync, - WorkingDirectoryValidationAbortedError -} from './working-directory-validation' - -let didEnsureSpawnHelperExecutable = false - -const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const - -function toUnpackedAsarPath(candidate: string): string { - return candidate - .replace(/app\.asar([/\\])/, 'app.asar.unpacked$1') - .replace(/node_modules\.asar([/\\])/, 'node_modules.asar.unpacked$1') -} - -export function getNodePtySpawnHelperCandidates(): string[] { - const unixTerminalPath = require.resolve('node-pty/lib/unixTerminal.js') - const packageRoot = - basename(unixTerminalPath) === 'unixTerminal.js' - ? unixTerminalPath.replace(/[/\\]lib[/\\]unixTerminal\.js$/, '') - : unixTerminalPath - - return [ - join(packageRoot, 'build', 'Release', 'spawn-helper'), - join(packageRoot, 'build', 'Debug', 'spawn-helper'), - join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper') - ].map(toUnpackedAsarPath) -} - -/** - * Validate that a shell binary exists and is executable. - * Returns an error message string if invalid, null if valid. - */ -export function getShellValidationError(shellPath: string): string | null { - if (!existsSync(shellPath)) { - return ( - `Shell "${shellPath}" does not exist. ` + - `Set a valid SHELL environment variable or install zsh/bash.` - ) - } - try { - accessSync(shellPath, fsConstants.X_OK) - } catch { - return `Shell "${shellPath}" is not executable. Check file permissions.` - } - return null -} - -/** - * Resolves an absolute Unix shell before node-pty forks. Bare commands and - * relative paths stay untouched so execvp can resolve them against PATH or cwd. - */ -export function resolveUnixShellPath(shellPath: string): string { - if (!isAbsolute(shellPath)) { - return shellPath - } - const candidates = [ - shellPath, - ...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) - ] - const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null) - if (resolved) { - return resolved - } - throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`) -} - -/** - * Ensure the node-pty spawn-helper binary has the executable bit set. - * - * Why: when Electron packages the app via asar, the native spawn-helper - * binary may lose its +x permission. This function detects and repairs - * that so pty.spawn() does not fail with EACCES on first launch. - * macOS only — no other platform builds or execs the helper. - */ -export function ensureNodePtySpawnHelperExecutable(): void { - if (didEnsureSpawnHelperExecutable || !usesNodePtySpawnHelper(process.platform)) { - return - } - didEnsureSpawnHelperExecutable = true - - try { - for (const candidate of getNodePtySpawnHelperCandidates()) { - if (!existsSync(candidate)) { - continue - } - const mode = statSync(candidate).mode - if ((mode & 0o111) !== 0) { - return - } - chmodSync(candidate, mode | 0o755) - return - } - } catch (error) { - console.warn( - `[pty] Failed to ensure node-pty spawn-helper is executable: ${error instanceof Error ? error.message : String(error)}` - ) - } -} - -/** A pre-resolved Windows shell attempt: an absolute executable plus the launch - * args + cwd computed for it. Used to walk the PowerShell -> Windows PowerShell - * -> cmd.exe fallback chain when ConPTY rejects the primary shell. */ -export type WindowsShellSpawnAttempt = { - shellPath: string - shellArgs: string[] - effectiveCwd: string - validationCwd: string - startupCommandDeliveredInShellArgs: boolean -} - -export type ShellSpawnParams = { - shellPath: string - shellArgs: string[] - termName?: string - cols: number - rows: number - cwd: string - env: Record - ptySpawn: typeof pty.spawn - getShellReadyConfig?: ( - shell: string - ) => { args: string[] | null; env: Record } | null - /** Env keys the primary shell's launch config wrote into `env`. Passed in - * rather than re-derived: asking for the config again re-runs wrapper - * generation just to read back its key names. */ - launchEnvKeys?: readonly string[] - /** Called before each fallback shell spawn so callers can update env vars - * (e.g. HISTFILE) that depend on which shell is about to run. */ - onBeforeFallbackSpawn?: (env: Record, fallbackShell: string) => void - /** Windows-only ordered fallback chain (PowerShell -> Windows PowerShell -> - * cmd.exe). When the first attempt (which must match shellPath/shellArgs) - * fails to spawn, the next real absolute executable is tried with its own - * recomputed args/cwd. */ - windowsFallbackAttempts?: WindowsShellSpawnAttempt[] -} - -export type ShellSpawnResult = { - process: pty.IPty - shellPath: string - /** False when a wrapper owns the reported status, so no exit code or signal - * from this process describes the shell (STA-4536). */ - reportsChildExitStatus?: boolean - /** True when the winning shell's startup command was already embedded in its - * argv, so callers must not re-deliver it through stdin. Only set when a - * Windows fallback attempt other than the primary was used. */ - startupCommandDeliveredInShellArgs?: boolean -} - -/** - * Walk the Windows PowerShell -> Windows PowerShell -> cmd.exe fallback chain. - * - * Why: ConPTY's CreateProcessW rejects a Store App Execution Alias stub with - * ERROR_ACCESS_DENIED (error code 5). The chain entries are real absolute - * executables with per-shell args, so when the primary fails we retry with the - * next safe shell instead of leaving the user with no terminal. - */ -// Why: match the daemon spawn path (pty-subprocess.ts) — the bundled ConPTY -// has the modern wrap-marker behavior xterm expects; legacy system ConPTY can -// corrupt full-width TUI rows in scrollback. Without this, degraded-mode and -// fresh-local spawns silently behave differently from daemon terminals. -function windowsConptyDllOptions(): { useConptyDll: true } | Record { - return process.platform === 'win32' ? { useConptyDll: true } : {} -} - -function spawnWindowsFallbackChain( - params: ShellSpawnParams, - primaryError: string -): ShellSpawnResult | null { - const { termName = 'xterm-256color', cols, rows, env, ptySpawn } = params - const attempts = params.windowsFallbackAttempts ?? [] - // Skip the first entry: it is the primary that already failed above. - for (const attempt of attempts.slice(1)) { - try { - const proc = ptySpawn(attempt.shellPath, attempt.shellArgs, { - name: termName, - cols, - rows, - cwd: attempt.effectiveCwd, - env, - ...windowsConptyDllOptions() - }) - console.warn( - `[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"` - ) - return { - process: proc, - shellPath: attempt.shellPath, - startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs - } - } catch { - // This fallback shell also failed -- try the next link in the chain. - } - } - return null -} - -/** - * Attempt to spawn a PTY shell. If the primary shell fails, try fallback shells - * (Unix: zsh/bash/sh; Windows: the PowerShell -> cmd.exe chain) before giving up. - */ -export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResult { - const { - shellPath, - shellArgs, - termName = 'xterm-256color', - cols, - rows, - cwd, - env, - ptySpawn, - getShellReadyConfig, - onBeforeFallbackSpawn - } = params - let primaryError: string | null = null - - if (process.platform !== 'win32') { - primaryError = getShellValidationError(shellPath) - } - - if (!primaryError) { - try { - const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, env) - return { - process: ptySpawn(wrapped.file, wrapped.args, { - name: termName, - cols, - rows, - cwd, - env, - ...windowsConptyDllOptions() - }), - shellPath, - reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file) - } - } catch (err) { - primaryError = err instanceof Error ? err.message : String(err) - } - } - - if (process.platform === 'win32') { - const fallback = spawnWindowsFallbackChain(params, primaryError ?? 'unknown error') - if (fallback) { - return fallback - } - } - - // Try fallback shells on Unix - if (process.platform !== 'win32') { - const fallbackShells = UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) - // Why: the previous shell's launch keys (its wrapper ZDOTDIR and the feature - // channel) mean nothing to a different shell. An unwrapped fallback writes - // none of them back, so they would stay exported to the pane and to every - // child — including a nested zsh that would then load Orca's wrapper. Tracked - // per attempt, not once: the second fallback must not inherit the first's. - let staleLaunchEnvKeys: readonly string[] = params.launchEnvKeys ?? [] - for (const fallback of fallbackShells) { - if (getShellValidationError(fallback)) { - continue - } - try { - const fallbackReady = getShellReadyConfig?.(fallback) - env.SHELL = fallback - onBeforeFallbackSpawn?.(env, fallback) - for (const key of staleLaunchEnvKeys) { - delete env[key] - } - Object.assign(env, fallbackReady?.env ?? {}) - staleLaunchEnvKeys = Object.keys(fallbackReady?.env ?? {}) - const wrapped = wrapShellSpawnForMacosTccAttribution( - fallback, - fallbackReady?.args ?? ['-l'], - env - ) - const proc = ptySpawn(wrapped.file, wrapped.args, { - name: termName, - cols, - rows, - cwd, - env - }) - console.warn( - `[pty] Primary shell "${shellPath}" failed (${primaryError ?? 'unknown error'}), fell back to "${fallback}"` - ) - return { - process: proc, - shellPath: fallback, - reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file) - } - } catch { - // Fallback also failed -- try next. - } - } - } - - const diag = formatLocalPtyEnvironmentDiag({ shell: shellPath, cwd }) - throw new Error( - `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + - `If this persists, please file an issue.` - ) -} diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts deleted file mode 100644 index f7967d710f78..000000000000 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { win32 as pathWin32 } from 'node:path' -import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { addWslEnvKeys } from '../wsl-env' -import { parseWslPath } from '../wsl' -import { isWindowsGitBashShellPath } from '../git-bash' -import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' -import { - ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, - resolveWindowsShellLaunchArgs -} from './windows-shell-args' -import type { PtySpawnOptions } from './types' - -export function finalizeWindowsLocalPtySpawnEnvironment(args: { - spawn: PtySpawnOptions - plan: LocalPtyLaunchPlan - env: Record -}): void { - const { spawn, plan, env } = args - const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null - if (pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe') { - if (codexHomeWslInfo) { - if (plan.launchWslDistro && plan.launchWslDistro !== codexHomeWslInfo.distro) { - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else { - env.CODEX_HOME = codexHomeWslInfo.linuxPath - env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath - // Why: wsl.exe only imports non-default env vars named in WSLENV. - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - if (!plan.launchWslDistro) { - const resolved = resolveWindowsShellLaunchArgs( - plan.shellPath, - plan.cwd, - plan.defaultCwd, - { - distro: codexHomeWslInfo.distro - } - ) - plan.shellArgs = resolved.shellArgs - plan.effectiveCwd = resolved.effectiveCwd - plan.validationCwd = resolved.validationCwd - plan.startupCommandDeliveredInShellArgs = - resolved.startupCommandDeliveredInShellArgs === true - } - } - } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { - // Why: Orca's Codex home is host-local; WSL Codex must use its Linux-side ~/.codex, not a Windows path. - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else if (env.CODEX_HOME) { - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - } - if (env.CLAUDE_CONFIG_DIR) { - // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. - addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) - } - if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { - // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. - addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) - } - } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { - // Why: WSL Codex homes are Linux paths Windows can't use; also drop ORCA_CODEX_HOME (shell-ready restores CODEX_HOME from it). - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } - - const shellBasename = pathWin32.basename(plan.shellPath).toLowerCase() - const codexLaunchPreflightCommand = env.ORCA_CODEX_LAUNCH_PREFLIGHT - if ( - codexLaunchPreflightCommand && - (shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(plan.shellPath)) - ) { - if (shellBasename === 'cmd.exe') { - // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. - env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' - } - const resolved = resolveWindowsShellLaunchArgs( - plan.shellPath, - plan.cwd, - plan.defaultCwd, - plan.launchWslContext, - spawn.command, - codexLaunchPreflightCommand - ) - plan.shellArgs = resolved.shellArgs - plan.effectiveCwd = resolved.effectiveCwd - plan.validationCwd = resolved.validationCwd - plan.startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true - } -} diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index cc63cfb9d09d..963b144cda38 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,5 +1,5 @@ import { settledWriteStub } from './settled-pty-write-stub' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { setPtyHostBindings } from '../ipc/pty-host-bindings' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ @@ -36,18 +36,6 @@ vi.mock('fs', () => ({ constants: { X_OK: 1 } })) -vi.mock('node-pty', () => ({ - spawn: vi.fn().mockReturnValue({ - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - process: 'zsh', - pid: 12345 - }) -})) - vi.mock('../opencode/hook-service', () => ({ openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } })) @@ -58,6 +46,8 @@ vi.mock('../pi/titlebar-extension-service', () => ({ import { deletePtyOwnership, + getLocalPtyProvider, + setLocalPtyProvider, registerPtyHandlers, registerSshPtyProvider, setPtyOwnership, @@ -74,7 +64,13 @@ describe('PTY provider dispatch', () => { } const mainWindowIpcEvent = { sender: mainWindow.webContents } + const originalLocalProvider = getLocalPtyProvider() + let local: IPtyProvider + afterEach(() => setLocalPtyProvider(originalLocalProvider)) + function setup(): void { + local = createMockProvider('local-test-pty') + setLocalPtyProvider(local) handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -130,7 +126,10 @@ describe('PTY provider dispatch', () => { rows: 24, connectionId: null })) as { id: string } - expect(result.id).toBeTruthy() + expect(result.id).toBe('local-test-pty') + expect(local.spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ cols: 80, rows: 24 }) + ) }) it('routes to local provider when connectionId is undefined', async () => { @@ -139,7 +138,10 @@ describe('PTY provider dispatch', () => { cols: 80, rows: 24 })) as { id: string } - expect(result.id).toBeTruthy() + expect(result.id).toBe('local-test-pty') + expect(local.spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ cols: 80, rows: 24 }) + ) }) it('routes to SSH provider when connectionId is set', async () => { diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 4b31fbbb2c8b..6f40170e90eb 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -156,6 +156,7 @@ export type IPtyProvider = { * and callers must keep functioning without them (the pending-output cap * still bounds memory when pause is unavailable). */ + hasPtyDeliveryPauseAdapter?: () => boolean pauseProducer?: (id: string) => void resumeProducer?: (id: string) => void /** diff --git a/src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts b/src/main/providers/pty-repaint-wide-char-buffer.bun.test.ts similarity index 62% rename from src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts rename to src/main/providers/pty-repaint-wide-char-buffer.bun.test.ts index 525f337b82f5..d04cf4c02f29 100644 --- a/src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts +++ b/src/main/providers/pty-repaint-wide-char-buffer.bun.test.ts @@ -1,36 +1,10 @@ -/** - * Real-ConPTY reproduction for #15192, asserted on BUFFER CONTENT rather than on - * the byte stream. - * - * Its sibling windows-conpty-wide-char-duplication.node-pty.test.ts asserts that - * no two identical wide characters arrive adjacent in the bytes node-pty emits. - * That passes on both backends, and it cannot fail for the fault the reporter - * actually described: in their decisive run the command echo was doubled while - * that same command's output was clean, so the shell never received doubled - * bytes. A repaint landing at the wrong cells is made entirely of legitimate - * bytes -- cursor positioning plus text -- and only the cells it lands on are - * wrong. The doubled text also pasted doubled into Notepad, so the corruption - * was in the buffer. - * - * So the ConPTY output is replayed into Orca's own main-side emulator, resizing - * it before the pty exactly as Session.resize does (session.ts), and the - * resulting lines are compared to the fixtures. - * - * Everything that does not need ConPTY runs on every platform: on macOS and - * Linux node-pty spawns a real pty, so the same assertions guard those and prove - * the replay harness is not silently vacuous on the Windows lane. Only the - * backend A/B (bundled OpenConsole vs the system ConPTY) is win32-only. - * - * The width sweep behind these fixtures lives in - * src/main/daemon/headless-emulator-wide-char-repaint.test.ts. - */ +/** Replays real Bun terminal output into Orca's emulator to check wide-cell fidelity. */ +import { recordBunPtyTranscript, type PtyTranscriptEvent } from './bun-pty-transcript-fixture' import { describe, expect, it } from 'vitest' import type { Terminal } from '@xterm/headless' import { HeadlessEmulator } from '../daemon/headless-emulator' import { isWideGlyph, readWrappedLineGlyphs } from '../daemon/__fixtures__/terminal-wide-cell-grid' -const itOnWindows = process.platform === 'win32' ? it : it.skip - const KOREAN_LINE = '안녕하세요 오르카 테스트입니다. 결론부터 말씀드리면 시각적 피로도' const LATIN_LINE = 'roadmap/complete-overhaul-backlog-history.md (1.75) R-08)' const LINE_REPEATS = 8 @@ -41,10 +15,9 @@ const glyphsOf = (line: string): string => line.replace(/\s+/g, '') const KOREAN_GLYPHS = glyphsOf(KOREAN_LINE) const LATIN_GLYPHS = glyphsOf(LATIN_LINE) -type Event = { data: string } | { resizeTo: number } +type Event = PtyTranscriptEvent type RunOptions = { - useConptyDll: boolean /** Milliseconds after spawn at which to resize, paired with the target width. */ resizes?: { atMs: number; cols: number }[] /** How long the child stays alive after its last write, so a resize can trigger a repaint. */ @@ -63,40 +36,12 @@ function childScript(holdMs: number): string { } async function recordConpty(options: RunOptions): Promise { - const nodePty = await import('node-pty') - const proc = nodePty.spawn(process.execPath, ['-e', childScript(options.holdMs)], { - name: 'xterm-256color', + return recordBunPtyTranscript({ + script: childScript(options.holdMs), cols: COLS, rows: ROWS, - cwd: process.cwd(), - env: process.env as Record, - ...(options.useConptyDll ? { useConptyDll: true } : {}) - }) - - // One ordered log: a resize recorded here replays at the same point in the - // stream it happened at, which is the only way the replay can be faithful. - const events: Event[] = [] - proc.onData((chunk) => { - events.push({ data: chunk }) + resizes: options.resizes }) - const timers = (options.resizes ?? []).map((resize) => - setTimeout(() => { - events.push({ resizeTo: resize.cols }) - try { - proc.resize(resize.cols, ROWS) - } catch { - /* the child may already have exited */ - } - }, resize.atMs) - ) - - await new Promise((resolve) => { - proc.onExit(() => resolve()) - }) - for (const timer of timers) { - clearTimeout(timer) - } - return events } function replayIntoEmulator(events: Event[]): string[] { @@ -147,26 +92,13 @@ describe('doubled-wide-glyph detector', () => { describe('pty repaint fidelity in the terminal buffer (#15192)', () => { it('leaves the buffer holding exactly the lines that were printed', async () => { - expectFixtureLinesOnly( - replayIntoEmulator(await recordConpty({ useConptyDll: true, holdMs: 50 })) - ) + expectFixtureLinesOnly(replayIntoEmulator(await recordConpty({ holdMs: 50 }))) }, 60_000) - itOnWindows( - 'holds the same lines on the system ConPTY (A/B for the bundled build)', - async () => { - expectFixtureLinesOnly( - replayIntoEmulator(await recordConpty({ useConptyDll: false, holdMs: 50 })) - ) - }, - 60_000 - ) - it('survives the resize the reporter used as a workaround, after output settles', async () => { // Resizing once the child is quiet isolates the repaint: nothing is being // written, so any line that changes was moved by ConPTY's redraw alone. const events = await recordConpty({ - useConptyDll: true, holdMs: 900, resizes: [ { atMs: 450, cols: 31 }, @@ -182,7 +114,6 @@ describe('pty repaint fidelity in the terminal buffer (#15192)', () => { // which would fail an exact comparison without proving duplication. What // must never happen either way is a glyph appearing twice. const events = await recordConpty({ - useConptyDll: true, holdMs: 200, resizes: [ { atMs: 60, cols: 33 }, diff --git a/src/main/providers/pty-spawn-validation.test.ts b/src/main/providers/pty-spawn-validation.test.ts new file mode 100644 index 000000000000..2baac47072ca --- /dev/null +++ b/src/main/providers/pty-spawn-validation.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as fs from 'node:fs' +import type { Stats } from 'node:fs' + +const { existsSyncMock, statSyncMock, accessSyncMock, wslUncDirectoryExistsMock } = vi.hoisted( + () => ({ + existsSyncMock: vi.fn(), + statSyncMock: vi.fn(), + accessSyncMock: vi.fn(), + wslUncDirectoryExistsMock: vi.fn() + }) +) + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + existsSync: existsSyncMock, + statSync: statSyncMock, + accessSync: accessSyncMock + } +}) + +function dirStats(isDirectory: boolean): Stats { + return { isDirectory: () => isDirectory } as Stats +} + +vi.mock('../wsl', () => ({ + wslUncDirectoryExists: wslUncDirectoryExistsMock +})) + +import { resolveUnixShellPath, validateWorkingDirectory } from './pty-spawn-validation' + +const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' +const NATIVE_DIR = 'C:\\Users\\jin\\repo' + +describe('validateWorkingDirectory', () => { + beforeEach(() => { + existsSyncMock.mockReset() + statSyncMock.mockReset() + wslUncDirectoryExistsMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', () => { + // Why: the Win32 9P stat is the exact path that falsely reported ENOENT and + // broke opening WSL worktrees. The distro answer must win. + wslUncDirectoryExistsMock.mockReturnValue(true) + existsSyncMock.mockReturnValue(false) + + expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() + expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) + // The fs fallback must not run when the distro confirmed existence. + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('rejects a WSL UNC worktree that does not exist inside the distro', () => { + wslUncDirectoryExistsMock.mockReturnValue(false) + + expect(() => validateWorkingDirectory(WSL_UNC_DIR)).toThrow(/does not exist/) + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('falls back to the fs check when the distro answer is inconclusive', () => { + wslUncDirectoryExistsMock.mockReturnValue(null) + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue(dirStats(true)) + + expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() + expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) + expect(existsSyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) + }) + + it('validates native Windows paths via fs without consulting the distro', () => { + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue(dirStats(true)) + + expect(() => validateWorkingDirectory(NATIVE_DIR)).not.toThrow() + expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + expect(existsSyncMock).toHaveBeenCalledWith(NATIVE_DIR) + }) + + it('rejects a missing native Windows path', () => { + existsSyncMock.mockReturnValue(false) + const previousVersion = process.env.ORCA_APP_VERSION + process.env.ORCA_APP_VERSION = '1.4.178-test' + + try { + expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow( + /does not exist.*orca: 1\.4\.178-test/ + ) + expect(wslUncDirectoryExistsMock).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', () => { + existsSyncMock.mockReturnValue(true) + statSyncMock.mockReturnValue(dirStats(false)) + + expect(() => validateWorkingDirectory(NATIVE_DIR)).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']) + }) +}) diff --git a/src/main/providers/pty-spawn-validation.ts b/src/main/providers/pty-spawn-validation.ts new file mode 100644 index 000000000000..25349780cb90 --- /dev/null +++ b/src/main/providers/pty-spawn-validation.ts @@ -0,0 +1,45 @@ +import { isAbsolute } from 'node:path' +import { existsSync, accessSync, constants as fsConstants } from 'node:fs' + +export { + formatLocalPtyEnvironmentDiag, + validateWorkingDirectory, + validateWorkingDirectoryAsync, + WorkingDirectoryValidationAbortedError +} from './working-directory-validation' + +const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const + +export function getShellValidationError(shellPath: string): string | null { + if (!existsSync(shellPath)) { + return ( + `Shell "${shellPath}" does not exist. ` + + `Set a valid SHELL environment variable or install zsh/bash.` + ) + } + try { + accessSync(shellPath, fsConstants.X_OK) + } catch { + return `Shell "${shellPath}" is not executable. Check file permissions.` + } + return null +} + +/** + * Resolves an absolute Unix shell before the native terminal forks. Bare commands and + * relative paths stay untouched so execvp can resolve them against PATH or cwd. + */ +export function resolveUnixShellPath(shellPath: string): string { + if (!isAbsolute(shellPath)) { + return shellPath + } + const candidates = [ + shellPath, + ...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) + ] + const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null) + if (resolved) { + return resolved + } + throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`) +} diff --git a/src/main/providers/relay-pty-generation-registry.ts b/src/main/providers/relay-pty-generation-registry.ts new file mode 100644 index 000000000000..e9e6aca9d17a --- /dev/null +++ b/src/main/providers/relay-pty-generation-registry.ts @@ -0,0 +1,20 @@ +import type { IPtyProvider } from './types' + +/** Source-credit generations identify transports, independently of SSH or WSL owner names. */ +export const relayProvidersByGeneration = new Map() + +export function registerRelayPtyGeneration(generation: number, provider: IPtyProvider): () => void { + if (!Number.isSafeInteger(generation) || generation <= 0) { + throw new Error('Invalid relay provider generation') + } + const existing = relayProvidersByGeneration.get(generation) + if (existing && existing !== provider) { + throw new Error('Relay provider generation already registered') + } + relayProvidersByGeneration.set(generation, provider) + return () => { + if (relayProvidersByGeneration.get(generation) === provider) { + relayProvidersByGeneration.delete(generation) + } + } +} diff --git a/src/main/providers/relay-pty-id-mapping.ts b/src/main/providers/relay-pty-id-mapping.ts new file mode 100644 index 000000000000..71e2344bcec0 --- /dev/null +++ b/src/main/providers/relay-pty-id-mapping.ts @@ -0,0 +1,13 @@ +import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' + +export type RelayPtyIdMapping = { + toAppPtyId: (relayPtyId: string) => string + toRelayPtyId: (appPtyId: string) => string +} + +export function sshRelayPtyIdMapping(connectionId: string): RelayPtyIdMapping { + return { + toAppPtyId: (id) => toAppSshPtyId(connectionId, id), + toRelayPtyId: (id) => toRelaySshPtyId(connectionId, id) + } +} diff --git a/src/main/providers/relay-pty-provider.ts b/src/main/providers/relay-pty-provider.ts new file mode 100644 index 000000000000..3c18a324e925 --- /dev/null +++ b/src/main/providers/relay-pty-provider.ts @@ -0,0 +1,317 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' +import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { RelayPtyIdMapping } from './relay-pty-id-mapping' +import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' +import type { + RemoteCliBridgeEnv, + SshPtyDataCallback, + SshPtyDeliveryPauseAdapter, + SshPtyExitCallback, + SshPtyReplayCallback +} from './ssh-pty-provider-contract' +import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' +import { spawnFreshSshPty } from './ssh-agent-session-create-operation' +import { mapSshPtyProcessList } from './ssh-agent-session-process-list' +import { + requestSshPtyAttach, + reattachSshPtySessionForSpawn, + type PtySourceRecoveryRequest, + type SshPtyAttachResult +} from './ssh-pty-session-reattach' +import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' +import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' +import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' +import type { PtyProcessInspection } from './pty-process-inspection' +import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair' +import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations' + +// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. +function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { + return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) } +} + +/** Remote PTY provider that proxies IPtyProvider operations through the relay. */ +export class RelayPtyProvider implements IPtyProvider { + private mux: SshChannelMultiplexer + private readonly ids: RelayPtyIdMapping + private livePtyIds = new Set() + readonly getAppliedSize: NonNullable + private readonly agentSessionCapabilities: SshAgentSessionCapabilities + private spawnExitRaces = new SshPtySpawnExitRaceTracker() + private readonly outputState: SshPtyProviderOutputState + private recoverFromTerminalUnavailable: TerminalRepairHook | null = null + private readonly rpcOperations: ReturnType + + deleteWorktreeHistory = (worktreeId: string): Promise => + this.rpcOperations.deleteWorktreeHistory(worktreeId) + write = (id: string, data: string): boolean => this.rpcOperations.write(id, data) + writeWithSettlement = (id: string, data: string): Promise => + this.rpcOperations.writeWithSettlement(id, data) + resize = (id: string, cols: number, rows: number): void => + this.rpcOperations.resize(id, cols, rows) + sendSignal = (id: string, signal: string): Promise => + this.rpcOperations.sendSignal(id, signal) + getCwd = (id: string): Promise => this.rpcOperations.getCwd(id) + getInitialCwd = (id: string): Promise => this.rpcOperations.getInitialCwd(id) + clearBuffer = (id: string): Promise => this.rpcOperations.clearBuffer(id) + closeStartupQueryAuthority = (id: string): Promise => + this.rpcOperations.closeStartupQueryAuthority(id) + acknowledgeDataEvent = (id: string, charCount: number): void => + this.rpcOperations.acknowledgeDataEvent(id, charCount) + hasChildProcesses = (id: string): Promise => this.rpcOperations.hasChildProcesses(id) + getForegroundProcess = (id: string): Promise => + this.rpcOperations.getForegroundProcess(id) + inspectProcess = ( + id: string, + options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } + ): Promise => this.rpcOperations.inspectProcess(id, options) + serialize = (ids: string[]): Promise => this.rpcOperations.serialize(ids) + revive = (state: string): Promise => this.rpcOperations.revive(state) + getDefaultShell = (): Promise => this.rpcOperations.getDefaultShell() + getProfiles = (): Promise<{ name: string; path: string }[]> => this.rpcOperations.getProfiles() + + requestHostRpc: NonNullable = (method, params, options) => + this.mux.request(method, params as Record, options) + + constructor( + ids: RelayPtyIdMapping, + mux: SshChannelMultiplexer, + private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv, + readonly providerGeneration = 1 + ) { + this.ids = ids + this.mux = mux + this.rpcOperations = createSshPtyProviderRpcOperations({ + mux, + toRelayPtyId: (id) => this.toRelayPtyId(id) + }) + this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) + this.getAppliedSize = createSshPtyAppliedSizeReader(mux, ids.toRelayPtyId) + + this.outputState = new SshPtyProviderOutputState(providerGeneration, { + mux, + toAppPtyId: (id) => this.toAppPtyId(id), + livePtyIds: this.livePtyIds, + recordExit: (relayPtyId, incarnationId) => { + this.spawnExitRaces.recordExit(relayPtyId, incarnationId) + } + }) + } + + dispose(): void { + this.outputState.dispose() + this.livePtyIds.clear() + } + + canProvideAuthoritativeBufferSnapshot = (_id: string): boolean => false + + private toRelayPtyId = (id: string): string => this.ids.toRelayPtyId(id) + + private toAppPtyId = (id: string): string => this.ids.toAppPtyId(id) + + /** Installed by SshRelaySession, which owns the connection, the repair lock and the reconnect. */ + setTerminalUnavailableRecovery(recover: TerminalRepairHook): void { + this.recoverFromTerminalUnavailable = recover + } + + hasLivePtys(): boolean { + return this.livePtyIds.size > 0 + } + + async spawn(opts: PtySpawnOptions): Promise { + return await spawnWithTerminalRuntimeRepair({ + attempt: () => this.spawnWithoutTerminalRuntimeRepair(opts), + recover: this.recoverFromTerminalUnavailable, + retry: (provider) => provider.spawnWithoutTerminalRuntimeRepair(opts) + }) + } + + private async spawnWithoutTerminalRuntimeRepair(opts: PtySpawnOptions): Promise { + if (opts.agentSessionEnsure && opts.sessionId) { + throw new Error('agent_session_claim_unavailable') + } + if (opts.agentSessionEnsure) { + const supportsClaims = await this.supportsAgentSessionClaims({ signal: opts.signal }) + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + if (!supportsClaims) { + throw new Error('agent_session_claim_unavailable') + } + } + if (opts.sessionId) { + return await reattachSshPtySessionForSpawn({ + mux: this.mux, + idMapping: this.ids, + sessionId: opts.sessionId, + options: opts, + exitRaceTracker: this.spawnExitRaces, + installSourceActivation: (relayPtyId, activation) => + this.outputState.installReceivingActivation(relayPtyId, activation), + rememberPtyIncarnation: (relayPtyId, incarnationId) => + this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId), + acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId) + }) + } + + const supportsCreateOperation = opts.agentSessionCreateOperationId + ? await this.supportsAgentSessionCreateOperations({ signal: opts.signal }) + : false + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + if (opts.agentSessionCreateOperationId && !supportsCreateOperation) { + // Why: host routing owns legacy selection; a changed relay must not downgrade after dispatch. + throw new Error('execution_owner_unavailable') + } + return await spawnFreshSshPty({ + mux: this.mux, + options: opts, + params: buildSshPtySpawnRequest({ + options: opts, + remoteCliBridgeEnv: this.remoteCliBridgeEnv, + supportsCreateOperation + }), + exitRaceTracker: this.spawnExitRaces, + installSourceActivation: (id, activation) => + this.outputState.installReceivingActivation(id, activation), + rememberPtyIncarnation: (id, incarnation) => + this.outputState.rememberPtyIncarnation(id, incarnation), + acceptLivePty: (id) => this.livePtyIds.add(id), + toAppPtyId: this.toAppPtyId + }) + } + + async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise { + return await this.agentSessionCapabilities.supportsClaims(options) + } + + providesAgentSessionOwnerListings(_ptyId: string): boolean { + return this.agentSessionCapabilities.providesOwnerListings() + } + + async supportsAgentSessionCreateOperations( + options: { signal?: AbortSignal } = {} + ): Promise { + return await this.agentSessionCapabilities.supportsCreateOperations(options) + } + + async supportsForegroundProcessEvidence( + options: { signal?: AbortSignal } = {} + ): Promise { + return await this.agentSessionCapabilities.supportsForegroundProcessEvidence(options) + } + + async attach(id: string): Promise { + const relayPtyId = this.toRelayPtyId(id) + await requestSshPtyAttach({ + mux: this.mux, + relayPtyId, + params: { id: relayPtyId }, + commitSourceActivation: true, + installSourceActivation: (ptyId, activation) => + this.outputState.installReceivingActivation(ptyId, activation), + rememberPtyIncarnation: (ptyId, incarnationId) => + this.outputState.rememberPtyIncarnation(ptyId, incarnationId) + }) + } + + async attachForReconnect( + id: string, + expected?: { paneKey?: string; tabId?: string }, + sourceRecovery?: PtySourceRecoveryRequest, + requireReplay = false + ): Promise { + // Why: reconnect owns replay delivery so stale/duplicate attach results can + // be filtered before they reach the renderer. The expected identity lets the + // relay reject a cross-generation id collision instead of reattaching this + // lease to a different pane's freshly spawned PTY. + const params = { + id: this.toRelayPtyId(id), + suppressReplayNotification: true, + ...(requireReplay ? { requireReplay: true } : {}), + ...(sourceRecovery ? { sourceRecovery } : {}), + ...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}), + ...(expected?.tabId ? { expectedTabId: expected.tabId } : {}) + } + const relayPtyId = this.toRelayPtyId(id) + return await requestSshPtyAttach({ + mux: this.mux, + relayPtyId, + params, + timeoutMs: 10_000, + installSourceActivation: (ptyId, activation) => + this.outputState.installReceivingActivation(ptyId, activation), + rememberPtyIncarnation: (ptyId, incarnationId) => + this.outputState.rememberPtyIncarnation(ptyId, incarnationId) + }) + } + + async shutdown(id: string, opts: Parameters[1]): Promise { + // Both fences are omitted rather than sent undefined: a host that predates either must see no + // key at all, and the owner fence in particular must never reach it as a falsy claim. + const { expectedIncarnationId, expectedOwnerClientInstanceId } = opts + await this.mux.request( + 'pty.shutdown', + { + id: this.toRelayPtyId(id), + immediate: opts.immediate ?? false, + keepHistory: opts.keepHistory ?? false, + ...(expectedIncarnationId === undefined ? {} : { expectedIncarnationId }), + ...(expectedOwnerClientInstanceId === undefined ? {} : { expectedOwnerClientInstanceId }) + }, + relayTimeoutOptions(opts.deadlineMs) + ) + this.livePtyIds.delete(id) + } + + async listProcesses(opts?: { + deadlineMs?: number + includeForegroundProcessEvidence?: boolean + }): Promise { + const result = await this.mux.request( + 'pty.listProcesses', + opts?.includeForegroundProcessEvidence === undefined + ? undefined + : { includeForegroundProcessEvidence: opts.includeForegroundProcessEvidence }, + relayTimeoutOptions(opts?.deadlineMs) + ) + const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) + for (const process of processes) { + this.livePtyIds.add(process.id) + const relayPtyId = this.toRelayPtyId(process.id) + this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) + } + return processes + } + + hasPty = (id: string): boolean => this.livePtyIds.has(id) + + onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback) + onRejectedData = (callback: SshPtyDataCallback): (() => void) => + this.outputState.onRejectedData(callback) + onReplay = (callback: SshPtyReplayCallback): (() => void) => this.outputState.onReplay(callback) + onExit = (callback: SshPtyExitCallback): (() => void) => this.outputState.onExit(callback) + + setPtyDeliveryPauseAdapter(adapter: SshPtyDeliveryPauseAdapter | null): void { + this.outputState.setDeliveryPauseAdapter(adapter) + } + + hasPtyDeliveryPauseAdapter(): boolean { + return this.outputState.hasDeliveryPauseAdapter() + } + + pauseProducer(id: string): void { + this.outputState.pause(this.toRelayPtyId(id)) + } + + resumeProducer(id: string): void { + this.outputState.resume(this.toRelayPtyId(id)) + } + + closeOutputIntake(reason: string): void { + this.mux.dispose('connection_lost') + console.error('[ssh-pty-provider] closed after bounded output intake failure', { reason }) + } +} diff --git a/src/main/providers/relay-pty-source-credit-registration.test.ts b/src/main/providers/relay-pty-source-credit-registration.test.ts new file mode 100644 index 000000000000..f47e78bfab49 --- /dev/null +++ b/src/main/providers/relay-pty-source-credit-registration.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { registerRelayPtySourceCredit } from './relay-pty-source-credit-registration' +import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { + installSshPtySourceAckPublisher, + installSshPtySourceCancellationPublisher +} from '../ipc/ssh-pty-output-intake-registry' +vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ + installSshPtySourceAckPublisher: vi.fn(), + installSshPtySourceCancellationPublisher: vi.fn() +})) +const releaseAck = vi.fn() +const releaseCancellation = vi.fn() +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(installSshPtySourceAckPublisher).mockReturnValue(releaseAck) + vi.mocked(installSshPtySourceCancellationPublisher).mockReturnValue(releaseCancellation) +}) +function fixture() { + const mux = new SshChannelMultiplexer({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn(), + close: vi.fn() + }) + const request = vi + .spyOn(mux, 'request') + .mockResolvedValue({ canceled: true, sentEndSu: 10, creditedEndSu: 5 }) + const registration = registerRelayPtySourceCredit(mux, 9, (id) => id.slice('wsl:'.length)) + const publish = vi.mocked(installSshPtySourceCancellationPublisher).mock.calls[0][1] + return { mux, request, registration, publish } +} +it('maps owner-specific cancellation IDs without an SSH identity', async () => { + const { mux, request, registration, publish } = fixture() + expect( + await publish({ + id: 'wsl:pty2:owner:1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token' + }) + ).toEqual({ sentEndSu: 10, creditedEndSu: 5 }) + expect(request).toHaveBeenCalledWith( + 'pty.cancelDelivery', + expect.objectContaining({ id: 'pty2:owner:1' }) + ) + registration.releaseAck() + registration.releaseCancellation() + expect(releaseAck).toHaveBeenCalledOnce() + expect(releaseCancellation).toHaveBeenCalledOnce() + mux.dispose() +}) +it.each([ + null, + { canceled: false }, + { canceled: true, sentEndSu: 4, creditedEndSu: 5 }, + { canceled: true, sentEndSu: -1, creditedEndSu: 0 } +])('rejects invalid cancellation proof %j', async (result) => { + const { mux, request, publish } = fixture() + request.mockResolvedValue(result) + await expect( + publish({ id: 'wsl:pty', clientGeneration: 2, ownerGeneration: 3, deliveryToken: 'token' }) + ).rejects.toThrow('proof_invalid') + mux.dispose() +}) +it('releases the first registration if the second fails', () => { + vi.mocked(installSshPtySourceCancellationPublisher).mockImplementation(() => { + throw new Error('duplicate') + }) + expect(fixture).toThrow('duplicate') + expect(releaseAck).toHaveBeenCalledOnce() +}) diff --git a/src/main/providers/relay-pty-source-credit-registration.ts b/src/main/providers/relay-pty-source-credit-registration.ts new file mode 100644 index 000000000000..a66dada12620 --- /dev/null +++ b/src/main/providers/relay-pty-source-credit-registration.ts @@ -0,0 +1,53 @@ +import { + installSshPtySourceAckPublisher, + installSshPtySourceCancellationPublisher +} from '../ipc/ssh-pty-output-intake-registry' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +/** SSH and guest relays share one credit authority; only their terminal ID mapping differs. */ +export function registerRelayPtySourceCredit( + mux: SshChannelMultiplexer, + generation: number, + toRelayPtyId: (id: string) => string +): { releaseAck: () => void; releaseCancellation: () => void } { + const releaseAck = installSshPtySourceAckPublisher(generation, (batch, onSettled) => + mux.notifyWithSettlement('pty.ackData', { ...batch }, (settlement) => + onSettled( + settlement.outcome === 'accepted' ? { ok: true } : { ok: false, error: settlement.error } + ) + ) + ) + try { + const releaseCancellation = installSshPtySourceCancellationPublisher( + generation, + async (request) => { + const result: unknown = await mux.request('pty.cancelDelivery', { + ...request, + id: toRelayPtyId(request.id) + }) + if ( + !result || + typeof result !== 'object' || + !('canceled' in result) || + result.canceled !== true || + !('sentEndSu' in result) || + typeof result.sentEndSu !== 'number' || + !Number.isSafeInteger(result.sentEndSu) || + result.sentEndSu < 0 || + !('creditedEndSu' in result) || + typeof result.creditedEndSu !== 'number' || + !Number.isSafeInteger(result.creditedEndSu) || + result.creditedEndSu < 0 || + result.creditedEndSu > result.sentEndSu + ) { + throw new Error('ssh_source_cancellation_proof_invalid') + } + return { sentEndSu: result.sentEndSu, creditedEndSu: result.creditedEndSu } + } + ) + return { releaseAck, releaseCancellation } + } catch (error) { + releaseAck() + throw error + } +} diff --git a/src/main/providers/relay-pty-source-recovery.ts b/src/main/providers/relay-pty-source-recovery.ts new file mode 100644 index 000000000000..73bef2a92566 --- /dev/null +++ b/src/main/providers/relay-pty-source-recovery.ts @@ -0,0 +1,97 @@ +import { + beginSshPtyOutputGenerationMigration, + closeSshPtyOutputGeneration, + getSshPtyAcceptedSourceCheckpoints +} from '../ipc/ssh-pty-output-intake-registry' +import type { SshPtyAcceptedSourceCheckpoint } from '../ipc/ssh-pty-output-source-obligations' +import type { SshPtyOutputMigrationResult } from '../ipc/ssh-pty-output-model-migration' +import type { PtySourceRecoveryRequest } from './ssh-pty-session-reattach' + +export type RelayPtySourceRecovery = { + checkpointsByAppPtyId: Map + modelMigrationsByAppPtyId: Map> +} + +export async function relayPtySourceRecoveryRequest( + recovery: RelayPtySourceRecovery | undefined, + appPtyId: string, + relayPtyId: string +): Promise { + const migration = recovery?.modelMigrationsByAppPtyId.get(appPtyId) + if (migration) { + const outcome = await migration + if (recovery?.modelMigrationsByAppPtyId.get(appPtyId) === migration) { + recovery.modelMigrationsByAppPtyId.delete(appPtyId) + } + if (outcome.status !== 'settled') { + return Object.freeze({ status: 'checkpointUnavailable' }) + } + } + const checkpoints = recovery?.checkpointsByAppPtyId + const checkpoint = checkpoints?.get(appPtyId) ?? checkpoints?.get(relayPtyId) + if (!checkpoint) { + return Object.freeze({ status: 'checkpointUnavailable' }) + } + return Object.freeze({ + status: 'checkpoint', + clientGeneration: checkpoint.clientGeneration, + ownerGeneration: checkpoint.ownerGeneration, + ptyIncarnation: checkpoint.ptyIncarnation, + deliveryToken: checkpoint.deliveryToken, + acceptedSourceEndSu: checkpoint.acceptedSourceEndSu + }) +} + +/** Move accepted model obligations before retiring a replaceable relay transport. */ +export function migrateRelayPtySourceGeneration(args: { + generation: number + closeReason: string + getRecovery: () => RelayPtySourceRecovery | undefined + toRelayPtyId: (id: string) => string +}): void { + const { generation, closeReason, getRecovery, toRelayPtyId } = args + const recovery = getRecovery() + if (!recovery) { + closeSshPtyOutputGeneration(generation, closeReason) + return + } + for (const checkpoint of getSshPtyAcceptedSourceCheckpoints(generation)) { + recovery.checkpointsByAppPtyId.set(checkpoint.id, checkpoint) + } + const migration = beginSshPtyOutputGenerationMigration(generation) + for (const [ptyId, result] of migration.byPty) { + const previous = recovery.modelMigrationsByAppPtyId.get(ptyId) + const fence = previous ? previous.then(() => result) : result + recovery.modelMigrationsByAppPtyId.set(ptyId, fence) + void fence.then((outcome) => { + const current = getRecovery() + if (current?.modelMigrationsByAppPtyId.get(ptyId) !== fence) { + return + } + if (outcome.status === 'settled') { + current.checkpointsByAppPtyId.set(ptyId, outcome.checkpoint) + } else { + current.checkpointsByAppPtyId.delete(ptyId) + current.checkpointsByAppPtyId.delete(toRelayPtyId(ptyId)) + } + current.modelMigrationsByAppPtyId.delete(ptyId) + }) + } + void migration.completion.then(() => closeSshPtyOutputGeneration(generation, closeReason)) +} + +/** A rejected lease cannot authorize checkpoints captured under its previous generation. */ +export function invalidateRelayPtySourceRecovery(recovery: RelayPtySourceRecovery): void { + recovery.checkpointsByAppPtyId.clear() + for (const [ptyId, migration] of recovery.modelMigrationsByAppPtyId) { + recovery.modelMigrationsByAppPtyId.set( + ptyId, + migration.then(() => + Object.freeze({ + status: 'checkpoint-unavailable' as const, + reason: 'completion-failed' as const + }) + ) + ) + } +} diff --git a/src/main/providers/settled-pty-writer-census.test.ts b/src/main/providers/settled-pty-writer-census.test.ts index 08dd99894007..1535b1dabe24 100644 --- a/src/main/providers/settled-pty-writer-census.test.ts +++ b/src/main/providers/settled-pty-writer-census.test.ts @@ -1,12 +1,9 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { execFileSync } from 'node:child_process' +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { LocalPtyProvider } from './local-pty-provider' import { SshPtyProvider } from './ssh-pty-provider' import { createMockMux } from './ssh-pty-provider-mock-multiplexer' import { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' vi.mock('electron', () => ({ @@ -25,33 +22,32 @@ const REPO_ROOT = join(__dirname, '..', '..', '..') * fabricated handoff fails here rather than silently clearing a mailbox reservation. */ const SETTLED_PTY_WRITER_FILES = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', + 'src/main/providers/relay-pty-provider.ts', 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', 'src/main/daemon/daemon-pty-adapter.ts' ] /** Where the provider-side settlement is actually decided; the adapter inherits its own. */ const SETTLED_WRITER_DECLARATIONS = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', + 'src/main/providers/relay-pty-provider.ts', 'src/main/providers/ssh-pty-provider-rpc-operations.ts', 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', 'src/main/daemon/daemon-pty-session-input.ts' ] -function declaredProviderFiles(): string[] { - const output = execFileSync('git', ['grep', '-l', '--', 'implements IPtyProvider', 'src/main'], { - cwd: REPO_ROOT, - encoding: 'utf8' - }) - // Tests may name the clause while pinning it; only production declarations count. - return output - .split('\n') - .filter((file) => file && !file.endsWith('.test.ts')) - .sort() +function declaredProviderFiles(directory = join(REPO_ROOT, 'src/main')): string[] { + const files: string[] = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + files.push(...declaredProviderFiles(path)) + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { + if (/\bclass\s+\w+[^{}]*\bimplements\s+IPtyProvider\b/.test(readFileSync(path, 'utf8'))) { + files.push(relative(REPO_ROOT, path).split('\\').join('/')) + } + } + } + return files.sort() } function settledWriterBody(file: string): string { @@ -71,14 +67,8 @@ describe('settled PTY writer census', () => { const daemonClient = { isConnected: () => false, onEvent: vi.fn(() => vi.fn()) } const adapter = new DaemonPtyAdapter(daemonClient as never) const instances = [ - new LocalPtyProvider({} as never), new SshPtyProvider('conn-census', createMockMux() as never), new DaemonPtyRouter({ current: adapter, legacy: [] }), - new DegradedDaemonPtyProvider({ - current: adapter, - legacy: [], - fallback: new LocalPtyProvider({} as never) - }), adapter ] for (const provider of instances) { diff --git a/src/main/providers/shell-startup-command-bun-fixture.ts b/src/main/providers/shell-startup-command-bun-fixture.ts new file mode 100644 index 000000000000..cf021160b429 --- /dev/null +++ b/src/main/providers/shell-startup-command-bun-fixture.ts @@ -0,0 +1,116 @@ +import { spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { PtyStartupIngress } from '../../shared/pty-startup-ingress' +import type { TerminalViewRgb } from '../../shared/terminal-view-attributes' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' +import { setAppEnvironment } from '../../shared/app-environment' +import { getShellLaunchConfig } from './local-pty-shell-ready' + +const STARTUP_COMMAND = `bash -c 'read -r line; printf "__STARTUP_INPUT__:%s\\n" "$line"'` +const BLACK: TerminalViewRgb = [0, 0, 0] + +function afterCommand(shell: string): string { + if (shell === 'fish') { + return `if set -q ${POSIX_SHELL_STARTUP_COMMAND_ENV}; echo __AFTER_ENV__:present; else; echo __AFTER_ENV__:missing; end; exit\n` + } + return `printf '__AFTER_ENV__:%s\\n' "\${${POSIX_SHELL_STARTUP_COMMAND_ENV}-missing}"; exit\n` +} + +export async function runShellStartupCommandFixture({ + shell, + home +}: { + shell: string + home: string +}): Promise { + setAppEnvironment({ + getPath: () => home, + getAppPath: () => home, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit() {}, + exit: (code) => process.exit(code), + getAppMetrics: () => [] + }) + const launch = getShellLaunchConfig( + shell, + ['overlay', 'markers', 'ready', 'identity'], + STARTUP_COMMAND + ) + if (launch.env[POSIX_SHELL_STARTUP_COMMAND_ENV] !== STARTUP_COMMAND) { + throw new Error('Startup command was not embedded') + } + return await new Promise((resolve, reject) => { + let proc!: ReturnType + const emulator = new HeadlessEmulator({ + cols: 120, + rows: 30, + onQueryReply: (reply) => { + if (!ingress.answerLiveQueryReply(reply)) { + proc.write(reply) + } + } + }) + emulator.installViewAttributeResponder(() => ({ + foreground: [255, 255, 255], + background: BLACK, + cursor: [255, 255, 255], + ansi: Array.from({ length: 256 }, () => BLACK), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: false + })) + proc = spawnBunPty({ + file: shell, + args: launch.args ?? [], + cols: 120, + rows: 30, + cwd: home, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), + ...launch.env, + HOME: home, + ORCA_ORIG_ZDOTDIR: home, + ORCA_ZSHENV_SOURCE_DIR: home, + TERM: 'xterm-256color' + } + }) + let transcript = '' + let sentInput = false + let sentAfter = false + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + write: (data) => proc.write(data), + onEmission: (emission) => { + transcript += emission.data + void emulator.write(emission.data, { forwardQueryReplies: true }) + if (!sentInput && transcript.includes(STARTUP_COMMAND)) { + sentInput = true + proc.write('hello\n') + } + if (!sentAfter && transcript.includes('__STARTUP_INPUT__:hello')) { + sentAfter = true + proc.write(afterCommand(shell)) + } + } + }) + const timeout = setTimeout(() => { + proc.kill() + emulator.dispose() + reject(new Error(`${shell} startup command timed out: ${JSON.stringify(transcript)}`)) + }, 5_000) + + proc.onData((data) => ingress.accept(data)) + proc.onExit(() => { + clearTimeout(timeout) + ingress.drainAndClose() + emulator.dispose() + proc.destroy() + resolve(transcript) + }) + }) +} diff --git a/src/main/providers/shell-startup-command-bun.integration.test.ts b/src/main/providers/shell-startup-command-bun.integration.test.ts new file mode 100644 index 000000000000..a61ae8fe54b8 --- /dev/null +++ b/src/main/providers/shell-startup-command-bun.integration.test.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { runBundledBunFixture } from '../bundled-bun-test-execution' + +const SHELLS = ['bash', 'zsh', 'fish'].filter((shell) => { + if (process.platform === 'win32') { + return false + } + try { + return runProcessSync({ program: shell, args: ['--version'] }).code === 0 + } catch { + return false + } +}) +const STARTUP_COMMAND = `bash -c 'read -r line; printf "__STARTUP_INPUT__:%s\\n" "$line"'` + +describe('bundled Bun POSIX shell startup-command delivery', () => { + it.each(SHELLS)( + '%s runs the command once, forwards stdin, and keeps the shell', + async (shell) => { + const home = mkdtempSync(join(tmpdir(), `orca-${shell}-startup-command-`)) + try { + const output = await runBundledBunFixture( + join(__dirname, 'shell-startup-command-bun-fixture.ts'), + 'runShellStartupCommandFixture', + { shell, home }, + 10_000 + ) + expect(typeof output).toBe('string') + if (typeof output !== 'string') { + throw new Error('Expected terminal transcript') + } + expect(output.split(STARTUP_COMMAND)).toHaveLength(2) + expect(output).toContain('__STARTUP_INPUT__:hello') + expect(output).toContain('__AFTER_ENV__:missing') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, + 15_000 + ) +}) diff --git a/src/main/providers/ssh-pty-applied-size.ts b/src/main/providers/ssh-pty-applied-size.ts index f725f7c2a091..b92517103162 100644 --- a/src/main/providers/ssh-pty-applied-size.ts +++ b/src/main/providers/ssh-pty-applied-size.ts @@ -8,7 +8,7 @@ const SSH_PTY_APPLIED_SIZE_TIMEOUT_MS = 1_000 export function createSshPtyAppliedSizeReader( mux: SshChannelMultiplexer, - connectionId: string + owner: string | ((id: string) => string) ): (id: string) => Promise { let supported: boolean | null = null return async (id) => { @@ -16,19 +16,19 @@ export function createSshPtyAppliedSizeReader( return null } try { - const result = (await mux.request( + const result = await mux.request( 'pty.getSize', { - id: toRelaySshPtyId(connectionId, id) + id: typeof owner === 'string' ? toRelaySshPtyId(owner, id) : owner(id) }, { timeoutMs: SSH_PTY_APPLIED_SIZE_TIMEOUT_MS } - )) as { - cols?: unknown - rows?: unknown - } | null + ) supported = true if ( !result || + typeof result !== 'object' || + !('cols' in result) || + !('rows' in result) || !Number.isInteger(result.cols) || !Number.isInteger(result.rows) || Number(result.cols) <= 0 || diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 76b5d9f85e48..810d4697e4f0 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,317 +1,18 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' -import type { WriteSettlement } from '../../shared/pty-write-settlement' -import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' -import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' -import type { - RemoteCliBridgeEnv, - SshPtyDataCallback, - SshPtyDeliveryPauseAdapter, - SshPtyExitCallback, - SshPtyReplayCallback -} from './ssh-pty-provider-contract' -import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' -import { spawnFreshSshPty } from './ssh-agent-session-create-operation' -import { mapSshPtyProcessList } from './ssh-agent-session-process-list' -import { - requestSshPtyAttach, - reattachSshPtySessionForSpawn, - type PtySourceRecoveryRequest, - type SshPtyAttachResult -} from './ssh-pty-session-reattach' -import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' -import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' -import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' -import type { PtyProcessInspection } from './pty-process-inspection' -import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair' -import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations' - -// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. -function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { - return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) } -} - -/** Remote PTY provider that proxies IPtyProvider operations through the relay. */ -export class SshPtyProvider implements IPtyProvider { - private mux: SshChannelMultiplexer - private connectionId: string - private livePtyIds = new Set() - readonly getAppliedSize: NonNullable - private readonly agentSessionCapabilities: SshAgentSessionCapabilities - private spawnExitRaces = new SshPtySpawnExitRaceTracker() - private readonly outputState: SshPtyProviderOutputState - private recoverFromTerminalUnavailable: TerminalRepairHook | null = null - private readonly rpcOperations: ReturnType - - deleteWorktreeHistory = (worktreeId: string): Promise => - this.rpcOperations.deleteWorktreeHistory(worktreeId) - write = (id: string, data: string): boolean => this.rpcOperations.write(id, data) - writeWithSettlement = (id: string, data: string): Promise => - this.rpcOperations.writeWithSettlement(id, data) - resize = (id: string, cols: number, rows: number): void => - this.rpcOperations.resize(id, cols, rows) - sendSignal = (id: string, signal: string): Promise => - this.rpcOperations.sendSignal(id, signal) - getCwd = (id: string): Promise => this.rpcOperations.getCwd(id) - getInitialCwd = (id: string): Promise => this.rpcOperations.getInitialCwd(id) - clearBuffer = (id: string): Promise => this.rpcOperations.clearBuffer(id) - closeStartupQueryAuthority = (id: string): Promise => - this.rpcOperations.closeStartupQueryAuthority(id) - acknowledgeDataEvent = (id: string, charCount: number): void => - this.rpcOperations.acknowledgeDataEvent(id, charCount) - hasChildProcesses = (id: string): Promise => this.rpcOperations.hasChildProcesses(id) - getForegroundProcess = (id: string): Promise => - this.rpcOperations.getForegroundProcess(id) - inspectProcess = ( - id: string, - options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } - ): Promise => this.rpcOperations.inspectProcess(id, options) - serialize = (ids: string[]): Promise => this.rpcOperations.serialize(ids) - revive = (state: string): Promise => this.rpcOperations.revive(state) - getDefaultShell = (): Promise => this.rpcOperations.getDefaultShell() - getProfiles = (): Promise<{ name: string; path: string }[]> => this.rpcOperations.getProfiles() - - requestHostRpc: NonNullable = (method, params, options) => - this.mux.request(method, params as Record, options) +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' +import { RelayPtyProvider } from './relay-pty-provider' +import { sshRelayPtyIdMapping } from './relay-pty-id-mapping' +/** SSH owns connection identity; the relay provider owns transport and terminal semantics. */ +export class SshPtyProvider extends RelayPtyProvider { constructor( - connectionId: string, + private readonly connectionId: string, mux: SshChannelMultiplexer, - private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv, - readonly providerGeneration = 1 + remoteCliBridgeEnv?: RemoteCliBridgeEnv, + providerGeneration = 1 ) { - this.connectionId = connectionId - this.mux = mux - this.rpcOperations = createSshPtyProviderRpcOperations({ - mux, - toRelayPtyId: (id) => this.toRelayPtyId(id) - }) - this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) - this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) - - this.outputState = new SshPtyProviderOutputState(providerGeneration, { - mux, - toAppPtyId: (id) => this.toAppPtyId(id), - livePtyIds: this.livePtyIds, - recordExit: (relayPtyId, incarnationId) => { - this.spawnExitRaces.recordExit(relayPtyId, incarnationId) - } - }) - } - - dispose(): void { - this.outputState.dispose() - this.livePtyIds.clear() + super(sshRelayPtyIdMapping(connectionId), mux, remoteCliBridgeEnv, providerGeneration) } getConnectionId = (): string => this.connectionId - - canProvideAuthoritativeBufferSnapshot = (_id: string): boolean => false - - private toRelayPtyId = (id: string): string => toRelaySshPtyId(this.connectionId, id) - - private toAppPtyId = (id: string): string => toAppSshPtyId(this.connectionId, id) - - /** Installed by SshRelaySession, which owns the connection, the repair lock and the reconnect. */ - setTerminalUnavailableRecovery(recover: TerminalRepairHook): void { - this.recoverFromTerminalUnavailable = recover - } - - hasLivePtys(): boolean { - return this.livePtyIds.size > 0 - } - - async spawn(opts: PtySpawnOptions): Promise { - return await spawnWithTerminalRuntimeRepair({ - attempt: () => this.spawnWithoutTerminalRuntimeRepair(opts), - recover: this.recoverFromTerminalUnavailable, - retry: (provider) => provider.spawnWithoutTerminalRuntimeRepair(opts) - }) - } - - private async spawnWithoutTerminalRuntimeRepair(opts: PtySpawnOptions): Promise { - if (opts.agentSessionEnsure && opts.sessionId) { - throw new Error('agent_session_claim_unavailable') - } - if (opts.agentSessionEnsure) { - const supportsClaims = await this.supportsAgentSessionClaims({ signal: opts.signal }) - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - if (!supportsClaims) { - throw new Error('agent_session_claim_unavailable') - } - } - if (opts.sessionId) { - return await reattachSshPtySessionForSpawn({ - mux: this.mux, - connectionId: this.connectionId, - sessionId: opts.sessionId, - options: opts, - exitRaceTracker: this.spawnExitRaces, - installSourceActivation: (relayPtyId, activation) => - this.outputState.installReceivingActivation(relayPtyId, activation), - rememberPtyIncarnation: (relayPtyId, incarnationId) => - this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId), - acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId) - }) - } - - const supportsCreateOperation = opts.agentSessionCreateOperationId - ? await this.supportsAgentSessionCreateOperations({ signal: opts.signal }) - : false - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - if (opts.agentSessionCreateOperationId && !supportsCreateOperation) { - // Why: host routing owns legacy selection; a changed relay must not downgrade after dispatch. - throw new Error('execution_owner_unavailable') - } - return await spawnFreshSshPty({ - mux: this.mux, - options: opts, - params: buildSshPtySpawnRequest({ - options: opts, - remoteCliBridgeEnv: this.remoteCliBridgeEnv, - supportsCreateOperation - }), - exitRaceTracker: this.spawnExitRaces, - installSourceActivation: (id, activation) => - this.outputState.installReceivingActivation(id, activation), - rememberPtyIncarnation: (id, incarnation) => - this.outputState.rememberPtyIncarnation(id, incarnation), - acceptLivePty: (id) => this.livePtyIds.add(id), - toAppPtyId: this.toAppPtyId - }) - } - - async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise { - return await this.agentSessionCapabilities.supportsClaims(options) - } - - providesAgentSessionOwnerListings(_ptyId: string): boolean { - return this.agentSessionCapabilities.providesOwnerListings() - } - - async supportsAgentSessionCreateOperations( - options: { signal?: AbortSignal } = {} - ): Promise { - return await this.agentSessionCapabilities.supportsCreateOperations(options) - } - - async supportsForegroundProcessEvidence( - options: { signal?: AbortSignal } = {} - ): Promise { - return await this.agentSessionCapabilities.supportsForegroundProcessEvidence(options) - } - - async attach(id: string): Promise { - const relayPtyId = this.toRelayPtyId(id) - await requestSshPtyAttach({ - mux: this.mux, - relayPtyId, - params: { id: relayPtyId }, - commitSourceActivation: true, - installSourceActivation: (ptyId, activation) => - this.outputState.installReceivingActivation(ptyId, activation), - rememberPtyIncarnation: (ptyId, incarnationId) => - this.outputState.rememberPtyIncarnation(ptyId, incarnationId) - }) - } - - async attachForReconnect( - id: string, - expected?: { paneKey?: string; tabId?: string }, - sourceRecovery?: PtySourceRecoveryRequest - ): Promise { - // Why: reconnect owns replay delivery so stale/duplicate attach results can - // be filtered before they reach the renderer. The expected identity lets the - // relay reject a cross-generation id collision instead of reattaching this - // lease to a different pane's freshly spawned PTY. - const params = { - id: this.toRelayPtyId(id), - suppressReplayNotification: true, - ...(sourceRecovery ? { sourceRecovery } : {}), - ...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}), - ...(expected?.tabId ? { expectedTabId: expected.tabId } : {}) - } - const relayPtyId = this.toRelayPtyId(id) - return await requestSshPtyAttach({ - mux: this.mux, - relayPtyId, - params, - timeoutMs: 10_000, - installSourceActivation: (ptyId, activation) => - this.outputState.installReceivingActivation(ptyId, activation), - rememberPtyIncarnation: (ptyId, incarnationId) => - this.outputState.rememberPtyIncarnation(ptyId, incarnationId) - }) - } - - async shutdown(id: string, opts: Parameters[1]): Promise { - // Both fences are omitted rather than sent undefined: a host that predates either must see no - // key at all, and the owner fence in particular must never reach it as a falsy claim. - const { expectedIncarnationId, expectedOwnerClientInstanceId } = opts - await this.mux.request( - 'pty.shutdown', - { - id: this.toRelayPtyId(id), - immediate: opts.immediate ?? false, - keepHistory: opts.keepHistory ?? false, - ...(expectedIncarnationId === undefined ? {} : { expectedIncarnationId }), - ...(expectedOwnerClientInstanceId === undefined ? {} : { expectedOwnerClientInstanceId }) - }, - relayTimeoutOptions(opts.deadlineMs) - ) - this.livePtyIds.delete(id) - } - - async listProcesses(opts?: { - deadlineMs?: number - includeForegroundProcessEvidence?: boolean - }): Promise { - const result = await this.mux.request( - 'pty.listProcesses', - opts?.includeForegroundProcessEvidence === undefined - ? undefined - : { includeForegroundProcessEvidence: opts.includeForegroundProcessEvidence }, - relayTimeoutOptions(opts?.deadlineMs) - ) - const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) - for (const process of processes) { - this.livePtyIds.add(process.id) - const relayPtyId = this.toRelayPtyId(process.id) - this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) - } - return processes - } - - hasPty = (id: string): boolean => this.livePtyIds.has(id) - - onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback) - onRejectedData = (callback: SshPtyDataCallback): (() => void) => - this.outputState.onRejectedData(callback) - onReplay = (callback: SshPtyReplayCallback): (() => void) => this.outputState.onReplay(callback) - onExit = (callback: SshPtyExitCallback): (() => void) => this.outputState.onExit(callback) - - setPtyDeliveryPauseAdapter(adapter: SshPtyDeliveryPauseAdapter | null): void { - this.outputState.setDeliveryPauseAdapter(adapter) - } - - hasPtyDeliveryPauseAdapter(): boolean { - return this.outputState.hasDeliveryPauseAdapter() - } - - pauseProducer(id: string): void { - this.outputState.pause(this.toRelayPtyId(id)) - } - - resumeProducer(id: string): void { - this.outputState.resume(this.toRelayPtyId(id)) - } - - closeOutputIntake(reason: string): void { - this.mux.dispose('connection_lost') - console.error('[ssh-pty-provider] closed after bounded output intake failure', { reason }) - } } diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index f05373a03dc3..3367e7bc2e5e 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -10,7 +10,7 @@ import { isSshPtyNotFoundError } from './ssh-pty-errors' import { isProvenExitedPtyAttachRefusal } from '../../shared/pty-attach-absence-evidence' -import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' +import { sshRelayPtyIdMapping, type RelayPtyIdMapping } from './relay-pty-id-mapping' import type { PtySpawnOptions, PtySpawnResult } from './types' import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import type { @@ -177,18 +177,27 @@ export type { PtySourceRecoveryRequest } const RESTORE_REQUIRED_ATTACH_ATTEMPTS = 2 -export async function reattachSshPtySession(args: { - mux: SshChannelMultiplexer - connectionId: string - sessionId: string - options: PtySpawnOptions - rememberPtyIncarnation?: (relayPtyId: string, incarnationId: unknown) => void - installSourceActivation?: ( - relayPtyId: string, - activation: PtySourceReceivingActivation - ) => SshPtyReceivingActivationLease -}): Promise { - const relaySessionId = toRelaySshPtyId(args.connectionId, args.sessionId) +type RelayPtyReattachIdentity = + | { connectionId: string; idMapping?: never } + | { connectionId?: never; idMapping: RelayPtyIdMapping } + +function reattachIds(identity: RelayPtyReattachIdentity): RelayPtyIdMapping { + return identity.idMapping ?? sshRelayPtyIdMapping(identity.connectionId) +} + +export async function reattachSshPtySession( + args: RelayPtyReattachIdentity & { + mux: SshChannelMultiplexer + sessionId: string + options: PtySpawnOptions + rememberPtyIncarnation?: (relayPtyId: string, incarnationId: unknown) => void + installSourceActivation?: ( + relayPtyId: string, + activation: PtySourceReceivingActivation + ) => SshPtyReceivingActivationLease + } +): Promise { + const relaySessionId = reattachIds(args).toRelayPtyId(args.sessionId) console.warn(`[ssh-pty] spawn() called with sessionId=${args.sessionId}, attempting pty.attach`) try { // Why: expected pane identity prevents a reused relay id from attaching the wrong shell. @@ -217,7 +226,7 @@ export async function reattachSshPtySession(args: { `[ssh-pty] pty.attach succeeded for ${args.sessionId}, replay=${!!attachResult.replay}` ) return { - id: toAppSshPtyId(args.connectionId, relaySessionId), + id: reattachIds(args).toAppPtyId(relaySessionId), isReattach: true, ...(attachResult.replay ? { replay: attachResult.replay } : {}), ...(attachResult.incarnationId ? { incarnationId: attachResult.incarnationId } : {}), @@ -262,7 +271,7 @@ export async function reattachSshPtySessionWithExitFence( let result: SshPtyReattachResult | undefined try { result = await reattachSshPtySession(args) - const relayPtyId = toRelaySshPtyId(args.connectionId, result.id) + const relayPtyId = reattachIds(args).toRelayPtyId(result.id) if ( args.exitRaceTracker.didMatchingExitArrive(operation, { id: relayPtyId, @@ -329,9 +338,6 @@ export async function reattachSshPtySessionForSpawn( // and positive evidence the PTY is live. Claiming expiry here made the caller retire the pane // binding and cold-restore the agent into a second copy of a running session. throw new Error( - `${SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR}: ${toRelaySshPtyId( - args.connectionId, - args.sessionId - )} ${restoreRequiredReason}` + `${SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR}: ${reattachIds(args).toRelayPtyId(args.sessionId)} ${restoreRequiredReason}` ) } diff --git a/src/main/providers/unavailable-pty-provider.test.ts b/src/main/providers/unavailable-pty-provider.test.ts new file mode 100644 index 000000000000..f5680d9c0117 --- /dev/null +++ b/src/main/providers/unavailable-pty-provider.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { createUnavailablePtyProvider } from './unavailable-pty-provider' + +describe('unavailable terminal service', () => { + it('refuses new work without claiming existing processes exited', async () => { + const provider = createUnavailablePtyProvider() + await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow( + 'Terminal service unavailable' + ) + await expect(provider.listProcesses()).rejects.toThrow('Terminal service unavailable') + await expect(provider.shutdown('existing', {})).rejects.toThrow('Terminal service unavailable') + await expect(provider.probePtyLiveness?.('existing')).resolves.toBeNull() + expect(provider.writeWithSettlement('existing', 'command')).toEqual({ + outcome: 'refused', + reason: 'provider_unavailable' + }) + }) +}) diff --git a/src/main/providers/unavailable-pty-provider.ts b/src/main/providers/unavailable-pty-provider.ts new file mode 100644 index 000000000000..6f567ae6993b --- /dev/null +++ b/src/main/providers/unavailable-pty-provider.ts @@ -0,0 +1,41 @@ +import { writeRefused } from '../../shared/pty-write-settlement' +import type { IPtyProvider } from './types' + +export function terminalServiceUnavailable(): Error { + return new Error( + 'Terminal service unavailable. Retry starting the terminal service; existing terminals have not been stopped.' + ) +} + +const unavailable = async (): Promise => { + throw terminalServiceUnavailable() +} +const unsubscribe = (): (() => void) => () => {} + +/** Startup must never silently move terminal ownership into Electron. */ +export function createUnavailablePtyProvider(): IPtyProvider { + return { + spawn: unavailable, + attach: unavailable, + probePtyLiveness: async () => null, + write: () => false, + writeWithSettlement: () => writeRefused('provider_unavailable'), + resize: () => {}, + shutdown: unavailable, + sendSignal: unavailable, + getCwd: unavailable, + getInitialCwd: unavailable, + clearBuffer: unavailable, + acknowledgeDataEvent: () => {}, + hasChildProcesses: async () => true, + getForegroundProcess: async () => null, + serialize: unavailable, + revive: unavailable, + listProcesses: unavailable, + getDefaultShell: unavailable, + getProfiles: unavailable, + onData: unsubscribe, + onReplay: unsubscribe, + onExit: unsubscribe + } +} diff --git a/src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts b/src/main/providers/windows-conpty-wide-char-duplication.bun.test.ts similarity index 50% rename from src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts rename to src/main/providers/windows-conpty-wide-char-duplication.bun.test.ts index 5f449bbfe498..f3eddde9a7f1 100644 --- a/src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts +++ b/src/main/providers/windows-conpty-wide-char-duplication.bun.test.ts @@ -1,23 +1,5 @@ -/** - * Real-ConPTY reproduction for #15192: wide characters arrive doubled in the - * terminal buffer on Windows (`안녕하세요` -> `안안녕녕하하세세요요`), while - * Latin is never affected. - * - * Why at this layer and not in the renderer: every renderer-side duplication - * path (delivery replay, snapshot restore, output-queue chunking) is - * script-blind and would double ASCII too. The one component in the stack that - * represents a character as TWO cells is the Windows console text buffer, where - * a DBCS glyph occupies a leading and a trailing cell that both carry the same - * wchar. Emitting the trailing cell as text produces exactly the reported - * signature. So the assertion is on the bytes node-pty hands us, BEFORE xterm. - * - * Orca pins the ConPTY implementation with `useConptyDll: true` - * (local-pty-utils.ts, pty-subprocess.ts, windows-conpty-warmup.ts), so the - * bundled OpenConsole build is a variable we control and the system one is the - * A/B. Both are exercised here. - * - * Runs only on win32; skipped elsewhere (macOS/Linux have no ConPTY). - */ +/** Real Windows terminal-byte fidelity for Korean wide characters and resize repaints. */ +import { recordBunPtyTranscript } from './bun-pty-transcript-fixture' import { describe, expect, it } from 'vitest' import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' import { isWideGlyph } from '../daemon/__fixtures__/terminal-wide-cell-grid' @@ -40,8 +22,7 @@ function doubledWideRuns(text: string): string[] { return hits } -async function runThroughConpty(opts: { useConptyDll: boolean }): Promise { - const nodePty = await import('node-pty') +async function runThroughConpty(): Promise { // Why node and not `echo`: cmd.exe's output codepage depends on the machine's // ANSI codepage, which would make the fixture bytes untrustworthy. Node always // writes UTF-8 here. @@ -53,54 +34,13 @@ async function runThroughConpty(opts: { useConptyDll: boolean }): Promise=${LINE_REPEATS}){clearInterval(t);process.exit(0);}},20);` ].join('') - const proc = nodePty.spawn(process.execPath, ['-e', script], { - name: 'xterm-256color', - // Why narrow: the reporter's doubling shows on wrapped rows, and a wide glyph - // straddling the wrap boundary is where the leading/trailing cell pair matters. + const events = await recordBunPtyTranscript({ + script, cols: 40, rows: 10, - cwd: process.cwd(), - env: process.env as Record, - ...(opts.useConptyDll ? { useConptyDll: true } : {}) + resizes: [38, 44, 36, 46].map((cols, index) => ({ cols, atMs: 60 + index * 50 })) }) - - let out = '' - proc.onData((chunk) => { - out += chunk - }) - // Why a resize mid-stream: ConPTY repaints its viewport on a size change, and - // the report is intermittent in a way that tracks repaints (the reporter's own - // workaround is resizing the window). - const resizes = [38, 44, 36, 46] - const timers = resizes.map((cols, index) => - setTimeout( - () => { - try { - proc.resize(cols, 10) - } catch { - /* the child may already have exited */ - } - }, - 60 + index * 50 - ) - ) - - try { - await new Promise((resolve) => { - proc.onExit(() => resolve()) - }) - } finally { - // Why: a child that never exits would otherwise outlive the run and leak into the rest of the job. - for (const timer of timers) { - clearTimeout(timer) - } - try { - proc.kill() - } catch { - /* already gone */ - } - } - return out + return events.map((event) => ('data' in event ? event.data : '')).join('') } // Why a self-test: the ConPTY cases only run on Windows, so without this the @@ -124,9 +64,9 @@ describe('doubled-wide-character detector', () => { describe('Windows ConPTY wide-character fidelity (#15192)', () => { itOnWindows( - 'does not double wide characters with the bundled ConPTY (useConptyDll)', + 'does not double wide characters through the Bun ConPTY backend', async () => { - const text = stripAnsiEscapeSequences(await runThroughConpty({ useConptyDll: true })) + const text = stripAnsiEscapeSequences(await runThroughConpty()) // Guard against a vacuous pass: the fixture must actually have reached us. expect(text).toContain(KOREAN_LINE.slice(0, 5)) expect(doubledWideRuns(text)).toEqual([]) @@ -135,13 +75,4 @@ describe('Windows ConPTY wide-character fidelity (#15192)', () => { }, 60_000 ) - - itOnWindows( - 'does not double wide characters with the system ConPTY (A/B for the bundled build)', - async () => { - const text = stripAnsiEscapeSequences(await runThroughConpty({ useConptyDll: false })) - expect(doubledWideRuns(text)).toEqual([]) - }, - 60_000 - ) }) diff --git a/src/main/providers/windows-console-attached-processes.test.ts b/src/main/providers/windows-console-attached-processes.test.ts index 5f96604ac162..73dd93657613 100644 --- a/src/main/providers/windows-console-attached-processes.test.ts +++ b/src/main/providers/windows-console-attached-processes.test.ts @@ -8,7 +8,26 @@ * it used to live in now answers a different question. */ import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +const { runtimeMock, spawnMock, versionMock } = vi.hoisted(() => ({ + runtimeMock: vi.fn(), + spawnMock: vi.fn(), + versionMock: vi.fn(() => 'version-one') +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ + getAppPath: () => '/app', + getVersion: versionMock, + onWillQuit: vi.fn() + }) +})) +vi.mock('../daemon/daemon-bun-runtime', () => ({ resolveDesktopDaemonBunRuntime: runtimeMock })) +vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: spawnMock })) +beforeEach(() => { + runtimeMock.mockReset() + spawnMock.mockReset() + versionMock.mockReturnValue('version-one') +}) import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' function forkWith(event: 'message' | 'error' | 'none', value?: unknown, pid?: number) { @@ -50,16 +69,17 @@ describe('readWindowsConsoleAttachedProcessIds', () => { } }) - it('returns exact console membership from the fixed node-pty helper', async () => { + it('returns exact console membership from the fixed Bun helper', async () => { const { forkProcess } = forkWith('message', [999, 101, 202, 303], 999) await expect( readWindowsConsoleAttachedProcessIds(101, { forkProcess, - resolveAgentPath: () => '/fixed/node-pty/conpty_console_list_agent.js' + resolveAgentPath: () => '/fixed/windows-bun-pty-gate-entry.js' }) ).resolves.toEqual(new Set([101, 202, 303])) - expect(forkProcess).toHaveBeenCalledWith('/fixed/node-pty/conpty_console_list_agent.js', [ + expect(forkProcess).toHaveBeenCalledWith('/fixed/windows-bun-pty-gate-entry.js', [ + '--console-process-list', '101' ]) }) @@ -107,6 +127,23 @@ describe('readWindowsConsoleAttachedProcessIds', () => { } }) + it('returns unknown when killing a timed-out helper throws', async () => { + vi.useFakeTimers() + try { + const { child, forkProcess } = forkWith('none') + child.kill.mockImplementation(() => { + throw new Error('access denied') + }) + const result = readWindowsConsoleAttachedProcessIds(101, { forkProcess, timeoutMs: 10 }) + await vi.advanceTimersByTimeAsync(10) + await expect(result).resolves.toBeNull() + child.emit('exit', 1) + expect(child.listenerCount('error')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('absorbs an asynchronous kill error after timeout settlement', async () => { vi.useFakeTimers() try { @@ -123,3 +160,88 @@ describe('readWindowsConsoleAttachedProcessIds', () => { } }) }) + +it('uses bundled Bun rather than the Electron executable for console attachment', async () => { + const { child, forkProcess } = forkWith('message', [101, 999], 999) + runtimeMock.mockResolvedValue({ + execPath: '/durable/bun-runtime.exe', + entryPath: '/durable/daemon-entry.js' + }) + spawnMock.mockImplementation(forkProcess) + await expect(readWindowsConsoleAttachedProcessIds(101)).resolves.toEqual(new Set([101])) + expect(spawnMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/durable/bun-runtime.exe', + args: [ + '--no-env-file', + '--config=NUL', + '--no-install', + '/durable/windows-bun-pty-gate-entry.js', + '--console-process-list', + '101' + ] + }) + ) + expect(child.kill).not.toHaveBeenCalled() + await expect(readWindowsConsoleAttachedProcessIds(101)).resolves.toEqual(new Set([101])) + expect(runtimeMock).toHaveBeenCalledTimes(1) +}) + +it('does not start a console helper after runtime resolution exceeds its budget', async () => { + vi.useFakeTimers() + try { + let resolve!: (value: { execPath: string; entryPath: string }) => void + runtimeMock.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + vi.resetModules() + const { readWindowsConsoleAttachedProcessIds: readFresh } = + await import('./windows-console-attached-processes') + const pending = readFresh(101, { timeoutMs: 10 }) + await vi.advanceTimersByTimeAsync(10) + await expect(pending).resolves.toBeNull() + resolve({ execPath: '/late/bun', entryPath: '/late/daemon-entry.js' }) + await vi.advanceTimersByTimeAsync(1) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } +}) + +it('coalesces concurrent verification and refreshes when the installed version changes', async () => { + vi.resetModules() + const { readWindowsConsoleAttachedProcessIds: readFresh } = + await import('./windows-console-attached-processes') + vi.stubGlobal('process', { ...process, versions: { ...process.versions, electron: '43.7.0' } }) + try { + let finish!: (runtime: { execPath: string; entryPath: string }) => void + runtimeMock.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + spawnMock.mockImplementation(() => { + const { child } = forkWith('none', undefined, 999) + queueMicrotask(() => child.emit('message', { consoleProcessList: [101, 999] })) + return child + }) + const first = readFresh(101) + const second = readFresh(101) + expect(runtimeMock).toHaveBeenCalledTimes(1) + finish({ execPath: '/v1/bun-runtime.exe', entryPath: '/v1/daemon-entry.js' }) + await expect(first).resolves.toEqual(new Set([101])) + await expect(second).resolves.toEqual(new Set([101])) + versionMock.mockReturnValue('version-two') + runtimeMock.mockResolvedValue({ + execPath: '/v2/bun-runtime.exe', + entryPath: '/v2/daemon-entry.js' + }) + await expect(readFresh(101)).resolves.toEqual(new Set([101])) + expect(runtimeMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.lastCall?.[0].program).toBe('/v2/bun-runtime.exe') + } finally { + vi.unstubAllGlobals() + } +}) diff --git a/src/main/providers/windows-console-attached-processes.ts b/src/main/providers/windows-console-attached-processes.ts index 5c288ac8f8e9..933a46f25ebb 100644 --- a/src/main/providers/windows-console-attached-processes.ts +++ b/src/main/providers/windows-console-attached-processes.ts @@ -1,8 +1,11 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import type { ChildProcess } from 'node:child_process' -import { forkProcess } from '../../shared/child-process/fork-process' +import { spawnProcess } from '../../shared/child-process/run-process' import { resolveWindowsBunPtyGateEntry } from '../daemon/pty-subprocess/windows-bun-pty-launch' import { WINDOWS_BUN_CONSOLE_LIST_ARGUMENT } from './windows-bun-console-process-list' +import { acquireWindowsConsoleRuntime } from './windows-console-runtime' + const CONPTY_PROCESS_LIST_TIMEOUT_MS = 3_000 type ProcessListMessage = { consoleProcessList?: unknown } @@ -13,14 +16,18 @@ type WindowsConsoleAttachedProcessDeps = { timeoutMs?: number } -function resolveConsoleListAgent(): string { - return process.versions.bun - ? resolveWindowsBunPtyGateEntry() - : require.resolve('node-pty/lib/conpty_console_list_agent.js') -} - -function spawnConsoleListAgent(modulePath: string, args: string[]): ChildProcess { - return forkProcess({ modulePath, args, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) +function spawnConsoleListAgent(execPath: string, modulePath: string, args: string[]): ChildProcess { + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + delete env.NODE_OPTIONS + delete env.NODE_PATH + delete env.BUN_OPTIONS + return spawnProcess({ + program: execPath, + args: [...bunOwnedRuntimeArgs('win32'), modulePath, ...args], + env, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'] + }) } /** @@ -28,8 +35,8 @@ function spawnConsoleListAgent(modulePath: string, args: string[]): ChildProcess * * Distinct from job membership on purpose. `GetConsoleProcessList` must be * called from a process attached to that console, and a process can hold only - * one console at a time -- which is why node-pty answers it from a separate - * process, and why this still forks. + * one console at a time -- so the Bun gate answers it in a separate + * process without changing the caller's console. * * Only the candidate FILTER may use this. That filter exists to drop a * descendant which detached from the console (`Start-Process`, a GUI child), and @@ -40,23 +47,33 @@ function spawnConsoleListAgent(modulePath: string, args: string[]): ChildProcess * candidate already exists, not on every foreground poll. Bounding it to one * pooled, supervised helper is the remaining half of that fix. */ -export function readWindowsConsoleAttachedProcessIds( +export async function readWindowsConsoleAttachedProcessIds( rootPid: number, deps: WindowsConsoleAttachedProcessDeps = {} ): Promise | null> { if (!Number.isSafeInteger(rootPid) || rootPid <= 0 || rootPid >= 0xffff_ffff) { - return Promise.resolve(null) + return null } + const deadline = Date.now() + (deps.timeoutMs ?? CONPTY_PROCESS_LIST_TIMEOUT_MS) let child: ChildProcess + let runtime: Awaited> = null try { - child = (deps.forkProcess ?? spawnConsoleListAgent)( - (deps.resolveAgentPath ?? resolveConsoleListAgent)(), - process.versions.bun - ? [WINDOWS_BUN_CONSOLE_LIST_ARGUMENT, String(rootPid)] - : [String(rootPid)] - ) + const args = [WINDOWS_BUN_CONSOLE_LIST_ARGUMENT, String(rootPid)] + if (deps.forkProcess) { + child = deps.forkProcess((deps.resolveAgentPath ?? resolveWindowsBunPtyGateEntry)(), args) + } else { + runtime = await acquireWindowsConsoleRuntime(deadline - Date.now()) + if (!runtime || Date.now() >= deadline) { + return null + } + child = spawnConsoleListAgent(runtime.execPath, runtime.entryPath, args) + } } catch { - return Promise.resolve(null) + runtime?.invalidate() + return null + } finally { + // A spawned helper is now protected by the process-table retention check. + runtime?.release() } return new Promise((resolve) => { @@ -66,6 +83,9 @@ export function readWindowsConsoleAttachedProcessIds( return } settled = true + if (value === null) { + runtime?.invalidate() + } clearTimeout(timeout) child.removeListener('message', onMessage) // Why: kill failures can emit asynchronously after timeout settlement; @@ -96,10 +116,17 @@ export function readWindowsConsoleAttachedProcessIds( consoleProcessIds.delete(helperPid) finish(consoleProcessIds) } - const timeout = setTimeout(() => { - child.kill() - finish(null) - }, deps.timeoutMs ?? CONPTY_PROCESS_LIST_TIMEOUT_MS) + const timeout = setTimeout( + () => { + try { + child.kill() + } catch { + // A failed kill leaves console ownership unknown. + } + finish(null) + }, + Math.max(1, deadline - Date.now()) + ) child.once('message', onMessage) child.once('error', onFailure) child.once('exit', onExit) diff --git a/src/main/providers/windows-console-runtime.test.ts b/src/main/providers/windows-console-runtime.test.ts new file mode 100644 index 000000000000..ccc93611d32a --- /dev/null +++ b/src/main/providers/windows-console-runtime.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ resolve: vi.fn(), version: vi.fn(), shutdown: vi.fn() })) +vi.mock('../daemon/daemon-bun-runtime', () => ({ resolveDesktopDaemonBunRuntime: mocks.resolve })) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ + getAppPath: () => '/app', + getVersion: mocks.version, + onWillQuit: mocks.shutdown + }) +})) +import { + acquireWindowsConsoleRuntime, + disposeWindowsConsoleRuntime +} from './windows-console-runtime' +beforeEach(() => { + vi.stubGlobal('process', { ...process, versions: { ...process.versions, electron: '43.7.0' } }) + mocks.resolve.mockReset() + mocks.version.mockReturnValue('first') +}) +afterEach(() => { + disposeWindowsConsoleRuntime() + vi.unstubAllGlobals() + vi.useRealTimers() +}) +it('holds invalidated pins until concurrent pending launches release them', async () => { + const releaseLaunchPin = vi.fn() + mocks.resolve.mockResolvedValue({ + execPath: '/bun', + entryPath: '/daemon-entry.js', + releaseLaunchPin + }) + const first = await acquireWindowsConsoleRuntime(1000) + const second = await acquireWindowsConsoleRuntime(1000) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + first?.invalidate() + first?.release() + await Promise.resolve() + expect(releaseLaunchPin).not.toHaveBeenCalled() + second?.release() + await Promise.resolve() + expect(releaseLaunchPin).toHaveBeenCalledTimes(1) + second?.release() + expect(releaseLaunchPin).toHaveBeenCalledTimes(1) +}) +it('releases a late resolver after every waiter has timed out', async () => { + vi.useFakeTimers() + const releaseLaunchPin = vi.fn() + let finish: + | ((runtime: { execPath: string; entryPath: string; releaseLaunchPin: () => void }) => void) + | undefined + mocks.resolve.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const pending = acquireWindowsConsoleRuntime(10) + const failure = expect(pending).rejects.toThrow() + await vi.advanceTimersByTimeAsync(10) + await failure + finish?.({ execPath: '/bun', entryPath: '/daemon-entry.js', releaseLaunchPin }) + await vi.advanceTimersByTimeAsync(1) + expect(releaseLaunchPin).toHaveBeenCalledTimes(1) +}) +it('releases replaced and disposed caches without invalidating successors', async () => { + const oldRelease = vi.fn(), + newRelease = vi.fn() + mocks.resolve + .mockResolvedValueOnce({ + execPath: '/old', + entryPath: '/old/daemon-entry.js', + releaseLaunchPin: oldRelease + }) + .mockResolvedValueOnce({ + execPath: '/new', + entryPath: '/new/daemon-entry.js', + releaseLaunchPin: newRelease + }) + const old = await acquireWindowsConsoleRuntime(1000) + old?.release() + mocks.version.mockReturnValue('second') + const current = await acquireWindowsConsoleRuntime(1000) + current?.release() + expect(oldRelease).toHaveBeenCalledTimes(1) + old?.invalidate() + expect(newRelease).not.toHaveBeenCalled() + disposeWindowsConsoleRuntime() + await Promise.resolve() + expect(newRelease).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/providers/windows-console-runtime.ts b/src/main/providers/windows-console-runtime.ts new file mode 100644 index 000000000000..4a52029acbad --- /dev/null +++ b/src/main/providers/windows-console-runtime.ts @@ -0,0 +1,98 @@ +import { dirname, join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { resolveDesktopDaemonBunRuntime, type DaemonBunRuntime } from '../daemon/daemon-bun-runtime' +import { resolveWindowsBunPtyGateEntry } from '../daemon/pty-subprocess/windows-bun-pty-launch' + +type RuntimeCache = { + key: string + promise: Promise + users: number + invalidated: boolean + released: boolean +} +let cached: RuntimeCache | null = null +let watchingShutdown = false + +function releaseUnused(entry: RuntimeCache): void { + if (!entry.invalidated || entry.users !== 0 || entry.released) { + return + } + entry.released = true + void entry.promise.then((runtime) => runtime?.releaseLaunchPin?.()).catch(() => {}) +} +function invalidate(entry: RuntimeCache): void { + entry.invalidated = true + if (cached === entry) { + cached = null + } + releaseUnused(entry) +} +export function disposeWindowsConsoleRuntime(): void { + if (cached) { + invalidate(cached) + } +} + +/** A cached path owns its pin; pending spawns keep invalidated entries alive until used. */ +export async function acquireWindowsConsoleRuntime(timeoutMs: number) { + if (process.versions.bun) { + return { + execPath: process.execPath, + entryPath: resolveWindowsBunPtyGateEntry(), + release() {}, + invalidate() {} + } + } + const environment = process.versions.electron ? getAppEnvironment() : null + if (environment && !watchingShutdown) { + environment.onWillQuit(disposeWindowsConsoleRuntime) + watchingShutdown = true + } + const key = environment + ? `${environment.getAppPath()}\0${environment.getVersion()}` + : process.execPath + if (cached?.key !== key) { + disposeWindowsConsoleRuntime() + cached = { + key, + promise: resolveDesktopDaemonBunRuntime(), + users: 0, + invalidated: false, + released: false + } + } + const entry = cached + entry.users++ + let released = false + const release = (): void => { + if (released) { + return + } + released = true + entry.users-- + releaseUnused(entry) + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const runtime = await waitForPromiseWithSignal(entry.promise, controller.signal) + if (!runtime) { + invalidate(entry) + release() + return null + } + return { + execPath: runtime.execPath, + entryPath: join(dirname(runtime.entryPath), 'windows-bun-pty-gate-entry.js'), + release, + invalidate: () => invalidate(entry) + } + } catch (error) { + invalidate(entry) + release() + throw error + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/providers/windows-pty-job-membership.test.ts b/src/main/providers/windows-pty-job-membership.test.ts index 8c8ab42122e9..46b34704415e 100644 --- a/src/main/providers/windows-pty-job-membership.test.ts +++ b/src/main/providers/windows-pty-job-membership.test.ts @@ -1,6 +1,6 @@ import type * as ChildProcess from 'node:child_process' import { describe, expect, it, vi } from 'vitest' -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../../shared/terminal-process' // Module-level, so it intercepts the module's own import binding. A spyOn of a // require()'d child_process does not: the first version of this test passed @@ -13,7 +13,20 @@ vi.mock('node:child_process', async (importOriginal) => ({ import { readWindowsPtyJobProcessIds } from './windows-pty-job-membership' -const pty = (pid = 100): IPty => ({ pid }) as unknown as IPty +const pty = (pid = 100): TerminalProcess => ({ + pid, + cols: 80, + rows: 24, + process: 'cmd.exe', + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + resize() {}, + clear() {}, + kill() {}, + pause() {}, + resume() {} +}) describe('readWindowsPtyJobProcessIds', () => { it('never spawns a child process to answer', () => { diff --git a/src/main/providers/windows-pty-job-membership.ts b/src/main/providers/windows-pty-job-membership.ts index 7bb37323b39d..f42e64e74c83 100644 --- a/src/main/providers/windows-pty-job-membership.ts +++ b/src/main/providers/windows-pty-job-membership.ts @@ -1,4 +1,4 @@ -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../../shared/terminal-process' import { isPtyJobOwnershipAvailable, listPtyJobProcessIds, @@ -24,8 +24,8 @@ import { * that processes died. */ export function readWindowsPtyJobProcessIds( - proc: IPty, - listJobProcessIds: (proc: IPty) => readonly number[] | null = listPtyJobProcessIds + proc: TerminalProcess, + listJobProcessIds: (proc: TerminalProcess) => readonly number[] | null = listPtyJobProcessIds ): ReadonlySet | null { const pids = listJobProcessIds(proc) if (!pids) { diff --git a/src/main/providers/windows-shell-preflight-runtime.windows.test.ts b/src/main/providers/windows-shell-preflight-runtime.windows.test.ts index 91f228b6e856..b648345427b7 100644 --- a/src/main/providers/windows-shell-preflight-runtime.windows.test.ts +++ b/src/main/providers/windows-shell-preflight-runtime.windows.test.ts @@ -10,7 +10,8 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import * as pty from 'node-pty' +import { z } from 'zod' +import { runBundledBunFixture } from '../bundled-bun-test-execution' import { afterEach, describe, expect, it } from 'vitest' import { resolveGitBashPath } from '../git-bash' import { @@ -69,51 +70,18 @@ async function runPty(options: { input?: string timeoutMs?: number }): Promise { - const proc = pty.spawn(options.shellPath, options.shellArgs, { - name: 'xterm-256color', - cols: 100, - rows: 30, - cwd: options.cwd, - env: options.env - }) - let output = '' - proc.onData((data) => { - output += data - }) - let exited = false - const exitPromise = new Promise((resolve) => { - proc.onExit(({ exitCode }) => { - exited = true - resolve(exitCode) - }) - }) - let timeout: ReturnType | undefined - const timeoutPromise = new Promise((_resolve, reject) => { - timeout = setTimeout( - () => reject(new Error(`timed out waiting for Windows shell PTY:\n${output}`)), - options.timeoutMs ?? 10_000 + const result = z + .object({ output: z.string(), exitCode: z.number() }) + .parse( + await runBundledBunFixture( + join(__dirname, '../daemon/windows-shell-preflight-bun-fixture.ts'), + 'runWindowsShellPreflight', + options, + (options.timeoutMs ?? 10_000) + 5_000 + ) ) - }) - - try { - if (options.input) { - proc.write(options.input.replaceAll('\n', '\r')) - } - const exitCode = await Promise.race([exitPromise, timeoutPromise]) - expect(exitCode, output).toBe(0) - return output - } finally { - if (timeout) { - clearTimeout(timeout) - } - if (!exited) { - try { - proc.kill() - } catch { - // The PTY may have exited while cleanup was starting. - } - } - } + expect(result.exitCode, result.output).toBe(0) + return result.output } afterEach(() => { diff --git a/src/main/providers/working-directory-validation.test.ts b/src/main/providers/working-directory-validation.test.ts index 8c7d3867b11c..d23a430b794b 100644 --- a/src/main/providers/working-directory-validation.test.ts +++ b/src/main/providers/working-directory-validation.test.ts @@ -10,7 +10,7 @@ vi.mock('../wsl', async (importOriginal) => { return { ...actual, wslUncDirectoryExistsAsync: wslUncDirectoryExistsAsyncMock } }) -import { validateWorkingDirectoryAsync } from './local-pty-utils' +import { validateWorkingDirectoryAsync } from './pty-spawn-validation' import { _resetWorkingDirectoryValidationStateForTest } from './working-directory-validation' let tempDir: string diff --git a/src/main/providers/working-directory-validation.ts b/src/main/providers/working-directory-validation.ts index cf098cad2c33..e45d4b53cc77 100644 --- a/src/main/providers/working-directory-validation.ts +++ b/src/main/providers/working-directory-validation.ts @@ -1,5 +1,5 @@ // Validates a PTY working directory before spawn, so node-pty cannot fail with -// an opaque ENOENT. Split from local-pty-utils to keep that file under its line +// an opaque ENOENT. Split from pty-spawn-validation to keep that file under its line // cap; the async path carries the cancellation contract described below. import { existsSync, statSync } from 'node:fs' diff --git a/src/main/pty-descendant-termination-job-coverage.test.ts b/src/main/pty-descendant-termination-job-coverage.test.ts index 738d16ab16fb..b8040e203999 100644 --- a/src/main/pty-descendant-termination-job-coverage.test.ts +++ b/src/main/pty-descendant-termination-job-coverage.test.ts @@ -16,8 +16,8 @@ import { describe, expect, it } from 'vitest' */ const SRC_DIR = join(__dirname, '..') const CALL = 'killWithDescendantSweep(' -// Local immediate and recognized-agent shutdown share one guarded call site. -const EXPECTED_MINIMUM_SITES = 4 +// TerminalHost teardown and Session termination each own guarded sweep sites. +const EXPECTED_MINIMUM_SITES = 3 function collectTypeScriptFiles(dir: string): string[] { const found: string[] = [] diff --git a/src/main/pty/node-pty-master-fd-retirement.test.ts b/src/main/pty/node-pty-master-fd-retirement.test.ts deleted file mode 100644 index a0b537de1ca7..000000000000 --- a/src/main/pty/node-pty-master-fd-retirement.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import * as pty from 'node-pty' -import { describe, expect, it } from 'vitest' - -/** - * node-pty hands the master fd to libuv, which closes it on EIO/EOF, but upstream - * never invalidated `_fd`, and none of the three fd-addressed surfaces consulted - * anything: `resize()`, the `process` getter, and `CustomWriteStream`, which holds - * its own plain-number copy of the fd taken at spawn. Orca's patch retires all - * three in the same block that gives up the handle - * (config/patches/node-pty@1.1.0.patch). - * - * Scope: this narrows the window, it does not close it. libuv closes the fd - * synchronously inside `uv_close`, before the JS `'close'` that runs `_close()`, - * so callers still need their own liveness verdict for that tick — and a relay - * host installs node-pty from npm, where this patch is not applied at all. - */ - -const POSIX_SHELL = '/bin/sh' - -function spawnPty(command: string, cols = 80, rows = 24): pty.IPty { - return pty.spawn(POSIX_SHELL, ['-c', command], { - name: 'xterm-256color', - cols, - rows, - cwd: process.cwd(), - env: { ...process.env } - }) -} - -function masterFd(term: pty.IPty): number { - return (term as unknown as { fd: number }).fd -} - -type CustomWriteStream = { _fd: number; _writeQueue: unknown[]; write(data: string): void } - -/** - * `Terminal._close()` already shadows `terminal.write` with a no-op, so the stream - * itself is the surface that still reached the fd: a residual `_writeQueue` and an - * in-flight `fs.write` both re-enter it after the close. - */ -function writeStream(term: pty.IPty): CustomWriteStream { - return (term as unknown as { _writeStream: CustomWriteStream })._writeStream -} - -/** - * Run `command` to completion and let node-pty finish giving up the master. - * - * `destroy: false` exercises only the EIO/EOF read-error path, which reaches - * `_close()` without ever calling `destroy()` — the path the exit of a shell - * actually takes, and the one the write stream was previously never told about. - */ -async function retiredPty( - command = 'exit 0', - { destroy = true }: { destroy?: boolean } = {} -): Promise<{ term: pty.IPty; spawnFd: number }> { - const term = spawnPty(command) - const spawnFd = masterFd(term) - await new Promise((resolve) => { - term.onExit(() => resolve()) - }) - if (destroy) { - ;(term as unknown as { destroy?: () => void }).destroy?.() - } - await new Promise((resolve) => setTimeout(resolve, 400)) - return { term, spawnFd } -} - -// Windows never reaches this code: WindowsTerminal.resize goes through the conpty -// agent and reads no fd, so the sentinel is written and never consulted there. -const describeOnPosix = process.platform === 'win32' ? describe.skip : describe - -describeOnPosix('node-pty master fd retirement', () => { - it('invalidates the descriptor once it gives up the handle', async () => { - const { term, spawnFd } = await retiredPty() - - expect(spawnFd).toBeGreaterThanOrEqual(0) - expect(masterFd(term)).toBe(-1) - }, 15000) - - it('answers a resize past retirement without issuing the ioctl', async () => { - const { term } = await retiredPty() - - // Pre-patch this threw `ioctl(2) failed, EBADF` out of whatever called it. - expect(() => term.resize(200, 50)).not.toThrow() - // Geometry stays at the last size actually applied rather than claiming one - // that no descriptor ever received. - expect([term.cols, term.rows]).toEqual([80, 24]) - }, 15000) - - it('retires the write stream fd on _close(), not only on destroy()', async () => { - const { term } = await retiredPty('exit 0', { destroy: false }) - - // The stream copied the fd number at spawn, so `Terminal._fd = -1` alone - // leaves it addressing a descriptor the kernel may already have reissued. - expect(writeStream(term)._fd).toBe(-1) - - writeStream(term).write('x') - expect(writeStream(term)._writeQueue).toHaveLength(0) - }, 15000) - - it('names the spawn file rather than tcgetpgrp on a retired descriptor', async () => { - const { term } = await retiredPty() - - expect(term.process).toBe(POSIX_SHELL) - }, 15000) -}) - -// Linux frees the master synchronously enough that the very next pty is handed the -// same descriptor number every time, which makes the reuse hazard directly -// observable rather than a race to reproduce. -// -// Which is also the constraint on writing a case here: the kernel hands out the -// lowest free number, so a case that returns while its live pty is still open -// leaks that descriptor into the next case's premise as an off-by-one. Await the -// exit, never a fixed sleep. -const describeOnLinux = process.platform === 'linux' ? describe : describe.skip - -describeOnLinux('node-pty master fd reuse', () => { - it('cannot resize a live pty handed the retired descriptor number', async () => { - const { term: retired, spawnFd } = await retiredPty() - - const live = spawnPty('sleep 1; stty size') - let output = '' - live.onData((data) => { - output += data - }) - try { - // The premise of this test: the kernel really did reissue the number. If it - // stops holding, the assertion below would pass for the wrong reason. - expect(masterFd(live)).toBe(spawnFd) - - // Pre-patch this reached TIOCSWINSZ on `live`'s master and silently resized - // a terminal it has no relationship to — no error, nothing for a liveness - // probe of the retired pid to observe. - retired.resize(200, 50) - - await new Promise((resolve) => { - live.onExit(() => resolve()) - }) - expect(output.trim()).toBe('24 80') - } finally { - live.kill() - } - }, 15000) - - it('cannot write into a live pty handed the retired descriptor number', async () => { - const { term: retired, spawnFd } = await retiredPty('exit 0', { destroy: false }) - - const live = spawnPty('sleep 1') - let output = '' - live.onData((data) => { - output += data - }) - try { - expect(masterFd(live)).toBe(spawnFd) - - // Pre-patch this fs.write reached `live`'s master, and the line discipline - // echoed it straight back: a retired pane's bytes landing in an unrelated - // terminal. Nothing has to read them for the leak to be observable. - writeStream(retired).write('leak\r') - - // Await the exit rather than sleeping: a fixed wait leaves this descriptor - // open into the next case, whose `expect(masterFd(live)).toBe(spawnFd)` - // premise then sees the kernel hand out the lower number this pty was still - // holding. That is what broke `node 24 1/8` on Linux, where alone among the - // platforms these cases actually run. - await new Promise((resolve) => { - live.onExit(() => resolve()) - }) - expect(output).not.toContain('leak') - } finally { - live.kill() - } - }, 15000) - - it('does not name a live pty foreground process off the retired descriptor', async () => { - const { term: retired, spawnFd } = await retiredPty() - - // `exec` replaces the shell, so the foreground pgrp's cmdline is distinct - // from the file this pty was spawned with. - const live = spawnPty('exec sleep 5') - try { - expect(masterFd(live)).toBe(spawnFd) - // Let the shell finish exec'ing, or its own cmdline is still the fallback. - await new Promise((resolve) => setTimeout(resolve, 300)) - - // Pre-patch this read tcgetpgrp off `live`'s master and reported `sleep`, - // attributing an unrelated pane's process to a pty that had already exited. - expect(retired.process).toBe(POSIX_SHELL) - } finally { - live.kill() - } - }, 15000) -}) diff --git a/src/main/pty/node-pty-master-fd-retirement.ts b/src/main/pty/node-pty-master-fd-retirement.ts deleted file mode 100644 index 292578dc9072..000000000000 --- a/src/main/pty/node-pty-master-fd-retirement.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * node-pty hands the master fd to libuv, and Orca's patch sets it to -1 in the same block that - * gives up the handle (config/patches/node-pty@1.1.0.patch). Past that point every fd-addressed - * answer is a stand-in rather than an error: the `process` getter names the spawn file instead of - * whatever `tcgetpgrp` would have reported, so callers that need a real observation have to ask - * about the descriptor first. Windows exposes no master fd, so it never reads as retired; an - * unpatched (relay-installed) node-pty never retires the number at all. - */ -export function isRetiredPtyMaster(proc: unknown): boolean { - if (typeof proc !== 'object' || proc === null || !('fd' in proc)) { - return false - } - const fd: unknown = proc.fd - return typeof fd === 'number' && fd < 0 -} diff --git a/src/main/pty/node-pty-pts-name.ts b/src/main/pty/node-pty-pts-name.ts deleted file mode 100644 index c3c4d441cebd..000000000000 --- a/src/main/pty/node-pty-pts-name.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * node-pty's unix terminal exposes the slave device path (`/dev/ttys003`, - * `/dev/pts/3`) but does not declare it on IPty, and the Windows terminal has no - * equivalent. Read it defensively so callers get `undefined` rather than a throw - * on a platform or version that lacks it. - */ -export function readPtsName(proc: unknown): string | undefined { - const value = (proc as { ptsName?: unknown } | null | undefined)?.ptsName - return typeof value === 'string' && value.length > 0 ? value : undefined -} diff --git a/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts b/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts deleted file mode 100644 index 6d8b6411ba56..000000000000 --- a/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * A shell that exits by itself must still close its pseudoconsole. - * - * `ClosePseudoConsole` is the only thing that reaps a ConPTY's console host — - * Orca's own job-ownership patch says so, because `CreatePseudoConsole` spawns - * that host before the per-pty job exists and it is therefore not a job member. - * Upstream node-pty calls it from exactly one place, `PtyKill`, which begins by - * looking the baton up by id — and the exit watcher in `SetupExitCallback` - * erased the baton the moment the shell died. So on the self-exit path (typing - * `exit`, which is how panes usually close) that lookup missed, `PtyKill` did - * nothing at all, and the pseudoconsole was never closed. - * - * There is a SECOND, independent defect on the same path: the `useConptyDll` - * branch of `WindowsPtyAgent.kill()` disposed the conout worker only from an - * `_outSocket.on('data')` handler, and no more data arrives once the shell has - * gone — so that worker leaked too. The non-DLL branch beside it already - * disposed unconditionally. The desktop always sets `useConptyDll`, so it hit - * both; the relay sets neither and hit only the first. - * - * Measured on Windows 11 / awin, 20 cycles, handles bucketed by NT object type, - * totals before -> after: - * - * self-exit, relay spawn 225 -> 285 becomes 219 -> 219 FLAT - * self-exit, desktop spawn 239 -> 439 becomes 222 -> 222 FLAT - * explicit kill, relay spawn 225 -> 285 becomes 219 -> 219 FLAT - * explicit kill, desktop spawn 235 -> 395 becomes 219 -> 219 FLAT - * - * Neither fix alone is enough on the desktop: the pseudoconsole close is worth - * +1 Process +1 File per terminal, the dispose +2 Thread +4 File. - * - * WHY THIS IS A PATCH-CONTENT PIN AND NOT A BEHAVIOURAL TEST: the defect is - * only observable as a per-NT-type handle count, which needs - * `NtQuerySystemInformation(SystemExtendedHandleInformation)`. Nothing in the - * repo can read that, and the cheaper Windows-observable proxies do not - * discriminate — the console host process is reaped either way (the leak is a - * handle to an already-exited object, not an orphaned process), and the - * `\\.\pipe\conpty-*` entries disappear either way. Both were measured and - * rejected as assertions rather than assumed. So this pins the mechanism - * instead, which is the real risk: a future resync of the vendored patch - * silently dropping the hunk. - */ - -const PATCH = readFileSync(join(__dirname, '../../../config/patches/node-pty@1.1.0.patch'), 'utf8') - -/** - * Just the `PtyKill` hunk. Several markers below also occur in the `PtyConnect` - * hunk above it, and a bare `indexOf` on the whole patch silently matched the - * wrong one — an assertion that then held regardless of what `PtyKill` did. - */ -const ptyKillHunk = (() => { - // Anchored on the hunk header's function context rather than its line - // numbers, which shift whenever anything above it in the patch changes. - const header = /^@@ .* @@ static Napi::Value PtyKill\(.*$/m.exec(PATCH) - if (!header) { - throw new Error('no PtyKill hunk in config/patches/node-pty@1.1.0.patch') - } - const from = header.index - const next = PATCH.indexOf('\n@@ ', from + 1) - return PATCH.slice(from, next === -1 ? undefined : next) -})() - -/** - * `indexOf` that throws instead of returning -1. A missing marker must fail the - * assertion that depends on it, not quietly make a slice or comparison vacuous. - */ -function indexIn(haystack: string, marker: string): number { - const at = haystack.indexOf(marker) - if (at === -1) { - throw new Error(`marker not found in the PtyKill hunk: ${marker}`) - } - return at -} - -describe('node-pty patch: pseudoconsole close on the self-exit path', () => { - it('does not let the exit watcher free the baton while the close is still owed', () => { - // Pinned as one block: the erase must stay INSIDE the consoleClosed guard. - // Upstream ran it unconditionally, which is the line that caused the leak, - // and a resync that re-flattens this is the failure mode worth catching. - expect(PATCH).toContain( - [ - '+ baton->shellExited = true;', - '+ if (baton->consoleClosed) {', - '+ const bool removed = remove_pty_baton_locked(baton->id);', - '+ assert(removed);', - '+ (void)removed;', - '+ }' - ].join('\n') - ) - }) - - it('closes the pseudoconsole from PtyKill even after the shell has exited', () => { - // hpc is copied out under the lock, so the close survives the baton's removal. - expect(PATCH).toContain('+ hpc = handle->hpc;') - expect(PATCH).toContain('+ pfnClosePseudoConsole(hpc);') - }) - - it('resolves the ConPTY DLL before it claims the close', () => { - // LoadConptyDll throws when conpty.dll is missing. Throwing after - // consoleClosed was set would strand the pseudoconsole for good: the retry - // finds the work claimed and does nothing. - // - // Anchored inside PtyKill, not by a bare indexOf: the identical line also - // appears in the PtyConnect hunk, earlier in the file, and matching that one - // made this assertion pass no matter where PtyKill resolved the DLL. - const dllResolve = indexIn( - ptyKillHunk, - '+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);' - ) - const claim = indexIn(ptyKillHunk, '+ handle->consoleClosed = true;') - expect(dllResolve).toBeLessThan(claim) - }) - - it('reaches hShell only under the null check the watcher can trip', () => { - // Pinned as one block. The watcher nulls hShell on exit, and upstream - // dereferenced it unconditionally; every remaining use — the duplication and - // the failure fallback below it — must stay inside this guard. - const start = indexIn(ptyKillHunk, '+ if (useConptyDll && handle->hShell != nullptr) {') - const end = indexIn(ptyKillHunk, '+ if (handle->shellExited) {') - const guarded = ptyKillHunk.slice(start, end) - expect(guarded).toContain('DuplicateHandle(GetCurrentProcess(), handle->hShell') - expect(guarded).toContain('TerminateProcess(handle->hShell, 1);') - // No ADDED line outside that guard may terminate through hShell. Removed - // (`-`) lines still carry upstream's unguarded call, which is the point. - const strayAdds = PATCH.replace(guarded, '') - .split('\n') - .filter((line) => line.startsWith('+') && line.includes('TerminateProcess(handle->hShell')) - expect(strayAdds).toEqual([]) - }) - - it('frees the baton from PtyKill when the shell has already exited', () => { - // The other half of the two-sided handshake. Without it a self-exit followed - // by kill() — the ordinary pane close — leaks one baton and one entry in the - // vector get_pty_baton scans linearly, forever. - expect(ptyKillHunk).toContain( - [ - '+ if (handle->shellExited) {', - '+ const bool removed = remove_pty_baton_locked(id);', - '+ assert(removed);', - '+ (void)removed;' - ].join('\n') - ) - }) - - it('still kills the shell when DuplicateHandle fails', () => { - // A null hShellDup is indistinguishable from the self-exit case, so a - // swallowed failure would leave the shell running after its pane closed — - // a worse outcome than the leak this patch exists to fix. - expect(PATCH).toContain( - [ - '+ hShellDup = nullptr;', - '+ TerminateProcess(handle->hShell, 1);', - '+ }' - ].join('\n') - ) - }) - - it('keeps the close idempotent so a second kill cannot double-close', () => { - expect(PATCH).toContain('+ if (handle != nullptr && !handle->consoleClosed) {') - expect(PATCH).toContain('+ handle->consoleClosed = true;') - }) -}) - -describe('node-pty patch: conout worker disposal on the self-exit path', () => { - // The desktop's larger half: 8 of its 10 leaked handles per terminal. - it('disposes the conout worker unconditionally in the useConptyDll branch', () => { - expect(PATCH).toContain('+ this._conoutSocketWorker.dispose();') - // The data handler is what never fired once the shell had gone. - expect(PATCH).toContain("- this._outSocket.on('data', function () {") - expect(PATCH).toContain('- _this._conoutSocketWorker.dispose();') - }) - - it('applies the same change to the TypeScript source the patch also carries', () => { - expect(PATCH).toContain('+ this._conoutSocketWorker.dispose();') - expect(PATCH).toContain("- this._outSocket.on('data', () => {") - }) -}) diff --git a/src/main/pty/omp-shell-wrapper-bun-fixture.ts b/src/main/pty/omp-shell-wrapper-bun-fixture.ts new file mode 100644 index 000000000000..fb6692248f3c --- /dev/null +++ b/src/main/pty/omp-shell-wrapper-bun-fixture.ts @@ -0,0 +1,64 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' + +type PosixShell = 'bash' | 'zsh' + +export async function runInteractivePosixPty(args: { + rcfileContent: string + env: Record + input: string + cwd: string + shell?: PosixShell +}): Promise { + const rcfile = join(args.cwd, 'rcfile') + writeFileSync(rcfile, args.rcfileContent) + const shell = args.shell ?? 'bash' + const shellArgs = shell === 'bash' ? ['--noprofile', '--rcfile', rcfile, '-i'] : ['-f', '-i'] + + const proc = spawnBunPty({ + file: shell, + args: shellArgs, + cols: 100, + rows: 30, + cwd: args.cwd, + env: { ...args.env, ORCA_TEST_RCFILE: rcfile } + }) + + let output = '' + proc.onData((data) => { + output += data + }) + + const exitPromise = new Promise<{ exitCode: number }>((resolve) => { + proc.onExit(({ exitCode }) => resolve({ exitCode })) + }) + + let timeout: ReturnType | null = null + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`timed out waiting for ${shell} PTY output:\n${output}`)), + 5000 + ) + }) + + try { + const input = shell === 'zsh' ? `source "$ORCA_TEST_RCFILE"\n${args.input}` : args.input + proc.write(input.replace(/\n/g, '\r')) + const { exitCode } = await Promise.race([exitPromise, timeoutPromise]) + if (exitCode !== 0) { + throw new Error(`Shell exited ${exitCode}: ${output}`) + } + return output + } finally { + if (timeout !== null) { + clearTimeout(timeout) + } + try { + proc.kill() + } catch { + // The process may already have exited normally before cleanup runs. + } + } +} + diff --git a/src/main/pty/omp-shell-wrapper.node-pty.test.ts b/src/main/pty/omp-shell-wrapper.bun.test.ts similarity index 90% rename from src/main/pty/omp-shell-wrapper.node-pty.test.ts rename to src/main/pty/omp-shell-wrapper.bun.test.ts index 15bc98bdb3be..9163576ebc5e 100644 --- a/src/main/pty/omp-shell-wrapper.node-pty.test.ts +++ b/src/main/pty/omp-shell-wrapper.bun.test.ts @@ -12,7 +12,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import * as pty from 'node-pty' +import { runBundledBunFixture } from '../bundled-bun-test-execution' import { afterEach, describe, expect, it } from 'vitest' import { getPosixOmpShellWrapper } from './omp-shell-wrapper' @@ -27,7 +27,7 @@ type PosixShell = 'bash' | 'zsh' const tempDirs: string[] = [] function makeTempDir(): string { - const dir = mkdtempSync(join(tmpdir(), 'orca-omp-node-pty-')) + const dir = mkdtempSync(join(tmpdir(), 'orca-omp-bun-')) tempDirs.push(dir) return dir } @@ -66,55 +66,19 @@ async function runInteractivePosixPty(args: { cwd: string shell?: PosixShell }): Promise { - const rcfile = join(args.cwd, 'rcfile') - writeFileSync(rcfile, args.rcfileContent) - const shell = args.shell ?? 'bash' - const shellArgs = shell === 'bash' ? ['--noprofile', '--rcfile', rcfile, '-i'] : ['-f', '-i'] - - const proc = pty.spawn(shell, shellArgs, { - name: 'xterm-256color', - cols: 100, - rows: 30, - cwd: args.cwd, - env: { ...args.env, ORCA_TEST_RCFILE: rcfile } - }) - - let output = '' - proc.onData((data) => { - output += data - }) - - const exitPromise = new Promise<{ exitCode: number }>((resolve) => { - proc.onExit(({ exitCode }) => resolve({ exitCode })) - }) - - let timeout: ReturnType | null = null - const timeoutPromise = new Promise((_resolve, reject) => { - timeout = setTimeout( - () => reject(new Error(`timed out waiting for ${shell} PTY output:\n${output}`)), - 5000 - ) - }) - - try { - const input = shell === 'zsh' ? `source "$ORCA_TEST_RCFILE"\n${args.input}` : args.input - proc.write(input.replace(/\n/g, '\r')) - const { exitCode } = await Promise.race([exitPromise, timeoutPromise]) - expect(exitCode).toBe(0) - return output - } finally { - if (timeout !== null) { - clearTimeout(timeout) - } - try { - proc.kill() - } catch { - // The process may already have exited normally before cleanup runs. - } + const output = await runBundledBunFixture( + join(import.meta.dirname, 'omp-shell-wrapper-bun-fixture.ts'), + 'runInteractivePosixPty', + args, + 10_000 + ) + if (typeof output !== 'string') { + throw new Error('Invalid shell fixture output') } + return output } -describePosix('OMP shell wrapper node-pty reproduction', () => { +describePosix('OMP shell wrapper bundled Bun reproduction', () => { afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) diff --git a/src/main/pty/posix-pty-process-groups-fixture.ts b/src/main/pty/posix-pty-process-groups-fixture.ts new file mode 100644 index 000000000000..c4c1095dba2a --- /dev/null +++ b/src/main/pty/posix-pty-process-groups-fixture.ts @@ -0,0 +1,107 @@ +import { runProcessSync } from '../../shared/child-process/run-process' +import { spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { forceKillPosixPtyProcessGroups } from './posix-pty-process-groups' + +type TaggedProcess = { pid: number; pgid: number } + +function readPs(args: string[]): string { + const result = runProcessSync({ program: 'ps', args, timeoutMs: 2_000 }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error('PTY fixture process table is unavailable') + } + return result.stdout +} + +function findTaggedProcesses(token: string): TaggedProcess[] { + const output = readPs(['-axo', 'pid=,pgid=,state=,command=']) + const matches: TaggedProcess[] = [] + for (const line of output.split(/\r?\n/)) { + if (!line.includes(token)) { + continue + } + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+/.exec(line) + if (match && !match[3].includes('Z')) { + matches.push({ pid: Number(match[1]), pgid: Number(match[2]) }) + } + } + return matches +} + +async function waitFor( + predicate: () => boolean, + timeoutMs = 5_000, + intervalMs = 25 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for PTY process state') + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } +} + +function cleanupTaggedProcesses(token: string): void { + for (const pgid of new Set(findTaggedProcesses(token).map((process) => process.pgid))) { + try { + process.kill(-pgid, 'SIGKILL') + } catch { + // The tagged test group may already be gone. + } + } +} + +function readProcessGroup(pid: number): number { + return Number(readPs(['-p', String(pid), '-o', 'pgid=']).trim()) +} + +export async function reapPosixForegroundJob(): Promise<{ + separateForegroundGroup: boolean + leaderExited: boolean + remainingTaggedProcesses: number +}> { + const token = `ORCA_PTY_GROUP_TEST_${process.pid}_${Date.now()}` + const proc = spawnBunPty({ + file: '/bin/sh', + args: [], + cols: 80, + rows: 24, + cwd: process.cwd(), + env: Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) + }) + let leaderExited = false + const exitSubscription = proc.onExit(() => { + leaderExited = true + }) + const dataSubscription = proc.onData(() => {}) + try { + await proc.waitForSpawn?.() + proc.write(`/bin/sh -lc 'trap "" HUP TERM; while :; do sleep 60; done' ${token}\n`) + await waitFor(() => findTaggedProcesses(token).length > 0) + const rootProcessGroup = readProcessGroup(proc.pid) + const separateForegroundGroup = findTaggedProcesses(token).some( + (process) => process.pgid !== rootProcessGroup + ) + forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) + await waitFor(() => leaderExited) + await waitFor(() => findTaggedProcesses(token).length === 0) + return { + separateForegroundGroup, + leaderExited, + remainingTaggedProcesses: findTaggedProcesses(token).length + } + } finally { + exitSubscription.dispose() + dataSubscription.dispose() + cleanupTaggedProcesses(token) + try { + proc.kill('SIGKILL') + } catch { + // The PTY leader may already have exited. + } + } +} diff --git a/src/main/pty/posix-pty-process-groups.integration.test.ts b/src/main/pty/posix-pty-process-groups.integration.test.ts index f8b191f98566..4bf72b6322aa 100644 --- a/src/main/pty/posix-pty-process-groups.integration.test.ts +++ b/src/main/pty/posix-pty-process-groups.integration.test.ts @@ -1,94 +1,23 @@ -import { execFileSync } from 'node:child_process' -import * as pty from 'node-pty' +import { join } from 'node:path' import { expect, it } from 'vitest' -import { forceKillPosixPtyProcessGroups } from './posix-pty-process-groups' - -type TaggedProcess = { pid: number; pgid: number } +import { runBundledBunFixture } from '../bundled-bun-test-execution' const itOnPosix = process.platform === 'win32' ? it.skip : it -function findTaggedProcesses(token: string): TaggedProcess[] { - const output = execFileSync('ps', ['-axo', 'pid=,pgid=,state=,command='], { - encoding: 'utf8' - }) - const matches: TaggedProcess[] = [] - for (const line of output.split(/\r?\n/)) { - if (!line.includes(token)) { - continue - } - const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+/.exec(line) - if (match && !match[3].includes('Z')) { - matches.push({ pid: Number(match[1]), pgid: Number(match[2]) }) - } - } - return matches -} - -async function waitFor( - predicate: () => boolean, - timeoutMs = 5_000, - intervalMs = 25 -): Promise { - const deadline = Date.now() + timeoutMs - while (!predicate()) { - if (Date.now() >= deadline) { - throw new Error('Timed out waiting for PTY process state') - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)) - } -} - -function cleanupTaggedProcesses(token: string): void { - for (const pgid of new Set(findTaggedProcesses(token).map((process) => process.pgid))) { - try { - process.kill(-pgid, 'SIGKILL') - } catch { - // The tagged test group may already be gone. - } - } -} - -function readProcessGroup(pid: number): number { - return Number(execFileSync('ps', ['-p', String(pid), '-o', 'pgid='], { encoding: 'utf8' }).trim()) -} - itOnPosix( 'reaps a foreground job that ignores terminal shutdown signals', async () => { - const token = `ORCA_PTY_GROUP_TEST_${process.pid}_${Date.now()}` - const proc = pty.spawn('/bin/sh', [], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: process.cwd(), - env: process.env as Record + const result = await runBundledBunFixture( + join(__dirname, 'posix-pty-process-groups-fixture.ts'), + 'reapPosixForegroundJob', + null, + 15_000 + ) + expect(result).toEqual({ + separateForegroundGroup: true, + leaderExited: true, + remainingTaggedProcesses: 0 }) - const exited = new Promise((resolve) => proc.onExit(() => resolve())) - - try { - proc.write(`/bin/sh -lc 'trap "" HUP TERM; while :; do sleep 60; done' ${token}\n`) - await waitFor(() => findTaggedProcesses(token).length > 0) - - const tagged = findTaggedProcesses(token) - const rootProcessGroup = readProcessGroup(proc.pid) - expect(tagged.some((process) => process.pgid !== rootProcessGroup)).toBe(true) - - forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) - await Promise.race([ - exited, - new Promise((_, reject) => - setTimeout(() => reject(new Error('PTY leader did not exit')), 5_000) - ) - ]) - await waitFor(() => findTaggedProcesses(token).length === 0) - } finally { - cleanupTaggedProcesses(token) - try { - proc.kill('SIGKILL') - } catch { - // node-pty may already have reaped and disposed the leader. - } - } }, - 10_000 + 20_000 ) diff --git a/src/main/pty/terminal-slave-device.ts b/src/main/pty/terminal-slave-device.ts new file mode 100644 index 000000000000..339dbf69e8eb --- /dev/null +++ b/src/main/pty/terminal-slave-device.ts @@ -0,0 +1,7 @@ +/** Backend-specific slave-device paths are optional on POSIX and absent on Windows. */ +export function readPtsName(proc: unknown): string | undefined { + if (typeof proc !== 'object' || proc === null || !('ptsName' in proc)) { + return undefined + } + return typeof proc.ptsName === 'string' && proc.ptsName.length > 0 ? proc.ptsName : undefined +} diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index f2fd976821b6..d5ace8b54ef1 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -9,8 +9,8 @@ vi.mock('../codex-cli/command', () => ({ resolveClaudeCommand: resolveClaudeCommandMock })) -vi.mock('node-pty', () => ({ - spawn: spawnMock +vi.mock('./hidden-daemon-pty', () => ({ + spawnHiddenDaemonPty: spawnMock })) import { fetchViaPty } from './claude-pty' @@ -22,6 +22,7 @@ function makeDisposable() { type MockTerm = { onData: ReturnType + onError: ReturnType onExit: ReturnType write: ReturnType kill: ReturnType @@ -38,6 +39,7 @@ function makeMockTerm(): MockTerm & { dataHandler = handler return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn((handler: () => void) => { exitHandler = handler return makeDisposable() @@ -56,6 +58,24 @@ describe('fetchViaPty', () => { resolveClaudeCommandMock.mockReturnValue('claude') }) + it('settles a transport error immediately and releases the hidden probe', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + const result = fetchViaPty() + await vi.advanceTimersByTimeAsync(0) + const callback = term.onError.mock.calls[0][0] + callback(new Error('Terminal service connection lost during usage probe')) + await expect(result).resolves.toMatchObject({ + status: 'error', + session: null, + weekly: null, + error: 'Terminal service connection lost during usage probe' + }) + expect(term.kill).toHaveBeenCalledOnce() + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() @@ -63,6 +83,7 @@ describe('fetchViaPty', () => { spawnMock.mockReturnValue({ onData: vi.fn(() => onDataDisposable), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => onExitDisposable), write: vi.fn(), kill: killMock diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index cbaedbd17f9d..7cda9ddab2e8 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -1,3 +1,4 @@ +import { spawnHiddenDaemonPty } from './hidden-daemon-pty' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' import { resolveClaudeCommand } from '../codex-cli/command' @@ -48,9 +49,77 @@ export async function fetchViaPty(options?: { if (options?.signal?.aborted) { return abortedClaudeUsageResult() } - const pty = await import('node-pty') - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() + + const claudeCommand = resolveClaudeCommand() + + // Windows CLI shims require their existing cmd.exe wrapper. + const spawnEnv = applyClaudeEnvPatch( + { ...process.env, TERM: 'xterm-256color' } as Record, + options?.authPreparation?.envPatch ?? {}, + { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } + ) + // Why: this hidden usage PTY spawns `claude` directly, not the user's shell + // wrapper, so without the configured proxy it would reach api.anthropic.com + // from the app's own IP — bypassing the proxy the user set for Claude and + // risking rate-limit/geo signals on the account. Falls back to {} when unset. + const proxyEnv = buildConfiguredProxyEnv(options?.networkProxySettings) + Object.assign(spawnEnv, proxyEnv) + const authPreparation = options?.authPreparation + const wslConfig = + authPreparation?.runtime === 'wsl' && + authPreparation.wslDistro && + authPreparation.wslLinuxConfigDir + ? { + distro: authPreparation.wslDistro, + linuxConfigDir: authPreparation.wslLinuxConfigDir + } + : null + const spawnFile = wslConfig ? 'wsl.exe' : process.platform === 'win32' ? 'cmd.exe' : claudeCommand + const spawnArgs = wslConfig + ? [ + '-d', + wslConfig.distro, + '--exec', + 'bash', + '-lc', + // Why: Windows-side env does not cross into the distro without WSLENV, + // so export the configured proxy inside the command for the inner claude. + [ + // Why: hidden usage probes must not inherit a root-like WSL cwd; + // keep Claude discovery bounded to a tiny temp directory. + ...getHiddenRateLimitWslCwdSetupCommands(), + `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, + ...Object.entries(proxyEnv).map( + ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` + ), + 'exec claude' + ].join(' && ') + ] + : process.platform === 'win32' + ? ['/c', `"${claudeCommand}"`] + : [] + + let term: Awaited> + try { + term = await spawnHiddenDaemonPty( + spawnFile, + spawnArgs, + { + name: 'xterm-256color', + cols: 120, + rows: 40, + // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a + // drive root), which can trigger unbounded file discovery. + cwd: resolveHiddenRateLimitPtyCwd(), + env: withCliRuntimeOnPath(claudeCommand, spawnEnv) + }, + options?.signal + ) + } catch (error) { + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } + throw error } return new Promise((resolve) => { @@ -63,67 +132,6 @@ export async function fetchViaPty(options?: { let stopSettleTimer: ReturnType | null = null let claude21UsageSettleTimer: ReturnType | null = null - const claudeCommand = resolveClaudeCommand() - - // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — - // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 - // and ensure the command path is properly quoted if it contains spaces. - const isWin32 = process.platform === 'win32' - const spawnEnv = applyClaudeEnvPatch( - { ...process.env, TERM: 'xterm-256color' } as Record, - options?.authPreparation?.envPatch ?? {}, - { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } - ) - // Why: this hidden usage PTY spawns `claude` directly, not the user's shell - // wrapper, so without the configured proxy it would reach api.anthropic.com - // from the app's own IP — bypassing the proxy the user set for Claude and - // risking rate-limit/geo signals on the account. Falls back to {} when unset. - const proxyEnv = buildConfiguredProxyEnv(options?.networkProxySettings) - Object.assign(spawnEnv, proxyEnv) - const authPreparation = options?.authPreparation - const wslConfig = - authPreparation?.runtime === 'wsl' && - authPreparation.wslDistro && - authPreparation.wslLinuxConfigDir - ? { - distro: authPreparation.wslDistro, - linuxConfigDir: authPreparation.wslLinuxConfigDir - } - : null - const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = wslConfig - ? [ - '-d', - wslConfig.distro, - '--exec', - 'bash', - '-lc', - // Why: Windows-side env does not cross into the distro without WSLENV, - // so export the configured proxy inside the command for the inner claude. - [ - // Why: hidden usage probes must not inherit a root-like WSL cwd; - // keep Claude discovery bounded to a tiny temp directory. - ...getHiddenRateLimitWslCwdSetupCommands(), - `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, - ...Object.entries(proxyEnv).map( - ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` - ), - 'exec claude' - ].join(' && ') - ] - : isWin32 - ? ['/c', `"${claudeCommand}"`] - : [] - - const term = pty.spawn(spawnFile, spawnArgs, { - name: 'xterm-256color', - cols: 120, - rows: 40, - // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a - // drive root), which can trigger unbounded file discovery. - cwd: resolveHiddenRateLimitPtyCwd(), - env: withCliRuntimeOnPath(claudeCommand, spawnEnv) - }) const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] let enterInterval: ReturnType | null = null let timeout: ReturnType | null = null @@ -318,12 +326,10 @@ export async function fetchViaPty(options?: { } } }) - if (onDataDisposable) { - termDisposables.push(onDataDisposable) - } + termDisposables.push(onDataDisposable) - const onExitDisposable = term.onExit(() => { - cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) + const finish = (failure?: Error): void => { + cleanupHiddenRateLimitPty(term, termDisposables, { kill: Boolean(failure) }) clearFollowupTimers() if (!resolved) { resolved = true @@ -332,7 +338,9 @@ export async function fetchViaPty(options?: { timeout = null } const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) + const { session, weekly, fableWeekly } = failure + ? { session: null, weekly: null, fableWeekly: null } + : parseClaudePtyUsage(clean) resolve({ provider: 'claude', session, @@ -340,15 +348,15 @@ export async function fetchViaPty(options?: { fableWeekly, updatedAt: Date.now(), error: - session || weekly || fableWeekly + failure?.message ?? + (session || weekly || fableWeekly ? null - : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), + : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean)), status: session || weekly || fableWeekly ? 'ok' : 'error' }) } - }) - if (onExitDisposable) { - termDisposables.push(onExitDisposable) } + termDisposables.push(term.onError(finish)) + termDisposables.push(term.onExit(() => finish())) }) } diff --git a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts index f482d2594b3c..5674cbc0116d 100644 --- a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts +++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts @@ -15,8 +15,8 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock +vi.mock('./hidden-daemon-pty', () => ({ + spawnHiddenDaemonPty: ptySpawnMock })) // Auth gate is covered separately; these tests assume a signed-in Codex. @@ -166,6 +166,7 @@ describe('fetchCodexRateLimits auth errors', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn((callback) => { ptyHandlers.onExit = callback return makeDisposable() @@ -202,6 +203,7 @@ describe('fetchCodexRateLimits auth errors', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write: ptyWrite, kill: ptyKill diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 3fefe5e3a2fd..3faaf88b4c9e 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -10,7 +10,7 @@ const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) -vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('./hidden-daemon-pty', () => ({ spawnHiddenDaemonPty: ptySpawnMock })) vi.mock('./codex-auth-presence', () => ({ probeCodexAuthPresence: vi.fn(async () => 'present') })) diff --git a/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts b/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts index db0c4a7f5ed2..ef3d0a2f9953 100644 --- a/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts +++ b/src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts @@ -20,8 +20,8 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock +vi.mock('./hidden-daemon-pty', () => ({ + spawnHiddenDaemonPty: ptySpawnMock })) // Auth gate is covered by codex-auth-presence.test.ts; these tests assume a signed-in Codex. diff --git a/src/main/rate-limits/codex-fetcher-process-contract.test.ts b/src/main/rate-limits/codex-fetcher-process-contract.test.ts index 8d067a686ad3..24e5687c0194 100644 --- a/src/main/rate-limits/codex-fetcher-process-contract.test.ts +++ b/src/main/rate-limits/codex-fetcher-process-contract.test.ts @@ -19,8 +19,8 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock +vi.mock('./hidden-daemon-pty', () => ({ + spawnHiddenDaemonPty: ptySpawnMock })) vi.mock('./codex-auth-presence', () => ({ diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts index 15c338aad89b..90c1c4c1ae34 100644 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -14,8 +14,8 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock +vi.mock('./hidden-daemon-pty', () => ({ + spawnHiddenDaemonPty: ptySpawnMock })) // Auth gate is covered separately; these tests assume a signed-in Codex. @@ -47,6 +47,7 @@ describe('fetchCodexRateLimits PTY settle timers', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write: vi.fn(), kill: vi.fn() @@ -90,6 +91,7 @@ describe('fetchCodexRateLimits PTY settle timers', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write: vi.fn(), kill: vi.fn() @@ -131,6 +133,7 @@ describe('fetchCodexRateLimits PTY settle timers', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write: vi.fn(), kill: vi.fn() @@ -176,6 +179,7 @@ describe('fetchCodexRateLimits PTY settle timers', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write, kill: vi.fn() @@ -229,6 +233,7 @@ describe('fetchCodexRateLimits PTY settle timers', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write: vi.fn(), kill: vi.fn() @@ -268,6 +273,7 @@ describe('fetchCodexRateLimits PTY settle timers', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write, kill: vi.fn() diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts index ee874ef59429..5f7516edaca6 100644 --- a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -20,8 +20,8 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock +vi.mock('./hidden-daemon-pty', () => ({ + spawnHiddenDaemonPty: ptySpawnMock })) vi.mock('./codex-auth-presence', () => ({ diff --git a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts index 0353bd4c31da..094cbbaf7134 100644 --- a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts +++ b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts @@ -14,7 +14,7 @@ const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) -vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('./hidden-daemon-pty', () => ({ spawnHiddenDaemonPty: ptySpawnMock })) vi.mock('../codex/codex-state-db', () => ({ isCodexStateDbBackfillPending: vi.fn(() => false) })) vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ startCodexStateDbBackfillRecoveryInBackground: vi.fn(() => Promise.resolve(null)) diff --git a/src/main/rate-limits/codex-fetcher-session-supplement.test.ts b/src/main/rate-limits/codex-fetcher-session-supplement.test.ts index 232bfc9a83dc..22efa3375302 100644 --- a/src/main/rate-limits/codex-fetcher-session-supplement.test.ts +++ b/src/main/rate-limits/codex-fetcher-session-supplement.test.ts @@ -9,7 +9,7 @@ const { childSpawnMock, readFileMock } = vi.hoisted(() => ({ vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) -vi.mock('node-pty', () => ({ spawn: vi.fn() })) +vi.mock('./hidden-daemon-pty', () => ({ spawnHiddenDaemonPty: vi.fn() })) vi.mock('./codex-auth-presence', () => ({ probeCodexAuthPresence: vi.fn(async () => 'present') })) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index be6edd3b3fb6..d7f33fa1d6a3 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -30,8 +30,8 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) -vi.mock('node-pty', () => ({ - spawn: ptySpawnMock +vi.mock('./hidden-daemon-pty', () => ({ + spawnHiddenDaemonPty: ptySpawnMock })) vi.mock('../codex/codex-state-db', () => ({ @@ -117,6 +117,7 @@ function makePtyTerm() { dataHandler = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn((callback: () => void) => { exitHandler = callback return makeDisposable() @@ -222,6 +223,7 @@ describe('fetchCodexRateLimits', () => { }) ptySpawnMock.mockReturnValue({ onData: vi.fn(() => onDataDisposable), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => onExitDisposable), write: vi.fn(), kill: killMock @@ -328,6 +330,7 @@ describe('fetchCodexRateLimits', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write: vi.fn(), kill: vi.fn() @@ -824,6 +827,7 @@ describe('fetchCodexRateLimits', () => { ptyHandlers.onData = callback return makeDisposable() }), + onError: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => makeDisposable()), write: vi.fn(), kill: vi.fn() diff --git a/src/main/rate-limits/codex-pty-rate-limit-probe.test.ts b/src/main/rate-limits/codex-pty-rate-limit-probe.test.ts index de3c6631dcc2..01178c10969d 100644 --- a/src/main/rate-limits/codex-pty-rate-limit-probe.test.ts +++ b/src/main/rate-limits/codex-pty-rate-limit-probe.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) +vi.mock('./hidden-daemon-pty', () => ({ spawnHiddenDaemonPty: spawnMock })) +import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' import { fetchCodexRateLimitsViaPty } from './codex-pty-rate-limit-probe' +afterEach(() => vi.useRealTimers()) + describe('Codex PTY rate-limit probe cancellation', () => { it('does not resolve the process command after cancellation', async () => { const controller = new AbortController() @@ -21,4 +26,56 @@ describe('Codex PTY rate-limit probe cancellation', () => { }) expect(resolveCommand).not.toHaveBeenCalled() }) + + it('settles a transport failure once and cancels pending status input', async () => { + vi.useFakeTimers() + const data = new Set<(value: string) => void>() + const errors = new Set<(error: Error) => void>() + const exits = new Set<() => void>() + const term = { + pid: 123, + write: vi.fn(), + kill: vi.fn(), + onData: (listener: (value: string) => void) => { + data.add(listener) + return { dispose: () => data.delete(listener) } + }, + onError: (listener: (error: Error) => void) => { + errors.add(listener) + return { dispose: () => errors.delete(listener) } + }, + onExit: (listener: () => void) => { + exits.add(listener) + return { dispose: () => exits.delete(listener) } + } + } + spawnMock.mockResolvedValue(term) + const result = fetchCodexRateLimitsViaPty(() => ({ + command: 'codex', + args: [], + cwd: '.', + env: {} + })) + await Promise.resolve() + for (const listener of data) { + listener('> ') + } + expect(term.write).toHaveBeenCalledExactlyOnceWith('/status') + for (const listener of errors) { + listener(new Error('transport unavailable')) + } + await expect(result).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + session: null, + weekly: null, + error: 'transport unavailable' + }) + expect(term.kill).toHaveBeenCalledOnce() + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + expect([data.size, errors.size, exits.size]).toEqual([0, 0, 0]) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(20_000) + expect(term.write).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/rate-limits/codex-pty-rate-limit-probe.ts b/src/main/rate-limits/codex-pty-rate-limit-probe.ts index 194e7d19fd65..f085cbb543b7 100644 --- a/src/main/rate-limits/codex-pty-rate-limit-probe.ts +++ b/src/main/rate-limits/codex-pty-rate-limit-probe.ts @@ -1,3 +1,4 @@ +import { spawnHiddenDaemonPty } from './hidden-daemon-pty' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { extractCodexAuthError } from '../../shared/codex-auth-errors' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' @@ -27,15 +28,32 @@ export async function fetchCodexRateLimitsViaPty( resolveCommand: () => CodexPtyRateLimitCommand, options?: CodexRateLimitFetchOptions ): Promise { - if (options?.signal?.aborted) { - return abortedCodexRateLimitResult() - } - const pty = await import('node-pty') if (options?.signal?.aborted) { return abortedCodexRateLimitResult() } const command = resolveCommand() + let term: Awaited> + try { + term = await spawnHiddenDaemonPty( + command.command, + command.args, + { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: command.cwd, + env: command.env + }, + options?.signal + ) + } catch (error) { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + throw error + } + return new Promise((resolve) => { let output = '' let resolved = false @@ -43,13 +61,6 @@ export async function fetchCodexRateLimitsViaPty( let settleTimer: ReturnType | null = null let timeout: ReturnType | null = null - const term = pty.spawn(command.command, command.args, { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: command.cwd, - env: command.env - }) const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] let statusEnter: ReturnType | null = null @@ -202,6 +213,25 @@ export async function fetchCodexRateLimitsViaPty( termDisposables.push(onDataDisposable) } + termDisposables.push( + term.onError((error) => { + if (resolved) { + return + } + resolved = true + clearSettleTimers() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + resolve({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: error.message, + status: 'error' + }) + }) + ) + const onExitDisposable = term.onExit(() => { cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) if (settleTimer) { diff --git a/src/main/rate-limits/hidden-daemon-pty.test.ts b/src/main/rate-limits/hidden-daemon-pty.test.ts new file mode 100644 index 000000000000..76bd6d0c3c3a --- /dev/null +++ b/src/main/rate-limits/hidden-daemon-pty.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + event: (_value: unknown) => {}, + disconnected: () => {}, + connect: vi.fn(), + request: vi.fn(), + disconnect: vi.fn(), + notify: vi.fn(), + removeEvent: vi.fn(), + removeDisconnect: vi.fn() +})) +vi.mock('../daemon/daemon-provider-state', () => ({ + getDaemonEndpointFacts: () => ({ socketPath: '/fixture/socket', tokenPath: '/fixture/token' }) +})) +vi.mock('../daemon/client', () => ({ + DaemonClient: class { + ensureConnected = state.connect + request = state.request + disconnect = state.disconnect + notify = state.notify + onEvent(callback: typeof state.event) { + state.event = callback + return state.removeEvent + } + onDisconnected(callback: () => void) { + state.disconnected = callback + return state.removeDisconnect + } + } +})) +import { spawnHiddenDaemonPty } from './hidden-daemon-pty' +const options = { + cwd: '/fixture', + cols: 120, + rows: 40, + env: { TOKEN: 'fixture', REMOVED: undefined } +} + +beforeEach(() => { + vi.clearAllMocks() + state.notify.mockReturnValue(true) + state.connect.mockResolvedValue(undefined) + state.request.mockImplementation(async (type) => + type === 'ping' ? { pong: true, capabilities: { transientPty: 1 } } : { pid: 123 } + ) +}) + +describe('hidden daemon PTY client', () => { + it('refuses an old daemon without spawning or replacing it', async () => { + state.request.mockResolvedValue({ pong: true }) + await expect(spawnHiddenDaemonPty('/tool', [], options)).rejects.toThrow('does not support') + expect(state.request).toHaveBeenCalledTimes(1) + expect(state.disconnect).toHaveBeenCalledOnce() + }) + + it('preserves exact arguments and strips absent environment values', async () => { + const term = await spawnHiddenDaemonPty('/tool', ['a b', '$literal'], options) + expect(state.request.mock.calls[1][1]).toMatchObject({ + file: '/tool', + args: ['a b', '$literal'], + env: { TOKEN: 'fixture' }, + cwd: '/fixture' + }) + expect(state.request.mock.calls[1][1].env).not.toHaveProperty('REMOVED') + term.kill() + expect(state.removeEvent).toHaveBeenCalledOnce() + expect(state.removeDisconnect).toHaveBeenCalledOnce() + }) + + it('buffers output and exit arriving before the create reply', async () => { + state.request.mockImplementation(async (type, payload) => { + if (type === 'ping') { + return { capabilities: { transientPty: 1 } } + } + state.event({ + type: 'event', + event: 'data', + sessionId: payload.id, + payload: { data: 'first' } + }) + state.event({ type: 'event', event: 'exit', sessionId: payload.id, payload: { code: 0 } }) + return { pid: 123 } + }) + const term = await spawnHiddenDaemonPty('/tool', [], options) + const events: unknown[] = [] + term.onData((data) => events.push(data)) + term.onExit((event) => events.push(event)) + await Promise.resolve() + expect(events).toEqual(['first', { exitCode: 0 }]) + term.destroy() + }) + + it('closes a connection that completes after abort', async () => { + let connected: () => void = () => {} + state.connect.mockImplementation( + () => + new Promise((resolve) => { + connected = resolve + }) + ) + const controller = new AbortController() + const creation = spawnHiddenDaemonPty('/tool', [], options, controller.signal) + controller.abort() + connected() + await expect(creation).rejects.toThrow() + expect(state.request).not.toHaveBeenCalled() + expect(state.disconnect).toHaveBeenCalledTimes(2) + }) + it.each(['disconnect', 'terminalError', 'overflow'])( + 'reports %s as failure, never process exit', + async (failure) => { + const term = await spawnHiddenDaemonPty('/tool', [], options) + const onError = vi.fn() + const onExit = vi.fn() + term.onError(onError) + term.onExit(onExit) + const id = state.request.mock.calls[1][1].id + if (failure === 'disconnect') { + state.disconnected() + } else { + state.event({ + type: 'event', + event: failure === 'overflow' ? 'data' : 'terminalError', + sessionId: id, + payload: failure === 'overflow' ? { data: 'x'.repeat(128 * 1024 + 1) } : {} + }) + } + await Promise.resolve() + expect(onError).toHaveBeenCalledOnce() + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error) + expect(onExit).not.toHaveBeenCalled() + expect(state.removeEvent).toHaveBeenCalledOnce() + expect(state.removeDisconnect).toHaveBeenCalledOnce() + } + ) + + it('keeps a confirmed exit when transport closes and delivers it only once', async () => { + const term = await spawnHiddenDaemonPty('/tool', [], options) + const onExit = vi.fn() + const onError = vi.fn() + term.onExit(onExit) + term.onError(onError) + const id = state.request.mock.calls[1][1].id + state.event({ type: 'event', event: 'exit', sessionId: id, payload: { code: 7 } }) + state.disconnected() + await Promise.resolve() + term.onData(vi.fn()) + await Promise.resolve() + expect(onExit).toHaveBeenCalledExactlyOnceWith({ exitCode: 7 }) + expect(onError).not.toHaveBeenCalled() + }) + + it('reports a failed input delivery once without claiming process exit', async () => { + const term = await spawnHiddenDaemonPty('/tool', [], options) + const onError = vi.fn() + const onExit = vi.fn() + term.onError(onError) + term.onExit(onExit) + state.notify.mockReturnValue(false) + term.write('/status') + term.write('\r') + state.disconnected() + await Promise.resolve() + expect(state.notify).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledExactlyOnceWith( + new Error('Terminal service could not receive usage probe input') + ) + expect(onExit).not.toHaveBeenCalled() + }) + + it('rejects a create receipt arriving after transport failure and removes listeners', async () => { + state.request.mockImplementation(async (type) => { + if (type === 'ping') { + return { capabilities: { transientPty: 1 } } + } + state.disconnected() + return { pid: 123 } + }) + await expect(spawnHiddenDaemonPty('/tool', [], options)).rejects.toThrow('connection lost') + expect(state.removeEvent).toHaveBeenCalledOnce() + expect(state.removeDisconnect).toHaveBeenCalledOnce() + }) + + it('retains a failure until listeners attach and ignores late exit evidence', async () => { + const term = await spawnHiddenDaemonPty('/tool', [], options) + state.disconnected() + await Promise.resolve() + const onError = vi.fn() + const onExit = vi.fn() + term.onError(onError) + term.onExit(onExit) + const id = state.request.mock.calls[1][1].id + state.event({ type: 'event', event: 'exit', sessionId: id, payload: { code: 0 } }) + await Promise.resolve() + expect(onError).toHaveBeenCalledOnce() + expect(onExit).not.toHaveBeenCalled() + expect(state.removeEvent).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/rate-limits/hidden-daemon-pty.ts b/src/main/rate-limits/hidden-daemon-pty.ts new file mode 100644 index 000000000000..3aaf3fb90f8f --- /dev/null +++ b/src/main/rate-limits/hidden-daemon-pty.ts @@ -0,0 +1,198 @@ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import { DaemonClient } from '../daemon/client' +import { getDaemonEndpointFacts } from '../daemon/daemon-provider-state' + +const probeEvent = z.object({ + type: z.literal('event'), + event: z.enum(['data', 'exit', 'terminalError']), + sessionId: z.string(), + payload: z.object({ data: z.string().optional(), code: z.number().optional() }) +}) +const capability = z.object({ capabilities: z.object({ transientPty: z.literal(1) }) }) + +export async function spawnHiddenDaemonPty( + file: string, + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv; cols: number; rows: number; name?: string }, + signal?: AbortSignal +) { + signal?.throwIfAborted() + const endpoint = getDaemonEndpointFacts() + if (!endpoint) { + throw new Error('Terminal service unavailable for usage probe') + } + const client = new DaemonClient(endpoint) + const id = randomUUID() + const dataListeners = new Set<(data: string) => void>() + const exitListeners = new Set<(event: { exitCode: number }) => void>() + let pending = '' + const errorListeners = new Set<(error: Error) => void>() + let outcome: { exitCode: number } | Error | null = null + const getFailure = (): Error | null => (outcome instanceof Error ? outcome : null) + let delivered = false + let closed = false + let activationQueued = false + const close = (): void => { + if (closed) { + client.disconnect() + return + } + closed = true + signal?.removeEventListener('abort', close) + removeEvents() + removeDisconnect() + dataListeners.clear() + exitListeners.clear() + errorListeners.clear() + pending = '' + client.disconnect() + } + const drain = (): void => { + if (activationQueued) { + return + } + activationQueued = true + queueMicrotask(() => { + activationQueued = false + if (closed) { + return + } + if (pending && dataListeners.size) { + const output = pending + pending = '' + for (const listener of dataListeners) { + listener(output) + } + } + if (closed || delivered || outcome === null) { + return + } + if (outcome instanceof Error ? errorListeners.size : exitListeners.size) { + delivered = true + try { + if (outcome instanceof Error) { + for (const listener of errorListeners) { + listener(outcome) + } + } else { + for (const listener of exitListeners) { + listener(outcome) + } + } + } finally { + close() + } + } + }) + } + const fail = (message: string): void => { + if (closed || outcome !== null) { + return + } + outcome = new Error(message) + pending = '' + client.disconnect() + drain() + } + const removeEvents = client.onEvent((input) => { + const parsed = probeEvent.safeParse(input) + if (!parsed.success || parsed.data.sessionId !== id || closed || outcome !== null) { + return + } + const event = parsed.data + if (event.event === 'data' && event.payload.data !== undefined) { + pending += event.payload.data + if (pending.length > 128 * 1024) { + fail('Usage probe output exceeded its buffer limit') + return + } + } else if (event.event === 'exit' && event.payload.code !== undefined) { + outcome = { exitCode: event.payload.code } + } else if (event.event !== 'data') { + fail('Terminal service could not complete the usage probe') + return + } + drain() + }) + const removeDisconnect = client.onDisconnected(() => { + fail('Terminal service connection lost during usage probe') + }) + signal?.addEventListener('abort', close, { once: true }) + try { + await client.ensureConnected() + signal?.throwIfAborted() + if (!capability.safeParse(await client.request('ping', {}, 5_000, signal)).success) { + throw new Error( + 'Running terminal service does not support hidden usage probes; retry after its terminals close' + ) + } + const env = Object.fromEntries( + Object.entries(options.env).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + const result = await client.request<{ pid: number }>( + 'createTransientPty', + { + id, + file, + args, + cwd: options.cwd, + env, + cols: options.cols, + rows: options.rows + }, + 10_000, + signal + ) + signal?.throwIfAborted() + if (closed) { + throw new Error('Usage probe canceled during startup') + } + const failure = getFailure() + if (failure) { + throw failure + } + return { + pid: result.pid, + write(data: string) { + if (!closed && outcome === null && !client.notify('writeTransientPty', { id, data })) { + fail('Terminal service could not receive usage probe input') + } + }, + kill: close, + destroy: close, + onData(listener: (data: string) => void) { + dataListeners.add(listener) + drain() + return { + dispose: () => { + dataListeners.delete(listener) + } + } + }, + onError(listener: (error: Error) => void) { + errorListeners.add(listener) + drain() + return { + dispose: () => { + errorListeners.delete(listener) + } + } + }, + onExit(listener: (event: { exitCode: number }) => void) { + exitListeners.add(listener) + drain() + return { + dispose: () => { + exitListeners.delete(listener) + } + } + } + } + } catch (error) { + close() + throw error + } +} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index 43e8453fa0fd..0d2054a5ddb0 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -20,7 +20,7 @@ describe('cleanupHiddenRateLimitPty', () => { vi.clearAllMocks() }) - it('disposes listeners, kills the child, then destroys the PTY fd on POSIX', () => { + it('disposes listeners before closing the hidden probe lease', () => { setPlatform('darwin') const dataDisposable = { dispose: vi.fn() } const exitDisposable = { dispose: vi.fn() } @@ -38,9 +38,7 @@ describe('cleanupHiddenRateLimitPty', () => { expect(exitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( killMock.mock.invocationCallOrder[0] ) - expect(killMock.mock.invocationCallOrder[0]).toBeLessThan( - term.destroy.mock.invocationCallOrder[0] - ) + expect(term.destroy).not.toHaveBeenCalled() }) it('releases the PTY fd without killing again after natural exit', () => { @@ -57,52 +55,6 @@ describe('cleanupHiddenRateLimitPty', () => { expect(term.destroy).toHaveBeenCalledTimes(1) }) - it('neutralizes POSIX destroy-time SIGHUP after the intentional kill', () => { - setPlatform('linux') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn(() => { - term.kill('SIGHUP') - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(killMock).toHaveBeenCalledTimes(1) - expect(killMock).toHaveBeenCalledWith() - }) - - it('does not destroy after an intentional Windows kill because destroy kills again', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).not.toHaveBeenCalled() - }) - - it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - it('removes registered hidden PTYs when cleanup kills them', () => { setPlatform('darwin') const term = { diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 068b937ee844..4c868e61f754 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -31,27 +31,12 @@ export function cleanupHiddenRateLimitPty( disposable.dispose() } - if (options.kill) { - try { + try { + if (options.kill) { term.kill() - } catch { - /* already exited */ - } - - // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can - // close the same ConPTY handle twice after an intentional termination. - if (process.platform === 'win32') { - return + } else { + term.destroy?.() } - } - - // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize - // the post-close SIGHUP hook after exit/kill to avoid pid reuse. - if (process.platform !== 'win32') { - term.kill = () => {} - } - try { - term.destroy?.() } catch { /* already torn down */ } diff --git a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts index 7994f7d8857d..1110caa42f1f 100644 --- a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts +++ b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts @@ -115,7 +115,7 @@ export class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRuntimeWithA size?: { cols: number; rows: number }, metadata: HeadlessSeedMetadata = {} ): void { - if (!data) { + if (!data && !metadata.allowEmpty) { return } const existing = this.headlessTerminals.get(ptyId) diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 37ac8c5a8394..fbe75df04703 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -110,9 +110,6 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP }, trailingOutput: { data: string; seq: number }[] = [] ): void { - if (!snapshot.data) { - return - } // Why: a redraw byte can create a suffix-only model before the renderer settles; replace it with the exact snapshot already sent mobile. this.providerSnapshotPreferredPtys.add(ptyId) this.disposeHeadlessTerminal(ptyId) @@ -120,7 +117,7 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP ptyId, snapshot.data, { cols: snapshot.cols, rows: snapshot.rows }, - { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } + { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks, allowEmpty: true } ) for (const chunk of trailingOutput) { this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) diff --git a/src/main/runtime/orchestration-codex-real-pty.integration.test.ts b/src/main/runtime/orchestration-codex-real-pty.integration.test.ts index 28be6b513a75..cb0843e44cea 100644 --- a/src/main/runtime/orchestration-codex-real-pty.integration.test.ts +++ b/src/main/runtime/orchestration-codex-real-pty.integration.test.ts @@ -2,7 +2,8 @@ import { createServer } from 'node:http' import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import * as pty from 'node-pty' +import { startBundledBunPty } from '../daemon/pty-subprocess/bun-tests/bundled-bun-pty-fixture' +import { getForegroundProcessName } from '../../relay/pty-shell-utils' import { expect, it, vi } from 'vitest' import { AgentHookServer } from '../agent-hooks/server' import { getManagedScript } from '../codex/codex-hook-script' @@ -144,7 +145,8 @@ it.skipIf(!binary || process.platform === 'win32').each(trials)( writeFileSync( join(directory, 'config.toml'), [ - 'model="gpt-5.6-terra"', + // A synthetic model avoids first-run migration prompts for retired public models. + 'model="orca-fixture-model"', 'model_provider="fixture"', 'check_for_update_on_startup=false', '[model_providers.fixture]', @@ -164,27 +166,27 @@ it.skipIf(!binary || process.platform === 'win32').each(trials)( value !== undefined && !key.startsWith('ORCA_') && !key.startsWith('CODEX_') ) ) as Record - const terminal = pty.spawn( - binary!, - ['--no-alt-screen', '--dangerously-bypass-hook-trust', 'Reply OK only'], - { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: workspace, - env: { - ...env, - ...hooks.buildPtyEnv(), - CODEX_HOME: directory, - TERM: 'xterm-256color', - ORCA_BACKGROUND_LAUNCH: '1', - ORCA_PANE_KEY: PANE_KEY, - ORCA_TAB_ID: TAB_ID, - ORCA_WORKTREE_ID: WORKTREE_ID, - ORCA_AGENT_LAUNCH_TOKEN: LAUNCH_TOKEN - } + if (!binary) { + throw new Error('Missing real Codex binary') + } + const terminal = await startBundledBunPty({ + file: binary, + args: ['--no-alt-screen', '--dangerously-bypass-hook-trust', 'Reply OK only'], + cols: 120, + rows: 40, + cwd: workspace, + env: { + ...env, + ...hooks.buildPtyEnv(), + CODEX_HOME: directory, + TERM: 'xterm-256color', + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_PANE_KEY: PANE_KEY, + ORCA_TAB_ID: TAB_ID, + ORCA_WORKTREE_ID: WORKTREE_ID, + ORCA_AGENT_LAUNCH_TOKEN: LAUNCH_TOKEN } - ) + }) let exited = false const exit = new Promise((resolve) => terminal.onExit(() => { @@ -207,7 +209,7 @@ it.skipIf(!binary || process.platform === 'win32').each(trials)( return true }, getForegroundProcess: async () => { - const name = terminal.process + const name = await getForegroundProcessName(terminal.pid, binary) record('foreground', name) return name } @@ -252,9 +254,10 @@ it.skipIf(!binary || process.platform === 'win32').each(trials)( expect(submittedMail).toBe(true) } finally { if (!exited) { - terminal.kill('SIGKILL') + terminal.kill() } await Promise.race([exit, delay(2000)]) + await terminal.dispose() hooks.stop() model.closeAllConnections() await new Promise((resolve) => model.close(() => resolve())) diff --git a/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts b/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts index f516e4f5b6d0..b92d13d2f644 100644 --- a/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts +++ b/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts @@ -47,8 +47,7 @@ describe('onPtyExit agent-status reconciliation', () => { }) it('reconciles a physical -1 exit reported by the provider callback', () => { - // node-pty forwards real exits as -1 (local-pty-provider-shutdown.test.ts pins this), so the - // numeric code alone cannot separate a dead process from a failed stop. + // A negative code alone cannot separate an observed process exit from a failed stop. const reconcile = vi.fn() runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { providerExitObserved: true }) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 82e5124bf402..341af339445d 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -146,6 +146,7 @@ export type RuntimeTerminalBufferSnapshot = { } export type HeadlessSeedMetadata = { + allowEmpty?: boolean cwd?: string | null oscLinks?: TerminalOscLinkRange[] preferProviderIfExisting?: boolean diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 562ab23ee3ec..b270ea3800c8 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -1,136 +1,43 @@ -import { fileURLToPath } from 'node:url' -import * as pty from 'node-pty' -import { afterEach, describe, expect, it } from 'vitest' -import type { OrcaRuntimeService } from './orca-runtime' -import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness' -import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' -import { TERMINAL_LIFECYCLE_METHODS } from './rpc/methods/terminal/terminal-lifecycle-methods' -import { getForegroundProcessName } from '../../relay/pty-shell-utils' - -// #6011 end-to-end: a REAL pty running a REAL process that emits a REAL name-only -// OSC title while streaming must not satisfy `orca terminal wait --for tui-idle`. -// Everything below is live — real bytes, real `ps` foreground reads, real timers — -// because the bug was a wait that returned satisfied in ~0s, so timing IS the proof. - -const FIXTURE = fileURLToPath(new URL('./tui-idle-agent-fixture.mjs', import.meta.url)) -const WORKTREE_ID = 'repo-1::/tmp/tui-idle-real-pty' -const TAB_ID = '55555555-5555-4555-8555-555555555555' -const LEAF_ID = '66666666-6666-4666-8666-666666666666' -const PTY_ID = 'pty-tui-idle-real' - -const waitMethod = TERMINAL_LIFECYCLE_METHODS.find((method) => method.name === 'terminal.wait')! - -const running: pty.IPty[] = [] - -afterEach(() => { - while (running.length > 0) { - try { - running.pop()?.kill() - } catch { - // The fixture may already be gone. - } - } -}) - -async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: number) { - const child = pty.spawn(process.execPath, [FIXTURE, mode, String(workMs)], { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: '/tmp' - }) - running.push(child) - - // Real foreground read against the real pty: the same helper the relay serves - // `pty.getForegroundProcess` with, so corroboration is host-produced here too. - const runtime = makeTuiIdleRuntime({ - repoPath: '/tmp/tui-idle-real-pty', - getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null) - }) - runtime.attachWindow(1) - const graph: RuntimeSyncWindowGraph = { - tabs: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - title: 'Agent', - activeLeafId: LEAF_ID, - layout: null - } - ], - leaves: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - leafId: LEAF_ID, - paneRuntimeId: 1, - ptyId: PTY_ID, - paneTitle: null, - title: '' - } - ] - } - runtime.syncWindowGraph(1, graph) - - const transcript: string[] = [] - child.onData((data) => { - transcript.push(data) - runtime.onPtyData(PTY_ID, data, Date.now()) - }) - - const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) - return { runtime, transcript, handle: terminals[0].handle } -} - -/** Exactly what `orca terminal wait --terminal --for tui-idle` reaches over RPC. */ -async function terminalWait( - runtime: OrcaRuntimeService, - terminal: string, - timeoutMs: number -): Promise<{ satisfied: boolean; elapsedMs: number }> { - const startedAt = Date.now() - try { - const result = await waitMethod.handler( - { terminal, for: 'tui-idle', timeoutMs }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.wait reads only `runtime` off its context; the rest is request plumbing this fixture has no use for. - { runtime } as Parameters[1] +import { join } from 'node:path' +import { z } from 'zod' +import { describe, expect, it } from 'vitest' +import { runBundledBunFixture } from '../bundled-bun-test-execution' + +// Real PTY bytes, foreground reads and elapsed quiescence guard the name-only title regression. +async function runAgentWait( + mode: 'explicit-idle' | 'quiet', + workMs: number, + timeoutMs: number, + waitForTitle: boolean +) { + return z + .object({ satisfied: z.boolean(), elapsedMs: z.number(), transcript: z.string() }) + .parse( + await runBundledBunFixture( + join(__dirname, '../daemon/tui-idle-agent-bun-fixture.ts'), + 'runTuiIdleAgentWait', + { mode, workMs, timeoutMs, waitForTitle }, + timeoutMs + 7_000 + ) ) - return { satisfied: result.wait.satisfied === true, elapsedMs: Date.now() - startedAt } - } catch (error) { - // Why only `timeout`: an unsatisfied wait is the outcome under test, but any other - // failure means the harness broke and must not read as a passing refusal. - if ((error instanceof Error ? error.message : String(error)) !== 'timeout') { - throw error - } - return { satisfied: false, elapsedMs: Date.now() - startedAt } - } } describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty', () => { it('does not satisfy while the real process streams under a name-only title', async () => { - const { runtime, transcript, handle } = await startRealAgentPane('quiet', 60_000) - // Wait for real control bytes before measuring idle behavior. - await expect.poll(() => transcript.join(''), { timeout: 5_000 }).toContain(']0;Codex') - - const outcome = await terminalWait(runtime, handle, 8_000) + const outcome = await runAgentWait('quiet', 60_000, 8_000, true) + expect(outcome.transcript).toContain('\x1b]0;Codex\x07') expect(outcome.satisfied).toBe(false) expect(outcome.elapsedMs).toBeGreaterThanOrEqual(7_500) }, 25_000) it('satisfies once the real process emits an explicit idle title', async () => { - const { runtime, handle } = await startRealAgentPane('explicit-idle', 3_000) - await new Promise((resolve) => setTimeout(resolve, 500)) - - const outcome = await terminalWait(runtime, handle, 20_000) + const outcome = await runAgentWait('explicit-idle', 3_000, 20_000, false) expect(outcome.satisfied).toBe(true) expect(outcome.elapsedMs).toBeGreaterThanOrEqual(1_500) }, 28_000) it('satisfies once the real process goes quiet with the agent still in foreground', async () => { - const { runtime, handle } = await startRealAgentPane('quiet', 3_000) - await new Promise((resolve) => setTimeout(resolve, 500)) - - const outcome = await terminalWait(runtime, handle, 20_000) + const outcome = await runAgentWait('quiet', 3_000, 20_000, false) expect(outcome.satisfied).toBe(true) // Corroboration is never instant: quiescence must elapse after the last byte. expect(outcome.elapsedMs).toBeGreaterThanOrEqual(3_000) diff --git a/src/main/ssh/orcad-activation-gate.ts b/src/main/ssh/orcad-activation-gate.ts index c95819191179..2081822a0242 100644 --- a/src/main/ssh/orcad-activation-gate.ts +++ b/src/main/ssh/orcad-activation-gate.ts @@ -39,7 +39,7 @@ export type OrcadActivationVerdict = | { decision: 'reject'; code: OrcadActivationRejectCode; reason: string } export type OrcadActivationExpectation = { - /** sha256(orcad.js).slice(0,16) computed from the bytes this client just uploaded. */ + /** Hash of the launcher and application bytes this client just uploaded. */ buildHash: string /** The full content-hashed version this deploy installed. */ fullVersion: string diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index dc8098041279..c9038fe37cf4 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,4 +1,4 @@ -import type { ClientChannel } from 'ssh2' +import type { Writable } from 'node:stream' import { createSshOperationAbortError } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' @@ -12,8 +12,24 @@ export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exe const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024 const RELAY_SENTINEL_BUFFER = Buffer.from(RELAY_SENTINEL, 'utf-8') +/** Shared startup boundary for SSH channels and guest-process pipes. */ +export type RelayStartupChannel = { + stdin: Pick + stderr: { + on(event: 'data', listener: (data: Buffer) => void): unknown + on(event: 'error', listener: (error: Error) => void): unknown + } + on(event: 'data', listener: (data: Buffer) => void): unknown + on(event: 'exit', listener: (code: number | null) => void): unknown + on(event: 'error', listener: (error: Error) => void): unknown + on(event: 'close', listener: () => void): unknown + pause(): unknown + resume(): unknown + close(): void +} + export function waitForSentinel( - channel: ClientChannel, + channel: RelayStartupChannel, signal?: AbortSignal ): Promise { return new Promise((resolve, reject) => { diff --git a/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts b/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts deleted file mode 100644 index c5700c991c19..000000000000 --- a/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Why this exists (STA-6674): a Linux host whose only unreachable endpoint is nodejs.org could -// not run a relay. node-pty ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp -// downloads `node-v-headers.tar.gz` unless told the host already has the headers -- which -// every official Node install does, at `/include/node`. This drives the real deploy at a -// Docker sshd whose nodejs.org resolves to 127.0.0.1 (ECONNREFUSED, exactly what the user saw). -// -// Run: ORCA_REVIEW_SSH_OFFLINE_HEADERS=1 pnpm test src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts -// Needs Docker and `pnpm build:relay`. ORCA_REVIEW_SSH_NODE_IMAGE picks the Node image -// (default node:24.12.0-bookworm, the user's version); ORCA_REVIEW_SSH_TARGET_HOST overrides -// the address the app connects to (default 127.0.0.1). -import { execFileSync, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { connect } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { SshConnection } from './ssh-connection' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import type { SshTarget } from '../../shared/ssh-types' - -const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_OFFLINE_HEADERS === '1' -const NODE_IMAGE = process.env.ORCA_REVIEW_SSH_NODE_IMAGE ?? 'node:24.12.0-bookworm' -const TARGET_HOST = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '127.0.0.1' - -type TargetFixture = { - containerName: string - identityFile: string - port: number - tempDir: string -} - -function run(command: string, args: string[], timeout = 30_000, input?: string): string { - return execFileSync(command, args, { - encoding: 'utf8', - stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], - timeout, - input - }).trim() -} - -function dockerExec(fixture: TargetFixture, command: string): string { - return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000) -} - -async function startTarget(): Promise { - const image = `orca-review-offline-headers:${NODE_IMAGE.replace(/[^A-Za-z0-9_.-]/g, '-')}` - run( - 'docker', - ['build', '-q', '-t', image, '-'], - 600_000, - [ - `FROM ${NODE_IMAGE}`, - 'RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server git && rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh', - '' - ].join('\n') - ) - const tempDir = mkdtempSync(join(tmpdir(), 'orca-offline-headers-ssh-')) - const identityFile = join(tempDir, 'id_ed25519') - run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) - const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() - const containerName = `orca-offline-headers-${randomUUID().slice(0, 12)}` - // Why a refused connection and not a dropped one: a timeout takes node-gyp's retry path and - // burns the deploy budget; the user's host refused, and that is the path under test. - run( - 'docker', - [ - 'run', - '-d', - '--name', - containerName, - '--add-host', - 'nodejs.org:127.0.0.1', - '-p', - '0.0.0.0::22', - '-e', - `AUTHORIZED_KEY=${publicKey}`, - image, - 'bash', - '-lc', - 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' - ], - 120_000 - ) - const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) - // `docker run -d` returns before sshd binds; connect() against a closed port is a flake. - await waitForSshBanner(port) - return { containerName, identityFile, port, tempDir } -} - -/** Resolves once sshd answers with its banner on the mapped port, or throws after the deadline. */ -async function waitForSshBanner(port: number, deadlineMs = 60_000): Promise { - const deadline = Date.now() + deadlineMs - for (;;) { - const gotBanner = await new Promise((resolve) => { - const socket = connect({ host: TARGET_HOST, port }) - const done = (value: boolean): void => { - socket.destroy() - resolve(value) - } - socket.setTimeout(2_000, () => done(false)) - socket.once('data', (chunk) => done(chunk.toString('utf8').startsWith('SSH-'))) - socket.once('error', () => done(false)) - }) - if (gotBanner) { - return - } - if (Date.now() > deadline) { - throw new Error(`sshd on port ${port} did not answer within ${deadlineMs / 1000}s`) - } - await new Promise((resolve) => setTimeout(resolve, 500)) - } -} - -function stopTarget(fixture: TargetFixture | null): void { - if (!fixture) { - return - } - spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 }) - rmSync(fixture.tempDir, { recursive: true, force: true }) -} - -function createConnection(fixture: TargetFixture): SshConnection { - const target: SshTarget = { - id: `offline-headers-${randomUUID()}`, - label: 'Offline node headers Docker SSH target', - source: 'manual', - host: TARGET_HOST, - port: fixture.port, - username: 'root', - identityFile: fixture.identityFile, - identitiesOnly: true - } - return new SshConnection(target, { onStateChange: vi.fn() }) -} - -describe.skipIf(!RUN_REVIEW_ORACLE)( - 'SSH relay deploy on a host that cannot reach nodejs.org', - () => { - let fixture: TargetFixture | null = null - - beforeAll(async () => { - fixture = await startTarget() - }, 900_000) - - afterAll(() => { - stopTarget(fixture) - }) - - it('compiles node-pty from the host Node install headers instead of downloading them', async () => { - const activeFixture = fixture as TargetFixture - expect(dockerExec(activeFixture, 'getent hosts nodejs.org')).toContain('127.0.0.1') - const connection = createConnection(activeFixture) - await connection.connect() - try { - const result = await deployAndLaunchRelay(connection, undefined, 60) - expect(result.remoteRelayDir).toBeTruthy() - - const evidence = dockerExec( - activeFixture, - [ - `cd '${result.remoteRelayDir}'`, - 'test -f node_modules/node-pty/build/Release/pty.node && echo PTY_NODE=built', - 'test -d /root/.cache/node-gyp && echo HEADERS=downloaded || echo HEADERS=local', - `node -e "require('node-pty'); require('@parcel/watcher'); console.log('NATIVE=loadable')"` - ].join('; ') - ) - console.log(`[offline-node-headers] ${NODE_IMAGE}: ${evidence.replace(/\n/g, ' ')}`) - expect(evidence).toContain('PTY_NODE=built') - expect(evidence).toContain('HEADERS=local') - expect(evidence).toContain('NATIVE=loadable') - } finally { - await connection.disconnect() - } - }, 600_000) - - it('names the missing-local-headers cause, not an Orca defect, when the host ships no headers', async () => { - // Same offline host, headers removed and the relay uninstalled so the deploy compiles again. - // This is the shape a review found misreported: the exec-failure message quotes the whole - // command (marker echo included) ahead of the output, and the parser must not read that copy. - const activeFixture = fixture as TargetFixture - dockerExec( - activeFixture, - 'rm -rf /usr/local/include/node /root/.orca-remote /root/.cache/node-gyp' - ) - const connection = createConnection(activeFixture) - await connection.connect() - try { - const error = await deployAndLaunchRelay(connection, undefined, 60).catch((e: Error) => e) - expect(error).toBeInstanceOf(Error) - const message = (error as Error).message - console.log(`[offline-node-headers] ${NODE_IMAGE} no-headers: ${message.split('\n')[0]}`) - expect(message).toContain('no local headers matching its own version') - expect(message).not.toContain('Orca defect') - expect(message).toContain('ECONNREFUSED') - } finally { - await connection.disconnect() - } - }, 600_000) - } -) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b739fbd3d8ce..edeaaf02f30c 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1,6 +1,12 @@ /* oxlint-disable max-lines */ // Why: single authority for all relay lifecycle state per SSH target (previously scattered across module Maps/Sets with duplicated paths). +import { + invalidateRelayPtySourceRecovery, + migrateRelayPtySourceGeneration, + relayPtySourceRecoveryRequest +} from '../providers/relay-pty-source-recovery' +import { registerRelayPtySourceCredit } from '../providers/relay-pty-source-credit-registration' import { randomUUID } from 'node:crypto' import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' @@ -56,11 +62,7 @@ import { allocateSshPtyProviderGeneration, applySshPtySourceCancellationProof, applySshPtySourceRecoveryCancellationProof, - beginSshPtyOutputGenerationMigration, - closeSshPtyOutputGeneration, - getSshPtyAcceptedSourceCheckpoints, - installSshPtySourceAckPublisher, - installSshPtySourceCancellationPublisher + closeSshPtyOutputGeneration } from '../ipc/ssh-pty-output-intake-registry' import { registerSshFilesystemProvider, @@ -1132,42 +1134,11 @@ export class SshRelaySession { this.sourceCancellationPublisherCleanup?.() this.sourceCancellationPublisherCleanup = null if (consumerOwnerState?.outputFlowControl) { - this.sourceAckPublisherCleanup = installSshPtySourceAckPublisher( - providerGeneration, - // ACK delivery is idempotent and re-derived from credit state, so it consumes - // the two-valued projection of the write settlement rather than the three arms. - (batch, onSettled) => - mux.notifyWithSettlement( - 'pty.ackData', - batch as unknown as Record, - (settlement) => - onSettled( - settlement.outcome === 'accepted' - ? { ok: true } - : { ok: false, error: settlement.error } - ) - ) - ) - this.sourceCancellationPublisherCleanup = installSshPtySourceCancellationPublisher( - providerGeneration, - async (request) => { - const result = (await mux.request('pty.cancelDelivery', { - ...request, - id: toRelaySshPtyId(this.targetId, request.id) - })) as Record - if ( - result.canceled !== true || - !Number.isSafeInteger(result.sentEndSu) || - !Number.isSafeInteger(result.creditedEndSu) - ) { - throw new Error('ssh_source_cancellation_proof_invalid') - } - return { - sentEndSu: result.sentEndSu as number, - creditedEndSu: result.creditedEndSu as number - } - } + const registration = registerRelayPtySourceCredit(mux, providerGeneration, (id) => + toRelaySshPtyId(this.targetId, id) ) + this.sourceAckPublisherCleanup = registration.releaseAck + this.sourceCancellationPublisherCleanup = registration.releaseCancellation } this.activePtyProviderGeneration = providerGeneration registerSshPtyProvider(this.targetId, ptyProvider) @@ -1326,18 +1297,7 @@ export class SshRelaySession { return } delete recovery.owner - recovery.checkpointsByAppPtyId.clear() - for (const [ptyId, migration] of recovery.modelMigrationsByAppPtyId) { - recovery.modelMigrationsByAppPtyId.set( - ptyId, - migration.then(() => - Object.freeze({ - status: 'checkpoint-unavailable' as const, - reason: 'completion-failed' as const - }) - ) - ) - } + invalidateRelayPtySourceRecovery(recovery) } private async rememberPtyConsumerRecovery(serverBuildId: string | undefined): Promise { @@ -2958,65 +2918,19 @@ export class SshRelaySession { if (!this.activePtyConsumerOwner()?.outputFlowControl) { return undefined } - const recovery = getSshPtyConsumerRecovery(this.targetId) - const migration = recovery?.modelMigrationsByAppPtyId.get(appPtyId) - if (migration) { - const outcome = await migration - if (recovery?.modelMigrationsByAppPtyId.get(appPtyId) === migration) { - recovery.modelMigrationsByAppPtyId.delete(appPtyId) - } - if (outcome.status !== 'settled') { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - } - const checkpoints = recovery?.checkpointsByAppPtyId - const relayPtyId = toRelaySshPtyId(this.targetId, appPtyId) - // Why: every checkpoint writer records app-id keys now, so the relay-id - // lookup (and its paired delete below) is a legacy guard only. - const checkpoint = checkpoints?.get(appPtyId) ?? checkpoints?.get(relayPtyId) - if (!checkpoint) { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - return Object.freeze({ - status: 'checkpoint', - clientGeneration: checkpoint.clientGeneration, - ownerGeneration: checkpoint.ownerGeneration, - ptyIncarnation: checkpoint.ptyIncarnation, - deliveryToken: checkpoint.deliveryToken, - acceptedSourceEndSu: checkpoint.acceptedSourceEndSu - }) + return relayPtySourceRecoveryRequest( + getSshPtyConsumerRecovery(this.targetId), + appPtyId, + toRelaySshPtyId(this.targetId, appPtyId) + ) } private beginPtyModelMigration(providerGeneration: number, closeReason: string): void { - const recovery = getSshPtyConsumerRecovery(this.targetId) - if (!recovery) { - closeSshPtyOutputGeneration(providerGeneration, closeReason) - return - } - for (const checkpoint of getSshPtyAcceptedSourceCheckpoints(providerGeneration)) { - recovery.checkpointsByAppPtyId.set(checkpoint.id, checkpoint) - } - const migration = beginSshPtyOutputGenerationMigration(providerGeneration) - for (const [ptyId, result] of migration.byPty) { - const previous = recovery.modelMigrationsByAppPtyId.get(ptyId) - const fence = previous ? previous.then(() => result) : result - recovery.modelMigrationsByAppPtyId.set(ptyId, fence) - void fence.then((outcome) => { - const current = getSshPtyConsumerRecovery(this.targetId) - if (current?.modelMigrationsByAppPtyId.get(ptyId) !== fence) { - return - } - if (outcome.status === 'settled') { - current.checkpointsByAppPtyId.set(ptyId, outcome.checkpoint) - } else { - current.checkpointsByAppPtyId.delete(ptyId) - current.checkpointsByAppPtyId.delete(toRelaySshPtyId(this.targetId, ptyId)) - } - current.modelMigrationsByAppPtyId.delete(ptyId) - }) - } - void migration.completion.then(() => { - closeSshPtyOutputGeneration(providerGeneration, closeReason) + migrateRelayPtySourceGeneration({ + generation: providerGeneration, + closeReason, + getRecovery: () => getSshPtyConsumerRecovery(this.targetId), + toRelayPtyId: (id) => toRelaySshPtyId(this.targetId, id) }) } diff --git a/src/main/startup/codex-launch-preparation.test.ts b/src/main/startup/codex-launch-preparation.test.ts new file mode 100644 index 000000000000..35ae9a8ffd90 --- /dev/null +++ b/src/main/startup/codex-launch-preparation.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + realHomeSelected: vi.fn(), + hooks: vi.fn(), + realHooks: vi.fn(), + settings: { agentStatusHooksEnabled: true }, + trust: vi.fn() +})) +vi.mock('electron', () => ({ app: { getPath: () => '/profile' } })) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + prepareForCodexLaunchAsync: mocks.prepare, + isHostSystemDefaultRealHomeSelected: mocks.realHomeSelected + }, + store: { getSettings: () => mocks.settings } + } +})) +vi.mock('../agent-trust-presets', () => ({ markCodexProjectTrusted: mocks.trust })) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { prepareRuntimeHomeForLaunch: mocks.hooks } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: mocks.realHooks +})) +vi.mock('../wsl', () => ({ getDefaultWslDistro: () => 'wrong-default' })) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabled: (settings: { agentStatusHooksEnabled: boolean }) => + settings.agentStatusHooksEnabled +})) + +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' + +const owner = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } +beforeEach(() => { + vi.clearAllMocks() + mocks.prepare.mockResolvedValue('/home/alice/.codex') + mocks.realHomeSelected.mockReturnValue(false) + mocks.hooks.mockResolvedValue({ state: 'installed' }) + mocks.settings.agentStatusHooksEnabled = true +}) + +describe('Codex startup launch wrapper', () => { + it.each([true, false])( + 'leaves captured guest hooks to guest preparation, hooks enabled=%s', + async (enabled) => { + mocks.settings.agentStatusHooksEnabled = enabled + const execution = { ...owner } + mocks.prepare.mockImplementation(async () => { + execution.userName = 'bob' + execution.home = '/home/bob' + return '/home/alice/.codex' + }) + expect( + await prepareCodexRuntimeHomeForLaunch( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + {}, + { wslExecution: execution } + ) + ).toBe('/home/alice/.codex') + expect(mocks.prepare).toHaveBeenCalledWith( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + {}, + { unavailableManagedHomePath: undefined, wslExecution: owner } + ) + expect(Object.isFrozen(mocks.prepare.mock.calls[0]?.[2].wslExecution)).toBe(true) + expect(mocks.hooks).not.toHaveBeenCalled() + expect(mocks.realHooks).not.toHaveBeenCalled() + expect(mocks.trust).not.toHaveBeenCalled() + } + ) + + it('refuses a captured owner paired with a host or different distro before preparation', async () => { + await expect( + prepareCodexRuntimeHomeForLaunch({ runtime: 'host' }, {}, { wslExecution: owner }) + ).rejects.toThrow('captured execution owner') + await expect( + prepareCodexRuntimeHomeForLaunch( + { runtime: 'wsl', wslDistro: 'Debian' }, + {}, + { wslExecution: owner } + ) + ).rejects.toThrow('captured execution owner') + expect(mocks.prepare).not.toHaveBeenCalled() + expect(mocks.hooks).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'keeps legacy noncaptured hook preparation, hooks enabled=%s', + async (enabled) => { + mocks.settings.agentStatusHooksEnabled = enabled + await prepareCodexRuntimeHomeForLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(mocks.hooks).toHaveBeenCalledWith( + '/home/alice/.codex', + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + enabled + ) + } + ) + + it('preserves host managed-home fallback and second preparation', async () => { + mocks.prepare.mockResolvedValueOnce(null).mockResolvedValueOnce('/managed') + mocks.realHomeSelected.mockReturnValueOnce(false).mockReturnValueOnce(true) + expect( + await prepareCodexRuntimeHomeForLaunch( + { runtime: 'host' }, + {}, + { unavailableManagedHomePath: '/old' } + ) + ).toBe('/managed') + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(mocks.prepare).toHaveBeenLastCalledWith( + { runtime: 'host' }, + {}, + { unavailableManagedHomePath: '/old' } + ) + expect(mocks.realHooks).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index 3b94c00b11d7..5be035de8334 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -1,3 +1,4 @@ +import { assertWslAccountExecutionTarget } from '../wsl/wsl-account-execution-context' import { app } from 'electron' import type { CodexHomeLaunchContext } from '../ipc/pty' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' @@ -13,6 +14,12 @@ export async function prepareCodexRuntimeHomeForLaunch( launchEnv?: NodeJS.ProcessEnv, launchContext?: CodexHomeLaunchContext ): Promise { + const wslExecution = launchContext?.wslExecution + ? Object.freeze({ ...launchContext.wslExecution }) + : undefined + if (wslExecution) { + assertWslAccountExecutionTarget(wslExecution, target) + } const runtimeHome = state.codexRuntimeHome if (!runtimeHome) { throw new Error('Codex runtime home service is not initialized') @@ -48,7 +55,8 @@ export async function prepareCodexRuntimeHomeForLaunch( // the fallbacks below all key off `null`, which means "system default", so // swallowing the refusal would launch the wrong account (#STA-4422). let runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath, + ...(wslExecution ? { wslExecution } : {}) }) if (runtimeHomePath === null && !realHomeHooksPrepared) { // Why: launch prep can reject an untrusted managed home and clear its @@ -57,7 +65,8 @@ export async function prepareCodexRuntimeHomeForLaunch( realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() if (realHomeHooksPrepared) { runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath, + ...(wslExecution ? { wslExecution } : {}) }) } } @@ -66,6 +75,10 @@ export async function prepareCodexRuntimeHomeForLaunch( // install below would target a home Codex never reads on this lane. return null } + // Guest preparation installs hooks through the captured user’s relay and honors hooks-off. + if (wslExecution) { + return runtimeHomePath + } const hookTarget = target?.runtime === 'wsl' ? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() } diff --git a/src/main/startup/codex-session-resume-launch.test.ts b/src/main/startup/codex-session-resume-launch.test.ts new file mode 100644 index 000000000000..544f35567df4 --- /dev/null +++ b/src/main/startup/codex-session-resume-launch.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + settings: { + codexManagedAccounts: [], + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'selected' } } + } +})) +vi.mock('electron', () => ({ app: { getPath: () => '/data' } })) +vi.mock('../codex/codex-wsl-session-resume', () => ({ + prepareCapturedWslCodexSessionResume: mocks.prepare +})) +vi.mock('./main-process-state', () => ({ + mainProcessState: { codexRuntimeHome: null, store: { getSettings: () => mocks.settings } } +})) +vi.mock('../codex/hook-service', () => ({ codexHookService: {} })) +vi.mock('../codex/codex-real-home-hook-install', () => ({ ensureRealHomeCodexHookState: vi.fn() })) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: vi.fn() +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabled: vi.fn() +})) +vi.mock('../agent-trust-presets', () => ({ markCodexProjectTrusted: vi.fn() })) +vi.mock('../codex/codex-home-paths', () => ({ + getSystemCodexHomePath: vi.fn(), + getOrcaManagedCodexHomePath: vi.fn() +})) +const execution = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } +const providerSession = { key: 'session_id', id: '11111111-2222-3333-4444-555555555555' } as const +beforeEach(() => { + mocks.prepare.mockReset() +}) +it('preserves the existing WSL route when no captured execution identity is supplied', async () => { + expect( + await prepareCodexSessionResumeForLaunch({ + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession + }) + ).toBeNull() + expect(mocks.prepare).not.toHaveBeenCalled() +}) +it('routes captured WSL resume through guest verification even without a host runtime home', async () => { + mocks.prepare.mockResolvedValue({ + outcome: 'resume', + codexHomePath: '/home/alice/account-origin' + }) + expect( + await prepareCodexSessionResumeForLaunch({ + wslExecution: execution, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession + }) + ).toEqual({ outcome: 'resume', codexHomePath: '/home/alice/account-origin' }) + expect(mocks.prepare).toHaveBeenCalledWith({ + execution, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession, + accounts: [], + selectedAccountId: 'selected' + }) +}) +it('propagates unverifiable guest ownership instead of silently using the selected account', async () => { + mocks.prepare.mockRejectedValue(new Error('owner changed')) + await expect( + prepareCodexSessionResumeForLaunch({ + wslExecution: execution, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + providerSession + }) + ).rejects.toThrow('owner changed') +}) diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index c537c4f75ef9..4c661a57ca49 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -1,3 +1,6 @@ +import { prepareCapturedWslCodexSessionResume } from '../codex/codex-wsl-session-resume' +import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' +import type { WslAccountExecutionContext } from '../wsl/wsl-account-execution-context' import { app } from 'electron' import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' @@ -14,6 +17,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-p import { mainProcessState as state } from './main-process-state' export async function prepareCodexSessionResumeForLaunch(args: { + wslExecution?: WslAccountExecutionContext providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv @@ -21,6 +25,19 @@ export async function prepareCodexSessionResumeForLaunch(args: { }): Promise { const runtimeHome = state.codexRuntimeHome const store = state.store + if (args.target.runtime === 'wsl' && args.wslExecution) { + if (!store) { + throw new Error('Codex session resume requires profile account state') + } + const settings = store.getSettings() + return prepareCapturedWslCodexSessionResume({ + execution: args.wslExecution, + target: args.target, + providerSession: args.providerSession, + accounts: settings.codexManagedAccounts, + selectedAccountId: getSelectedCodexAccountIdForTarget(settings, args.target) + }) + } if (args.target.runtime === 'wsl' || !runtimeHome || !store) { return null } diff --git a/src/main/startup/first-window-startup-services.test.ts b/src/main/startup/first-window-startup-services.test.ts index 728fd5c41b93..987998f4c0ba 100644 --- a/src/main/startup/first-window-startup-services.test.ts +++ b/src/main/startup/first-window-startup-services.test.ts @@ -143,8 +143,7 @@ describe('startFirstWindowStartupServices', () => { await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) await expect(started.firstWindowReady).resolves.toBeUndefined() - // Why: opening the PTY gate before the daemon attempt finishes would - // spawn non-restorable LocalPtyProvider fallback terminals (#5232). + // Restored terminals wait for daemon authority while startup is still within its budget. expect(ptyGateOpened).toBe(false) expect(daemonSignal?.aborted).toBe(false) expect(onDaemonError).not.toHaveBeenCalled() @@ -159,17 +158,25 @@ describe('startFirstWindowStartupServices', () => { } }) - it('fails open the local PTY gate at the hard cap while aborting hung services', async () => { + it('opens the hard-cap gate while allowing a late daemon to become ready', async () => { vi.useFakeTimers() const onDaemonError = vi.fn() const onAgentHookServerError = vi.fn() let daemonSignal: AbortSignal | undefined + let finishDaemon!: () => void + const installed = vi.fn() try { const started = startFirstWindowStartupServices({ startDaemonPtyProvider: (signal) => { daemonSignal = signal - return new Promise(() => {}) + return new Promise((resolve) => { + finishDaemon = resolve + }).then(() => { + if (!signal.aborted) { + installed() + } + }) }, startAgentHookServer: () => Promise.resolve(), onDaemonError, @@ -184,7 +191,10 @@ describe('startFirstWindowStartupServices', () => { expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) expect(onAgentHookServerError).not.toHaveBeenCalled() - expect(daemonSignal?.aborted).toBe(true) + expect(daemonSignal?.aborted).toBe(false) + finishDaemon() + await vi.advanceTimersByTimeAsync(0) + expect(installed).toHaveBeenCalledOnce() } finally { vi.useRealTimers() } diff --git a/src/main/startup/first-window-startup-services.ts b/src/main/startup/first-window-startup-services.ts index 407ba23f5555..9f464021c439 100644 --- a/src/main/startup/first-window-startup-services.ts +++ b/src/main/startup/first-window-startup-services.ts @@ -17,17 +17,14 @@ type FirstWindowStartupServicesResult = { } export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000 -// Why: a slow (but succeeding) daemon start must not flip terminals to the -// LocalPtyProvider fallback — local PTYs are killed on quit, so panes bound to -// them lose their daemon sessions permanently (#5232). The PTY gate therefore -// waits for the daemon attempt itself and only fail-opens at a hard cap that -// exists solely as a deadlock backstop. +// Bound the startup gate without discarding a daemon that becomes ready later. export const LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS = 60_000 function startService( label: string, start: (signal: AbortSignal) => Promise, - onError: (error: unknown) => void + onError: (error: unknown) => void, + abortOnTimeout = true ): StartupService { const abortController = new AbortController() let settled = false @@ -50,7 +47,9 @@ function startService( return } reportedTimeout = true - abortController.abort() + if (abortOnTimeout) { + abortController.abort() + } onError(new Error(`${label} startup timed out`)) } } @@ -65,14 +64,8 @@ export function startFirstWindowStartupServices({ onDaemonError, onAgentHookServerError }: FirstWindowStartupServices): FirstWindowStartupServicesResult { - // Why: daemon startup and hook-server binding are independent, but both gate - // restored terminals; run them together so cold-start latency is max(), not sum(). - // The first window fails open quickly so the user sees the app; the local PTY - // gate waits for the services themselves (a slow daemon must not flip spawns - // to the non-restorable LocalPtyProvider fallback) and only fails open at the - // hard cap, which also aborts the services so a late daemon swap cannot - // strand any fallback PTYs that spawn after the gate opens. - const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError) + // Independent services start together; a timeout opens the UI but leaves daemon recovery running. + const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError, false) const hooks = startService('agent hook server', startAgentHookServer, onAgentHookServerError) const allServicesReady = Promise.all([daemon.ready, hooks.ready]).then(() => undefined) let windowTimeout: ReturnType | null = null diff --git a/src/main/startup/legacy-worker-renderer-recovery.test.ts b/src/main/startup/legacy-worker-renderer-recovery.test.ts index d20ac3b8675d..838037033213 100644 --- a/src/main/startup/legacy-worker-renderer-recovery.test.ts +++ b/src/main/startup/legacy-worker-renderer-recovery.test.ts @@ -126,7 +126,7 @@ describe('legacy worker renderer recovery', () => { ) await startup expect(reconcile).toHaveBeenCalledOnce() - expect(daemonSignal?.aborted).toBe(true) + expect(daemonSignal?.aborted).toBe(false) } finally { vi.useRealTimers() } diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 162faf66b2f4..622f326f7a57 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -2,6 +2,7 @@ import { app } from 'electron' import { classifyError } from '../telemetry/classify-error' import { track } from '../telemetry/client' import { getPtyIdForPaneKey } from '../ipc/pty' +import { tryGetProviderForPty } from '../ipc/pty/provider/registry' import { getDaemonProvider, initDaemonPtyProvider, @@ -99,6 +100,8 @@ export async function reapRestoredSubagentsWithoutLiveAgent(): Promise { ) await sweepRestoredSubagentsWithoutLiveAgent({ probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), + probeLiveWslPty: async (ptyId) => + (await tryGetProviderForPty(ptyId)?.probePtyLiveness?.(ptyId)) ?? null, isLocalExecutionHost: (worktreeId) => isLocalExecutionHost( resolveAgentWorkspaceExecutionHostId(worktreeId, { @@ -182,10 +185,10 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) }, onDaemonError: (error) => { - // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). + // Report service failure without implying terminal ownership moved into Electron. const reason = error instanceof Error ? error.message : String(error) console.error( - `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` + `[daemon] STARTUP FAILED — new terminals are unavailable until the terminal service recovers. Reason: ${reason}` ) track('daemon_start_failed', classifyError(error)) }, diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index ad4548db7bd4..108a8f55caa8 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -177,6 +177,9 @@ function installWillQuitHandler(): void { }) : Promise.resolve() // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. + // Fence delayed guest owner publication before the final profile write. + const wslDaemonShutdown = state.wslDaemonSessions?.dispose() ?? Promise.resolve() + void wslDaemonShutdown.catch(() => {}) wslHookRelayManager.disposeAll() const statsFlush = state.stats?.flushAsync() ?? Promise.resolve() // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. @@ -250,6 +253,7 @@ function installWillQuitHandler(): void { // temp+rename swap means a write cut short by the deadline leaves the old file intact. settleTeardownWithinDeadline([ { name: 'daemon', promise: daemonTeardown }, + { name: 'wsl-daemons', promise: wslDaemonShutdown }, { name: 'browser', promise: browserShutdown }, { name: 'runtime-rpc', promise: rpcStopAndClear }, { name: 'watchers', promise: watcherShutdown }, diff --git a/src/main/startup/main-process-runtime-launch-activation.test.ts b/src/main/startup/main-process-runtime-launch-activation.test.ts index 42813af450e7..e4f437d906cc 100644 --- a/src/main/startup/main-process-runtime-launch-activation.test.ts +++ b/src/main/startup/main-process-runtime-launch-activation.test.ts @@ -36,7 +36,6 @@ vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), registerHeadlessPtyRuntime: vi.fn() })) -vi.mock('../providers/local-pty-provider', () => ({ LocalPtyProvider: class {} })) vi.mock('../browser/offscreen-browser-backend', () => ({ OffscreenBrowserBackend: class {} })) vi.mock('../browser/browser-manager', () => ({ browserManager: {} })) vi.mock('./main-process-relay-status', () => ({ diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 858c6b757867..e12f61835211 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -8,8 +8,8 @@ import { } from '../persistence' import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import { registerMobileHandlers } from '../ipc/mobile' -import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty' -import { LocalPtyProvider } from '../providers/local-pty-provider' +import { registerHeadlessPtyRuntime } from '../ipc/pty' +import { daemonOwnsFreshPersistentPtys } from '../daemon/daemon-provider-state' import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' import { browserManager } from '../browser/browser-manager' @@ -55,7 +55,7 @@ function settleDesktopActivation(): void { return } settleServeDesktopActivation(gate, { - hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) + hasPersistentPtyProvider: daemonOwnsFreshPersistentPtys() }) } @@ -141,10 +141,14 @@ async function launchServeMode( runtime, prepareCodexRuntimeHomeForLaunch, () => state.store!.getSettings(), - (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + (target, execution) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target, execution), state.store!, prepareCodexSessionResumeForLaunch, - { onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit } + { + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, + wslDaemonSessions: state.wslDaemonSessions + } ) await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 20ea8262c9a2..2aaa702fe387 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -1,3 +1,6 @@ +import { join } from 'node:path' +import { WslDaemonSessions } from '../wsl/wsl-daemon-sessions' +import { getDaemonHistoryDir } from '../daemon/daemon-launch-paths' import { applySessionSearchSettingsChange, installChildSessionSearchService @@ -48,6 +51,13 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { if (!store || !stats) { throw new Error('Store and stats must be initialized before runtime') } + if (process.platform === 'win32' && !state.wslDaemonSessions) { + state.wslDaemonSessions = new WslDaemonSessions({ + store, + profileScope: getProfileUserDataPath(), + historyRoot: join(getDaemonHistoryDir(), 'wsl') + }) + } const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { resolve: (selector) => { const environment = resolveEnvironment(app.getPath('userData'), selector) @@ -74,7 +84,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // `orca serve`, which never opens one, and the fleet path runs there too. const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const runtime = new OrcaRuntimeService(store, stats, { - prepareClaudeAuth: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + prepareClaudeAuth: (target, execution) => + state.claudeRuntimeAuth!.prepareForClaudeLaunch(target, execution), agentSessionClaimSigner: loadAgentSessionClaimSigner( getProfileUserDataPath(), getProfileUserDataPath() diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index f6e86423029e..7ebc3a8ec4d9 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -1,3 +1,4 @@ +import type { WslDaemonSessions } from '../wsl/wsl-daemon-sessions' import type { BrowserWindow, Tray } from 'electron' import { app } from 'electron' import type { Store } from '../persistence' @@ -63,6 +64,10 @@ function createInitialProfileStateAdmission(): ProfileStateRuntimeAdmission | un return undefined } +function createInitialWslDaemonSessions(): WslDaemonSessions | undefined { + return undefined +} + /** Mutable composition-root state shared by startup, window, serve, and quit phases. */ export const mainProcessState = { mainWindow: null as BrowserWindow | null, @@ -70,6 +75,7 @@ export const mainProcessState = { isQuitting: false, store: null as Store | null, profileStateStartup: createInitialProfileStateStartup(), + wslDaemonSessions: createInitialWslDaemonSessions(), profileStateAdmission: createInitialProfileStateAdmission(), stats: null as StatsCollector | null, claudeUsage: null as ClaudeUsageStore | null, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts index 50d0bd49db53..f6f1c04bb779 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -40,6 +40,7 @@ const { pluginMarketplaceInstaller: null, desktopRelayService: null, isServeMode: false, + wslDaemonSessions: { prepareFresh: vi.fn(), reconnect: vi.fn(), dispose: vi.fn() }, localPtyStartupReady: Promise.resolve(), localPtyProviderStartupReady: Promise.resolve() }, @@ -81,6 +82,26 @@ describe('main window profile-state update preparation', () => { vi.clearAllMocks() }) + it('forwards captured guest account identity through the terminal auth callback', async () => { + const window = { webContents: { id: 17 } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this mocked composition root only reads webContents. + attachMainWindowCoreServices(window as never, { + markExpectedRendererReload: vi.fn(), + recordRendererReload: vi.fn() + }) + expect(attachMainWindowServicesMock.mock.calls[0]?.[5]).toMatchObject({ + wslDaemonSessions: state.wslDaemonSessions + }) + const prepare = attachMainWindowServicesMock.mock.calls[0]?.[4] + if (typeof prepare !== 'function') { + throw new Error('Expected terminal auth preparation callback') + } + const target = { runtime: 'wsl', wslDistro: 'Ubuntu' } + const execution = { distro: 'Ubuntu', userName: 'alice', userId: '1000', home: '/home/alice' } + await prepare(target, execution) + expect(state.claudeRuntimeAuth.prepareForClaudeLaunch).toHaveBeenCalledWith(target, execution) + }) + it('publishes both recovery forms with one profile checkpoint before an update quit', async () => { const window = { webContents: { id: 17 } } diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index c292a4455c7c..3f351a96c34b 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -111,9 +111,10 @@ export function attachMainWindowCoreServices( store, runtime, prepareCodexRuntimeHomeForLaunch, - (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + (target, execution) => claudeRuntimeAuth.prepareForClaudeLaunch(target, execution), { prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, + wslDaemonSessions: state.wslDaemonSessions, awaitLocalPtyStartup: () => state.localPtyStartupReady, awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady, onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { diff --git a/src/main/terminal-history-fish-session-bun-fixture.ts b/src/main/terminal-history-fish-session-bun-fixture.ts new file mode 100644 index 000000000000..0bf01293f05a --- /dev/null +++ b/src/main/terminal-history-fish-session-bun-fixture.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { spawnBunPty } from './daemon/pty-subprocess/bun-pty-process' +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) { + return true + } + await sleep(20) + } + return false +} + +export async function runFishHistoryFixture({ + binary, + home, + dataHome, + session, + promptMark, + marker +}: { + binary: string + home: string + dataHome: string + session: string + promptMark: string + marker: string +}): Promise { + const term = spawnBunPty({ + file: binary, + args: ['-l', '-i'], + cols: 120, + rows: 30, + cwd: home, + // Fully pinned: no ambient HOME/XDG_* reaches fish, so this cannot pass + // only on a machine whose real fish config happens to cooperate. + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: home, + TERM: 'xterm-256color', + // LC_ALL wins over any LANG/LC_* a host might contribute, pinning fish's locale. + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + XDG_CONFIG_HOME: home, + XDG_DATA_HOME: dataHome, + // The production injection under test (terminal-history.ts). + fish_history: session + } + }) + + let rendered = '' + term.onData((chunk) => { + rendered += chunk + if (chunk.includes('\x1b[0c') || chunk.includes('\x1b[c')) { + term.write('\x1b[?62;4;6;22c') + } + if (chunk.includes('\x1b[6n')) { + term.write('\x1b[1;1R') + } + if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) { + term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\') + } + }) + let exited = false + term.onExit(() => { + exited = true + }) + + try { + assert.equal(await waitUntil(() => rendered.includes(promptMark), 15_000), true) + term.write(`${marker}\r`) + assert.equal( + await waitUntil(() => rendered.includes('orca-worktree-scoped-history'), 5_000), + true + ) + // fish flushes history on exit, so the read must wait for the process to go. + term.write('exit\r') + assert.equal(await waitUntil(() => exited, 10_000), true) + } finally { + if (!exited) { + term.kill() + } + assert.equal(await waitUntil(() => exited, 3000), true) + term.destroy() + } +} diff --git a/src/main/terminal-history-fish-session.node-pty.test.ts b/src/main/terminal-history-fish-session.bun.test.ts similarity index 58% rename from src/main/terminal-history-fish-session.node-pty.test.ts rename to src/main/terminal-history-fish-session.bun.test.ts index f4d7b3cf404f..04c65a59a90e 100644 --- a/src/main/terminal-history-fish-session.node-pty.test.ts +++ b/src/main/terminal-history-fish-session.bun.test.ts @@ -18,6 +18,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { fishRequirementViolation, resolveFishBinary } from '../shared/fish-binary-requirement' +import { runBundledBunFixture } from './bundled-bun-test-execution' import { fishHistorySessionName, resolveFishHistoryDir } from './fish-history-session' const FISH = resolveFishBinary(4) @@ -27,19 +28,6 @@ const PROMPT_MARK = 'ORCAHIST> ' const WORKTREE_HASH = 'deadbeefdeadbeef' const MARKER = 'echo orca-worktree-scoped-history' -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (predicate()) { - return true - } - await sleep(20) - } - return false -} - describe('fish keeps per-worktree history under the session Orca names', () => { let home: string | null = null @@ -58,8 +46,6 @@ describe('fish keeps per-worktree history under the session Orca names', () => { itWithFish( 'writes an interactive command to $XDG_DATA_HOME/fish/_history, not the shared file', async () => { - const nodePty = await import('node-pty') - home = mkdtempSync(path.join(tmpdir(), 'orca-fish-history-')) const dataHome = path.join(home, 'data') mkdirSync(path.join(home, 'fish'), { recursive: true }) @@ -74,65 +60,22 @@ describe('fish keeps per-worktree history under the session Orca names', () => { ) const session = fishHistorySessionName(WORKTREE_HASH) - const term = nodePty.spawn(FISH.path as string, ['-l', '-i'], { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: home, - // Fully pinned: no ambient HOME/XDG_* reaches fish, so this cannot pass - // only on a machine whose real fish config happens to cooperate. - env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: home, - TERM: 'xterm-256color', - // LC_ALL wins over any LANG/LC_* a host might contribute, pinning fish's locale. - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - XDG_CONFIG_HOME: home, - XDG_DATA_HOME: dataHome, - // The production injection under test (terminal-history.ts). - fish_history: session - } - }) - - let rendered = '' - term.onData((chunk) => { - rendered += chunk - if (chunk.includes('\x1b[0c') || chunk.includes('\x1b[c')) { - term.write('\x1b[?62;4;6;22c') - } - if (chunk.includes('\x1b[6n')) { - term.write('\x1b[1;1R') - } - if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) { - term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\') - } - }) - let exited = false - term.onExit(() => { - exited = true - }) - - expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true) - term.write(`${MARKER}\r`) - expect(await waitUntil(() => rendered.includes('orca-worktree-scoped-history'), 5_000)).toBe( - true - ) - // fish flushes history on exit, so the read must wait for the process to go. - term.write('exit\r') - expect(await waitUntil(() => exited, 10_000)).toBe(true) - try { - term.kill() - } catch { - // already gone + if (!FISH.path) { + throw new Error('Fish binary missing') } + await runBundledBunFixture( + path.join(__dirname, 'terminal-history-fish-session-bun-fixture.ts'), + 'runFishHistoryFixture', + { binary: FISH.path, home, dataHome, session, promptMark: PROMPT_MARK, marker: MARKER }, + 35_000 + ) const scopedPath = path.join( resolveFishHistoryDir({ XDG_DATA_HOME: dataHome }), `${session}_history` ) expect(scopedPath).toBe(path.join(dataHome, 'fish', `${session}_history`)) - const scoped = readFileSync(scopedPath as string, 'utf8') + const scoped = readFileSync(scopedPath, 'utf8') // YAML-ish records, not one line per command — any reader must handle this shape. expect(scoped).toContain(`- cmd: ${MARKER}`) expect(scoped).toMatch(/^ {2}when: \d+$/m) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 5a7aab6b145e..8e97f754c28a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,3 +1,4 @@ +import type { WslDaemonSessions } from '../wsl/wsl-daemon-sessions' import { ipcMain } from 'electron' import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' import type { Store } from '../persistence' @@ -27,10 +28,9 @@ import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runti import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { PrepareClaudeAuth } from '../ipc/pty/host-env/types' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' -import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { scheduleWorktreeBaseDirectoryWatcherSync, setWorktreeBaseDirectoryWatcherSyncContext @@ -51,10 +51,9 @@ export function attachMainWindowServices( store: Store, runtime: OrcaRuntimeService, getSelectedCodexHomePath?: GetSelectedCodexHomePath, - prepareClaudeAuth?: ( - target?: ClaudeAccountSelectionTarget - ) => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, options?: { + wslDaemonSessions?: WslDaemonSessions prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise @@ -93,6 +92,7 @@ export function attachMainWindowServices( store, { prepareCodexSessionResume: options?.prepareCodexSessionResume, + wslDaemonSessions: options?.wslDaemonSessions, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight, diff --git a/src/main/windows/windows-conpty-provider.test.ts b/src/main/windows/windows-conpty-provider.test.ts new file mode 100644 index 000000000000..856f3318799d --- /dev/null +++ b/src/main/windows/windows-conpty-provider.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveWindowsConptyProvider } from './windows-conpty-provider' + +vi.mock('../../shared/windows-conpty-release', async () => { + const { createHash } = await import('node:crypto') + return { + WINDOWS_CONPTY_FILES: Object.fromEntries( + ['x64', 'arm64'].map((arch) => [ + arch, + Object.fromEntries( + ['conpty.dll', 'OpenConsole.exe'].map((file) => [ + file, + createHash('sha256').update(`${arch}/${file}`).digest('hex') + ]) + ) + ]) + ) + } +}) +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) +function fixture(arch: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-conpty-provider-')) + roots.push(root) + for (const file of ['conpty.dll', 'OpenConsole.exe']) { + writeFileSync(join(root, file), `${arch}/${file}`) + } + return root +} +describe('trusted Windows ConPTY provider', () => { + it.each(['x64', 'arm64'])('selects only the pinned %s pair', (arch) => { + const root = fixture(arch) + expect(resolveWindowsConptyProvider(root, arch)).toBe(join(root, 'conpty.dll')) + expect(() => resolveWindowsConptyProvider(root, arch === 'x64' ? 'arm64' : 'x64')).toThrow( + 'identity mismatch' + ) + }) + it.each(['conpty.dll', 'OpenConsole.exe'])('rejects a missing or corrupt %s', (file) => { + const root = fixture('x64') + rmSync(join(root, file)) + expect(() => resolveWindowsConptyProvider(root, 'x64')).toThrow() + writeFileSync(join(root, file), 'foreign bytes') + expect(() => resolveWindowsConptyProvider(root, 'x64')).toThrow('identity mismatch') + }) + it('rejects unknown architectures before opening files', () => { + expect(() => resolveWindowsConptyProvider('/missing', 'ia32')).toThrow('Unsupported') + }) +}) diff --git a/src/main/windows/windows-conpty-provider.ts b/src/main/windows/windows-conpty-provider.ts new file mode 100644 index 000000000000..183f3a38d6e3 --- /dev/null +++ b/src/main/windows/windows-conpty-provider.ts @@ -0,0 +1,21 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { WINDOWS_CONPTY_FILES } from '../../shared/windows-conpty-release' + +/** Validate both halves before Bun can load the provider or start its console host. */ +export function resolveWindowsConptyProvider(directory: string, arch: string): string { + if (arch !== 'x64' && arch !== 'arm64') { + throw new Error(`Unsupported ConPTY architecture: ${arch}`) + } + const root = resolve(directory) + for (const [filename, expected] of Object.entries(WINDOWS_CONPTY_FILES[arch])) { + const actual = createHash('sha256') + .update(readFileSync(join(root, filename))) + .digest('hex') + if (actual !== expected) { + throw new Error(`Windows ConPTY identity mismatch: ${arch}/${filename}`) + } + } + return join(root, 'conpty.dll') +} diff --git a/src/main/windows/windows-host-job.win32.test.ts b/src/main/windows/windows-host-job.win32.test.ts index 941d81fd4d31..6b4046a3b9bd 100644 --- a/src/main/windows/windows-host-job.win32.test.ts +++ b/src/main/windows/windows-host-job.win32.test.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { spawn } from 'node:child_process' +import { spawnProcess } from '../../shared/child-process/run-process' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' @@ -55,18 +56,27 @@ describeOnWindows('host job reaps the tree when the host dies', () => { }) it('kills a pty and its detached grandchild when the host is force-killed', async () => { - const nodePtyDir = join(process.cwd(), 'node_modules', 'node-pty') + const runtime = [ + process.env.BUN_EXECUTABLE, + join(process.cwd(), 'out/orcad/bun-runtime.exe'), + join(process.cwd(), 'out/cli-runtime', `win32-${process.arch}`, 'bun-runtime.exe') + ].find((entry): entry is string => Boolean(entry && existsSync(entry))) + if (!runtime) { + throw new Error('Pinned bundled Bun runtime is required') + } + const source = join(process.cwd(), 'src/main/daemon/pty-subprocess') const hostScript = join(dir, 'host.js') // The host assigns itself, then opens a pty whose shell spawns a DETACHED // grandchild — the process a parent-pid walk cannot see. writeFileSync( hostScript, [ - `const pty = require(${JSON.stringify(nodePtyDir)});`, - `const { loadNativeModule } = require(${JSON.stringify(`${nodePtyDir}/lib/utils`)});`, - `const native = loadNativeModule('conpty').module;`, - `console.log('ASSIGNED=' + native.assignCurrentProcessToJob());`, - `const term = pty.spawn('cmd.exe', [], { name: 'xterm', cols: 80, rows: 30, cwd: ${JSON.stringify(dir)}, useConptyDll: true });`, + `if (process.versions.bun !== ${JSON.stringify(ORCAD_BUN_VERSION)}) throw new Error('Unexpected Bun runtime');`, + `const { spawnBunPty } = require(${JSON.stringify(join(source, 'bun-pty-process.ts'))});`, + `const { createWindowsBunPtyLaunch } = require(${JSON.stringify(join(source, 'windows-bun-pty-launch.ts'))});`, + `const { assignCurrentProcessToBunPtyHostJob } = require(${JSON.stringify(join(source, 'windows-bun-pty-job.ts'))});`, + `console.log('ASSIGNED=' + assignCurrentProcessToBunPtyHostJob());`, + `const term = spawnBunPty({file: process.env.ComSpec || 'cmd.exe', args: [], env: process.env, cols: 80, rows: 30, cwd: ${JSON.stringify(dir)}}, {createWindowsLaunch: args => createWindowsBunPtyLaunch(args, {workerPath: ${JSON.stringify(join(source, 'windows-bun-pty-gate-entry.ts'))}})});`, `term.onData((d) => { const m = /GC=(\\d+)/.exec(d); if (m) console.log('GRANDCHILD=' + m[1]); });`, `term.write('node -e "const{spawn}=require(\\'child_process\\');const c=spawn(process.execPath,[\\'-e\\',\\'setInterval(()=>{},1000)\\'],{detached:true,windowsHide:true,stdio:\\'ignore\\'});c.unref();console.log(\\'GC=\\'+c.pid);"\\r');`, `setTimeout(() => console.log('SHELL=' + term.pid), 4000);`, @@ -74,7 +84,11 @@ describeOnWindows('host job reaps the tree when the host dies', () => { ].join('\n') ) - const host = spawn(process.execPath, [hostScript], { stdio: ['ignore', 'pipe', 'pipe'] }) + const host = spawnProcess({ + program: runtime, + args: ['--no-env-file', hostScript], + stdio: ['ignore', 'pipe', 'pipe'] + }) let output = '' host.stdout.on('data', (chunk) => { output += String(chunk) @@ -82,21 +96,23 @@ describeOnWindows('host job reaps the tree when the host dies', () => { host.stdout.on('error', () => {}) host.stderr?.on('error', () => {}) - for (let attempt = 0; attempt < 60 && !/SHELL=/.test(output); attempt += 1) { - await sleep(250) - } + let shellPid = 0 + let grandchildPid = 0 + try { + for (let attempt = 0; attempt < 60 && !/SHELL=/.test(output); attempt += 1) { + await sleep(250) + } - expect(output).toContain('ASSIGNED=true') - const shellPid = Number(/SHELL=(\d+)/.exec(output)?.[1]) - const grandchildPid = Number(/GRANDCHILD=(\d+)/.exec(output)?.[1]) - expect(Number.isInteger(shellPid)).toBe(true) - expect(Number.isInteger(grandchildPid)).toBe(true) - expect(isAlive(grandchildPid)).toBe(true) + expect(output).toContain('ASSIGNED=true') + shellPid = Number(/SHELL=(\d+)/.exec(output)?.[1]) + grandchildPid = Number(/GRANDCHILD=(\d+)/.exec(output)?.[1]) + expect(Number.isInteger(shellPid)).toBe(true) + expect(Number.isInteger(grandchildPid)).toBe(true) + expect(isAlive(grandchildPid)).toBe(true) - // Force-kill only the host: no tree kill, nothing given a chance to unwind. - // This is the daemon-crash shape. - process.kill(host.pid!, 'SIGKILL') - try { + // Force-kill only the host: no tree kill, nothing given a chance to unwind. + // This is the daemon-crash shape. + process.kill(host.pid!, 'SIGKILL') const [shellExited, grandchildExited] = await Promise.all([ waitForProcessExit(shellPid, 15_000), waitForProcessExit(grandchildPid, 15_000) @@ -104,7 +120,10 @@ describeOnWindows('host job reaps the tree when the host dies', () => { expect(shellExited).toBe(true) expect(grandchildExited).toBe(true) } finally { - for (const pid of [shellPid, grandchildPid]) { + for (const pid of [host.pid, shellPid, grandchildPid]) { + if (!pid || pid <= 0) { + continue + } try { process.kill(pid) } catch { diff --git a/src/main/windows/windows-msys-job.win32.test.ts b/src/main/windows/windows-msys-job.win32.test.ts index 9bf5485102e9..4267091c3c07 100644 --- a/src/main/windows/windows-msys-job.win32.test.ts +++ b/src/main/windows/windows-msys-job.win32.test.ts @@ -1,3 +1,5 @@ +import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' +import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,7 +9,15 @@ import { resolveGitBashPath } from '../git-bash' import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { listPtyJobProcessIds, terminatePtyJob } from './windows-pty-job' -const describeOnWindows = process.platform === 'win32' ? describe : describe.skip +const spawnTestPty: typeof spawnBunPty = (args) => + spawnBunPty(args, { + createWindowsLaunch: (launch) => + createWindowsBunPtyLaunch(launch, { + workerPath: join(__dirname, '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts') + }) + }) + +const describeOnWindows = process.platform === 'win32' && canUseBunPty() ? describe : describe.skip function isAlive(pid: number): boolean { try { @@ -28,12 +38,17 @@ describeOnWindows('MSYS terminal job ownership', () => { script, "console.log('MSYS_OWNED_CHILD=' + process.pid); setInterval(() => {}, 1000)\n" ) - const pty = await import('node-pty') - const proc = pty.spawn(shell!, ['-c', 'exec "$BASH" --noprofile --norc -i'], { + const proc = spawnTestPty({ + file: shell!, + args: ['-c', 'exec "$BASH" --noprofile --norc -i'], + env: Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), cwd: tmpdir(), cols: 120, - rows: 30, - useConptyDll: true + rows: 30 }) let output = '' let childPid: number | undefined @@ -49,6 +64,7 @@ describeOnWindows('MSYS terminal job ownership', () => { } }) try { + await proc.waitForSpawn?.() proc.write( `${quotePosixShell(process.execPath.replace(/\\/g, '/'))} ${quotePosixShell(script.replace(/\\/g, '/'))}\r` ) diff --git a/src/main/windows/windows-process-tree-runtime-manifest.ts b/src/main/windows/windows-process-tree-runtime-manifest.ts new file mode 100644 index 000000000000..bae0668f15d7 --- /dev/null +++ b/src/main/windows/windows-process-tree-runtime-manifest.ts @@ -0,0 +1,15 @@ +export const WINDOWS_PROCESS_TREE_REQUIRED = [ + 'package.json', + 'lib/index.js', + 'build/Release/windows_process_tree.node' +] as const + +export function isRuntimeProcessTreePath(packageRel: string): boolean { + return ( + packageRel === '' || + packageRel.startsWith('lib/') || + WINDOWS_PROCESS_TREE_REQUIRED.some( + (required) => required === packageRel || required.startsWith(`${packageRel}/`) + ) + ) +} diff --git a/src/main/windows/windows-pty-job.test.ts b/src/main/windows/windows-pty-job.test.ts index 6210ac9d0469..3bb0cd6de2f2 100644 --- a/src/main/windows/windows-pty-job.test.ts +++ b/src/main/windows/windows-pty-job.test.ts @@ -1,206 +1,84 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { IPty } from 'node-pty' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockPtyProcess } from '../daemon/pty-subprocess-test-harness' +import { listPtyJobProcessIds, ptyShellProcessId, terminatePtyJob } from './windows-pty-job' -const { recordSelfInitiatedTreeKillMock } = vi.hoisted(() => ({ - recordSelfInitiatedTreeKillMock: vi.fn() -})) +const { recordKill } = vi.hoisted(() => ({ recordKill: vi.fn() })) vi.mock('../crash-reporting/self-initiated-tree-kill-log', () => ({ - recordSelfInitiatedTreeKill: recordSelfInitiatedTreeKillMock + recordSelfInitiatedTreeKill: recordKill })) - -import { - __setConptyJobNativeForTests, - assignHostProcessToKillOnCloseJob, - isPtyJobOwnershipAvailable, - listPtyJobProcessIds, - terminatePtyJob -} from './windows-pty-job' - -const ptyWithHandle = (id: unknown, pid = 4242): IPty => ({ _pty: id, pid }) as unknown as IPty - beforeEach(() => { - recordSelfInitiatedTreeKillMock.mockReset() -}) - -afterEach(() => { - __setConptyJobNativeForTests() + recordKill.mockReset() }) -describe('terminatePtyJob', () => { - it('terminates the job for a pty that has one', () => { - const terminateJob = vi.fn().mockReturnValue(true) - __setConptyJobNativeForTests(() => ({ - terminateJob, - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - - expect(terminatePtyJob(ptyWithHandle(7))).toBe('terminated') - expect(terminateJob).toHaveBeenCalledWith(7, 4242) - }) - - it.each([ - ['no job assigned', false], - ['native refusal', false] - ])('reports unavailable rather than success on %s', (_case, nativeResult) => { - // Why this matters: "we could not tell" is precisely the state the old - // parent-pid probe returned, and treating it as success is how a live - // agent tree was declared dead and left holding the worktree open. - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn().mockReturnValue(nativeResult), - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - expect(terminatePtyJob(ptyWithHandle(7))).toBe('unavailable') - }) - - it.each([ - ['a POSIX terminal with no handle id', undefined], - ['a non-integer handle id', 'nope'] - ])('reports unavailable for %s', (_case, id) => { - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn().mockReturnValue(true), - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - expect(terminatePtyJob(ptyWithHandle(id))).toBe('unavailable') - }) - - it('passes the shell pid so an id from another backend cannot match', () => { - // winpty mints `pty` ids from its own counter and the JS layer stores both - // in the same field, so an id alone can name a live ConPTY pane. The native - // side refuses on a pid mismatch; this pins that we always send the pid. - const terminateJob = vi.fn().mockReturnValue(true) - __setConptyJobNativeForTests(() => ({ - terminateJob, - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - - terminatePtyJob(ptyWithHandle(3, 9001)) - expect(terminateJob).toHaveBeenCalledWith(3, 9001) - }) - - it('reports unavailable when the shell pid is unusable', () => { - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn().mockReturnValue(true), - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - expect(terminatePtyJob(ptyWithHandle(3, 0))).toBe('unavailable') - }) - - it('reports unavailable when this build has no job support', () => { - // A node-pty rebuilt from unpatched sources exports neither symbol. - __setConptyJobNativeForTests(() => null) - expect(terminatePtyJob(ptyWithHandle(7))).toBe('unavailable') - expect(isPtyJobOwnershipAvailable()).toBe(false) +describe('owned Windows PTY jobs', () => { + it('does not infer job ownership from a PID or an obsolete native handle', () => { + const proc = { ...mockPtyProcess(4242), _pty: 7 } + expect(terminatePtyJob(proc)).toBe('unavailable') + expect(listPtyJobProcessIds(proc)).toBeNull() + expect(recordKill).not.toHaveBeenCalled() }) - it('reports unavailable when the native call throws', () => { - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn().mockImplementation(() => { - throw new Error('handle closed') - }), - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - expect(terminatePtyJob(ptyWithHandle(7))).toBe('unavailable') - }) -}) - -describe('listPtyJobProcessIds', () => { - it('returns the live pids, including a detached grandchild', () => { - // Measured on Windows 11: a grandchild spawned detached leaves the console - // and reparents, so neither GetConsoleProcessList nor a parent-pid walk - // sees it. Job membership does. - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn(), - listJobProcessIds: vi.fn().mockReturnValue([107184, 91480]), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - expect(listPtyJobProcessIds(ptyWithHandle(1))).toEqual([107184, 91480]) - }) - - it('reports null when there is no answer to give', () => { - // Null is 'unverifiable', never 'they died'. A dead tree also reads null, - // because node-pty drops its handle record on exit -- so a caller that - // treats null as proof of death would be right by accident here and wrong - // on a host that refused the job assignment. - __setConptyJobNativeForTests(() => null) - expect(listPtyJobProcessIds(ptyWithHandle(1))).toBeNull() - - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn(), - listJobProcessIds: vi.fn().mockReturnValue(null), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - expect(listPtyJobProcessIds(ptyWithHandle(1))).toBeNull() - }) -}) - -describe('assignHostProcessToKillOnCloseJob', () => { - it('reports whether the host process is now kill-on-close', () => { - const assignCurrentProcessToJob = vi.fn().mockReturnValue(true) - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn(), - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob - })) - expect(assignHostProcessToKillOnCloseJob()).toBe(true) - }) - - it('degrades to false when the OS refuses the assignment', () => { - // An outer job that forbids nesting; orphan behaviour is then simply what - // it was before, rather than a crash. - __setConptyJobNativeForTests(() => ({ - terminateJob: vi.fn(), - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(false) - })) - expect(assignHostProcessToKillOnCloseJob()).toBe(false) - - __setConptyJobNativeForTests(() => null) - expect(assignHostProcessToKillOnCloseJob()).toBe(false) - }) -}) - -describe('PTY Job Object teardown breadcrumbs', () => { - function withNative(terminateJob: () => boolean): void { - __setConptyJobNativeForTests(() => ({ - terminateJob, - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn().mockReturnValue(true) - })) - } - - it('records the shell pid whose job it terminated', () => { - withNative(() => true) - - expect(terminatePtyJob(ptyWithHandle(7, 4242))).toBe('terminated') - expect(recordSelfInitiatedTreeKillMock).toHaveBeenCalledWith({ + it('uses retained ownership and records confirmed termination', () => { + const terminateOwnedTree = vi.fn(() => 'terminated' as const) + const proc = { ...mockPtyProcess(4242), terminateOwnedTree } + expect(terminatePtyJob(proc)).toBe('terminated') + expect(terminateOwnedTree).toHaveBeenCalledOnce() + expect(recordKill).toHaveBeenCalledWith({ pid: 4242, site: 'windows-pty-job-teardown', scope: 'win-pty-job' }) }) - it('records nothing when the native side refused', () => { - withNative(() => false) - - expect(terminatePtyJob(ptyWithHandle(7))).toBe('unavailable') - expect(recordSelfInitiatedTreeKillMock).not.toHaveBeenCalled() + it.each(['refused', 'throws'])('keeps %s termination unverifiable', (mode) => { + const proc = { + ...mockPtyProcess(4242), + terminateOwnedTree: () => { + if (mode === 'throws') { + throw new Error('job unavailable') + } + return 'unavailable' as const + } + } + expect(terminatePtyJob(proc)).toBe('unavailable') + expect(recordKill).not.toHaveBeenCalled() }) - it('never downgrades a real termination to unavailable because diagnostics failed', () => { - // `unavailable` escalates callers to the pid-addressed taskkill this - // instrumentation exists to constrain, so the breadcrumb must sit outside - // the native call's catch. - withNative(() => true) - recordSelfInitiatedTreeKillMock.mockImplementation(() => { - throw new Error('breadcrumb store exploded') + it('does not downgrade confirmed termination if diagnostics throw', () => { + recordKill.mockImplementation(() => { + throw new Error('diagnostic failure') }) + const proc = { ...mockPtyProcess(4242), terminateOwnedTree: () => 'terminated' as const } + expect(() => terminatePtyJob(proc)).toThrow('diagnostic failure') + }) + + it('preserves live, empty and unverifiable job results', () => { + for (const result of [[4242, 4243], [], null]) { + expect( + listPtyJobProcessIds({ ...mockPtyProcess(4242), listOwnedProcessIds: () => result }) + ).toEqual(result) + } + expect( + listPtyJobProcessIds({ + ...mockPtyProcess(4242), + listOwnedProcessIds: () => { + throw new Error('unreadable') + } + }) + ).toBeNull() + }) - expect(() => terminatePtyJob(ptyWithHandle(7))).toThrow('breadcrumb store exploded') + it('never substitutes a Windows gate PID for an unknown shell', () => { + expect( + ptyShellProcessId({ ...mockPtyProcess(4242), jobRootProcessIsWrapper: true }) + ).toBeUndefined() + expect( + ptyShellProcessId({ + ...mockPtyProcess(4242), + jobRootProcessIsWrapper: true, + shellProcessId: 4243 + }) + ).toBe(4243) + expect(ptyShellProcessId(mockPtyProcess(4242))).toBe(4242) }) }) diff --git a/src/main/windows/windows-pty-job.ts b/src/main/windows/windows-pty-job.ts index 814f2dbaa8ab..17908e66431b 100644 --- a/src/main/windows/windows-pty-job.ts +++ b/src/main/windows/windows-pty-job.ts @@ -1,41 +1,11 @@ -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../../shared/terminal-process' import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' import { loadWindowsBunPtyJobNative } from '../daemon/pty-subprocess/windows-bun-pty-native' -import { createRequire } from 'node:module' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' -/** - * Job-object ownership for a ConPTY's process tree. - * - * Before this, Orca answered "is this tree mine, and how do I kill it?" by - * scraping the process table, walking parent pids back to itself, and then - * running `taskkill /T /F` only if the walk said yes. Every step of that is a - * guess: - * - * - a pid walk cannot survive pid reuse, so teardown had to refuse whenever it - * was unsure, and a refused kill is an orphaned agent tree holding the - * worktree directory open (#9045, #10475, #10087); - * - a descendant that reparented is invisible to the walk entirely; - * - the scrape itself could be blocked by policy, which read as "no evidence". - * - * A job object replaces the inference with a handle. node-pty puts each shell - * into its own job at creation, before it can spawn anything, so membership is - * the kernel's answer rather than ours. - * - * The job is not kill-on-close: it makes an explicit teardown exact, and - * deliberately does not change what a clean shell exit means for anything the - * user backgrounded. - */ - -const requireFromMain = createRequire(__filename) - -type ConptyNative = { - terminateJob: (id: number, shellPid: number) => boolean - listJobProcessIds: (id: number, shellPid: number) => number[] | null - assignCurrentProcessToJob: () => boolean -} +export type JobTerminationOutcome = 'terminated' | 'unavailable' -type SelfOwnedPty = IPty & { +type SelfOwnedPty = Pick & { jobRootProcessIsWrapper?: true shellProcessId?: number terminateOwnedTree?: () => JobTerminationOutcome @@ -43,202 +13,43 @@ type SelfOwnedPty = IPty & { } /** The gate's pid never proves that its user shell remains alive. */ -export function ptyShellProcessId(proc: IPty): number | undefined { +export function ptyShellProcessId(proc: SelfOwnedPty): number | undefined { const owned: SelfOwnedPty = proc return owned.jobRootProcessIsWrapper ? owned.shellProcessId : proc.pid } -let cachedNative: ConptyNative | null | undefined -let nativeLoader: () => ConptyNative | null = loadConptyNative - -function loadConptyNative(): ConptyNative | null { - if (cachedNative !== undefined) { - return cachedNative - } - if (process.platform !== 'win32') { - cachedNative = null - return cachedNative - } - try { - const { loadNativeModule } = requireFromMain('node-pty/lib/utils') as { - loadNativeModule: (name: string) => { module: unknown } - } - const native = loadNativeModule('conpty').module as Partial - // Why feature-detect: a node-pty rebuilt from unpatched sources exports - // neither symbol, and calling through would throw on every teardown. - cachedNative = - typeof native?.terminateJob === 'function' && typeof native?.listJobProcessIds === 'function' - ? (native as ConptyNative) - : null - } catch { - cachedNative = null - } - return cachedNative -} - -/** - * node-pty's per-terminal handle id, paired with the shell pid that proves it. - * - * Neither is on the public `IPty` surface, so read defensively. The pid is not - * belt-and-braces: the winpty backend mints `pty` ids from its own counter and - * the JS layer stores both in the same field, so an id alone can name a - * different, live ConPTY pane. The native side refuses on a pid mismatch. - */ -function ptyJobTarget(proc: IPty): { id: number; shellPid: number } | null { - const id = (proc as unknown as { _pty?: unknown })._pty - const shellPid = proc.pid - if (!Number.isInteger(id) || !Number.isInteger(shellPid) || (shellPid as number) <= 0) { - return null - } - return { id: id as number, shellPid: shellPid as number } -} - -export type JobTerminationOutcome = 'terminated' | 'unavailable' - -/** - * Kill a PTY's entire process tree. - * - * Returns `unavailable` — never a false `terminated` — when the tree has no - * job. Callers must degrade to the older best-effort path rather than treat - * that as success, because "we could not tell" is exactly the state that used - * to be misread as "nothing to kill". - */ -export function terminatePtyJob(proc: IPty): JobTerminationOutcome { +/** Only the terminal's retained job handle can authorize whole-tree termination. */ +export function terminatePtyJob(proc: SelfOwnedPty): JobTerminationOutcome { const owned: SelfOwnedPty = proc - if (typeof owned.terminateOwnedTree === 'function') { - let outcome: JobTerminationOutcome - try { - outcome = owned.terminateOwnedTree() - } catch { - return 'unavailable' - } - if (outcome === 'terminated') { - recordSelfInitiatedTreeKill({ - pid: proc.pid, - site: 'windows-pty-job-teardown', - scope: 'win-pty-job' - }) - } - return outcome - } - const target = ptyJobTarget(proc) - const native = nativeLoader() - if (!target || !native) { + if (!owned.terminateOwnedTree) { return 'unavailable' } - let terminated: boolean + let outcome: JobTerminationOutcome try { - terminated = native.terminateJob(target.id, target.shellPid) + outcome = owned.terminateOwnedTree() } catch { return 'unavailable' } - if (!terminated) { - return 'unavailable' + if (outcome === 'terminated') { + recordSelfInitiatedTreeKill({ + pid: proc.pid, + site: 'windows-pty-job-teardown', + scope: 'win-pty-job' + }) } - // Outside the try: that catch is the native-refusal contract, and a throw from - // the breadcrumb path would downgrade a real termination to `unavailable`, - // escalating callers to the pid-addressed taskkill this instrumentation exists - // to constrain. - recordSelfInitiatedTreeKill({ - pid: target.shellPid, - site: 'windows-pty-job-teardown', - scope: 'win-pty-job' - }) - return 'terminated' + return outcome } -/** - * Pids still alive in a PTY's tree, or null when there is no answer. - * - * Measured on Windows 11: once the shell exits, node-pty closes the job, so a - * terminated tree reports **null**, not `[]`. (Its handle record now outlives - * the shell until `kill()` runs — see config/patches/node-pty@1.1.0.patch — but - * the nulled job handle is what makes the answer null either way.) - * Null therefore means "unverifiable" in the sense of - * docs/reference/ssh-execution-boundary.md — this build has no job support, - * the terminal is not a ConPTY, or it is no longer tracked. It is never - * evidence that processes died. - * - * The value this does add is descendant liveness for a tree that IS still - * tracked: a pane whose shell is alive can be asked what is running under it, - * including children that detached from the console. - */ -export function listPtyJobProcessIds(proc: IPty): readonly number[] | null { +/** A missing or unreadable job is unverifiable, never evidence that its processes exited. */ +export function listPtyJobProcessIds(proc: SelfOwnedPty): readonly number[] | null { const owned: SelfOwnedPty = proc - if (typeof owned.listOwnedProcessIds === 'function') { - try { - return owned.listOwnedProcessIds() - } catch { - return null - } - } - const target = ptyJobTarget(proc) - const native = nativeLoader() - if (!target || !native) { - return null - } try { - return native.listJobProcessIds(target.id, target.shellPid) + return owned.listOwnedProcessIds?.() ?? null } catch { return null } } -/** - * Put this process in a kill-on-close job so its descendants die with it. - * - * Why this is separate from the per-PTY jobs: those cannot carry - * KILL_ON_JOB_CLOSE, because their handle is released when the shell exits and - * that would reap whatever the user had backgrounded. This one is released only - * when the process itself dies, so it reaps a crashed host without changing - * what a clean exit means. Children inherit membership, so the per-PTY jobs - * nest inside it and every pty is covered. - * - * Call it from the pty spawn path, not from startup: resolving the native - * module loads the ConPTY addon, and paying that before the daemon publishes - * its endpoint delays readiness — the boot smoke test caught exactly that. - * - * Call it BEFORE the first pty. `AssignProcessToJobObject` adds only the named - * process; children inherit membership, but a pty that already exists does not - * join retroactively and would not be reaped. - * - * Call it from the terminal daemon, not from the app: an app-main crash must - * still leave sessions alive, which is what win-crash-survival-e2e asserts. A - * daemon death reaping its shells is the intended change (#9195, #10415). - * - * Returns false when the OS refuses -- an outer job that forbids nesting -- in - * which case orphan behaviour is simply what it was before. - */ -let hostJobAssigned: boolean | null = null - -export function assignHostProcessToKillOnCloseJob(): boolean { - if (hostJobAssigned !== null) { - return hostJobAssigned - } - hostJobAssigned = assignHostProcessOnce() - return hostJobAssigned -} - -function assignHostProcessOnce(): boolean { - const native = nativeLoader() - if (!native) { - return false - } - try { - return native.assignCurrentProcessToJob() - } catch { - return false - } -} - -/** Whether this build can own PTY trees with job objects at all. */ export function isPtyJobOwnershipAvailable(): boolean { - return canUseBunPty() ? loadWindowsBunPtyJobNative() !== null : nativeLoader() !== null -} - -/** Test-only: substitute the native module (it is resolved via createRequire). */ -export function __setConptyJobNativeForTests(loader?: () => ConptyNative | null): void { - nativeLoader = loader ?? loadConptyNative - cachedNative = undefined - hostJobAssigned = null + return canUseBunPty() && loadWindowsBunPtyJobNative() !== null } diff --git a/src/main/windows/windows-pty-job.win32.test.ts b/src/main/windows/windows-pty-job.win32.test.ts index cb15463bff85..c59d919a8212 100644 --- a/src/main/windows/windows-pty-job.win32.test.ts +++ b/src/main/windows/windows-pty-job.win32.test.ts @@ -1,8 +1,10 @@ +import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { IPty } from 'node-pty' +import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import type { BunPtyProcess } from '../daemon/pty-subprocess/bun-pty-process-contract' import { runProcess } from '../../shared/child-process/run-process' import { isPtyJobOwnershipAvailable, @@ -17,11 +19,19 @@ import { * neither `GetConsoleProcessList` nor a parent-pid walk can see it. That is the * process that outlived its pane and held the worktree directory open * (#9045, #10475, #10897). Job membership is the only mechanism that finds it, - * so the assertion below is the whole justification for the node-pty patch. + * so the assertion below is the reason this runtime must retain exact job ownership. * * Runs only on win32; skipped elsewhere. */ -const describeOnWindows = process.platform === 'win32' ? describe : describe.skip +const spawnTestPty: typeof spawnBunPty = (args) => + spawnBunPty(args, { + createWindowsLaunch: (launch) => + createWindowsBunPtyLaunch(launch, { + workerPath: join(__dirname, '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts') + }) + }) + +const describeOnWindows = process.platform === 'win32' && canUseBunPty() ? describe : describe.skip const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -44,7 +54,7 @@ async function waitUntilDead(pid: number, timeoutMs = 30_000): Promise { } describeOnWindows('ConPTY job ownership', () => { - const spawned: IPty[] = [] + const spawned: BunPtyProcess[] = [] afterEach(() => { for (const proc of spawned.splice(0)) { @@ -57,18 +67,23 @@ describeOnWindows('ConPTY job ownership', () => { }) async function spawnShellWithDetachedGrandchild(): Promise<{ - proc: IPty + proc: BunPtyProcess grandchildPid: number }> { - const nodePty = await import('node-pty') - const proc = nodePty.spawn('cmd.exe', [], { - name: 'xterm-256color', + const proc = spawnTestPty({ + file: process.env.ComSpec || 'cmd.exe', + args: [], + env: Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), cols: 80, rows: 30, - cwd: process.cwd(), - useConptyDll: true + cwd: process.cwd() }) spawned.push(proc) + await proc.waitForSpawn?.() let grandchildPid: number | null = null proc.onData((chunk) => { @@ -84,7 +99,7 @@ describeOnWindows('ConPTY job ownership', () => { "{detached:true,windowsHide:true,stdio:'ignore'});", "c.unref();console.log('ORCA_GC='+c.pid);" ].join('') - proc.write(`node -e "${script}"\r`) + proc.write(`"${process.execPath}" -e "${script}"\r`) for (let attempt = 0; attempt < 80 && grandchildPid === null; attempt += 1) { await sleep(250) @@ -98,15 +113,13 @@ describeOnWindows('ConPTY job ownership', () => { } it('reports this build as able to own pty trees', () => { - // A node-pty rebuilt from unpatched sources would silently fall back to the - // old probe, so every assertion below would pass vacuously. expect(isPtyJobOwnershipAvailable()).toBe(true) }) it('keeps the native table intact while shell cleanup overlaps new terminals', async () => { const result = await runProcess({ program: process.execPath, - args: [join(process.cwd(), 'config', 'scripts', 'windows-pty-table-stress.cjs')], + args: [join(process.cwd(), 'config', 'scripts', 'windows-pty-table-stress.mjs')], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, timeoutMs: 90_000 }) @@ -123,7 +136,7 @@ describeOnWindows('ConPTY job ownership', () => { const pids = listPtyJobProcessIds(proc) expect(pids).not.toBeNull() - expect(pids).toContain(proc.pid) + expect(pids).toContain(proc.shellProcessId) expect(pids).toContain(grandchildPid) }, 60_000) @@ -160,23 +173,22 @@ describeOnWindows('ConPTY job ownership', () => { }, 60_000) it('still starts a backgrounded child from inside the job', async () => { - // Scope note: `start /b` uses CREATE_NEW_CONSOLE, not - // CREATE_BREAKAWAY_FROM_JOB, so this does NOT prove the BREAKAWAY_OK flag - // is doing its job -- it proves job membership does not block ordinary - // backgrounding. The flag itself rests on the Win32 contract (a job lacking - // JOB_OBJECT_LIMIT_BREAKAWAY_OK denies breakaway with ERROR_ACCESS_DENIED - // regardless of its other limits) and is not covered by a test here. - // Covering it needs a helper that passes the flag to CreateProcess. - const nodePty = await import('node-pty') + // Denying explicit breakaway must still permit ordinary background commands. const marker = join(mkdtempSync(join(tmpdir(), 'orca-breakaway-')), 'marker.txt') - const proc = nodePty.spawn('cmd.exe', [], { - name: 'xterm-256color', + const proc = spawnTestPty({ + file: process.env.ComSpec || 'cmd.exe', + args: [], + env: Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), cols: 100, rows: 30, - cwd: tmpdir(), - useConptyDll: true + cwd: tmpdir() }) spawned.push(proc) + await proc.waitForSpawn?.() let output = '' proc.onData((chunk) => { @@ -190,7 +202,7 @@ describeOnWindows('ConPTY job ownership', () => { }, 60_000) it('stops answering once the tree is gone, rather than claiming it is empty', async () => { - // Measured, not assumed: node-pty drops its handle record and closes the + // The adapter closes its handle record and the // job when the shell exits, so a dead tree is unverifiable here rather than // observably empty. Callers must not read null as proof of death -- the // verdict vocabulary in docs/reference/ssh-execution-boundary.md applies. diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index 45f99ffa2467..31eac7ed773e 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -25,7 +25,6 @@ main/git/command-runner/wsl-command-resolution.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts main/local-worktree-filesystem.ts -main/providers/local-pty-spawn.ts main/rate-limits/claude-pty.ts main/rate-limits/codex-fetcher.ts main/wsl-availability.ts diff --git a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt index f0a4c4f10826..c05435dd9e7e 100644 --- a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt @@ -29,3 +29,10 @@ main/wsl.ts # point of the module (#16463). A failed stat drops to the next candidate for # that one call and is re-asked on the next, so there is no value to pin. main/wsl-interop-spawn-directory.ts + +# Dormant WSL relay/snapshot compatibility surfaces. Their catches only retire a +# best-effort stream or return an unavailable restore surface; no result is cached +# or used to claim guest discovery. They are removed with the relay retirement. +main/wsl/wsl-guest-artifact-install-script.ts +main/wsl/wsl-pty-restore-surface.ts +main/wsl/wsl-relay-output-binding.ts diff --git a/src/main/wsl/wsl-account-execution-context.ts b/src/main/wsl/wsl-account-execution-context.ts new file mode 100644 index 000000000000..71ceb875edf2 --- /dev/null +++ b/src/main/wsl/wsl-account-execution-context.ts @@ -0,0 +1,23 @@ +/** Captured operation identity; account selection remains keyed by distro. */ +export type WslAccountExecutionContext = Readonly<{ + distro: string + userName: string + userId: string + home: string +}> + +export function assertWslAccountExecutionTarget( + execution: WslAccountExecutionContext, + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } +): void { + if ( + target?.runtime !== 'wsl' || + target.wslDistro?.toLowerCase() !== execution.distro.toLowerCase() || + !execution.userName || + /[\0\r\n]/.test(execution.userName) || + !/^\d+$/.test(execution.userId) || + !execution.home.startsWith('/') + ) { + throw new Error('WSL account preparation does not match the captured execution owner') + } +} diff --git a/src/main/wsl/wsl-bun-runtime.ts b/src/main/wsl/wsl-bun-runtime.ts index be3215493ea3..f163cf58c332 100644 --- a/src/main/wsl/wsl-bun-runtime.ts +++ b/src/main/wsl/wsl-bun-runtime.ts @@ -39,7 +39,8 @@ export async function assertWslRuntimeDistroRunning( /** Refuse guest commands without a confirmed running verdict; WSL has no atomic check-and-exec. */ export function createRunningWslRuntimeRunner( distro: string, - callerSignal?: AbortSignal + callerSignal?: AbortSignal, + user?: string ): { run: (spec: WslSpec) => Promise signal: AbortSignal @@ -52,6 +53,7 @@ export function createRunningWslRuntimeRunner( const result = await runWslProcess({ ...spec, distro, + ...(user ? { user } : {}), timeoutMs: Math.max(1, Math.min(15_000, deadline - Date.now())), maxOutputBytes: 16 * 1024 }) diff --git a/src/main/wsl/wsl-bun-runtime.win32.integration.test.ts b/src/main/wsl/wsl-bun-runtime.win32.integration.test.ts index 3ebff9dbecc2..c9a6335833fb 100644 --- a/src/main/wsl/wsl-bun-runtime.win32.integration.test.ts +++ b/src/main/wsl/wsl-bun-runtime.win32.integration.test.ts @@ -1,4 +1,3 @@ -import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,7 +7,7 @@ import { hasAppEnvironment, setAppEnvironment } from '../../shared/app-environment' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { ORCAD_BUN_VERSION, orcadBunVersionProbeArgs } from '../../shared/orcad-bun-runtime' import { getMainHttpClient, setMainHttpClient } from '../network/http-client' import { shellEscape } from '../ssh/ssh-connection-utils' import { createRunningWslRuntimeRunner, ensureWslBunRuntime } from './wsl-bun-runtime' @@ -101,7 +100,7 @@ it.skipIf(process.platform !== 'win32' || !distro)( expect( await run({ program: executable, - args: [...bunOwnedRuntimeArgs('linux'), '--print', 'process.versions.bun'], + args: orcadBunVersionProbeArgs('linux'), loginPath: 'none' }) ).toBe(ORCAD_BUN_VERSION) @@ -118,7 +117,7 @@ it.skipIf(process.platform !== 'win32' || !distro)( expect( await run({ program: repaired, - args: [...bunOwnedRuntimeArgs('linux'), '--print', 'process.versions.bun'], + args: orcadBunVersionProbeArgs('linux'), loginPath: 'none' }) ).toBe(ORCAD_BUN_VERSION) diff --git a/src/main/wsl/wsl-daemon-connector-protocol.test.ts b/src/main/wsl/wsl-daemon-connector-protocol.test.ts new file mode 100644 index 000000000000..34876270122c --- /dev/null +++ b/src/main/wsl/wsl-daemon-connector-protocol.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonClient } from '../daemon/client' +import { DaemonServer } from '../daemon/daemon-server' +import { runProcess } from '../../shared/child-process/run-process' +import { openWslDaemonConnectorStream } from './wsl-daemon-connector-stream' +import { + WSL_DAEMON_CONNECTOR_SCRIPT, + WSL_DAEMON_READ_TOKEN_SCRIPT +} from './wsl-daemon-connector-script' + +it.skipIf(process.platform === 'win32')( + 'authenticates the actual daemon over two child pipes and reconnects to the same owner', + async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-guest-protocol-')) + const plan = { + socket: join(directory, 'd.sock'), + tokenPath: join(directory, 'token'), + userId: String(process.getuid?.()), + home: process.env.HOME + } + const server = new DaemonServer({ + socketPath: plan.socket, + tokenPath: plan.tokenPath, + spawnSubprocess: () => { + throw new Error('No terminal spawn expected') + } + }) + const observer = new DaemonClient({ socketPath: plan.socket, tokenPath: plan.tokenPath }) + const client = new DaemonClient({ + transport: { + async readToken({ signal, timeoutMs }) { + const result = await runProcess({ + program: process.execPath, + args: ['-e', WSL_DAEMON_READ_TOKEN_SCRIPT, JSON.stringify(plan)], + signal, + timeoutMs + }) + if (result.code !== 0) { + throw new Error('Token read failed') + } + return result.stdout + }, + connect: (_role, { signal }) => + openWslDaemonConnectorStream( + { + program: process.execPath, + args: ['-e', WSL_DAEMON_CONNECTOR_SCRIPT, JSON.stringify(plan)] + }, + signal + ) + } + }) + try { + await server.start() + await observer.ensureConnected() + await client.ensureConnected() + const identity = client.getDaemonIdentity() + expect(identity?.pid).toBe(process.pid) + expect(await client.request('ping', undefined)).toEqual({ pong: true }) + expect(await client.request('listSessions', undefined)).toEqual({ sessions: [] }) + client.disconnect() + await client.ensureConnected() + expect(client.getDaemonIdentity()).toEqual(identity) + expect(await client.request('ping', undefined)).toEqual({ pong: true }) + } finally { + client.disconnect() + observer.disconnect() + await server.shutdown() + rmSync(directory, { recursive: true, force: true }) + } + } +) diff --git a/src/main/wsl/wsl-daemon-connector-script.ts b/src/main/wsl/wsl-daemon-connector-script.ts new file mode 100644 index 000000000000..f7c530bd4c3b --- /dev/null +++ b/src/main/wsl/wsl-daemon-connector-script.ts @@ -0,0 +1,36 @@ +export const WSL_DAEMON_CONNECTOR_READY = 'ORCA_WSL_DAEMON_CONNECTED\n' + +/** No shell or stdout preamble: both daemon channels retain their existing byte protocol. */ +export const WSL_DAEMON_CONNECTOR_SCRIPT = String.raw` +const net = require('node:net'); +const plan = JSON.parse(process.argv[1]); +if (String(process.getuid()) !== plan.userId || process.env.HOME !== plan.home) { + throw new Error('WSL daemon execution owner changed'); +} +const socket = net.createConnection(plan.socket); +socket.once('connect', () => { + process.stderr.write('ORCA_WSL_DAEMON_CONNECTED\n'); + process.stdin.pipe(socket); + socket.pipe(process.stdout); +}); +socket.once('error', () => { process.exitCode = 1; }); +socket.once('close', () => { process.stdin.destroy(); }); +process.stdin.once('error', () => socket.destroy()); +process.stdout.once('error', () => socket.destroy()); +` + +export const WSL_DAEMON_READ_TOKEN_SCRIPT = String.raw` +const fs = require('node:fs'); +const plan = JSON.parse(process.argv[1]); +if (String(process.getuid()) !== plan.userId || process.env.HOME !== plan.home) { + throw new Error('WSL daemon execution owner changed'); +} +const fd = fs.openSync(plan.tokenPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); +try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.size > 4096) { + throw new Error('WSL daemon token ownership is unverifiable'); + } + process.stdout.write(fs.readFileSync(fd, 'utf8').trim()); +} finally { fs.closeSync(fd); } +` diff --git a/src/main/wsl/wsl-daemon-connector-stream.test.ts b/src/main/wsl/wsl-daemon-connector-stream.test.ts new file mode 100644 index 000000000000..dd5c9685ccde --- /dev/null +++ b/src/main/wsl/wsl-daemon-connector-stream.test.ts @@ -0,0 +1,109 @@ +import { afterEach, expect, it } from 'vitest' +import { createServer } from 'node:net' +import { mkdtempSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { once } from 'node:events' +import { runProcess } from '../../shared/child-process/run-process' +import { openWslDaemonConnectorStream } from './wsl-daemon-connector-stream' +import { + WSL_DAEMON_CONNECTOR_SCRIPT, + WSL_DAEMON_READ_TOKEN_SCRIPT +} from './wsl-daemon-connector-script' + +const cleanups: (() => void)[] = [] +afterEach(() => + cleanups + .splice(0) + .toReversed() + .forEach((cleanup) => cleanup()) +) +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-guest-channel-')) + cleanups.push(() => rmSync(directory, { recursive: true, force: true })) + return { + directory, + socket: join(directory, 'd.sock'), + home: process.env.HOME, + userId: String(process.getuid?.()) + } +} +const unix = it.skipIf(process.platform === 'win32') + +unix( + 'preserves raw bytes and the last reply through connector EOF without stopping the server', + async () => { + const plan = fixture() + const reply = Buffer.alloc(256 * 1024) + for (let index = 0; index < reply.length; index++) { + reply[index] = index % 256 + } + const server = createServer((socket) => { + socket.once('data', () => socket.end(reply)) + }) + cleanups.push(() => server.close()) + server.listen(plan.socket) + await once(server, 'listening') + const stream = await openWslDaemonConnectorStream( + { + program: process.execPath, + args: ['-e', WSL_DAEMON_CONNECTOR_SCRIPT, JSON.stringify(plan)] + }, + AbortSignal.timeout(10_000) + ) + const chunks: Buffer[] = [] + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + const ended = once(stream, 'end') + stream.write('request') + await ended + expect(Buffer.concat(chunks).equals(reply)).toBe(true) + expect(server.listening).toBe(true) + stream.destroy() + } +) + +unix('rejects changed guest identity before connecting', async () => { + const plan = { ...fixture(), userId: '-1' } + await expect( + openWslDaemonConnectorStream( + { + program: process.execPath, + args: ['-e', WSL_DAEMON_CONNECTOR_SCRIPT, JSON.stringify(plan)] + }, + AbortSignal.timeout(10_000) + ) + ).rejects.toThrow('closed before connection') +}) + +it('cancels a connector that never reaches readiness', async () => { + const abort = new AbortController() + const pending = openWslDaemonConnectorStream( + { + program: process.execPath, + args: ['-e', 'setInterval(()=>{},1000)'] + }, + abort.signal + ) + abort.abort() + await expect(pending).rejects.toThrow('canceled') +}) + +unix('only reads an owner-private regular token, never a symlink', async () => { + const plan = fixture() + const tokenPath = join(plan.directory, 'token') + writeFileSync(tokenPath, 'test-private-token', { mode: 0o600 }) + const read = (file: string) => + runProcess({ + program: process.execPath, + args: ['-e', WSL_DAEMON_READ_TOKEN_SCRIPT, JSON.stringify({ ...plan, tokenPath: file })], + timeoutMs: 10_000 + }) + const valid = await read(tokenPath) + expect(valid.code).toBe(0) + expect(valid.stdout).toBe('test-private-token') + const alias = join(plan.directory, 'alias') + symlinkSync(tokenPath, alias) + const refused = await read(alias) + expect(refused.code).not.toBe(0) + expect(refused.stdout).toBe('') +}) diff --git a/src/main/wsl/wsl-daemon-connector-stream.ts b/src/main/wsl/wsl-daemon-connector-stream.ts new file mode 100644 index 000000000000..b0ecf5273a55 --- /dev/null +++ b/src/main/wsl/wsl-daemon-connector-stream.ts @@ -0,0 +1,61 @@ +import { Duplex, Readable, Writable } from 'node:stream' +import { spawnProcess, type ProcessSpec } from '../../shared/child-process/run-process' +import { WSL_DAEMON_CONNECTOR_READY } from './wsl-daemon-connector-script' + +/** Closing this stream only retires its disposable connector, never the guest daemon. */ +export function openWslDaemonConnectorStream( + spec: ProcessSpec, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + const child = spawnProcess(spec) + const stream = Duplex.fromWeb( + { readable: Readable.toWeb(child.stdout), writable: Writable.toWeb(child.stdin) }, + { objectMode: false } + ) + // A child can fail before the awaiting protocol consumer has installed its listener. + stream.on('error', () => {}) + stream.once('close', () => child.kill()) + child.once('error', (error) => stream.destroy(error)) + child.once('close', () => stream.end()) + return new Promise((resolve, reject) => { + let preamble = '' + let settled = false + const cleanup = () => { + signal.removeEventListener('abort', abort) + child.stderr.off('data', onStderr) + child.off('error', fail) + child.off('close', closed) + child.stderr.resume() + } + const fail = (error: Error) => { + if (settled) { + return + } + settled = true + cleanup() + stream.destroy() + reject(error) + } + const closed = () => fail(new Error('WSL daemon connector closed before connection')) + const abort = () => + fail(new Error('WSL daemon connector connection canceled', { cause: signal.reason })) + const onStderr = (bytes: Buffer) => { + preamble += bytes.toString('utf8') + if (preamble.length > 4096) { + fail(new Error('WSL daemon connector readiness output exceeded its limit')) + } else if (preamble === WSL_DAEMON_CONNECTOR_READY) { + settled = true + cleanup() + resolve(stream) + } + } + child.stderr.on('data', onStderr) + child.once('error', fail) + child.once('close', closed) + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) { + abort() + } + }) +} diff --git a/src/main/wsl/wsl-daemon-endpoint.test.ts b/src/main/wsl/wsl-daemon-endpoint.test.ts new file mode 100644 index 000000000000..f1f08b70d439 --- /dev/null +++ b/src/main/wsl/wsl-daemon-endpoint.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { readFile } from 'node:fs/promises' +import { prepareWslGuestOwner } from './wsl-guest-owner-preparation' +import { prepareWslDaemonEndpoint, startPreparedWslDaemonOwner } from './wsl-daemon-endpoint' +import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import { readWslDistributionIdentity } from './wsl-distribution-identity' + +vi.mock('node:fs/promises', () => ({ readFile: vi.fn() })) +vi.mock('../daemon/daemon-bun-runtime', () => ({ + desktopDaemonBundleDir: () => '/app/terminal-daemon' +})) +vi.mock('./wsl-guest-owner-preparation', () => ({ prepareWslGuestOwner: vi.fn() })) +vi.mock('./wsl-bun-runtime', () => ({ createRunningWslRuntimeRunner: vi.fn() })) +vi.mock('./wsl-distribution-identity', () => ({ readWslDistributionIdentity: vi.fn() })) +const run = vi.fn() +beforeEach(() => { + vi.clearAllMocks() + run.mockImplementation(async (spec) => + spec.program === 'wslpath' ? '/mnt/app/terminal-daemon' : 'ready' + ) + vi.mocked(readFile).mockResolvedValue(Buffer.from('daemon-artifact')) + vi.mocked(readWslDistributionIdentity).mockResolvedValue('registered-distro') + const execution = { run, signal: new AbortController().signal } + vi.mocked(createRunningWslRuntimeRunner).mockReturnValue(execution) + vi.mocked(prepareWslGuestOwner).mockResolvedValue({ + distributionId: 'registered-distro', + userName: 'captured', + userId: '1000', + platform: 'linux-x64', + libc: 'glibc', + runtime: '/private/bun', + execution, + environment: { home: '/home/u', path: '/usr/bin', envBinary: '/usr/bin/env' } + }) +}) + +it('installs only the daemon bundle with captured-owner guards and a private token path', async () => { + const prepared = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + const command = run.mock.calls.find(([spec]) => spec.program === '/usr/bin/env')?.[0] + const plan = JSON.parse(command.args.at(-1)) + expect(plan.files).toEqual([ + { name: 'daemon-entry.js', sha256: expect.stringMatching(/^[a-f0-9]{64}$/) } + ]) + expect(plan).toMatchObject({ + userId: '1000', + home: '/home/u', + source: '/mnt/app/terminal-daemon' + }) + expect(command.args).toEqual( + expect.arrayContaining(['--no-env-file', '--config=/dev/null', '--no-install']) + ) + expect(prepared.endpoint).toMatchObject({ + userName: 'captured', + distributionId: 'registered-distro' + }) + expect(prepared.endpoint.tokenPath).toBe(`${plan.ownerDirectory}/token`) + expect(Object.isFrozen(prepared.endpoint)).toBe(true) +}) + +it('keeps profiles and changed artifact contents on independent endpoints', async () => { + const first = await prepareWslDaemonEndpoint('Ubuntu', 'first') + const otherProfile = await prepareWslDaemonEndpoint('Ubuntu', 'second') + expect(otherProfile.entry).toBe(first.entry) + expect(otherProfile.endpoint.socket).not.toBe(first.endpoint.socket) + vi.mocked(readFile).mockResolvedValue(Buffer.from('new-daemon')) + const changed = await prepareWslDaemonEndpoint('Ubuntu', 'first') + expect(changed.entry).not.toBe(first.entry) + expect(changed.endpoint.socket).not.toBe(first.endpoint.socket) +}) + +it('never launches after the registered distro is replaced', async () => { + const prepared = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + run.mockClear() + vi.mocked(readWslDistributionIdentity).mockResolvedValue('different-distro') + await expect(startPreparedWslDaemonOwner(prepared)).rejects.toThrow('replaced') + expect(run).not.toHaveBeenCalled() +}) + +it('launches through the captured user and accepts only confirmed readiness', async () => { + const prepared = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + run.mockResolvedValue('started') + await startPreparedWslDaemonOwner(prepared) + expect(run.mock.lastCall?.[0].args).toEqual( + expect.arrayContaining(['--no-env-file', '--config=/dev/null', '--no-install']) + ) + expect(createRunningWslRuntimeRunner).toHaveBeenCalledWith('ubuntu', undefined, 'captured') + run.mockResolvedValue('incomplete') + await expect(startPreparedWslDaemonOwner(prepared)).rejects.toThrow('could not be started') +}) + +it('uses independent fresh endpoints for renamed users or changed environment executables', async () => { + const first = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + const initial = await prepareWslGuestOwner('Ubuntu') + vi.mocked(prepareWslGuestOwner).mockResolvedValue({ ...initial, userName: 'renamed' }) + const renamed = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + expect(renamed.owner.relayBuildId).not.toBe(first.owner.relayBuildId) + expect(renamed.endpoint.socket).not.toBe(first.endpoint.socket) + vi.mocked(prepareWslGuestOwner).mockResolvedValue({ + ...initial, + environment: { ...initial.environment, envBinary: '/opt/homebrew/bin/env' } + }) + const changed = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + expect(changed.owner.relayBuildId).not.toBe(first.owner.relayBuildId) + expect(changed.endpoint.socket).not.toBe(first.endpoint.socket) + expect(first.endpoint.userName).toBe('captured') + expect(first.endpoint.envBinary).toBe('/usr/bin/env') +}) + +it('canonicalizes fresh distro aliases without modifying persisted reconnect identities', async () => { + const first = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + const alias = await prepareWslDaemonEndpoint('ubuntu', 'profile') + expect(alias).toEqual(first) + expect(alias.owner.distro).toBe('ubuntu') +}) diff --git a/src/main/wsl/wsl-daemon-endpoint.ts b/src/main/wsl/wsl-daemon-endpoint.ts new file mode 100644 index 000000000000..0177e55df826 --- /dev/null +++ b/src/main/wsl/wsl-daemon-endpoint.ts @@ -0,0 +1,149 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join, posix } from 'node:path' +import { desktopDaemonBundleDir } from '../daemon/daemon-bun-runtime' +import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime' +import { prepareWslGuestOwner } from './wsl-guest-owner-preparation' +import { WSL_GUEST_ARTIFACT_INSTALL_SCRIPT } from './wsl-guest-artifact-install-script' +import { WSL_DAEMON_START_SCRIPT } from './wsl-daemon-start-script' +import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import { readWslDistributionIdentity } from './wsl-distribution-identity' +import type { PersistedWslDaemonEndpoint } from '../../shared/wsl-daemon-recovery' +import type { WslPtyOwner } from '../../shared/wsl-pty-id' + +export type PreparedWslDaemonEndpoint = Readonly<{ + endpoint: Readonly + owner: Readonly + entry: string + path: string + artifactId: string +}> + +const hash = (bytes: string | Buffer) => createHash('sha256').update(bytes).digest('hex') +const runtimeEnvArgs = [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'ELECTRON_RUN_AS_NODE' +].flatMap((key) => ['-u', key]) + +/** The platform-neutral daemon bundle runs with the verified guest-native Bun runtime. */ +export async function prepareWslDaemonEndpoint( + distro: string, + profileScope: string, + signal?: AbortSignal +): Promise { + if (!profileScope) { + throw new Error('WSL daemon requires a profile scope') + } + distro = distro.trim().toLowerCase() + const { distributionId, userName, userId, platform, libc, runtime, environment, execution } = + await prepareWslGuestOwner(distro, signal) + const directory = desktopDaemonBundleDir() + const entryName = 'daemon-entry.js' + const files = [{ name: entryName, sha256: hash(await readFile(join(directory, entryName))) }] + const target = `${platform}-${libc}` as const + const artifactId = hash( + JSON.stringify({ runtime: ORCAD_BUN_RELEASE_ASSETS[target].executableSha256, files }) + ) + const home = environment.home + const ownerId = hash( + JSON.stringify({ + distributionId, + userId, + userName, + home, + profileScope, + runtime, + envBinary: environment.envBinary + }) + ) + const artifactRoot = posix.join(home, '.cache/orca/terminal-daemons') + const guestDirectory = posix.join(artifactRoot, artifactId) + const ownerDirectory = posix.join( + home, + '.orca-wsl', + ownerId.slice(0, 20), + artifactId.slice(0, 20) + ) + const socket = posix.join(ownerDirectory, 'd.sock') + if (Buffer.byteLength(posix.join(ownerDirectory, '.p0000000000')) > 100) { + throw new Error('WSL home path is too long for a private terminal socket') + } + const source = await execution.run({ + program: 'wslpath', + args: ['-a', '-u', directory], + loginPath: 'none' + }) + const installed = await execution.run({ + program: environment.envBinary, + args: [ + ...runtimeEnvArgs, + runtime, + ...bunOwnedRuntimeArgs('linux'), + '-e', + WSL_GUEST_ARTIFACT_INSTALL_SCRIPT, + JSON.stringify({ + source, + artifactRoot, + directory: guestDirectory, + ownerDirectory, + files, + userId, + home + }) + ], + loginPath: 'none' + }) + if (installed !== 'ready') { + throw new Error('WSL daemon artifact preparation did not finish') + } + return Object.freeze({ + endpoint: Object.freeze({ + distro, + entry: posix.join(guestDirectory, entryName), + serverBuildId: artifactId, + distributionId, + userName, + userId, + home, + envBinary: environment.envBinary, + runtime, + socket, + tokenPath: posix.join(ownerDirectory, 'token') + }), + owner: Object.freeze({ distro, relayBuildId: `daemon+${artifactId}+${ownerId}` }), + entry: posix.join(guestDirectory, entryName), + path: environment.path, + artifactId + }) +} + +/** The daemon itself arbitrates concurrent launches; contact failure never authorizes replacement. */ +export async function startPreparedWslDaemonOwner( + prepared: PreparedWslDaemonEndpoint, + signal?: AbortSignal +): Promise { + const endpoint = prepared.endpoint + if ((await readWslDistributionIdentity(endpoint.distro)) !== endpoint.distributionId) { + throw new Error('WSL distribution was replaced; refusing to start a different terminal owner') + } + const execution = createRunningWslRuntimeRunner(endpoint.distro, signal, endpoint.userName) + const result = await execution.run({ + program: endpoint.envBinary, + args: [ + ...runtimeEnvArgs, + endpoint.runtime, + ...bunOwnedRuntimeArgs('linux'), + '-e', + WSL_DAEMON_START_SCRIPT, + JSON.stringify({ ...endpoint, entry: prepared.entry, path: prepared.path }) + ], + loginPath: 'none' + }) + if (result !== 'started' && result !== 'existing') { + throw new Error('WSL terminal daemon could not be started') + } +} diff --git a/src/main/wsl/wsl-daemon-pty-provider.ts b/src/main/wsl/wsl-daemon-pty-provider.ts new file mode 100644 index 000000000000..0e1bc1d00807 --- /dev/null +++ b/src/main/wsl/wsl-daemon-pty-provider.ts @@ -0,0 +1,138 @@ +import { + parseAppWslPtyId, + toAppWslPtyId, + toRelayWslPtyId, + type WslPtyOwner +} from '../../shared/wsl-pty-id' +import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' + +/** Only translates identities; the shared daemon adapter owns sessions, snapshots and recovery. */ +export class WslDaemonPtyProvider implements IPtyProvider { + readonly owner: Readonly + + constructor( + owner: WslPtyOwner, + private readonly adapter: DaemonPtyAdapter + ) { + this.owner = Object.freeze({ ...owner }) + } + + private guestId = (id: string): string => toRelayWslPtyId(this.owner, id) + private appId = (id: string): string => toAppWslPtyId(this.owner, id) + + async spawn(options: PtySpawnOptions): Promise { + const encoded = options.sessionId && parseAppWslPtyId(options.sessionId) + if (options.sessionId && !encoded && (options.attachOnly || !options.isNewSession)) { + throw new Error('Guest daemon attach requires an encoded WSL terminal identity') + } + const result = await this.adapter.spawn({ + ...options, + sessionId: encoded ? this.guestId(options.sessionId!) : options.sessionId, + terminalWindowsWslDistro: undefined, + terminalWindowsPowerShellImplementation: undefined + }) + return { + ...result, + id: this.appId(result.id), + wslDistro: this.owner.distro, + ...(result.agentSessionEnsure + ? { + agentSessionEnsure: { + ...result.agentSessionEnsure, + owner: { + ...result.agentSessionEnsure.owner, + ptyId: this.appId(result.agentSessionEnsure.owner.ptyId) + } + } + } + : {}) + } + } + + attach = (id: string) => this.adapter.attach(this.guestId(id)) + hasPty = (id: string) => this.adapter.hasPty(this.guestId(id)) + probePtyLiveness = (id: string) => this.adapter.probePtyLiveness(this.guestId(id)) + write = (id: string, data: string) => this.adapter.write(this.guestId(id), data) + writeWithSettlement = (id: string, data: string) => + this.adapter.writeWithSettlement(this.guestId(id), data) + resize = (id: string, cols: number, rows: number) => + this.adapter.resize(this.guestId(id), cols, rows) + pauseProducer = (id: string) => this.adapter.pauseProducer(this.guestId(id)) + resumeProducer = (id: string) => this.adapter.resumeProducer(this.guestId(id)) + setPtyBackgrounded = (id: string, background: boolean) => + this.adapter.setPtyBackgrounded(this.guestId(id), background) + shutdown: IPtyProvider['shutdown'] = (id, options) => + this.adapter.shutdown(this.guestId(id), options) + sendSignal = (id: string, signal: string) => this.adapter.sendSignal(this.guestId(id), signal) + getCwd = (id: string) => this.adapter.getCwd(this.guestId(id)) + getInitialCwd = (id: string) => this.adapter.getInitialCwd(this.guestId(id)) + clearBuffer = (id: string) => this.adapter.clearBuffer(this.guestId(id)) + closeStartupQueryAuthority = (id: string) => + this.adapter.closeStartupQueryAuthority(this.guestId(id)) + acknowledgeDataEvent = (id: string, count: number) => + this.adapter.acknowledgeDataEvent(this.guestId(id), count) + async hasChildProcesses(id: string): Promise { + const guestId = this.guestId(id) + try { + const inspection = await this.adapter.inspectProcess(guestId) + return inspection.childProcessEvidence !== 'no-children' + } catch { + return true + } + } + getForegroundProcess = (id: string) => this.adapter.getForegroundProcess(this.guestId(id)) + confirmForegroundProcess = (id: string) => this.adapter.confirmForegroundProcess(this.guestId(id)) + confirmShellForeground = (id: string) => this.adapter.confirmShellForeground(this.guestId(id)) + inspectProcess = (id: string, options?: Parameters[1]) => + this.adapter.inspectProcess(this.guestId(id), options) + getBufferSnapshot = ( + id: string, + options?: Parameters[1] + ) => this.adapter.getBufferSnapshot(this.guestId(id), options) + canProvideAuthoritativeBufferSnapshot = (id: string) => + this.adapter.canProvideAuthoritativeBufferSnapshot(this.guestId(id)) + getAppliedSize = (id: string) => this.adapter.getAppliedSize(this.guestId(id)) + supportsGitCredentialGuardHost = () => this.adapter.supportsGitCredentialGuardHost() + supportsAgentSessionClaims = () => this.adapter.supportsAgentSessionClaims() + supportsAgentSessionCreateOperations = () => this.adapter.supportsAgentSessionCreateOperations() + providesAgentSessionOwnerListings = (id: string) => + this.adapter.providesAgentSessionOwnerListings(this.guestId(id)) + getDefaultShell = () => this.adapter.getDefaultShell() + getProfiles = () => this.adapter.getProfiles() + serialize = (ids: string[]) => this.adapter.serialize(ids.map(this.guestId)) + revive = (state: string) => this.adapter.revive(state) + ackColdRestore = (id: string) => this.adapter.ackColdRestore(this.guestId(id)) + + async listProcesses(options?: Parameters[0]) { + const processes = await this.adapter.listProcesses(options) + return processes.map((entry) => ({ + ...entry, + id: this.appId(entry.id), + wslDistro: this.owner.distro, + ...(entry.agentSessionOwners + ? { + agentSessionOwners: entry.agentSessionOwners.map((owner) => ({ + ...owner, + ptyId: this.appId(owner.ptyId) + })) + } + : {}) + })) + } + + onData: IPtyProvider['onData'] = (listener) => + this.adapter.onData((event) => listener({ ...event, id: this.appId(event.id) })) + onReplay: IPtyProvider['onReplay'] = (listener) => + this.adapter.onReplay((event) => listener({ ...event, id: this.appId(event.id) })) + onExit: IPtyProvider['onExit'] = (listener) => + this.adapter.onExit((event) => listener({ ...event, id: this.appId(event.id) })) + onBackgroundStreamEvent: NonNullable = (listener) => + this.adapter.onBackgroundStreamEvent((event) => + listener({ ...event, id: this.appId(event.id) }) + ) + onWriteUnavailable: NonNullable = (listener) => + this.adapter.onWriteUnavailable((event) => listener({ ...event, id: this.appId(event.id) })) + establishLifecycleLease = () => this.adapter.establishLifecycleLease() + disconnectOnly = () => this.adapter.disconnectOnly() +} diff --git a/src/main/wsl/wsl-daemon-reattach-hooks.test.ts b/src/main/wsl/wsl-daemon-reattach-hooks.test.ts new file mode 100644 index 000000000000..583b930d1513 --- /dev/null +++ b/src/main/wsl/wsl-daemon-reattach-hooks.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { prepareWslDaemonReattachHooks } from './wsl-daemon-reattach-hooks' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { toAppWslPtyId } from '../../shared/wsl-pty-id' +import type { WslDaemonConnection } from './wsl-daemon-sessions' + +vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ + wslHookRelayManager: { ensureForDistro: vi.fn(async () => {}) } +})) +beforeEach(() => vi.clearAllMocks()) + +it('restarts hooks as the persisted user rather than the current default', async () => { + const owner = { distro: 'Ubuntu', relayBuildId: 'retained-owner' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Hook preparation reads only endpoint.distro and endpoint.userName from this connection. + const connection = { endpoint: { distro: 'Ubuntu', userName: 'root' } } as WslDaemonConnection + const reconnect = vi.fn(async () => connection) + const result = { id: toAppWslPtyId(owner, 'retained'), isReattach: true } + await expect( + prepareWslDaemonReattachHooks({ result, sessions: { reconnect }, hooksEnabled: true }) + ).resolves.toBe(true) + expect(reconnect).toHaveBeenCalledWith({ ...owner, relayPtyId: 'retained' }) + expect(wslHookRelayManager.ensureForDistro).toHaveBeenCalledWith( + 'Ubuntu', + undefined, + undefined, + 'root' + ) +}) + +it('does not prepare hooks for disabled hooks or a fresh guest spawn', async () => { + const id = toAppWslPtyId({ distro: 'Ubuntu', relayBuildId: 'owner' }, 'terminal') + await expect( + prepareWslDaemonReattachHooks({ result: { id, isReattach: true }, hooksEnabled: false }) + ).resolves.toBe(true) + await expect(prepareWslDaemonReattachHooks({ result: { id }, hooksEnabled: true })).resolves.toBe( + true + ) + expect(wslHookRelayManager.ensureForDistro).not.toHaveBeenCalled() +}) + +it('does not delay an already-live attach on unavailable hook preparation', async () => { + const reconnect = vi.fn(() => new Promise(() => {})) + const id = toAppWslPtyId({ distro: 'Ubuntu', relayBuildId: 'owner' }, 'terminal') + await expect( + prepareWslDaemonReattachHooks({ + result: { id, isReattach: true }, + sessions: { reconnect }, + hooksEnabled: true + }) + ).resolves.toBe(true) + expect(wslHookRelayManager.ensureForDistro).not.toHaveBeenCalled() +}) diff --git a/src/main/wsl/wsl-daemon-reattach-hooks.ts b/src/main/wsl/wsl-daemon-reattach-hooks.ts new file mode 100644 index 000000000000..0ba46596f812 --- /dev/null +++ b/src/main/wsl/wsl-daemon-reattach-hooks.ts @@ -0,0 +1,38 @@ +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { parseAppWslPtyId } from '../../shared/wsl-pty-id' +import type { WslDaemonSessions } from './wsl-daemon-sessions' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import type { PtySpawnResult } from '../providers/types' + +/** Return true for guest owners so callers never fall back to default-user hook preparation. */ +export async function prepareWslDaemonReattachHooks(args: { + result: Pick + sessions?: Pick + hooksEnabled: boolean + selectedCodexHomePath?: string | null +}): Promise { + const owner = parseAppWslPtyId(args.result.id) + if (!owner) { + return false + } + if (!args.result.isReattach || !args.hooksEnabled) { + return true + } + const sessions = args.sessions + if (!sessions) { + console.warn('[wsl] Terminal hooks unavailable: captured owner is not connected') + return true + } + void waitForPromiseWithSignal( + sessions.reconnect(owner).then(async ({ endpoint }) => { + await wslHookRelayManager.ensureForDistro( + endpoint.distro, + args.selectedCodexHomePath, + undefined, + endpoint.userName + ) + }), + AbortSignal.timeout(15_000) + ).catch((error) => console.warn('[wsl] Terminal reattach hooks unavailable', error)) + return true +} diff --git a/src/main/wsl/wsl-daemon-sessions.test.ts b/src/main/wsl/wsl-daemon-sessions.test.ts new file mode 100644 index 000000000000..0953b17b9896 --- /dev/null +++ b/src/main/wsl/wsl-daemon-sessions.test.ts @@ -0,0 +1,317 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { parseAppWslPtyId, toAppWslPtyId } from '../../shared/wsl-pty-id' +import { WslDaemonSessions } from './wsl-daemon-sessions' +import type { WslDaemonRecovery } from '../../shared/wsl-daemon-recovery' +import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { prepareWslDaemonEndpoint, startPreparedWslDaemonOwner } from './wsl-daemon-endpoint' +import { createWslDaemonTransport } from './wsl-daemon-transport' +import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import { registerWslPtyProvider } from '../ipc/pty/provider/registry' +import { readWslDistributionIdentity } from './wsl-distribution-identity' + +const { lease, detach, unregister, run } = vi.hoisted(() => ({ + lease: vi.fn(), + detach: vi.fn(), + unregister: vi.fn(), + run: vi.fn() +})) +vi.mock('../daemon/daemon-pty-adapter', () => ({ + DaemonPtyAdapter: vi.fn( + class { + establishLifecycleLease = lease + disconnectOnly = detach + } + ) +})) +vi.mock('../ipc/pty/provider/registry', () => ({ registerWslPtyProvider: vi.fn(() => unregister) })) +vi.mock('./wsl-daemon-endpoint', () => ({ + prepareWslDaemonEndpoint: vi.fn(), + startPreparedWslDaemonOwner: vi.fn() +})) +vi.mock('./wsl-daemon-transport', () => ({ createWslDaemonTransport: vi.fn(() => ({})) })) +vi.mock('./wsl-bun-runtime', () => ({ createRunningWslRuntimeRunner: vi.fn(() => ({ run })) })) +vi.mock('./wsl-distribution-identity', () => ({ readWslDistributionIdentity: vi.fn() })) +const owner = { distro: 'Ubuntu', relayBuildId: 'daemon+artifact+profile-user' } +const endpoint = { + distro: 'Ubuntu', + distributionId: 'registration', + userName: 'alice', + userId: '1000', + home: '/home/alice', + envBinary: '/usr/bin/env', + runtime: '/bin/bun', + entry: '/daemon.js', + socket: '/private/socket', + tokenPath: '/private/token', + serverBuildId: 'artifact' +} +const prepared = { owner, endpoint, entry: '/daemon.js', path: '/bin', artifactId: 'artifact' } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function setup(record: WslDaemonRecovery | null = null) { + const store = { + getWslDaemonRecovery: vi.fn(() => record), + upsertWslDaemonRecovery: vi.fn(async (_value: WslDaemonRecovery) => {}) + } + return { + store, + sessions: new WslDaemonSessions({ store, profileScope: '/profile', historyRoot: '/history' }) + } +} +beforeEach(() => { + vi.clearAllMocks() + lease.mockReset().mockResolvedValue(undefined) + detach.mockReset().mockResolvedValue(undefined) + run.mockReset().mockResolvedValue('/bin/bash') + vi.mocked(prepareWslDaemonEndpoint).mockReset().mockResolvedValue(prepared) + vi.mocked(startPreparedWslDaemonOwner).mockReset().mockResolvedValue(undefined) + vi.mocked(readWslDistributionIdentity).mockResolvedValue('registration') +}) + +it('coalesces preparation and publishes only after durable owner admission', async () => { + const { store, sessions } = setup() + const persisted = deferred() + store.upsertWslDaemonRecovery.mockReturnValue(persisted.promise) + const first = sessions.prepareFresh('Ubuntu') + const second = sessions.prepareFresh('ubuntu') + await vi.waitFor(() => expect(store.upsertWslDaemonRecovery).toHaveBeenCalledOnce()) + expect(registerWslPtyProvider).not.toHaveBeenCalled() + persisted.resolve() + const [a, b] = await Promise.all([first, second]) + expect(a).toBe(b) + expect(Object.isFrozen(a.execution)).toBe(true) + expect(a.execution).toEqual({ + distro: 'Ubuntu', + userName: 'alice', + userId: '1000', + home: '/home/alice' + }) + expect(prepareWslDaemonEndpoint).toHaveBeenCalledOnce() + expect(registerWslPtyProvider).toHaveBeenCalledOnce() + expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() + expect(DaemonPtyAdapter).toHaveBeenCalledWith( + expect.objectContaining({ + historyPath: expect.stringMatching(/history[/\\][a-f0-9]{64}$/), + guest: expect.objectContaining({ defaultShell: '/bin/bash', defaultCwd: '/home/alice' }) + }) + ) + await sessions.dispose() +}) + +it('reconnects only the persisted explicit owner without preparing or starting', async () => { + const { sessions } = setup({ kind: 'daemon', ...owner, endpoint }) + const [a, b] = await Promise.all([sessions.reconnect(owner), sessions.reconnect(owner)]) + expect(a).toBe(b) + expect(createWslDaemonTransport).toHaveBeenCalledWith(endpoint) + expect(run.mock.lastCall?.[0].args).toEqual( + expect.arrayContaining(['--no-env-file', '--config=/dev/null', '--no-install']) + ) + expect(createRunningWslRuntimeRunner).toHaveBeenCalledWith( + 'Ubuntu', + expect.any(AbortSignal), + 'alice' + ) + expect(prepareWslDaemonEndpoint).not.toHaveBeenCalled() + expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() + await sessions.dispose() +}) + +it('delegates failed fresh contact to the safe starter, but never starts on durable-write failure', async () => { + lease.mockRejectedValueOnce(new Error('missing endpoint')) + const { store, sessions } = setup() + store.upsertWslDaemonRecovery.mockRejectedValueOnce(new Error('disk full')) + await expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('disk full') + expect(startPreparedWslDaemonOwner).toHaveBeenCalledOnce() + expect(lease).toHaveBeenCalledTimes(2) + expect(detach).toHaveBeenCalledOnce() + expect(registerWslPtyProvider).not.toHaveBeenCalled() + await sessions.dispose() +}) + +it('does not retry or publish when the fresh owner cannot be verified by the starter', async () => { + lease.mockRejectedValueOnce(new Error('missing endpoint')) + vi.mocked(startPreparedWslDaemonOwner).mockRejectedValueOnce(new Error('owner unverifiable')) + const { store, sessions } = setup() + await expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('owner unverifiable') + expect(startPreparedWslDaemonOwner).toHaveBeenCalledWith(prepared, expect.any(AbortSignal)) + expect(lease).toHaveBeenCalledOnce() + expect(store.upsertWslDaemonRecovery).not.toHaveBeenCalled() + expect(registerWslPtyProvider).not.toHaveBeenCalled() + expect(detach).toHaveBeenCalledOnce() + await sessions.dispose() +}) + +it('isolates caller cancellation from another waiter and the durable connection', async () => { + const { store, sessions } = setup() + const persisted = deferred() + store.upsertWslDaemonRecovery.mockReturnValue(persisted.promise) + const abort = new AbortController() + const canceled = sessions.prepareFresh('Ubuntu', abort.signal) + const survivor = sessions.prepareFresh('Ubuntu') + abort.abort(new Error('cancel only this spawn')) + await expect(canceled).rejects.toThrow('cancel only this spawn') + persisted.resolve() + await survivor + expect(detach).not.toHaveBeenCalled() + await sessions.dispose() +}) + +it('fences publication after profile disposal during a durable write', async () => { + const { store, sessions } = setup() + const persisted = deferred() + store.upsertWslDaemonRecovery.mockReturnValue(persisted.promise) + const opening = sessions.prepareFresh('Ubuntu') + const rejected = expect(opening).rejects.toThrow('disposed') + await vi.waitFor(() => expect(store.upsertWslDaemonRecovery).toHaveBeenCalledOnce()) + const closing = sessions.dispose() + persisted.resolve() + await Promise.all([rejected, closing]) + expect(registerWslPtyProvider).not.toHaveBeenCalled() + expect(detach).toHaveBeenCalledOnce() +}) + +it('unregisters immediately and awaits detach checkpoint completion', async () => { + const { sessions } = setup() + await sessions.prepareFresh('Ubuntu') + const checkpoint = deferred() + detach.mockReturnValue(checkpoint.promise) + let completed = false + const closing = sessions.dispose().then(() => { + completed = true + }) + expect(unregister).toHaveBeenCalledOnce() + await Promise.resolve() + expect(completed).toBe(false) + checkpoint.resolve() + await closing + expect(completed).toBe(true) +}) + +it('does not execute guest probes after a distro replacement', async () => { + vi.mocked(readWslDistributionIdentity).mockResolvedValue('replacement') + const { sessions } = setup({ kind: 'daemon', ...owner, endpoint }) + await expect(sessions.reconnect(owner)).rejects.toThrow('unverifiable') + expect(run).not.toHaveBeenCalled() + expect(registerWslPtyProvider).not.toHaveBeenCalled() + await sessions.dispose() +}) + +it('refuses unknown persisted owners instead of capturing the current default user', async () => { + const { sessions } = setup() + await expect(sessions.reconnect(owner)).rejects.toThrow('unverifiable') + expect(prepareWslDaemonEndpoint).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + await sessions.dispose() +}) + +it('does not start or republish an unreachable persisted daemon', async () => { + const { sessions, store } = setup({ kind: 'daemon', ...owner, endpoint }) + lease.mockRejectedValueOnce(new Error('owner contact unverifiable')) + await expect(sessions.reconnect(owner)).rejects.toThrow('unverifiable') + expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() + expect(store.upsertWslDaemonRecovery).not.toHaveBeenCalled() + expect(registerWslPtyProvider).not.toHaveBeenCalled() + expect(detach).toHaveBeenCalledOnce() + await sessions.dispose() +}) + +it('does not publish delayed preparation after the profile has closed', async () => { + const preparation = deferred() + vi.mocked(prepareWslDaemonEndpoint).mockReturnValue(preparation.promise) + const { sessions } = setup() + const opening = sessions.prepareFresh('Ubuntu') + const rejected = expect(opening).rejects.toThrow('disposed') + const closing = sessions.dispose() + preparation.resolve(prepared) + await Promise.all([rejected, closing]) + expect(DaemonPtyAdapter).not.toHaveBeenCalled() + expect(registerWslPtyProvider).not.toHaveBeenCalled() +}) + +it('refuses a changed endpoint under an already-published owner identity', async () => { + const { sessions } = setup() + await sessions.prepareFresh('Ubuntu') + vi.mocked(prepareWslDaemonEndpoint).mockResolvedValue({ + ...prepared, + endpoint: { ...endpoint, userName: 'bob' } + }) + await expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('cannot change') + expect(DaemonPtyAdapter).toHaveBeenCalledOnce() + expect(registerWslPtyProvider).toHaveBeenCalledOnce() + await sessions.dispose() +}) + +it('reconnects a parsed terminal identity without persisting its session suffix', async () => { + const { store, sessions } = setup({ kind: 'daemon', ...owner, endpoint }) + const parsed = parseAppWslPtyId(toAppWslPtyId(owner, 'persisted-session')) + if (!parsed) { + throw new Error('Invalid fixture terminal identity') + } + try { + const connection = await sessions.reconnect(parsed) + expect(connection.owner).toEqual(owner) + expect(store.upsertWslDaemonRecovery).toHaveBeenCalledWith({ + kind: 'daemon', + ...owner, + endpoint + }) + expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() + } finally { + await sessions.dispose() + } +}) + +it('restarts a missing cached owner for a fresh spawn without replacing a retained session', async () => { + const { sessions } = setup({ kind: 'daemon', ...owner, endpoint }) + await sessions.prepareFresh('Ubuntu') + lease.mockRejectedValueOnce(new Error('owner contact unverifiable')) + await expect(sessions.reconnect(owner)).rejects.toThrow('unverifiable') + expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() + lease.mockRejectedValueOnce(new Error('missing endpoint')) + await sessions.prepareFresh('Ubuntu') + expect(startPreparedWslDaemonOwner).toHaveBeenCalledOnce() + expect(DaemonPtyAdapter).toHaveBeenCalledOnce() + await sessions.dispose() +}) + +it('retains a cached owner when fresh startup cannot prove its endpoint absent', async () => { + const { sessions } = setup({ kind: 'daemon', ...owner, endpoint }) + await sessions.prepareFresh('Ubuntu') + lease.mockRejectedValueOnce(new Error('contact failed')) + vi.mocked(startPreparedWslDaemonOwner).mockRejectedValueOnce(new Error('owner unverifiable')) + await expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('owner unverifiable') + expect(detach).not.toHaveBeenCalled() + expect(unregister).not.toHaveBeenCalled() + await sessions.reconnect(owner) + expect(DaemonPtyAdapter).toHaveBeenCalledOnce() + await sessions.dispose() +}) + +it('lets a fresh caller recover after joining a failed retained-owner reconnect', async () => { + const { sessions } = setup({ kind: 'daemon', ...owner, endpoint }) + let fail!: (error: Error) => void + lease.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + fail = reject + }) + ) + const restore = sessions.reconnect(owner) + const rejected = expect(restore).rejects.toThrow('missing endpoint') + await vi.waitFor(() => expect(lease).toHaveBeenCalledOnce()) + const fresh = sessions.prepareFresh('Ubuntu') + await vi.waitFor(() => expect(prepareWslDaemonEndpoint).toHaveBeenCalledOnce()) + await Promise.resolve() + lease.mockRejectedValueOnce(new Error('missing endpoint')) + fail(new Error('missing endpoint')) + await rejected + await fresh + expect(startPreparedWslDaemonOwner).toHaveBeenCalledOnce() + expect(registerWslPtyProvider).toHaveBeenCalledOnce() + await sessions.dispose() +}) diff --git a/src/main/wsl/wsl-daemon-sessions.ts b/src/main/wsl/wsl-daemon-sessions.ts new file mode 100644 index 000000000000..bb686e15a436 --- /dev/null +++ b/src/main/wsl/wsl-daemon-sessions.ts @@ -0,0 +1,271 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + normalizeWslDaemonRecovery, + type PersistedWslDaemonEndpoint +} from '../../shared/wsl-daemon-recovery' +import { wslPtyOwnerKey } from '../../shared/wsl-pty-consumer-recovery' +import type { WslPtyOwner } from '../../shared/wsl-pty-id' +import type { Store } from '../persistence' +import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { registerWslPtyProvider } from '../ipc/pty/provider/registry' +import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import { createWslDaemonTransport } from './wsl-daemon-transport' +import { WslDaemonPtyProvider } from './wsl-daemon-pty-provider' +import { + prepareWslDaemonEndpoint, + startPreparedWslDaemonOwner, + type PreparedWslDaemonEndpoint +} from './wsl-daemon-endpoint' +import type { WslAccountExecutionContext } from './wsl-account-execution-context' +import { readWslDistributionIdentity } from './wsl-distribution-identity' + +export type WslDaemonConnection = Readonly<{ + owner: Readonly + endpoint: Readonly + provider: WslDaemonPtyProvider +}> +export type PreparedWslDaemonTerminalOwner = Readonly<{ + prepared: PreparedWslDaemonEndpoint + execution: WslAccountExecutionContext + connection: WslDaemonConnection +}> +type Entry = { + endpoint: Readonly + pending: Promise + close?: () => Promise +} + +/** Profile-scoped composition only; the existing daemon owns terminal state and reconnection. */ +export class WslDaemonSessions { + private readonly entries = new Map() + private readonly preparing = new Map>() + private readonly lifetime = new AbortController() + private closing: Promise | undefined + + constructor( + private readonly options: { + store: Pick + profileScope: string + historyRoot: string + } + ) { + if (!options.profileScope || !options.historyRoot) { + throw new Error('WSL daemon sessions require profile and history scopes') + } + } + + prepareFresh(distro: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + this.lifetime.signal.throwIfAborted() + const key = distro.trim().toLowerCase() + if (!key) { + throw new Error('WSL terminal distro is required') + } + let pending = this.preparing.get(key) + if (!pending) { + pending = this.prepare(distro).finally(() => this.preparing.delete(key)) + this.preparing.set(key, pending) + } + return waitForPromiseWithSignal(pending, signal) + } + + private async prepare(distro: string): Promise { + const prepared = await prepareWslDaemonEndpoint( + distro, + this.options.profileScope, + this.lifetime.signal + ) + this.lifetime.signal.throwIfAborted() + const connection = await this.connect(prepared.owner, prepared.endpoint, undefined, prepared) + return Object.freeze({ + prepared, + connection, + execution: Object.freeze({ + distro: prepared.endpoint.distro, + userName: prepared.endpoint.userName, + userId: prepared.endpoint.userId, + home: prepared.endpoint.home + }) + }) + } + + reconnect(owner: WslPtyOwner, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + this.lifetime.signal.throwIfAborted() + const record = this.options.store.getWslDaemonRecovery(owner) + if (!record) { + return Promise.reject(new Error('WSL daemon owner endpoint is unverifiable')) + } + const reconnecting = this.connect(owner, record.endpoint).then(async (connection) => { + this.lifetime.signal.throwIfAborted() + await connection.provider.establishLifecycleLease() + this.lifetime.signal.throwIfAborted() + return connection + }) + return waitForPromiseWithSignal(reconnecting, signal) + } + + private connect( + owner: WslPtyOwner, + endpoint: PersistedWslDaemonEndpoint, + signal?: AbortSignal, + prepared?: PreparedWslDaemonEndpoint + ): Promise { + this.lifetime.signal.throwIfAborted() + const identity = Object.freeze({ distro: owner.distro, relayBuildId: owner.relayBuildId }) + const record = normalizeWslDaemonRecovery({ kind: 'daemon', ...identity, endpoint }) + if (!record) { + return Promise.reject(new Error('WSL daemon owner endpoint is incomplete')) + } + const key = wslPtyOwnerKey(owner) + let entry = this.entries.get(key) + if (entry && JSON.stringify(entry.endpoint) !== JSON.stringify(record.endpoint)) { + return Promise.reject(new Error('WSL daemon owner endpoint cannot change')) + } + if (!entry) { + const captured = Object.freeze({ ...record.endpoint }) + const pending = this.open(identity, captured, prepared).catch((error) => { + this.entries.delete(key) + throw error + }) + entry = { endpoint: captured, pending } + this.entries.set(key, entry) + return waitForPromiseWithSignal(entry.pending, signal) + } + const connection = entry.pending + return waitForPromiseWithSignal( + prepared + ? connection.then( + async (connected) => { + await this.establishOwnerLease(connected.provider, prepared) + return connected + }, + () => { + this.lifetime.signal.throwIfAborted() + return this.connect(owner, endpoint, undefined, prepared) + } + ) + : connection, + signal + ) + } + + private async establishOwnerLease( + provider: WslDaemonPtyProvider, + prepared?: PreparedWslDaemonEndpoint + ): Promise { + const signal = this.lifetime.signal + signal.throwIfAborted() + try { + await provider.establishLifecycleLease() + } catch (error) { + signal.throwIfAborted() + if (!prepared) { + throw error + } + await startPreparedWslDaemonOwner(prepared, signal) + signal.throwIfAborted() + await provider.establishLifecycleLease() + } + signal.throwIfAborted() + } + + private async open( + owner: Readonly, + endpoint: Readonly, + prepared?: PreparedWslDaemonEndpoint + ): Promise { + const signal = this.lifetime.signal + const shell = await readGuestShell(endpoint, signal) + signal.throwIfAborted() + const historyId = createHash('sha256') + .update(JSON.stringify([this.options.profileScope, wslPtyOwnerKey(owner)])) + .digest('hex') + const adapter = new DaemonPtyAdapter({ + profileScope: this.options.profileScope, + historyPath: join(this.options.historyRoot, historyId), + guest: { + distro: owner.distro, + transport: createWslDaemonTransport(endpoint), + defaultShell: shell, + defaultCwd: endpoint.home, + profiles: [{ name: shell.slice(shell.lastIndexOf('/') + 1), path: shell }] + } + }) + const provider = new WslDaemonPtyProvider(owner, adapter) + let unregister: (() => void) | undefined + try { + await this.establishOwnerLease(provider, prepared) + await this.options.store.upsertWslDaemonRecovery({ kind: 'daemon', ...owner, endpoint }) + signal.throwIfAborted() + unregister = registerWslPtyProvider(owner, provider) + const entry = this.entries.get(wslPtyOwnerKey(owner)) + if (!entry) { + throw new Error('WSL daemon session disappeared before publication') + } + entry.close = async () => { + unregister?.() + await provider.disconnectOnly() + } + return Object.freeze({ owner, endpoint, provider }) + } catch (error) { + unregister?.() + await provider.disconnectOnly() + throw error + } + } + + dispose(): Promise { + if (!this.closing) { + this.lifetime.abort(new Error('WSL daemon profile was disposed')) + const entries = [...this.entries.values()] + const closing = entries.map(async (entry) => entry.close?.()) + this.closing = (async () => { + await Promise.all([ + Promise.allSettled([ + ...this.preparing.values(), + ...entries.map((entry) => entry.pending) + ]), + Promise.all(closing) + ]) + this.entries.clear() + this.preparing.clear() + })() + } + return this.closing + } +} + +async function readGuestShell( + endpoint: PersistedWslDaemonEndpoint, + signal: AbortSignal +): Promise { + if ((await readWslDistributionIdentity(endpoint.distro)) !== endpoint.distributionId) { + throw new Error('WSL distribution was replaced; the terminal owner is unverifiable') + } + signal.throwIfAborted() + const runner = createRunningWslRuntimeRunner(endpoint.distro, signal, endpoint.userName) + const script = + 'const e=JSON.parse(process.argv[1]);if(String(process.getuid())!==e.userId||process.env.HOME!==e.home)throw new Error("WSL terminal owner changed");const s=process.env.SHELL;if(!s||!s.startsWith("/")||/[\\0\\r\\n]/.test(s))throw new Error("WSL login shell unavailable");console.log(s)' + return runner.run({ + program: endpoint.envBinary, + args: [ + ...[ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'ELECTRON_RUN_AS_NODE' + ].flatMap((key) => ['-u', key]), + endpoint.runtime, + ...bunOwnedRuntimeArgs('linux'), + '-e', + script, + JSON.stringify({ userId: endpoint.userId, home: endpoint.home }) + ], + loginPath: 'preferred' + }) +} diff --git a/src/main/wsl/wsl-daemon-spawn-route.ts b/src/main/wsl/wsl-daemon-spawn-route.ts new file mode 100644 index 000000000000..32928870e683 --- /dev/null +++ b/src/main/wsl/wsl-daemon-spawn-route.ts @@ -0,0 +1,57 @@ +import { parseAppWslPtyId } from '../../shared/wsl-pty-id' +import type { WslAccountExecutionContext } from './wsl-account-execution-context' +import type { WslDaemonConnection, WslDaemonSessions } from './wsl-daemon-sessions' +import type { PreparedWslGuestSpawnOwner } from './wsl-guest-spawn-options' + +export type WslDaemonSpawnRoute = Readonly<{ + connection: WslDaemonConnection + execution: WslAccountExecutionContext + prepared: PreparedWslGuestSpawnOwner + fresh: boolean +}> + +/** Existing unencoded IDs retain their Windows owner; only fresh WSL terminals change backend. */ +export async function prepareWslDaemonSpawnRoute(args: { + sessions?: Pick + connectionId?: string | null + sessionId?: string + isNewSession?: boolean + distro?: string | null + signal?: AbortSignal + platform?: NodeJS.Platform +}): Promise { + if (args.connectionId) { + return null + } + const owner = args.sessionId ? parseAppWslPtyId(args.sessionId) : null + if (args.sessionId?.startsWith('wsl:') && !owner) { + throw new Error('Invalid WSL terminal identity') + } + if (owner) { + if (!args.sessions || (args.platform ?? process.platform) !== 'win32') { + throw new Error('WSL terminal owner is unavailable on this host') + } + const connection = await args.sessions.reconnect(owner, args.signal) + return Object.freeze({ + connection, + prepared: Object.freeze({ owner: connection.owner, endpoint: connection.endpoint }), + execution: Object.freeze({ + distro: connection.endpoint.distro, + userName: connection.endpoint.userName, + userId: connection.endpoint.userId, + home: connection.endpoint.home + }), + fresh: false + }) + } + if ( + (args.sessionId && args.isNewSession !== true) || + !args.sessions || + !args.distro || + (args.platform ?? process.platform) !== 'win32' + ) { + return null + } + const fresh = await args.sessions.prepareFresh(args.distro, args.signal) + return Object.freeze({ ...fresh, fresh: true }) +} diff --git a/src/main/wsl/wsl-daemon-start-script.test.ts b/src/main/wsl/wsl-daemon-start-script.test.ts new file mode 100644 index 000000000000..0889c4fad90a --- /dev/null +++ b/src/main/wsl/wsl-daemon-start-script.test.ts @@ -0,0 +1,86 @@ +import { EventEmitter } from 'node:events' +import { runInNewContext } from 'node:vm' +import { expect, it, vi } from 'vitest' +import { WSL_DAEMON_START_SCRIPT } from './wsl-daemon-start-script' + +it.each(['live', 'EACCES', 'timeout', 'ENOENT', 'ECONNREFUSED'])( + 'launches only after verified absent %s contact', + async (contact) => { + const absent = contact === 'ENOENT' || contact === 'ECONNREFUSED' + const spawn = vi.fn(() => ({ unref: vi.fn() })) + let contacts = 0 + const processState = { + argv: [ + 'bun', + JSON.stringify({ + userId: '1000', + home: '/home/u', + runtime: '/bun', + entry: '/daemon.js', + socket: '/s', + tokenPath: '/t' + }) + ], + env: { HOME: '/home/u' }, + getuid: () => 1000, + exitCode: 0 + } + const require = () => ({ + createConnection: () => { + const outcome = contacts++ > 0 ? 'live' : contact + const socket = new EventEmitter() + Object.assign(socket, { setTimeout: vi.fn(), destroy: vi.fn() }) + queueMicrotask(() => { + if (outcome === 'live') { + socket.emit('connect') + } else if (outcome === 'timeout') { + socket.emit('timeout') + } else { + socket.emit('error', Object.assign(new Error(outcome), { code: outcome })) + } + }) + return socket + } + }) + await runInNewContext(WSL_DAEMON_START_SCRIPT, { + require, + process: processState, + Bun: { spawn }, + console: { log: vi.fn(), error: vi.fn() }, + setTimeout + }) + expect(spawn).toHaveBeenCalledTimes(absent ? 1 : 0) + if (absent) { + expect(spawn).toHaveBeenCalledWith( + [ + '/bun', + '--no-env-file', + '--config=/dev/null', + '--no-install', + '/daemon.js', + '--socket', + '/s', + '--token', + '/t' + ], + expect.objectContaining({ detached: true }) + ) + } + expect(processState.exitCode).toBe(contact === 'live' || absent ? 0 : 1) + } +) + +it('rejects a changed user before touching the endpoint', () => { + const require = vi.fn() + expect(() => + runInNewContext(WSL_DAEMON_START_SCRIPT, { + require, + process: { + argv: ['bun', JSON.stringify({ userId: '1000', home: '/home/u' })], + getuid: () => 1001, + env: { HOME: '/home/u' } + } + }) + ).toThrow('owner changed') + expect(require).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/wsl/wsl-daemon-start-script.ts b/src/main/wsl/wsl-daemon-start-script.ts new file mode 100644 index 000000000000..37517c4d7b97 --- /dev/null +++ b/src/main/wsl/wsl-daemon-start-script.ts @@ -0,0 +1,35 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' + +/** Only confirmed absence permits startup; daemon endpoint arbitration protects racing launches. */ +export const WSL_DAEMON_START_SCRIPT = String.raw` +const net=require('node:net'); +const plan=JSON.parse(process.argv[1]); +if (String(process.getuid()) !== plan.userId || process.env.HOME !== plan.home) throw Error('WSL daemon execution owner changed'); +function contact() { + return new Promise((resolve,reject)=>{ + const socket=net.createConnection(plan.socket); + socket.setTimeout(2000); + socket.once('connect',()=>{socket.destroy();resolve('live');}); + socket.once('timeout',()=>{socket.destroy();reject(Error('Guest owner contact is unverifiable'));}); + socket.once('error',error=>{ + if (['ENOENT','ECONNREFUSED'].includes(error.code)) resolve('absent'); + else reject(Error('Guest owner contact is unverifiable: '+error.code)); + }); + }); +} +(async()=>{ + if(await contact()==='live'){console.log('existing');return;} + const env={...process.env,HOME:plan.home,PATH:plan.path,ORCA_BACKGROUND_LAUNCH:'1'}; + for(const key of ['NODE_OPTIONS','NODE_PATH','BUN_OPTIONS','BUN_INSPECT','ELECTRON_RUN_AS_NODE']) delete env[key]; + const child=Bun.spawn([plan.runtime,...${JSON.stringify(bunOwnedRuntimeArgs('linux'))},plan.entry,'--socket',plan.socket,'--token',plan.tokenPath],{ + detached:true,stdin:'ignore',stdout:'ignore',stderr:'ignore',cwd:plan.home,env + }); + child.unref(); + const deadline=Date.now()+10000; + while(Date.now()setTimeout(resolve,50)); + } + throw Error('Guest daemon did not publish its endpoint'); +})().catch(error=>{console.error(error.message);process.exitCode=1;}); +` diff --git a/src/main/wsl/wsl-daemon-transport.test.ts b/src/main/wsl/wsl-daemon-transport.test.ts new file mode 100644 index 000000000000..6fb71a306dc6 --- /dev/null +++ b/src/main/wsl/wsl-daemon-transport.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { PassThrough } from 'node:stream' +import { createWslDaemonTransport } from './wsl-daemon-transport' +import { runProcess } from '../../shared/child-process/run-process' +import { assertWslRuntimeDistroRunning } from './wsl-bun-runtime' +import { readWslDistributionIdentity } from './wsl-distribution-identity' +import { openWslDaemonConnectorStream } from './wsl-daemon-connector-stream' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('./wsl-bun-runtime', () => ({ assertWslRuntimeDistroRunning: vi.fn() })) +vi.mock('./wsl-distribution-identity', () => ({ readWslDistributionIdentity: vi.fn() })) +vi.mock('./wsl-executable-path', () => ({ + resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' +})) +vi.mock('../wsl-interop-spawn-directory', () => ({ + resolveWslInteropSpawnCwd: () => 'C:\\Windows' +})) +vi.mock('./wsl-daemon-connector-stream', () => ({ openWslDaemonConnectorStream: vi.fn() })) +const endpoint = { + distro: 'Ubuntu', + distributionId: 'registered-owner', + userName: 'alice', + userId: '1001', + home: '/home/alice', + envBinary: '/usr/bin/env', + runtime: '/home/alice/runtime/bun', + socket: '/home/alice/owner/d.sock', + tokenPath: '/home/alice/owner/token' +} +const operation = () => ({ timeoutMs: 5000, signal: new AbortController().signal }) +beforeEach(() => { + vi.resetAllMocks() + vi.mocked(readWslDistributionIdentity).mockResolvedValue(endpoint.distributionId) + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: 'token', + stderr: '', + timedOut: false + }) + vi.mocked(openWslDaemonConnectorStream).mockImplementation(async () => new PassThrough()) +}) + +it('pins both channels and token reads to the captured guest user without shell interpretation', async () => { + const mutable = { ...endpoint } + const transport = createWslDaemonTransport(mutable) + mutable.userName = 'bob' + expect(await transport.readToken(operation())).toBe('token') + const control = await transport.connect('control', operation()) + const stream = await transport.connect('stream', operation()) + const specs = [ + vi.mocked(runProcess).mock.calls[0][0], + ...vi.mocked(openWslDaemonConnectorStream).mock.calls.map(([spec]) => spec) + ] + for (const spec of specs) { + expect(spec.args).toEqual(expect.arrayContaining(['-d', 'Ubuntu', '-u', 'alice', '--exec'])) + expect(spec.args).toEqual( + expect.arrayContaining(['--no-env-file', '--config=/dev/null', '--no-install']) + ) + expect(spec.args).toContain(JSON.stringify(endpoint)) + expect(spec.args).not.toContain('--') + } + expect(assertWslRuntimeDistroRunning).toHaveBeenCalledTimes(3) + control.destroy() + stream.destroy() +}) + +it('refuses a re-registered or stopped distro before spawning a connector or reading a token', async () => { + const transport = createWslDaemonTransport(endpoint) + vi.mocked(readWslDistributionIdentity).mockResolvedValue('replacement') + await expect(transport.connect('control', operation())).rejects.toThrow('unverifiable') + await expect(transport.readToken(operation())).rejects.toThrow('unverifiable') + vi.mocked(assertWslRuntimeDistroRunning).mockRejectedValue(new Error('not running')) + await expect(transport.connect('stream', operation())).rejects.toThrow('not running') + expect(openWslDaemonConnectorStream).not.toHaveBeenCalled() + expect(runProcess).not.toHaveBeenCalled() +}) + +it('does not include arbitrary guest stderr in token failure messages', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: 'private credential content', + timedOut: false + }) + await expect(createWslDaemonTransport(endpoint).readToken(operation())).rejects.toThrow( + 'authentication token is unavailable' + ) +}) + +it('refuses malformed guest identity before any execution', () => { + expect(() => createWslDaemonTransport({ ...endpoint, tokenPath: 'C:\\host-token' })).toThrow( + 'absolute guest paths' + ) + expect(() => createWslDaemonTransport({ ...endpoint, userId: '' })).toThrow( + 'captured execution owner' + ) + expect(runProcess).not.toHaveBeenCalled() +}) diff --git a/src/main/wsl/wsl-daemon-transport.ts b/src/main/wsl/wsl-daemon-transport.ts new file mode 100644 index 000000000000..459ba1fe55f3 --- /dev/null +++ b/src/main/wsl/wsl-daemon-transport.ts @@ -0,0 +1,111 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' +import type { + DaemonClientTransport, + DaemonTransportOperation +} from '../daemon/daemon-client-transport' +import { runProcess, type ProcessSpec } from '../../shared/child-process/run-process' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + assertWslAccountExecutionTarget, + type WslAccountExecutionContext +} from './wsl-account-execution-context' +import { assertWslRuntimeDistroRunning } from './wsl-bun-runtime' +import { readWslDistributionIdentity } from './wsl-distribution-identity' +import { resolveWslExecutablePath } from './wsl-executable-path' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' +import { openWslDaemonConnectorStream } from './wsl-daemon-connector-stream' +import { + WSL_DAEMON_CONNECTOR_SCRIPT, + WSL_DAEMON_READ_TOKEN_SCRIPT +} from './wsl-daemon-connector-script' + +export type WslDaemonEndpoint = WslAccountExecutionContext & + Readonly<{ + distributionId: string + envBinary: string + runtime: string + socket: string + tokenPath: string + }> + +/** Guest paths and process identities never pass through the Windows filesystem/process probes. */ +export function createWslDaemonTransport(value: WslDaemonEndpoint): DaemonClientTransport { + const endpoint = Object.freeze({ ...value }) + assertWslAccountExecutionTarget(endpoint, { runtime: 'wsl', wslDistro: endpoint.distro }) + if (!endpoint.distributionId || /[\0\r\n]/.test(endpoint.distro)) { + throw new Error('Invalid WSL daemon identity') + } + for (const path of [ + endpoint.home, + endpoint.envBinary, + endpoint.runtime, + endpoint.socket, + endpoint.tokenPath + ]) { + if (!path.startsWith('/') || /[\0\r\n]/.test(path)) { + throw new Error('WSL daemon requires absolute guest paths') + } + } + const verifyOwner = async ({ signal }: DaemonTransportOperation) => { + await assertWslRuntimeDistroRunning(endpoint.distro, signal) + const registration = await waitForPromiseWithSignal( + readWslDistributionIdentity(endpoint.distro), + signal + ) + if (registration !== endpoint.distributionId) { + throw new Error('WSL distribution was replaced; the terminal owner is unverifiable') + } + signal.throwIfAborted() + } + const command = (script: string): ProcessSpec => ({ + program: resolveWslExecutablePath(), + args: buildWslExecArgs( + endpoint.distro, + [ + endpoint.envBinary, + ...[ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'ELECTRON_RUN_AS_NODE' + ].flatMap((key) => ['-u', key]), + endpoint.runtime, + ...bunOwnedRuntimeArgs('linux'), + '-e', + script, + JSON.stringify(endpoint) + ], + endpoint.userName + ), + cwd: resolveWslInteropSpawnCwd() + }) + return { + async readToken(operation) { + await verifyOwner(operation) + const result = await runProcess({ + ...command(WSL_DAEMON_READ_TOKEN_SCRIPT), + signal: operation.signal, + timeoutMs: operation.timeoutMs, + maxOutputBytes: 4096 + }) + operation.signal.throwIfAborted() + const token = result.stdout.trim() + if ( + result.code !== 0 || + result.timedOut || + result.outputTruncated || + !token || + /[\0\r\n]/.test(token) + ) { + throw new Error('WSL daemon authentication token is unavailable') + } + return token + }, + async connect(_role, operation) { + await verifyOwner(operation) + return openWslDaemonConnectorStream(command(WSL_DAEMON_CONNECTOR_SCRIPT), operation.signal) + } + } +} diff --git a/src/main/wsl/wsl-distribution-identity.test.ts b/src/main/wsl/wsl-distribution-identity.test.ts new file mode 100644 index 000000000000..dffe339c5f64 --- /dev/null +++ b/src/main/wsl/wsl-distribution-identity.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import { parseWslDistributionIdentity } from './wsl-distribution-identity' +const key = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss' +const guid = '{11111111-2222-3333-4444-555555555555}' +it('reads exact distro registration and refuses ambiguous or absent registrations', () => { + const row = `${key}\\${guid}\r\n DistributionName REG_SZ Ubuntu-24.04\r\n` + expect(parseWslDistributionIdentity(row, 'Ubuntu-24.04')).toBe(guid) + expect(parseWslDistributionIdentity(row, 'ubuntu-24.04')).toBe(guid) + expect(parseWslDistributionIdentity(row, 'Ubuntu')).toBeNull() + expect( + parseWslDistributionIdentity( + row + row.replace(guid, '{aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}'), + 'Ubuntu-24.04' + ) + ).toBeNull() +}) +it('does not accept a same-named registry value outside the distribution key', () => { + expect( + parseWslDistributionIdentity( + `HKEY_CURRENT_USER\\Elsewhere\\${guid}\n DistributionName REG_SZ Ubuntu`, + 'Ubuntu' + ) + ).toBeNull() +}) diff --git a/src/main/wsl/wsl-distribution-identity.ts b/src/main/wsl/wsl-distribution-identity.ts new file mode 100644 index 000000000000..a1aa33f0abf1 --- /dev/null +++ b/src/main/wsl/wsl-distribution-identity.ts @@ -0,0 +1,47 @@ +import { join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' + +const DISTRIBUTIONS_KEY = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss' + +/** The registration GUID changes when a distro is removed and recreated under the same name. */ +export function parseWslDistributionIdentity(output: string, distro: string): string | null { + let identity: string | null = null + const matches = new Set() + for (const line of output.split(/\r?\n/)) { + const key = line.trim() + if (key.startsWith('HKEY_')) { + const suffix = key.slice(DISTRIBUTIONS_KEY.length) + identity = + key.toLowerCase().startsWith(DISTRIBUTIONS_KEY.toLowerCase()) && + /^\\\{[0-9a-f-]{36}\}$/i.test(suffix) + ? suffix.slice(1).toLowerCase() + : null + continue + } + const value = /^\s*DistributionName\s+REG_SZ\s+(.+?)\s*$/.exec(line) + if (identity && value?.[1]?.toLowerCase() === distro.toLowerCase()) { + matches.add(identity) + } + } + return matches.size === 1 ? [...matches][0] : null +} + +export async function readWslDistributionIdentity(distro: string): Promise { + if (process.platform !== 'win32' || !process.env.SystemRoot) { + throw new Error('WSL distribution identity is unavailable on this host') + } + const result = await runProcess({ + program: join(process.env.SystemRoot, 'System32', 'reg.exe'), + args: ['query', DISTRIBUTIONS_KEY, '/s', '/v', 'DistributionName'], + timeoutMs: 5000, + maxOutputBytes: 256 * 1024 + }) + const identity = + result.code === 0 && !result.timedOut + ? parseWslDistributionIdentity(result.stdout, distro) + : null + if (!identity) { + throw new Error('WSL distribution registration could not be verified') + } + return identity +} diff --git a/src/main/wsl/wsl-guest-artifact-install-script.ts b/src/main/wsl/wsl-guest-artifact-install-script.ts new file mode 100644 index 000000000000..2845c8d0ffc4 --- /dev/null +++ b/src/main/wsl/wsl-guest-artifact-install-script.ts @@ -0,0 +1,57 @@ +/** Runs under the already verified guest runtime; argv carries data without shell interpolation. */ +export const WSL_GUEST_ARTIFACT_INSTALL_SCRIPT = String.raw` +const fs = require('node:fs'); +const path = require('node:path'); +const {createHash, randomUUID} = require('node:crypto'); +const plan = JSON.parse(process.argv[1]); +if (plan.userId !== undefined && (String(process.getuid()) !== plan.userId || process.env.HOME !== plan.home)) throw Error('WSL artifact execution owner changed'); +function privateDirectory(directory) { + if (!path.isAbsolute(directory) || path.resolve(directory)!==directory) throw Error('Invalid guest terminal directory'); + let ancestor=path.parse(directory).root; + for(const segment of directory.slice(ancestor.length).split(path.sep).filter(Boolean)) { + ancestor=path.join(ancestor,segment); + try {fs.mkdirSync(ancestor,{mode:0o700});} catch(error) {if(error.code!=='EEXIST')throw error;} + const checked=fs.lstatSync(ancestor); + if(!checked.isDirectory() || checked.isSymbolicLink() || ![0,process.getuid()].includes(checked.uid)) throw Error('Unowned guest terminal ancestor'); + if((checked.mode & 0o022) && !(checked.uid===0 && (checked.mode & 0o1000))) throw Error('Writable guest terminal ancestor'); + } + const stat=fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid()) throw Error('Unowned guest terminal directory'); + fs.chmodSync(directory,0o700); +} +function matches(directory) { + try { + const directoryStat=fs.lstatSync(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || directoryStat.uid!==process.getuid()) return false; + return plan.files.every(file=> { + const entry=path.join(directory,file.name); + const stat=fs.lstatSync(entry); + return stat.isFile() && !stat.isSymbolicLink() && (!file.executable || (stat.mode & 0o100)!==0) && createHash('sha256').update(fs.readFileSync(entry)).digest('hex')===file.sha256; + }); + } catch { return false; } +} +privateDirectory(plan.artifactRoot); +privateDirectory(plan.ownerDirectory); +if (fs.existsSync(plan.directory)) { + if (!matches(plan.directory)) throw Error('Existing guest terminal artifacts changed; refusing to replace a possible live owner'); +} else { + const stage=path.join(plan.artifactRoot,'.stage-'+randomUUID()); + privateDirectory(stage); + try { + for (const file of plan.files) { + const destination=path.join(stage,file.name); + fs.mkdirSync(path.dirname(destination),{recursive:true,mode:0o700}); + fs.copyFileSync(file.source || path.join(plan.source,file.sourceName || file.name),destination,fs.constants.COPYFILE_EXCL); + fs.chmodSync(destination,file.executable ? 0o700 : 0o600); + const fd=fs.openSync(destination,'r'); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + } + if (!matches(stage)) throw Error('Guest terminal artifact checksum mismatch'); + try { fs.renameSync(stage,plan.directory); } + catch(error) { if (!['EEXIST','ENOTEMPTY'].includes(error.code) || !matches(plan.directory)) throw error; } + const fd=fs.openSync(plan.artifactRoot,'r'); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + } finally { fs.rmSync(stage,{recursive:true,force:true}); } +} +console.log('ready'); +` diff --git a/src/main/wsl/wsl-guest-environment.ts b/src/main/wsl/wsl-guest-environment.ts index 4edfb862236e..15c6af95eb82 100644 --- a/src/main/wsl/wsl-guest-environment.ts +++ b/src/main/wsl/wsl-guest-environment.ts @@ -74,7 +74,8 @@ function parseProbePayload(payload: string | null): WslGuestEnvironment | null { async function probeGuestEnvironment( distro: string | undefined, - budgetMs: number + budgetMs: number, + user?: string ): Promise { // Resolve `env` rather than assume /usr/bin/env: a distro that moved it would // otherwise fail every later call. @@ -86,7 +87,7 @@ async function probeGuestEnvironment( const captured = buildWslCapturedLoginShellCommand(script) const result = await runProcess({ program: resolveWslExecutablePath(), - args: buildWslExecArgs(distro, ['sh', '-c', captured.command]), + args: buildWslExecArgs(distro, ['sh', '-c', captured.command], user), // The runner sets this for every command it launches (#9010); the probe // spawns wsl.exe itself, so without it wsl.exe's own errors arrive UTF-16LE // and the NUL-separated payload below is read through NUL-riddled text. @@ -110,8 +111,8 @@ async function probeGuestEnvironment( return environment ? { kind: 'resolved', environment } : { kind: 'transient' } } -function cacheKey(distro: string | undefined): string { - return distro ?? '' +function cacheKey(distro: string | undefined, user?: string): string { + return `${distro ?? ''}${user ? `\0${user}` : ''}` } /** Null means "could not ask", never "has no PATH" -- callers fall back. */ @@ -122,9 +123,10 @@ export function getWslGuestEnvironment( * timer, so a 5s caller could reach `runProcess` with 1ms left and report a * timeout for a command that would have taken milliseconds. */ - budgetMs = PROBE_TIMEOUT_MS + budgetMs = PROBE_TIMEOUT_MS, + user?: string ): Promise { - const key = cacheKey(distro) + const key = cacheKey(distro, user) const cached = resolved.get(key) if (cached) { return Promise.resolve(cached) @@ -172,7 +174,7 @@ export function getWslGuestEnvironment( // host without System32\wsl.exe, EAGAIN under memory pressure). Uncaught, the // rejected promise stays in `inFlight` and every later call re-throws it for // the process lifetime -- all WSL features wedged until restart. - const probe = probeGuestEnvironment(distro, budgetMs) + const probe = probeGuestEnvironment(distro, budgetMs, user) .catch((): ProbeOutcome => ({ kind: 'transient' })) .then((outcome) => { if (inFlight.get(key) !== probe) { @@ -213,11 +215,14 @@ export function invalidateWslGuestEnvironment(distro?: string, all = false): voi probedWithBudget.clear() return } - const key = cacheKey(distro) - inFlight.delete(key) - resolved.delete(key) - retryAfter.delete(key) - probedWithBudget.delete(key) + const base = cacheKey(distro) + for (const cache of [inFlight, resolved, retryAfter, probedWithBudget]) { + for (const key of cache.keys()) { + if (key === base || key.startsWith(`${base}\0`)) { + cache.delete(key) + } + } + } } /** Test-only: the cached value without probing. */ diff --git a/src/main/wsl/wsl-guest-owner-preparation.ts b/src/main/wsl/wsl-guest-owner-preparation.ts new file mode 100644 index 000000000000..218787010084 --- /dev/null +++ b/src/main/wsl/wsl-guest-owner-preparation.ts @@ -0,0 +1,48 @@ +import { readWslDistributionIdentity } from './wsl-distribution-identity' +import { parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' +import { + assertWslRuntimeDistroRunning, + createRunningWslRuntimeRunner, + ensureWslBunRuntime +} from './wsl-bun-runtime' +import { getWslGuestEnvironment, invalidateWslGuestEnvironment } from './wsl-guest-environment' + +/** Capture the default user once; every later probe and install runs as that owner. */ +export async function prepareWslGuestOwner(distro: string, signal?: AbortSignal) { + const distributionId = await readWslDistributionIdentity(distro) + const initial = createRunningWslRuntimeRunner(distro, signal) + const userName = await initial.run({ program: 'id', args: ['-un'], loginPath: 'none' }) + if (!userName || /[\0\r\n]/.test(userName)) { + throw new Error('Invalid WSL guest user') + } + const execution = createRunningWslRuntimeRunner(distro, signal, userName) + const architecture = await execution.run({ program: 'uname', args: ['-m'], loginPath: 'none' }) + if (!['x86_64', 'aarch64', 'arm64'].includes(architecture)) { + throw new Error(`Unsupported WSL architecture: ${architecture}`) + } + const platform: 'linux-x64' | 'linux-arm64' = + architecture === 'x86_64' ? 'linux-x64' : 'linux-arm64' + const libc = parseOrcadLinuxLibc( + await execution.run({ + script: + 'getconf GNU_LIBC_VERSION 2>/dev/null || ldd --version 2>&1 || for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done', + loginPath: 'none' + }) + ) + const runtime = await ensureWslBunRuntime(execution) + await assertWslRuntimeDistroRunning(distro, execution.signal) + invalidateWslGuestEnvironment(distro) + const environment = await getWslGuestEnvironment(distro, undefined, userName) + execution.signal.throwIfAborted() + if (!environment) { + throw new Error('WSL login environment is unavailable') + } + const userId = await execution.run({ program: 'id', args: ['-u'], loginPath: 'none' }) + if (!/^\d+$/.test(userId)) { + throw new Error('WSL guest user could not be verified') + } + if ((await readWslDistributionIdentity(distro)) !== distributionId) { + throw new Error('WSL distribution was replaced during owner preparation') + } + return { distributionId, userName, userId, platform, libc, runtime, environment, execution } +} diff --git a/src/main/wsl/wsl-guest-spawn-options.test.ts b/src/main/wsl/wsl-guest-spawn-options.test.ts new file mode 100644 index 000000000000..f89b915485f3 --- /dev/null +++ b/src/main/wsl/wsl-guest-spawn-options.test.ts @@ -0,0 +1,275 @@ +import { runInNewContext } from 'node:vm' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prepareWslGuestSpawnOptions as prepareOptions } from './wsl-guest-spawn-options' +import { runWslProcess } from './wsl-runner' +import { assertWslRuntimeDistroRunning, createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import { toAppWslPtyId } from '../../shared/wsl-pty-id' +import type { PtySpawnOptions } from '../providers/types' +function prepareWslGuestSpawnOptions( + prepared: Parameters[0], + options: Partial +) { + return prepareOptions(prepared, { cols: 80, rows: 24, ...options }) +} + +vi.mock('./wsl-runner', () => ({ runWslProcess: vi.fn() })) +vi.mock('./wsl-bun-runtime', () => ({ + assertWslRuntimeDistroRunning: vi.fn(), + createRunningWslRuntimeRunner: vi.fn() +})) +vi.mock('../pty/wsl-orca-env', () => ({ addOrcaWslInteropEnv: vi.fn() })) +const prepared = { + endpoint: { + distro: 'Ubuntu', + userName: 'user', + userId: '1000', + runtime: '/runtime/bun', + entry: '/relay/relay.js', + socket: '/socket', + credentialFile: '/credential' + }, + owner: { distro: 'Ubuntu', relayBuildId: 'build+hash' }, + home: '/home/user', + path: '/usr/bin', + envBinary: '/usr/bin/env', + ripgrepPath: '/relay/rg' +} +const run = vi.fn() +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(createRunningWslRuntimeRunner).mockReturnValue({ + run, + signal: new AbortController().signal + }) + run.mockResolvedValue('/mnt/c/work') + vi.mocked(runWslProcess).mockResolvedValue({ + code: 0, + stdout: JSON.stringify({ + PATH: '/usr/bin', + HOME: '/home/user', + SHELL: '/bin/bash', + ORCA_TOKEN: 'token' + }), + stderr: '', + timedOut: false, + environmentResolved: true + }) +}) + +describe('guest terminal spawn preparation', () => { + it('fences existing identities without probing or spawning', async () => { + const sessionId = toAppWslPtyId(prepared.owner, 'pty2:owner:1') + expect(await prepareWslGuestSpawnOptions(prepared, { sessionId })).toEqual({ + cols: 80, + rows: 24, + sessionId, + isNewSession: false + }) + await expect( + prepareWslGuestSpawnOptions(prepared, { sessionId: 'pty2:windows:1' }) + ).rejects.toThrow('WSL-owned') + await expect( + prepareWslGuestSpawnOptions(prepared, { + sessionId: toAppWslPtyId({ ...prepared.owner, distro: 'Debian' }, 'pty2:owner:1') + }) + ).rejects.toThrow('different distro') + expect(runWslProcess).not.toHaveBeenCalled() + }) + it('translates host cwd and replaces inherited host shell environment', async () => { + const result = await prepareWslGuestSpawnOptions(prepared, { + sessionId: 'desktop-new', + isNewSession: true, + cwd: 'C:\\work', + env: { + PATH: 'C:\\Windows', + HOME: 'C:\\Users\\user', + NODE_OPTIONS: '--require bad', + ORCA_TOKEN: 'token' + }, + shellOverride: 'wsl.exe' + }) + expect(result).toMatchObject({ + cwd: '/mnt/c/work', + env: { PATH: '/usr/bin', HOME: '/home/user', ORCA_TOKEN: 'token' } + }) + expect(result.sessionId).toBeUndefined() + expect(result.shellOverride).toBeUndefined() + expect(run).toHaveBeenCalledWith({ + program: 'wslpath', + args: ['-a', '-u', 'C:\\work'], + loginPath: 'none' + }) + const spec = vi.mocked(runWslProcess).mock.calls[0][0] + expect(spec.env).not.toHaveProperty('PATH') + expect(spec.env).not.toHaveProperty('NODE_OPTIONS') + expect(spec.args).toEqual( + expect.arrayContaining(['--no-env-file', '--config=/dev/null', '--no-install']) + ) + expect(spec.args).toContain('-u') + expect(assertWslRuntimeDistroRunning).toHaveBeenCalled() + }) + it('rejects foreign UNC cwd, reminted guest IDs and fresh attach-only requests', async () => { + await expect( + prepareWslGuestSpawnOptions(prepared, { cwd: '\\\\wsl.localhost\\Debian\\home' }) + ).rejects.toThrow('different WSL distro') + await expect( + prepareWslGuestSpawnOptions(prepared, { + sessionId: toAppWslPtyId(prepared.owner, 'pty2:owner:1'), + isNewSession: true + }) + ).rejects.toThrow('reminted') + await expect(prepareWslGuestSpawnOptions(prepared, { attachOnly: true })).rejects.toThrow( + 'cannot create' + ) + }) + it('applies deletions and refuses unavailable guest environment', async () => { + expect( + (await prepareWslGuestSpawnOptions(prepared, { envToDelete: ['ORCA_TOKEN'] })).env + ).not.toHaveProperty('ORCA_TOKEN') + vi.mocked(runWslProcess).mockResolvedValue({ + code: 0, + stdout: '{}', + stderr: '', + timedOut: false, + environmentResolved: false + }) + await expect(prepareWslGuestSpawnOptions(prepared, {})).rejects.toThrow('could not be resolved') + }) +}) + +it('refuses a changed login HOME before returning prepared options', async () => { + vi.mocked(runWslProcess).mockResolvedValue({ + code: 0, + stdout: JSON.stringify({ HOME: '/home/other' }), + stderr: '', + timedOut: false, + environmentResolved: true + }) + await expect(prepareWslGuestSpawnOptions(prepared, {})).rejects.toThrow('captured owner') +}) + +it('checks guest UID and HOME before the environment probe emits credentials', async () => { + await prepareWslGuestSpawnOptions(prepared, {}) + const args = vi.mocked(runWslProcess).mock.calls[0][0].args ?? [] + const scriptIndex = args.indexOf('-e') + 1 + const log = vi.fn() + for (const [uid, home] of [ + [1001, prepared.home], + [1000, '/home/other'] + ] as const) { + expect(() => + runInNewContext(args[scriptIndex], { + process: { + argv: ['bun', ...args.slice(scriptIndex + 1)], + getuid: () => uid, + env: { HOME: home, ORCA_TOKEN: 'private' } + }, + console: { log } + }) + ).toThrow('execution owner changed') + } + expect(log).not.toHaveBeenCalled() + runInNewContext(args[scriptIndex], { + process: { + argv: ['bun', ...args.slice(scriptIndex + 1)], + getuid: () => 1000, + env: { HOME: prepared.home } + }, + console: { log } + }) + expect(log).toHaveBeenCalledOnce() +}) + +const daemon = { + owner: prepared.owner, + endpoint: { + distro: 'Ubuntu', + distributionId: 'registration', + userName: 'user', + userId: '1000', + home: '/home/user', + runtime: '/runtime/bun', + entry: '/daemon/daemon.js', + envBinary: '/usr/bin/env', + socket: '/socket', + tokenPath: '/token', + serverBuildId: 'daemon-build' + } +} + +it('preserves a fresh stable daemon identity while legacy relay spawn still mints its own', async () => { + const options = { + sessionId: 'fresh-desktop', + isNewSession: true, + cwd: '/work', + env: { ORCA_PANE_KEY: 'pane' } + } + const guest = await prepareWslGuestSpawnOptions(daemon, options) + expect(guest.sessionId).toBe('fresh-desktop') + expect(guest.isNewSession).toBe(true) + expect(guest.shellOverride).toBe('/bin/bash') + expect(vi.mocked(runWslProcess).mock.calls[0][0]).toMatchObject({ + distro: 'Ubuntu', + user: 'user', + program: '/usr/bin/env' + }) + const relay = await prepareWslGuestSpawnOptions(prepared, options) + expect(relay.sessionId).toBeUndefined() + expect(relay.isNewSession).toBeUndefined() +}) + +it('keeps daemon attaches bound to the encoded owner without auth/environment preparation', async () => { + const id = toAppWslPtyId(daemon.owner, 'stable-session') + expect( + await prepareWslGuestSpawnOptions(daemon, { + sessionId: id, + isNewSession: false, + attachOnly: true + }) + ).toMatchObject({ sessionId: id, isNewSession: false, attachOnly: true }) + await expect( + prepareWslGuestSpawnOptions(daemon, { + sessionId: toAppWslPtyId({ ...daemon.owner, relayBuildId: 'other-user' }, 'stable-session'), + isNewSession: false + }) + ).rejects.toThrow('different distro or relay build') + expect(runWslProcess).not.toHaveBeenCalled() +}) + +it('injects guest-owned history after login environment resolution for a fresh daemon spawn', async () => { + run.mockResolvedValue('ready') + const options = await prepareWslGuestSpawnOptions(daemon, { + cwd: '/work', + sessionId: 'fresh', + isNewSession: true, + worktreeId: 'folder', + historyIsolationEnabled: true, + env: { ORCA_USER_DATA_PATH: 'C:\\Orca\\profile' } + }) + expect(options.env?.HISTFILE).toMatch( + /^\/home\/user\/.orca-wsl\/[a-f0-9]+\/terminal-history\/[a-f0-9]+\/bash_history$/ + ) + expect(options.env?.ORCA_HISTFILE).toBe(options.env?.HISTFILE) + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ program: '/usr/bin/env', loginPath: 'none' }) + ) + expect(options.sessionId).toBe('fresh') +}) + +it('accepts UNC paths with registered casing for a canonical fresh owner', async () => { + const owner = { + ...prepared, + owner: { ...prepared.owner, distro: 'ubuntu' }, + endpoint: { ...prepared.endpoint, distro: 'ubuntu' } + } + const result = await prepareWslGuestSpawnOptions(owner, { + cwd: String.raw`\\wsl$\Ubuntu\home\user`, + env: { CODEX_HOME: String.raw`\\wsl$\Ubuntu\home\user\.codex` } + }) + expect(result.cwd).toBe('/home/user') + expect(runWslProcess).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ CODEX_HOME: '/home/user/.codex' }) + }) + ) +}) diff --git a/src/main/wsl/wsl-guest-spawn-options.ts b/src/main/wsl/wsl-guest-spawn-options.ts new file mode 100644 index 000000000000..f091a8d057a4 --- /dev/null +++ b/src/main/wsl/wsl-guest-spawn-options.ts @@ -0,0 +1,204 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' +import { prepareWslGuestTerminalHistory } from './wsl-guest-terminal-history' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { isWslShellName } from '../../shared/local-windows-terminal-runtime' +import { parseAppWslPtyId, toRelayWslPtyId } from '../../shared/wsl-pty-id' +import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { isHostCodexHomeForWsl } from '../pty/codex-home-wsl-env' +import type { PtySpawnOptions } from '../providers/types' +import { createRunningWslRuntimeRunner, assertWslRuntimeDistroRunning } from './wsl-bun-runtime' +import { runWslProcess } from './wsl-runner' +import type { WslPtyOwner } from '../../shared/wsl-pty-id' + +export type PreparedWslGuestSpawnOwner = Readonly<{ + owner: Readonly + endpoint: Readonly<{ + distro: string + userName?: string + userId?: string + runtime: string + home?: string + envBinary?: string + tokenPath?: string + }> + home?: string + envBinary?: string +}> + +export function capturedWslGuestSpawnOwner(prepared: PreparedWslGuestSpawnOwner) { + const daemon = prepared.endpoint.tokenPath !== undefined + const home = prepared.endpoint.home ?? prepared.home ?? '' + const envBinary = prepared.endpoint.envBinary ?? prepared.envBinary ?? '' + if ( + prepared.endpoint.distro !== prepared.owner.distro || + !home.startsWith('/') || + !envBinary.startsWith('/') + ) { + throw new Error('Guest spawn requires captured distro, home and environment executable') + } + return { + owner: Object.freeze({ ...prepared.owner }), + endpoint: Object.freeze({ ...prepared.endpoint }), + home, + envBinary, + daemon + } +} +import { assertWslAccountExecutionTarget } from './wsl-account-execution-context' + +const READ_ENVIRONMENT = + 'const owner=JSON.parse(process.argv[2]);if(String(process.getuid())!==owner.userId||process.env.HOME!==owner.home)throw new Error("WSL terminal execution owner changed");const keys=JSON.parse(process.argv[1]);console.log(JSON.stringify(Object.fromEntries(keys.filter(k=>typeof process.env[k]==="string").map(k=>[k,process.env[k]]))))' +const RUNTIME_ENV_KEYS = [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'ELECTRON_RUN_AS_NODE' +] + +function parseEnvironment(value: string): Record { + const parsed: unknown = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Invalid guest terminal environment') + } + const result: Record = {} + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry !== 'string') { + throw new Error('Invalid guest terminal environment value') + } + result[key] = entry + } + return result +} + +/** Let WSL apply its own WSLENV/path rules before sending a guest-native spawn request. */ +export async function prepareWslGuestSpawnOptions( + captured: PreparedWslGuestSpawnOwner, + options: PtySpawnOptions, + signal?: AbortSignal +): Promise { + const prepared = capturedWslGuestSpawnOwner(captured) + const { owner } = prepared + const execution = createRunningWslRuntimeRunner(owner.distro, signal, prepared.endpoint.userName) + if (options.sessionId && !options.isNewSession) { + toRelayWslPtyId(owner, options.sessionId) + return { ...options, isNewSession: false } + } + if (options.attachOnly) { + throw new Error('A WSL attach cannot create a new terminal') + } + if (options.sessionId && parseAppWslPtyId(options.sessionId)) { + throw new Error('A WSL terminal identity cannot be reminted as a fresh spawn') + } + const accountExecution = { + distro: owner.distro, + userName: prepared.endpoint.userName ?? '', + userId: prepared.endpoint.userId ?? '', + home: prepared.home + } + assertWslAccountExecutionTarget(accountExecution, { runtime: 'wsl', wslDistro: owner.distro }) + let cwd = options.cwd ?? prepared.home + const unc = parseWslUncPath(cwd) + if (unc) { + if (unc.distro.toLowerCase() !== owner.distro.toLowerCase()) { + throw new Error('Terminal cwd belongs to a different WSL distro') + } + cwd = unc.linuxPath + } else if (!cwd.startsWith('/')) { + cwd = await execution.run({ program: 'wslpath', args: ['-a', '-u', cwd], loginPath: 'none' }) + } + if (!cwd.startsWith('/')) { + throw new Error('Terminal cwd did not resolve to a guest path') + } + const env = { ...options.env } + addOrcaWslInteropEnv(env) + for (const key of ['PATH', 'Path', 'HOME', 'SHELL', ...RUNTIME_ENV_KEYS]) { + delete env[key] + } + if (env.CODEX_HOME) { + const codex = parseWslUncPath(env.CODEX_HOME) + if (codex?.distro.toLowerCase() === owner.distro.toLowerCase()) { + env.CODEX_HOME = codex.linuxPath + env.ORCA_CODEX_HOME = codex.linuxPath + } else if (codex || isHostCodexHomeForWsl(env.CODEX_HOME)) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } + } + const keys = [ + ...new Set([ + ...Object.keys(env), + 'PATH', + 'HOME', + 'SHELL', + 'HISTFILE', + 'fish_history', + 'XDG_DATA_HOME' + ]) + ].filter((key) => key !== 'WSLENV') + await assertWslRuntimeDistroRunning(owner.distro, execution.signal) + const result = await runWslProcess({ + distro: owner.distro, + user: prepared.endpoint.userName, + program: prepared.envBinary, + args: [ + ...RUNTIME_ENV_KEYS.flatMap((key) => ['-u', key]), + prepared.endpoint.runtime, + ...bunOwnedRuntimeArgs('linux'), + '-e', + READ_ENVIRONMENT, + JSON.stringify(keys), + JSON.stringify(accountExecution) + ], + env, + loginPath: 'preferred', + timeoutMs: 15_000, + maxOutputBytes: 256 * 1024 + }) + execution.signal.throwIfAborted() + if (result.code !== 0 || result.timedOut || !result.environmentResolved) { + throw new Error('WSL terminal environment could not be resolved') + } + const guestEnv = parseEnvironment(result.stdout) + if (guestEnv.HOME !== prepared.home) { + throw new Error('WSL terminal environment does not match its captured owner') + } + const guestShell = + options.shellOverride && !isWslShellName(options.shellOverride) + ? options.shellOverride + : guestEnv.SHELL || '/bin/bash' + if (prepared.daemon) { + await prepareWslGuestTerminalHistory( + execution, + accountExecution, + prepared.endpoint.runtime, + prepared.envBinary, + options, + guestEnv, + guestShell + ) + } + for (const key of options.envToDelete ?? []) { + delete guestEnv[key] + } + const { + sessionId: _freshDesktopId, + isNewSession: _fresh, + terminalWindowsWslDistro: _distro, + terminalWindowsPowerShellImplementation: _powershell, + codexHomePathOverride: _codex, + ...guestOptions + } = options + return { + ...guestOptions, + ...(prepared.daemon ? { sessionId: _freshDesktopId, isNewSession: _fresh } : {}), + cwd, + env: guestEnv, + shellOverride: + options.shellOverride && !isWslShellName(options.shellOverride) + ? options.shellOverride + : prepared.daemon + ? guestShell + : undefined + } +} diff --git a/src/main/wsl/wsl-guest-terminal-history-script.ts b/src/main/wsl/wsl-guest-terminal-history-script.ts new file mode 100644 index 000000000000..6d5f0049c16f --- /dev/null +++ b/src/main/wsl/wsl-guest-terminal-history-script.ts @@ -0,0 +1,36 @@ +/** Runs only under the captured guest user, before publishing the history environment. */ +export const WSL_GUEST_HISTORY_PREPARE_SCRIPT = String.raw` +const fs = require('node:fs'), path = require('node:path'); +const p = JSON.parse(process.argv[1]); +if (String(process.getuid()) !== p.owner.userId || process.env.HOME !== p.owner.home) + throw new Error('WSL history owner changed'); +const relative = path.relative(p.owner.home, p.directory); +if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) + throw new Error('WSL history path escaped its owner'); +let directory = p.owner.home; +for (const part of relative.split(path.sep)) { + directory = path.join(directory, part); + try { fs.mkdirSync(directory, { mode: 0o700 }); } + catch (error) { if (error.code !== 'EEXIST') throw error; } + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid()) + throw new Error('WSL history directory is not owned by its captured user'); +} +const meta = path.join(directory, 'meta.json'); +let existing; +try { + const stat = fs.lstatSync(meta); + if (stat.isFile() && stat.size <= 32768) existing = JSON.parse(fs.readFileSync(meta, 'utf8')); +} catch {} +if (!existing || (p.fishSession && (existing.fishSession !== p.fishSession || existing.fishHistoryDir !== p.fishHistoryDir))) { + const temporary = path.join(directory, '.meta-' + process.pid + '-' + require('node:crypto').randomUUID()); + try { + fs.writeFileSync(temporary, JSON.stringify({ worktreeId: p.worktreeId, + createdAt: existing?.createdAt || new Date().toISOString(), + ...(p.fishSession ? { fishSession: p.fishSession, fishHistoryDir: p.fishHistoryDir } : {}) + }), { flag: 'wx', mode: 0o600 }); + fs.renameSync(temporary, meta); + } finally { try { fs.unlinkSync(temporary); } catch {} } +} +console.log('ready'); +` diff --git a/src/main/wsl/wsl-guest-terminal-history.test.ts b/src/main/wsl/wsl-guest-terminal-history.test.ts new file mode 100644 index 000000000000..c4cf1d43bd14 --- /dev/null +++ b/src/main/wsl/wsl-guest-terminal-history.test.ts @@ -0,0 +1,189 @@ +import { + mkdtempSync, + readFileSync, + existsSync, + statSync, + rmSync, + symlinkSync, + mkdirSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, posix } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { hashWorktreeId } from '../terminal-history-id' +import { prepareWslGuestTerminalHistory } from './wsl-guest-terminal-history' +import type { createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import type { PtySpawnOptions } from '../providers/types' + +const directories: string[] = [] +afterEach(() => { + for (const path of directories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) +function fixture() { + const home = mkdtempSync(join(tmpdir(), 'orca-guest-history-')) + directories.push(home) + const owner = { + distro: 'Ubuntu', + userName: 'captured', + userId: String(process.getuid?.() ?? 0), + home + } + const run = vi.fn['run']>(async (spec) => { + const args = spec.args ?? [] + expect(args).toEqual( + expect.arrayContaining(['--no-env-file', '--config=/dev/null', '--no-install']) + ) + const script = args.indexOf('-e') + const result = await runProcess({ + program: process.execPath, + args: args.slice(script), + env: { ...process.env, HOME: home } + }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout.trim() + }) + const options: PtySpawnOptions = { + cols: 80, + rows: 24, + worktreeId: 'folder-workspace', + historyIsolationEnabled: true, + env: { ORCA_USER_DATA_PATH: 'C:\\Orca\\profile-a' } + } + const invoke = ( + env: Record, + shell: string, + override: Partial = {} + ) => + prepareWslGuestTerminalHistory( + { run }, + owner, + '/bun', + '/usr/bin/env', + { ...options, ...override }, + env, + shell + ) + return { home, owner, run, options, invoke } +} + +describe.skipIf(process.platform === 'win32')('guest history scripts on a real filesystem', () => { + it.each(['bash', 'zsh'])( + 'isolates %s history and writes owner-private metadata', + async (shell) => { + const { invoke, home, options } = fixture() + const env: Record = { HOME: home } + await invoke(env, `/bin/${shell}`) + const directory = posix.join( + home, + '.orca-wsl', + hashWorktreeId(options.env!.ORCA_USER_DATA_PATH), + 'terminal-history', + hashWorktreeId('folder-workspace') + ) + expect(env.HISTFILE).toBe(`${directory}/${shell}_history`) + expect(env.ORCA_HISTFILE).toBe(env.HISTFILE) + const meta = JSON.parse(readFileSync(join(directory, 'meta.json'), 'utf8')) + expect(meta.worktreeId).toBe('folder-workspace') + expect(statSync(directory).mode & 0o777).toBe(0o700) + expect(statSync(join(directory, 'meta.json')).mode & 0o777).toBe(0o600) + await invoke({}, `/bin/${shell}`) + expect(JSON.parse(readFileSync(join(directory, 'meta.json'), 'utf8')).createdAt).toBe( + meta.createdAt + ) + } + ) + + it('uses fish session names and its real guest XDG data directory', async () => { + const { invoke, home, options } = fixture() + const env: Record = { HOME: home, XDG_DATA_HOME: `${home}/custom-data` } + await invoke(env, '/usr/bin/fish') + expect(env.fish_history).toMatch(/^orca_relay_[0-9a-f]{16}$/) + expect(env).not.toHaveProperty('HISTFILE') + expect(env.XDG_DATA_HOME).toBe(`${home}/custom-data`) + const metaPath = join( + home, + '.orca-wsl', + hashWorktreeId(options.env!.ORCA_USER_DATA_PATH), + 'terminal-history', + hashWorktreeId('folder-workspace'), + 'meta.json' + ) + expect(JSON.parse(readFileSync(metaPath, 'utf8'))).toMatchObject({ + fishSession: env.fish_history, + fishHistoryDir: `${home}/custom-data/fish` + }) + }) + + it('keeps profiles and worktree/folder IDs separate', async () => { + const { invoke } = fixture() + const a: Record = {}, + b: Record = {}, + c: Record = {} + await invoke(a, '/bin/bash') + await invoke(b, '/bin/bash', { worktreeId: 'git-worktree' }) + await invoke(c, '/bin/bash', { env: { ORCA_USER_DATA_PATH: 'C:\\Orca\\profile-b' } }) + expect(new Set([a.HISTFILE, b.HISTFILE, c.HISTFILE]).size).toBe(3) + }) + + it('checks UID before creating any guest history directory', async () => { + const { run, owner, home, options } = fixture() + await expect( + prepareWslGuestTerminalHistory( + { run }, + { ...owner, userId: String(Number(owner.userId) + 1) }, + '/bun', + '/usr/bin/env', + options, + {}, + '/bin/bash' + ) + ).rejects.toThrow('owner changed') + expect(existsSync(join(home, '.orca-wsl'))).toBe(false) + }) + + it('refuses symlinked history roots instead of writing outside its owner tree', async () => { + const { invoke, home } = fixture() + const outside = join(home, 'outside') + mkdirSync(outside) + symlinkSync(outside, join(home, '.orca-wsl')) + await expect(invoke({}, '/bin/bash')).rejects.toThrow('not owned') + expect(existsSync(join(outside, 'terminal-history'))).toBe(false) + }) +}) + +it('leaves explicit user history untouched and skips disabled/unsupported cases', async () => { + const { invoke, run } = fixture() + const bash = { HISTFILE: '/custom/history', ORCA_HISTFILE: '/old/orca' } + await invoke(bash, '/bin/bash') + expect(bash).toEqual({ HISTFILE: '/custom/history' }) + const fish = { fish_history: 'my-session' } + await invoke(fish, '/bin/fish') + expect(fish).toEqual({ fish_history: 'my-session' }) + const off = { HISTFILE: '/inherited', ORCA_HISTFILE: '/unchanged' } + await invoke(off, '/bin/bash', { historyIsolationEnabled: false }) + expect(off.ORCA_HISTFILE).toBe('/unchanged') + await invoke({}, '/bin/bash', { worktreeId: undefined }) + await invoke({}, '/bin/sh') + expect(run).not.toHaveBeenCalled() +}) + +it.skipIf(process.platform === 'win32')( + 'replaces only inherited Orca-owned history settings', + async () => { + const { invoke } = fixture() + const env = { + HISTFILE: '/old/terminal-history/0123456789abcdef/bash_history', + ORCA_HISTFILE: '/old', + fish_history: 'orca_0123456789abcdef' + } + await invoke(env, '/bin/bash') + expect(env.HISTFILE).not.toContain('/old/') + expect(env.ORCA_HISTFILE).toBe(env.HISTFILE) + expect(env).not.toHaveProperty('fish_history') + } +) diff --git a/src/main/wsl/wsl-guest-terminal-history.ts b/src/main/wsl/wsl-guest-terminal-history.ts new file mode 100644 index 000000000000..fba26c318132 --- /dev/null +++ b/src/main/wsl/wsl-guest-terminal-history.ts @@ -0,0 +1,88 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' +import { posix } from 'node:path' +import { resolveShellKind } from '../terminal-history' +import { hashWorktreeId } from '../terminal-history-id' +import { dropInheritedOrcaFishHistory, relayFishHistorySessionName } from '../fish-history-session' +import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' +import type { PtySpawnOptions } from '../providers/types' +import type { WslAccountExecutionContext } from './wsl-account-execution-context' +import type { createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import { WSL_GUEST_HISTORY_PREPARE_SCRIPT } from './wsl-guest-terminal-history-script' + +/** Plans on the desktop; the captured guest alone creates its history files. */ +export async function prepareWslGuestTerminalHistory( + execution: Pick, 'run'>, + owner: WslAccountExecutionContext, + runtime: string, + envBinary: string, + options: PtySpawnOptions, + env: Record, + shell: string +): Promise { + if (!options.worktreeId || options.historyIsolationEnabled !== true) { + return + } + delete env.ORCA_HISTFILE + dropInheritedOrcaFishHistory(env) + dropInheritedOrcaHistFile(env) + const kind = resolveShellKind(shell) + if (kind !== 'bash' && kind !== 'zsh' && kind !== 'fish') { + return + } + if (kind === 'fish' ? env.fish_history : env.HISTFILE) { + return + } + const profile = options.env?.ORCA_USER_DATA_PATH + if (!profile) { + throw new Error('Guest history isolation requires the captured Orca profile') + } + const worktreeHash = hashWorktreeId(options.worktreeId) + const directory = posix.join( + owner.home, + '.orca-wsl', + hashWorktreeId(profile), + 'terminal-history', + worktreeHash + ) + // Guest desktops must not sweep history owned by an independently updating Windows client. + const fishSession = + kind === 'fish' + ? relayFishHistorySessionName(hashWorktreeId(JSON.stringify([profile, options.worktreeId]))) + : undefined + const fishHistoryDir = fishSession + ? posix.join( + env.XDG_DATA_HOME?.startsWith('/') + ? env.XDG_DATA_HOME + : posix.join(owner.home, '.local/share'), + 'fish' + ) + : undefined + const payload = { owner, directory, worktreeId: options.worktreeId, fishSession, fishHistoryDir } + const result = await execution.run({ + program: envBinary, + args: [ + ...[ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'ELECTRON_RUN_AS_NODE' + ].flatMap((key) => ['-u', key]), + runtime, + ...bunOwnedRuntimeArgs('linux'), + '-e', + WSL_GUEST_HISTORY_PREPARE_SCRIPT, + JSON.stringify(payload) + ], + loginPath: 'none' + }) + if (result !== 'ready') { + throw new Error('Guest terminal history preparation failed') + } + if (fishSession) { + env.fish_history = fishSession + } else { + env.HISTFILE = posix.join(directory, `${kind}_history`) + env.ORCA_HISTFILE = env.HISTFILE + } +} diff --git a/src/main/wsl/wsl-guest-terminal-preparation.test.ts b/src/main/wsl/wsl-guest-terminal-preparation.test.ts new file mode 100644 index 000000000000..c964b64a6943 --- /dev/null +++ b/src/main/wsl/wsl-guest-terminal-preparation.test.ts @@ -0,0 +1,266 @@ +import type * as GuestSpawnOptions from './wsl-guest-spawn-options' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prepareWslGuestTerminalSpawn } from './wsl-guest-terminal-preparation' +import { prepareWslGuestSpawnOptions } from './wsl-guest-spawn-options' +import { buildPtyHostEnv } from '../ipc/pty/host-env/assembly' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { toAppWslPtyId } from '../../shared/wsl-pty-id' + +vi.mock('./wsl-guest-spawn-options', async (importOriginal) => ({ + ...(await importOriginal()), + prepareWslGuestSpawnOptions: vi.fn() +})) +vi.mock('../ipc/pty/host-env/assembly', () => ({ buildPtyHostEnv: vi.fn() })) +vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ + wslHookRelayManager: { ensureForDistro: vi.fn(), getGuestEndpointFilePath: vi.fn() } +})) +const prepared = { + owner: { distro: 'Ubuntu', relayBuildId: 'build' }, + endpoint: { + distro: 'Ubuntu', + userName: 'alice', + runtime: '/bun', + entry: '/relay', + socket: '/socket', + credentialFile: '/credential' + }, + home: '/home/alice', + path: '/usr/bin', + envBinary: '/usr/bin/env', + ripgrepPath: '/rg' +} +const options = { + cols: 80, + rows: 24, + sessionId: 'fresh-desktop', + isNewSession: true, + env: { ORCA_PANE_KEY: 'pane' } +} +const policy = { + isPackaged: true, + userDataPath: 'C:\\Orca', + selectedCodexHomePath: '/home/alice/.codex', + agentStatusHooksEnabled: true +} +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(wslHookRelayManager.ensureForDistro).mockResolvedValue() + vi.mocked(wslHookRelayManager.getGuestEndpointFilePath).mockReturnValue( + '/home/alice/.orca-wsl/agent-hooks/instance-a/endpoint' + ) + vi.mocked(buildPtyHostEnv).mockImplementation((_id, env) => ({ + ...env, + ORCA_WSL_CLI_DIR: 'C:\\cli' + })) + vi.mocked(prepareWslGuestSpawnOptions).mockImplementation(async (_owner, value) => value) +}) +describe('guest terminal environment preparation', () => { + it('awaits the exact owner and reuses host policy before guest translation', async () => { + const result = await prepareWslGuestTerminalSpawn(prepared, options, { + ...policy, + launchAgent: 'omp' + }) + expect(wslHookRelayManager.ensureForDistro).toHaveBeenCalledWith( + 'Ubuntu', + policy.selectedCodexHomePath, + 'omp', + 'alice' + ) + expect(buildPtyHostEnv).toHaveBeenCalledOnce() + expect(buildPtyHostEnv).toHaveBeenCalledWith( + 'fresh-desktop', + options.env, + expect.objectContaining({ isWsl: true, wslDistro: 'Ubuntu', wslUser: 'alice' }) + ) + expect(result.env).toMatchObject({ ORCA_PANE_KEY: 'pane', ORCA_WSL_CLI_DIR: 'C:\\cli' }) + }) + it('refuses foreign-home hook endpoints before building spawn state', async () => { + for (const endpoint of ['/home/bob/.orca-wsl/agent-hooks/instance-a/endpoint']) { + vi.mocked(wslHookRelayManager.getGuestEndpointFilePath).mockReturnValue(endpoint) + await expect(prepareWslGuestTerminalSpawn(prepared, options, policy)).rejects.toThrow( + 'terminal owner' + ) + } + expect(buildPtyHostEnv).not.toHaveBeenCalled() + expect(prepareWslGuestSpawnOptions).not.toHaveBeenCalled() + }) + it('does not reconfigure an attach or adopt a legacy Windows owner', async () => { + const sessionId = toAppWslPtyId(prepared.owner, 'pty2:owner:1') + await prepareWslGuestTerminalSpawn( + prepared, + { ...options, sessionId, isNewSession: false }, + policy + ) + await expect( + prepareWslGuestTerminalSpawn( + prepared, + { ...options, sessionId: 'pty2:windows:1', isNewSession: false }, + policy + ) + ).rejects.toThrow('WSL-owned') + expect(wslHookRelayManager.ensureForDistro).not.toHaveBeenCalled() + expect(buildPtyHostEnv).not.toHaveBeenCalled() + }) + it('cancels a waiting spawn without aborting the shared hook relay setup', async () => { + let finish: (() => void) | undefined + vi.mocked(wslHookRelayManager.ensureForDistro).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const controller = new AbortController() + const pending = prepareWslGuestTerminalSpawn(prepared, options, policy, controller.signal) + controller.abort(new Error('spawn cancelled')) + await expect(pending).rejects.toThrow('spawn cancelled') + finish?.() + expect(buildPtyHostEnv).not.toHaveBeenCalled() + }) + it('refuses a Windows account home before launching a guest hook relay', async () => { + await expect( + prepareWslGuestTerminalSpawn(prepared, options, { + ...policy, + selectedCodexHomePath: 'C:\\Users\\alice' + }) + ).rejects.toThrow('account home') + expect(wslHookRelayManager.ensureForDistro).not.toHaveBeenCalled() + }) + it('honors disabled hooks without requiring a receiver', async () => { + await prepareWslGuestTerminalSpawn(prepared, options, { + ...policy, + agentStatusHooksEnabled: false + }) + expect(wslHookRelayManager.ensureForDistro).not.toHaveBeenCalled() + expect(buildPtyHostEnv).toHaveBeenCalledOnce() + }) +}) + +it.each([true, false])( + 'prepares daemon hooks for its captured user, enabled=%s', + async (enabled) => { + const daemon = { + owner: prepared.owner, + endpoint: { + distro: 'Ubuntu', + distributionId: 'registration', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/bun', + entry: '/daemon', + envBinary: '/usr/bin/env', + socket: '/socket', + tokenPath: '/token', + serverBuildId: 'daemon-build' + } + } + const result = await prepareWslGuestTerminalSpawn(daemon, options, { + ...policy, + agentStatusHooksEnabled: enabled + }) + expect(result).toMatchObject({ sessionId: 'fresh-desktop', isNewSession: true }) + if (enabled) { + expect(wslHookRelayManager.ensureForDistro).toHaveBeenCalledWith( + 'Ubuntu', + policy.selectedCodexHomePath, + undefined, + 'alice' + ) + } else { + expect(wslHookRelayManager.ensureForDistro).not.toHaveBeenCalled() + } + expect(prepareWslGuestSpawnOptions).toHaveBeenCalledWith( + expect.objectContaining({ + home: '/home/alice', + envBinary: '/usr/bin/env', + endpoint: daemon.endpoint + }), + expect.anything(), + undefined + ) + } +) + +it('keeps the daemon owner captured while hook readiness awaits', async () => { + const daemon = { + owner: { ...prepared.owner }, + endpoint: { + distro: 'Ubuntu', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/bun', + envBinary: '/usr/bin/env', + tokenPath: '/token' + } + } + vi.mocked(wslHookRelayManager.ensureForDistro).mockImplementation(async () => { + daemon.endpoint.userName = 'bob' + daemon.endpoint.home = '/home/bob' + daemon.owner.relayBuildId = 'other-owner' + await Promise.resolve() + }) + await prepareWslGuestTerminalSpawn(daemon, options, policy) + expect(prepareWslGuestSpawnOptions).toHaveBeenCalledWith( + expect.objectContaining({ + owner: prepared.owner, + endpoint: expect.objectContaining({ userName: 'alice', home: '/home/alice' }) + }), + expect.anything(), + undefined + ) +}) + +it('derives stale-hook deletion only after the guest hook coordinates are built', async () => { + vi.mocked(buildPtyHostEnv).mockImplementation((_id, env) => ({ + ...env, + ORCA_AGENT_HOOK_ENDPOINT: '/home/alice/.orca-wsl/agent-hooks/endpoint' + })) + const result = await prepareWslGuestTerminalSpawn(prepared, options, policy) + expect(result.envToDelete).not.toContain('ORCA_AGENT_HOOK_ENDPOINT') + expect(result.envToDelete).toContain('ORCA_AGENT_HOOK_PORT') +}) + +it('degrades missing hook endpoints without blocking fresh terminals', async () => { + vi.mocked(wslHookRelayManager.getGuestEndpointFilePath).mockReturnValue(null) + await prepareWslGuestTerminalSpawn(prepared, options, policy) + expect(buildPtyHostEnv).toHaveBeenCalledWith( + 'fresh-desktop', + options.env, + expect.objectContaining({ agentStatusHooksEnabled: false }) + ) + expect(prepareWslGuestSpawnOptions).toHaveBeenCalledOnce() +}) + +it('degrades failed optional hook setup without using a stale endpoint', async () => { + vi.mocked(wslHookRelayManager.ensureForDistro).mockRejectedValueOnce(new Error('unavailable')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await prepareWslGuestTerminalSpawn(prepared, options, policy) + expect(buildPtyHostEnv).toHaveBeenCalledWith( + 'fresh-desktop', + options.env, + expect.objectContaining({ agentStatusHooksEnabled: false }) + ) + expect(wslHookRelayManager.getGuestEndpointFilePath).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } +}) + +it('accepts a selected account UNC path using registered distro casing', async () => { + await prepareWslGuestTerminalSpawn( + { + ...prepared, + owner: { ...prepared.owner, distro: 'ubuntu' }, + endpoint: { ...prepared.endpoint, distro: 'ubuntu' } + }, + options, + { ...policy, selectedCodexHomePath: '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' } + ) + expect(buildPtyHostEnv).toHaveBeenCalledWith( + 'fresh-desktop', + options.env, + expect.objectContaining({ selectedCodexHomePath: '/home/alice/.codex', wslDistro: 'ubuntu' }) + ) +}) diff --git a/src/main/wsl/wsl-guest-terminal-preparation.ts b/src/main/wsl/wsl-guest-terminal-preparation.ts new file mode 100644 index 000000000000..3799d56eda87 --- /dev/null +++ b/src/main/wsl/wsl-guest-terminal-preparation.ts @@ -0,0 +1,111 @@ +import { + getInheritedAgentHookEnvKeysToDelete, + mergePtyEnvDeletions +} from '../ipc/pty/host-env/pi-agent' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType +} from '../../shared/pi-agent-kind' +import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { posix } from 'node:path' +import { waitForPromiseWithSignal, throwIfSignalAborted } from '../../shared/abort-signal-reason' +import { toRelayWslPtyId, parseAppWslPtyId } from '../../shared/wsl-pty-id' +import { buildPtyHostEnv } from '../ipc/pty/host-env/assembly' +import type { BuildPtyHostEnvOptions } from '../ipc/pty/host-env/types' +import type { PtySpawnOptions } from '../providers/types' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { + prepareWslGuestSpawnOptions, + capturedWslGuestSpawnOwner, + type PreparedWslGuestSpawnOwner +} from './wsl-guest-spawn-options' + +/** Account selection/auth are prepared by the existing spawn preflight before this boundary. */ +export async function prepareWslGuestTerminalSpawn( + captured: PreparedWslGuestSpawnOwner, + options: PtySpawnOptions, + policy: BuildPtyHostEnvOptions, + signal?: AbortSignal +): Promise { + const prepared = capturedWslGuestSpawnOwner(captured) + throwIfSignalAborted(signal) + if (options.sessionId && !options.isNewSession) { + toRelayWslPtyId(prepared.owner, options.sessionId) + return prepareWslGuestSpawnOptions(prepared, options, signal) + } + if (options.attachOnly || (options.sessionId && parseAppWslPtyId(options.sessionId))) { + throw new Error('Guest spawn preparation cannot replace an existing terminal owner') + } + const { userName: user, distro } = prepared.endpoint + if (!user || distro !== prepared.owner.distro || !options.sessionId) { + throw new Error('Guest spawn preparation requires an explicit owner and fresh desktop identity') + } + const selectedHome = policy.selectedCodexHomePath + const uncHome = selectedHome ? parseWslUncPath(selectedHome) : null + if ( + selectedHome && + (uncHome + ? uncHome.distro.toLowerCase() !== distro.toLowerCase() + : !selectedHome.startsWith('/')) + ) { + throw new Error('Selected account home does not belong to the guest owner') + } + const ownedPolicy = { + ...policy, + selectedCodexHomePath: uncHome?.linuxPath ?? selectedHome, + isWsl: true, + wslDistro: distro, + wslUser: user + } + if (policy.agentStatusHooksEnabled) { + const kind = isPiCompatibleAgentType(policy.launchAgent) + ? policy.launchAgent + : policy.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand( + resolveSetupAgentSequenceLaunchCommand(options.env ?? {}, policy.launchCommand) + ) + : null + let ready = true + try { + await waitForPromiseWithSignal( + wslHookRelayManager.ensureForDistro( + distro, + ownedPolicy.selectedCodexHomePath, + kind === 'pi' || kind === 'omp' ? kind : undefined, + user + ), + signal + ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) + : AbortSignal.timeout(15_000) + ) + } catch (error) { + throwIfSignalAborted(signal) + ready = false + console.warn('[wsl] Terminal hooks unavailable; continuing without hooks', error) + } + const endpoint = ready ? wslHookRelayManager.getGuestEndpointFilePath(distro, user) : null + const root = posix.join(prepared.home, '.orca-wsl', 'agent-hooks') + if (endpoint && (!endpoint.startsWith(`${root}/`) || posix.normalize(endpoint) !== endpoint)) { + throw new Error('Guest hook endpoint is unavailable for the terminal owner') + } + ownedPolicy.agentStatusHooksEnabled = Boolean(endpoint) + if (!endpoint) { + ownedPolicy.codexStatusHooksEnabled = false + } + } + throwIfSignalAborted(signal) + const env = buildPtyHostEnv(options.sessionId, { ...options.env }, ownedPolicy) + return prepareWslGuestSpawnOptions( + prepared, + { + ...options, + env, + envToDelete: mergePtyEnvDeletions( + options.envToDelete, + getInheritedAgentHookEnvKeysToDelete(env) + ) + }, + signal + ) +} diff --git a/src/main/wsl/wsl-invocation-boundary.test.ts b/src/main/wsl/wsl-invocation-boundary.test.ts index 29906362a47c..e8c137a3c032 100644 --- a/src/main/wsl/wsl-invocation-boundary.test.ts +++ b/src/main/wsl/wsl-invocation-boundary.test.ts @@ -365,7 +365,7 @@ describe('wsl.exe is spawned through one runner', () => { // so it passed even for a scanner that found nothing. This fails the moment // detection stops seeing a call that is definitely there. expect(offenders).toContain('main/git/command-runner/wsl-command-resolution.ts') - expect(offenders).toContain('main/providers/local-pty-spawn.ts') + expect(offenders).not.toContain('main/providers/local-pty-spawn.ts') }) it('adds no new direct wsl.exe spawn', () => { diff --git a/src/main/wsl/wsl-runner.ts b/src/main/wsl/wsl-runner.ts index ac7676c25db9..d549b7a2b0b2 100644 --- a/src/main/wsl/wsl-runner.ts +++ b/src/main/wsl/wsl-runner.ts @@ -64,6 +64,8 @@ export type WslCommand = export type WslSpec = WslCommand & { /** Undefined selects the distro's default. */ distro?: string + /** Preserve an existing execution owner when the distro default changes. */ + user?: string /** Required, with no default: the wrong answer here is the defect this file exists to prevent. */ loginPath: WslLoginPath /** Guest (POSIX) path. */ @@ -210,7 +212,7 @@ export async function runWslProcess(spec: WslSpec): Promise { const remainingForProbe = deadline - Date.now() const probeBudgetMs = Math.max(1, Math.min(4_000, Math.floor(remainingForProbe / 2))) const environment = wantsEnvironment - ? await getWslGuestEnvironment(spec.distro, probeBudgetMs) + ? await getWslGuestEnvironment(spec.distro, probeBudgetMs, spec.user) : null // Probe failure must NOT fall back to the login shell. That lane sources @@ -224,7 +226,10 @@ export async function runWslProcess(spec: WslSpec): Promise { const argvForm = buildGuestArgv(environment, spec, 'argv') // Measure what is actually spawned: `wsl.exe` and `-d --exec` are // prepended after this point and are part of the same budget. - const fullLine = [resolveWslExecutablePath(), ...buildWslExecArgs(spec.distro, argvForm)] + const fullLine = [ + resolveWslExecutablePath(), + ...buildWslExecArgs(spec.distro, argvForm, spec.user) + ] // Argv is the default, but it has a hard ceiling that stdin does not. A user's // `orca.yaml` hook is the one unbounded script Orca runs, so past the cap the // choice is between failing to spawn at all and accepting the stdin caveat. @@ -239,7 +244,7 @@ export async function runWslProcess(spec: WslSpec): Promise { const remainingMs = Math.max(1, deadline - Date.now()) const result = await runProcess({ program: resolveWslExecutablePath(), - args: buildWslExecArgs(spec.distro, argv), + args: buildWslExecArgs(spec.distro, argv, spec.user), // Name a Windows directory rather than inheriting one: an inherited cwd that // is later deleted (the worktree Orca launched from) fails every later spawn // (#16463). Never the guest cwd -- withGuestCwd still cds inside. diff --git a/src/main/zsh-startup-hook-pty-harness.ts b/src/main/zsh-startup-hook-pty-harness.ts index a4de4e3bba9d..2c14738e8266 100644 --- a/src/main/zsh-startup-hook-pty-harness.ts +++ b/src/main/zsh-startup-hook-pty-harness.ts @@ -11,16 +11,19 @@ * command being typed, so matching on stdout matches the echo of the probe as * readily as its output. */ -import { spawnSync } from 'node:child_process' +import { runProcessSync } from '../shared/child-process/run-process' +import { runBundledBunFixture } from './bundled-bun-test-execution' +import { spawnBunPty } from './daemon/pty-subprocess/bun-pty-process' +import { z } from 'zod' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import * as pty from 'node-pty' -export const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 +export const hasZsh = + process.platform !== 'win32' && runProcessSync({ program: 'zsh', args: ['--version'] }).code === 0 export const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() + ? (runProcessSync({ program: 'sh', args: ['-c', 'command -v zsh'] }).stdout || '').trim() : '' /** OSC sequences the wrapper emits, as the terminal would receive them. */ @@ -89,13 +92,30 @@ function parseValues(resultPath: string): Record { * so a slow prompt framework makes the run slower, never flaky. */ export async function runZshPty(options: ZshPtyOptions): Promise { + if (!process.versions.bun) { + return z + .object({ + output: z.string(), + values: z.record(z.string(), z.string()), + exitedBeforePrompt: z.boolean() + }) + .parse( + await runBundledBunFixture( + __filename, + 'runZshPty', + options, + (options.timeoutMs ?? 20_000) + 5_000 + ) + ) + } const sentinel = '@@ORCA-PTY-READY@@' const workDir = mkdtempSync(join(tmpdir(), 'orca-zsh-pty-')) const resultPath = join(workDir, 'probe.txt') const timeoutMs = options.timeoutMs ?? 20_000 - const proc = pty.spawn(ZSH_PATH, ['-l', '-i'], { - name: 'xterm-256color', + const proc = spawnBunPty({ + file: ZSH_PATH, + args: ['-l', '-i'], cols: 200, rows: 40, cwd: options.cwd ?? workDir, diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 49f706e57417..affa22b15eb2 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -39,7 +39,7 @@ import { /** Runtime values the hook re-exports after the user's own startup files ran. */ export type ZshWrapperRestoreSpec = { - /** The managed WSL CLI dir onto PATH — local wrappers only; a no-op outside WSL. */ + /** The managed WSL CLI dir onto PATH; a no-op outside WSL. */ managedWslCli: boolean /** Orca's agent-teams shim dir back onto PATH. */ agentTeamsPath: boolean diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 49e2189fd537..5e9c0cf150e2 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -146,6 +146,7 @@ export const ptyStreamAndSerializationApi = { killAll: () => ipcRenderer.invoke('pty:management:killAll'), killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), restart: () => ipcRenderer.invoke('pty:management:restart'), + retry: () => ipcRenderer.invoke('pty:management:retry'), macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution'), resetFolderAccess: () => ipcRenderer.invoke('pty:management:resetFolderAccess') } diff --git a/src/preload/api/pty-management-api.ts b/src/preload/api/pty-management-api.ts index ddc94c77f2ce..0bb36f934f9e 100644 --- a/src/preload/api/pty-management-api.ts +++ b/src/preload/api/pty-management-api.ts @@ -41,8 +41,12 @@ export type PtyManagementFolderAccessResetResult = | { outcome: 'probed'; mismatch: PtyManagementFolderAccessMismatch | null } export type PtyManagementApi = { - // `degraded`: daemon is alive but can't spawn fresh PTYs, so new terminals run locally without daemon persistence. - listSessions: () => Promise<{ sessions: PtyManagementSession[]; degraded: boolean }> + // Existing sessions remain available while fresh terminal creation is unavailable. + listSessions: () => Promise<{ + sessions: PtyManagementSession[] + degraded: boolean + unavailable?: boolean + }> killAll: () => Promise<{ killedCount: number remainingCount: number @@ -50,6 +54,7 @@ export type PtyManagementApi = { }> killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> restart: () => Promise<{ success: boolean }> + retry: () => Promise<{ success: boolean }> macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth folderAccessMismatch: PtyManagementFolderAccessMismatch | null diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 08c5f824f7fc..fffd166ab2f8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../shared/terminal-process' import { killWithDescendantSweep } from '../main/pty-descendant-termination' import { bunRelayPtyModule } from './relay-pty-runtime' import { @@ -100,7 +100,7 @@ import { } from '../shared/claimed-agent-pty-owner' import type { RelayPtySourceOutput } from './relay-pty-source-output' import { signalPosixPtyForegroundGroup } from '../main/pty/posix-pty-foreground-group' -import { readPtsName } from '../main/pty/node-pty-pts-name' +import { readPtsName } from '../main/pty/terminal-slave-device' import type { RelayPtySourcePublication } from './relay-pty-source-publication' import type { PtySourceRecoveryRequest, @@ -197,7 +197,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | type ManagedPty = { id: string incarnationId: string - pty: IPty + pty: TerminalProcess initialCwd: string /** Why a chunk deque: rebuilding a rolling 100KB string per PTY chunk copied the * whole window on every write once saturated. Readers are attach/adopt/revive only. */ @@ -284,7 +284,7 @@ type ManagedStartupCommand = { } // Why: Windows ConPTY rejects signals; forward them only on POSIX. -function killPtyProcess(pty: IPty, signal: string): void { +function killPtyProcess(pty: TerminalProcess, signal: string): void { if (process.platform === 'win32') { pty.kill() return @@ -2149,13 +2149,7 @@ export class PtyHandler { if (this.reapPtyProvenExited(managed)) { return } - // The patched node-pty retires `_fd` in the same block that gives up the - // master (config/patches/node-pty@1.1.0.patch), which makes a resize past - // that point a no-op rather than a TIOCSWINSZ aimed at a reused descriptor. - // That covers only part of the window and does not cover this process at - // all: libuv closes the fd synchronously inside `uv_close`, before the JS - // `'close'` that runs `_close()`, and a relay host installs node-pty from - // npm, where the patch is not applied. So the catch below stays. + // Backend resize can race native terminal disposal; keep teardown errors local to this PTY. try { managed.pty.resize(cols, rows) } catch (err) { @@ -2999,7 +2993,7 @@ export class PtyHandler { const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { terminalWindowsWslDistro }) - let term: IPty + let term: TerminalProcess try { term = await ptyMod.spawn(shell, shellLaunch.args, { name: spawnEnv.TERM ?? 'xterm-256color', diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 2231fc2b29b9..0951ca46f20f 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,5 +1,6 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' +import { WSL_MANAGED_CLI_PATH_RESTORE } from '../main/wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' import { BASH_FEATURE_CHANNEL_BLOCK, @@ -32,7 +33,7 @@ function getRelayZshWrapperSpec(): ZshStartupHookSpec { overlayRestoreComment: '# Why: remote startup files can re-export user defaults after relay spawn.', restores: { - managedWslCli: false, + managedWslCli: true, agentTeamsPath: false, remoteCliBinDir: true, codexHome: false, @@ -73,6 +74,7 @@ fi [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${WSL_MANAGED_CLI_PATH_RESTORE} ${getPosixOmpShellWrapper()} ${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local diff --git a/src/relay/pty-shell-wsl-cli.test.ts b/src/relay/pty-shell-wsl-cli.test.ts new file mode 100644 index 000000000000..df83fed798fb --- /dev/null +++ b/src/relay/pty-shell-wsl-cli.test.ts @@ -0,0 +1,55 @@ +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 { runProcess, runProcessSync } from '../shared/child-process/run-process' +import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe.each(['bash', 'zsh'])('guest relay %s CLI environment', (shell) => { + const available = + process.platform !== 'win32' && + runProcessSync({ program: shell, args: ['--version'] }).code === 0 + + it.skipIf(!available)('restores the managed CLI after user startup replaces PATH', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca guest cli ')) + directories.push(root) + const home = join(root, 'home') + const cli = join(root, 'managed cli') + const wrappers = join(root, 'wrappers') + mkdirSync(home) + mkdirSync(cli) + writeFileSync(join(cli, 'orca-ide'), '#!/bin/sh\nprintf "guest-cli-ok\\n"\n') + chmodSync(join(cli, 'orca-ide'), 0o755) + const startup = 'export PATH=/usr/bin:/bin\n' + writeFileSync(join(home, '.bash_profile'), startup) + writeFileSync(join(home, '.zshrc'), startup) + expect(ensureOverlayRestoreWrappers(wrappers)).toBe(true) + const result = await runProcess({ + program: shell, + args: + shell === 'bash' + ? ['--noprofile', '--rcfile', join(wrappers, 'bash', 'rcfile'), '-i'] + : ['-i'], + env: { + HOME: home, + PATH: '/usr/bin:/bin', + TERM: 'xterm', + ZDOTDIR: join(wrappers, 'zsh'), + ORCA_WSL_CLI_DIR: cli, + ORCA_CLI_COMMAND: 'orca-ide' + }, + input: 'orca-ide\nexit 0\n', + timeoutMs: 5_000 + }) + expect(result.code).toBe(0) + expect(result.stdout).toContain('guest-cli-ok') + expect(result.stderr).not.toContain('command not found') + }) +}) diff --git a/src/relay/relay-daemon-fatal-reap.test.ts b/src/relay/relay-daemon-fatal-reap.test.ts index b8b268547eaa..8900208a8175 100644 --- a/src/relay/relay-daemon-fatal-reap.test.ts +++ b/src/relay/relay-daemon-fatal-reap.test.ts @@ -96,7 +96,6 @@ vi.mock('./relay-grace-lifecycle', () => ({ import { createMockDispatcher } from './pty-handler-test-harness' import { runRelayDaemon } from './relay-daemon' -import { __setConptyJobNativeForTests } from '../main/windows/windows-pty-job' describe('relay daemon fatal PTY reap', () => { let originalPlatform: PropertyDescriptor | undefined @@ -137,7 +136,6 @@ describe('relay daemon fatal PTY reap', () => { } const handler = daemonMocks.runtimePtyHandler as PtyHandler | null await handler?.dispose({ waitForPhysicalExit: false }).catch(() => {}) - __setConptyJobNativeForTests() vi.restoreAllMocks() if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -150,15 +148,18 @@ describe('relay daemon fatal PTY reap', () => { throw new Error('ConPTY close failed') }) const secondKill = vi.fn() + const terminateJob = vi.fn((id: number) => + id === 22 ? ('terminated' as const) : ('unavailable' as const) + ) daemonMocks.mockPtySpawn - .mockReturnValueOnce(createMockPty(11, 101, firstKill)) - .mockReturnValueOnce(createMockPty(22, 202, secondKill)) - const terminateJob = vi.fn((id: number) => id === 22) - __setConptyJobNativeForTests(() => ({ - terminateJob, - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn() - })) + .mockReturnValueOnce({ + ...createMockPty(11, 101, firstKill), + terminateOwnedTree: () => terminateJob(11) + }) + .mockReturnValueOnce({ + ...createMockPty(22, 202, secondKill), + terminateOwnedTree: () => terminateJob(22) + }) const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) await runRelayDaemon({ @@ -178,10 +179,7 @@ describe('relay daemon fatal PTY reap', () => { expect(fatalListener).toBeDefined() fatalListener!(new Error('relay crashed'), 'uncaughtException') - expect(terminateJob.mock.calls).toEqual([ - [11, 101], - [22, 202] - ]) + expect(terminateJob.mock.calls).toEqual([[11], [22]]) expect(firstKill).toHaveBeenCalledOnce() expect(firstKill.mock.calls[0]).toEqual([]) expect(secondKill).not.toHaveBeenCalled() diff --git a/src/relay/relay-pty-runtime.ts b/src/relay/relay-pty-runtime.ts index be85dc6663a1..45a641def26c 100644 --- a/src/relay/relay-pty-runtime.ts +++ b/src/relay/relay-pty-runtime.ts @@ -1,4 +1,4 @@ -import type { IPty } from 'node-pty' +import type { TerminalProcess } from '../shared/terminal-process' import { waitForPromiseWithSignal } from '../shared/abort-signal-reason' import { spawnBunPty } from '../main/daemon/pty-subprocess/bun-pty-process' @@ -16,7 +16,7 @@ export type RelayPtyModule = { args: string[], options: RelayPtySpawnOptions, signal?: AbortSignal - ): IPty | Promise + ): TerminalProcess | Promise } export const bunRelayPtyModule: RelayPtyModule = { diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index 732566877928..87f8d7531189 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -1,3 +1,4 @@ +import { TerminalServiceRetryNotice } from './TerminalServiceRetryNotice' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import type { PtyManagementSession } from '../../../../preload/api-types' @@ -19,6 +20,7 @@ import { translate } from '@/i18n/i18n' type ConfirmKind = 'killOne' export function ManageSessionsSection(): React.JSX.Element { + const [serviceUnavailable, setServiceUnavailable] = useState(false) const [sessions, setSessions] = useState([]) const [isRefreshing, setIsRefreshing] = useState(true) const [hasLoadedOnce, setHasLoadedOnce] = useState(false) @@ -82,6 +84,7 @@ export function ManageSessionsSection(): React.JSX.Element { return result.sessions } setSessions(result.sessions) + setServiceUnavailable(result.degraded || result.unavailable === true) return result.sessions } catch (err) { console.error('[manage-sessions] listSessions failed', err) @@ -218,6 +221,7 @@ export function ManageSessionsSection(): React.JSX.Element { showManageSessionsButton={false} refreshRevision={attributionRefreshRevision} /> + {serviceUnavailable && void refresh()} />} ({ translate: (_key: string, fallback: string) => fallback })) +const container = document.createElement('div') +const root = createRoot(container) +afterEach(async () => { + await act(async () => root.render(null)) +}) + +it('keeps retries nondestructive and disables the action until settlement', async () => { + let finish!: (value: { success: boolean }) => void + const retry = vi.fn( + () => + new Promise<{ success: boolean }>((resolve) => { + finish = resolve + }) + ) + const restart = vi.fn() + Object.assign(window, { api: { pty: { management: { retry, restart } } } }) + const recovered = vi.fn() + await act(async () => root.render()) + const button = container.querySelector('button')! + await act(async () => button.click()) + expect(button.disabled).toBe(true) + expect(retry).toHaveBeenCalledOnce() + expect(restart).not.toHaveBeenCalled() + await act(async () => finish({ success: true })) + expect(recovered).toHaveBeenCalledOnce() + expect(button.disabled).toBe(false) +}) + +it('retains an inline error and permits another attempt', async () => { + const retry = vi.fn(async () => ({ success: false })) + Object.assign(window, { api: { pty: { management: { retry } } } }) + const recovered = vi.fn() + await act(async () => root.render()) + await act(async () => container.querySelector('button')!.click()) + expect(container.textContent).toContain('still unavailable') + expect(container.querySelector('button')!.disabled).toBe(false) + expect(recovered).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/settings/TerminalServiceRetryNotice.tsx b/src/renderer/src/components/settings/TerminalServiceRetryNotice.tsx new file mode 100644 index 000000000000..412ce945d0ea --- /dev/null +++ b/src/renderer/src/components/settings/TerminalServiceRetryNotice.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import { Button } from '../ui/button' +import { translate } from '@/i18n/i18n' + +export function TerminalServiceRetryNotice({ + onRecovered +}: { + onRecovered: () => void +}): React.JSX.Element { + const [busy, setBusy] = useState(false) + const [failed, setFailed] = useState(false) + + const retry = async (): Promise => { + if (busy) { + return + } + setBusy(true) + setFailed(false) + try { + const result = await window.api.pty.management.retry() + if (result.success) { + onRecovered() + } else { + setFailed(true) + } + } catch { + setFailed(true) + } finally { + setBusy(false) + } + } + + return ( +
+

+ {failed + ? translate( + 'terminal.service.retryFailed', + 'The terminal service is still unavailable. Retry preserves existing sessions. You can also use Restart daemon below; restarting closes its running sessions.' + ) + : translate( + 'terminal.service.unavailable', + 'New terminals are unavailable. Retry the terminal service without stopping existing sessions.' + )} +

+ +
+ ) +} diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index 535c34544312..e80177d83f00 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -94,6 +94,7 @@ export function createPtyApi(): NonNullable['pty']> { killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), killOne: () => Promise.resolve({ success: false }), restart: () => Promise.resolve({ success: false }), + retry: () => Promise.resolve({ success: false }), // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. macTccAttribution: () => Promise.resolve({ health: 'unknown' as const, folderAccessMismatch: null }), diff --git a/src/shared/fish-query-reply-child-stdin.node-pty.test.ts b/src/shared/fish-query-reply-child-stdin.node-pty.test.ts deleted file mode 100644 index 2bb3925328b2..000000000000 --- a/src/shared/fish-query-reply-child-stdin.node-pty.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Real-fish regression for #13892: a terminal query reply Orca held back is overtaken - * by the DA1 answer written later in the same turn, so fish's read sentinel hands the - * tty to the child while the OSC 11 reply is still queued — and the CHILD READS IT. - * - * What is real here: node-pty running fish, `PtyStartupIngress`, `PtyStartupReplyDelivery` - * and both echo probes, plus the host's own write gate - * (`answerLiveQueryReply` — copied from local-pty-provider.write, which - * is what decides whether a reply is deferred at all). Only the renderer is modelled: it - * answers queries strictly in the order they appear in the stream, so any inversion the - * child sees was produced by the delivery split and nothing else. - * - * The assertion is about a CHILD PROCESS'S STDIN, not the screen: a rendered-output check - * passes while the bytes are still being eaten by the next `npx` / `brew` confirm prompt. - * The child reads a full LINE because a leaked reply carries no newline, so a - * once('data') child would report it alone whenever it happened to land in its own read. - */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { fishRequirementViolation, resolveFishBinary } from './fish-binary-requirement' -import { PtyStartupIngress } from './pty-startup-ingress' - -// Why fish 4: the DA1-sentinel handoff this measures lives in the 4.0 Rust tty_handoff. -const FISH = resolveFishBinary(4) -const itWithFish = FISH.available ? it : it.skip - -const PROMPT_MARK = 'ORCA13892> ' - -/* oxlint-disable no-control-regex -- terminal query grammars are control sequences */ -/** Anchored at an ESC, first match wins; reply values match xterm.js's. */ -const QUERY_GRAMMARS = [ - { - re: /^\x1b\]1[012];\?(\x07|\x1b\\)/, - reply: (m: RegExpExecArray) => `\x1b]11;rgb:1e1e/1e1e/1e1e${m[1]}` - }, - { re: /^\x1b\[\?6n/, reply: () => '\x1b[?1;1;1R' }, - { re: /^\x1b\[6n/, reply: () => '\x1b[1;1R' }, - { re: /^\x1b\[\?996n/, reply: () => '\x1b[?997;1n' }, - { re: /^\x1b\[>0?c/, reply: () => '\x1b[>0;276;0c' }, - { re: /^\x1b\[0?c/, reply: () => '\x1b[?1;2c' }, - { re: /^\x1b\[>0?q/, reply: () => '\x1bP>|Orca\x1b\\' }, - { re: /^\x1b\[\?u/, reply: () => '\x1b[?0u' } -] as const -/** Still accumulating: no CSI final byte and no OSC/DCS terminator yet. */ -const PARTIAL_QUERY_RE = - /^(?:\x1b|\x1b\[[?>=]?[0-9;]*|\x1b\][0-9]*(?:;[^\x07\x1b]*)?\x1b?|\x1bP[^\x1b]*\x1b?)$/ -/* oxlint-enable no-control-regex */ - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (predicate()) { - return true - } - await sleep(10) - } - return false -} - -describe('a held query reply never reaches the next child process (#13892)', () => { - let configHome: string | null = null - - // Always runs, so the CI lane cannot report green with the regression below skipped. - it('has the fish this suite needs when CI requires one', () => { - expect(fishRequirementViolation(FISH)).toBeNull() - }) - - // Same contract for the other half of the setup: a prebuilt node-pty has no echoState, - // so the fix under test would be off and the regression below would run vacuously. - - afterEach(() => { - if (configHome) { - rmSync(configHome, { recursive: true, force: true }) - configHome = null - } - }) - - itWithFish( - 'answers OSC 11 in the query turn so the reply cannot land in the child’s stdin', - async () => { - configHome = mkdtempSync(path.join(tmpdir(), 'orca-fish-13892-')) - mkdirSync(path.join(configHome, 'fish'), { recursive: true }) - writeFileSync( - path.join(configHome, 'fish/config.fish'), - [ - 'set -g fish_greeting ""', - `function fish_prompt; printf '${PROMPT_MARK}'; end`, - 'function fish_right_prompt; end', - '' - ].join('\n') - ) - const childScript = path.join(configHome, 'read-stdin.mjs') - writeFileSync( - childScript, - "let buffered = ''\n" + - "process.stdin.on('data', (d) => {\n" + - " buffered += d.toString('utf8')\n" + - " if (!buffered.includes('\\n')) return\n" + - " process.stdout.write('CHILD-READ:' + JSON.stringify(buffered) + '\\n')\n" + - ' process.exit(0)\n' + - '})\n' - ) - - const nodePty = await import('node-pty') - const term = nodePty.spawn(FISH.path as string, ['-l', '-i'], { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: configHome, - env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: configHome, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - LANG: 'en_US.UTF-8', - XDG_CONFIG_HOME: configHome, - XDG_DATA_HOME: path.join(configHome, 'data'), - ORCA_NODE_BIN: process.execPath, - ORCA_CHILD_SCRIPT: childScript - } - }) - - let rendered = '' - const ingress = new PtyStartupIngress({ - ownerBackend: 'posix-pty', - write: (data) => term.write(data), - onEmission: (emission) => { - rendered += emission.data - answerQueriesInOrder(emission.data) - } - }) - - // The host gate: cooked-echo-risk replies are written by the ingress with their - // echo shapes armed; DA1/CPR stay on the host's own path, in call order. - const hostWrite = (data: string): void => { - if (ingress.answerLiveQueryReply(data)) { - return - } - term.write(data) - } - - let tail = '' - let oscQueryCount = 0 - function answerQueriesInOrder(chunk: string): void { - let buffer = tail + chunk - tail = '' - let index = 0 - while (index < buffer.length) { - const at = buffer.indexOf('\x1b', index) - if (at === -1) { - return - } - const rest = buffer.slice(at) - const grammar = QUERY_GRAMMARS.map((candidate) => ({ - candidate, - match: candidate.re.exec(rest) - })).find((entry) => entry.match) - if (grammar?.match) { - if (grammar.candidate === QUERY_GRAMMARS[0]) { - oscQueryCount += 1 - } - hostWrite(grammar.candidate.reply(grammar.match)) - index = at + grammar.match[0].length - continue - } - if (PARTIAL_QUERY_RE.test(rest)) { - tail = rest - return - } - index = at + 1 - } - } - - let exited = false - term.onExit(() => { - exited = true - }) - term.onData((data) => ingress.accept(data)) - - try { - expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true) - await sleep(500) - const oscQueriesBeforeHandoff = oscQueryCount - - // Type-ahead is the deterministic shape: queue the child's command while an - // external command still owns the tty, so fish repaints its prompt (re-querying - // OSC 11) and hands the tty over in the same breath. - term.write('sleep 0.4\r') - await sleep(150) - term.write('"$ORCA_NODE_BIN" "$ORCA_CHILD_SCRIPT"\r') - await sleep(1_500) - expect(oscQueryCount).toBeGreaterThan(oscQueriesBeforeHandoff) - - const renderedBeforeChildInput = rendered.length - term.write('hello\r') - expect( - await waitUntil( - () => rendered.slice(renderedBeforeChildInput).includes('CHILD-READ:'), - 10_000 - ) - ).toBe(true) - - const childRead = - rendered.slice(renderedBeforeChildInput).match(/CHILD-READ:[^\r\n]*/)?.[0] ?? '' - // The merge-blocking assertion: the child's first LINE is what the user typed, - // with no escape byte in front of it. Pre-fix this reads - // `\u001b]11;rgb:1e1e/1e1e/1e1e\u001b\\hello\n`. - expect(childRead).toBe('CHILD-READ:"hello\\n"') - } finally { - term.write('exit\r') - await waitUntil(() => exited, 3_000) - try { - term.kill() - } catch { - // already gone - } - } - }, - 45_000 - ) -}) diff --git a/src/shared/node-pty-spawn-helper.test.ts b/src/shared/node-pty-spawn-helper.test.ts deleted file mode 100644 index 4562c2bc9b7b..000000000000 --- a/src/shared/node-pty-spawn-helper.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { usesNodePtySpawnHelper } from './node-pty-spawn-helper' - -describe('usesNodePtySpawnHelper', () => { - it('is macOS only', () => { - // The predicate this file exists for: node-pty's binding.gyp declares the - // spawn-helper target inside OS=="mac". Reading it as "every non-Windows platform" - // is what reported spawn_helper_missing on healthy Linux hosts (#17844). - expect(usesNodePtySpawnHelper('darwin')).toBe(true) - for (const platform of ['linux', 'win32', 'freebsd', 'openbsd', 'sunos', 'aix']) { - expect(usesNodePtySpawnHelper(platform)).toBe(false) - } - }) -}) diff --git a/src/shared/node-pty-spawn-helper.ts b/src/shared/node-pty-spawn-helper.ts deleted file mode 100644 index c3e40df101a8..000000000000 --- a/src/shared/node-pty-spawn-helper.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Whether node-pty execs its `spawn-helper` binary on a platform. - * - * Only macOS: binding.gyp declares the `spawn-helper` target inside `OS=="mac"`, and - * `src/unix/pty.cc` reads the helper path only under `#if defined(__APPLE__)`. Every - * other platform forks directly, so requiring the helper there calls a working host - * broken. - */ -export function usesNodePtySpawnHelper(platform: string): boolean { - return platform === 'darwin' -} diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index 61d57f385516..638885750b86 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -3,12 +3,34 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { BUNDLED_RIPGREP_PLATFORMS, bundledRipgrepBinaryName } from './bundled-ripgrep' import { + ORCAD_ARTIFACTS, + ORCAD_WINDOWS_CONPTY_ARTIFACTS, ORCAD_RIPGREP_ARTIFACTS, ORCAD_RIPGREP_LICENSE_ARTIFACTS, orcadArtifactFilenames } from './orcad-artifacts' describe('standalone runtime artifacts', () => { + it.each(['win32', 'win32-x64', 'win32-arm64'])( + 'requires the full ConPTY closure on %s', + (target) => { + expect(orcadArtifactFilenames(target)).toEqual( + expect.arrayContaining([...ORCAD_WINDOWS_CONPTY_ARTIFACTS]) + ) + } + ) + + it.each(['', 'linux-x64-glibc', 'linux-arm64-musl', 'darwin-arm64'])( + 'preserves the POSIX artifact sequence for %s', + (target) => { + expect(orcadArtifactFilenames(target)).toEqual( + ORCAD_ARTIFACTS.filter((artifact) => !artifact.optional).map( + (artifact) => artifact.filename + ) + ) + } + ) + it('ships search binaries for every SSH host and includes them in the install identity', () => { const expected = BUNDLED_RIPGREP_PLATFORMS.map( (platform) => `ripgrep/${platform}/${bundledRipgrepBinaryName(platform)}` diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 98688026a159..46949d240a85 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -11,6 +11,13 @@ export const ORCAD_BUN_RUNTIME_FILENAME = 'bun-runtime' export const ORCAD_WINDOWS_BUN_RUNTIME_FILENAME = 'bun-runtime.exe' export const ORCAD_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' +export const ORCAD_WINDOWS_CONPTY_ARTIFACTS = [ + 'conpty/conpty.dll', + 'conpty/OpenConsole.exe', + 'conpty/LICENSE.txt', + 'conpty/Microsoft.Windows.Console.ConPTY.nuspec', + 'conpty/conpty.json' +] as const export function orcadBunRuntimeFilename(target: string): string { return target === 'win32' || target.startsWith('win32-') @@ -106,7 +113,7 @@ export function orcadArtifactFilenames(target = ''): string[] { : artifact.filename ) if (target === 'win32' || target.startsWith('win32-')) { - filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME) + filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME, ...ORCAD_WINDOWS_CONPTY_ARTIFACTS) } return filenames } diff --git a/src/shared/orcad-bun-runtime.ts b/src/shared/orcad-bun-runtime.ts index 76fc03258c86..385303db1a02 100644 --- a/src/shared/orcad-bun-runtime.ts +++ b/src/shared/orcad-bun-runtime.ts @@ -1,5 +1,16 @@ export const ORCAD_BUN_VERSION = '1.4.2' +// Custom builds may add a CLI banner suffix without changing their runtime API version. +export function orcadBunVersionProbeArgs(platform: NodeJS.Platform = process.platform): string[] { + return [ + '--no-env-file', + platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install', + '--print', + 'process.versions.bun' + ] +} + export const ORCAD_BUN_TARGETS = [ 'darwin-arm64', 'darwin-x64', diff --git a/src/shared/orcad-profile-preflight.ts b/src/shared/orcad-profile-preflight.ts index 2541a09ebdec..cb34d68cba34 100644 --- a/src/shared/orcad-profile-preflight.ts +++ b/src/shared/orcad-profile-preflight.ts @@ -1,6 +1,7 @@ import { z } from 'zod' export const ORCAD_PROFILE_PREFLIGHT_FLAG = '--orcad-profile-state-preflight' +export const ORCAD_NATIVE_PREFLIGHT_CHILD_FLAG = '--orcad-native-profile-preflight-child' export const ORCAD_STARTUP_PREFLIGHT_FLAG = '--orcad-startup-preflight' export const ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS = 90_000 // Server startup follows the disposable native/SQLite probe on every bundled launch. diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 8cb5a88f702c..f7ef3889fd6b 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -406,7 +406,7 @@ const DIRECT_SINGLE_SOURCE_SURFACES: readonly { marker: 'isCursorAgentTitle' }, { - path: 'src/main/providers/local-pty-session-activation.ts', + path: 'src/main/daemon/pty-subprocess.ts', classification: 'action-consumer', marker: 'launchAgent' }, diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index a081eee76ab3..8c819b11c7ea 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -1,3 +1,4 @@ +import type { WslPtyRecoveryRecord } from './wsl-pty-consumer-recovery' import type { ExecutionHostId } from './execution-host' import type { RemovedSshTargetTombstone, @@ -103,6 +104,7 @@ export type PersistedState = { sshRemotePtyLeases: SshRemotePtyLease[] /** Main-owned authenticated relay recovery records; never expose through renderer settings APIs. */ sshPtyConsumerRecoveries?: SshPtyConsumerRecovery[] + wslPtyConsumerRecoveries?: WslPtyRecoveryRecord[] /** Live local Claude daemon session ids; seeds the live-PTY gate so early OAuth refresh can't rotate the single-use refresh token out from under a running daemon. */ claudeLivePtySessionIds?: string[] migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] diff --git a/src/shared/terminal-process.ts b/src/shared/terminal-process.ts new file mode 100644 index 000000000000..3d290959b9ea --- /dev/null +++ b/src/shared/terminal-process.ts @@ -0,0 +1,17 @@ +export type TerminalProcessExit = { exitCode: number; signal?: number } + +/** The terminal engine contract consumed by the daemon and relay. */ +export type TerminalProcess = { + readonly pid: number + readonly cols: number + readonly rows: number + readonly process: string + onData(listener: (data: string) => void): { dispose(): void } + onExit(listener: (event: TerminalProcessExit) => void): { dispose(): void } + write(data: string | Buffer): void + resize(cols: number, rows: number): void + clear(): void + kill(signal?: string): void + pause(): void + resume(): void +} diff --git a/src/shared/terminal-unicode-provider.test.ts b/src/shared/terminal-unicode-provider.test.ts new file mode 100644 index 000000000000..34c683d45547 --- /dev/null +++ b/src/shared/terminal-unicode-provider.test.ts @@ -0,0 +1,65 @@ +import { Terminal } from '@xterm/headless' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import type { IUnicodeVersionProvider } from '@xterm/xterm' +import { afterEach, expect, it, vi } from 'vitest' +import { activateOrcaTerminalUnicodeProvider } from './terminal-unicode-provider' + +const terminals: Terminal[] = [] +afterEach(() => { + vi.unstubAllGlobals() + terminals.splice(0).forEach((terminal) => terminal.dispose()) +}) + +function provider(bun: boolean): IUnicodeVersionProvider { + vi.stubGlobal('process', { + ...process, + versions: { ...process.versions, bun: bun ? '1.4.2' : undefined } + }) + const terminal = new Terminal({ allowProposedApi: true }) + terminals.push(terminal) + terminal.loadAddon(new Unicode11Addon()) + const unicode = terminal.unicode + const register = vi.spyOn(unicode, 'register') + const unicodeCore: { + unicode: typeof unicode + _core?: { unicodeService?: { _providers?: Record } } + } = terminal + activateOrcaTerminalUnicodeProvider({ unicode, _core: unicodeCore._core }) + const registered = register.mock.calls[0]?.[0] + if (!registered) { + throw new Error('Unicode provider was not registered') + } + return registered +} + +it('preserves Unicode11 and ZWJ properties across the Bun ASCII specialization', () => { + const original = provider(false) + const optimized = provider(true) + const preceding = new Set([0, 1, 2, 3, 4, 5]) + for (const codepoint of [0x200d, 0x301, 0x1f469, 0x1f4bb, 0xac00, 0xfe0f]) { + // Snapshot the states because this loop extends the set. + for (const state of Array.from(preceding)) { + preceding.add(original.charProperties(codepoint, state)) + } + } + for (const state of preceding) { + for (let codepoint = 0; codepoint <= 0xff; codepoint++) { + expect(optimized.charProperties(codepoint, state)).toBe( + original.charProperties(codepoint, state) + ) + } + } + for (const text of ['a\u200db', '👩‍💻 ascii', '한글 abc', 'a\u0301b', '🏳️‍🌈 x']) { + let originalState = 0 + let optimizedState = 0 + for (const char of text) { + const codepoint = char.codePointAt(0) + if (codepoint === undefined) { + throw new Error('Missing codepoint') + } + originalState = original.charProperties(codepoint, originalState) + optimizedState = optimized.charProperties(codepoint, optimizedState) + expect(optimizedState).toBe(originalState) + } + } +}) diff --git a/src/shared/terminal-unicode-provider.ts b/src/shared/terminal-unicode-provider.ts index ecd0fe7e7823..4d4b12e856e3 100644 --- a/src/shared/terminal-unicode-provider.ts +++ b/src/shared/terminal-unicode-provider.ts @@ -52,6 +52,16 @@ class OrcaUnicodeProvider implements IUnicodeVersionProvider { } } +class BunOrcaUnicodeProvider extends OrcaUnicodeProvider { + public override charProperties(codepoint: number, preceding: number): number { + // Unicode11 printable ASCII after an ordinary width-one cell has unchanged properties. + if (preceding === 2 && codepoint >= 0x20 && codepoint < 0x7f) { + return 2 + } + return super.charProperties(codepoint, preceding) + } +} + export function activateOrcaTerminalUnicodeProvider(terminal: XtermTerminalWithUnicodeCore): void { const { unicode } = terminal if (unicode.activeVersion === ORCA_UNICODE_VERSION) { @@ -65,7 +75,12 @@ export function activateOrcaTerminalUnicodeProvider(terminal: XtermTerminalWithU } if (!unicode.versions.includes(ORCA_UNICODE_VERSION)) { - unicode.register(new OrcaUnicodeProvider(baseProvider)) + // Keep V8's measured fast path unchanged; JSC benefits from bypassing the ASCII call chain. + const Provider = + typeof process !== 'undefined' && process.versions?.bun + ? BunOrcaUnicodeProvider + : OrcaUnicodeProvider + unicode.register(new Provider(baseProvider)) } unicode.activeVersion = ORCA_UNICODE_VERSION } diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 55082bc24704..6c930e8f1358 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -135,7 +135,7 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands. draftPasteReadySignal: 'render-cursor-after-bracketed-paste', // Why 20s: measured on two Windows hosts (ConPTY dll backend, as pinned by - // local-pty-utils), opencode does not enable bracketed paste until ~4.8s and its + // pty-spawn-validation), opencode does not enable bracketed paste until ~4.8s and its // composer is not ready until ~10s — so the 8s default expired first and the draft // was pasted blind, mid-startup (#22479). The signal itself fired every time in // those runs, so the budget was the problem, not a dropped escape. diff --git a/src/shared/windows-conpty-release.ts b/src/shared/windows-conpty-release.ts new file mode 100644 index 000000000000..7e807cf21a35 --- /dev/null +++ b/src/shared/windows-conpty-release.ts @@ -0,0 +1,18 @@ +/** Microsoft Terminal v1.23.12811.0 assets, identical to the qualified ConPTY provider. */ +export const WINDOWS_CONPTY_VERSION = '1.23.251008001' +export const WINDOWS_CONPTY_ARCHIVE = { + url: 'https://github.com/microsoft/terminal/releases/download/v1.23.12811.0/Microsoft.Windows.Console.ConPTY.1.23.251008001.nupkg', + sha256: 'f7e142a99f5dfee573ace98676b3c7c2fcdb870bc90b238086a4d80a36fcf099', + nuspecSha256: '845e41cf307cf71a59c25a7555fa05a91087930048f8a7918dc614f1255241af' +} as const + +export const WINDOWS_CONPTY_FILES = { + x64: { + 'conpty.dll': '7c7430632052ff703540b68371ec43821820aa1335d8e11dfbcd9ff00e9daaed', + 'OpenConsole.exe': 'd1fe7faa62f9e955e2ac2371f95d7e5513df4d496255097158f979c94782c5fc' + }, + arm64: { + 'conpty.dll': 'b6cca5f1081111f59e5255eec043d924ebd11a86d09986c0d121b680540b3380', + 'OpenConsole.exe': 'b7763ba27e80d5e84f9e60d1368ec2ddb25dfd10283336c0c9f3f252d35cb2f8' + } +} as const diff --git a/src/shared/wsl-daemon-recovery.ts b/src/shared/wsl-daemon-recovery.ts new file mode 100644 index 000000000000..d3bb62d3077e --- /dev/null +++ b/src/shared/wsl-daemon-recovery.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' + +const identity = z + .string() + .min(1) + .max(512) + .refine((value) => value.trim() === value && !/[\0\r\n]/.test(value)) +const guestPath = identity.refine( + (value) => + value.startsWith('/') && + !value.includes('\\') && + !value + .split('/') + .slice(1) + .some((part) => part === '.' || part === '..' || part === '') +) +const endpoint = z + .object({ + distro: identity, + distributionId: identity, + userName: identity, + userId: z.string().regex(/^\d+$/), + home: guestPath, + runtime: guestPath, + entry: guestPath, + envBinary: guestPath, + socket: guestPath, + tokenPath: guestPath, + serverBuildId: identity + }) + .strict() +const recovery = z + .object({ + kind: z.literal('daemon'), + distro: identity, + relayBuildId: identity, + endpoint + }) + .strict() + .refine((value) => value.distro === value.endpoint.distro) + +export type WslDaemonRecovery = z.infer +export type PersistedWslDaemonEndpoint = z.infer + +/** Only endpoint identity is persisted; authentication remains in the guest-private token file. */ +export function normalizeWslDaemonRecovery(value: unknown): WslDaemonRecovery | null { + const parsed = recovery.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index fd0f0b505c98..2cc131fc3565 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -14,9 +14,15 @@ export function quotePosixShell(value: string): string { */ export function buildWslExecArgs( distro: string | undefined, - shellArgs: readonly string[] + shellArgs: readonly string[], + user?: string ): string[] { - return [...(distro ? ['-d', distro] : []), '--exec', ...shellArgs] + return [ + ...(distro ? ['-d', distro] : []), + ...(user ? ['--user', user] : []), + '--exec', + ...shellArgs + ] } export function buildWslLoginShellCommand(command: string): string { diff --git a/src/shared/wsl-pty-consumer-recovery.ts b/src/shared/wsl-pty-consumer-recovery.ts new file mode 100644 index 000000000000..c06609c1335b --- /dev/null +++ b/src/shared/wsl-pty-consumer-recovery.ts @@ -0,0 +1,67 @@ +import type { WslDaemonRecovery } from './wsl-daemon-recovery' +import { z } from 'zod' +import type { WslPtyOwner } from './wsl-pty-id' + +const identity = z + .string() + .min(1) + .max(512) + .refine((value) => !/[\0\r\n]/.test(value)) +const guestPath = identity.refine((value) => value.startsWith('/')) +const endpointSchema = z.object({ + distro: identity, + distributionId: identity, + userId: z.string().regex(/^\d+$/), + userName: identity, + serverBuildId: identity, + envBinary: guestPath, + runtime: guestPath, + entry: guestPath, + socket: guestPath, + credentialFile: guestPath +}) + +const recoverySchema = z.object({ + distro: identity, + relayBuildId: identity, + clientInstanceId: identity, + clientGeneration: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + ownerGeneration: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + ownerLease: z.string().min(1).max(4096), + endpoint: endpointSchema.optional(), + outputFlowControl: z + .object({ + version: z.literal(1), + windowSu: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) + }) + .optional() +}) + +export type PersistedWslRelayEndpoint = z.infer + +export function normalizePersistedWslRelayEndpoint( + value: unknown +): PersistedWslRelayEndpoint | null { + const parsed = endpointSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +export type WslPtyConsumerRecovery = z.infer + +export function normalizeWslPtyConsumerRecovery( + value: unknown, + encrypted = false +): WslPtyConsumerRecovery | null { + const parsed = recoverySchema.safeParse(value) + return parsed.success && (encrypted || parsed.data.ownerLease.length <= 512) ? parsed.data : null +} + +export function wslPtyOwnerKey(owner: WslPtyOwner): string { + return `${encodeURIComponent(owner.distro)}@@${encodeURIComponent(owner.relayBuildId)}` +} + +export type WslPtyRecoveryRecord = WslPtyConsumerRecovery | WslDaemonRecovery + +export function isWslDaemonRecovery(record: WslPtyRecoveryRecord): record is WslDaemonRecovery { + return 'kind' in record && record.kind === 'daemon' +} diff --git a/src/shared/wsl-pty-id.test.ts b/src/shared/wsl-pty-id.test.ts new file mode 100644 index 000000000000..bf9131698ab8 --- /dev/null +++ b/src/shared/wsl-pty-id.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { parseAppWslPtyId, toAppWslPtyId, toRelayWslPtyId } from './wsl-pty-id' +import { parseAppSshPtyId } from './ssh-pty-id' + +const owner = { distro: 'Ubuntu @ home', relayBuildId: '0.1.0+abc' } + +describe('WSL terminal owner identity', () => { + it('round trips distro, exact build and opaque guest terminal without an SSH identity', () => { + const id = toAppWslPtyId(owner, 'pty2:epoch:1') + expect(parseAppWslPtyId(id)).toEqual({ ...owner, relayPtyId: 'pty2:epoch:1' }) + expect(toRelayWslPtyId(owner, id)).toBe('pty2:epoch:1') + expect(toAppWslPtyId(owner, id)).toBe(id) + expect(parseAppSshPtyId(id)).toBeNull() + }) + + it('fences both a different distro and a replaced relay build', () => { + const id = toAppWslPtyId(owner, 'pty2:epoch:1') + for (const other of [ + { ...owner, distro: 'Debian' }, + { ...owner, relayBuildId: 'new-build' } + ]) { + expect(() => toRelayWslPtyId(other, id)).toThrow('different distro or relay build') + expect(() => toAppWslPtyId(other, id)).toThrow('different distro or relay build') + } + }) + + it.each([ + 'daemon:legacy', + 'ssh:host@@pty-1', + 'wsl:broken', + 'wsl:%XX@@build@@id', + 'wsl:Ubuntu@@build@@' + ])('never interprets foreign or malformed IDs as guest-owned: %s', (id) => { + expect(parseAppWslPtyId(id)).toBeNull() + expect(() => toRelayWslPtyId(owner, id)).toThrow() + }) + + it('encodes separators inside owner names rather than changing the owner', () => { + const special = { distro: 'Ubuntu@@other', relayBuildId: 'build@@other' } + expect(parseAppWslPtyId(toAppWslPtyId(special, 'pty@@opaque'))).toEqual({ + ...special, + relayPtyId: 'pty@@opaque' + }) + }) +}) diff --git a/src/shared/wsl-pty-id.ts b/src/shared/wsl-pty-id.ts new file mode 100644 index 000000000000..e407f2b425d6 --- /dev/null +++ b/src/shared/wsl-pty-id.ts @@ -0,0 +1,64 @@ +const PREFIX = 'wsl:' +const SEPARATOR = '@@' + +export type WslPtyOwner = { + distro: string + relayBuildId: string +} + +export type ParsedWslPtyId = WslPtyOwner & { relayPtyId: string } + +function validPart(value: string): boolean { + return value.trim().length > 0 && !/[\0\r\n]/.test(value) +} + +export function parseAppWslPtyId(id: string): ParsedWslPtyId | null { + if (!id.startsWith(PREFIX)) { + return null + } + const first = id.indexOf(SEPARATOR, PREFIX.length) + const second = id.indexOf(SEPARATOR, first + SEPARATOR.length) + if (first === -1 || second === -1) { + return null + } + try { + const distro = decodeURIComponent(id.slice(PREFIX.length, first)) + const relayBuildId = decodeURIComponent(id.slice(first + SEPARATOR.length, second)) + const relayPtyId = id.slice(second + SEPARATOR.length) + return validPart(distro) && validPart(relayBuildId) && validPart(relayPtyId) + ? { distro, relayBuildId, relayPtyId } + : null + } catch { + return null + } +} + +function assertOwner(owner: WslPtyOwner, parsed: ParsedWslPtyId): void { + if (parsed.distro !== owner.distro || parsed.relayBuildId !== owner.relayBuildId) { + throw new Error('WSL terminal belongs to a different distro or relay build') + } +} + +export function toAppWslPtyId(owner: WslPtyOwner, relayPtyId: string): string { + if (!validPart(owner.distro) || !validPart(owner.relayBuildId) || !validPart(relayPtyId)) { + throw new Error('Invalid WSL terminal identity') + } + const parsed = parseAppWslPtyId(relayPtyId) + if (parsed) { + assertOwner(owner, parsed) + return relayPtyId + } + if (relayPtyId.startsWith(PREFIX) || relayPtyId.startsWith('ssh:')) { + throw new Error('Expected a guest-local terminal identity') + } + return `${PREFIX}${encodeURIComponent(owner.distro)}${SEPARATOR}${encodeURIComponent(owner.relayBuildId)}${SEPARATOR}${relayPtyId}` +} + +export function toRelayWslPtyId(owner: WslPtyOwner, id: string): string { + const parsed = parseAppWslPtyId(id) + if (!parsed) { + throw new Error('Expected a WSL-owned terminal identity') + } + assertOwner(owner, parsed) + return parsed.relayPtyId +} diff --git a/tests/e2e/capture-opencode-tui-repro.mjs b/tests/e2e/capture-opencode-tui-repro.mjs index fb4b3969209f..aad4036a4cf3 100644 --- a/tests/e2e/capture-opencode-tui-repro.mjs +++ b/tests/e2e/capture-opencode-tui-repro.mjs @@ -4,15 +4,20 @@ // node tests/e2e/capture-opencode-tui-repro.mjs // Then run the captured replay test documented in terminal-foreground-redraw-freeze.spec.ts. -import { createRequire } from 'node:module' import { createWriteStream, existsSync, mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' -const require = createRequire(import.meta.url) -const pty = require('node-pty') +import { + relaunchTranscriptWithBun, + spawnTranscriptPty +} from '../../config/scripts/pty-transcript-bun-runtime.mjs' const scriptDir = import.meta.dirname const repoRoot = path.resolve(scriptDir, '..', '..') +const relaunched = await relaunchTranscriptWithBun(repoRoot, process.argv.slice(2), process.argv[1]) +if (relaunched !== null) { + process.exit(relaunched) +} function readOption(name, fallback) { const index = process.argv.indexOf(name) @@ -121,22 +126,21 @@ mkdirSync(path.dirname(outputPath), { recursive: true }) writeFileSync(harnessPath, harnessSource) const out = createWriteStream(outputPath) -const command = process.platform === 'win32' ? 'bun.exe' : 'bun' -const child = pty.spawn(command, ['run', './orca-opencode-tui-repro.tsx'], { - cwd: opencodePackagePath, - cols: 120, - rows: 40, - name: 'xterm-256color', - env: { - ...process.env, - FORCE_COLOR: '1', - TERM: 'xterm-256color' - } -}) - -child.onData((data) => { - out.write(data) -}) +const child = await spawnTranscriptPty( + { + file: process.execPath, + args: ['run', './orca-opencode-tui-repro.tsx'], + cwd: opencodePackagePath, + cols: 120, + rows: 40, + env: { + ...process.env, + FORCE_COLOR: '1', + TERM: 'xterm-256color' + } + }, + (bytes) => out.write(bytes) +) child.onExit(({ exitCode }) => { out.end(() => { diff --git a/tests/e2e/daemon-generation-bun-runtime.unit.test.ts b/tests/e2e/daemon-generation-bun-runtime.unit.test.ts new file mode 100644 index 000000000000..4237a85ce6ce --- /dev/null +++ b/tests/e2e/daemon-generation-bun-runtime.unit.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from 'vitest' +import { join } from 'node:path' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { + createDaemonGenerationRuntime, + launchDaemonGeneration, + spawnGenerationCanary, + pingGenerationCanary, + cleanupDaemonGenerationFixtures, + type DaemonGeneration, + type GenerationCanary +} from './helpers/daemon-generation-safety-fixtures' +import { processIdentityIsAlive } from './helpers/daemon-generation-processes' + +it.skipIf(process.env.ORCA_DAEMON_GENERATION_SMOKE !== '1')( + 'runs current and legacy-protocol real PTY canaries under verified Bun without Electron UI', + async () => { + const runtime = await createDaemonGenerationRuntime({ + outputDir: join(process.cwd(), '.build', 'daemon-generation-bun-smoke'), + outputPath: (...parts: string[]) => + join(process.cwd(), '.build', 'daemon-generation-bun-smoke', ...parts) + }) + const generations: DaemonGeneration[] = [] + const canaries: GenerationCanary[] = [] + try { + for (const protocolVersion of [PROTOCOL_VERSION, PROTOCOL_VERSION - 1]) { + const generation = await launchDaemonGeneration({ + runtime, + label: `protocol-${protocolVersion}`, + protocolVersion + }) + generations.push(generation) + const canary = await spawnGenerationCanary({ runtime, generation, role: 'live' }) + canaries.push(canary) + await pingGenerationCanary(canary, 'first') + } + for (const canary of canaries) { + expect(await processIdentityIsAlive(canary.rootIdentity)).toBe(true) + expect(await processIdentityIsAlive(canary.descendantIdentity)).toBe(true) + await pingGenerationCanary(canary, 'still-live') + } + } finally { + runtime.retainDiagnostics(generations) + await cleanupDaemonGenerationFixtures({ generations, canaries }) + runtime.remove() + } + }, + 90_000 +) diff --git a/tests/e2e/fixtures/daemon-generation-entry.ts b/tests/e2e/fixtures/daemon-generation-entry.ts index c3899e6f6ba3..f41451103831 100644 --- a/tests/e2e/fixtures/daemon-generation-entry.ts +++ b/tests/e2e/fixtures/daemon-generation-entry.ts @@ -1,4 +1,5 @@ import process from 'node:process' +import { ORCAD_BUN_VERSION } from '../../../src/shared/orcad-bun-runtime' import { startDaemon, type DaemonHandle } from '../../../src/main/daemon/daemon-main' import { createPtySubprocess } from '../../../src/main/daemon/pty-subprocess' import { createDaemonFileLog } from '../../../src/main/daemon/daemon-file-log' @@ -42,6 +43,9 @@ function parseFixtureArgs(argv: string[]): FixtureArgs { } async function main(): Promise { + if (process.versions.bun !== ORCAD_BUN_VERSION) { + throw new Error('Daemon-generation PTY fixtures require the pinned Bun runtime') + } const { protocolVersion, socketPath, tokenPath, logPath, refuseDispose } = parseFixtureArgs( process.argv.slice(2) ) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index cd5d9b3c4e0b..91b42a9861a4 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -10,7 +10,7 @@ * temp file so the worker fixture can pick it up at runtime. */ -import { execSync } from 'node:child_process' +import { execFileSync, execSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import path from 'node:path' @@ -63,6 +63,37 @@ export default function globalSetup(): void { }) console.error('[e2e] CLI build complete.') } + const terminalEntry = path.join(root, 'out', 'terminal-daemon', 'daemon-entry.js') + const terminalGate = path.join(root, 'out', 'terminal-daemon', 'windows-bun-pty-gate-entry.js') + const runtimeDirectory = path.join( + root, + 'out', + 'cli-runtime', + `${process.platform}-${process.arch}` + ) + const runtimeManifest = path.join(runtimeDirectory, 'runtime.json') + const runtimeBinary = path.join( + runtimeDirectory, + process.platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime' + ) + if (!existsSync(runtimeManifest) || !existsSync(runtimeBinary)) { + execFileSync( + process.execPath, + ['config/scripts/build-cli-runtime.mjs', '--arch', process.arch], + { + cwd: root, + stdio: 'inherit', + timeout: CLI_E2E_BUILD_TIMEOUT_MS + } + ) + } + if (!process.env.SKIP_BUILD || !existsSync(terminalEntry) || !existsSync(terminalGate)) { + execSync('node config/scripts/build-terminal-daemon.mjs', { + cwd: root, + stdio: 'inherit', + timeout: CLI_E2E_BUILD_TIMEOUT_MS + }) + } if (process.env.ORCA_E2E_WEB_CLIENT === '1') { if (process.env.SKIP_BUILD && existsSync(outWeb)) { console.error('[e2e] SKIP_BUILD set and web client exists — skipping web build') diff --git a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts index 88111024368d..ca9aecc2d24c 100644 --- a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts +++ b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts @@ -2,6 +2,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { homedir, tmpdir } from 'node:os' import path from 'node:path' import { build } from 'esbuild' +import { + resolveDaemonBunRuntime, + type DaemonBunRuntime +} from '../../../src/main/daemon/daemon-bun-runtime' import type { TestInfo } from '@playwright/test' const TEMP_PREFIX = 'orca-9749-dg-' @@ -24,6 +28,7 @@ export type DaemonGenerationRuntime = { legacyCloseClientEntryPath: string canaryPath: string electronPath: string + bunRuntime: DaemonBunRuntime retainDiagnostics(generations: readonly DiagnosticGeneration[]): void remove(): void } @@ -91,13 +96,13 @@ async function buildFixtureEntry(entryPoint: string, outfile: string): Promise ): Promise { const tempRoot = process.platform === 'darwin' ? path.join(path.sep, 'tmp') : tmpdir() const rootDir = mkdtempSync(path.join(tempRoot, TEMP_PREFIX)) @@ -105,7 +110,7 @@ export async function createDaemonGenerationRuntime( const userDataDir = path.join(rootDir, 'user-data') const daemonDir = path.join(userDataDir, 'daemon') mkdirSync(daemonDir, { recursive: true }) - const entryPath = path.join(rootDir, 'daemon-generation-entry.cjs') + const entryPath = path.join(rootDir, 'daemon-entry.js') const reconnectClientEntryPath = path.join(rootDir, 'daemon-generation-reconnect-client.cjs') const legacyCloseClientEntryPath = path.join(rootDir, 'daemon-generation-legacy-close-client.cjs') const repoRoot = process.cwd() @@ -121,7 +126,28 @@ export async function createDaemonGenerationRuntime( path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-legacy-close-client.ts'), legacyCloseClientEntryPath ) + await buildFixtureEntry( + path.join(repoRoot, 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts'), + path.join(rootDir, 'windows-bun-pty-gate-entry.js') + ) + const bunRuntime = await resolveDaemonBunRuntime({ + bundleDir: rootDir, + runtimeDir: path.join(repoRoot, 'out', 'cli-runtime', `${process.platform}-${process.arch}`), + platform: process.platform, + arch: process.arch, + ...(process.platform === 'win32' + ? { + windowsProcessTreeDir: path.join( + repoRoot, + 'node_modules', + '@vscode', + 'windows-process-tree' + ) + } + : {}) + }) return { + bunRuntime, rootDir, userDataDir, daemonDir, diff --git a/tests/e2e/helpers/daemon-generation-safety-fixtures.ts b/tests/e2e/helpers/daemon-generation-safety-fixtures.ts index 3bbaa94ed571..c168ef0a4786 100644 --- a/tests/e2e/helpers/daemon-generation-safety-fixtures.ts +++ b/tests/e2e/helpers/daemon-generation-safety-fixtures.ts @@ -73,6 +73,26 @@ export async function launchDaemonGeneration(options: { const tokenPath = getDaemonTokenPath(runtime.daemonDir, protocolVersion) const logPath = path.join(runtime.rootDir, `${label}.daemon.log`) let startupLog = '' + const env: NodeJS.ProcessEnv = { + ...process.env, + NODE_PATH: path.join(process.cwd(), 'node_modules'), + ORCA_USER_DATA_PATH: runtime.userDataDir, + ORCA_BACKGROUND_LAUNCH: '1' + } + for (const key of [ + 'ELECTRON_RUN_AS_NODE', + 'NODE_OPTIONS', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'BUN_INSPECT_BRK', + 'BUN_INSPECT_WAIT', + 'BUN_CONPTY_LIBRARY' + ]) { + delete env[key] + } + if (runtime.bunRuntime.conptyLibraryPath) { + env.BUN_CONPTY_LIBRARY = runtime.bunRuntime.conptyLibraryPath + } const child = fork( runtime.entryPath, [ @@ -89,14 +109,10 @@ export async function launchDaemonGeneration(options: { ], { cwd: runtime.userDataDir, - execPath: runtime.electronPath, + execPath: runtime.bunRuntime.execPath, + execArgv: ['--no-env-file'], windowsHide: true, - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - NODE_PATH: path.join(process.cwd(), 'node_modules'), - ORCA_USER_DATA_PATH: runtime.userDataDir - }, + env, stdio: ['ignore', 'ignore', 'pipe', 'ipc'] } ) diff --git a/tests/e2e/pty-input-write-queue-ssh.spec.ts b/tests/e2e/pty-input-write-queue-ssh.spec.ts index 08a18d1a2287..251ff835aeaf 100644 --- a/tests/e2e/pty-input-write-queue-ssh.spec.ts +++ b/tests/e2e/pty-input-write-queue-ssh.spec.ts @@ -1,6 +1,6 @@ import { mkdirSync } from 'node:fs' import path from 'node:path' -import { expect, test } from './helpers/orca-app' +import { test } from './helpers/orca-app' import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' import { cleanupDockerSshRelayTarget, @@ -100,7 +100,7 @@ test.describe('PTY input write queue over SSH', () => { } }) - test('keeps fish query replies out of the next child stdin on an upstream relay pty', async ({ + test('keeps fish query replies out of the next child stdin on a relay terminal', async ({ orcaPage }, testInfo) => { test.slow() @@ -147,13 +147,6 @@ test.describe('PTY input write queue over SSH', () => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await connectDockerSshRelayTarget(orcaPage, target) - const relayExports = execDockerSshRelayTargetCommand( - target, - 'module=$(find /root/.orca-remote -type d -path \'*/node_modules/node-pty\' | head -n 1); node -e "const p=require(process.argv[1]); console.log(Object.keys(p.native || {}).join(\',\'))" "$module"' - ) - testInfo.annotations.push({ type: 'relay-node-pty-exports', description: relayExports }) - expect(relayExports).not.toContain('echoState') - await ensureTerminalVisible(orcaPage, 45_000) await waitForActiveTerminalManager(orcaPage, 60_000) const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) diff --git a/tests/e2e/terminal-foreground-confirmation.unit.test.ts b/tests/e2e/terminal-foreground-confirmation.unit.test.ts index 04873836a341..9088cb0045e8 100644 --- a/tests/e2e/terminal-foreground-confirmation.unit.test.ts +++ b/tests/e2e/terminal-foreground-confirmation.unit.test.ts @@ -5,8 +5,14 @@ const { spawnMock, resolveForegroundMock } = vi.hoisted(() => ({ resolveForegroundMock: vi.fn() })) -vi.mock('node-pty', () => ({ spawn: spawnMock })) -vi.mock('../../src/main/pwsh', () => ({ isPwshAvailable: vi.fn(() => false) })) +vi.mock('../../src/main/daemon/pty-subprocess/bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: spawnMock +})) +vi.mock('../../src/main/pwsh', () => ({ + isPwshAvailable: vi.fn(() => false), + isPwshAvailableAsync: vi.fn(async () => false) +})) vi.mock('../../src/main/providers/windows-powershell-executable', () => ({ resolveWindowsPowerShellExecutablePath: () => 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', diff --git a/tests/tools/daemon-relocation-spike/README.md b/tests/tools/daemon-relocation-spike/README.md index 0cf861c1b453..a09ccad8b00c 100644 --- a/tests/tools/daemon-relocation-spike/README.md +++ b/tests/tools/daemon-relocation-spike/README.md @@ -1,5 +1,11 @@ # daemon-relocation-spike +> Historical probe for packaged Electron/node-pty daemon hosts. Current Bun packages +> do not contain its required files. Use this only with an archived compatible build; +> current runtime relocation is covered by `daemon-bun-runtime` tests and the +> Windows packaged Bun capability smoke. + + A throwaway probe that answers one empirical question for Phase 1 of the Windows update-survival work: diff --git a/tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs b/tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs index 1b2cbcae38c7..eaceb19bde3c 100644 --- a/tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs +++ b/tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node /** - * Ground-truth repro harness for issue #9993: color-scheme (CSI ?997;1n) replies + * Historical node-pty-only repro for issue #9993: color-scheme (CSI ?997;1n) replies * leaking into a child process' stdin under fish. * - * Simulates Orca's terminal pipeline around a REAL fish shell: + * Requires the pre-Bun checkout and its patched node-pty dependency. + * Current Bun coverage lives in fish-query-reply-child-stdin.bun.test.ts. + * Simulates the historical terminal pipeline around a REAL fish shell: * node-pty -> scanMode2031ReplyDecision() (the real scanner from * src/shared/terminal-color-scheme-protocol.ts) * -> reply `CSI ?997;1n` written back to the PTY, optionally after diff --git a/tests/tools/omp-close-lifecycle-bun-fixture.mjs b/tests/tools/omp-close-lifecycle-bun-fixture.mjs new file mode 100644 index 000000000000..4955daac51ee --- /dev/null +++ b/tests/tools/omp-close-lifecycle-bun-fixture.mjs @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict' +import { spawnBunPty } from '../../src/main/daemon/pty-subprocess/bun-pty-process.ts' +import { Session } from '../../src/main/daemon/session.ts' +import { TerminalSessionTeardown } from '../../src/main/daemon/terminal-session-teardown.ts' +import { createDaemonPtySubprocessHandle } from '../../src/main/daemon/pty-subprocess/subprocess-handle.ts' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { runProcess } from '../../src/shared/child-process/run-process.ts' +import { + captureDescendantSnapshot, + readProcessTable +} from '../../src/main/pty-descendant-termination.ts' +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const quote = (value) => `'${value.replaceAll("'", "'\\''")}'` +const ownedPidRows = async (pids) => { + const result = await runProcess({ + program: 'ps', + args: ['-p', pids.join(','), '-o', 'pid=,ppid=,pgid=,stat=,args='], + maxOutputBytes: 16000 + }) + assert.equal(result.timedOut, false) + assert.equal(result.signal, null) + assert.equal(result.stderr.trim(), '') + assert.ok([0, 1].includes(result.code)) + if (result.code === 1) { + assert.equal(result.stdout.trim(), '') + } + return result.stdout.trim() +} +export async function runOmpCloseProbe({ binary, externalTool, shell: requestedShell }) { + const fixtures = join(process.cwd(), '.bench-fixtures') + mkdirSync(fixtures, { recursive: true }) + const output = mkdtempSync(join(fixtures, 'omp-close-')) + const report = [] + for (const launch of ['recognized', 'typed']) { + const close = 'explicit' + const home = mkdtempSync(join(tmpdir(), 'orca-omp-close-home-')) + const agentHome = join(home, 'agent') + mkdirSync(agentHome) + const config = join(home, 'probe.yml') + writeFileSync( + config, + 'startup:\n setupWizard: false\n showSplash: false\n checkUpdate: false\n' + ) + const id = `${launch}-${close}` + let transcript = '' + let nativeExit = null + const shell = requestedShell ?? (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash') + const shellArgs = shell.endsWith('zsh') ? ['-f', '-i'] : ['--noprofile', '--norc', '-i'] + const proc = spawnBunPty({ + file: shell, + args: shellArgs, + cols: 120, + rows: 35, + cwd: home, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + ZDOTDIR: home, + XDG_CONFIG_HOME: join(home, 'config'), + XDG_DATA_HOME: join(home, 'data'), + XDG_CACHE_HOME: join(home, 'cache'), + XDG_STATE_HOME: join(home, 'state'), + OMP_CODING_AGENT_DIR: agentHome, + PI_CODING_AGENT_DIR: agentHome, + OMP_PROFILE: '', + PI_PROFILE: '', + PI_CONFIG_DIR: '.omp', + PI_CONFIG_FILES: '', + ORCA_BACKGROUND_LAUNCH: '1' + } + }) + const daemonSession = new Session({ + sessionId: id, + cols: 120, + rows: 35, + shellReadySupported: false, + ...(launch === 'recognized' ? { launchAgent: 'omp' } : {}), + subprocess: createDaemonPtySubprocessHandle({ + process: proc, + shellPath: shell, + spawnCwd: home, + env: process.env, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: id, + startupAgentRecognition: null + }) + }) + proc.onData((data) => { + transcript = (transcript + data).slice(-131072) + }) + const exitSubscription = proc.onExit((event) => { + nativeExit = event + }) + let snapshot + try { + proc.write(`${quote(binary)} --no-session --config ${quote(config)}\r`) + await delay(5000) + snapshot = await captureDescendantSnapshot(proc.pid) + assert.ok(snapshot?.descendants.length > 0) + if (externalTool) { + proc.write('! /bin/sleep 120\r') + for (let attempt = 0; attempt < 25; attempt++) { + await delay(200) + snapshot = await captureDescendantSnapshot(proc.pid) + if (snapshot?.descendants.length > 1) { + break + } + } + assert.ok(snapshot?.descendants.length > 1) + } + const pids = [proc.pid, ...snapshot.descendants.map((row) => row.pid)] + const before = await ownedPidRows(pids) + assert.ok(before.includes('omp'), `Expected OMP process: ${before}`) + if (externalTool) { + assert.ok(before.includes('sleep')) + } + const started = Date.now() + let closeError = null + try { + await new TerminalSessionTeardown(new Map([[id, daemonSession]])).killSession( + id, + daemonSession, + true + ) + } catch (error) { + closeError = String(error) + } + await delay(6000) + const after = await ownedPidRows(pids) + report.push({ + launch, + close, + externalTool, + backend: 'daemon', + before, + after, + nativeExit, + tracked: daemonSession.isAlive, + elapsedMs: Date.now() - started, + closeError, + home + }) + writeFileSync(join(output, `${id}.txt`), transcript) + writeFileSync(join(output, 'report.json'), JSON.stringify(report, null, 2)) + assert.equal(closeError, null) + assert.equal(after, '') + } finally { + writeFileSync(join(output, `${id}.txt`), transcript) + if (snapshot) { + const current = await readProcessTable() + const owned = [ + ...snapshot.descendants, + ...(snapshot.root ? [{ ...snapshot.root, pgid: snapshot.rootPgid }] : []) + ] + for (const row of current.rows) { + if ( + owned.some( + (known) => + known.pid === row.pid && + known.startedAt === row.startedAt && + known.pgid === row.pgid + ) + ) { + try { + process.kill(row.pid, 'SIGKILL') + } catch {} + } + } + } + daemonSession?.dispose() + exitSubscription.dispose() + rmSync(home, { recursive: true, force: true }) + } + } + writeFileSync(join(output, 'report.json'), JSON.stringify(report, null, 2)) + return output +} diff --git a/tests/tools/omp-close-lifecycle.md b/tests/tools/omp-close-lifecycle.md index 03e2dd6fc31d..87a912ebd85c 100644 --- a/tests/tools/omp-close-lifecycle.md +++ b/tests/tools/omp-close-lifecycle.md @@ -1,12 +1,10 @@ # OMP owned-PTY close probe (#9530) -This opt-in probe launches an actual installed OMP binary in disposable local PTYs -and calls Orca's production `shutdownLocalPty` and `killAllLocalPtys` functions, -or daemon `Session`, native subprocess handle, and `TerminalSessionTeardown`. -It sets the same agent-session ownership flag that `activateLocalPtySession` sets -for `launchAgent` / recognized startup commands, then repeats without that flag -to represent OMP typed into a shell. This isolates termination policy; it does not -exercise Agent button delivery or terminal-tab/handle routing. +This opt-in probe launches an actual installed OMP binary through the bundled Bun +PTY adapter and calls Orca's production daemon `Session`, subprocess handle, and +`TerminalSessionTeardown`. It repeats recognized-agent and typed-in-shell launches. +This isolates termination policy; it does not exercise Agent button delivery or +terminal-tab/handle routing. The retired in-process provider is no longer tested. ```sh ORCA_BACKGROUND_LAUNCH=1 ORCA_OMP_PROBE_BINARY=/absolute/path/to/omp \ @@ -16,11 +14,11 @@ ORCA_BACKGROUND_LAUNCH=1 ORCA_OMP_PROBE_BINARY=/absolute/path/to/omp \ The probe defaults to zsh on macOS and bash on other POSIX hosts. Set `ORCA_OMP_PROBE_SHELL` to the absolute path of either shell to override. Windows -is skipped. It requires the existing node-pty native dependency for the current -Node runtime. The normal unit suite skips the test unless a binary is supplied. +is skipped. It requires the pinned bundled Bun runtime in `out/orcad`, +`out/cli-runtime`, or `BUN_EXECUTABLE`. The normal unit suite skips the test unless a binary is supplied. Each case waits five seconds for OMP startup, captures the owned process tree, -requests explicit close or local quit cleanup, and verifies those exact process +requests explicit daemon close, and verifies those exact process IDs are absent using host `ps` after a six-second observation window. It records raw terminal output and before/after process rows in `.bench-fixtures/omp-close-*`. The fixture contains no prompt or model request. It disables the first-run setup @@ -47,9 +45,8 @@ No stale foreground OMP was reproduced in these local termination-policy cases. ## Detached external tool reproduction and correction Set `ORCA_OMP_PROBE_EXTERNAL_TOOL=1` to run `! /bin/sleep 120` in OMP before -explicit immediate close. Add `ORCA_OMP_PROBE_BACKEND=daemon` to exercise the daemon -backend. Each mode tests both recognized and typed launches; these modes do not -run the local-quit cases. The probe makes no model requests. Both OMP/PI profiles +explicit immediate close. Both recognized and typed launches use the daemon +backend; `ORCA_OMP_PROBE_BACKEND` is no longer needed. The probe makes no model requests. Both OMP/PI profiles are cleared, and XDG data/cache/state roots are isolated alongside configuration. On macOS, installed Orca `1.4.202-hourly.202609132311` and OMP `18.1.18`, an actual diff --git a/tests/tools/omp-close-lifecycle.test.mjs b/tests/tools/omp-close-lifecycle.test.mjs index 47df8325c563..a19aa22faa86 100644 --- a/tests/tools/omp-close-lifecycle.test.mjs +++ b/tests/tools/omp-close-lifecycle.test.mjs @@ -1,224 +1,24 @@ import { it, expect } from 'vitest' -import * as pty from 'node-pty' -import { Session } from '../../src/main/daemon/session.ts' -import { TerminalSessionTeardown } from '../../src/main/daemon/terminal-session-teardown.ts' -import { createDaemonPtySubprocessHandle } from '../../src/main/daemon/pty-subprocess/subprocess-handle.ts' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { runProcess } from '../../src/shared/child-process/run-process.ts' -import { - captureDescendantSnapshot, - readProcessTable -} from '../../src/main/pty-descendant-termination.ts' -import { - createPtyPhysicalExit, - shutdownLocalPty, - killAllLocalPtys -} from '../../src/main/providers/local-pty-termination.ts' -import { - ptyProcesses, - ptyAgentSessionIds, - ptyPhysicalExits, - ptyExitDisposables, - clearPtyState -} from '../../src/main/providers/local-pty-provider-state.ts' +import { runBundledBunFixture } from '../../src/main/bundled-bun-test-execution.ts' const binary = process.env.ORCA_OMP_PROBE_BINARY -const externalTool = process.env.ORCA_OMP_PROBE_EXTERNAL_TOOL === '1' -const daemonBackend = process.env.ORCA_OMP_PROBE_BACKEND === 'daemon' -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) -const quote = (value) => `'${value.replaceAll("'", "'\\''")}'` -const ownedPidRows = async (pids) => { - const result = await runProcess({ - program: 'ps', - args: ['-p', pids.join(','), '-o', 'pid=,ppid=,pgid=,stat=,comm='], - maxOutputBytes: 16000 - }) - expect(result.timedOut).toBe(false) - expect(result.signal).toBeNull() - expect(result.stderr.trim()).toBe('') - expect([0, 1]).toContain(result.code) - if (result.code === 1) { - expect(result.stdout.trim()).toBe('') - } - return result.stdout.trim() -} + it.skipIf(!binary || process.platform === 'win32')( 'observes actual OMP under production owned-PTY closure policy', async () => { - const fixtures = join(process.cwd(), '.bench-fixtures') - mkdirSync(fixtures, { recursive: true }) - const output = mkdtempSync(join(fixtures, 'omp-close-')) - const report = [] - for (const launch of ['recognized', 'typed']) { - for (const close of externalTool || daemonBackend ? ['explicit'] : ['explicit', 'quit']) { - expect(ptyProcesses.size).toBe(0) - const home = mkdtempSync(join(tmpdir(), 'orca-omp-close-home-')) - const agentHome = join(home, 'agent') - mkdirSync(agentHome) - const config = join(home, 'probe.yml') - writeFileSync( - config, - 'startup:\n setupWizard: false\n showSplash: false\n checkUpdate: false\n' - ) - const id = `${launch}-${close}` - let transcript = '' - let nativeExit = null - const shell = - process.env.ORCA_OMP_PROBE_SHELL ?? - (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash') - const shellArgs = shell.endsWith('zsh') ? ['-f', '-i'] : ['--noprofile', '--norc', '-i'] - const proc = pty.spawn(shell, shellArgs, { - name: 'xterm-256color', - cols: 120, - rows: 35, - cwd: home, - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - ZDOTDIR: home, - XDG_CONFIG_HOME: join(home, 'config'), - XDG_DATA_HOME: join(home, 'data'), - XDG_CACHE_HOME: join(home, 'cache'), - XDG_STATE_HOME: join(home, 'state'), - OMP_CODING_AGENT_DIR: agentHome, - PI_CODING_AGENT_DIR: agentHome, - OMP_PROFILE: '', - PI_PROFILE: '', - PI_CONFIG_DIR: '.omp', - PI_CONFIG_FILES: '', - ORCA_BACKGROUND_LAUNCH: '1' - } - }) - const daemonSession = daemonBackend - ? new Session({ - sessionId: id, - cols: 120, - rows: 35, - shellReadySupported: false, - ...(launch === 'recognized' ? { launchAgent: 'omp' } : {}), - subprocess: createDaemonPtySubprocessHandle({ - process: proc, - shellPath: shell, - spawnCwd: home, - env: process.env, - startupCommandDeliveredInShellArgs: false, - reportsChildExitStatus: true, - sessionId: id, - startupAgentRecognition: null - }) - }) - : null - proc.onData((data) => { - transcript = (transcript + data).slice(-131072) - }) - if (!daemonSession) { - ptyProcesses.set(id, proc) - createPtyPhysicalExit(id) - if (launch === 'recognized') { - ptyAgentSessionIds.add(id) - } - } - ptyExitDisposables.set( - id, - proc.onExit((event) => { - nativeExit = event - ptyPhysicalExits.get(id)?.markExited() - clearPtyState(id) - rmSync(home, { recursive: true, force: true }) - }) - ) - let snapshot - try { - proc.write(`${quote(binary)} --no-session --config ${quote(config)}\r`) - await delay(5000) - snapshot = await captureDescendantSnapshot(proc.pid) - expect(snapshot?.descendants.length).toBeGreaterThan(0) - if (externalTool) { - proc.write('! /bin/sleep 120\r') - for (let attempt = 0; attempt < 25; attempt++) { - await delay(200) - snapshot = await captureDescendantSnapshot(proc.pid) - if (snapshot?.descendants.length > 1) { - break - } - } - expect(snapshot?.descendants.length).toBeGreaterThan(1) - } - const pids = [proc.pid, ...snapshot.descendants.map((row) => row.pid)] - const before = await ownedPidRows(pids) - expect(before).toContain('omp') - if (externalTool) { - expect(before).toContain('sleep') - } - const started = Date.now() - let closeError = null - try { - if (daemonSession) { - await new TerminalSessionTeardown(new Map([[id, daemonSession]])).killSession( - id, - daemonSession, - true - ) - } else if (close === 'explicit') { - await shutdownLocalPty(id, { immediate: externalTool }) - } else { - killAllLocalPtys() - } - } catch (error) { - closeError = String(error) - } - await delay(6000) - const after = await ownedPidRows(pids) - report.push({ - launch, - close, - externalTool, - backend: daemonBackend ? 'daemon' : 'local', - before, - after, - nativeExit, - tracked: daemonSession ? daemonSession.isAlive : ptyProcesses.has(id), - elapsedMs: Date.now() - started, - closeError, - home - }) - writeFileSync(join(output, `${id}.txt`), transcript) - writeFileSync(join(output, 'report.json'), JSON.stringify(report, null, 2)) - expect(closeError).toBeNull() - expect(after).toBe('') - } finally { - if (snapshot) { - const current = await readProcessTable() - const owned = [ - ...snapshot.descendants, - ...(snapshot.root ? [{ ...snapshot.root, pgid: snapshot.rootPgid }] : []) - ] - for (const row of current.rows) { - if ( - owned.some( - (known) => - known.pid === row.pid && - known.startedAt === row.startedAt && - known.pgid === row.pgid - ) - ) { - try { - process.kill(row.pid, 'SIGKILL') - } catch {} - } - } - } - daemonSession?.dispose() - clearPtyState(id) - rmSync(home, { recursive: true, force: true }) - } - } - } - writeFileSync(join(output, 'report.json'), JSON.stringify(report, null, 2)) + const output = await runBundledBunFixture( + join(import.meta.dirname, 'omp-close-lifecycle-bun-fixture.mjs'), + 'runOmpCloseProbe', + { + binary, + externalTool: process.env.ORCA_OMP_PROBE_EXTERNAL_TOOL === '1', + shell: process.env.ORCA_OMP_PROBE_SHELL + }, + 85_000 + ) + expect(typeof output).toBe('string') console.log(output) }, - 90000 + 90_000 ) diff --git a/tests/tools/windows-pty-native-capability-smoke/README.md b/tests/tools/windows-pty-native-capability-smoke/README.md index fa18f4346873..bfa74dc003df 100644 --- a/tests/tools/windows-pty-native-capability-smoke/README.md +++ b/tests/tools/windows-pty-native-capability-smoke/README.md @@ -1,16 +1,13 @@ # Packaged Windows PTY native capability smoke -This narrow harness runs the packaged `Orca.exe` with `ELECTRON_RUN_AS_NODE=1` and loads its bundled `node-pty`. Checkout Node processes provide the target/canary shell fixtures, so the smoke proves the packaged native addon without putting a GUI-subsystem Electron executable under ConPTY. A 256-bit fixture token binds the target shell, a grandchild that remains live after its intermediate launcher exits, and an unrelated canary to one unique named-pipe channel; the oracle checks the patched exports, job PIDs, exact job-handle termination, PTY/socket exit events, and canary survival. +Runs the exact packaged `resources/cli-runtime/bun-runtime.exe` with its hash-verified ConPTY closure. The runner compiles the production `spawnBunPty` implementation into a private temporary directory and copies the packaged gate beside it. Checkout Node supplies target/canary fixture processes; no GUI process is launched and no runtime is downloaded. -Run it against an unpacked Windows package: +A unique 256-bit token binds a target shell, its exited transient WScript launcher, the surviving grandchild, and an unrelated canary to a named pipe. The oracle requires live job membership, exact retained-job termination via public owned-process APIs, PTY/socket exits, and canary survival followed by its separate teardown. It never infers ownership from the process table or accesses private terminal handles. ```text pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe ``` -The official `v1.4.158` package is the causal red artifact: it fails immediately because its -packaged `node-pty` lacks `assignCurrentProcessToJob`. Candidate green evidence comes from the -required Windows packaging job, which runs this smoke without retries. The one-shot probe flushes -its evidence and exits explicitly because direct native job termination intentionally bypasses -node-pty's ConPTY-worker disposal path; a 45-second outer ceiling reports bounded stdout, stderr, -and native-stage breadcrumbs if any boundary stops progressing. +The executable argument selects the package directory; the probe executes its Bun runtime. Package manifest/hash validation occurs before execution. Environment injection options are removed, the ConPTY selector is set before Bun starts, and dotenv/config loading is isolated. A 45-second ceiling reports bounded output and stage breadcrumbs; the production host job owns cleanup when the probe exits. + +This qualifies the packaged runtime/provider/gate with freshly compiled production adapter code. It does not prove the packaged daemon bundle contains identical adapter code or test rendered Electron routes. Packages predating Bun are no longer valid negative controls for this gate; mutated evidence tests preserve oracle failure coverage. diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.mjs b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-oracle.mjs similarity index 82% rename from tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.mjs rename to tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-oracle.mjs index d311bf24f28b..a8d7786cfa7b 100644 --- a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-oracle.mjs @@ -1,4 +1,4 @@ -const REQUIRED_EXPORTS = ['assignCurrentProcessToJob', 'listJobProcessIds', 'terminateJob'] +const REQUIRED_APIS = ['listOwnedProcessIds', 'terminateOwnedTree', 'waitForSpawn'] const FIXTURE_TOKEN_PATTERN = /^[a-f0-9]{64}$/ function isFixtureObservation(value, expected) { @@ -23,16 +23,16 @@ function isPtyExit(value, terminalHandle) { return value?.terminalHandle === terminalHandle && Number.isInteger(value.exitCode) } -export function evaluatePackagedNodePtyCapability(evidence) { +export function evaluatePackagedBunCapability(evidence) { const failures = [] - const exports = new Set(evidence?.patchedExports ?? []) + const exports = new Set(evidence?.ownedApis ?? []) const fixtureToken = evidence?.fixtureToken const channel = evidence?.channel const target = evidence?.target const canary = evidence?.canary - if (REQUIRED_EXPORTS.some((name) => !exports.has(name))) { - failures.push('the packaged node-pty addon is missing a required patched export') + if (REQUIRED_APIS.some((name) => !exports.has(name))) { + failures.push('the packaged Bun addon is missing a required owned-process API') } if (!FIXTURE_TOKEN_PATTERN.test(fixtureToken) || !channel?.includes(fixtureToken)) { failures.push('the fixture channel is not bound to the per-run unguessable token') @@ -80,9 +80,21 @@ export function evaluatePackagedNodePtyCapability(evidence) { } else if (target.jobProcessIds.includes(target?.launcherExited?.pid)) { failures.push('the transient launcher is still live in the target job') } + if ( + typeof target?.terminalHandle !== 'string' || + !target.terminalHandle || + typeof canary?.terminalHandle !== 'string' || + !canary.terminalHandle || + target.terminalHandle === canary.terminalHandle + ) { + failures.push('target and canary need distinct retained terminal identities') + } + if (target?.jobProcessIds?.includes(canary?.process?.pid)) { + failures.push('target job contains the unrelated canary') + } const close = evidence?.close if ( - close?.method !== 'terminate-job' || + close?.method !== 'terminate-owned-tree' || close?.requestedHandle !== target?.terminalHandle || close?.completedHandle !== target?.terminalHandle ) { @@ -129,8 +141,8 @@ export function evaluatePackagedNodePtyCapability(evidence) { return { pass: failures.length === 0, failures } } -export function assertPackagedNodePtyCapability(evidence) { - const result = evaluatePackagedNodePtyCapability(evidence) +export function assertPackagedBunCapability(evidence) { + const result = evaluatePackagedBunCapability(evidence) if (!result.pass) { throw new Error( `Packaged node-pty native capability failed:\n- ${result.failures.join('\n- ')}` diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-oracle.test.mjs similarity index 79% rename from tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs rename to tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-oracle.test.mjs index a752c6f499df..d24ed0998193 100644 --- a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-oracle.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { evaluatePackagedNodePtyCapability } from './packaged-node-pty-capability-oracle.mjs' +import { evaluatePackagedBunCapability } from './packaged-bun-capability-oracle.mjs' const fixtureToken = 'a'.repeat(64) const channel = `\\\\.\\pipe\\orca-pty-native-capability-${fixtureToken}` @@ -18,7 +18,7 @@ function passingEvidence() { const grandchild = observation(4102, 'target-grandchild') const canary = observation(5100, 'canary-shell') return { - patchedExports: ['assignCurrentProcessToJob', 'listJobProcessIds', 'terminateJob'], + ownedApis: ['listOwnedProcessIds', 'terminateOwnedTree', 'waitForSpawn'], fixtureToken, channel, target: { @@ -37,7 +37,7 @@ function passingEvidence() { socketClosed: closure('canary-shell') }, close: { - method: 'terminate-job', + method: 'terminate-owned-tree', requestedHandle: 'pty-job:7:4100', completedHandle: 'pty-job:7:4100', targetExit: { terminalHandle: 'pty-job:7:4100', exitCode: 1, signal: 0 }, @@ -47,16 +47,16 @@ function passingEvidence() { } } -describe('packaged node-pty native capability oracle', () => { +describe('packaged Bun native capability oracle', () => { it('accepts exact target teardown while the unrelated canary remains live', () => { - expect(evaluatePackagedNodePtyCapability(passingEvidence())).toEqual({ + expect(evaluatePackagedBunCapability(passingEvidence())).toEqual({ pass: true, failures: [] }) }) it.each([ - ['missing patched export', (evidence) => evidence.patchedExports.pop()], + ['missing owned-process API', (evidence) => evidence.ownedApis.pop()], ['launcher exit absent', (evidence) => delete evidence.target.launcherExited], ['empty job membership', (evidence) => (evidence.target.jobProcessIds = [])], [ @@ -73,10 +73,18 @@ describe('packaged node-pty native capability oracle', () => { const evidence = passingEvidence() mutate(evidence) - expect(evaluatePackagedNodePtyCapability(evidence).pass).toBe(false) + expect(evaluatePackagedBunCapability(evidence).pass).toBe(false) }) it.each([ + [ + 'shared terminal handle', + (evidence) => (evidence.canary.terminalHandle = evidence.target.terminalHandle) + ], + [ + 'canary in target job', + (evidence) => evidence.target.jobProcessIds.push(evidence.canary.process.pid) + ], ['guessable token', (evidence) => (evidence.fixtureToken = 'run-1')], [ 'grandchild from another channel', @@ -93,6 +101,6 @@ describe('packaged node-pty native capability oracle', () => { const evidence = passingEvidence() mutate(evidence) - expect(evaluatePackagedNodePtyCapability(evidence).pass).toBe(false) + expect(evaluatePackagedBunCapability(evidence).pass).toBe(false) }) }) diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-probe.cjs similarity index 83% rename from tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs rename to tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-probe.cjs index 580a2852a199..197fc54990f3 100644 --- a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-probe.cjs @@ -3,7 +3,7 @@ const { writeSync } = require('node:fs') const net = require('node:net') const path = require('node:path') -const EVIDENCE_PREFIX = 'ORCA_NODE_PTY_CAPABILITY_EVIDENCE=' +const EVIDENCE_PREFIX = 'ORCA_BUN_PTY_CAPABILITY_EVIDENCE=' const EXPECTED_ROLES = new Set([ 'target-shell', 'target-launcher-exited', @@ -73,10 +73,8 @@ function buildGrandchildLaunch(channel, fixtureToken) { } } -function startGrandchildAfterLauncherExit(channel, fixtureToken, resourcesDir) { - const { spawnProcess } = require( - path.join(resourcesDir, 'app.asar.unpacked', 'out', 'shared', 'child-process', 'run-process.js') - ) +function startGrandchildAfterLauncherExit(channel, fixtureToken, adapterPath) { + const { spawnProcess } = require(adapterPath) const launch = buildGrandchildLaunch(channel, fixtureToken) const child = spawnProcess({ ...launch, @@ -100,11 +98,11 @@ function startGrandchildAfterLauncherExit(channel, fixtureToken, resourcesDir) { }) } -async function runPtyShell(channel, fixtureToken, role, resourcesDir) { +async function runPtyShell(channel, fixtureToken, role, adapterPath) { const socket = connectFixture(channel, fixtureToken, `${role}-shell`) try { if (role === 'target') { - await startGrandchildAfterLauncherExit(channel, fixtureToken, resourcesDir) + await startGrandchildAfterLauncherExit(channel, fixtureToken, adapterPath) } } catch (error) { socket.destroy() @@ -206,7 +204,7 @@ function createFixtureServer(channel, fixtureToken) { } function terminalHandle(pty) { - return `pty-job:${pty._pty}:${pty.pid}` + return `bun-owned-job:${pty.pid}` } function exitEvent(pty) { @@ -227,41 +225,35 @@ function waitForBarrier(promise, label, timeoutMs = 30_000) { return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)) } -async function exercise(resourcesDir, fixtureExecutable) { - const nodePtyDir = path.join(resourcesDir, 'node_modules', 'node-pty') - stage('addon-load:start') - const nodePty = require(nodePtyDir) - const { module: native } = require(path.join(nodePtyDir, 'lib', 'utils.js')).loadNativeModule( - 'conpty' - ) - stage('addon-load:done') - const patchedExports = ['assignCurrentProcessToJob', 'listJobProcessIds', 'terminateJob'] - for (const name of patchedExports) { - if (typeof native[name] !== 'function') { - throw new Error(`packaged node-pty is missing ${name}`) - } +async function exercise(resourcesDir, fixtureExecutable, adapterPath) { + if (!process.versions.bun) { + throw new Error('Probe requires the packaged Bun runtime') } - stage('host-job-assign:start') - const hostJobAssigned = native.assignCurrentProcessToJob() - stage('host-job-assign:done') - if (!hostJobAssigned) { - throw new Error('packaged probe could not establish host job ownership') + const { spawnBunPty } = require(adapterPath) + const ownedApis = ['listOwnedProcessIds', 'terminateOwnedTree', 'waitForSpawn'] + const spawn = (role, channel, fixtureToken) => { + const terminal = spawnBunPty({ + file: fixtureExecutable, + args: [__filename, '--pty-shell', channel, fixtureToken, role, adapterPath], + cols: 80, + rows: 30, + cwd: process.cwd(), + env: process.env + }) + for (const name of ownedApis) { + if (typeof terminal[name] !== 'function') { + terminal.destroy() + throw new Error(`Packaged Bun terminal lacks ${name}`) + } + } + return terminal } - const fixtureToken = randomBytes(32).toString('hex') const channel = `\\\\.\\pipe\\orca-pty-native-capability-${fixtureToken}` const fixtures = createFixtureServer(channel, fixtureToken) stage('fixture-listen:start') await waitForBarrier(fixtures.listening, 'fixture server listen') stage('fixture-listen:done') - const options = { - name: 'xterm-256color', - cols: 80, - rows: 30, - cwd: process.cwd(), - env: process.env, - useConptyDll: true - } const created = [] const closed = new Set() const exitPromises = [] @@ -269,27 +261,20 @@ async function exercise(resourcesDir, fixtureExecutable) { try { stage('target-spawn:start') - const target = nodePty.spawn( - fixtureExecutable, - [__filename, '--pty-shell', channel, fixtureToken, 'target', resourcesDir], - options - ) + const target = spawn('target', channel, fixtureToken) stage('target-spawn:done') created.push(target) const targetExited = exitEvent(target) exitPromises.push(targetExited) stage('canary-spawn:start') - const canary = nodePty.spawn( - fixtureExecutable, - [__filename, '--pty-shell', channel, fixtureToken, 'canary', resourcesDir], - options - ) + const canary = spawn('canary', channel, fixtureToken) stage('canary-spawn:done') created.push(canary) const canaryHandle = terminalHandle(canary) const canaryExited = exitEvent(canary) exitPromises.push(canaryExited) + await Promise.all([target.waitForSpawn(), canary.waitForSpawn()]) stage('fixture-readiness:start') const [shell, launcherExited, grandchild, canaryProcess] = await Promise.all([ waitForBarrier(fixtures.waitForRole('target-shell'), 'target shell readiness'), @@ -299,13 +284,13 @@ async function exercise(resourcesDir, fixtureExecutable) { ]) stage('fixture-readiness:done') stage('target-job-list:start') - const targetJobProcessIds = native.listJobProcessIds(target._pty, target.pid) + const targetJobProcessIds = target.listOwnedProcessIds() stage('target-job-list:done') const targetHandle = terminalHandle(target) stage('target-job-terminate:start') - const targetTerminated = native.terminateJob(target._pty, target.pid) + const targetTerminated = target.terminateOwnedTree() stage('target-job-terminate:done') - if (!targetTerminated) { + if (targetTerminated !== 'terminated') { throw new Error('exact target job termination was refused') } closed.add(target) @@ -321,14 +306,14 @@ async function exercise(resourcesDir, fixtureExecutable) { stage('target-exit-barriers:done') stage('canary-job-list:start') - const canaryJobProcessIdsAfterTargetClose = native.listJobProcessIds(canary._pty, canary.pid) + const canaryJobProcessIdsAfterTargetClose = canary.listOwnedProcessIds() stage('canary-job-list:done') const canarySocket = fixtures.sockets.get('canary-shell') const connectedAfterTargetClose = Boolean(canarySocket && !canarySocket.destroyed) stage('canary-job-terminate:start') - const canaryTerminated = native.terminateJob(canary._pty, canary.pid) + const canaryTerminated = canary.terminateOwnedTree() stage('canary-job-terminate:done') - if (!canaryTerminated) { + if (canaryTerminated !== 'terminated') { throw new Error('exact canary job termination was refused') } closed.add(canary) @@ -340,7 +325,7 @@ async function exercise(resourcesDir, fixtureExecutable) { stage('canary-exit-barriers:done') const evidence = { - patchedExports, + ownedApis, fixtureToken, channel, target: { @@ -359,7 +344,7 @@ async function exercise(resourcesDir, fixtureExecutable) { socketClosed: canaryClosed }, close: { - method: 'terminate-job', + method: 'terminate-owned-tree', requestedHandle: targetHandle, completedHandle: targetHandle, targetExit, @@ -380,7 +365,7 @@ async function exercise(resourcesDir, fixtureExecutable) { for (const pty of created) { if (!closed.has(pty)) { stage('cleanup-job-terminate:start') - native.terminateJob(pty._pty, pty.pid) + pty.terminateOwnedTree() stage('cleanup-job-terminate:done') } } @@ -414,7 +399,7 @@ async function main() { if (!args[1]) { throw new Error('exercise mode requires a fixture Node executable') } - await exercise(args[0], args[1]) + await exercise(args[0], args[1], args[2]) return } if (mode === '--exit-contract-fixture') { @@ -422,7 +407,7 @@ async function main() { setInterval(() => {}, 60_000) return } - throw new Error(`unknown packaged node-pty capability probe mode: ${mode}`) + throw new Error(`unknown packaged Bun capability probe mode: ${mode}`) } module.exports = { @@ -432,7 +417,7 @@ module.exports = { reportFixtureObservation } -if (require.main === module) { +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { const mode = process.argv[2] main().then( () => (isOneShotMode(mode) ? exitOneShot(0) : undefined), diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-probe.test.mjs similarity index 95% rename from tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs rename to tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-probe.test.mjs index f26b5ae481e4..590178c45d65 100644 --- a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-bun-capability-probe.test.mjs @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { runProcess } from '../../../src/shared/child-process/run-process' const require = createRequire(import.meta.url) -const probePath = require.resolve('./packaged-node-pty-capability-probe.cjs') +const probePath = require.resolve('./packaged-bun-capability-probe.cjs') const { buildGrandchildLaunch, createFixtureServer, @@ -23,7 +23,7 @@ afterEach(() => { } }) -describe('packaged node-pty launcher-surviving grandchild', () => { +describe('packaged Bun launcher-surviving grandchild', () => { it('flushes one-shot evidence and exits despite a referenced handle', async () => { expect(isOneShotMode('--exercise')).toBe(true) expect(isOneShotMode('--exit-contract-fixture')).toBe(true) diff --git a/tests/tools/windows-pty-native-capability-smoke/run.mjs b/tests/tools/windows-pty-native-capability-smoke/run.mjs index 491fba2642d2..b53ad5f63b9a 100644 --- a/tests/tools/windows-pty-native-capability-smoke/run.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/run.mjs @@ -1,9 +1,12 @@ +import { build } from 'esbuild' +import { mkdtemp, copyFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { createRequire } from 'node:module' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { assertPackagedNodePtyCapability } from './packaged-node-pty-capability-oracle.mjs' +import { assertPackagedBunCapability } from './packaged-bun-capability-oracle.mjs' -const EVIDENCE_PREFIX = 'ORCA_NODE_PTY_CAPABILITY_EVIDENCE=' +const EVIDENCE_PREFIX = 'ORCA_BUN_PTY_CAPABILITY_EVIDENCE=' const PROBE_TIMEOUT_MS = 45_000 const MAX_DIAGNOSTIC_CHARS = 8_000 @@ -19,14 +22,37 @@ export function checkoutRunProcessPath() { return path.resolve(import.meta.dirname, '../../../out/shared/child-process/run-process.js') } -export function packagedProbeInvocation(executable, environment = process.env) { +export function packagedProbeInvocation(executable, adapterPath, environment = process.env) { const resolvedExecutable = path.resolve(executable) const resourcesDir = path.join(path.dirname(resolvedExecutable), 'resources') - const probe = path.join(import.meta.dirname, 'packaged-node-pty-capability-probe.cjs') + const probe = path.join(import.meta.dirname, 'packaged-bun-capability-probe.cjs') return { - program: resolvedExecutable, - args: [probe, '--exercise', resourcesDir, process.execPath], - env: { ...environment, ELECTRON_RUN_AS_NODE: '1' }, + program: path.join(resourcesDir, 'cli-runtime', 'bun-runtime.exe'), + args: [ + '--no-env-file', + `--config=${path.join(path.dirname(adapterPath), 'bunfig.toml')}`, + probe, + '--exercise', + resourcesDir, + process.execPath, + adapterPath + ], + env: { + ...Object.fromEntries( + Object.entries(environment).filter( + ([key]) => + ![ + 'ELECTRON_RUN_AS_NODE', + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT' + ].includes(key.toUpperCase()) + ) + ), + ORCA_BACKGROUND_LAUNCH: '1', + BUN_CONPTY_LIBRARY: path.join(resourcesDir, 'cli-runtime', 'conpty', 'conpty.dll') + }, timeoutMs: PROBE_TIMEOUT_MS } } @@ -51,6 +77,32 @@ function parseEvidence(stdout) { return JSON.parse(line.slice(EVIDENCE_PREFIX.length)) } +export async function buildCapabilityAdapter(directory, resources) { + const adapterPath = path.join(directory, 'capability-adapter.cjs') + const root = path.resolve(import.meta.dirname, '../../..') + await build({ + stdin: { + contents: + "export { spawnBunPty } from './src/main/daemon/pty-subprocess/bun-pty-process'; export { spawnProcess, runProcess } from './src/shared/child-process/run-process'", + resolveDir: root, + loader: 'ts' + }, + outfile: adapterPath, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node24', + external: ['bun:ffi', 'electron'], + logLevel: 'silent' + }) + await copyFile( + path.join(resources, 'terminal-daemon', 'windows-bun-pty-gate-entry.js'), + path.join(directory, 'windows-bun-pty-gate-entry.js') + ) + await writeFile(path.join(directory, 'bunfig.toml'), '') + return adapterPath +} + async function main() { if (process.platform !== 'win32') { throw new Error('windows-pty-native-capability-smoke requires a physical Windows host') @@ -58,13 +110,21 @@ async function main() { const executable = executableArgument(process.argv.slice(2)) const require = createRequire(import.meta.url) const { runProcess } = require(checkoutRunProcessPath()) - const result = await runProcess(packagedProbeInvocation(executable)) - if (result.code !== 0 || result.timedOut) { - throw new Error(formatProbeFailure(result)) + const resources = path.join(path.dirname(executable), 'resources') + const { verifyCliRuntimeDirectory } = require('../../../config/bundled-cli-runtime.cjs') + verifyCliRuntimeDirectory(path.join(resources, 'cli-runtime'), 'win32', process.arch) + const directory = await mkdtemp(path.join(tmpdir(), 'orca-packaged-bun-capability-')) + try { + const adapterPath = await buildCapabilityAdapter(directory, resources) + const result = await runProcess(packagedProbeInvocation(executable, adapterPath)) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(formatProbeFailure(result)) + } + assertPackagedBunCapability(parseEvidence(result.stdout)) + process.stdout.write(`[windows-pty-native-capability-smoke] PASS ${executable}\n`) + } finally { + await rm(directory, { recursive: true, force: true }) } - - assertPackagedNodePtyCapability(parseEvidence(result.stdout)) - process.stdout.write(`[windows-pty-native-capability-smoke] PASS ${executable}\n`) } if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { diff --git a/tests/tools/windows-pty-native-capability-smoke/run.test.mjs b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs index bb8ab525da6b..66084e05b929 100644 --- a/tests/tools/windows-pty-native-capability-smoke/run.test.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs @@ -6,20 +6,35 @@ import { checkoutRunProcessPath, formatProbeFailure, packagedProbeInvocation } f const runnerSource = readFileSync(new URL('./run.mjs', import.meta.url), 'utf8') describe('packaged Windows native smoke runner boundary', () => { - it('uses one checkout-owned runner for current and affected package paths', () => { - const current = packagedProbeInvocation('/ci/current/dist/win-unpacked/Orca.exe') - const affected = packagedProbeInvocation('/ci/1.4.158/dist/win-unpacked/Orca.exe') - + it('runs the packaged runtime with its provider and explicit config', () => { + const current = packagedProbeInvocation( + '/ci/current/dist/win-unpacked/Orca.exe', + '/tmp/probe/capability-adapter.cjs', + { ELECTRON_RUN_AS_NODE: '1', NODE_OPTIONS: '--inspect', BUN_CONPTY_LIBRARY: 'untrusted.dll' } + ) expect(checkoutRunProcessPath()).toBe(path.resolve('out/shared/child-process/run-process.js')) - expect(current.program).not.toBe(affected.program) - expect(current.args[0]).toBe(affected.args[0]) - expect(current.args[1]).toBe('--exercise') - expect(current.args[2]).not.toBe(affected.args[2]) - expect(current.args[3]).toBe(process.execPath) - expect(affected.args[3]).toBe(process.execPath) - expect(current.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(affected.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(current.timeoutMs).toBe(affected.timeoutMs) + expect(current.program).toBe( + '/ci/current/dist/win-unpacked/resources/cli-runtime/bun-runtime.exe' + ) + expect(current.args.slice(0, 2)).toEqual([ + '--no-env-file', + `--config=${path.join(path.dirname('/tmp/probe/capability-adapter.cjs'), 'bunfig.toml')}` + ]) + expect(current.args).toContain('--exercise') + expect(current.args.at(-1)).toBe('/tmp/probe/capability-adapter.cjs') + expect(current.args.at(-2)).toBe(process.execPath) + expect(current.env.ELECTRON_RUN_AS_NODE).toBeUndefined() + expect(current.env.NODE_OPTIONS).toBeUndefined() + expect(current.env.BUN_CONPTY_LIBRARY).toBe( + path.join( + path.resolve('/ci/current/dist/win-unpacked'), + 'resources', + 'cli-runtime', + 'conpty', + 'conpty.dll' + ) + ) + expect(current.env.ORCA_BACKGROUND_LAUNCH).toBe('1') expect(current.timeoutMs).toBe(45_000) }) From 09d1fb9bf8e1e358531db51d6c72c5e1dc07a2d5 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 04:43:17 -0700 Subject: [PATCH 02/53] Normalize vendored license and fixture whitespace --- config/licenses/windows-conpty-LICENSE.txt | 42 +++++++++---------- src/main/pty/omp-shell-wrapper-bun-fixture.ts | 1 - 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/config/licenses/windows-conpty-LICENSE.txt b/config/licenses/windows-conpty-LICENSE.txt index 017b9885a46a..8cb179cdb694 100644 --- a/config/licenses/windows-conpty-LICENSE.txt +++ b/config/licenses/windows-conpty-LICENSE.txt @@ -1,21 +1,21 @@ -Copyright (c) Microsoft Corporation. All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/main/pty/omp-shell-wrapper-bun-fixture.ts b/src/main/pty/omp-shell-wrapper-bun-fixture.ts index fb6692248f3c..90b069d7c497 100644 --- a/src/main/pty/omp-shell-wrapper-bun-fixture.ts +++ b/src/main/pty/omp-shell-wrapper-bun-fixture.ts @@ -61,4 +61,3 @@ export async function runInteractivePosixPty(args: { } } } - From 362ba8c2f22eeea802b199d767aabd17d5139cae Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 04:55:54 -0700 Subject: [PATCH 03/53] Keep guest listeners detached during restart and simplify byte streams --- .../pty/provider/bind-listeners-wsl.test.ts | 29 +++++++++++++++++++ src/main/ipc/pty/provider/registry.ts | 13 +++++---- .../wsl/wsl-daemon-connector-stream.test.ts | 5 +++- src/main/wsl/wsl-daemon-connector-stream.ts | 10 +++---- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts b/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts index e2580dec1b5c..3d8e0bbc4be6 100644 --- a/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts +++ b/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts @@ -1,3 +1,4 @@ +import { runCoalescedDaemonRestart } from '../../../daemon/daemon-restart-state' import { afterEach, expect, it, vi } from 'vitest' import { bindProviderListeners } from './bind-listeners' import { setRebindProviderListeners, unbindLocalProviderListeners } from './listener-lifecycle' @@ -67,3 +68,31 @@ it('binds added guests once, excludes SSH and removes listeners across reload an unbindLocalProviderListeners() expect(native.listeners.size).toBe(0) }) + +it('does not rebind a retiring local provider when guests change during restart', async () => { + const rebind = vi.fn() + setRebindProviderListeners(rebind) + let finish!: () => void + const pending = runCoalescedDaemonRestart(async () => { + await new Promise((resolve) => { + finish = resolve + }) + return { killedCount: 0 } + }) + try { + const release = registerWslPtyProvider( + { distro: 'Ubuntu', relayBuildId: 'during-restart' }, + provider().value + ) + releases.push(release) + release() + expect(rebind).not.toHaveBeenCalled() + } finally { + finish() + await pending + } + releases.push( + registerWslPtyProvider({ distro: 'Ubuntu', relayBuildId: 'after-restart' }, provider().value) + ) + expect(rebind).toHaveBeenCalledOnce() +}) diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts index 65d689df13b2..a1da362b5ca9 100644 --- a/src/main/ipc/pty/provider/registry.ts +++ b/src/main/ipc/pty/provider/registry.ts @@ -1,3 +1,4 @@ +import { isDaemonRestartInFlight } from '../../../daemon/daemon-restart-state' import { parseAppWslPtyId, type WslPtyOwner } from '../../../../shared/wsl-pty-id' import { wslPtyOwnerKey } from '../../../../shared/wsl-pty-consumer-recovery' import { relayProvidersByGeneration } from '../../../providers/relay-pty-generation-registry' @@ -50,11 +51,15 @@ export function registerWslPtyProvider(owner: WslPtyOwner, provider: IPtyProvide throw new Error('WSL terminal owner already registered') } wslProviders.set(key, provider) - rebindLocalProviderListeners() + if (!isDaemonRestartInFlight()) { + rebindLocalProviderListeners() + } return () => { if (wslProviders.get(key) === provider) { wslProviders.delete(key) - rebindLocalProviderListeners() + if (!isDaemonRestartInFlight()) { + rebindLocalProviderListeners() + } } } } @@ -164,9 +169,7 @@ export function getSshPtyProvider(connectionId: string): IPtyProvider | undefine return sshProviders.get(connectionId) } -/** Get the installed PTY provider (for direct access in tests/runtime). - * After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider; - * callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */ +/** Get the installed daemon provider for runtime operations and tests. */ export function getLocalPtyProvider(): IPtyProvider { return localProvider } diff --git a/src/main/wsl/wsl-daemon-connector-stream.test.ts b/src/main/wsl/wsl-daemon-connector-stream.test.ts index dd5c9685ccde..2ceae531fb7e 100644 --- a/src/main/wsl/wsl-daemon-connector-stream.test.ts +++ b/src/main/wsl/wsl-daemon-connector-stream.test.ts @@ -1,3 +1,4 @@ +import { DaemonConnectionLostError } from '../daemon/daemon-errors' import { afterEach, expect, it } from 'vitest' import { createServer } from 'node:net' import { mkdtempSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' @@ -51,6 +52,8 @@ unix( }, AbortSignal.timeout(10_000) ) + expect(stream.readableObjectMode).toBe(false) + expect(stream.writableObjectMode).toBe(false) const chunks: Buffer[] = [] stream.on('data', (chunk: Buffer) => chunks.push(chunk)) const ended = once(stream, 'end') @@ -72,7 +75,7 @@ unix('rejects changed guest identity before connecting', async () => { }, AbortSignal.timeout(10_000) ) - ).rejects.toThrow('closed before connection') + ).rejects.toBeInstanceOf(DaemonConnectionLostError) }) it('cancels a connector that never reaches readiness', async () => { diff --git a/src/main/wsl/wsl-daemon-connector-stream.ts b/src/main/wsl/wsl-daemon-connector-stream.ts index b0ecf5273a55..d9369e7ed362 100644 --- a/src/main/wsl/wsl-daemon-connector-stream.ts +++ b/src/main/wsl/wsl-daemon-connector-stream.ts @@ -1,4 +1,5 @@ -import { Duplex, Readable, Writable } from 'node:stream' +import { DaemonConnectionLostError } from '../daemon/daemon-errors' +import { Duplex } from 'node:stream' import { spawnProcess, type ProcessSpec } from '../../shared/child-process/run-process' import { WSL_DAEMON_CONNECTOR_READY } from './wsl-daemon-connector-script' @@ -9,10 +10,7 @@ export function openWslDaemonConnectorStream( ): Promise { signal.throwIfAborted() const child = spawnProcess(spec) - const stream = Duplex.fromWeb( - { readable: Readable.toWeb(child.stdout), writable: Writable.toWeb(child.stdin) }, - { objectMode: false } - ) + const stream = Duplex.from({ readable: child.stdout, writable: child.stdin }) // A child can fail before the awaiting protocol consumer has installed its listener. stream.on('error', () => {}) stream.once('close', () => child.kill()) @@ -37,7 +35,7 @@ export function openWslDaemonConnectorStream( stream.destroy() reject(error) } - const closed = () => fail(new Error('WSL daemon connector closed before connection')) + const closed = () => fail(new DaemonConnectionLostError('Connection lost')) const abort = () => fail(new Error('WSL daemon connector connection canceled', { cause: signal.reason })) const onStderr = (bytes: Buffer) => { From 7f0dfbb8b84d397ac8a0b08586de2d30ad7b4699 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 04:58:45 -0700 Subject: [PATCH 04/53] Document the Node stream-pair typing gap --- src/main/wsl/wsl-daemon-connector-stream.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/wsl/wsl-daemon-connector-stream.ts b/src/main/wsl/wsl-daemon-connector-stream.ts index d9369e7ed362..d8ac01873eaa 100644 --- a/src/main/wsl/wsl-daemon-connector-stream.ts +++ b/src/main/wsl/wsl-daemon-connector-stream.ts @@ -10,7 +10,9 @@ export function openWslDaemonConnectorStream( ): Promise { signal.throwIfAborted() const child = spawnProcess(spec) - const stream = Duplex.from({ readable: child.stdout, writable: child.stdin }) + const pair = { readable: child.stdout, writable: child.stdin } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Node supports Node-stream pairs; current typings omit them. Byte/EOF tests cover this path. + const stream = Duplex.from(pair as unknown as Parameters[0]) // A child can fail before the awaiting protocol consumer has installed its listener. stream.on('error', () => {}) stream.once('close', () => child.kill()) From 3c81563deadba234db70feb79af1727d4403b823 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 04:59:16 -0700 Subject: [PATCH 05/53] Prepare replacement runtimes before retiring daemons and shorten prune locks --- .../daemon-bun-runtime-retention.test.ts | 47 +++++++- .../daemon/daemon-bun-runtime-retention.ts | 28 ++++- .../daemon/daemon-out-of-process-launcher.ts | 9 +- .../daemon/daemon-replacement-preflight.ts | 29 ++++- ...emon-replacement-runtime-readiness.test.ts | 105 ++++++++++++++++++ .../daemon/daemon-runtime-launch-pin.test.ts | 14 +++ 6 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 src/main/daemon/daemon-replacement-runtime-readiness.test.ts diff --git a/src/main/daemon/daemon-bun-runtime-retention.test.ts b/src/main/daemon/daemon-bun-runtime-retention.test.ts index a02a39e23cb0..d22da00ccded 100644 --- a/src/main/daemon/daemon-bun-runtime-retention.test.ts +++ b/src/main/daemon/daemon-bun-runtime-retention.test.ts @@ -1,3 +1,4 @@ +import type * as FileSystem from 'node:fs/promises' import { mkdtemp, mkdir, @@ -19,6 +20,19 @@ import { pruneDaemonBunRuntimes } from './daemon-bun-runtime-retention' +const { removingTrash } = vi.hoisted(() => ({ removingTrash: vi.fn() })) +vi.mock('node:fs/promises', async (importOriginal) => { + const fs = await importOriginal() + return { + ...fs, + rm: async (...args: Parameters) => { + if (String(args[0]).includes('.bun-trash-')) { + await removingTrash() + } + return fs.rm(...args) + } + } +}) const { snapshot, available, identities } = vi.hoisted(() => ({ snapshot: vi.fn(), available: vi.fn(), @@ -32,6 +46,7 @@ vi.mock('../windows/windows-process-table', () => ({ })) const roots: string[] = [] beforeEach(() => { + removingTrash.mockReset().mockResolvedValue(undefined) snapshot.mockReset().mockResolvedValue([]) available.mockReturnValue(true) identities.mockReturnValue(true) @@ -78,7 +93,7 @@ describe('managed Windows daemon runtime retention', () => { await pruneDaemonBunRuntimes(host) expect(await generations(root)).toHaveLength(4) }) - it('a launch waits for the exclusive snapshot and deletion to finish', async () => { + it('a launch waits for the exclusive snapshot to finish', async () => { const { host, root } = await fixture() let finish: (() => void) | undefined snapshot.mockImplementation( @@ -100,6 +115,36 @@ describe('managed Windows daemon runtime retention', () => { await pruning ;(await launching).release() }) + it('admits launches while retired files are still being deleted', async () => { + const { host, root } = await fixture() + let finish: (() => void) | undefined + removingTrash.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const pruning = pruneDaemonBunRuntimes(host) + await vi.waitFor(() => expect(removingTrash).toHaveBeenCalled()) + const pin = await acquireDaemonRuntimeLaunchPin(root) + try { + expect(await generations(root)).toHaveLength(4) + expect((await readdir(root)).some((name) => name.startsWith('.bun-trash-'))).toBe(true) + } finally { + pin.release() + finish?.() + await pruning + } + expect((await readdir(root)).some((name) => name.startsWith('.bun-trash-'))).toBe(false) + }) + it('retries deletion of retired directories left by interrupted pruning', async () => { + const { host, root } = await fixture(0) + const trash = join(root, '.bun-trash-12345678-1234-1234-1234-123456789abc') + await mkdir(trash) + await writeFile(join(trash, 'daemon-entry.js'), 'entry') + await pruneDaemonBunRuntimes(host) + expect((await readdir(root)).filter((name) => name.startsWith('.bun-trash-'))).toEqual([]) + }) it.each(['bun-runtime.exe', 'bun.exe', 'OpenConsole.exe'])( 'retains live %s closures', async (name) => { diff --git a/src/main/daemon/daemon-bun-runtime-retention.ts b/src/main/daemon/daemon-bun-runtime-retention.ts index b039e94c4399..ca695f5d5f2e 100644 --- a/src/main/daemon/daemon-bun-runtime-retention.ts +++ b/src/main/daemon/daemon-bun-runtime-retention.ts @@ -1,4 +1,5 @@ -import { lstat, readdir, realpath, rm } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { lstat, readdir, realpath, rename, rm } from 'node:fs/promises' import { join, win32 } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { @@ -17,6 +18,7 @@ import { export const MANAGED_DAEMON_RUNTIME_DIRECTORY = 'managed-v1' const GENERATION = /^bun-[a-f0-9]{64}(?:\.repair-[1-9][0-9]*)?$/u const STAGING = /^\.bun-staging-[a-f0-9-]{36}$/u +const TRASH = /^\.bun-trash-[a-f0-9-]{36}$/u const RETAIN_UNUSED = 2 const DELETE_LIMIT = 4 @@ -88,9 +90,9 @@ export async function collectPinnedDaemonRuntimeDirectories( canonicalImage = normalized(await resolveExecutablePath(image)) canonicalImages.set(image, canonicalImage) } catch (error) { - // Unpublished staging may lack images; unknown or published identities still veto deletion. + // Staging and retired trees may lack images; unknown published identities veto deletion. if ( - !STAGING.test(win32.basename(directory)) || + (!STAGING.test(win32.basename(directory)) && !TRASH.test(win32.basename(directory))) || !(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT' @@ -115,6 +117,7 @@ export async function pruneDaemonBunRuntimes(hostRoot: string): Promise { return } const requestedRoot = join(hostRoot, MANAGED_DAEMON_RUNTIME_DIRECTORY) + const retired: string[] = [] let admission: ProfileStateRuntimeAdmission | undefined try { if ((await lstat(requestedRoot)).isSymbolicLink()) { @@ -124,7 +127,10 @@ export async function pruneDaemonBunRuntimes(hostRoot: string): Promise { admission = acquireProfileStateMaintenance(root) const candidates: { path: string; modified: number; staging: boolean }[] = [] for (const entry of await readdir(root, { withFileTypes: true })) { - if (!entry.isDirectory() || (!GENERATION.test(entry.name) && !STAGING.test(entry.name))) { + if ( + !entry.isDirectory() || + (!GENERATION.test(entry.name) && !STAGING.test(entry.name) && !TRASH.test(entry.name)) + ) { continue } const path = join(root, entry.name) @@ -132,7 +138,11 @@ export async function pruneDaemonBunRuntimes(hostRoot: string): Promise { if (!stat.isDirectory() || stat.isSymbolicLink() || (await realpath(path)) !== path) { continue } - candidates.push({ path, modified: stat.mtimeMs, staging: STAGING.test(entry.name) }) + candidates.push({ + path, + modified: stat.mtimeMs, + staging: STAGING.test(entry.name) || TRASH.test(entry.name) + }) } const pinned = await collectPinnedDaemonRuntimeDirectories( await readWindowsProcessTableFresh(), @@ -151,7 +161,9 @@ export async function pruneDaemonBunRuntimes(hostRoot: string): Promise { if ((await lstat(candidate.path)).isSymbolicLink()) { continue } - await rm(candidate.path, { recursive: true, force: true }) + const trash = join(root, `.bun-trash-${randomUUID()}`) + await rename(candidate.path, trash) + retired.push(trash) } } catch { // Retention is optional; missing identity, busy admissions and disk errors preserve files. @@ -162,6 +174,10 @@ export async function pruneDaemonBunRuntimes(hostRoot: string): Promise { console.warn('[daemon] Could not release runtime retention ownership', error) } } + // Detached names cannot be launched; slow antivirus deletion must not block launch pins. + for (const path of retired) { + await rm(path, { recursive: true, force: true }).catch(() => {}) + } } const pendingPrunes = new Set() diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 0f359eb8278c..2d5ebdb70c2a 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -101,6 +101,9 @@ export function createOutOfProcessLauncher( ) } let bunHost: DaemonBunRuntime | null = null + let runtimePromise: ReturnType | undefined + const getReplacementRuntime = (): ReturnType => + (runtimePromise ??= resolveDesktopDaemonBunRuntime()) try { const preservedHandle = await prepareDaemonReplacement({ runtimeDir, @@ -109,6 +112,9 @@ export function createOutOfProcessLauncher( entryPath, recoveryDeadlineMs, attributedReason, + prepareReplacementRuntime: async () => { + bunHost = await getReplacementRuntime() + }, releaseAdoptionClient, preserveDaemon, launchNonce @@ -118,8 +124,7 @@ export function createOutOfProcessLauncher( } const userDataPath = getAppEnvironment().getPath('userData') - // Resolve only after adoption: an existing owner must not depend on new runtime files. - bunHost = await resolveDesktopDaemonBunRuntime() + bunHost = await getReplacementRuntime() // Windows Bun artifacts are immutable copies outside the installer kill zone. const forkEntryPath = bunHost?.entryPath ?? entryPath let launched diff --git a/src/main/daemon/daemon-replacement-preflight.ts b/src/main/daemon/daemon-replacement-preflight.ts index e445ebc566a3..145698c99a0b 100644 --- a/src/main/daemon/daemon-replacement-preflight.ts +++ b/src/main/daemon/daemon-replacement-preflight.ts @@ -33,6 +33,7 @@ type ReplacementPreflightOptions = { /** Absolute deadline for the whole adopt-or-replace decision; see DAEMON_RECOVERY_BUDGET_MS. */ recoveryDeadlineMs: number attributedReason: DaemonReplaceReason | null + prepareReplacementRuntime: () => Promise releaseAdoptionClient: () => void preserveDaemon: PreserveDaemon launchNonce: string @@ -48,6 +49,7 @@ export async function prepareDaemonReplacement( entryPath, recoveryDeadlineMs, attributedReason, + prepareReplacementRuntime, releaseAdoptionClient, preserveDaemon, launchNonce @@ -59,6 +61,7 @@ export async function prepareDaemonReplacement( } | undefined let confirmedReplacement = false + let cleanupProtocol = false const health = await checkDaemonHealth(socketPath, tokenPath) if (health === 'healthy') { const pidRecord = readDaemonPidRecord(getDaemonPidPath(runtimeDir)) @@ -87,7 +90,7 @@ export async function prepareDaemonReplacement( reason: 'unhealthy_resolver', liveSessionCount } - confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)).cleaned + cleanupProtocol = true } else { // Why: a protocol-healthy daemon can outlive its launching app bundle (dev worktree rebuild, or packaged update replacing the app path). const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) @@ -124,8 +127,7 @@ export async function prepareDaemonReplacement( reason: stalePackagedBundle ? 'stale_bundle' : 'different_app_path', liveSessionCount: 0 } - confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)) - .cleaned + cleanupProtocol = true } else { const attributionHealth = await getMacDaemonTccAttributionHealth( runtimeDir, @@ -145,8 +147,7 @@ export async function prepareDaemonReplacement( '[daemon] Replacing daemon whose macOS TCC attribution is severed (spawning app binary no longer exists)' ) pendingReplacement = { reason: 'severed_tcc_attribution', liveSessionCount } - confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)) - .cleaned + cleanupProtocol = true } else { return preserveDaemon() } @@ -211,6 +212,24 @@ export async function prepareDaemonReplacement( } } + // Adoption needs no new artifacts; replacement must be ready before stopping its owner. + await prepareReplacementRuntime() + // Copying a runtime may take seconds; another client may have spawned during preparation. + const currentSessionCount = await getAliveDaemonSessionCount( + socketPath, + tokenPath, + recoveryDeadlineMs + ) + if ( + (currentSessionCount !== null && currentSessionCount > 0) || + (cleanupProtocol && currentSessionCount === null) + ) { + return preserveDaemon(health === 'pty-spawn-unhealthy' ? 'fresh-spawns-unavailable' : undefined) + } + if (cleanupProtocol) { + confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)).cleaned + } + // Why: a raw socket can outlive a broken daemon; kill by PID before respawn so the new daemon doesn't race the stale one. releaseAdoptionClient() const killOutcome = await killStaleDaemon(runtimeDir, socketPath, tokenPath) diff --git a/src/main/daemon/daemon-replacement-runtime-readiness.test.ts b/src/main/daemon/daemon-replacement-runtime-readiness.test.ts new file mode 100644 index 000000000000..fcfb859815a5 --- /dev/null +++ b/src/main/daemon/daemon-replacement-runtime-readiness.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ + health: vi.fn(), + resolver: vi.fn(), + sessions: vi.fn(), + identity: vi.fn(), + attribution: vi.fn(), + cleanup: vi.fn(), + kill: vi.fn() +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ isPackaged: () => false }) +})) +vi.mock('./daemon-health', () => ({ + checkDaemonHealth: mocks.health, + getMacDaemonSystemResolverHealth: mocks.resolver +})) +vi.mock('./daemon-launch-paths', () => ({ + getAliveDaemonSessionCount: mocks.sessions, + probeDaemonSocket: async () => false, + DAEMON_SOCKET_PROBE_TIMEOUT_MS: 10 +})) +vi.mock('./daemon-pid-identity', () => ({ getDaemonLaunchIdentity: mocks.identity })) +vi.mock('./daemon-endpoint-incarnation', () => ({ readDaemonPidRecord: () => null })) +vi.mock('./daemon-tcc-attribution', () => ({ getMacDaemonTccAttributionHealth: mocks.attribution })) +vi.mock('./daemon-protocol-cleanup', () => ({ cleanupDaemonForProtocol: mocks.cleanup })) +vi.mock('./daemon-stale-kill', () => ({ killStaleDaemon: mocks.kill })) +vi.mock('./daemon-lifecycle-event', () => ({ trackDaemonReplaced: vi.fn() })) +import { prepareDaemonReplacement } from './daemon-replacement-preflight' +function options() { + return { + runtimeDir: '/runtime', + socketPath: '/socket', + tokenPath: '/token', + entryPath: '/entry', + recoveryDeadlineMs: Date.now() + 1000, + attributedReason: null, + launchNonce: 'nonce', + prepareReplacementRuntime: vi.fn(async () => {}), + releaseAdoptionClient: vi.fn(), + preserveDaemon: vi.fn(async () => ({ adopted: true as const, shutdown: async () => {} })) + } +} +beforeEach(() => { + vi.resetAllMocks() + mocks.health.mockResolvedValue('healthy') + mocks.resolver.mockResolvedValue('healthy') + mocks.sessions.mockResolvedValue(0) + mocks.identity.mockResolvedValue('match') + mocks.attribution.mockResolvedValue('healthy') + mocks.cleanup.mockResolvedValue({ cleaned: true }) + mocks.kill.mockResolvedValue({ killed: true, liveOwnerSurvived: false }) +}) +it.each(['resolver', 'identity', 'attribution', 'unhealthy'])( + 'does not stop an incumbent after runtime preparation fails (%s)', + async (reason) => { + if (reason === 'resolver') { + mocks.resolver.mockResolvedValue('unhealthy') + } + if (reason === 'identity') { + mocks.identity.mockResolvedValue('mismatch') + } + if (reason === 'attribution') { + mocks.attribution.mockResolvedValue('severed') + } + if (reason === 'unhealthy') { + mocks.health.mockResolvedValue('rejected') + } + const args = options() + args.prepareReplacementRuntime.mockRejectedValue(new Error('runtime unavailable')) + await expect(prepareDaemonReplacement(args)).rejects.toThrow('runtime unavailable') + expect(mocks.cleanup).not.toHaveBeenCalled() + expect(mocks.kill).not.toHaveBeenCalled() + expect(args.releaseAdoptionClient).not.toHaveBeenCalled() + } +) +it('keeps healthy adoption independent of new runtime files', async () => { + const args = options() + args.prepareReplacementRuntime.mockRejectedValue(new Error('missing runtime')) + expect(await prepareDaemonReplacement(args)).toMatchObject({ adopted: true }) + expect(args.prepareReplacementRuntime).not.toHaveBeenCalled() +}) +it('prepares once before protocol cleanup and stale process termination', async () => { + mocks.identity.mockResolvedValue('mismatch') + const args = options() + await prepareDaemonReplacement(args) + expect(args.prepareReplacementRuntime).toHaveBeenCalledTimes(1) + expect(args.prepareReplacementRuntime.mock.invocationCallOrder[0]).toBeLessThan( + mocks.cleanup.mock.invocationCallOrder[0] + ) + expect(mocks.cleanup.mock.invocationCallOrder[0]).toBeLessThan( + mocks.kill.mock.invocationCallOrder[0] + ) +}) + +it.each([1, null])( + 'preserves an incumbent when preparation changes session evidence to %s', + async (count) => { + mocks.identity.mockResolvedValue('mismatch') + mocks.sessions.mockResolvedValueOnce(0).mockResolvedValue(count) + expect(await prepareDaemonReplacement(options())).toMatchObject({ adopted: true }) + expect(mocks.cleanup).not.toHaveBeenCalled() + expect(mocks.kill).not.toHaveBeenCalled() + } +) diff --git a/src/main/daemon/daemon-runtime-launch-pin.test.ts b/src/main/daemon/daemon-runtime-launch-pin.test.ts index 44095dec33c3..e452148cc373 100644 --- a/src/main/daemon/daemon-runtime-launch-pin.test.ts +++ b/src/main/daemon/daemon-runtime-launch-pin.test.ts @@ -77,6 +77,20 @@ describe('daemon runtime launch pin', () => { ) expect(mocks.release).toHaveBeenCalledTimes(1) }) + it('releases the prepared runtime pin when replacement preflight fails', async () => { + mocks.replace.mockImplementation( + async (options: { prepareReplacementRuntime: () => Promise }) => { + await options.prepareReplacementRuntime() + throw new Error('replacement failed') + } + ) + await expect(createOutOfProcessLauncher('/profile')('/socket', '/token')).rejects.toThrow( + 'replacement failed' + ) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + expect(mocks.launch).not.toHaveBeenCalled() + expect(mocks.release).toHaveBeenCalledTimes(1) + }) it('never resolves or pins runtime files when adopting a live daemon', async () => { const handle = { adopted: true, shutdown: vi.fn() } mocks.replace.mockResolvedValue(handle) From 84aa535127ee436594144f14406ee2be532f0a1e Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:05:17 -0700 Subject: [PATCH 06/53] Recover retained WSL terminals only after exact owner exit proof --- src/main/daemon/client.test.ts | 14 +++ src/main/daemon/client.ts | 18 ++-- src/main/daemon/daemon-client-connections.ts | 5 + src/main/daemon/daemon-client-duplex.test.ts | 39 ++++++- .../daemon/daemon-client-hello-handshake.ts | 19 +++- src/main/daemon/daemon-client-transport.ts | 6 +- src/main/daemon/daemon-entry.ts | 4 +- src/main/daemon/daemon-hello-protocol.ts | 2 + src/main/daemon/daemon-main.ts | 4 + .../daemon/daemon-pty-guest-execution.test.ts | 22 ++++ src/main/daemon/daemon-pty-runtime-state.ts | 3 +- src/main/daemon/daemon-server-options.ts | 2 + src/main/daemon/daemon-server.ts | 2 + .../wsl-consumer-recovery-operations.ts | 26 ++++- .../loading-store/wsl-daemon-recovery.test.ts | 47 ++++++++ src/main/wsl/wsl-daemon-endpoint.test.ts | 35 +++++- src/main/wsl/wsl-daemon-endpoint.ts | 29 ++++- src/main/wsl/wsl-daemon-incarnation.test.ts | 64 +++++++++++ src/main/wsl/wsl-daemon-incarnation.ts | 72 +++++++++++++ .../wsl/wsl-daemon-owner-admission.test.ts | 94 ++++++++++++++++ src/main/wsl/wsl-daemon-owner-admission.ts | 100 ++++++++++++++++++ src/main/wsl/wsl-daemon-sessions.test.ts | 46 +++++--- src/main/wsl/wsl-daemon-sessions.ts | 53 ++++------ src/main/wsl/wsl-daemon-start-script.test.ts | 62 ++++++++--- src/main/wsl/wsl-daemon-start-script.ts | 12 ++- src/shared/wsl-daemon-recovery.ts | 14 ++- 26 files changed, 701 insertions(+), 93 deletions(-) create mode 100644 src/main/wsl/wsl-daemon-incarnation.test.ts create mode 100644 src/main/wsl/wsl-daemon-incarnation.ts create mode 100644 src/main/wsl/wsl-daemon-owner-admission.test.ts create mode 100644 src/main/wsl/wsl-daemon-owner-admission.ts diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 2d7973bae880..8683c3ab8deb 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -164,6 +164,8 @@ describe('DaemonClient', () => { pid: 123, startedAtMs: 456, launchNonce: 'launch-a', + linuxStartTicks: '12345', + bootId: 'guest-boot', entryPath: '/Applications/Orca.app/Contents/Resources/daemon-entry.js', appVersion: '1.2.3', spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' @@ -176,6 +178,18 @@ describe('DaemonClient', () => { expect(client.getDaemonIdentity()).toEqual(identity) }) + it.each([ + { linuxStartTicks: '123' }, + { bootId: 'boot' }, + { linuxStartTicks: 'not-ticks', bootId: 'boot' } + ])('rejects incomplete or malformed guest incarnation %#', async (extra) => { + await startMockDaemon({ + helloIdentity: () => ({ pid: 123, startedAtMs: 456, launchNonce: 'launch', ...extra }) + }) + client = new DaemonClient({ socketPath, tokenPath }) + await expect(client.ensureConnected()).rejects.toThrow('Invalid daemon identity') + }) + it('rejects a v24 daemon that omits endpoint identity', async () => { await startMockDaemon({ omitHelloIdentity: true }) client = new DaemonClient({ socketPath, tokenPath }) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 0ccf76e150f4..0fe204207dcf 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -62,7 +62,7 @@ export class DaemonClient { private requestCounter = 0 private cleanupSocketListeners: (() => void) | null = null - constructor(opts: DaemonClientOptions) { + constructor(private readonly opts: DaemonClientOptions) { this.transport = resolveDaemonTransport(opts) this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION } @@ -118,7 +118,6 @@ export class DaemonClient { const attempt = new DaemonConnectionAttempt(this.transport, timeoutMs, sharedBudget) const controller = attempt.controller this.connectionAbort = controller - const pendingListenerCleanups = attempt.readerCleanups const cleanupPendingListeners = () => attempt.releaseReaders() try { @@ -135,7 +134,7 @@ export class DaemonClient { attempt.remainingMs() ) this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket) - pendingListenerCleanups.push( + attempt.readerCleanups.push( attachControlResponseReader(this.controlSocket, (response) => this.pendingRequests.settle(response) ) @@ -154,16 +153,17 @@ export class DaemonClient { if (!sameDaemonIdentity(controlIdentity, streamIdentity)) { throw new DaemonProtocolError('Daemon identity changed during connection') } - pendingListenerCleanups.push( - attachStreamEventReader(this.streamSocket, (event) => { - this.eventListeners.each((listener) => listener(event)) - }) - ) + await this.opts.admitIdentity?.(controlIdentity) this.assertConnectionAttemptCurrent(attemptGeneration) if (controller.signal.aborted) { throw controller.signal.reason } + attempt.readerCleanups.push( + attachStreamEventReader(this.streamSocket, (event) => { + this.eventListeners.each((listener) => listener(event)) + }) + ) this.connected = true this.observedAuthenticatedDisconnect = false this.daemonIdentity = controlIdentity @@ -171,7 +171,7 @@ export class DaemonClient { this.connectionGeneration++ const gen = this.connectionGeneration - pendingListenerCleanups.push( + attempt.readerCleanups.push( armDaemonSocketCloseHandlers(this.controlSocket, this.streamSocket, () => this.handleDisconnect(gen) ) diff --git a/src/main/daemon/daemon-client-connections.ts b/src/main/daemon/daemon-client-connections.ts index 7eedef1285bf..03696123fbe4 100644 --- a/src/main/daemon/daemon-client-connections.ts +++ b/src/main/daemon/daemon-client-connections.ts @@ -22,6 +22,8 @@ type DaemonClientConnectionOptions = { identity: { launchNonce: string | null startedAtMs: number | null + linuxStartTicks?: string + bootId?: string entryPath: string | null appVersion: string | null spawnerExecPath: string | null @@ -151,6 +153,9 @@ export class DaemonClientConnections { pid: process.pid, startedAtMs: identity.startedAtMs, launchNonce: identity.launchNonce, + ...(identity.linuxStartTicks && identity.bootId + ? { linuxStartTicks: identity.linuxStartTicks, bootId: identity.bootId } + : {}), ...(identity.entryPath ? { entryPath: identity.entryPath } : {}), ...(identity.appVersion ? { appVersion: identity.appVersion } : {}), ...(identity.spawnerExecPath ? { spawnerExecPath: identity.spawnerExecPath } : {}) diff --git a/src/main/daemon/daemon-client-duplex.test.ts b/src/main/daemon/daemon-client-duplex.test.ts index 98e0f2d00cc9..d4c5495aa702 100644 --- a/src/main/daemon/daemon-client-duplex.test.ts +++ b/src/main/daemon/daemon-client-duplex.test.ts @@ -38,7 +38,10 @@ function deferred() { }) return { promise, resolve } } -function harness(otherIdentity = identity) { +function harness( + otherIdentity = identity, + admitIdentity?: (identity: typeof otherIdentity | null) => Promise +) { const endpoints = new Map>() const hellos: unknown[] = [] const transport: DaemonClientTransport = { @@ -75,12 +78,44 @@ function harness(otherIdentity = identity) { return endpoint.client } } - const client = new DaemonClient({ transport }) + const client = new DaemonClient({ transport, admitIdentity }) clients.push(client) return { client, transport, endpoints, hellos } } describe('daemon ordered duplex transport', () => { + it('does not publish a connection or its events before admission and fences a canceled admission', async () => { + const first = deferred() + const second = deferred() + const admit = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { client, endpoints } = harness(identity, admit) + const event = vi.fn() + client.onEvent(event) + const connecting = expect(client.ensureConnected()).rejects.toThrow('Disconnected') + await vi.waitFor(() => expect(admit).toHaveBeenCalledOnce()) + endpoints.get('stream')!.server.write( + encodeNdjson({ + type: 'event', + event: 'data', + sessionId: 's', + payload: { data: 'unadmitted' } + }) + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(event).not.toHaveBeenCalled() + await expect(client.request('ping', undefined)).rejects.toThrow('Not connected') + client.disconnect() + first.resolve() + await connecting + const reconnected = client.ensureConnected() + await vi.waitFor(() => expect(admit).toHaveBeenCalledTimes(2)) + await expect(client.request('ping', undefined)).rejects.toThrow('Not connected') + second.resolve() + await reconnected + await expect(client.request('ping', undefined)).resolves.toEqual({}) + expect(event).not.toHaveBeenCalled() + }) + it('authenticates both roles, handles RPC/events and settles notify writes', async () => { const { client, endpoints, hellos } = harness() const event = vi.fn() diff --git a/src/main/daemon/daemon-client-hello-handshake.ts b/src/main/daemon/daemon-client-hello-handshake.ts index c57db6d14cd4..189f25cdad32 100644 --- a/src/main/daemon/daemon-client-hello-handshake.ts +++ b/src/main/daemon/daemon-client-hello-handshake.ts @@ -118,6 +118,8 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n pid?: unknown startedAtMs?: unknown launchNonce?: unknown + linuxStartTicks?: unknown + bootId?: unknown entryPath?: unknown appVersion?: unknown spawnerExecPath?: unknown @@ -133,7 +135,20 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n ) { return null } + if ( + (identity.linuxStartTicks !== undefined || identity.bootId !== undefined) && + (typeof identity.linuxStartTicks !== 'string' || + !/^\d+$/.test(identity.linuxStartTicks) || + typeof identity.bootId !== 'string' || + !identity.bootId || + /[\0\r\n]/.test(identity.bootId)) + ) { + return null + } return { + ...(typeof identity.linuxStartTicks === 'string' && typeof identity.bootId === 'string' + ? { linuxStartTicks: identity.linuxStartTicks, bootId: identity.bootId } + : {}), pid: identity.pid as number, startedAtMs: identity.startedAtMs, launchNonce: identity.launchNonce, @@ -159,6 +174,8 @@ export function sameDaemonIdentity( right !== null && left.pid === right.pid && left.startedAtMs === right.startedAtMs && - left.launchNonce === right.launchNonce) + left.launchNonce === right.launchNonce && + left.linuxStartTicks === right.linuxStartTicks && + left.bootId === right.bootId) ) } diff --git a/src/main/daemon/daemon-client-transport.ts b/src/main/daemon/daemon-client-transport.ts index 8bdb40fb7246..e5f6fd59dee5 100644 --- a/src/main/daemon/daemon-client-transport.ts +++ b/src/main/daemon/daemon-client-transport.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import type { Duplex } from 'node:stream' import { connectDaemonSocket } from './daemon-client-socket-connect' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' import { DaemonProtocolError } from './types' export type DaemonTransportOperation = { timeoutMs: number; signal: AbortSignal } @@ -9,7 +10,10 @@ export type DaemonClientTransport = { /** Return a connected ordered byte stream; destroy must release its bridge resources. */ connect: (role: 'control' | 'stream', operation: DaemonTransportOperation) => Promise } -export type DaemonClientOptions = { protocolVersion?: number } & ( +export type DaemonClientOptions = { + protocolVersion?: number + admitIdentity?: (identity: DaemonEndpointIdentity | null) => Promise +} & ( | { socketPath: string; tokenPath: string; transport?: never } | { transport: DaemonClientTransport; socketPath?: never; tokenPath?: never } ) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 5958742124c9..6f5f6d7e7cab 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -267,7 +267,9 @@ async function main(): Promise { tokenPath, ...(pidPath ? { pidPath } : {}), ...(launchNonce ? { launchNonce } : {}), - ...(pidPath ? { startedAtMs } : {}), + startedAtMs, + linuxStartTicks: readyIdentity.linuxStartTicks, + bootId: readyIdentity.bootId, ...(entryPath ? { entryPath } : {}), ...(appVersion ? { appVersion } : {}), ...(spawnerExecPath ? { spawnerExecPath } : {}), diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts index 4f2fd9828ec1..cc6e48ea04d8 100644 --- a/src/main/daemon/daemon-hello-protocol.ts +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -10,6 +10,8 @@ export type DaemonEndpointIdentity = { pid: number startedAtMs: number launchNonce: string + linuxStartTicks?: string + bootId?: string /** Optional launch metadata. Absent from daemons that predate it; readers must fall back. */ entryPath?: string appVersion?: string diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index b2683b0af999..9040e1f0d673 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -8,6 +8,8 @@ export type DaemonStartOptions = { pidPath?: string launchNonce?: string startedAtMs?: number + linuxStartTicks?: string + bootId?: string publishEndpointOwnership?: DaemonServerOptions['publishEndpointOwnership'] entryPath?: string appVersion?: string @@ -30,6 +32,8 @@ export type DaemonHandle = { export async function startDaemon(opts: DaemonStartOptions): Promise { const server = new DaemonServer({ + linuxStartTicks: opts.linuxStartTicks, + bootId: opts.bootId, socketPath: opts.socketPath, tokenPath: opts.tokenPath, ...(opts.pidPath ? { pidPath: opts.pidPath } : {}), diff --git a/src/main/daemon/daemon-pty-guest-execution.test.ts b/src/main/daemon/daemon-pty-guest-execution.test.ts index 604049c7f156..8c8eb68fc1aa 100644 --- a/src/main/daemon/daemon-pty-guest-execution.test.ts +++ b/src/main/daemon/daemon-pty-guest-execution.test.ts @@ -87,6 +87,28 @@ describe('guest daemon execution boundary', () => { expect(reportCwd).not.toHaveBeenCalled() }) + it('does not spawn before durable admission and refuses a connection changed while waiting', async () => { + let release!: () => void + const admission = new Promise((resolve) => { + release = resolve + }) + const admitted = vi.fn(() => admission) + const adapter = new DaemonPtyAdapter({ guest: { ...guest(), admitIdentity: admitted } }) + adapters.push(adapter) + const spawning = expect( + adapter.spawn({ cols: 80, rows: 24, isNewSession: true }) + ).rejects.toThrow('Connection lost') + await vi.waitFor(() => expect(admitted).toHaveBeenCalledOnce()) + expect(spawn).not.toHaveBeenCalled() + for (const socket of connections) { + socket.destroy() + } + await new Promise((resolve) => setImmediate(resolve)) + release() + await spawning + expect(spawn).not.toHaveBeenCalled() + }) + it('rejects desktop endpoint mixing and foreign paths before spawning', async () => { expect(() => new DaemonPtyAdapter({ guest: guest(), tokenPath: '/desktop/token' })).toThrow( 'cannot use desktop' diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts index da9d63c52877..fe7adf70c51e 100644 --- a/src/main/daemon/daemon-pty-runtime-state.ts +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -54,6 +54,7 @@ export type GuestDaemonExecution = { defaultShell: string defaultCwd: string profiles: readonly { name: string; path: string }[] + admitIdentity?: (identity: DaemonEndpointIdentity | null) => Promise } export type DaemonPtyAdapterOptions = { @@ -229,7 +230,7 @@ export abstract class DaemonPtyRuntimeState { } this.client = new DaemonClient({ ...(this.guest - ? { transport: this.guest.transport } + ? { transport: this.guest.transport, admitIdentity: this.guest.admitIdentity } : { socketPath: this.socketPath, tokenPath: this.tokenPath }), protocolVersion: opts.protocolVersion }) diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts index 06f2cda6cb96..3cecafa82dc5 100644 --- a/src/main/daemon/daemon-server-options.ts +++ b/src/main/daemon/daemon-server-options.ts @@ -7,6 +7,8 @@ export type DaemonServerOptions = { pidPath?: string launchNonce?: string startedAtMs?: number + linuxStartTicks?: string + bootId?: string publishEndpointOwnership?: () => void entryPath?: string appVersion?: string diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 4c4bf401041e..e6b802b1e34a 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -131,6 +131,8 @@ export class DaemonServer { identity: { launchNonce, startedAtMs, + linuxStartTicks: options.linuxStartTicks, + bootId: options.bootId, entryPath: options.entryPath ?? null, appVersion: options.appVersion ?? null, spawnerExecPath: options.spawnerExecPath ?? null diff --git a/src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts b/src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts index c336490b0234..4f54acb4ce3a 100644 --- a/src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts +++ b/src/main/persistence/loading-store/wsl-consumer-recovery-operations.ts @@ -1,5 +1,6 @@ import { normalizeWslDaemonRecovery, + type WslDaemonIncarnation, type WslDaemonRecovery } from '../../../shared/wsl-daemon-recovery' import type { StoreRuntimeState } from './store-runtime-state' @@ -52,7 +53,10 @@ export class WslConsumerRecoveryOperations { ) } - async upsertWslDaemonRecovery(record: WslDaemonRecovery): Promise { + async upsertWslDaemonRecovery( + record: WslDaemonRecovery, + expectedIncarnation?: WslDaemonIncarnation | null + ): Promise { const normalized = normalizeWslDaemonRecovery(record) if (!normalized) { throw new Error('Invalid WSL daemon recovery record') @@ -73,11 +77,25 @@ export class WslConsumerRecoveryOperations { } } const current = rows.find((row) => wslPtyOwnerKey(row) === key) - if (current) { - if (JSON.stringify(current) !== JSON.stringify(normalized)) { + if (current && isWslDaemonRecovery(current)) { + if (JSON.stringify(current.endpoint) !== JSON.stringify(normalized.endpoint)) { return { value: new Error('WSL daemon owner identity cannot change'), persist: false } } - return { value: undefined, persist: false } + if (JSON.stringify(current.incarnation) === JSON.stringify(normalized.incarnation)) { + return { value: undefined, persist: false } + } + if ( + !normalized.incarnation || + expectedIncarnation === undefined || + JSON.stringify(current.incarnation ?? null) !== JSON.stringify(expectedIncarnation) + ) { + return { value: new Error('WSL daemon incarnation admission changed'), persist: false } + } + } else if (expectedIncarnation) { + return { + value: new Error('WSL daemon incarnation admission disappeared'), + persist: false + } } runtime.state.wslPtyConsumerRecoveries = [ ...rows.filter((row) => wslPtyOwnerKey(row) !== key), diff --git a/src/main/persistence/loading-store/wsl-daemon-recovery.test.ts b/src/main/persistence/loading-store/wsl-daemon-recovery.test.ts index d03e4d48f513..cc1f28bcffd2 100644 --- a/src/main/persistence/loading-store/wsl-daemon-recovery.test.ts +++ b/src/main/persistence/loading-store/wsl-daemon-recovery.test.ts @@ -122,3 +122,50 @@ describe('persisted guest daemon endpoint identity', () => { expect(readState().wslPtyConsumerRecoveries ?? []).toEqual([]) }) }) + +const oldIncarnation = { + pid: 42, + startedAtMs: 100, + launchNonce: 'old', + linuxStartTicks: '123', + bootId: 'boot' +} +const newIncarnation = { ...oldIncarnation, pid: 43, launchNonce: 'new', linuxStartTicks: '456' } + +it('admits incarnation changes only against the expected prior while keeping endpoint immutable', async () => { + const { store, readState } = await fixture() + await store.upsertWslDaemonRecovery(recovery) + await store.upsertWslDaemonRecovery({ ...recovery, incarnation: oldIncarnation }, null) + await expect( + store.upsertWslDaemonRecovery({ ...recovery, incarnation: newIncarnation }, null) + ).rejects.toThrow('admission changed') + await expect( + store.upsertWslDaemonRecovery( + { + ...recovery, + endpoint: { ...recovery.endpoint, socket: '/other' }, + incarnation: newIncarnation + }, + oldIncarnation + ) + ).rejects.toThrow('owner identity cannot change') + await store.upsertWslDaemonRecovery({ ...recovery, incarnation: newIncarnation }, oldIncarnation) + expect(readState().wslPtyConsumerRecoveries).toEqual([ + { ...recovery, incarnation: newIncarnation } + ]) +}) +it('rolls back refused incarnation admission without losing the prior durable owner', async () => { + const { store, authority, readState } = await fixture() + await store.upsertWslDaemonRecovery({ ...recovery, incarnation: oldIncarnation }) + const gate = authority.pause() + const rejected = expect( + store.upsertWslDaemonRecovery({ ...recovery, incarnation: newIncarnation }, oldIncarnation) + ).rejects.toThrow('disk refused') + await gate.started.promise + expect(readState().wslPtyConsumerRecoveries).toEqual([ + { ...recovery, incarnation: oldIncarnation } + ]) + gate.finish.reject(new Error('disk refused')) + await rejected + expect(store.getWslDaemonRecovery(recovery)?.incarnation).toEqual(oldIncarnation) +}) diff --git a/src/main/wsl/wsl-daemon-endpoint.test.ts b/src/main/wsl/wsl-daemon-endpoint.test.ts index f1f08b70d439..151472f85932 100644 --- a/src/main/wsl/wsl-daemon-endpoint.test.ts +++ b/src/main/wsl/wsl-daemon-endpoint.test.ts @@ -1,10 +1,16 @@ +import { proveWslDaemonIncarnationExited } from './wsl-daemon-incarnation' import { beforeEach, expect, it, vi } from 'vitest' import { readFile } from 'node:fs/promises' import { prepareWslGuestOwner } from './wsl-guest-owner-preparation' -import { prepareWslDaemonEndpoint, startPreparedWslDaemonOwner } from './wsl-daemon-endpoint' +import { + prepareWslDaemonEndpoint, + startPreparedWslDaemonOwner, + startRetainedWslDaemonOwner +} from './wsl-daemon-endpoint' import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' import { readWslDistributionIdentity } from './wsl-distribution-identity' +vi.mock('./wsl-daemon-incarnation', () => ({ proveWslDaemonIncarnationExited: vi.fn() })) vi.mock('node:fs/promises', () => ({ readFile: vi.fn() })) vi.mock('../daemon/daemon-bun-runtime', () => ({ desktopDaemonBundleDir: () => '/app/terminal-daemon' @@ -112,3 +118,30 @@ it('canonicalizes fresh distro aliases without modifying persisted reconnect ide expect(alias).toEqual(first) expect(alias.owner.distro).toBe('ubuntu') }) + +it('checks old process death before starting the retained artifact after bundle replacement', async () => { + const old = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + vi.mocked(readFile).mockResolvedValue(Buffer.from('new-daemon')) + const current = await prepareWslDaemonEndpoint('Ubuntu', 'profile') + expect(current.endpoint.entry).not.toBe(old.endpoint.entry) + const incarnation = { + pid: 42, + startedAtMs: 100, + launchNonce: 'old', + linuxStartTicks: '123', + bootId: 'boot' + } + run.mockClear().mockResolvedValue('started') + vi.mocked(proveWslDaemonIncarnationExited).mockRejectedValueOnce(new Error('still live')) + await expect(startRetainedWslDaemonOwner(old.endpoint, incarnation)).rejects.toThrow('still live') + expect(run).not.toHaveBeenCalled() + vi.mocked(proveWslDaemonIncarnationExited).mockResolvedValue(undefined) + await startRetainedWslDaemonOwner(old.endpoint, incarnation) + expect(proveWslDaemonIncarnationExited).toHaveBeenLastCalledWith( + old.endpoint, + incarnation, + undefined + ) + expect(JSON.parse(run.mock.lastCall?.[0].args.at(-1))).toEqual(old.endpoint) + expect(createRunningWslRuntimeRunner).toHaveBeenLastCalledWith('ubuntu', undefined, 'captured') +}) diff --git a/src/main/wsl/wsl-daemon-endpoint.ts b/src/main/wsl/wsl-daemon-endpoint.ts index 0177e55df826..cfda7c908f8d 100644 --- a/src/main/wsl/wsl-daemon-endpoint.ts +++ b/src/main/wsl/wsl-daemon-endpoint.ts @@ -1,3 +1,4 @@ +import { proveWslDaemonIncarnationExited } from './wsl-daemon-incarnation' import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' @@ -9,7 +10,10 @@ import { WSL_GUEST_ARTIFACT_INSTALL_SCRIPT } from './wsl-guest-artifact-install- import { WSL_DAEMON_START_SCRIPT } from './wsl-daemon-start-script' import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' import { readWslDistributionIdentity } from './wsl-distribution-identity' -import type { PersistedWslDaemonEndpoint } from '../../shared/wsl-daemon-recovery' +import type { + PersistedWslDaemonEndpoint, + WslDaemonIncarnation +} from '../../shared/wsl-daemon-recovery' import type { WslPtyOwner } from '../../shared/wsl-pty-id' export type PreparedWslDaemonEndpoint = Readonly<{ @@ -126,7 +130,24 @@ export async function startPreparedWslDaemonOwner( prepared: PreparedWslDaemonEndpoint, signal?: AbortSignal ): Promise { - const endpoint = prepared.endpoint + await startWslDaemonOwner(prepared.endpoint, prepared.path, signal) +} + +/** A retained endpoint can restart only after its admitted process incarnation exited. */ +export async function startRetainedWslDaemonOwner( + endpoint: PersistedWslDaemonEndpoint, + incarnation: WslDaemonIncarnation | undefined, + signal?: AbortSignal +): Promise { + await proveWslDaemonIncarnationExited(endpoint, incarnation, signal) + await startWslDaemonOwner(endpoint, undefined, signal) +} + +async function startWslDaemonOwner( + endpoint: PersistedWslDaemonEndpoint, + path: string | undefined, + signal?: AbortSignal +): Promise { if ((await readWslDistributionIdentity(endpoint.distro)) !== endpoint.distributionId) { throw new Error('WSL distribution was replaced; refusing to start a different terminal owner') } @@ -139,9 +160,9 @@ export async function startPreparedWslDaemonOwner( ...bunOwnedRuntimeArgs('linux'), '-e', WSL_DAEMON_START_SCRIPT, - JSON.stringify({ ...endpoint, entry: prepared.entry, path: prepared.path }) + JSON.stringify({ ...endpoint, path }) ], - loginPath: 'none' + loginPath: path === undefined ? 'preferred' : 'none' }) if (result !== 'started' && result !== 'existing') { throw new Error('WSL terminal daemon could not be started') diff --git a/src/main/wsl/wsl-daemon-incarnation.test.ts b/src/main/wsl/wsl-daemon-incarnation.test.ts new file mode 100644 index 000000000000..1aae69a808de --- /dev/null +++ b/src/main/wsl/wsl-daemon-incarnation.test.ts @@ -0,0 +1,64 @@ +import { runInNewContext } from 'node:vm' +import { expect, it, vi } from 'vitest' +import { WSL_DAEMON_INCARNATION_SCRIPT } from './wsl-daemon-incarnation' + +it.each([ + 'same', + 'new-boot', + 'missing-pid', + 'hidden-pid', + 'hidden-EPERM', + 'reused-pid', + 'EACCES', + 'malformed', + 'no-proof' +])('classifies retained guest incarnation: %s', (scenario) => { + const log = vi.fn() + const stat = `42 (shell ) with spaces) ${Array.from({ length: 20 }, (_, i) => (i === 19 ? (scenario === 'reused-pid' ? '999' : '123') : '0')).join(' ')}` + const check = () => + runInNewContext(WSL_DAEMON_INCARNATION_SCRIPT, { + require: () => ({ + readFileSync: (path: string) => { + if (path.endsWith('boot_id')) { + return scenario === 'new-boot' ? 'new-boot' : 'original-boot' + } + if (['missing-pid', 'hidden-pid', 'hidden-EPERM', 'EACCES'].includes(scenario)) { + throw Object.assign(new Error('unreadable'), { + code: scenario === 'EACCES' ? 'EACCES' : 'ENOENT' + }) + } + return scenario === 'malformed' ? 'bad stat' : stat + } + }), + process: { + kill: () => { + if (scenario !== 'hidden-pid') { + throw Object.assign(new Error('probe'), { + code: scenario === 'hidden-EPERM' ? 'EPERM' : 'ESRCH' + }) + } + }, + getuid: () => 1000, + env: { HOME: '/home/u' }, + argv: [ + 'bun', + JSON.stringify({ + userId: '1000', + home: '/home/u', + incarnation: + scenario === 'no-proof' + ? undefined + : { pid: 42, linuxStartTicks: '123', bootId: 'original-boot' } + }) + ] + }, + console: { log } + }) + if (['new-boot', 'missing-pid', 'reused-pid'].includes(scenario)) { + check() + expect(log).toHaveBeenCalledWith('exited') + } else { + expect(check).toThrow() + expect(log).not.toHaveBeenCalled() + } +}) diff --git a/src/main/wsl/wsl-daemon-incarnation.ts b/src/main/wsl/wsl-daemon-incarnation.ts new file mode 100644 index 000000000000..ed679af6b094 --- /dev/null +++ b/src/main/wsl/wsl-daemon-incarnation.ts @@ -0,0 +1,72 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' +import type { + PersistedWslDaemonEndpoint, + WslDaemonIncarnation +} from '../../shared/wsl-daemon-recovery' +import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' +import { readWslDistributionIdentity } from './wsl-distribution-identity' + +/** A missing socket never proves that the daemon's surviving terminals exited. */ +export const WSL_DAEMON_INCARNATION_SCRIPT = String.raw` +const fs=require('node:fs'); +const plan=JSON.parse(process.argv[1]); +if(String(process.getuid())!==plan.userId || process.env.HOME!==plan.home) throw Error('WSL daemon execution owner changed'); +const prior=plan.incarnation; +if(!prior) throw Error('Retained guest owner has no process identity; recovery is unverifiable'); +const boot=fs.readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(); +if(!boot) throw Error('Guest boot identity is unverifiable'); +let exited=boot!==prior.bootId; +if(!exited) { + let stat; + try {stat=fs.readFileSync('/proc/'+prior.pid+'/stat','utf8');} + catch(error) { + if(error.code!=='ENOENT') throw error; + try {process.kill(prior.pid,0);} + catch(probe) {if(probe.code==='ESRCH') exited=true; else throw probe;} + if(!exited) throw Error('Guest process identity is unverifiable'); + } + if(stat!==undefined) { + const close=stat.lastIndexOf(')'); + const ticks=close<0 ? undefined : stat.slice(close+2).split(/\s+/)[19]; + if(!ticks || !/^\d+$/.test(ticks)) throw Error('Guest process identity is unverifiable'); + exited=ticks!==prior.linuxStartTicks; + } +} +if(!exited) throw Error('Retained guest daemon is still live; refusing replacement'); +console.log('exited'); +` + +export async function proveWslDaemonIncarnationExited( + endpoint: PersistedWslDaemonEndpoint, + incarnation: WslDaemonIncarnation | undefined, + signal?: AbortSignal +): Promise { + if (!incarnation) { + throw new Error('Retained guest owner has no process identity; recovery is unverifiable') + } + if ((await readWslDistributionIdentity(endpoint.distro)) !== endpoint.distributionId) { + throw new Error('WSL distribution was replaced; terminal owner is unverifiable') + } + const runner = createRunningWslRuntimeRunner(endpoint.distro, signal, endpoint.userName) + const result = await runner.run({ + program: endpoint.envBinary, + args: [ + ...[ + 'NODE_OPTIONS', + 'NODE_PATH', + 'BUN_OPTIONS', + 'BUN_INSPECT', + 'ELECTRON_RUN_AS_NODE' + ].flatMap((key) => ['-u', key]), + endpoint.runtime, + ...bunOwnedRuntimeArgs('linux'), + '-e', + WSL_DAEMON_INCARNATION_SCRIPT, + JSON.stringify({ userId: endpoint.userId, home: endpoint.home, incarnation }) + ], + loginPath: 'none' + }) + if (result !== 'exited') { + throw new Error('Retained guest daemon death is unverifiable') + } +} diff --git a/src/main/wsl/wsl-daemon-owner-admission.test.ts b/src/main/wsl/wsl-daemon-owner-admission.test.ts new file mode 100644 index 000000000000..f03d8f0e7082 --- /dev/null +++ b/src/main/wsl/wsl-daemon-owner-admission.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { WslDaemonOwnerAdmission } from './wsl-daemon-owner-admission' +import { proveWslDaemonIncarnationExited } from './wsl-daemon-incarnation' +import { startRetainedWslDaemonOwner } from './wsl-daemon-endpoint' +import type { WslDaemonRecovery } from '../../shared/wsl-daemon-recovery' +vi.mock('./wsl-daemon-incarnation', () => ({ proveWslDaemonIncarnationExited: vi.fn() })) +vi.mock('./wsl-daemon-endpoint', () => ({ + startRetainedWslDaemonOwner: vi.fn(), + startPreparedWslDaemonOwner: vi.fn() +})) +const owner = { distro: 'Ubuntu', relayBuildId: 'old-build' } +const endpoint = { + distro: 'Ubuntu', + distributionId: 'distro', + userName: 'alice', + userId: '1000', + home: '/home/alice', + runtime: '/old/bun', + entry: '/old/daemon-entry.js', + envBinary: '/usr/bin/env', + socket: '/old/socket', + tokenPath: '/old/token', + serverBuildId: 'old-build' +} +const prior = { + pid: 42, + startedAtMs: 100, + launchNonce: 'old', + linuxStartTicks: '123', + bootId: 'boot' +} +const next = { ...prior, pid: 43, launchNonce: 'new', linuxStartTicks: '456' } +function setup(record?: WslDaemonRecovery) { + const store = { + getWslDaemonRecovery: vi.fn(() => record ?? null), + upsertWslDaemonRecovery: vi.fn(async (value: WslDaemonRecovery) => { + record = value + }) + } + const admission = new WslDaemonOwnerAdmission( + store, + owner, + endpoint, + new AbortController().signal + ) + return { admission, store } +} +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(proveWslDaemonIncarnationExited).mockReset().mockResolvedValue(undefined) +}) +it('gains proof by attaching to an identity-less record and admits before returning', async () => { + const { admission, store } = setup({ kind: 'daemon', ...owner, endpoint }) + await admission.admitIdentity(prior) + expect(store.upsertWslDaemonRecovery).toHaveBeenCalledWith( + { kind: 'daemon', ...owner, endpoint, incarnation: prior }, + null + ) + expect(proveWslDaemonIncarnationExited).not.toHaveBeenCalled() +}) +it('refuses displaced but live prior owner even when the new hello authenticated', async () => { + const { admission, store } = setup({ kind: 'daemon', ...owner, endpoint, incarnation: prior }) + vi.mocked(proveWslDaemonIncarnationExited).mockRejectedValueOnce(new Error('still live')) + await expect(admission.admitIdentity(next)).rejects.toThrow('still live') + expect(store.upsertWslDaemonRecovery).not.toHaveBeenCalled() +}) +it('proves old incarnation exited before CAS admission of the new one', async () => { + const { admission, store } = setup({ kind: 'daemon', ...owner, endpoint, incarnation: prior }) + await admission.admitIdentity(next) + expect(proveWslDaemonIncarnationExited).toHaveBeenCalledWith( + endpoint, + prior, + expect.any(AbortSignal) + ) + expect(store.upsertWslDaemonRecovery).toHaveBeenCalledWith( + { kind: 'daemon', ...owner, endpoint, incarnation: next }, + prior + ) +}) +it('never starts another daemon after a durable admission failure', async () => { + const { admission, store } = setup() + store.upsertWslDaemonRecovery.mockRejectedValueOnce(new Error('disk full')) + await expect(admission.establishLease(() => admission.admitIdentity(prior))).rejects.toThrow( + 'disk full' + ) + expect(startRetainedWslDaemonOwner).not.toHaveBeenCalled() +}) +it('passes the retained incarnation to the checked starter on cached-adapter recovery', async () => { + const { admission } = setup({ kind: 'daemon', ...owner, endpoint, incarnation: prior }) + await admission.recover() + expect(startRetainedWslDaemonOwner).toHaveBeenCalledWith(endpoint, prior, expect.any(AbortSignal)) + vi.mocked(startRetainedWslDaemonOwner).mockRejectedValueOnce(new Error('unverifiable')) + await expect(admission.recover()).rejects.toThrow('unverifiable') +}) diff --git a/src/main/wsl/wsl-daemon-owner-admission.ts b/src/main/wsl/wsl-daemon-owner-admission.ts new file mode 100644 index 000000000000..94d0135ac008 --- /dev/null +++ b/src/main/wsl/wsl-daemon-owner-admission.ts @@ -0,0 +1,100 @@ +import { + wslDaemonIncarnationSchema, + type PersistedWslDaemonEndpoint +} from '../../shared/wsl-daemon-recovery' +import type { WslPtyOwner } from '../../shared/wsl-pty-id' +import type { DaemonEndpointIdentity } from '../daemon/daemon-hello-protocol' +import type { Store } from '../persistence' +import { proveWslDaemonIncarnationExited } from './wsl-daemon-incarnation' +import { + startPreparedWslDaemonOwner, + startRetainedWslDaemonOwner, + type PreparedWslDaemonEndpoint +} from './wsl-daemon-endpoint' + +export class WslDaemonAdmissionError extends Error {} + +/** Durable incarnation admission fences cold restore from displaced but still-live owners. */ +export class WslDaemonOwnerAdmission { + private pending = Promise.resolve() + + constructor( + private readonly store: Pick, + private readonly owner: WslPtyOwner, + private readonly endpoint: PersistedWslDaemonEndpoint, + private readonly signal: AbortSignal, + private readonly prepared?: PreparedWslDaemonEndpoint + ) {} + + admitIdentity = (identity: DaemonEndpointIdentity | null): Promise => { + const pending = this.pending + .then(() => this.admit(identity)) + .catch((cause) => { + throw new WslDaemonAdmissionError( + `WSL daemon identity could not be durably admitted: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause } + ) + }) + this.pending = pending.catch(() => {}) + return pending + } + + private async admit(identity: DaemonEndpointIdentity | null): Promise { + this.signal.throwIfAborted() + const parsed = wslDaemonIncarnationSchema.safeParse( + identity && { + pid: identity.pid, + startedAtMs: identity.startedAtMs, + launchNonce: identity.launchNonce, + linuxStartTicks: identity.linuxStartTicks, + bootId: identity.bootId + } + ) + const previous = this.store.getWslDaemonRecovery(this.owner) + const incarnation = parsed.success ? parsed.data : undefined + if ( + previous?.incarnation && + JSON.stringify(previous.incarnation) !== JSON.stringify(incarnation) + ) { + if (!incarnation) { + throw new Error('Authenticated guest owner has no incarnation evidence') + } + await proveWslDaemonIncarnationExited(this.endpoint, previous.incarnation, this.signal) + } + await this.store.upsertWslDaemonRecovery( + { + kind: 'daemon', + ...this.owner, + endpoint: this.endpoint, + ...(incarnation ? { incarnation } : {}) + }, + previous?.incarnation ?? null + ) + this.signal.throwIfAborted() + } + + recover = async (): Promise => { + this.signal.throwIfAborted() + const previous = this.store.getWslDaemonRecovery(this.owner) + if (previous) { + await startRetainedWslDaemonOwner(this.endpoint, previous.incarnation, this.signal) + } else if (this.prepared) { + await startPreparedWslDaemonOwner(this.prepared, this.signal) + } else { + throw new Error('WSL daemon owner endpoint is unverifiable') + } + this.signal.throwIfAborted() + } + + async establishLease(establish: () => Promise): Promise { + try { + await establish() + } catch (error) { + if (error instanceof WslDaemonAdmissionError) { + throw error + } + await this.recover() + await establish() + } + } +} diff --git a/src/main/wsl/wsl-daemon-sessions.test.ts b/src/main/wsl/wsl-daemon-sessions.test.ts index 0953b17b9896..ca13a5aa1acf 100644 --- a/src/main/wsl/wsl-daemon-sessions.test.ts +++ b/src/main/wsl/wsl-daemon-sessions.test.ts @@ -18,7 +18,11 @@ const { lease, detach, unregister, run } = vi.hoisted(() => ({ vi.mock('../daemon/daemon-pty-adapter', () => ({ DaemonPtyAdapter: vi.fn( class { - establishLifecycleLease = lease + constructor(private readonly options: ConstructorParameters[0]) {} + establishLifecycleLease = async () => { + await lease() + await this.options.guest?.admitIdentity?.(null) + } disconnectOnly = detach } ) @@ -26,7 +30,10 @@ vi.mock('../daemon/daemon-pty-adapter', () => ({ vi.mock('../ipc/pty/provider/registry', () => ({ registerWslPtyProvider: vi.fn(() => unregister) })) vi.mock('./wsl-daemon-endpoint', () => ({ prepareWslDaemonEndpoint: vi.fn(), - startPreparedWslDaemonOwner: vi.fn() + startPreparedWslDaemonOwner: vi.fn(), + startRetainedWslDaemonOwner: vi.fn(async () => { + throw new Error('owner unverifiable') + }) })) vi.mock('./wsl-daemon-transport', () => ({ createWslDaemonTransport: vi.fn(() => ({})) })) vi.mock('./wsl-bun-runtime', () => ({ createRunningWslRuntimeRunner: vi.fn(() => ({ run })) })) @@ -56,7 +63,9 @@ function deferred() { function setup(record: WslDaemonRecovery | null = null) { const store = { getWslDaemonRecovery: vi.fn(() => record), - upsertWslDaemonRecovery: vi.fn(async (_value: WslDaemonRecovery) => {}) + upsertWslDaemonRecovery: vi.fn(async (value: WslDaemonRecovery) => { + record = value + }) } return { store, @@ -255,26 +264,29 @@ it('reconnects a parsed terminal identity without persisting its session suffix' try { const connection = await sessions.reconnect(parsed) expect(connection.owner).toEqual(owner) - expect(store.upsertWslDaemonRecovery).toHaveBeenCalledWith({ - kind: 'daemon', - ...owner, - endpoint - }) + expect(store.upsertWslDaemonRecovery).toHaveBeenCalledWith( + { + kind: 'daemon', + ...owner, + endpoint + }, + null + ) expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() } finally { await sessions.dispose() } }) -it('restarts a missing cached owner for a fresh spawn without replacing a retained session', async () => { +it('refuses a missing identity-less owner even for a fresh spawn', async () => { const { sessions } = setup({ kind: 'daemon', ...owner, endpoint }) await sessions.prepareFresh('Ubuntu') lease.mockRejectedValueOnce(new Error('owner contact unverifiable')) await expect(sessions.reconnect(owner)).rejects.toThrow('unverifiable') expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() lease.mockRejectedValueOnce(new Error('missing endpoint')) - await sessions.prepareFresh('Ubuntu') - expect(startPreparedWslDaemonOwner).toHaveBeenCalledOnce() + await expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('unverifiable') + expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() expect(DaemonPtyAdapter).toHaveBeenCalledOnce() await sessions.dispose() }) @@ -284,7 +296,7 @@ it('retains a cached owner when fresh startup cannot prove its endpoint absent', await sessions.prepareFresh('Ubuntu') lease.mockRejectedValueOnce(new Error('contact failed')) vi.mocked(startPreparedWslDaemonOwner).mockRejectedValueOnce(new Error('owner unverifiable')) - await expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('owner unverifiable') + await expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('unverifiable') expect(detach).not.toHaveBeenCalled() expect(unregister).not.toHaveBeenCalled() await sessions.reconnect(owner) @@ -292,7 +304,7 @@ it('retains a cached owner when fresh startup cannot prove its endpoint absent', await sessions.dispose() }) -it('lets a fresh caller recover after joining a failed retained-owner reconnect', async () => { +it('does not bypass missing identity proof when fresh spawn joins failed reconnect', async () => { const { sessions } = setup({ kind: 'daemon', ...owner, endpoint }) let fail!: (error: Error) => void lease.mockImplementationOnce( @@ -302,16 +314,16 @@ it('lets a fresh caller recover after joining a failed retained-owner reconnect' }) ) const restore = sessions.reconnect(owner) - const rejected = expect(restore).rejects.toThrow('missing endpoint') + const rejected = expect(restore).rejects.toThrow('unverifiable') await vi.waitFor(() => expect(lease).toHaveBeenCalledOnce()) - const fresh = sessions.prepareFresh('Ubuntu') + const fresh = expect(sessions.prepareFresh('Ubuntu')).rejects.toThrow('unverifiable') await vi.waitFor(() => expect(prepareWslDaemonEndpoint).toHaveBeenCalledOnce()) await Promise.resolve() lease.mockRejectedValueOnce(new Error('missing endpoint')) fail(new Error('missing endpoint')) await rejected await fresh - expect(startPreparedWslDaemonOwner).toHaveBeenCalledOnce() - expect(registerWslPtyProvider).toHaveBeenCalledOnce() + expect(startPreparedWslDaemonOwner).not.toHaveBeenCalled() + expect(registerWslPtyProvider).not.toHaveBeenCalled() await sessions.dispose() }) diff --git a/src/main/wsl/wsl-daemon-sessions.ts b/src/main/wsl/wsl-daemon-sessions.ts index bb686e15a436..e20799885a48 100644 --- a/src/main/wsl/wsl-daemon-sessions.ts +++ b/src/main/wsl/wsl-daemon-sessions.ts @@ -1,3 +1,4 @@ +import { WslDaemonOwnerAdmission } from './wsl-daemon-owner-admission' import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { createHash } from 'node:crypto' import { join } from 'node:path' @@ -14,11 +15,7 @@ import { registerWslPtyProvider } from '../ipc/pty/provider/registry' import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' import { createWslDaemonTransport } from './wsl-daemon-transport' import { WslDaemonPtyProvider } from './wsl-daemon-pty-provider' -import { - prepareWslDaemonEndpoint, - startPreparedWslDaemonOwner, - type PreparedWslDaemonEndpoint -} from './wsl-daemon-endpoint' +import { prepareWslDaemonEndpoint, type PreparedWslDaemonEndpoint } from './wsl-daemon-endpoint' import type { WslAccountExecutionContext } from './wsl-account-execution-context' import { readWslDistributionIdentity } from './wsl-distribution-identity' @@ -32,9 +29,10 @@ export type PreparedWslDaemonTerminalOwner = Readonly<{ execution: WslAccountExecutionContext connection: WslDaemonConnection }> +type AdmittedConnection = WslDaemonConnection & { establishLease: () => Promise } type Entry = { endpoint: Readonly - pending: Promise + pending: Promise close?: () => Promise } @@ -101,7 +99,7 @@ export class WslDaemonSessions { } const reconnecting = this.connect(owner, record.endpoint).then(async (connection) => { this.lifetime.signal.throwIfAborted() - await connection.provider.establishLifecycleLease() + await connection.establishLease() this.lifetime.signal.throwIfAborted() return connection }) @@ -113,7 +111,7 @@ export class WslDaemonSessions { endpoint: PersistedWslDaemonEndpoint, signal?: AbortSignal, prepared?: PreparedWslDaemonEndpoint - ): Promise { + ): Promise { this.lifetime.signal.throwIfAborted() const identity = Object.freeze({ distro: owner.distro, relayBuildId: owner.relayBuildId }) const record = normalizeWslDaemonRecovery({ kind: 'daemon', ...identity, endpoint }) @@ -140,7 +138,7 @@ export class WslDaemonSessions { prepared ? connection.then( async (connected) => { - await this.establishOwnerLease(connected.provider, prepared) + await connected.establishLease() return connected }, () => { @@ -153,42 +151,31 @@ export class WslDaemonSessions { ) } - private async establishOwnerLease( - provider: WslDaemonPtyProvider, - prepared?: PreparedWslDaemonEndpoint - ): Promise { - const signal = this.lifetime.signal - signal.throwIfAborted() - try { - await provider.establishLifecycleLease() - } catch (error) { - signal.throwIfAborted() - if (!prepared) { - throw error - } - await startPreparedWslDaemonOwner(prepared, signal) - signal.throwIfAborted() - await provider.establishLifecycleLease() - } - signal.throwIfAborted() - } - private async open( owner: Readonly, endpoint: Readonly, prepared?: PreparedWslDaemonEndpoint - ): Promise { + ): Promise { const signal = this.lifetime.signal const shell = await readGuestShell(endpoint, signal) signal.throwIfAborted() const historyId = createHash('sha256') .update(JSON.stringify([this.options.profileScope, wslPtyOwnerKey(owner)])) .digest('hex') + const admission = new WslDaemonOwnerAdmission( + this.options.store, + owner, + endpoint, + signal, + prepared + ) const adapter = new DaemonPtyAdapter({ + respawn: admission.recover, profileScope: this.options.profileScope, historyPath: join(this.options.historyRoot, historyId), guest: { distro: owner.distro, + admitIdentity: admission.admitIdentity, transport: createWslDaemonTransport(endpoint), defaultShell: shell, defaultCwd: endpoint.home, @@ -196,10 +183,10 @@ export class WslDaemonSessions { } }) const provider = new WslDaemonPtyProvider(owner, adapter) + const establishLease = () => admission.establishLease(() => provider.establishLifecycleLease()) let unregister: (() => void) | undefined try { - await this.establishOwnerLease(provider, prepared) - await this.options.store.upsertWslDaemonRecovery({ kind: 'daemon', ...owner, endpoint }) + await establishLease() signal.throwIfAborted() unregister = registerWslPtyProvider(owner, provider) const entry = this.entries.get(wslPtyOwnerKey(owner)) @@ -210,7 +197,7 @@ export class WslDaemonSessions { unregister?.() await provider.disconnectOnly() } - return Object.freeze({ owner, endpoint, provider }) + return Object.freeze({ owner, endpoint, provider, establishLease }) } catch (error) { unregister?.() await provider.disconnectOnly() diff --git a/src/main/wsl/wsl-daemon-start-script.test.ts b/src/main/wsl/wsl-daemon-start-script.test.ts index 0889c4fad90a..42e833fd2a59 100644 --- a/src/main/wsl/wsl-daemon-start-script.test.ts +++ b/src/main/wsl/wsl-daemon-start-script.test.ts @@ -1,9 +1,18 @@ +import { createHash } from 'node:crypto' import { EventEmitter } from 'node:events' import { runInNewContext } from 'node:vm' import { expect, it, vi } from 'vitest' import { WSL_DAEMON_START_SCRIPT } from './wsl-daemon-start-script' -it.each(['live', 'EACCES', 'timeout', 'ENOENT', 'ECONNREFUSED'])( +const hash = (value: string) => createHash('sha256').update(value).digest('hex') +const serverBuildId = hash( + JSON.stringify({ + runtime: hash('runtime'), + files: [{ name: 'daemon-entry.js', sha256: hash('entry') }] + }) +) + +it.each(['live', 'EACCES', 'timeout', 'ENOENT', 'ECONNREFUSED', 'changed-artifact'])( 'launches only after verified absent %s contact', async (contact) => { const absent = contact === 'ENOENT' || contact === 'ECONNREFUSED' @@ -13,6 +22,7 @@ it.each(['live', 'EACCES', 'timeout', 'ENOENT', 'ECONNREFUSED'])( argv: [ 'bun', JSON.stringify({ + serverBuildId, userId: '1000', home: '/home/u', runtime: '/bun', @@ -25,23 +35,41 @@ it.each(['live', 'EACCES', 'timeout', 'ENOENT', 'ECONNREFUSED'])( getuid: () => 1000, exitCode: 0 } - const require = () => ({ - createConnection: () => { - const outcome = contacts++ > 0 ? 'live' : contact - const socket = new EventEmitter() - Object.assign(socket, { setTimeout: vi.fn(), destroy: vi.fn() }) - queueMicrotask(() => { - if (outcome === 'live') { - socket.emit('connect') - } else if (outcome === 'timeout') { - socket.emit('timeout') - } else { - socket.emit('error', Object.assign(new Error(outcome), { code: outcome })) - } - }) - return socket + const require = (module: string) => { + if (module === 'node:crypto') { + return { createHash } } - }) + if (module === 'node:fs') { + return { + lstatSync: () => ({ + isFile: () => true, + isSymbolicLink: () => false, + uid: 1000, + mode: 0o700 + }), + readFileSync: (file: string) => + file === '/bun' ? 'runtime' : contact === 'changed-artifact' ? 'changed' : 'entry' + } + } + return { + createConnection: () => { + const outcome = + contacts++ > 0 ? 'live' : contact === 'changed-artifact' ? 'ENOENT' : contact + const socket = new EventEmitter() + Object.assign(socket, { setTimeout: vi.fn(), destroy: vi.fn() }) + queueMicrotask(() => { + if (outcome === 'live') { + socket.emit('connect') + } else if (outcome === 'timeout') { + socket.emit('timeout') + } else { + socket.emit('error', Object.assign(new Error(outcome), { code: outcome })) + } + }) + return socket + } + } + } await runInNewContext(WSL_DAEMON_START_SCRIPT, { require, process: processState, diff --git a/src/main/wsl/wsl-daemon-start-script.ts b/src/main/wsl/wsl-daemon-start-script.ts index 37517c4d7b97..b90cda541107 100644 --- a/src/main/wsl/wsl-daemon-start-script.ts +++ b/src/main/wsl/wsl-daemon-start-script.ts @@ -19,7 +19,17 @@ function contact() { } (async()=>{ if(await contact()==='live'){console.log('existing');return;} - const env={...process.env,HOME:plan.home,PATH:plan.path,ORCA_BACKGROUND_LAUNCH:'1'}; + const fs=require('node:fs'); + const {createHash}=require('node:crypto'); + const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); + const readArtifact=file=>{ + const stat=fs.lstatSync(file); + if(!stat.isFile() || stat.isSymbolicLink() || stat.uid!==process.getuid() || (stat.mode & 0o022)) throw Error('Guest daemon artifact ownership changed'); + return fs.readFileSync(file); + }; + const artifactId=hash(JSON.stringify({runtime:hash(readArtifact(plan.runtime)),files:[{name:'daemon-entry.js',sha256:hash(readArtifact(plan.entry))}]})); + if(artifactId!==plan.serverBuildId) throw Error('Retained guest daemon artifacts changed; refusing restart'); + const env={...process.env,HOME:plan.home,PATH:plan.path??process.env.PATH,ORCA_BACKGROUND_LAUNCH:'1'}; for(const key of ['NODE_OPTIONS','NODE_PATH','BUN_OPTIONS','BUN_INSPECT','ELECTRON_RUN_AS_NODE']) delete env[key]; const child=Bun.spawn([plan.runtime,...${JSON.stringify(bunOwnedRuntimeArgs('linux'))},plan.entry,'--socket',plan.socket,'--token',plan.tokenPath],{ detached:true,stdin:'ignore',stdout:'ignore',stderr:'ignore',cwd:plan.home,env diff --git a/src/shared/wsl-daemon-recovery.ts b/src/shared/wsl-daemon-recovery.ts index d3bb62d3077e..127dcf751727 100644 --- a/src/shared/wsl-daemon-recovery.ts +++ b/src/shared/wsl-daemon-recovery.ts @@ -29,12 +29,24 @@ const endpoint = z serverBuildId: identity }) .strict() +export const wslDaemonIncarnationSchema = z + .object({ + pid: z.number().int().positive().safe(), + startedAtMs: z.number().positive().finite(), + launchNonce: identity, + linuxStartTicks: z.string().regex(/^\d+$/), + bootId: identity + }) + .strict() +export type WslDaemonIncarnation = z.infer + const recovery = z .object({ kind: z.literal('daemon'), distro: identity, relayBuildId: identity, - endpoint + endpoint, + incarnation: wslDaemonIncarnationSchema.optional() }) .strict() .refine((value) => value.distro === value.endpoint.distro) From 8dd6a157b4194ec6297f2da162ff10bb2de3bd02 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:08:24 -0700 Subject: [PATCH 07/53] Validate daemon identity fields without unchecked casts --- .../daemon/daemon-client-hello-handshake.ts | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/main/daemon/daemon-client-hello-handshake.ts b/src/main/daemon/daemon-client-hello-handshake.ts index 189f25cdad32..acdf4143ad0e 100644 --- a/src/main/daemon/daemon-client-hello-handshake.ts +++ b/src/main/daemon/daemon-client-hello-handshake.ts @@ -110,23 +110,18 @@ export function sendDaemonHello( }) } -function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | null { - if (!value || typeof value !== 'object') { +function isDaemonIdentityRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' +} + +function parseDaemonEndpointIdentity(identity: unknown): DaemonEndpointIdentity | null { + if (!isDaemonIdentityRecord(identity)) { return null } - const identity = value as { - pid?: unknown - startedAtMs?: unknown - launchNonce?: unknown - linuxStartTicks?: unknown - bootId?: unknown - entryPath?: unknown - appVersion?: unknown - spawnerExecPath?: unknown - } if ( + typeof identity.pid !== 'number' || !Number.isSafeInteger(identity.pid) || - (identity.pid as number) <= 0 || + identity.pid <= 0 || typeof identity.startedAtMs !== 'number' || !Number.isFinite(identity.startedAtMs) || identity.startedAtMs <= 0 || @@ -149,7 +144,7 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n ...(typeof identity.linuxStartTicks === 'string' && typeof identity.bootId === 'string' ? { linuxStartTicks: identity.linuxStartTicks, bootId: identity.bootId } : {}), - pid: identity.pid as number, + pid: identity.pid, startedAtMs: identity.startedAtMs, launchNonce: identity.launchNonce, ...(typeof identity.entryPath === 'string' && identity.entryPath.length > 0 From 7deaccb78528237753a722914733d6805039c99b Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:17:52 -0700 Subject: [PATCH 08/53] Repair bundled Bun fixture results and platform test prerequisites --- .github/workflows/bun-profile-tests.yml | 2 +- .github/workflows/computer-e2e.yml | 3 ++ config/scripts/computer-e2e-workflow.test.mjs | 16 ++++++++++ src/main/bundled-bun-test-execution.test.ts | 30 +++++++++++++++++++ src/main/bundled-bun-test-execution.ts | 2 +- 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index 39eb8d42b7ad..5a0e6737e019 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -187,7 +187,7 @@ jobs: -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' set -eu - apk add --no-cache bash git libstdc++ python3 make g++ + apk add --no-cache bash git libstdc++ python3 make g++ procps git config --global --add safe.directory /work npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" pnpm install --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index d34c016682f1..9411ab75f472 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -89,6 +89,9 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node + - name: Prepare pinned Bun for Windows shell fixtures + if: runner.os == 'Windows' + run: node --input-type=module -e "import { buildCliRuntime } from './config/scripts/build-cli-runtime.mjs'; await buildCliRuntime(process.platform, process.arch)" # Why: without --config, bare vitest ignores config/vitest.config.ts (there # is no root config) and falls back to the 5s default timeout with no # Windows worker cap, so the real csc.exe launcher-compile tests time out diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index fc605bac5bdc..048ac893e9de 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -78,6 +78,22 @@ describe('computer-use e2e workflow', () => { expect(triggerPaths).not.toContain('src/shared/runtime-types.ts') }) + it('prepares pinned Bun and ConPTY before native Windows shell fixtures', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') + ) + const steps = workflow.jobs['native-smoke'].steps + const preparation = steps.findIndex((step) => + step.run?.includes('await buildCliRuntime(process.platform, process.arch)') + ) + const fixtures = steps.findIndex((step) => + step.run?.includes('windows-shell-preflight-runtime.windows.test.ts') + ) + expect(preparation).toBeGreaterThan(-1) + expect(steps[preparation].if).toBe("runner.os == 'Windows'") + expect(preparation).toBeLessThan(fixtures) + }) + it('runs focused computer-use regression tests in the PR native-smoke job', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') diff --git a/src/main/bundled-bun-test-execution.test.ts b/src/main/bundled-bun-test-execution.test.ts index fbd719b71184..838784898335 100644 --- a/src/main/bundled-bun-test-execution.test.ts +++ b/src/main/bundled-bun-test-execution.test.ts @@ -1,3 +1,5 @@ +import { runInNewContext } from 'node:vm' +import { ORCAD_BUN_VERSION } from '../shared/orcad-bun-runtime' import { afterEach, describe, expect, it, vi } from 'vitest' import { runBundledBunFixture } from './bundled-bun-test-execution' @@ -10,6 +12,34 @@ afterEach(() => { }) describe('bundled Bun fixture launch environment', () => { + it.each([undefined, { output: 'captured' }])( + 'serializes a completed fixture result: %j', + async (value) => { + exists.mockReturnValue(true) + run.mockImplementationOnce(async (spec) => { + let stdout = '' + await runInNewContext(spec.args[1], { + require: (name: string) => + name === 'node:fs' + ? { readFileSync: () => spec.input } + : { capture: async () => value }, + process: { + versions: { bun: ORCAD_BUN_VERSION }, + stdout: { + write: (chunk: string) => { + stdout += chunk + } + } + }, + console + }) + return { code: 0, stdout, stderr: '', timedOut: false, outputTruncated: false } + }) + expect(await runBundledBunFixture('/fixture.ts', 'capture', null, 1000)).toEqual( + value ?? null + ) + } + ) it('removes runtime injection while retaining explicit fixture arguments and shell environment', async () => { exists.mockReturnValue(true) run.mockResolvedValue({ diff --git a/src/main/bundled-bun-test-execution.ts b/src/main/bundled-bun-test-execution.ts index 1c405d8aefe7..01418bd6477c 100644 --- a/src/main/bundled-bun-test-execution.ts +++ b/src/main/bundled-bun-test-execution.ts @@ -25,7 +25,7 @@ export async function runBundledBunFixture( const args = JSON.parse(require('node:fs').readFileSync(0, 'utf8')); const fixture = require(${JSON.stringify(modulePath)}); Promise.resolve(fixture[${JSON.stringify(exportName)}](args)).then( - result => process.stdout.write(JSON.stringify(result)), + result => process.stdout.write(JSON.stringify(result ?? null)), error => { console.error(error); process.exitCode = 1 } ); ` From a716a7c893f6ef9bf3a334f9e775d851a53d66cf Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:18:50 -0700 Subject: [PATCH 09/53] Keep process-group qualification compatible with stock Alpine ps --- .github/workflows/bun-profile-tests.yml | 2 +- src/main/pty/posix-pty-process-groups-fixture.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index 5a0e6737e019..39eb8d42b7ad 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -187,7 +187,7 @@ jobs: -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' set -eu - apk add --no-cache bash git libstdc++ python3 make g++ procps + apk add --no-cache bash git libstdc++ python3 make g++ git config --global --add safe.directory /work npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" pnpm install --frozen-lockfile --ignore-scripts diff --git a/src/main/pty/posix-pty-process-groups-fixture.ts b/src/main/pty/posix-pty-process-groups-fixture.ts index c4c1095dba2a..da2ed57d8d99 100644 --- a/src/main/pty/posix-pty-process-groups-fixture.ts +++ b/src/main/pty/posix-pty-process-groups-fixture.ts @@ -13,7 +13,12 @@ function readPs(args: string[]): string { } function findTaggedProcesses(token: string): TaggedProcess[] { - const output = readPs(['-axo', 'pid=,pgid=,state=,command=']) + const output = readPs([ + '-e', + '-w', + '-o', + 'pid=PROCESS_ID,pgid=PROCESS_GID,stat=PROCESS_STATE,args=COMMAND' + ]) const matches: TaggedProcess[] = [] for (const line of output.split(/\r?\n/)) { if (!line.includes(token)) { @@ -52,7 +57,14 @@ function cleanupTaggedProcesses(token: string): void { } function readProcessGroup(pid: number): number { - return Number(readPs(['-p', String(pid), '-o', 'pgid=']).trim()) + const rows = readPs(['-e', '-o', 'pid=PROCESS_ID,pgid=PROCESS_GID']) + for (const row of rows.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(row) + if (match && Number(match[1]) === pid) { + return Number(match[2]) + } + } + throw new Error('PTY fixture root process group is unavailable') } export async function reapPosixForegroundJob(): Promise<{ From da9ac2907555725cc6393f927905aa8ea8c865ca Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:21:16 -0700 Subject: [PATCH 10/53] Preserve restart listener updates and bound replacement rechecks --- .../daemon/daemon-out-of-process-launcher.ts | 1 + .../daemon/daemon-replacement-preflight.ts | 30 ++++---- ...emon-replacement-runtime-readiness.test.ts | 29 +++++++- src/main/daemon/daemon-restart-state.ts | 4 + .../pty/provider/bind-listeners-wsl.test.ts | 74 ++++++++++++++++++- src/main/ipc/pty/provider/registry.ts | 27 +++++-- 6 files changed, 143 insertions(+), 22 deletions(-) diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 2d5ebdb70c2a..968d09f08027 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -114,6 +114,7 @@ export function createOutOfProcessLauncher( attributedReason, prepareReplacementRuntime: async () => { bunHost = await getReplacementRuntime() + return bunHost !== null }, releaseAdoptionClient, preserveDaemon, diff --git a/src/main/daemon/daemon-replacement-preflight.ts b/src/main/daemon/daemon-replacement-preflight.ts index 145698c99a0b..b1424db1611a 100644 --- a/src/main/daemon/daemon-replacement-preflight.ts +++ b/src/main/daemon/daemon-replacement-preflight.ts @@ -33,7 +33,8 @@ type ReplacementPreflightOptions = { /** Absolute deadline for the whole adopt-or-replace decision; see DAEMON_RECOVERY_BUDGET_MS. */ recoveryDeadlineMs: number attributedReason: DaemonReplaceReason | null - prepareReplacementRuntime: () => Promise + /** False only when this runtime needs no artifact preparation. */ + prepareReplacementRuntime: () => Promise releaseAdoptionClient: () => void preserveDaemon: PreserveDaemon launchNonce: string @@ -213,18 +214,21 @@ export async function prepareDaemonReplacement( } // Adoption needs no new artifacts; replacement must be ready before stopping its owner. - await prepareReplacementRuntime() - // Copying a runtime may take seconds; another client may have spawned during preparation. - const currentSessionCount = await getAliveDaemonSessionCount( - socketPath, - tokenPath, - recoveryDeadlineMs - ) - if ( - (currentSessionCount !== null && currentSessionCount > 0) || - (cleanupProtocol && currentSessionCount === null) - ) { - return preserveDaemon(health === 'pty-spawn-unhealthy' ? 'fresh-spawns-unavailable' : undefined) + const preparedRuntime = await prepareReplacementRuntime() + if (preparedRuntime) { + // Artifact preparation invalidates the earlier count; an expired budget gives no new proof. + const currentSessionCount = + Date.now() < recoveryDeadlineMs + ? await getAliveDaemonSessionCount(socketPath, tokenPath, recoveryDeadlineMs) + : null + if ( + (currentSessionCount !== null && currentSessionCount > 0) || + (cleanupProtocol && currentSessionCount === null) + ) { + return preserveDaemon( + health === 'pty-spawn-unhealthy' ? 'fresh-spawns-unavailable' : undefined + ) + } } if (cleanupProtocol) { confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)).cleaned diff --git a/src/main/daemon/daemon-replacement-runtime-readiness.test.ts b/src/main/daemon/daemon-replacement-runtime-readiness.test.ts index fcfb859815a5..5eaa6dc2aa6e 100644 --- a/src/main/daemon/daemon-replacement-runtime-readiness.test.ts +++ b/src/main/daemon/daemon-replacement-runtime-readiness.test.ts @@ -36,7 +36,7 @@ function options() { recoveryDeadlineMs: Date.now() + 1000, attributedReason: null, launchNonce: 'nonce', - prepareReplacementRuntime: vi.fn(async () => {}), + prepareReplacementRuntime: vi.fn(async () => true), releaseAdoptionClient: vi.fn(), preserveDaemon: vi.fn(async () => ({ adopted: true as const, shutdown: async () => {} })) } @@ -103,3 +103,30 @@ it.each([1, null])( expect(mocks.kill).not.toHaveBeenCalled() } ) + +it('retains the prior evidence without a redundant probe when no runtime is materialized', async () => { + mocks.identity.mockResolvedValue('mismatch') + const args = options() + args.prepareReplacementRuntime.mockResolvedValue(false) + await prepareDaemonReplacement(args) + expect(mocks.sessions).toHaveBeenCalledOnce() + expect(mocks.kill).toHaveBeenCalledOnce() +}) +it('does not extend an exhausted budget or treat pre-copy emptiness as current proof', async () => { + mocks.identity.mockResolvedValue('mismatch') + const args = options() + const clock = vi.spyOn(Date, 'now') + clock.mockReturnValue(args.recoveryDeadlineMs - 100) + args.prepareReplacementRuntime.mockImplementation(async () => { + clock.mockReturnValue(args.recoveryDeadlineMs) + return true + }) + try { + expect(await prepareDaemonReplacement(args)).toMatchObject({ adopted: true }) + expect(mocks.sessions).toHaveBeenCalledOnce() + expect(mocks.cleanup).not.toHaveBeenCalled() + expect(mocks.kill).not.toHaveBeenCalled() + } finally { + clock.mockRestore() + } +}) diff --git a/src/main/daemon/daemon-restart-state.ts b/src/main/daemon/daemon-restart-state.ts index e37fad69709f..d65ff39bdcad 100644 --- a/src/main/daemon/daemon-restart-state.ts +++ b/src/main/daemon/daemon-restart-state.ts @@ -5,6 +5,10 @@ export type RestartDaemonResult = { // Why: coalesce concurrent restartDaemon() calls so two entries can't race the 7-step sequence against a half-spawned replacement. let restartInFlight: Promise | null = null +export function getDaemonRestartInFlight(): Promise | null { + return restartInFlight +} + export function isDaemonRestartInFlight(): boolean { return restartInFlight !== null } diff --git a/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts b/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts index 3d8e0bbc4be6..a6628f31950e 100644 --- a/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts +++ b/src/main/ipc/pty/provider/bind-listeners-wsl.test.ts @@ -1,4 +1,7 @@ -import { runCoalescedDaemonRestart } from '../../../daemon/daemon-restart-state' +import { + isDaemonRestartInFlight, + runCoalescedDaemonRestart +} from '../../../daemon/daemon-restart-state' import { afterEach, expect, it, vi } from 'vitest' import { bindProviderListeners } from './bind-listeners' import { setRebindProviderListeners, unbindLocalProviderListeners } from './listener-lifecycle' @@ -91,8 +94,77 @@ it('does not rebind a retiring local provider when guests change during restart' finish() await pending } + expect(rebind).toHaveBeenCalledOnce() + rebind.mockClear() releases.push( registerWslPtyProvider({ distro: 'Ubuntu', relayBuildId: 'after-restart' }, provider().value) ) expect(rebind).toHaveBeenCalledOnce() }) + +it.each([false, true])( + 'binds a guest arriving after the final rebind but before restart settles (failed=%s)', + async (failed) => { + const native = provider(), + guest = provider() + setLocalPtyProvider(native.value) + const session = { + acceptPtyDataForRenderer: vi.fn(), + sendModelRestoreNeededMarker: vi.fn(), + consumeSyntheticKillExit: vi.fn(), + sendPtyExitToRenderer: vi.fn() + } + const rebind = vi.fn(() => bindProviderListeners(session)) + setRebindProviderListeners(rebind) + let lateRegistered = false + const pending = runCoalescedDaemonRestart(async () => { + await Promise.resolve() + rebind() + queueMicrotask(() => { + expect(isDaemonRestartInFlight()).toBe(true) + releases.push( + registerWslPtyProvider({ distro: 'Ubuntu', relayBuildId: 'late' }, guest.value) + ) + expect(guest.listeners.size).toBe(0) + lateRegistered = true + }) + if (failed) { + throw new Error('restart failed') + } + return { killedCount: 0 } + }) + await (failed ? expect(pending).rejects.toThrow('restart failed') : pending) + await vi.waitFor(() => expect(guest.listeners.size).toBe(1)) + expect(lateRegistered).toBe(true) + expect(native.listeners.size).toBe(1) + guest.emit('guest') + expect(session.acceptPtyDataForRenderer).toHaveBeenCalledOnce() + expect(rebind).toHaveBeenCalledTimes(2) + } +) + +it('rebinds once after a failed restart with registrations and removals in flight', async () => { + const rebind = vi.fn() + setRebindProviderListeners(rebind) + let fail!: (error: Error) => void + const pending = runCoalescedDaemonRestart( + () => + new Promise((_resolve, reject) => { + fail = reject + }) + ) + const rejection = expect(pending).rejects.toThrow('failed') + const release = registerWslPtyProvider( + { distro: 'Ubuntu', relayBuildId: 'failed' }, + provider().value + ) + releases.push(release) + release() + releases.push( + registerWslPtyProvider({ distro: 'Ubuntu', relayBuildId: 'survivor' }, provider().value) + ) + expect(rebind).not.toHaveBeenCalled() + fail(new Error('failed')) + await rejection + await vi.waitFor(() => expect(rebind).toHaveBeenCalledOnce()) +}) diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts index a1da362b5ca9..b7a05f6f812a 100644 --- a/src/main/ipc/pty/provider/registry.ts +++ b/src/main/ipc/pty/provider/registry.ts @@ -1,4 +1,4 @@ -import { isDaemonRestartInFlight } from '../../../daemon/daemon-restart-state' +import { getDaemonRestartInFlight } from '../../../daemon/daemon-restart-state' import { parseAppWslPtyId, type WslPtyOwner } from '../../../../shared/wsl-pty-id' import { wslPtyOwnerKey } from '../../../../shared/wsl-pty-consumer-recovery' import { relayProvidersByGeneration } from '../../../providers/relay-pty-generation-registry' @@ -44,6 +44,23 @@ export function getProvider(connectionId: string | null | undefined): IPtyProvid return provider } +let rebindDeferred = false +function requestWslProviderListenerRebind(): void { + const restart = getDaemonRestartInFlight() + if (!restart) { + rebindLocalProviderListeners() + } else if (!rebindDeferred) { + rebindDeferred = true + const settled = (): void => { + rebindDeferred = false + requestWslProviderListenerRebind() + } + void restart.then(settled, settled).catch((error) => { + console.warn('[pty] Could not rebind guest terminal listeners after restart', error) + }) + } +} + export function registerWslPtyProvider(owner: WslPtyOwner, provider: IPtyProvider): () => void { const key = wslPtyOwnerKey(owner) const previous = wslProviders.get(key) @@ -51,15 +68,11 @@ export function registerWslPtyProvider(owner: WslPtyOwner, provider: IPtyProvide throw new Error('WSL terminal owner already registered') } wslProviders.set(key, provider) - if (!isDaemonRestartInFlight()) { - rebindLocalProviderListeners() - } + requestWslProviderListenerRebind() return () => { if (wslProviders.get(key) === provider) { wslProviders.delete(key) - if (!isDaemonRestartInFlight()) { - rebindLocalProviderListeners() - } + requestWslProviderListenerRebind() } } } From 40507a9e541eee1f5e38a5e243ad521fade594fb Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:24:06 -0700 Subject: [PATCH 11/53] Avoid waiting for startup markers in plain guest shells --- src/main/daemon/daemon-pty-guest-execution.test.ts | 8 ++++++++ src/main/daemon/daemon-pty-session-spawn.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/daemon/daemon-pty-guest-execution.test.ts b/src/main/daemon/daemon-pty-guest-execution.test.ts index 8c8eb68fc1aa..cc7744096bf6 100644 --- a/src/main/daemon/daemon-pty-guest-execution.test.ts +++ b/src/main/daemon/daemon-pty-guest-execution.test.ts @@ -87,6 +87,14 @@ describe('guest daemon execution boundary', () => { expect(reportCwd).not.toHaveBeenCalled() }) + it('delivers plain guest shell input without waiting for an unrequested startup marker', async () => { + const { provider: p } = provider() + const result = await p.spawn({ cols: 80, rows: 24, isNewSession: true }) + await p.writeWithSettlement(result.id, 'echo ready\r') + await waitFor(() => subprocess.write.mock.calls.length > 0) + expect(subprocess.write).toHaveBeenCalledWith('echo ready\r') + }) + it('does not spawn before durable admission and refuses a connection changed while waiting', async () => { let release!: () => void const admission = new Promise((resolve) => { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 8ad9a2e4f67e..bafc967f0e88 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -225,7 +225,8 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { : process.platform !== 'win32' && opts.command ? resolveUnixShellPath(opts.shellOverride || resolvePtyShellPath(opts.env ?? {})) : '' - const shellReadySupported = shellPathSupportsPtyStartupBarrier(effectiveShellPath) + const shellReadySupported = + Boolean(opts.command) && shellPathSupportsPtyStartupBarrier(effectiveShellPath) const immediateMarker = shellReadyMarkerComesFromLineEditor(effectiveShellPath) const shellReadyTimeoutMs = shellReadySupported && From bf6dc897282afaa91a0ea26f52283b81f84756a4 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:29:09 -0700 Subject: [PATCH 12/53] Remove retired WSL paths from failure-semantics exceptions --- .../__fixtures__/wsl-probe-failure-swallow-allowlist.txt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt index c05435dd9e7e..494befc7bc28 100644 --- a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt @@ -30,9 +30,6 @@ main/wsl.ts # that one call and is re-asked on the next, so there is no value to pin. main/wsl-interop-spawn-directory.ts -# Dormant WSL relay/snapshot compatibility surfaces. Their catches only retire a -# best-effort stream or return an unavailable restore surface; no result is cached -# or used to claim guest discovery. They are removed with the relay retirement. +# Artifact verification failures refuse installation or replacement; no negative +# discovery result is cached. main/wsl/wsl-guest-artifact-install-script.ts -main/wsl/wsl-pty-restore-surface.ts -main/wsl/wsl-relay-output-binding.ts From 66db147e8b22466f940fe51b1e37fd8abab86571 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:35:44 -0700 Subject: [PATCH 13/53] Fix BusyBox fixture flags and extract rate-limit PTY fixtures --- .../pty/posix-pty-process-groups-fixture.ts | 5 ++-- src/main/rate-limits/codex-fetcher.test.ts | 25 +----------------- .../rate-limits/codex-rate-limit-test-pty.ts | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+), 26 deletions(-) create mode 100644 src/main/rate-limits/codex-rate-limit-test-pty.ts diff --git a/src/main/pty/posix-pty-process-groups-fixture.ts b/src/main/pty/posix-pty-process-groups-fixture.ts index da2ed57d8d99..a4686bd049d3 100644 --- a/src/main/pty/posix-pty-process-groups-fixture.ts +++ b/src/main/pty/posix-pty-process-groups-fixture.ts @@ -7,7 +7,9 @@ type TaggedProcess = { pid: number; pgid: number } function readPs(args: string[]): string { const result = runProcessSync({ program: 'ps', args, timeoutMs: 2_000 }) if (result.code !== 0 || result.timedOut || result.outputTruncated) { - throw new Error('PTY fixture process table is unavailable') + throw new Error( + `PTY fixture process table is unavailable (exit=${result.code}, timeout=${result.timedOut}, truncated=${result.outputTruncated}): ${result.stderr}` + ) } return result.stdout } @@ -15,7 +17,6 @@ function readPs(args: string[]): string { function findTaggedProcesses(token: string): TaggedProcess[] { const output = readPs([ '-e', - '-w', '-o', 'pid=PROCESS_ID,pgid=PROCESS_GID,stat=PROCESS_STATE,args=COMMAND' ]) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index d7f33fa1d6a3..bd50a14d3804 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -1,3 +1,4 @@ +import { makeDisposable, makePtyTerm } from './codex-rate-limit-test-pty' import { EventEmitter } from 'node:events' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -54,10 +55,6 @@ import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' import { getCmdExePath } from '../win32-utils' import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' -function makeDisposable() { - return { dispose: vi.fn() } -} - function makeRpcChild() { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter @@ -109,26 +106,6 @@ function respondToRpcRateLimitRead( }) } -function makePtyTerm() { - let dataHandler: ((data: string) => void) | null = null - let exitHandler: (() => void) | null = null - return { - onData: vi.fn((callback: (data: string) => void) => { - dataHandler = callback - return makeDisposable() - }), - onError: vi.fn(() => ({ dispose: vi.fn() })), - onExit: vi.fn((callback: () => void) => { - exitHandler = callback - return makeDisposable() - }), - write: vi.fn(), - kill: vi.fn(), - emitData: (data: string) => dataHandler?.(data), - emitExit: () => exitHandler?.() - } -} - describe('fetchCodexRateLimits', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/main/rate-limits/codex-rate-limit-test-pty.ts b/src/main/rate-limits/codex-rate-limit-test-pty.ts new file mode 100644 index 000000000000..5dc8a0e4b822 --- /dev/null +++ b/src/main/rate-limits/codex-rate-limit-test-pty.ts @@ -0,0 +1,26 @@ +import { vi } from 'vitest' + +export function makeDisposable() { + return { dispose: vi.fn() } +} + +export function makePtyTerm() { + let dataHandler: ((data: string) => void) | null = null + let exitHandler: (() => void) | null = null + return { + onData: vi.fn((callback: (data: string) => void) => { + dataHandler = callback + return makeDisposable() + }), + onError: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn((callback: () => void) => { + exitHandler = callback + return makeDisposable() + }), + write: vi.fn(), + kill: vi.fn(), + emitData: (data: string) => dataHandler?.(data), + emitExit: () => exitHandler?.() + } +} + From cbe2a6167c5ad6e7ff3fb1062844029b30cc382b Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:40:28 -0700 Subject: [PATCH 14/53] Keep exported PTY mock types portable --- src/main/rate-limits/codex-rate-limit-test-pty.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/rate-limits/codex-rate-limit-test-pty.ts b/src/main/rate-limits/codex-rate-limit-test-pty.ts index 5dc8a0e4b822..299cebdf5198 100644 --- a/src/main/rate-limits/codex-rate-limit-test-pty.ts +++ b/src/main/rate-limits/codex-rate-limit-test-pty.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest' export function makeDisposable() { - return { dispose: vi.fn() } + return { dispose: vi.fn<() => void>() } } export function makePtyTerm() { @@ -12,13 +12,13 @@ export function makePtyTerm() { dataHandler = callback return makeDisposable() }), - onError: vi.fn(() => ({ dispose: vi.fn() })), + onError: vi.fn(() => ({ dispose: vi.fn<() => void>() })), onExit: vi.fn((callback: () => void) => { exitHandler = callback return makeDisposable() }), - write: vi.fn(), - kill: vi.fn(), + write: vi.fn<(data: string) => void>(), + kill: vi.fn<() => void>(), emitData: (data: string) => dataHandler?.(data), emitExit: () => exitHandler?.() } From 2596e3d43fc1e243b439b1ae2b305b53e297546c Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:40:39 -0700 Subject: [PATCH 15/53] Cold restore retained WSL panes after authenticated session absence --- src/main/ipc/pty/ipc/spawn-env-codex.ts | 2 +- src/main/ipc/pty/ipc/spawn-env.ts | 2 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 2 +- src/main/ipc/pty/ipc/spawn-wsl-options.ts | 15 ++++-- .../ipc/pty/ipc/spawn-wsl-routing.test.ts | 45 +++++++++++++++++ .../pty/runtime/spawn-guest-environment.ts | 12 ++++- src/main/ipc/pty/runtime/spawn-preflight.ts | 8 ++-- .../ipc/pty/runtime/spawn-wsl-routing.test.ts | 38 +++++++++++++++ src/main/wsl/wsl-daemon-spawn-route.ts | 9 +++- src/main/wsl/wsl-guest-spawn-options.test.ts | 34 +++++++++++++ src/main/wsl/wsl-guest-spawn-options.ts | 15 ++++-- .../wsl-guest-terminal-preparation.test.ts | 48 +++++++++++++++++++ .../wsl/wsl-guest-terminal-preparation.ts | 29 +++++++++-- 13 files changed, 240 insertions(+), 19 deletions(-) diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index f11404be2040..226c704ee651 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -34,7 +34,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.codexSelectionTarget = ctx.wslGuest ? { runtime: 'wsl', wslDistro: ctx.wslGuest.execution.distro } : getCodexSelectionTargetForPty(ctx.effectiveShellOverride, ctx.cwd, ctx.expectedWslDistro) - if (ctx.wslGuest && !ctx.wslGuest.fresh) { + if (ctx.wslGuest && !ctx.wslGuest.fresh && !ctx.wslGuest.coldRestore) { ctx.env = ctx.baseEnv return } diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index 7cbdcec04969..a60d084534e5 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -71,7 +71,7 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise = {}) { } }) const provider = new WslDaemonPtyProvider(owner, adapter) + vi.spyOn(provider, 'probePtyLiveness').mockResolvedValue(true) const connection = { owner, endpoint, provider } const sessions = new WslDaemonSessions({ profileScope: '/profile', @@ -284,3 +285,47 @@ it('passes captured user to Codex selection and avoids desktop backfill probes o expect(buildPtyHostEnv).not.toHaveBeenCalled() adapter.dispose() }) + +it('cold restores the same absent guest ID with captured-user auth and guest environment preparation', async () => { + const id = toAppWslPtyId(owner, 'guest-pty') + const { ctx, provider, fresh, auth, selected, spawn, adapter } = fixture({ + sessionId: id, + command: 'claude --resume current' + }) + vi.mocked(provider.probePtyLiveness).mockResolvedValue(false) + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + await buildPtyIpcSpawnOptions(ctx) + expect(ctx.wslGuest).toMatchObject({ fresh: false, coldRestore: true }) + expect(fresh).not.toHaveBeenCalled() + expect(auth).toHaveBeenCalledWith(expect.anything(), execution) + expect(selected).toHaveBeenCalled() + expect(prepareWslGuestTerminalSpawn).toHaveBeenCalledWith( + expect.objectContaining({ owner, endpoint }), + expect.objectContaining({ sessionId: id, isNewSession: false, attachOnly: false }), + expect.anything(), + undefined, + 'confirmed-exited' + ) + await executePtyIpcSpawn(ctx) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: id, + isNewSession: false, + attachOnly: false, + command: 'claude --resume current' + }) + ) + adapter.dispose() +}) +it('refuses retained guest restore when the admitted owner cannot prove session absence', async () => { + const { ctx, provider, fresh, auth, spawn, adapter } = fixture({ + sessionId: toAppWslPtyId(owner, 'guest-pty') + }) + vi.mocked(provider.probePtyLiveness).mockResolvedValue(null) + await expect(preparePtyIpcSpawnPreflight(ctx)).rejects.toThrow('unverifiable') + expect(fresh).not.toHaveBeenCalled() + expect(auth).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + adapter.dispose() +}) diff --git a/src/main/ipc/pty/runtime/spawn-guest-environment.ts b/src/main/ipc/pty/runtime/spawn-guest-environment.ts index 740c146c33a3..5943a9a54c50 100644 --- a/src/main/ipc/pty/runtime/spawn-guest-environment.ts +++ b/src/main/ipc/pty/runtime/spawn-guest-environment.ts @@ -10,6 +10,15 @@ export async function prepareRuntimeGuestSpawnOptions(ctx: RuntimePtySpawnState) if (!ctx.wslGuest) { return } + if (!ctx.wslGuest.fresh && !ctx.wslGuest.coldRestore) { + ctx.spawnOptions.attachOnly = true + return + } + if (ctx.wslGuest.coldRestore) { + ctx.spawnOptions.sessionId = ctx.effectiveSessionAppId + ctx.spawnOptions.isNewSession = false + ctx.spawnOptions.attachOnly = false + } const settings = ctx.deps.getSettings?.() const environment = getAppEnvironment() ctx.spawnOptions = await prepareWslGuestTerminalSpawn( @@ -31,7 +40,8 @@ export async function prepareRuntimeGuestSpawnOptions(ctx: RuntimePtySpawnState) routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: true }, - ctx.args.signal + ctx.args.signal, + ctx.wslGuest.coldRestore ? 'confirmed-exited' : undefined ) ctx.env = ctx.spawnOptions.env if (ctx.env) { diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 0f12ff28b153..e40f9bc431db 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -112,7 +112,7 @@ export async function prepareRuntimePtySpawn( ctx.expectedWslDistro = ctx.wslGuest.connection.owner.distro ctx.isDaemonHostSpawn = true ctx.isNewDaemonSession = ctx.wslGuest.fresh - ctx.isClaudeLaunch &&= ctx.wslGuest.fresh + ctx.isClaudeLaunch &&= ctx.wslGuest.fresh || ctx.wslGuest.coldRestore === true ctx.sessionId ??= args.agentSessionCreateOperationId ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) : mintPtySessionId(args.worktreeId) @@ -143,7 +143,7 @@ export async function prepareRuntimePtySpawn( ctx.expectedWslDistro ) const codexResumePreparation = - ctx.preAdoptedStablePane || (ctx.wslGuest && !ctx.wslGuest.fresh) + ctx.preAdoptedStablePane || (ctx.wslGuest && !ctx.wslGuest.fresh && !ctx.wslGuest.coldRestore) ? null : ctx.deps.prepareCodexResumeHome({ connectionId: args.connectionId, @@ -208,7 +208,9 @@ export async function prepareRuntimePtySpawn( launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined })) ?? null ctx.selectedCodexHomePath = - !ctx.preAdoptedStablePane && !args.connectionId && (!ctx.wslGuest || ctx.wslGuest.fresh) + !ctx.preAdoptedStablePane && + !args.connectionId && + (!ctx.wslGuest || ctx.wslGuest.fresh || ctx.wslGuest.coldRestore) ? getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, codexResumeHome diff --git a/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts b/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts index ba3243f67246..e073d79be1e4 100644 --- a/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts +++ b/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts @@ -295,3 +295,41 @@ it('prepares captured-user hooks for an already-materialized runtime guest pane' hooksEnabled: true }) }) + +it('prepares same-ID runtime cold restore with captured guest account context', async () => { + vi.mocked(prepareWslDaemonSpawnRoute).mockResolvedValue({ ...route(false), coldRestore: true }) + const deps = makeDeps() + deps.getSelectedCodexHomePath = vi.fn(async () => null) + deps.prepareClaudeAuth = vi.fn(async () => ({ + configDir: '/home/alice/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'test' + })) + deps.prepareCodexResumeHome = vi.fn(() => null) + const id = toAppWslPtyId(owner, 'pty2:kept:1') + const ctx = createRuntimePtySpawnState(deps, { + cols: 80, + rows: 24, + sessionId: id, + command: 'claude --resume current' + }) + await prepareRuntimePtySpawn(ctx) + await buildRuntimePtySpawnOptions(ctx) + expect(ctx.spawnOptions).toMatchObject({ + sessionId: id, + isNewSession: false, + attachOnly: false, + command: 'claude --resume current' + }) + expect(deps.prepareClaudeAuth).toHaveBeenCalledWith(expect.anything(), endpoint) + expect(deps.prepareCodexResumeHome).toHaveBeenCalled() + expect(prepareWslGuestTerminalSpawn).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ sessionId: id }), + expect.anything(), + undefined, + 'confirmed-exited' + ) + ctx.finishTerminalInstall() +}) diff --git a/src/main/wsl/wsl-daemon-spawn-route.ts b/src/main/wsl/wsl-daemon-spawn-route.ts index 32928870e683..9101cbff6b47 100644 --- a/src/main/wsl/wsl-daemon-spawn-route.ts +++ b/src/main/wsl/wsl-daemon-spawn-route.ts @@ -8,6 +8,7 @@ export type WslDaemonSpawnRoute = Readonly<{ execution: WslAccountExecutionContext prepared: PreparedWslGuestSpawnOwner fresh: boolean + coldRestore?: boolean }> /** Existing unencoded IDs retain their Windows owner; only fresh WSL terminals change backend. */ @@ -32,6 +33,11 @@ export async function prepareWslDaemonSpawnRoute(args: { throw new Error('WSL terminal owner is unavailable on this host') } const connection = await args.sessions.reconnect(owner, args.signal) + const live = await connection.provider.probePtyLiveness(args.sessionId!) + args.signal?.throwIfAborted() + if (live === null) { + throw new Error('WSL terminal liveness is unverifiable; refusing cold restore') + } return Object.freeze({ connection, prepared: Object.freeze({ owner: connection.owner, endpoint: connection.endpoint }), @@ -41,7 +47,8 @@ export async function prepareWslDaemonSpawnRoute(args: { userId: connection.endpoint.userId, home: connection.endpoint.home }), - fresh: false + fresh: false, + coldRestore: live === false }) } if ( diff --git a/src/main/wsl/wsl-guest-spawn-options.test.ts b/src/main/wsl/wsl-guest-spawn-options.test.ts index f89b915485f3..03a3cfc6325f 100644 --- a/src/main/wsl/wsl-guest-spawn-options.test.ts +++ b/src/main/wsl/wsl-guest-spawn-options.test.ts @@ -273,3 +273,37 @@ it('accepts UNC paths with registered casing for a canonical fresh owner', async }) ) }) + +it('translates a positively absent daemon session without reminting its ID or inheriting desktop paths', async () => { + run.mockResolvedValue('ready') + const id = toAppWslPtyId(daemon.owner, 'retained') + const result = await prepareOptions( + daemon, + { + cols: 80, + rows: 24, + sessionId: id, + isNewSession: false, + cwd: '\\\\wsl$\\Ubuntu\\work', + worktreeId: 'folder', + historyIsolationEnabled: true, + env: { PATH: 'C:\\bin', HOME: 'C:\\Users\\bob', ORCA_USER_DATA_PATH: 'C:\\profile' } + }, + undefined, + 'confirmed-exited' + ) + expect(result).toMatchObject({ + sessionId: id, + isNewSession: false, + cwd: '/work', + shellOverride: '/bin/bash', + env: { HOME: '/home/user', PATH: '/usr/bin' } + }) + expect(result.env?.HISTFILE).toContain('/home/user/.orca-wsl/') + expect(runWslProcess).toHaveBeenCalledWith( + expect.objectContaining({ + user: 'user', + env: expect.not.objectContaining({ PATH: 'C:\\bin', HOME: 'C:\\Users\\bob' }) + }) + ) +}) diff --git a/src/main/wsl/wsl-guest-spawn-options.ts b/src/main/wsl/wsl-guest-spawn-options.ts index f091a8d057a4..d27c52e9c5ff 100644 --- a/src/main/wsl/wsl-guest-spawn-options.ts +++ b/src/main/wsl/wsl-guest-spawn-options.ts @@ -75,19 +75,28 @@ function parseEnvironment(value: string): Record { export async function prepareWslGuestSpawnOptions( captured: PreparedWslGuestSpawnOwner, options: PtySpawnOptions, - signal?: AbortSignal + signal?: AbortSignal, + mode?: 'confirmed-exited' ): Promise { const prepared = capturedWslGuestSpawnOwner(captured) const { owner } = prepared const execution = createRunningWslRuntimeRunner(owner.distro, signal, prepared.endpoint.userName) + if ( + mode === 'confirmed-exited' && + (!prepared.daemon || !options.sessionId || options.isNewSession) + ) { + throw new Error('Cold restore requires an existing guest daemon terminal identity') + } if (options.sessionId && !options.isNewSession) { toRelayWslPtyId(owner, options.sessionId) - return { ...options, isNewSession: false } + if (mode !== 'confirmed-exited') { + return { ...options, isNewSession: false } + } } if (options.attachOnly) { throw new Error('A WSL attach cannot create a new terminal') } - if (options.sessionId && parseAppWslPtyId(options.sessionId)) { + if (mode !== 'confirmed-exited' && options.sessionId && parseAppWslPtyId(options.sessionId)) { throw new Error('A WSL terminal identity cannot be reminted as a fresh spawn') } const accountExecution = { diff --git a/src/main/wsl/wsl-guest-terminal-preparation.test.ts b/src/main/wsl/wsl-guest-terminal-preparation.test.ts index c964b64a6943..abbc149ae1f1 100644 --- a/src/main/wsl/wsl-guest-terminal-preparation.test.ts +++ b/src/main/wsl/wsl-guest-terminal-preparation.test.ts @@ -176,6 +176,7 @@ it.each([true, false])( endpoint: daemon.endpoint }), expect.anything(), + undefined, undefined ) } @@ -207,6 +208,7 @@ it('keeps the daemon owner captured while hook readiness awaits', async () => { endpoint: expect.objectContaining({ userName: 'alice', home: '/home/alice' }) }), expect.anything(), + undefined, undefined ) }) @@ -264,3 +266,49 @@ it('accepts a selected account UNC path using registered distro casing', async ( expect.objectContaining({ selectedCodexHomePath: '/home/alice/.codex', wslDistro: 'ubuntu' }) ) }) + +it('prepares a confirmed exited terminal with its raw guest identity and current account', async () => { + const daemon = { + owner: prepared.owner, + endpoint: { + ...prepared.endpoint, + userId: '1000', + home: '/home/alice', + envBinary: '/usr/bin/env', + tokenPath: '/token' + } + } + const sessionId = toAppWslPtyId(prepared.owner, 'pty2:owner:1') + const result = await prepareWslGuestTerminalSpawn( + daemon, + { ...options, sessionId, isNewSession: false, command: 'claude --resume current' }, + policy, + undefined, + 'confirmed-exited' + ) + expect(result).toMatchObject({ + sessionId, + isNewSession: false, + command: 'claude --resume current' + }) + expect(buildPtyHostEnv).toHaveBeenCalledWith( + 'pty2:owner:1', + options.env, + expect.objectContaining({ wslUser: 'alice', selectedCodexHomePath: '/home/alice/.codex' }) + ) + vi.clearAllMocks() + await expect( + prepareWslGuestTerminalSpawn( + daemon, + { + ...options, + sessionId: toAppWslPtyId({ ...prepared.owner, relayBuildId: 'foreign' }, 'pty2:owner:1'), + isNewSession: false + }, + policy, + undefined, + 'confirmed-exited' + ) + ).rejects.toThrow() + expect(buildPtyHostEnv).not.toHaveBeenCalled() +}) diff --git a/src/main/wsl/wsl-guest-terminal-preparation.ts b/src/main/wsl/wsl-guest-terminal-preparation.ts index 3799d56eda87..a9977cd94c5b 100644 --- a/src/main/wsl/wsl-guest-terminal-preparation.ts +++ b/src/main/wsl/wsl-guest-terminal-preparation.ts @@ -26,15 +26,27 @@ export async function prepareWslGuestTerminalSpawn( captured: PreparedWslGuestSpawnOwner, options: PtySpawnOptions, policy: BuildPtyHostEnvOptions, - signal?: AbortSignal + signal?: AbortSignal, + mode?: 'confirmed-exited' ): Promise { const prepared = capturedWslGuestSpawnOwner(captured) throwIfSignalAborted(signal) + if ( + mode === 'confirmed-exited' && + (!prepared.daemon || !options.sessionId || options.isNewSession) + ) { + throw new Error('Cold restore requires an existing guest daemon terminal identity') + } if (options.sessionId && !options.isNewSession) { toRelayWslPtyId(prepared.owner, options.sessionId) - return prepareWslGuestSpawnOptions(prepared, options, signal) + if (mode !== 'confirmed-exited') { + return prepareWslGuestSpawnOptions(prepared, options, signal) + } } - if (options.attachOnly || (options.sessionId && parseAppWslPtyId(options.sessionId))) { + if ( + options.attachOnly || + (mode !== 'confirmed-exited' && options.sessionId && parseAppWslPtyId(options.sessionId)) + ) { throw new Error('Guest spawn preparation cannot replace an existing terminal owner') } const { userName: user, distro } = prepared.endpoint @@ -95,7 +107,13 @@ export async function prepareWslGuestTerminalSpawn( } } throwIfSignalAborted(signal) - const env = buildPtyHostEnv(options.sessionId, { ...options.env }, ownedPolicy) + const env = buildPtyHostEnv( + mode === 'confirmed-exited' + ? toRelayWslPtyId(prepared.owner, options.sessionId) + : options.sessionId, + { ...options.env }, + ownedPolicy + ) return prepareWslGuestSpawnOptions( prepared, { @@ -106,6 +124,7 @@ export async function prepareWslGuestTerminalSpawn( getInheritedAgentHookEnvKeysToDelete(env) ) }, - signal + signal, + mode ) } From 93a8997b914334db2951a6541b6ca398203658c7 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:51:19 -0700 Subject: [PATCH 16/53] Cover retained guest preparation failure and live attach policy --- .../ipc/pty/ipc/spawn-wsl-routing.test.ts | 29 +++++++++++++++++++ .../ipc/pty/runtime/spawn-wsl-routing.test.ts | 3 ++ 2 files changed, 32 insertions(+) diff --git a/src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts b/src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts index 0574e11208c0..0c9a984cae31 100644 --- a/src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts +++ b/src/main/ipc/pty/ipc/spawn-wsl-routing.test.ts @@ -1,3 +1,4 @@ +import * as ProviderStateCleanup from '../provider/state-cleanup' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' import type * as GuestSpawnOptions from '../../../wsl/wsl-guest-spawn-options' import type * as GuestTerminalPreparation from '../../../wsl/wsl-guest-terminal-preparation' @@ -329,3 +330,31 @@ it('refuses retained guest restore when the admitted owner cannot prove session expect(spawn).not.toHaveBeenCalled() adapter.dispose() }) + +it.each([false, true])( + 'preserves retained provider state on preparation failure (fresh=%s)', + async (freshSession) => { + const id = toAppWslPtyId(owner, 'retained-after-failure') + const { ctx, provider, spawn, adapter } = fixture(freshSession ? {} : { sessionId: id }) + vi.mocked(provider.probePtyLiveness).mockResolvedValue(false) + const clear = vi.spyOn(ProviderStateCleanup, 'clearProviderPtyState') + try { + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + vi.mocked(prepareWslGuestTerminalSpawn).mockRejectedValueOnce( + new Error('guest preparation failed') + ) + await expect(buildPtyIpcSpawnOptions(ctx)).rejects.toThrow('guest preparation failed') + expect(spawn).not.toHaveBeenCalled() + if (freshSession) { + expect(clear).toHaveBeenCalledExactlyOnceWith(ctx.effectiveSessionId) + } else { + expect(clear).not.toHaveBeenCalled() + expect(ctx.effectiveSessionAppId).toBe(id) + expect(ctx.isMintedSessionId).toBe(false) + } + } finally { + adapter.dispose() + } + } +) diff --git a/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts b/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts index e073d79be1e4..b84079cf7a56 100644 --- a/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts +++ b/src/main/ipc/pty/runtime/spawn-wsl-routing.test.ts @@ -196,6 +196,9 @@ describe('runtime guest daemon routing', () => { await buildRuntimePtySpawnOptions(ctx) expect(ctx.spawnOptions.sessionId).toBe(id) expect(ctx.spawnOptions.isNewSession).toBeUndefined() + expect(ctx.spawnOptions.attachOnly).toBe(true) + expect(prepareWslGuestTerminalSpawn).not.toHaveBeenCalled() + expect(buildPtyHostEnv).not.toHaveBeenCalled() expect(ctx.effectiveSessionAppId).toBe(id) expect(deps.prepareClaudeAuth).not.toHaveBeenCalled() expect(deps.getSelectedCodexHomePath).not.toHaveBeenCalled() From 1daf97b30af0b63cf21c9d3f30fefdd85646a5f3 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:52:54 -0700 Subject: [PATCH 17/53] fix: prepare Windows runtime artifacts before CI probes --- .github/workflows/computer-e2e.yml | 3 +- .github/workflows/pr.yml | 2 +- config/scripts/build-orcad.mjs | 33 +++++++++---------- config/scripts/computer-e2e-workflow.test.mjs | 3 ++ .../nsis-bun-terminal-uninstall.test.mjs | 2 +- config/scripts/profile-state-worker-smoke.mjs | 2 +- .../windows-shell-preflight-bun-fixture.ts | 26 ++++++++++----- 7 files changed, 42 insertions(+), 29 deletions(-) diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 9411ab75f472..0aa55e5f2b7a 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -92,6 +92,8 @@ jobs: - name: Prepare pinned Bun for Windows shell fixtures if: runner.os == 'Windows' run: node --input-type=module -e "import { buildCliRuntime } from './config/scripts/build-cli-runtime.mjs'; await buildCliRuntime(process.platform, process.arch)" + - name: Build terminal service and Windows gate for shell fixtures + run: pnpm run build:terminal-daemon # Why: without --config, bare vitest ignores config/vitest.config.ts (there # is no root config) and falls back to the 5s default timeout with no # Windows worker cap, so the real csc.exe launcher-compile tests time out @@ -137,7 +139,6 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm run build:electron-vite:parallel - - run: pnpm run build:terminal-daemon - run: pnpm exec vitest run --config config/vitest.config.ts config/scripts/daemon-smoke-launch.test.mjs # Exercise the shipped Bun runtime and standalone daemon bundle together. - name: Daemon boot smoke diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ed3d2d06e245..0485da13ccbb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -977,7 +977,7 @@ jobs: # Keep cold PowerShell probes out of concurrent native and renderer tests. - name: Test installer PowerShell capability run: >- - pnpm exec vitest run --config config/vitest.config.ts + pnpm exec vitest run --config config/vitest.config.ts --fileParallelism=false config/scripts/nsis-process-check.test.mjs config/scripts/nsis-bun-terminal-uninstall.test.mjs diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 9284ff6e48be..23f8df5e0402 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -272,28 +272,27 @@ if (graphErrors.length > 0) { } } -try { - if (targetIsCurrent) { - await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput }) - } -} catch (error) { - console.error('[build-orcad] profile state worker check failed:', error) - process.exitCode = 1 -} - -// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on -// this string, so two different builds carrying one version would share a directory — and an -// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale -// bytes while reporting the new version. +// Remote install directories are keyed by content, including every shipped companion. if (process.exitCode !== 1) { const fullVersion = computeOrcadFullVersion(OUT_DIR, { target: BUILD_TARGET, agentBrowserFilename: AGENT_BROWSER_NAME }) - writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion) - console.log( - `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Bun ${ORCAD_BUN_VERSION} included.` - ) + const versionPath = join(OUT_DIR, ORCAD_VERSION_FILENAME) + writeFileSync(versionPath, fullVersion) + try { + // Windows preflight verifies the installed version before launching its native child. + if (targetIsCurrent) { + await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput }) + } + console.log( + `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Bun ${ORCAD_BUN_VERSION} included.` + ) + } catch (error) { + rmSync(versionPath, { force: true }) + console.error('[build-orcad] profile state worker check failed:', error) + process.exitCode = 1 + } } // Verify the shipped native watcher actually subscribes under the bundled runtime. diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 048ac893e9de..df1845dc740e 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -92,6 +92,9 @@ describe('computer-use e2e workflow', () => { expect(preparation).toBeGreaterThan(-1) expect(steps[preparation].if).toBe("runner.os == 'Windows'") expect(preparation).toBeLessThan(fixtures) + const gateBuild = steps.findIndex((step) => step.run === 'pnpm run build:terminal-daemon') + expect(gateBuild).toBeGreaterThan(-1) + expect(gateBuild).toBeLessThan(fixtures) }) it('runs focused computer-use regression tests in the PR native-smoke job', () => { diff --git a/config/scripts/nsis-bun-terminal-uninstall.test.mjs b/config/scripts/nsis-bun-terminal-uninstall.test.mjs index e02bc6faff61..322f15984f46 100644 --- a/config/scripts/nsis-bun-terminal-uninstall.test.mjs +++ b/config/scripts/nsis-bun-terminal-uninstall.test.mjs @@ -7,7 +7,7 @@ const hooks = readFileSync(new URL('../nsis/orca-installer-hooks.nsh', import.me const source = readFileSync( new URL('../nsis/orca-bun-terminal-uninstall.nsh', import.meta.url), 'utf8' -) +).replaceAll('\r\n', '\n') const command = source.match(/-Command "([^"\n]+)"`/)?.[1]?.replaceAll('$$', '$') if (!command) { throw new Error('Missing fixed Bun terminal uninstall command') diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs index 81ff1b5eaf13..282a9fbeed30 100644 --- a/config/scripts/profile-state-worker-smoke.mjs +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -82,7 +82,7 @@ function runWorker(entry, workerData, steps, timeoutMs) { }) } -/** Exercise the shipped entries and copied state before publishing their content version. */ +/** Exercise the shipped entries and copied state with their installed content version. */ export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000, runtimePath } = {}) { if (runtimePath) { const nonce = randomUUID() diff --git a/src/main/daemon/windows-shell-preflight-bun-fixture.ts b/src/main/daemon/windows-shell-preflight-bun-fixture.ts index 80ee69c6205f..c7e335a1fddc 100644 --- a/src/main/daemon/windows-shell-preflight-bun-fixture.ts +++ b/src/main/daemon/windows-shell-preflight-bun-fixture.ts @@ -1,3 +1,5 @@ +import { join } from 'node:path' +import { createWindowsBunPtyLaunch } from './pty-subprocess/windows-bun-pty-launch' import { spawnBunPty } from './pty-subprocess/bun-pty-process' export async function runWindowsShellPreflight(options: { @@ -14,14 +16,22 @@ export async function runWindowsShellPreflight(options: { env[key] = value } } - const proc = spawnBunPty({ - file: options.shellPath, - args: options.shellArgs, - cols: 100, - rows: 30, - cwd: options.cwd, - env - }) + const proc = spawnBunPty( + { + file: options.shellPath, + args: options.shellArgs, + cols: 100, + rows: 30, + cwd: options.cwd, + env + }, + { + createWindowsLaunch: (args) => + createWindowsBunPtyLaunch(args, { + workerPath: join(process.cwd(), 'out', 'terminal-daemon', 'windows-bun-pty-gate-entry.js') + }) + } + ) let output = '' proc.onData((data) => { output += data From 6e3e09c6ca5adc4b13d1e205c4ced1bb5a49dc18 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:54:17 -0700 Subject: [PATCH 18/53] test(runtime): repair migrated terminal CI contracts and BusyBox oracle --- config/oxlint-anti-slop.json | 11 ++-- config/reliability-gates.jsonc | 50 +++++++++---------- .../relay-bash-rcfile.txt | 7 +++ .../relay-zsh-zshenv.txt | 7 +++ .../spawn-environment-pi-owner.test.ts | 4 +- .../pty/posix-pty-process-groups-fixture.ts | 7 +-- src/relay/pty-shell-wsl-cli.test.ts | 3 +- .../child-process-import-allowlist.txt | 1 - .../windows-console-visibility-allowlist.txt | 1 - .../child-process-import-boundary.test.ts | 2 +- .../windows-console-visibility.test.ts | 2 +- 11 files changed, 52 insertions(+), 43 deletions(-) diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index 4728c38a7e9d..434ddd365f04 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -47,8 +47,8 @@ "overrides": [ { "files": [ - "**/*.test.{ts,tsx}", - "**/*.spec.{ts,tsx}", + "**/*.test.{ts,tsx,mjs}", + "**/*.spec.{ts,tsx,mjs}", "tests/**/*.{ts,tsx}", "**/__mocks__/**" ], @@ -56,11 +56,10 @@ "anti-slop/no-module-mocking": "off" } }, - // mock-descendant-sweep.ts (daemon and relay) is a test-only side-effect shim: its whole body - // is one vi.mock that keeps mock PTY PIDs away from the host process table, and it exists so - // 60 suites do not each inline the same hoisted factory. It is never imported by product code. + // These test-only shims keep fake PTYs away from native runtime calls. + // Product code never imports them. { - "files": ["**/mock-descendant-sweep.ts"], + "files": ["**/mock-descendant-sweep.ts", "**/pty-ipc-native-runtime-mock.ts"], "rules": { "anti-slop/no-module-mocking": "off" } diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2fed0d52ebb9..7c6f0e3aa7b0 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2514,8 +2514,8 @@ "node config/scripts/run-bun-profile-tests.mjs src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts tests/e2e/ssh-startup-exec-readiness.spec.ts tests/e2e/paired-startup-exec-readiness.spec.ts", // Historical runner retained for the dated evidence records below. "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts tests/e2e/ssh-startup-exec-readiness.spec.ts tests/e2e/paired-startup-exec-readiness.spec.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts" ], "testFiles": [ "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", @@ -2617,7 +2617,7 @@ "date": "2026-08-12", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", "result": "passed", "durationSeconds": 6, "summary": "473 tests passed with one local Fish skip, including every-boundary scanner composition, real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles." @@ -2626,7 +2626,7 @@ "date": "2026-08-15", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/daemon/daemon-pty-startup-delivery.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/daemon/shell-ready.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", "result": "passed", "durationSeconds": 6, "summary": "233 tests passed with one local Fish skip, including exact Bash PROMPT_COMMAND byte/status/lifecycle composition across daemon, local, and relay owners." @@ -8763,7 +8763,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.bun.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-wsl-cwd-validation.test.ts src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts src/main/pty/omp-shell-wrapper.bun.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts", - "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" + "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.bun.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts" ], "testFiles": [ "src/shared/terminal-startup-cwd.test.ts", @@ -8877,7 +8877,7 @@ "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", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.bun.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.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." @@ -9530,7 +9530,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-pty-router.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-pty-router.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/rpc/methods/agent-session.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/web-session-intent-owner.test.ts src/renderer/src/runtime/remote-server-parity.test.ts tests/e2e/remote-agent-session-focus-authority.spec.ts config/scripts/remote-agent-session-authority-repro.mjs config/scripts/remote-agent-session-process-cleanup.mjs tests/e2e/remote-terminal-tab-retirement.unit.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ "src/main/providers/pty-process-inspection.test.ts", @@ -9775,7 +9775,7 @@ "date": "2026-07-23", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", "result": "passed", "durationSeconds": 11.96, "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane." @@ -12589,7 +12589,7 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/daemon/daemon-init-provider-installation.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts --reporter=dot" ], "testFiles": [ "src/main/daemon/daemon-session-owner-resolution.test.ts", @@ -12637,7 +12637,7 @@ "date": "2026-08-06", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts --reporter=dot", "result": "passed", "durationSeconds": 5.44, "summary": "Four files passed 535 owner-resolution, router, degraded-provider, and IPC lifecycle tests. With only the resolver fix disabled, the unchanged four-file gate failed four stale-absence, identity-retry, and router-liveness assertions; restoring it passed 535 of 535." @@ -14359,7 +14359,7 @@ "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts tests/e2e/terminal-shortcuts.spec.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts" ], "testFiles": [ "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", @@ -14584,7 +14584,7 @@ "date": "2026-08-25", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", "result": "passed", "durationSeconds": 3.88, "summary": "Twenty-six focused test files passed (489 tests) on the PR head, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence." @@ -14649,7 +14649,7 @@ "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", - "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx" + "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ "src/main/runtime/quarter-circle-title-send-authorization.test.ts", @@ -14755,7 +14755,7 @@ "date": "2026-08-25", "runner": "local", "platform": "macos", - "command": "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", + "command": "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/daemon/terminal-host-process-inspection.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", "result": "passed", "durationSeconds": 13.74, "summary": "Twelve focused test files passed (1351 tests, 1 skipped), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." @@ -15145,7 +15145,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-init-live-session-preservation.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts" ], "testFiles": [ "src/main/runtime/orchestration-message-delivery-identity.test.ts", @@ -15307,7 +15307,7 @@ "date": "2026-09-05", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts", "result": "passed", "durationSeconds": 4.73, "summary": "267 tests passed after the pointer-write path moved to the three-valued WriteSettlement union. New coverage: a dropped in-flight SSH settlement reaches the stager as unverifiable with bytes handed to the transport, a settled write that throws mid-pointer preserves the write-attempted reservation, an Enter whose settlement is lost stays at enter-attempted with no second Enter after restart, a refusal releases the reservation and drains a delivery parked behind its watermark, the production controller refuses before any byte when the routed provider cannot settle, and a census pins the five production IPtyProvider classes and rejects a settlement synthesized from the fire-and-forget write. Each new assertion was verified red against the pre-fix shape." @@ -15316,7 +15316,7 @@ "date": "2026-08-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/providers/unavailable-pty-provider.test.ts src/main/daemon/daemon-fresh-spawn-admission.test.ts", "result": "passed", "durationSeconds": 8.22, "summary": "245 tests passed across mailbox identity, durable coordinator-handle migration, insertion-time canonicalization, duplicate-free 51-row ownership branch caps, unrestricted reservation merging, direct and Dispatch pointer suppression, persisted reconciliation, 50-row paging and filtered waits, cross-PTY serialization, lifecycle fencing, bounded daemon and SSH transport settlement, outstanding Deliveries, reminted Dispatch ownership, acknowledgment, cancellation, and bounded pane lookup." @@ -18085,10 +18085,10 @@ "pnpm run build:relay", "node config/scripts/relay-watcher-fault-harness.mjs", "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts config/scripts/runtime-file-watcher-fault-harness.mjs config/scripts/runtime-file-watcher-resource-probe.mjs src/relay/relay-filesystem-watch-registry.test.ts src/relay/relay-watcher-pending-setup-waiters.test.ts src/relay/relay-watcher-parent-removal.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/fs-handler.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs config/scripts/relay-watcher-fault-harness.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts" ], "testFiles": [ @@ -18607,7 +18607,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.98, "summary": "Thirty-nine watcher, cancellation, desktop/serve, paired-web, WSL, SSH/relay, removal, and PTY physical-exit files passed with 2,008 tests. Capacity, quarantine, runtime-root, relay, and SSH setup layers each prove that 10,000 aborted callers detach while one anchor remains and zero waiters remain after settlement." @@ -18616,7 +18616,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.59, "summary": "Thirty-four watcher, cancellation, desktop/serve, paired-web, WSL, removal, PTY physical-exit, provider, renderer, and path files passed with 1,999 tests; paired-web cancellation now owns a server token before ready and aborts pending setup without a late native install." @@ -18634,7 +18634,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.01, "summary": "Twenty-seven watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,886 tests. The suite includes nested removal fencing, renderer/runtime cwd-only admission, legacy provider rows, rejected kill retries, transient WSL capacity, and synchronous-startup reservation cleanup." @@ -18706,7 +18706,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-descendant-shutdown.test.ts src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", "result": "passed", "durationSeconds": 9.12, "summary": "Twenty-five watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,762 tests. The suite covers operations joining in-flight termination, kill failures retaining ownership, bounded timeouts, atomic relay spawn/revive caps, and PTY-removal admission." diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 61bdd01dd506..02e9402ced5a 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -26,6 +26,13 @@ fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then + if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then + export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" + else + printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 + fi +fi # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep # their normal argv shape. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index ff90115d30d5..f0818f8f05cf 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -41,6 +41,13 @@ __orca_deferred_init() { builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions precmd_functions=(${precmd_functions:#__orca_deferred_init}) + if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then + if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then + export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" + else + printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 + fi + fi if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts b/src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts index d834abc9b63b..a1b70509de43 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment-pi-owner.test.ts @@ -33,10 +33,10 @@ describe('independent daemon terminal Pi ownership', () => { expect(env).toMatchObject(terminal) }) it('scrubs owners reintroduced by shell launch preparation', () => { - const env = { ...terminal, ...owners, APP_ENV: 'kept' } + const env: Record = { ...terminal, ...owners, APP_ENV: 'kept' } finalizeDaemonPtyEnvironment(env, terminal) for (const key of Object.keys(owners)) { - expect(Reflect.get(env, key)).toBeUndefined() + expect(env[key]).toBeUndefined() } expect(env).toMatchObject({ ...terminal, APP_ENV: 'kept' }) }) diff --git a/src/main/pty/posix-pty-process-groups-fixture.ts b/src/main/pty/posix-pty-process-groups-fixture.ts index a4686bd049d3..271d599b1b51 100644 --- a/src/main/pty/posix-pty-process-groups-fixture.ts +++ b/src/main/pty/posix-pty-process-groups-fixture.ts @@ -15,11 +15,8 @@ function readPs(args: string[]): string { } function findTaggedProcesses(token: string): TaggedProcess[] { - const output = readPs([ - '-e', - '-o', - 'pid=PROCESS_ID,pgid=PROCESS_GID,stat=PROCESS_STATE,args=COMMAND' - ]) + // BusyBox uses nonempty headers as exact widths; retain the default command width. + const output = readPs(['-e', '-o', 'pid=PROCESS_ID,pgid=PROCESS_GID,stat=PROCESS_STATE,args=']) const matches: TaggedProcess[] = [] for (const line of output.split(/\r?\n/)) { if (!line.includes(token)) { diff --git a/src/relay/pty-shell-wsl-cli.test.ts b/src/relay/pty-shell-wsl-cli.test.ts index df83fed798fb..59854e1d65aa 100644 --- a/src/relay/pty-shell-wsl-cli.test.ts +++ b/src/relay/pty-shell-wsl-cli.test.ts @@ -15,7 +15,8 @@ afterEach(() => { describe.each(['bash', 'zsh'])('guest relay %s CLI environment', (shell) => { const available = process.platform !== 'win32' && - runProcessSync({ program: shell, args: ['--version'] }).code === 0 + runProcessSync({ program: 'sh', args: ['-c', 'command -v "$1"', 'orca-shell-probe', shell] }) + .code === 0 it.skipIf(!available)('restores the managed CLI after user startup replaces PATH', async () => { const root = mkdtempSync(join(tmpdir(), 'orca guest cli ')) diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 7741c9907dde..0d5b9a85ea2d 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -156,7 +156,6 @@ src/main/workspace-space-analysis.ts src/main/wsl-availability.ts src/main/wsl-unc-delete.ts src/main/wsl.ts -src/main/zsh-startup-hook-pty-harness.ts src/relay/agent-exec-handler.ts src/relay/ai-vault-service-client-state.ts src/relay/ai-vault-service-client.ts diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 6de7701b81ba..ecc24994f1a4 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -49,7 +49,6 @@ main/workspace-space-analysis.ts main/wsl-availability.ts main/wsl-unc-delete.ts main/wsl.ts -main/zsh-startup-hook-pty-harness.ts relay/agent-exec-handler.ts relay/external-automations-handler.ts relay/fs-handler-git-fallback.ts diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index e5c1d3621126..6d88a6de993d 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -29,7 +29,7 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( * May only ever be DECREASED, and only by migrating a file off * `node:child_process`. Raising it is never the fix. */ -const DIRECT_IMPORTER_PIN = 149 +const DIRECT_IMPORTER_PIN = 148 const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index 0f92d943d298..bc3e4b60b4b6 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist( * the allowlist does not bound this: a swap (one file fixed and delisted, one * new file added with its entry) satisfies both membership assertions. */ -const UNHIDDEN_SPAWNER_PIN = 61 +const UNHIDDEN_SPAWNER_PIN = 60 const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ From 403fec69de64206769dd4883522fa5610d2bd78f Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:55:30 -0700 Subject: [PATCH 19/53] Preserve daemon spawn identity after routing recovery --- .../pty/runtime/queried-host-kinds.test.ts | 10 ++++- src/main/ipc/pty/runtime/spawn-preflight.ts | 42 +++++++++++-------- .../settled-pty-writer-census.test.ts | 19 ++++++--- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/main/ipc/pty/runtime/queried-host-kinds.test.ts b/src/main/ipc/pty/runtime/queried-host-kinds.test.ts index 1f7ce2459d88..6e6e1b33db1b 100644 --- a/src/main/ipc/pty/runtime/queried-host-kinds.test.ts +++ b/src/main/ipc/pty/runtime/queried-host-kinds.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parseExecutionHostId } from '../../../../shared/execution-host' -import { sshProviders } from '../provider/registry' +import { localProvider, setLocalPtyProvider, sshProviders } from '../provider/registry' import { listProcessesWithHostScopeFromRuntimeController } from './inventory-operations' +import { createUnavailablePtyProvider } from '../../../providers/unavailable-pty-provider' import type { PtyRuntimeControllerDeps } from './controller-deps' /** @@ -21,7 +22,12 @@ import type { PtyRuntimeControllerDeps } from './controller-deps' * `kind === 'ssh'` in `orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts`. */ describe('the hosts a PTY inventory can report having queried', () => { + const originalProvider = localProvider + beforeEach(() => { + setLocalPtyProvider({ ...createUnavailablePtyProvider(), listProcesses: async () => [] }) + }) afterEach(() => { + setLocalPtyProvider(originalProvider) sshProviders.clear() }) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index e40f9bc431db..e73379df41c0 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -71,24 +71,7 @@ export async function prepareRuntimePtySpawn( terminalWindowsWslDistro: null } ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride - ctx.isDaemonHostSpawn = !args.connectionId && !routesFreshSpawnsToLocalProvider(ctx.provider) - ctx.callerRequestedSessionId = args.sessionId?.trim() - ctx.requestedSessionId = - ctx.callerRequestedSessionId ?? - (ctx.isDaemonHostSpawn && args.agentSessionCreateOperationId - ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) - : undefined) - ctx.sessionId = - ctx.requestedSessionId ?? - (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - ctx.effectiveSessionRelayId = - ctx.sessionId !== undefined ? getRelayPtyId(args.connectionId, ctx.sessionId) : undefined - ctx.effectiveSessionAppId = - ctx.sessionId !== undefined ? getAppPtyId(args.connectionId, ctx.sessionId) : undefined - ctx.isNewDaemonSession = - !ctx.preAdoptedStablePane && - ctx.isDaemonHostSpawn && - (ctx.callerRequestedSessionId === undefined || args.isNewSession === true) + prepareRuntimeSessionIdentity(ctx) ctx.expectedWslDistro = !args.connectionId ? (resolveWslSessionContext({ cwd: ctx.cwd, @@ -135,6 +118,7 @@ export async function prepareRuntimePtySpawn( ) if (freshSpawnRecovery) { await freshSpawnRecovery + prepareRuntimeSessionIdentity(ctx) } } ctx.codexSelectionTarget = getCodexSelectionTargetForPty( @@ -278,3 +262,25 @@ export async function prepareRuntimePtySpawn( return null } + +function prepareRuntimeSessionIdentity(ctx: RuntimePtySpawnState): void { + const args = ctx.args + ctx.isDaemonHostSpawn = !args.connectionId && !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.callerRequestedSessionId = args.sessionId?.trim() + ctx.requestedSessionId = + ctx.callerRequestedSessionId ?? + (ctx.isDaemonHostSpawn && args.agentSessionCreateOperationId + ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) + : undefined) + ctx.sessionId = + ctx.requestedSessionId ?? + (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionRelayId = + ctx.sessionId !== undefined ? getRelayPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.effectiveSessionAppId = + ctx.sessionId !== undefined ? getAppPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.isNewDaemonSession = + !ctx.preAdoptedStablePane && + ctx.isDaemonHostSpawn && + (ctx.callerRequestedSessionId === undefined || args.isNewSession === true) +} diff --git a/src/main/providers/settled-pty-writer-census.test.ts b/src/main/providers/settled-pty-writer-census.test.ts index 1535b1dabe24..1890ae62213b 100644 --- a/src/main/providers/settled-pty-writer-census.test.ts +++ b/src/main/providers/settled-pty-writer-census.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it, vi } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' import { createMockMux } from './ssh-pty-provider-mock-multiplexer' import { DaemonPtyRouter } from '../daemon/daemon-pty-router' +import { WslDaemonPtyProvider } from '../wsl/wsl-daemon-pty-provider' +import { createUnavailablePtyProvider } from './unavailable-pty-provider' import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' vi.mock('electron', () => ({ @@ -24,7 +26,8 @@ const REPO_ROOT = join(__dirname, '..', '..', '..') const SETTLED_PTY_WRITER_FILES = [ 'src/main/providers/relay-pty-provider.ts', 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/daemon-pty-adapter.ts' + 'src/main/daemon/daemon-pty-adapter.ts', + 'src/main/wsl/wsl-daemon-pty-provider.ts' ] /** Where the provider-side settlement is actually decided; the adapter inherits its own. */ @@ -32,7 +35,9 @@ const SETTLED_WRITER_DECLARATIONS = [ 'src/main/providers/relay-pty-provider.ts', 'src/main/providers/ssh-pty-provider-rpc-operations.ts', 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/daemon-pty-session-input.ts' + 'src/main/daemon/daemon-pty-session-input.ts', + 'src/main/wsl/wsl-daemon-pty-provider.ts', + 'src/main/providers/unavailable-pty-provider.ts' ] function declaredProviderFiles(directory = join(REPO_ROOT, 'src/main')): string[] { @@ -64,12 +69,16 @@ describe('settled PTY writer census', () => { }) it('exposes a settled writer on every production provider instance', () => { - const daemonClient = { isConnected: () => false, onEvent: vi.fn(() => vi.fn()) } - const adapter = new DaemonPtyAdapter(daemonClient as never) + const adapter = new DaemonPtyAdapter({ + socketPath: join(__dirname, 'census.sock'), + tokenPath: join(__dirname, 'census.token') + }) const instances = [ new SshPtyProvider('conn-census', createMockMux() as never), new DaemonPtyRouter({ current: adapter, legacy: [] }), - adapter + adapter, + new WslDaemonPtyProvider({ distro: 'Ubuntu', relayBuildId: 'census-build' }, adapter), + createUnavailablePtyProvider() ] for (const provider of instances) { expect(typeof provider.writeWithSettlement, provider.constructor.name).toBe('function') From 65f71dbcd992dd7e670c9f75d0be2a474692dcd1 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:55:55 -0700 Subject: [PATCH 20/53] Separate staged runtime tests from built artifact qualification --- .github/workflows/bun-profile-tests.yml | 5 +++- .../scripts/bundled-cli-runtime-fixture.mjs | 10 +++++--- ...lectron-builder-runtime-resources.test.mjs | 15 +++++++++--- config/scripts/orca-dev-bin.test.mjs | 23 +++++++++++-------- .../orcad-companion-artifacts.test.mjs | 5 +++- config/scripts/run-bundled-runtime-tests.mjs | 7 +++++- 6 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index 39eb8d42b7ad..3b26806454f0 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -25,6 +25,9 @@ on: type: boolean default: false +env: + ORCA_TEST_REQUIRE_ORCAD_ARTIFACTS: '1' + permissions: contents: read @@ -183,7 +186,7 @@ jobs: - name: Verify native Alpine artifact and persistence run: | docker run --rm --init -i \ - -e ORCA_BACKGROUND_LAUNCH=1 \ + -e ORCA_BACKGROUND_LAUNCH=1 -e ORCA_TEST_REQUIRE_ORCAD_ARTIFACTS=1 \ -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' set -eu diff --git a/config/scripts/bundled-cli-runtime-fixture.mjs b/config/scripts/bundled-cli-runtime-fixture.mjs index af36933dc063..848199aa86c4 100644 --- a/config/scripts/bundled-cli-runtime-fixture.mjs +++ b/config/scripts/bundled-cli-runtime-fixture.mjs @@ -6,10 +6,14 @@ import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { cliRuntimeTarget, cliRuntimeFilename } from '../bundled-cli-runtime.cjs' -export async function writeBundledCliRuntimeFixture(directory, platform, arch) { - const bytes = 'runtime fixture' +export async function writeBundledCliRuntimeFixture( + directory, + platform, + arch, + bytes = 'runtime fixture' +) { await mkdir(directory, { recursive: true }) - await writeFile(join(directory, cliRuntimeFilename(platform)), bytes) + await writeFile(join(directory, cliRuntimeFilename(platform)), bytes, { mode: 0o755 }) await copyFile( join(import.meta.dirname, '../../resources/licenses/bun/LICENSE.md'), join(directory, 'LICENSE.md') diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index e5fa704aeec2..b7a181dcb5ef 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -466,14 +466,23 @@ describe('packaged runtime resources', () => { ) await writeFile(join(unpackedCliDir, 'cli-bin.js'), "require('./index.js')\n", 'utf8') await mkdir(join(resourcesDir, 'terminal-daemon'), { recursive: true }) - await writeFile(join(resourcesDir, 'terminal-daemon', 'daemon-entry.js'), '') - await writeBundledCliRuntimeFixture(join(resourcesDir, 'cli-runtime'), 'linux', 'x64') + await writeFile( + join(resourcesDir, 'terminal-daemon', 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n' + ) + const quotedNode = `'${process.execPath.replaceAll("'", "'\\''")}'` + await writeBundledCliRuntimeFixture( + join(resourcesDir, 'cli-runtime'), + 'linux', + process.arch, + `#!/bin/sh\nexec ${quotedNode} "$@"\n` + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ appOutDir: join(root, 'linux-unpacked'), electronPlatformName: 'linux', - arch: 1, + arch: process.arch === 'arm64' ? 3 : 1, packager: { appInfo: { version: '9.9.9' } } }) diff --git a/config/scripts/orca-dev-bin.test.mjs b/config/scripts/orca-dev-bin.test.mjs index 5d7120f1cd53..abecdd3f27de 100644 --- a/config/scripts/orca-dev-bin.test.mjs +++ b/config/scripts/orca-dev-bin.test.mjs @@ -14,16 +14,21 @@ describe('orca-dev package bin', () => { expect(readFileSync(wrapperPath, 'utf8')).toMatch(/^#!\/usr\/bin\/env node\n/) }) + const launcherBuilt = existsSync(path.join(projectDir, 'out', 'cli', 'cli-bun-launcher.js')) + const required = process.env.ORCA_TEST_REQUIRE_CLI_ARTIFACTS === '1' + it.skipIf( - !existsSync( - path.join( - projectDir, - 'out', - 'cli-runtime', - `${process.platform}-${process.arch}`, - process.platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime' - ) - ) + !required && + (!launcherBuilt || + !existsSync( + path.join( + projectDir, + 'out', + 'cli-runtime', + `${process.platform}-${process.arch}`, + process.platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime' + ) + )) )('hands the dev CLI to staged Bun without requiring Bash', () => { const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-bin-')) const cliEntry = path.join(root, 'cli-entry.cjs') diff --git a/config/scripts/orcad-companion-artifacts.test.mjs b/config/scripts/orcad-companion-artifacts.test.mjs index aec230388868..d00348d80c31 100644 --- a/config/scripts/orcad-companion-artifacts.test.mjs +++ b/config/scripts/orcad-companion-artifacts.test.mjs @@ -19,7 +19,10 @@ const entries = [ 'port-scan-command-worker-entry.js' ] -describe.skipIf(!existsSync(runtime))('packaged orcad companion processes', () => { +const built = existsSync(join(directory, '.version')) +const required = process.env.ORCA_TEST_REQUIRE_ORCAD_ARTIFACTS === '1' + +describe.skipIf(!built && !required)('packaged orcad companion processes', () => { it('reads a transcript and executes a probe without checkout dependencies', async () => { const root = await mkdtemp(join(tmpdir(), 'orca companion artifacts ')) try { diff --git a/config/scripts/run-bundled-runtime-tests.mjs b/config/scripts/run-bundled-runtime-tests.mjs index 9b91d69cd57b..772cf503d0d2 100644 --- a/config/scripts/run-bundled-runtime-tests.mjs +++ b/config/scripts/run-bundled-runtime-tests.mjs @@ -13,7 +13,12 @@ const runtime = join( `${process.platform}-${process.arch}`, cliRuntimeFilename(process.platform) ) -const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', BUN_EXECUTABLE: runtime } +const env = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + BUN_EXECUTABLE: runtime, + ORCA_TEST_REQUIRE_CLI_ARTIFACTS: '1' +} function run(args, environment = env) { const result = runProcessSync({ program: process.execPath, From 82bcb01d17d378bfa5ca0b6af30bf2f00d15b824 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 05:57:18 -0700 Subject: [PATCH 21/53] Include ConPTY files in Windows artifact materialization fixture --- src/main/ssh/orcad-artifact-materializer.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts index beff61176eea..b5dc84e546c8 100644 --- a/src/main/ssh/orcad-artifact-materializer.test.ts +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -19,6 +19,7 @@ import { ORCAD_TEMPLATE_MANIFEST_FILENAME, ORCAD_TEMPLATE_TARGETS_DIR, ORCAD_VERSION_FILENAME, + ORCAD_WINDOWS_CONPTY_ARTIFACTS, orcadArtifactFilenames, orcadTemplateCommonFilenames } from '../../shared/orcad-artifacts' @@ -90,6 +91,9 @@ function createTemplate(target: OrcadBunTarget = TARGET): { write(runtimePath, 'bun-executable') if (target.startsWith('win32-')) { write(join(templateDir, 'windows-process-tree.node'), 'process-table') + for (const filename of ORCAD_WINDOWS_CONPTY_ARTIFACTS) { + write(join(templateDir, filename), filename) + } } write( join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), From a03df6c5a3711b23349251c89a0856495bdacf65 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 06:04:42 -0700 Subject: [PATCH 22/53] Remove retired in-process fallback spawn contract --- src/main/daemon/daemon-init-mock-types.ts | 1 - src/main/daemon/daemon-init-test-harness.ts | 1 - .../pty-controller-spawn-admission.test.ts | 57 ++++++++---------- src/main/ipc/pty-ipc-daemon-test-provider.ts | 17 +++++- .../ipc/pty-pane-claim-arbitration.test.ts | 1 - .../ipc/pty/host-env/fresh-spawn-routing.ts | 17 ------ src/main/ipc/pty/ipc/spawn-env.ts | 10 +--- src/main/ipc/pty/ipc/spawn-preflight.ts | 40 +------------ src/main/ipc/pty/runtime/spawn-preflight.ts | 59 ++++++------------- src/main/providers/pty-provider-contract.ts | 4 -- 10 files changed, 65 insertions(+), 142 deletions(-) diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index b0175412f86e..57a978549707 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -68,7 +68,6 @@ export type LaunchedDaemonIdentity = { /** Local (non-daemon) PTY provider the degraded/fallback paths install. */ export type MockLocalPtyProvider = { - routesFreshSpawnsToLocalProvider: undefined spawn: Mock<(opts: { sessionId?: string }) => Promise<{ id: string }>> attach: Mock<() => Promise> hasPty: Mock<() => boolean> diff --git a/src/main/daemon/daemon-init-test-harness.ts b/src/main/daemon/daemon-init-test-harness.ts index 6060ed9b759d..6db0f3822f10 100644 --- a/src/main/daemon/daemon-init-test-harness.ts +++ b/src/main/daemon/daemon-init-test-harness.ts @@ -125,7 +125,6 @@ function createDaemonInitMockState(): DaemonInitMockState { } = { current: null } const localFallbackProvider: MockLocalPtyProvider = { - routesFreshSpawnsToLocalProvider: undefined, spawn: vi.fn(async (opts: { sessionId?: string }) => ({ id: opts.sessionId ?? 'local-fallback-pty' })), diff --git a/src/main/ipc/pty-controller-spawn-admission.test.ts b/src/main/ipc/pty-controller-spawn-admission.test.ts index 65f5ba565c9e..afaaf5e2cde7 100644 --- a/src/main/ipc/pty-controller-spawn-admission.test.ts +++ b/src/main/ipc/pty-controller-spawn-admission.test.ts @@ -1,3 +1,5 @@ +import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { DaemonPtyRouter } from '../daemon/daemon-pty-router' import { describe, expect, it, vi } from 'vitest' import { openCodeBuildPtyEnvMock, @@ -115,7 +117,7 @@ describe('registerPtyHandlers', () => { expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) clearProviderPtyState(ptyId) }) - it('adopts a live controller-owned local fallback when listings cannot serialize claims', async () => { + it('adopts a live controller-owned terminal when listings cannot serialize claims', async () => { const sessions: { id: string incarnationId: string @@ -132,7 +134,6 @@ describe('registerPtyHandlers', () => { spawn: physicalSpawn, authoritativeOwnerListings: false }) - Object.assign(provider, { routesFreshSpawnsToLocalProvider: true }) setLocalPtyProvider(provider as never) const controller = registerAgentClaimController() const request = { @@ -156,25 +157,22 @@ describe('registerPtyHandlers', () => { clearProviderPtyState('pty-local-claim') }) it.each(['runtime controller', 'renderer IPC'] as const)( - 'recovers degraded fresh-spawn routing before %s chooses daemon host semantics', + '%s preserves daemon identity while the router recovers fresh spawning', async (entryPoint) => { - let degraded = true - const daemonSpawn = vi.fn(async (options: { sessionId?: string }) => ({ + const adapter = new DaemonPtyAdapter({ + socketPath: '/test/recovery.sock', + tokenPath: '/test/recovery.token' + }) + const daemonSpawn = vi.spyOn(adapter, 'spawn').mockImplementation(async (options) => ({ id: options.sessionId ?? 'unexpected-fallback-id' })) - const provider = createAgentClaimProvider({ spawn: daemonSpawn }) - const recoverFreshSpawnRouting = vi.fn(async () => { - degraded = false - return true - }) - Object.defineProperties(provider, { - routesFreshSpawnsToLocalProvider: { - configurable: true, - get: () => (degraded ? true : undefined) - }, - recoverFreshSpawnRouting: { value: recoverFreshSpawnRouting } + const recoverFreshSpawnRouting = vi.fn(async () => true) + const provider = new DaemonPtyRouter({ + current: adapter, + legacy: [], + probeFreshSpawn: recoverFreshSpawnRouting }) - setLocalPtyProvider(provider as never) + setLocalPtyProvider(provider) const controller = registerAgentClaimController() const worktreeId = 'repo::/tmp/recovered-daemon-routing' const spawnArgs = { @@ -199,23 +197,20 @@ describe('registerPtyHandlers', () => { } ) it('recovers degraded routing for a fresh runtime session with a stable id', async () => { - let degraded = true - const daemonSpawn = vi.fn(async (options: { sessionId?: string; isNewSession?: boolean }) => ({ + const adapter = new DaemonPtyAdapter({ + socketPath: '/test/recovery.sock', + tokenPath: '/test/recovery.token' + }) + const daemonSpawn = vi.spyOn(adapter, 'spawn').mockImplementation(async (options) => ({ id: options.sessionId ?? 'unexpected-fallback-id' })) - const provider = createAgentClaimProvider({ spawn: daemonSpawn }) - const recoverFreshSpawnRouting = vi.fn(async () => { - degraded = false - return true + const recoverFreshSpawnRouting = vi.fn(async () => true) + const provider = new DaemonPtyRouter({ + current: adapter, + legacy: [], + probeFreshSpawn: recoverFreshSpawnRouting }) - Object.defineProperties(provider, { - routesFreshSpawnsToLocalProvider: { - configurable: true, - get: () => (degraded ? true : undefined) - }, - recoverFreshSpawnRouting: { value: recoverFreshSpawnRouting } - }) - setLocalPtyProvider(provider as never) + setLocalPtyProvider(provider) const controller = registerAgentClaimController() await controller.spawn({ diff --git a/src/main/ipc/pty-ipc-daemon-test-provider.ts b/src/main/ipc/pty-ipc-daemon-test-provider.ts index 9ef771b8de52..fac0343eff53 100644 --- a/src/main/ipc/pty-ipc-daemon-test-provider.ts +++ b/src/main/ipc/pty-ipc-daemon-test-provider.ts @@ -124,7 +124,22 @@ export class PtyIpcDaemonTestProvider extends DaemonPtyAdapter { } case 'setSessionBackground': return {} - default: + case 'abortHistorySeedTransfer': + case 'appendHistorySeedTransfer': + case 'cancelCreateOrAttach': + case 'closeTransientPty': + case 'confirmForegroundProcess': + case 'confirmShellForeground': + case 'createTransientPty': + case 'finishHistorySeedTransfer': + case 'inspectProcess': + case 'ping': + case 'ptySpawnHealth': + case 'shutdown': + case 'shutdownIfIdle': + case 'startHistorySeedTransfer': + case 'systemResolverHealth': + case 'writeTransientPty': throw new Error(`IPC daemon test transport does not implement ${request.type}`) } } diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 6a659de082c3..abe6c2d8c68d 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -371,7 +371,6 @@ describe('registerPtyHandlers', () => { spawn: physicalSpawn, authoritativeOwnerListings: false }) - Object.assign(provider, { routesFreshSpawnsToLocalProvider: true }) setLocalPtyProvider(provider as never) registerPtyHandlers(mainWindow as never, runtime) const controller = ( diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts index e03c29e79bc8..e731b0afcb35 100644 --- a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -1,6 +1,5 @@ import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' -import type { IPtyProvider } from '../../../providers/types' import { beginTerminalInstall } from '../../watcher-removal-gate' export function isClaudeLaunchCommand(command: string | undefined): boolean { @@ -12,22 +11,6 @@ export function isClaudeLaunchCommand(command: string | undefined): boolean { ) } -export function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { - return provider.routesFreshSpawnsToLocalProvider === true -} - -export function recoverFreshSpawnProviderRouting( - provider: IPtyProvider, - connectionId: string | null | undefined, - sessionId: string | undefined, - isNewSession = sessionId === undefined -): Promise | undefined { - if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { - return - } - return provider.recoverFreshSpawnRouting?.() -} - export function beginPtySpawnForWorktree( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index a60d084534e5..38222262c3af 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -13,7 +13,6 @@ import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' -import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { parseValidPaneKey } from '../pane/key-state' import { shouldRefreshNativeClaudeAgentTeamsEnv } from '../pane/launch-authority' @@ -81,14 +80,9 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise