mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/sta-3077-reattach-pane-cardinality
This commit is contained in:
@@ -239,7 +239,8 @@ jobs:
|
||||
|
||||
- name: Test real shell contracts
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
|
||||
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
src/main/daemon/shell-ready.test.ts \
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready.test.ts \
|
||||
@@ -275,6 +276,7 @@ jobs:
|
||||
- name: Test shard
|
||||
run: |
|
||||
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/providers/local-pty-shell-ready.test.ts \
|
||||
@@ -424,8 +426,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test Windows PowerShell CLI boundary
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
src/main/orca-profiles/profile-index-store.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
|
||||
@@ -10,7 +10,6 @@ inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-10",
|
||||
"updatedAt": "2026-08-12",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -10,6 +10,103 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "terminal-session.shell-ready-exec-prompt-fallback",
|
||||
"title": "Startup exec falls back to the identified shell's line editor",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-session-readiness",
|
||||
"layer": "posix-pty-and-shell-startup",
|
||||
"surfaces": ["queued terminal startup commands", "zsh and bash startup files"],
|
||||
"platforms": ["macos", "linux"],
|
||||
"providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "local-daemon", "ssh-relay"],
|
||||
"coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity stripping plus provider- and renderer-owned delivery. Renderer-owned relay fallback publishes the existing ready marker, so mixed client/host versions need no new wire capability. Live SSH, paired-runtime, and WSL remain gaps.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/issues/13767"],
|
||||
"invariant": "Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, prompt fallback releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger early fallback release.",
|
||||
"oracle": "Run the real generated wrappers under node-pty with disposable zsh and bash startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must strip the identity and release through the same prompt contract.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"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-prompt-readiness-probe.test.ts",
|
||||
"src/shared/pty-slave-line-discipline-echo.test.ts",
|
||||
"src/shared/shell-process-readiness.test.ts",
|
||||
"src/main/daemon/session.test.ts",
|
||||
"src/main/daemon/shell-ready.test.ts",
|
||||
"src/main/providers/local-pty-shell-ready.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/relay/pty-shell-launch.test.ts",
|
||||
"src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts",
|
||||
"assertions": [
|
||||
"real zsh .zshenv/.zprofile and bash profile replacement shells receive queued input exactly once",
|
||||
"same-shell raw silent reads retain queued input",
|
||||
"child-process raw silent reads retain queued input while the identified shell is not foreground",
|
||||
"a blocking zle-line-init read retains queued input until the hook completes",
|
||||
"sqlite3 and a sqlite3 image renamed zsh cannot impersonate the launched shell executable path",
|
||||
"zsh configurations without a line-editor enable sequence retain the existing timeout backstop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/relay/pty-handler.test.ts",
|
||||
"assertions": [
|
||||
"the SSH relay owner strips startup identity and releases provider-delivered input through prompt readiness",
|
||||
"renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness",
|
||||
"normal renderer-owned ready markers pass through unchanged and retain no readiness state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"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-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4,
|
||||
"summary": "All real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles plus identity, foreground, executable-image, protocol, and line-editor contracts passed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 6,
|
||||
"scope": "real local zsh/bash exec and silent-read integration gate"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Fresh deterministic local runs pass; CI and soak history have not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 is byte-identical across runs. Latest main 09ec516ae5 and the candidate with its production implementation reverted both failed exactly the three zsh .zprofile/.zshenv and bash exec cases by deterministic 5-second timeout; the zle-line-init oracle also exposed unsafe baseline delivery, while all ten other safety controls passed. The candidate passed all 14 on macOS."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The normal ready-marker path cancels before external probing. A missing marker triggers a debounced stty read only after a line-editor enable sequence, followed by narrow PID status and executable-image inspection only when termios matches. Slow ordinary startup output triggers zero probes; rejected protocol emissions are capped at four probes per startup. There is no per-command hook or steady-state polling."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect Linux, SSH-daemon, and paired-runtime live evidence.",
|
||||
"Collect CI and soak history without unexplained flakes.",
|
||||
"Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The fallback requires a readable POSIX PTY slave; WSL through wsl.exe, ConPTY, and native Windows retain the existing timeout backstop.",
|
||||
"An exec integration that forks a replacement shell instead of retaining the identified shell PID retains the existing timeout backstop.",
|
||||
"A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.",
|
||||
"The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.",
|
||||
"The fallback restores safe startup-command delivery but cannot recreate OSC 133 hooks or overlay restores already discarded by exec.",
|
||||
"Startup code running in the launched shell can deliberately spoof the public line-editor enable sequence after configuring matching termios; the existing timeout path already trusts a public OSC marker from the same startup code."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if prompt fallback enters a normal, child, or line-init read; a non-shell executable triggers prompt fallback; the identified shell PID is not foreground; delivery duplicates; ordinary startup output spawns probes; probe attempts exceed four; or the focused gate flakes without an identified harness defect."
|
||||
},
|
||||
{
|
||||
"id": "editor.restored-sibling-owner-reparent",
|
||||
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
|
||||
@@ -1361,6 +1458,136 @@
|
||||
],
|
||||
"demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect."
|
||||
},
|
||||
{
|
||||
"id": "browser-session.remote-terminal-link-ownership",
|
||||
"title": "Paired terminal links open one browser on their owning runtime",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "browser-runtime",
|
||||
"layer": "renderer-paired-runtime-routing",
|
||||
"surfaces": [
|
||||
"paired remote terminal HTTP links",
|
||||
"terminal link action destinations",
|
||||
"runtime-owned managed browser creation"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["remote-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["remote-runtime", "ssh"],
|
||||
"coverageNotes": "A headed macOS desktop host and separate paired Electron client exercise a real mirrored terminal link, owner-asserted host browser creation, exact host/client page identity, rendered DOM, explicit browser-tab activation, client external-browser suppression, exact host/client close convergence, and continued original-PTY liveness. Unit contracts cover OSC, WebLinksAddon, click fallback, explicit System Browser routing, failed creation, incapable runtimes, and unchanged direct SSH behavior.",
|
||||
"motivatingLinks": [
|
||||
"https://stablygroup.slack.com/archives/C0AK2T3JEF4/p1786567689897109",
|
||||
"https://github.com/stablyai/orca/issues/12418"
|
||||
],
|
||||
"invariant": "Selecting Orca Browser for an HTTP link in a runtime-owned terminal creates exactly one browser page on that owning runtime and exactly one mirrored client browser workspace, with matching remote page identity and no client external-browser call. Explicit System Browser remains client-owned, direct SSH remains system-browser-only, and unknown or mismatched owners fail closed.",
|
||||
"oracle": "Use the focused routing contracts as the byte-identical baseline/candidate oracle. Require runtime OSC, WebLinksAddon, click fallback, action-menu, and modifier paths to target the exact owner without client fallback, while direct SSH and generic remote document links stay external. Separately run one byte-identical headed paired Electron journey on the candidate and with only runtime browser registration disabled. Require one new host browser tab, one new client mirror with exact page identity, rendered fixture DOM, explicit browser-tab activation, zero client shell.openExternal calls, exact close convergence, unchanged terminal counts, and continued output on the original PTY.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts",
|
||||
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts",
|
||||
"src/renderer/src/lib/http-link-routing.test.ts",
|
||||
"src/renderer/src/lib/http-link-modifier-routing.test.ts",
|
||||
"src/renderer/src/lib/workspace-browser-tab-open.test.ts",
|
||||
"tests/e2e/paired-remote-terminal-browser-link.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts",
|
||||
"assertions": [
|
||||
"OSC, WebLinksAddon, and click-fallback activations route through the exact runtime owner",
|
||||
"direct SSH continues to use only the system browser"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-browser-tab-open.test.ts",
|
||||
"assertions": [
|
||||
"an asserted runtime cannot fall back to a client browser",
|
||||
"mismatched and incapable runtime owners fail closed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-browser-link.spec.ts",
|
||||
"assertions": [
|
||||
"one terminal action produces one host page and one client mirror with exact identity",
|
||||
"the host browser renders the fixture while client external navigation stays empty",
|
||||
"an explicit browser-tab click reveals the exact mirror",
|
||||
"closing the mirror returns host and client inventories to baseline",
|
||||
"the original remote PTY accepts and renders fresh output after browser close"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 2.58,
|
||||
"summary": "Latest main e4c278eb523 failed 6/25 owner-routing assertions: runtime terminal links called the viewing client's external browser and the managed-browser helper did not enforce an asserted runtime owner."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.83,
|
||||
"summary": "Candidate passed 84/84 across six focused routing, modifier, availability, owner assertion, failure, SSH, and system-browser contract files."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 32.9,
|
||||
"summary": "Candidate created one host browser and one client mirror with matching page identity, rendered the fixture, activated on an explicit tab click, made zero client external-browser calls, converged both inventories after exact UI close, and delivered fresh output on the original remote PTY."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "failed",
|
||||
"durationSeconds": 60,
|
||||
"summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 45,
|
||||
"scope": "six focused renderer contract files plus one isolated headed paired-runtime journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The focused contracts are deterministic and the headed paired-runtime journey passed locally after its PTY namespace oracle was corrected; longer CI and soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The focused owner-routing oracle is red on latest main e4c278eb523 and green on the candidate. The byte-identical headed Electron oracle is green on the candidate and red when the sole source delta replaces the runtime browser opener registration with registerRuntimeHttpLinkBrowserOpener(null); candidate evidence proves exact host/client identity, rendered content, explicit activation, exact close convergence, original-PTY survival, and absence of client external navigation."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The production change adds no timer, polling, retry, subprocess, stream opcode, RPC method, or host publication. Each link hover or activation performs bounded in-memory owner and capability lookups; an Orca Browser action issues the same single browser creation and snapshot reconciliation already used by the tab bar."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect Linux and physical Windows paired-server evidence.",
|
||||
"Collect a live headless-serve terminal-link journey.",
|
||||
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Live paired-server validation currently covers macOS only.",
|
||||
"A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.",
|
||||
"Direct SSH intentionally retains its existing system-browser-only semantics.",
|
||||
"Terminal-link activation does not change the existing asynchronous host/client focus policy."
|
||||
],
|
||||
"demotionRule": "Demote if one runtime terminal link creates a client-owned or duplicate browser, targets a runtime other than the pane owner, invokes the client external browser for Orca Browser, or removes the source terminal."
|
||||
},
|
||||
{
|
||||
"id": "browser-session.remote-html-preview-ownership",
|
||||
"title": "Paired HTML previews keep one host page and one stable client split",
|
||||
@@ -7533,14 +7760,16 @@
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon"],
|
||||
"coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.",
|
||||
"coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, verified managed-Claude launch identity, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal when no verified launch identity exists.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"],
|
||||
"invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.",
|
||||
"oracle": "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.",
|
||||
"invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; a generic busy title requires independent agent identity from the title, a verified managed launch bound to the current PTY incarnation, or the foreground process; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.",
|
||||
"oracle": "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.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-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.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
],
|
||||
"testFiles": [
|
||||
"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.test.ts",
|
||||
@@ -7553,6 +7782,14 @@
|
||||
"src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/quarter-circle-title-send-authorization.test.ts",
|
||||
"assertions": [
|
||||
"a verified managed-Claude launch authorizes a task-text quarter-circle title without foreground-process work",
|
||||
"a bare pane, an unverified launch hint, and a replacement PTY incarnation remain refused",
|
||||
"quarter circles remain working signals and braille authorization is unchanged"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
@@ -7620,6 +7857,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 20.9,
|
||||
"summary": "The focused file passed 9 tests, including current-incarnation managed-Claude authorization with zero foreground calls and fail-closed bare, unverified, and replacement-incarnation quarter-circle titles."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-11",
|
||||
"runner": "local",
|
||||
@@ -7640,11 +7886,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing."
|
||||
"evidence": "Before the verified-launch fallback, the managed-Claude unavailable-foreground case failed with isRunningAgent false; the focused file passed 7 tests after the fix. Removing the launch-incarnation comparison makes the replacement-incarnation refusal fail. Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; physical Windows evidence remains missing."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative."
|
||||
"evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove verified managed-Claude launch identity, permission/title blockers, and ordinary recognized-agent evidence add zero provider calls; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.",
|
||||
@@ -7657,7 +7903,7 @@
|
||||
"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."
|
||||
],
|
||||
"demotionRule": "Keep non-blocking or demote to protection none if 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."
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.agent-prompt-injection",
|
||||
|
||||
@@ -28,6 +28,8 @@ const transientDownloadErrorCodes = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
// GitHub release CDN can refuse HTTP/2 streams under load; retry like socket resets.
|
||||
'ERR_HTTP2_STREAM_ERROR',
|
||||
'ENETDOWN',
|
||||
'ENETRESET',
|
||||
'ENETUNREACH',
|
||||
@@ -185,7 +187,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) {
|
||||
function getDownloadRetryDelays() {
|
||||
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
|
||||
if (!configured) {
|
||||
return [1_000, 3_000]
|
||||
// Why: GitHub release CDN returns intermittent 503 / HTTP2 stream refusals
|
||||
// under CI fan-out; a few short attempts still exhaust during outages.
|
||||
return [1_000, 3_000, 5_000, 10_000]
|
||||
}
|
||||
|
||||
const delays = configured.split(',').map(Number)
|
||||
@@ -200,7 +204,7 @@ function isTransientDownloadError(error) {
|
||||
if (transientDownloadErrorCodes.has(candidate?.code)) {
|
||||
return true
|
||||
}
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
const statusCode = getDownloadErrorStatusCode(candidate)
|
||||
if (
|
||||
statusCode === 408 ||
|
||||
statusCode === 425 ||
|
||||
@@ -213,6 +217,13 @@ function isTransientDownloadError(error) {
|
||||
return false
|
||||
}
|
||||
|
||||
function getDownloadErrorStatusCode(error) {
|
||||
// @electron/get FetchDownloader uses Fetch Response.status; older got uses statusCode.
|
||||
return (
|
||||
error?.statusCode ?? error?.status ?? error?.response?.statusCode ?? error?.response?.status
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorChain(error) {
|
||||
const errors = []
|
||||
let candidate = error
|
||||
@@ -225,7 +236,7 @@ function getErrorChain(error) {
|
||||
|
||||
function formatDownloadError(error) {
|
||||
for (const candidate of getErrorChain(error)) {
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
const statusCode = getDownloadErrorStatusCode(candidate)
|
||||
if (statusCode) {
|
||||
return `HTTP ${statusCode}`
|
||||
}
|
||||
|
||||
@@ -118,6 +118,58 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries HTTP/2 stream refusal Electron download failures', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadErrorCode: 'ERR_HTTP2_STREAM_ERROR'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain(
|
||||
'Transient Electron download failure (ERR_HTTP2_STREAM_ERROR)'
|
||||
)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries Electron download HTTP 503 failures from Fetch Response.status', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadHttpStatus: 503
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain('Transient Electron download failure (HTTP 503)')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails after exhausting transient Electron download retries', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
@@ -279,7 +331,12 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
|
||||
function writeFakeElectronGet(
|
||||
projectDir,
|
||||
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
|
||||
{
|
||||
downloadNeverSettles = false,
|
||||
downloadFailures = 0,
|
||||
downloadErrorCode = 'ECONNRESET',
|
||||
downloadHttpStatus = null
|
||||
} = {}
|
||||
) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
@@ -299,6 +356,11 @@ exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
|
||||
if (${JSON.stringify(downloadHttpStatus)} != null) {
|
||||
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
|
||||
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
|
||||
throw error
|
||||
}
|
||||
const cause = Object.assign(new Error('download failed'), {
|
||||
code: ${JSON.stringify(downloadErrorCode)}
|
||||
})
|
||||
|
||||
@@ -2273,12 +2273,6 @@
|
||||
"auto.components.settings.GitPane.976afc6b3e": {
|
||||
"ko": "워크스페이스를 만들면 Orca가 원격 기준 브랜치를 새로고침하고, 일치하는 로컬 브랜치를 안전하게 fast-forward합니다. 예:"
|
||||
},
|
||||
"auto.components.settings.GitPane.b5f534717a": {
|
||||
"ko": "공동 작성"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9b5771bb1": {
|
||||
"ko": "표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9c011fbc2": {
|
||||
"ko": "속도 제한"
|
||||
},
|
||||
@@ -2288,9 +2282,6 @@
|
||||
"auto.components.settings.GitPane.db3a127eb1": {
|
||||
"ko": "이렇게 하면 다음과 같은 명령이 유지됩니다"
|
||||
},
|
||||
"auto.components.settings.GitPane.e02ea23a32": {
|
||||
"ko": "Orca 표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.f35007e6e8": {
|
||||
"ko": "git-username"
|
||||
},
|
||||
|
||||
@@ -44,7 +44,6 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Issue #{{value0}}': '이슈 #{{value0}}',
|
||||
'Linked issue #{{value0}}': '연결된 이슈 #{{value0}}',
|
||||
'Issue # or GitHub URL': '이슈 # 또는 GitHub URL',
|
||||
'Add Orca attribution to commits, PRs, and issues.': '커밋, PR 및 이슈에 Orca 속성을 추가합니다.',
|
||||
'Paste an issue URL, or enter a number. Leave blank to remove the link.':
|
||||
'이슈 URL을 붙여넣거나 숫자를 입력하세요. 링크를 제거하려면 비워두세요.',
|
||||
'Connect Linear in Settings to search issues.': '이슈를 검색하려면 설정에서 Linear를 연결하세요.',
|
||||
@@ -157,8 +156,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.',
|
||||
'Discard unsaved Git AI Author changes?':
|
||||
'저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.',
|
||||
'Branch naming, base refs, and Git AI Author.': '브랜치 이름 지정, 기본 참조 및 Git AI Author.',
|
||||
'Used by repositories that inherit global hosted-review defaults.':
|
||||
'전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.',
|
||||
'Defaults used when the hosted-review composer opens.':
|
||||
|
||||
@@ -164,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
oauth: 'OAuth',
|
||||
attribution: '归因',
|
||||
skill: '技能',
|
||||
skills: '技能',
|
||||
phone: '手机',
|
||||
|
||||
@@ -25,10 +25,10 @@ describe('locale-translation-policy es PR glossary', () => {
|
||||
it('rewrites PR inside longer sentences', () => {
|
||||
expect(
|
||||
repairEs(
|
||||
'Add Orca attribution to commits, PRs, and issues.',
|
||||
'Agregue la atribución de Orca a commits, relaciones públicas y problemas.'
|
||||
'Split a large change into smaller PRs',
|
||||
'Dividir un cambio grande en relaciones públicas más pequeños'
|
||||
)
|
||||
).toBe('Agregue la atribución de Orca a commits, PR y problemas.')
|
||||
).toBe('Dividir un cambio grande en PR más pequeños')
|
||||
expect(
|
||||
repairEs(
|
||||
'Open the PR details to view current reviewers.',
|
||||
|
||||
@@ -44,22 +44,14 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('显示 Orca Mobile 按钮')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.GitPane.e02ea23a32',
|
||||
enValue: 'Orca Attribution',
|
||||
localeValue: 'Orca归属',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Orca 归因')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.ab4b21b58e',
|
||||
enValue: 'Branch naming, base refs, attribution, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用、归属和 Git AI 作者。',
|
||||
enValue: 'Branch naming, base refs, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用和 Git AI 作者。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('分支命名、基础引用、归因和 Git AI Author。')
|
||||
).toBe('分支命名、基础引用和 Git AI Author。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569',
|
||||
|
||||
@@ -3,7 +3,6 @@ export const ZH_PHRASE_FIXES_ROUND5 = [
|
||||
{ pattern: /Orca集成开发环境/g, replacement: 'Orca IDE', whenEnIncludes: 'Orca IDE' },
|
||||
{ pattern: /Orca第一/g, replacement: 'Orca 优先', whenEnIncludes: 'Orca first' },
|
||||
{ pattern: /Orca移动/g, replacement: 'Orca Mobile', whenEnIncludes: 'Orca Mobile' },
|
||||
{ pattern: /Orca归属/g, replacement: 'Orca 归因', whenEnIncludes: 'Orca Attribution' },
|
||||
{ pattern: /Orca标志/g, replacement: 'Orca 标志', whenEnIncludes: 'Orca logo' },
|
||||
{ pattern: /喜欢Orca/g, replacement: '喜欢 Orca', whenEnIncludes: 'Enjoying Orca' },
|
||||
{ pattern: /认识Orca/g, replacement: '了解 Orca', whenEnIncludes: 'Get to know Orca' },
|
||||
|
||||
@@ -144,7 +144,6 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Expand Orca': '展开 Orca',
|
||||
'Support Orca': '支持 Orca',
|
||||
'Restarting Orca…': '正在重启 Orca…',
|
||||
'Orca Attribution': 'Orca 归因',
|
||||
'Show Orca Mobile Button': '显示 Orca Mobile 按钮',
|
||||
'Codex Accounts': 'Codex 账户',
|
||||
'Codex Account': 'Codex 账户',
|
||||
@@ -186,8 +185,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.":
|
||||
'让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。',
|
||||
'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'分支命名、基础引用、归因和 Git AI Author。',
|
||||
'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。',
|
||||
'You have unsaved Git AI Author changes. Leaving will discard them.':
|
||||
'您有未保存的 Git AI Author 更改。离开将丢弃它们。',
|
||||
'Discard unsaved Git AI Author changes?': '放弃未保存的 Git AI Author 更改?',
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('orchestration skill guidance', () => {
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
|
||||
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const dependencyAction = parse(
|
||||
)
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
@@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
expect(shellStep).toBeDefined()
|
||||
expect(shellInstall).toBeDefined()
|
||||
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
|
||||
// Why the whole workflow, not just the general shards: any other lane installing
|
||||
// these shells would silently start running the real-shell tests twice.
|
||||
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
@@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
|
||||
- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
|
||||
- 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
|
||||
- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요.
|
||||
|
||||
_또는 패키지 매니저로 설치:_
|
||||
|
||||
@@ -226,8 +229,8 @@ yay -S stably-orca-bin
|
||||
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -235,6 +238,10 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
|
||||
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
|
||||
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요.
|
||||
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
|
||||
|
||||
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
|
||||
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
|
||||
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
|
||||
@@ -249,6 +256,14 @@ yay -S stably-orca-bin
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/star-history.png" alt="stablyai/orca GitHub Star 히스토리 차트" width="880" />
|
||||
</p>
|
||||
|
||||
## 서명된 빌드
|
||||
|
||||
Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
|
||||
|
||||
@@ -253,6 +253,9 @@ platform :ios do
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
app_identifier: BUNDLE_ID,
|
||||
# distribute_only means there is no local .ipa to sniff, so fastlane would
|
||||
# otherwise fall through to an interactive platform prompt and crash in CI.
|
||||
app_platform: "ios",
|
||||
app_version: version,
|
||||
build_number: build_number,
|
||||
distribute_only: true,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
HostedReviewCreationBlockedReason,
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewProvider
|
||||
HostedReviewCreationEligibility
|
||||
} from '../../../src/shared/hosted-review'
|
||||
import {
|
||||
buildMobileCreatePrAction,
|
||||
@@ -146,7 +145,7 @@ describe('buildMobileCreatePrAction', () => {
|
||||
|
||||
it('keeps a disabled status row for providers without review creation', () => {
|
||||
const { descriptor } = action({
|
||||
eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider })
|
||||
eligibility: eligibility({ provider: 'unsupported' })
|
||||
})
|
||||
|
||||
expect(descriptor).toMatchObject({
|
||||
@@ -211,7 +210,7 @@ describe('cold-mount layout stability (issue #8411)', () => {
|
||||
'unsupported provider',
|
||||
{
|
||||
kind: 'ready',
|
||||
eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider })
|
||||
eligibility: eligibility({ provider: 'unsupported' })
|
||||
} satisfies MobileCreatePrEligibilityState
|
||||
],
|
||||
['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState]
|
||||
|
||||
@@ -72,7 +72,7 @@ export function buildMobileCreatePrAction({
|
||||
}
|
||||
// Why: mirror desktop's structural provider gate (supportsHostedReviewCreation)
|
||||
// instead of relying on the host always emitting a hidden blockedReason for
|
||||
// non-creatable providers like bitbucket.
|
||||
// non-creatable providers.
|
||||
if (!supportsHostedReviewCreation(eligibility.provider)) {
|
||||
return {
|
||||
visible: true,
|
||||
|
||||
@@ -195,7 +195,7 @@ orca orchestration worker-start --task <task_b> --worktree current --agent claud
|
||||
For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json
|
||||
orca orchestration worker-start --task <task_id> --worktree current --agent claude --model opus --effort high --json
|
||||
```
|
||||
|
||||
`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { callMock, remoteMock } = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
@@ -29,6 +29,9 @@ import { main } from '../index'
|
||||
import { okFixture, queueFixtures } from '../test-fixtures'
|
||||
|
||||
describe('orca emulator CLI handlers', () => {
|
||||
const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID
|
||||
const originalWorktreeId = process.env.ORCA_WORKTREE_ID
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
callMock.mockReset()
|
||||
@@ -38,6 +41,19 @@ describe('orca emulator CLI handlers', () => {
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWorkspaceId === undefined) {
|
||||
delete process.env.ORCA_WORKSPACE_ID
|
||||
} else {
|
||||
process.env.ORCA_WORKSPACE_ID = originalWorkspaceId
|
||||
}
|
||||
if (originalWorktreeId === undefined) {
|
||||
delete process.env.ORCA_WORKTREE_ID
|
||||
} else {
|
||||
process.env.ORCA_WORKTREE_ID = originalWorktreeId
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves relative APK paths before calling the runtime', async () => {
|
||||
queueFixtures(callMock, okFixture('req_install', { ok: true }))
|
||||
|
||||
@@ -73,6 +89,46 @@ describe('orca emulator CLI handlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the folder workspace exported by the current Orca terminal', async () => {
|
||||
process.env.ORCA_WORKSPACE_ID = 'folder:folder-1'
|
||||
delete process.env.ORCA_WORKTREE_ID
|
||||
callMock.mockResolvedValue(
|
||||
okFixture('req_attach', {
|
||||
attached: true,
|
||||
info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' }
|
||||
})
|
||||
)
|
||||
|
||||
await main(['emulator', 'attach', 'device-1'], '/folder/project')
|
||||
|
||||
expect(callMock).toHaveBeenCalledOnce()
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'emulator.attach',
|
||||
{ device: 'device-1', worktree: 'folder:folder-1', focus: false },
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the current git worktree exported by the Orca terminal', async () => {
|
||||
process.env.ORCA_WORKSPACE_ID = 'folder:stale-parent'
|
||||
process.env.ORCA_WORKTREE_ID = 'repo-1::/repo/project '
|
||||
callMock.mockResolvedValue(
|
||||
okFixture('req_attach', {
|
||||
attached: true,
|
||||
info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' }
|
||||
})
|
||||
)
|
||||
|
||||
await main(['emulator', 'attach', 'device-1'], '/repo/project')
|
||||
|
||||
expect(callMock).toHaveBeenCalledOnce()
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'emulator.attach',
|
||||
{ device: 'device-1', worktree: 'repo-1::/repo/project ', focus: false },
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects relative APK paths for remote runtimes', async () => {
|
||||
remoteMock.mockReturnValue(true)
|
||||
|
||||
|
||||
@@ -237,6 +237,14 @@ export async function getEmulatorWorktreeSelector(
|
||||
if (client.isRemote) {
|
||||
return undefined
|
||||
}
|
||||
const terminalWorktreeId = process.env.ORCA_WORKTREE_ID
|
||||
if (terminalWorktreeId?.trim()) {
|
||||
return terminalWorktreeId
|
||||
}
|
||||
const folderWorkspaceId = process.env.ORCA_WORKSPACE_ID?.trim()
|
||||
if (folderWorkspaceId?.startsWith('folder:')) {
|
||||
return folderWorkspaceId
|
||||
}
|
||||
try {
|
||||
return await resolveCurrentWorktreeSelector(cwd, client)
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveRelayGrokHome } from './managed-hook-runtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeChildProcess from 'node:child_process'
|
||||
|
||||
// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every
|
||||
// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling
|
||||
// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the
|
||||
// first case silently flips to the fallback path.
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeChildProcess>()
|
||||
return { ...actual, execFile: vi.fn() }
|
||||
})
|
||||
|
||||
const { execFile } = await import('node:child_process')
|
||||
const execFileMock = vi.mocked(execFile)
|
||||
const { resolveRelayGrokHome } = await import('./managed-hook-runtime')
|
||||
|
||||
type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void
|
||||
|
||||
function stubProbeOutput(stdout: string): void {
|
||||
execFileMock.mockImplementation(((...args: unknown[]) => {
|
||||
;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' })
|
||||
return undefined
|
||||
}) as unknown as typeof execFile)
|
||||
}
|
||||
|
||||
function stubProbeFailure(error: Error): void {
|
||||
execFileMock.mockImplementation(((...args: unknown[]) => {
|
||||
;(args.at(-1) as ExecFileCallback)(error)
|
||||
return undefined
|
||||
}) as unknown as typeof execFile)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
@@ -8,14 +41,39 @@ afterEach(() => {
|
||||
describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => {
|
||||
it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => {
|
||||
vi.stubEnv('SHELL', '/bin/sh')
|
||||
vi.stubEnv('GROK_HOME', '/srv/grok///')
|
||||
stubProbeOutput('/srv/grok///\n')
|
||||
|
||||
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok')
|
||||
|
||||
const [shell, args] = execFileMock.mock.calls[0] ?? []
|
||||
expect(shell).toBe('/bin/sh')
|
||||
// `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test.
|
||||
expect(args?.[0]).toBe('-c')
|
||||
})
|
||||
|
||||
it('passes -lc to a login shell that supports it', async () => {
|
||||
vi.stubEnv('SHELL', '/bin/zsh')
|
||||
stubProbeOutput('/srv/grok\n')
|
||||
|
||||
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok')
|
||||
|
||||
const [shell, args] = execFileMock.mock.calls[0] ?? []
|
||||
expect(shell).toBe('/bin/zsh')
|
||||
expect(args?.[0]).toBe('-lc')
|
||||
})
|
||||
|
||||
it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => {
|
||||
vi.stubEnv('SHELL', '/bin/sh')
|
||||
vi.stubEnv('GROK_HOME', '../relative')
|
||||
stubProbeOutput('../relative\n')
|
||||
|
||||
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok')
|
||||
})
|
||||
|
||||
// Why: this is the branch that made the old test flaky — pin it so a probe failure is
|
||||
// an asserted fallback rather than an invisible substitution for a real answer.
|
||||
it('falls back when the probe fails or times out', async () => {
|
||||
vi.stubEnv('SHELL', '/bin/sh')
|
||||
stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true }))
|
||||
|
||||
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { walkSessionFiles } from './session-scanner-discovery'
|
||||
|
||||
describe('walkSessionFiles WSL gate refusals', () => {
|
||||
it('rethrows gate refusals instead of reporting an empty tree', async () => {
|
||||
const refusal = new WslTranscriptFsError('timeout', 'slow share')
|
||||
await expect(
|
||||
walkSessionFiles('\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions', 'codex', [], {
|
||||
extensions: new Set(['.jsonl']),
|
||||
readDirectory: async () => {
|
||||
throw refusal
|
||||
}
|
||||
})
|
||||
).rejects.toBe(refusal)
|
||||
})
|
||||
|
||||
it('still treats ordinary readdir failures as an empty tree', async () => {
|
||||
await expect(
|
||||
walkSessionFiles('/missing/root', 'codex', [], {
|
||||
extensions: new Set(['.jsonl']),
|
||||
readDirectory: async () => {
|
||||
throw Object.assign(new Error('no such directory'), { code: 'ENOENT' })
|
||||
}
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { extname, join } from 'node:path'
|
||||
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
@@ -64,8 +65,13 @@ export async function walkSessionFiles(
|
||||
entries = options.readDirectory
|
||||
? await options.readDirectory(dirPath)
|
||||
: await readdir(dirPath, { withFileTypes: true })
|
||||
} catch {
|
||||
} catch (error) {
|
||||
options.signal?.throwIfAborted()
|
||||
// Why: a gate refusal means the scan could not run, not that the tree is
|
||||
// empty — swallowing it would misreport a stalled distro as "no transcript".
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title'
|
||||
import { toHostReadableTranscriptPath } from '../native-chat/host-readable-transcript-path'
|
||||
import { wslTranscriptFsRefusal } from '../native-chat/wsl-transcript-fs-gate'
|
||||
|
||||
export function resolveHostReadableAiVaultTitleRequests(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
@@ -10,7 +11,15 @@ export function resolveHostReadableAiVaultTitleRequests(
|
||||
if (!request.transcriptPath || signal?.aborted) {
|
||||
return request
|
||||
}
|
||||
const transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath)
|
||||
let transcriptPath: string | null
|
||||
try {
|
||||
transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath)
|
||||
} catch (error) {
|
||||
// Why: one stalled distro's path must not fail the whole titles batch;
|
||||
// degrade to the id-only shape like any unreadable path.
|
||||
void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal
|
||||
transcriptPath = null
|
||||
}
|
||||
return transcriptPath && !signal?.aborted
|
||||
? { ...request, transcriptPath }
|
||||
: { agent: request.agent, sessionId: request.sessionId }
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyTerminalAttributionEnv } from './terminal-attribution'
|
||||
|
||||
describe('applyTerminalAttributionEnv PATH key casing', () => {
|
||||
let tmpRoot: string | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpRoot) {
|
||||
rmSync(tmpRoot, { force: true, recursive: true })
|
||||
tmpRoot = null
|
||||
}
|
||||
})
|
||||
|
||||
function makeTmpRoot(): string {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-path-casing-'))
|
||||
return tmpRoot
|
||||
}
|
||||
|
||||
// Why: a Windows child that inherits both spellings crashes the packaged orca.exe launcher
|
||||
// with "Item has already been added. Key in dictionary: 'PATH'" (stablyai/orca#12046).
|
||||
function pathEnvKeys(env: Record<string, string>): string[] {
|
||||
return Object.keys(env).filter((key) => /^path$/i.test(key))
|
||||
}
|
||||
|
||||
it('writes back the inherited Windows `Path` spelling instead of adding `PATH`', () => {
|
||||
const root = makeTmpRoot()
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const baseEnv: Record<string, string> = {
|
||||
Path: 'C:\\Windows\\system32;C:\\Program Files\\Git\\cmd',
|
||||
SystemRoot: 'C:\\Windows'
|
||||
}
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
platform: 'win32',
|
||||
shellFamily: 'native-windows',
|
||||
userDataPath
|
||||
})
|
||||
|
||||
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
|
||||
const pathEntries = baseEnv.Path.split(';')
|
||||
|
||||
expect(pathEnvKeys(baseEnv)).toEqual(['Path'])
|
||||
expect(pathEntries[0]).toBe(win32Dir)
|
||||
expect(pathEntries).toContain('C:\\Program Files\\Git\\cmd')
|
||||
})
|
||||
|
||||
// Why: Win32 resolves a duplicated variable by taking the first match in the block, so the
|
||||
// shim must land on the first-listed spelling and the shadowed one must keep its value —
|
||||
// dropping it would promote it the moment anything downstream removes the live key.
|
||||
it.each([
|
||||
{ first: 'PATH', second: 'Path' },
|
||||
{ first: 'Path', second: 'PATH' }
|
||||
])(
|
||||
'prepends the shim onto the first-listed `$first` and leaves `$second` untouched',
|
||||
({ first, second }) => {
|
||||
const root = makeTmpRoot()
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const baseEnv: Record<string, string> = {
|
||||
[first]: 'C:\\tools\\bin;C:\\Windows\\system32',
|
||||
[second]: 'C:\\Windows\\system32'
|
||||
}
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
platform: 'win32',
|
||||
shellFamily: 'native-windows',
|
||||
userDataPath
|
||||
})
|
||||
|
||||
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
|
||||
expect(baseEnv[first].split(';')).toEqual([
|
||||
win32Dir,
|
||||
'C:\\tools\\bin',
|
||||
'C:\\Windows\\system32'
|
||||
])
|
||||
expect(baseEnv[second]).toBe('C:\\Windows\\system32')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a shadowed duplicate when the live spelling strips down to nothing', () => {
|
||||
const root = makeTmpRoot()
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32')
|
||||
const baseEnv: Record<string, string> = { Path: shimDir, PATH: 'C:\\Windows\\system32' }
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: false,
|
||||
platform: 'win32',
|
||||
shellFamily: 'native-windows',
|
||||
userDataPath
|
||||
})
|
||||
|
||||
expect(pathEnvKeys(baseEnv)).toEqual(['PATH'])
|
||||
expect(baseEnv.PATH).toBe('C:\\Windows\\system32')
|
||||
})
|
||||
|
||||
it('strips shim entries from the inherited `Path` when attribution is disabled', () => {
|
||||
const root = makeTmpRoot()
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32')
|
||||
const baseEnv: Record<string, string> = { Path: `${shimDir};C:\\Windows\\system32` }
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: false,
|
||||
platform: 'win32',
|
||||
shellFamily: 'native-windows',
|
||||
userDataPath
|
||||
})
|
||||
|
||||
expect(pathEnvKeys(baseEnv)).toEqual(['Path'])
|
||||
expect(baseEnv.Path).toBe('C:\\Windows\\system32')
|
||||
})
|
||||
|
||||
// Why: the daemon receives a sparse env patch with no path key and re-merges its own block
|
||||
// underneath it, so guessing the spelling here hands the child both.
|
||||
it.each([
|
||||
{ hostKey: 'PATH', otherKey: 'Path' },
|
||||
{ hostKey: 'Path', otherKey: 'PATH' }
|
||||
])('adopts a host block spelt `$hostKey` on a path-less env', ({ hostKey, otherKey }) => {
|
||||
const root = makeTmpRoot()
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const inherited = { PATH: process.env.PATH, Path: process.env.Path }
|
||||
delete process.env.PATH
|
||||
delete process.env.Path
|
||||
process.env[hostKey] = 'C:\\Windows\\system32'
|
||||
|
||||
try {
|
||||
const baseEnv: Record<string, string> = { SystemRoot: 'C:\\Windows' }
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
platform: 'win32',
|
||||
shellFamily: 'native-windows',
|
||||
userDataPath
|
||||
})
|
||||
|
||||
expect(pathEnvKeys(baseEnv)).toEqual([hostKey])
|
||||
expect(baseEnv[otherKey]).toBeUndefined()
|
||||
} finally {
|
||||
delete process.env[hostKey]
|
||||
for (const [key, value] of Object.entries(inherited)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a case-sensitive POSIX `Path` variable alone', () => {
|
||||
const root = makeTmpRoot()
|
||||
const baseEnv: Record<string, string> = {
|
||||
PATH: '/usr/bin:/bin',
|
||||
Path: '/decoy'
|
||||
}
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
platform: 'linux',
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
expect(baseEnv.Path).toBe('/decoy')
|
||||
expect(baseEnv.PATH.split(':')).toContain('/usr/bin')
|
||||
})
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyTerminalAttributionEnv } from './terminal-attribution'
|
||||
|
||||
// Why (#12046 follow-up): `powershell.exe -File <script> ... -` fails argument
|
||||
// binding with PSArgumentException before the script runs, so any command
|
||||
// carrying a bare `-` must never be dispatched to the PowerShell wrapper.
|
||||
describe('win32 attribution wrappers with a bare `-` argument', () => {
|
||||
let tmpRoot: string | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpRoot) {
|
||||
rmSync(tmpRoot, { force: true, recursive: true })
|
||||
tmpRoot = null
|
||||
}
|
||||
})
|
||||
|
||||
function shimDir(): string {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-dash-'))
|
||||
const userDataPath = join(tmpRoot, 'user-data')
|
||||
const baseEnv: Record<string, string> = {
|
||||
Path: 'C:\\Windows\\system32',
|
||||
SystemRoot: 'C:\\Windows'
|
||||
}
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
platform: 'win32',
|
||||
shellFamily: 'native-windows',
|
||||
userDataPath
|
||||
})
|
||||
return join(userDataPath, 'orca-terminal-attribution', 'win32')
|
||||
}
|
||||
|
||||
it('guards the git PowerShell dispatch behind a bare-dash check', () => {
|
||||
const wrapper = readFileSync(join(shimDir(), 'git.cmd'), 'utf8')
|
||||
const guardAt = wrapper.indexOf('call :orca_has_bare_dash %*')
|
||||
const dispatchAt = wrapper.indexOf('powershell.exe')
|
||||
|
||||
expect(guardAt).toBeGreaterThan(-1)
|
||||
expect(guardAt).toBeLessThan(dispatchAt)
|
||||
expect(wrapper).toContain('if not errorlevel 1 goto run')
|
||||
expect(wrapper).toContain(':orca_has_bare_dash')
|
||||
expect(wrapper).toContain('if "%~1"=="-" exit /b 0')
|
||||
})
|
||||
|
||||
it('guards the gh PowerShell dispatch behind a bare-dash check', () => {
|
||||
const wrapper = readFileSync(join(shimDir(), 'gh.cmd'), 'utf8')
|
||||
const guardAt = wrapper.indexOf('call :orca_has_bare_dash %*')
|
||||
const dispatchAt = wrapper.indexOf('powershell.exe')
|
||||
|
||||
expect(guardAt).toBeGreaterThan(-1)
|
||||
expect(guardAt).toBeLessThan(dispatchAt)
|
||||
expect(wrapper).toContain(':orca_has_bare_dash')
|
||||
})
|
||||
|
||||
const itWindows = process.platform === 'win32' ? it : it.skip
|
||||
|
||||
// Why: the guard exists for a runtime failure, so the real regression is a
|
||||
// real `git commit -F -` through the generated wrapper on a real Windows host.
|
||||
itWindows('commits a message piped through `git commit -F -`', () => {
|
||||
const dir = shimDir()
|
||||
const gitCmd = join(dir, 'git.cmd')
|
||||
const repo = mkdtempSync(join(tmpdir(), 'orca-attribution-dash-repo-'))
|
||||
try {
|
||||
const git = (...args: string[]): void => {
|
||||
spawnSync('git', args, { cwd: repo, encoding: 'utf8' })
|
||||
}
|
||||
git('init', '-q', '-b', 'main')
|
||||
git('config', 'user.email', 'test@example.com')
|
||||
git('config', 'user.name', 'Test')
|
||||
writeFileSync(join(repo, 'a.txt'), 'hello\n')
|
||||
git('add', '.')
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
|
||||
ORCA_REAL_GIT: spawnSync('where', ['git.exe'], { encoding: 'utf8' }).stdout.split(
|
||||
/\r?\n/
|
||||
)[0]
|
||||
}
|
||||
const result = spawnSync('cmd.exe', ['/d', '/c', gitCmd, 'commit', '-F', '-'], {
|
||||
cwd: repo,
|
||||
env,
|
||||
input: 'piped subject\n',
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(result.stderr).not.toContain('PSArgumentException')
|
||||
expect(result.status).toBe(0)
|
||||
expect(
|
||||
spawnSync('git', ['log', '-1', '--format=%B'], { cwd: repo, encoding: 'utf8' }).stdout
|
||||
).toContain('piped subject')
|
||||
} finally {
|
||||
rmSync(repo, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,824 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyTerminalAttributionEnv, resolveAttributionShellFamily } from './terminal-attribution'
|
||||
|
||||
describe('applyTerminalAttributionEnv', () => {
|
||||
let tmpRoot: string | null = null
|
||||
// Why: these subprocess fixtures create extensionless Bash fake commands;
|
||||
// native Windows command resolution is covered by wrapper/PATH assertions below.
|
||||
const posixSubprocessIt = process.platform === 'win32' ? it.skip : it
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpRoot) {
|
||||
rmSync(tmpRoot, { force: true, recursive: true })
|
||||
tmpRoot = null
|
||||
}
|
||||
})
|
||||
|
||||
function makeTmpRoot(): string {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-'))
|
||||
return tmpRoot
|
||||
}
|
||||
|
||||
function stripInheritedAttributionPath(pathValue: string): string {
|
||||
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
|
||||
return pathValue
|
||||
.split(pathDelimiter)
|
||||
.filter((entry) => !entry.includes('orca-terminal-attribution'))
|
||||
.join(pathDelimiter)
|
||||
}
|
||||
|
||||
function cleanAttributionEnv(env?: Record<string, string>): Record<string, string> {
|
||||
const base = { ...process.env }
|
||||
delete base.ORCA_ENABLE_GIT_ATTRIBUTION
|
||||
delete base.ORCA_GIT_COMMIT_TRAILER
|
||||
delete base.ORCA_GH_PR_FOOTER
|
||||
delete base.ORCA_GH_ISSUE_FOOTER
|
||||
delete base.ORCA_ATTRIBUTION_SHIM_DIR
|
||||
delete base.ORCA_REAL_GIT
|
||||
delete base.ORCA_REAL_GH
|
||||
base.PATH = stripInheritedAttributionPath(base.PATH ?? '')
|
||||
const next = { ...base, ...env }
|
||||
return next as Record<string, string>
|
||||
}
|
||||
|
||||
function runGit(repo: string, args: string[], env?: Record<string, string>): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(env)
|
||||
})
|
||||
}
|
||||
|
||||
it('classifies Windows native and POSIX shell families for attribution shims', () => {
|
||||
expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'powershell.exe' })).toBe(
|
||||
'native-windows'
|
||||
)
|
||||
expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'cmd.exe' })).toBe(
|
||||
'native-windows'
|
||||
)
|
||||
expect(
|
||||
resolveAttributionShellFamily({
|
||||
platform: 'win32',
|
||||
shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('posix')
|
||||
expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'wsl.exe' })).toBe('posix')
|
||||
expect(resolveAttributionShellFamily({ platform: 'win32', isWsl: true })).toBe('posix')
|
||||
expect(resolveAttributionShellFamily({ platform: 'darwin', shellPath: '/bin/zsh' })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
posixSubprocessIt('does not amend HEAD when git commit --dry-run exits successfully', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
runGit(repo, ['commit', '-m', 'initial'])
|
||||
|
||||
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
const beforeHead = runGit(repo, ['rev-parse', 'HEAD']).trim()
|
||||
writeFileSync(join(repo, 'second.txt'), 'second\n')
|
||||
runGit(repo, ['add', 'second.txt'])
|
||||
|
||||
// Why: dry-run reports what would be committed but must not rewrite the
|
||||
// existing HEAD just because the real git command returns success.
|
||||
runGit(repo, ['commit', '--dry-run', '-m', 'second'], attributionEnv)
|
||||
|
||||
expect(runGit(repo, ['rev-parse', 'HEAD']).trim()).toBe(beforeHead)
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).not.toContain('Co-authored-by: Orca')
|
||||
|
||||
runGit(repo, ['commit', '-m', 'second'], attributionEnv)
|
||||
expect(runGit(repo, ['rev-parse', 'HEAD']).trim()).not.toBe(beforeHead)
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
posixSubprocessIt('still adds the trailer when git commit uses --no-verify shorthand', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
|
||||
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
runGit(repo, ['commit', '-n', '-m', 'initial'], attributionEnv)
|
||||
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
posixSubprocessIt('adds the trailer when git commit uses combined -am shorthand', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
runGit(repo, ['commit', '-m', 'initial'])
|
||||
writeFileSync(join(repo, 'README.md'), 'changed\n')
|
||||
|
||||
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
runGit(repo, ['commit', '-am', 'combined message'], attributionEnv)
|
||||
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
posixSubprocessIt('adds the trailer when git commit follows global git config args', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
|
||||
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
runGit(repo, ['-c', 'core.quotePath=false', 'commit', '-m', 'initial'], attributionEnv)
|
||||
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
posixSubprocessIt('adds the trailer to commit message files before git runs', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
const messagePath = join(root, 'message.txt')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
writeFileSync(messagePath, 'initial from file\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
|
||||
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
runGit(repo, ['commit', '-F', messagePath], attributionEnv)
|
||||
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
expect(readFileSync(messagePath, 'utf8')).toBe('initial from file\n')
|
||||
})
|
||||
|
||||
posixSubprocessIt(
|
||||
'passes missing commit message files through without adding fallback message args',
|
||||
() => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const argsPath = join(root, 'commit-args')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'git'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" == "commit" ]]; then
|
||||
printf '%s\\n' "$@" >"${argsPath}"
|
||||
exit 9
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'git'), 0o755)
|
||||
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
execFileSync('git', ['commit', '-F', join(root, 'missing-message.txt')], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
).toThrow()
|
||||
|
||||
expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca')
|
||||
}
|
||||
)
|
||||
|
||||
posixSubprocessIt(
|
||||
'passes reuse and fixup commit message modes through without attribution',
|
||||
() => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const argsPath = join(root, 'commit-args')
|
||||
const messagePath = join(root, 'message.txt')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(messagePath, 'from file\n')
|
||||
writeFileSync(
|
||||
join(binDir, 'git'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" == "commit" ]]; then
|
||||
printf '%s\\n' "$@" >>"${argsPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'git'), 0o755)
|
||||
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
execFileSync('git', ['commit', '-C', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
execFileSync('git', ['commit', '--fixup', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
execFileSync('git', ['commit', '-F', messagePath, '--fixup', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca')
|
||||
}
|
||||
)
|
||||
|
||||
posixSubprocessIt('adds the trailer before commit-msg hooks validate the commit', () => {
|
||||
const root = makeTmpRoot()
|
||||
const repo = join(root, 'repo')
|
||||
mkdirSync(repo)
|
||||
runGit(repo, ['init'])
|
||||
runGit(repo, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
|
||||
const hookPath = join(repo, '.git', 'hooks', 'commit-msg')
|
||||
const hookCounterPath = join(repo, 'hook-count')
|
||||
writeFileSync(
|
||||
hookPath,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
count=0
|
||||
if [[ -f "${hookCounterPath}" ]]; then
|
||||
count="$(cat "${hookCounterPath}")"
|
||||
fi
|
||||
printf '%s\\n' "$((count + 1))" >"${hookCounterPath}"
|
||||
grep -Fq 'Co-authored-by: Orca <help@stably.ai>' "$1"
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(hookPath, 0o755)
|
||||
writeFileSync(join(repo, 'README.md'), 'initial\n')
|
||||
runGit(repo, ['add', 'README.md'])
|
||||
|
||||
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
runGit(repo, ['commit', '-m', 'initial'], attributionEnv)
|
||||
|
||||
expect(readFileSync(hookCounterPath, 'utf8').trim()).toBe('1')
|
||||
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
|
||||
'Co-authored-by: Orca <help@stably.ai>'
|
||||
)
|
||||
})
|
||||
|
||||
posixSubprocessIt('adds git attribution to the original commit command without amending', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const commitPath = join(root, 'commit-called')
|
||||
const amendPath = join(root, 'amend-called')
|
||||
const argsPath = join(root, 'commit-args')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'git'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2 $3" == "config --bool commit.gpgsign" ]]; then
|
||||
printf '%s\\n' 'true'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "commit" ]]; then
|
||||
if [[ "\${2:-}" == "--amend" ]]; then
|
||||
touch "${amendPath}"
|
||||
else
|
||||
printf '%s\\n' "$@" >"${argsPath}"
|
||||
touch "${commitPath}"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'git'), 0o755)
|
||||
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
execFileSync('git', ['commit', '-m', 'signed commit'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(existsSync(commitPath)).toBe(true)
|
||||
expect(existsSync(amendPath)).toBe(false)
|
||||
expect(readFileSync(argsPath, 'utf8')).toContain('Co-authored-by: Orca <help@stably.ai>')
|
||||
})
|
||||
|
||||
posixSubprocessIt('passes editor-based commits through without attribution', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const argsPath = join(root, 'commit-args')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'git'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" == "commit" ]]; then
|
||||
printf '%s\\n' "$@" >"${argsPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'git'), 0o755)
|
||||
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
execFileSync('git', ['commit'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(readFileSync(argsPath, 'utf8')).toBe('commit\n')
|
||||
})
|
||||
|
||||
posixSubprocessIt('preserves interactive gh pr create without guessing which PR to edit', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'interactive create complete'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "pr view --json url" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'Existing body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(output).toBe('interactive create complete\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
posixSubprocessIt('adds gh attribution for noninteractive create output URLs', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const prMarkerPath = join(root, 'pr-edit-called')
|
||||
const issueMarkerPath = join(root, 'issue-edit-called')
|
||||
const patchArgsPath = join(root, 'patch-args')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "issue create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'PR body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/issues/456" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'Issue body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
printf '%s\\n' "$@" >"${patchArgsPath}"
|
||||
touch "${prMarkerPath}"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
|
||||
touch "${issueMarkerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
expect(
|
||||
execFileSync('gh', ['pr', 'create', '--fill'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
).toBe('https://github.com/stablyai/orca/pull/123\n')
|
||||
expect(
|
||||
execFileSync('gh', ['issue', 'create', '--title', 'Issue', '--body', 'Body'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
).toBe('https://github.com/stablyai/orca/issues/456\n')
|
||||
|
||||
expect(existsSync(prMarkerPath)).toBe(true)
|
||||
expect(existsSync(issueMarkerPath)).toBe(true)
|
||||
expect(readFileSync(patchArgsPath, 'utf8')).toContain('body=@')
|
||||
expect(readFileSync(patchArgsPath, 'utf8')).not.toContain('PR body')
|
||||
})
|
||||
|
||||
posixSubprocessIt('passes gh create help through without editing existing PRs or issues', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2 $3" == "pr create --help" ]]; then
|
||||
printf '%s\\n' 'pr help'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3" == "issue create --help" ]]; then
|
||||
printf '%s\\n' 'issue help'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "pr view --json url" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "issue list" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create', '--help'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(output).toBe('pr help\n')
|
||||
const issueOutput = execFileSync('gh', ['issue', 'create', '--help'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(issueOutput).toBe('issue help\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
posixSubprocessIt(
|
||||
'preserves interactive gh issue create without guessing which issue to edit',
|
||||
() => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "issue create" ]]; then
|
||||
printf '%s\\n' 'interactive issue create complete'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "issue list" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
const output = execFileSync('gh', ['issue', 'create'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(output).toBe('interactive issue create complete\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
posixSubprocessIt('skips gh attribution edits when viewing the created item fails', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
const markerPath = join(root, 'gh-edit-called')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
touch "${markerPath}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
posixSubprocessIt('keeps gh create successful when the attribution edit fails', () => {
|
||||
const root = makeTmpRoot()
|
||||
const binDir = join(root, 'bin')
|
||||
mkdirSync(binDir)
|
||||
writeFileSync(
|
||||
join(binDir, 'gh'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1 $2" == "pr create" ]]; then
|
||||
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
|
||||
printf '%s\\n' 'Existing body'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
|
||||
exit 9
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(join(binDir, 'gh'), 0o755)
|
||||
const attributionEnv = {
|
||||
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
|
||||
}
|
||||
applyTerminalAttributionEnv(attributionEnv, {
|
||||
enabled: true,
|
||||
userDataPath: join(root, 'user-data')
|
||||
})
|
||||
|
||||
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
|
||||
encoding: 'utf8',
|
||||
env: cleanAttributionEnv(attributionEnv)
|
||||
})
|
||||
|
||||
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
|
||||
})
|
||||
|
||||
it('fails open when shim files cannot be written', () => {
|
||||
const root = makeTmpRoot()
|
||||
const blockedUserDataPath = join(root, 'not-a-directory')
|
||||
writeFileSync(blockedUserDataPath, 'blocked\n')
|
||||
const baseEnv: Record<string, string> = { PATH: '/usr/bin' }
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
userDataPath: blockedUserDataPath
|
||||
})
|
||||
|
||||
expect(baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(baseEnv.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('does not duplicate shim directories when applied to an already-injected env', () => {
|
||||
const root = makeTmpRoot()
|
||||
const baseEnv: Record<string, string> = {
|
||||
PATH: stripInheritedAttributionPath(process.env.PATH ?? '')
|
||||
}
|
||||
const options = { enabled: true, userDataPath: join(root, 'user-data') }
|
||||
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, options)
|
||||
applyTerminalAttributionEnv(baseEnv, options)
|
||||
|
||||
const shimEntries = baseEnv.PATH.split(pathDelimiter).filter((entry) =>
|
||||
entry.includes('orca-terminal-attribution')
|
||||
)
|
||||
expect(new Set(shimEntries).size).toBe(shimEntries.length)
|
||||
})
|
||||
|
||||
it('puts only Windows shims on PATH for native Windows shells', () => {
|
||||
const root = makeTmpRoot()
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const baseEnv: Record<string, string> = { PATH: 'C:\\Git\\cmd;C:\\Windows\\System32' }
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
platform: 'win32',
|
||||
shellFamily: 'native-windows',
|
||||
userDataPath
|
||||
})
|
||||
|
||||
const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix')
|
||||
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
|
||||
const pathEntries = baseEnv.PATH.split(';')
|
||||
|
||||
expect(pathEntries[0]).toBe(win32Dir)
|
||||
expect(pathEntries).not.toContain(posixDir)
|
||||
expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(existsSync(join(win32Dir, 'git.cmd'))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps POSIX shims first for Windows Git Bash and WSL shells', () => {
|
||||
const root = makeTmpRoot()
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const baseEnv: Record<string, string> = { PATH: 'C:\\Program Files\\Git\\cmd;C:\\Windows' }
|
||||
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: true,
|
||||
platform: 'win32',
|
||||
shellFamily: 'posix',
|
||||
userDataPath
|
||||
})
|
||||
|
||||
const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix')
|
||||
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
|
||||
const pathEntries = baseEnv.PATH.split(';')
|
||||
|
||||
expect(pathEntries[0]).toBe(posixDir)
|
||||
expect(pathEntries).not.toContain(win32Dir)
|
||||
expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBe(posixDir)
|
||||
expect(existsSync(join(posixDir, 'git'))).toBe(true)
|
||||
})
|
||||
|
||||
it('writes PowerShell wrappers without raw-template backslash escapes', () => {
|
||||
const root = makeTmpRoot()
|
||||
applyTerminalAttributionEnv(
|
||||
{ PATH: stripInheritedAttributionPath(process.env.PATH ?? '') },
|
||||
{ enabled: true, userDataPath: join(root, 'user-data') }
|
||||
)
|
||||
|
||||
const shimDir = join(root, 'user-data', 'orca-terminal-attribution', 'win32')
|
||||
const gitWrapper = readFileSync(join(shimDir, 'git-wrapper.ps1'), 'utf8')
|
||||
const ghWrapper = readFileSync(join(shimDir, 'gh-wrapper.ps1'), 'utf8')
|
||||
|
||||
expect(gitWrapper).toContain('Test-ExplicitCommitMessage')
|
||||
expect(gitWrapper).toContain('"`r`n`r`n"')
|
||||
expect(ghWrapper).toContain('$body.TrimEnd("`r", "`n")')
|
||||
expect(ghWrapper).toContain('"`r`n`r`n"')
|
||||
expect(gitWrapper).not.toContain('"\\`r"')
|
||||
expect(ghWrapper).not.toContain('"\\`r"')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -166,7 +166,7 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, rows).close()
|
||||
}
|
||||
|
||||
it('bulk clears once and restores the live Google cookies before importing', async () => {
|
||||
it('never clears or reconstructs live Google cookies before importing', async () => {
|
||||
const sourceCookiesPath = seedSource([
|
||||
{ domain: '.google.com', name: 'SID', value: 'transplanted-sid' },
|
||||
{ domain: '.example.com', name: 'session', value: 'new' }
|
||||
@@ -186,13 +186,10 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
googleCookiesSkipped: 1,
|
||||
domains: ['example.com']
|
||||
})
|
||||
expect(clearStorageDataMock).toHaveBeenCalledOnce()
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([
|
||||
'.google.com',
|
||||
'.example.com'
|
||||
])
|
||||
expect(clearStorageDataMock).not.toHaveBeenCalled()
|
||||
expect(cookiesRemoveMock).toHaveBeenCalledOnce()
|
||||
expect(cookiesRemoveMock).toHaveBeenCalledWith('https://example.com/', 'stale')
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com'])
|
||||
})
|
||||
|
||||
it('reports an excluded Google row even when its encrypted value is invalid', async () => {
|
||||
@@ -270,24 +267,31 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('fails the import and restores the full snapshot when the bulk clear rejects', async () => {
|
||||
// Why (STA-4061): reconstruction drops partition keys, so a rejected removal must not rebuild
|
||||
// anything — not the excluded Google rows, and not the non-Google rows that were already gone.
|
||||
it('fails the import without reconstructing any cookie when selective removal rejects', async () => {
|
||||
const sourceCookiesPath = seedSource([
|
||||
{ domain: '.example.com', name: 'session', value: 'new' }
|
||||
])
|
||||
seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }])
|
||||
cookiesGetMock.mockResolvedValue([
|
||||
existingCookie('.google.com', 'SID'),
|
||||
existingCookie('.example.com', 'stale')
|
||||
existingCookie('.example.com', 'removed-first'),
|
||||
existingCookie('.other.test', 'stale')
|
||||
])
|
||||
clearStorageDataMock.mockRejectedValue(new Error('cookie store unavailable'))
|
||||
cookiesRemoveMock.mockImplementation(async (_url: string, name: string) => {
|
||||
if (name === 'stale') {
|
||||
throw new Error('cookie store unavailable')
|
||||
}
|
||||
})
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
expect(result.ok || result.reason).toContain('Could not clear existing cookies')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledOnce()
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'stale'])
|
||||
expect(clearStorageDataMock).not.toHaveBeenCalled()
|
||||
expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['removed-first', 'stale'])
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type CdpCookie = { name: string; value: string; partitionKey?: Record<string, unknown> }
|
||||
|
||||
type FixtureResult = {
|
||||
beforePartitionKey: Record<string, unknown> | undefined
|
||||
clearError: string | null
|
||||
remainingChips: CdpCookie[]
|
||||
remainingPlain: CdpCookie[]
|
||||
}
|
||||
|
||||
const EXPECTED_PARTITION_KEY = {
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
|
||||
function buildFixtureMain(policyPath: string, resultPath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { removeAllCookiesExcept, isNonTransplantableCookieDomain } = require(${JSON.stringify(policyPath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 20000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:partition-rollback-cookie-test'
|
||||
const targetSession = session.fromPartition(partition)
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>cookie rollback fixture</title>')
|
||||
mark('window loaded')
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
await debug.sendCommand('Network.enable')
|
||||
mark('network enabled')
|
||||
|
||||
// Only CDP can create a partitioned cookie; Electron's cookies API has no partitionKey.
|
||||
await debug.sendCommand('Network.setCookie', {
|
||||
url: 'https://app.acme-chips.test/',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: ${JSON.stringify(EXPECTED_PARTITION_KEY)}
|
||||
})
|
||||
const beforeChips = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
.find((cookie) => cookie.name === 'chips-auth')
|
||||
if (!beforeChips || !beforeChips.partitionKey) throw new Error('CHIPS fixture cookie was not stored partitioned')
|
||||
mark('partitioned cookie set')
|
||||
|
||||
await targetSession.cookies.set({ url: 'https://plain.example/', name: 'plain', value: 'stale', secure: true })
|
||||
await targetSession.cookies.set({ url: 'https://victim.example/', name: 'victim', value: 'stale', secure: true })
|
||||
mark('removable cookies set')
|
||||
|
||||
const store = {
|
||||
get: (filter) => targetSession.cookies.get(filter),
|
||||
set: (details) => targetSession.cookies.set(details),
|
||||
// Why: one rejecting removal is what drove the old rollback; the rest still succeed.
|
||||
remove: async (url, name) => {
|
||||
if (name === 'victim') throw new Error('forced victim removal failure')
|
||||
return targetSession.cookies.remove(url, name)
|
||||
}
|
||||
}
|
||||
|
||||
let clearError = null
|
||||
try {
|
||||
await removeAllCookiesExcept(store, (cookie) => isNonTransplantableCookieDomain(cookie.domain ?? ''))
|
||||
} catch (error) {
|
||||
clearError = String(error?.message || error)
|
||||
}
|
||||
mark('clear finished')
|
||||
|
||||
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const project = (name) => afterCookies
|
||||
.filter((cookie) => cookie.name === name)
|
||||
.map((cookie) => ({ name: cookie.name, value: cookie.value, partitionKey: cookie.partitionKey }))
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
beforePartitionKey: beforeChips.partitionKey,
|
||||
clearError,
|
||||
remainingChips: project('chips-auth'),
|
||||
remainingPlain: project('plain')
|
||||
}))
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-partition-rollback-'))
|
||||
fixtureRoots.push(root)
|
||||
const policyPath = join(root, 'browser-cookie-import-policy.cjs')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'),
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'browser-cookie-import-policy.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(policyPath, resultPath))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 60_000
|
||||
})
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(fixtureResult) as FixtureResult
|
||||
}
|
||||
|
||||
describe('non-Google partitioned cookie under a failed Electron cookie clear', () => {
|
||||
it('never resurrects a removed partitioned cookie', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY)
|
||||
expect(result.clearError).toContain('Could not clear existing cookies')
|
||||
// STA-4061: rollback rebuilt this cookie through cookies.set, which drops partitionKey.
|
||||
expect(
|
||||
result.remainingChips,
|
||||
`chips-auth survived: ${JSON.stringify(result.remainingChips)}`
|
||||
).toEqual([])
|
||||
expect(result.remainingPlain).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type FixtureResult = {
|
||||
before: Record<string, unknown>
|
||||
after: Record<string, unknown>
|
||||
electronCookieKeys: string[]
|
||||
importResult: Record<string, unknown>
|
||||
remainingNames: string[]
|
||||
}
|
||||
|
||||
function buildFixtureMain(
|
||||
importPath: string,
|
||||
resultPath: string,
|
||||
sourceCookiesPath: string
|
||||
): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { importCookiesFromBrowser } = require(${JSON.stringify(importPath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 15000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:partition-cookie-test'
|
||||
const targetSession = session.fromPartition(partition)
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>cookie fixture</title>')
|
||||
mark('window loaded')
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
await debug.sendCommand('Network.enable')
|
||||
mark('network enabled')
|
||||
await debug.sendCommand('Network.setCookie', {
|
||||
url: 'https://accounts.google.com/',
|
||||
name: 'partitioned-google',
|
||||
value: 'live-value',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: { topLevelSite: 'https://example.com', hasCrossSiteAncestor: true }
|
||||
})
|
||||
mark('partitioned cookie set')
|
||||
await targetSession.cookies.set({
|
||||
url: 'https://stale.example/',
|
||||
name: 'stale',
|
||||
value: 'remove-me',
|
||||
secure: true
|
||||
})
|
||||
mark('ordinary cookie set')
|
||||
|
||||
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const before = beforeCookies.find((cookie) => cookie.name === 'partitioned-google')
|
||||
const electronCookie = (await targetSession.cookies.get({ name: 'partitioned-google' }))[0]
|
||||
if (!before || !electronCookie) throw new Error('Partitioned fixture cookie was not created')
|
||||
mark('cookies read')
|
||||
|
||||
const importResult = await importCookiesFromBrowser({
|
||||
family: 'chrome',
|
||||
label: 'Chromium fixture',
|
||||
cookiesPath: ${JSON.stringify(sourceCookiesPath)},
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
}, partition)
|
||||
if (!importResult.ok) throw new Error('Native import failed: ' + importResult.reason)
|
||||
mark('native import complete')
|
||||
|
||||
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const after = afterCookies.find((cookie) => cookie.name === 'partitioned-google')
|
||||
if (!after) throw new Error('Partitioned Google cookie was removed')
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
before,
|
||||
after,
|
||||
electronCookieKeys: Object.keys(electronCookie).sort(),
|
||||
importResult,
|
||||
remainingNames: afterCookies.map((cookie) => cookie.name).sort()
|
||||
}))
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-partition-cookie-'))
|
||||
fixtureRoots.push(root)
|
||||
const importPath = join(root, 'browser-cookie-import.cjs')
|
||||
const registryStubPath = join(root, 'browser-session-registry.cjs')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const sourceCookiesPath = join(root, 'source', 'Network', 'Cookies')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{ domain: '.example.com', name: 'imported', value: 'new-value' }
|
||||
]).close()
|
||||
writeFileSync(
|
||||
registryStubPath,
|
||||
'exports.browserSessionRegistry = { clearPendingCookieImport() {}, setPendingCookieImport() {} }\n'
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'stub-browser-session-registry',
|
||||
resolveId(source, importer) {
|
||||
return source === './browser-session-registry' &&
|
||||
importer?.endsWith('browser-cookie-import.ts')
|
||||
? registryStubPath
|
||||
: null
|
||||
}
|
||||
}
|
||||
],
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'),
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'browser-cookie-import.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(importPath, resultPath, sourceCookiesPath))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 60_000
|
||||
})
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(fixtureResult) as FixtureResult
|
||||
}
|
||||
|
||||
describe('native Chromium excluded partition cookie under Electron', () => {
|
||||
it('survives selective clearing unchanged without a lossy Electron reconstruction', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
expect(result.before.partitionKey).toEqual({
|
||||
topLevelSite: 'https://example.com',
|
||||
hasCrossSiteAncestor: true
|
||||
})
|
||||
expect(result.electronCookieKeys).not.toContain('partitionKey')
|
||||
expect(result.after).toEqual(result.before)
|
||||
expect(result.importResult).toMatchObject({
|
||||
ok: true,
|
||||
summary: { importedCookies: 1, domains: ['example.com'] }
|
||||
})
|
||||
expect(result.remainingNames).toEqual(['imported', 'partitioned-google'])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
bulkClearCookiesExcept,
|
||||
isGoogleSourceBoundCookie,
|
||||
isNonTransplantableCookieDomain,
|
||||
NON_TRANSPLANTABLE_HOST_KEY_SQL,
|
||||
normalizeCookieDomain,
|
||||
removeAllCookiesExcept,
|
||||
replaceCookiesForImportedDomains
|
||||
} from './browser-cookie-import-policy'
|
||||
|
||||
@@ -207,87 +207,111 @@ describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('bulkClearCookiesExcept', () => {
|
||||
it('bulk clears a large jar once and restores only excluded cookies', async () => {
|
||||
const existing = [
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('accounts.google.com', 'ACCOUNT'),
|
||||
...Array.from({ length: 1_000 }, (_, index) =>
|
||||
cookie(`site-${index}.example`, `session-${index}`)
|
||||
)
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
describe('removeAllCookiesExcept', () => {
|
||||
it('never removes or reconstructs excluded Google cookies', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('accounts.google.com', 'ACCOUNT'),
|
||||
cookie('.example.com', 'session'),
|
||||
cookie('other.test', 'tracker', '/scoped')
|
||||
])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const clearStorageData = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, (existingCookie) =>
|
||||
await removeAllCookiesExcept({ get, remove }, (existingCookie) =>
|
||||
isNonTransplantableCookieDomain(existingCookie.domain ?? '')
|
||||
)
|
||||
|
||||
expect(get).toHaveBeenCalledOnce()
|
||||
expect(get).toHaveBeenCalledWith({})
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
expect(clearStorageData).toHaveBeenCalledWith({ storages: ['cookies'] })
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'ACCOUNT'])
|
||||
expect(remove.mock.calls).toEqual([
|
||||
['https://example.com/', 'session'],
|
||||
['https://other.test/scoped', 'tracker']
|
||||
])
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the complete snapshot when the bulk clear rejects', async () => {
|
||||
const existing = [
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('.example.com', 'first'),
|
||||
cookie('.other.test', 'second')
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
// Why (STA-4061): reconstructing a removed cookie loses its partition key, and the snapshot
|
||||
// cannot say which cookies had one, so a failed clear must stay failed.
|
||||
it('never reconstructs removed cookies when another removal fails', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('.example.com', 'first', '/one'),
|
||||
cookie('.example.com', 'second', '/two'),
|
||||
cookie('.example.com', 'third', '/three')
|
||||
])
|
||||
const remove = vi.fn().mockImplementation(async (_url: string, name: string) => {
|
||||
if (name === 'second') {
|
||||
throw new Error('store unavailable')
|
||||
}
|
||||
})
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const clearStorageData = vi.fn().mockRejectedValue(new Error('store unavailable'))
|
||||
|
||||
await expect(
|
||||
bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, () => true)
|
||||
).rejects.toThrow('Could not clear existing cookies')
|
||||
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'first', 'second'])
|
||||
removeAllCookiesExcept({ get, remove }, (existingCookie) =>
|
||||
isNonTransplantableCookieDomain(existingCookie.domain ?? '')
|
||||
)
|
||||
).rejects.toThrow('the session was left partially cleared')
|
||||
expect(remove).toHaveBeenCalledTimes(3)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the complete snapshot when excluded-cookie restoration fails', async () => {
|
||||
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
let googleAttempts = 0
|
||||
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
|
||||
if (name === 'SID' && googleAttempts++ === 0) {
|
||||
throw new Error('transient restore failure')
|
||||
}
|
||||
})
|
||||
const clearStorageData = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
bulkClearCookiesExcept(
|
||||
{ cookies: { get, set }, clearStorageData },
|
||||
(existingCookie) => existingCookie.domain === '.google.com'
|
||||
it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`))
|
||||
)
|
||||
).rejects.toThrow('Could not preserve excluded cookies')
|
||||
let releaseRemovals: (() => void) | undefined
|
||||
const removalsReleased = new Promise<void>((resolve) => {
|
||||
releaseRemovals = resolve
|
||||
})
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const remove = vi.fn().mockImplementation(async () => {
|
||||
active++
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await removalsReleased
|
||||
active--
|
||||
})
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
|
||||
const clearing = removeAllCookiesExcept({ get, remove }, () => false)
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8))
|
||||
expect(maxActive).toBe(8)
|
||||
releaseRemovals?.()
|
||||
await clearing
|
||||
|
||||
expect(remove).toHaveBeenCalledTimes(12)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports when the complete-snapshot rollback also fails', async () => {
|
||||
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
|
||||
if (name === 'SID') {
|
||||
throw new Error('persistent restore failure')
|
||||
}
|
||||
it('serializes cookies that share Electron removal coordinates', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
cookie('.example.com', 'session'),
|
||||
{ ...cookie('example.com', 'session'), hostOnly: true }
|
||||
])
|
||||
let releaseFirst: (() => void) | undefined
|
||||
const firstReleased = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const clearStorageData = vi.fn().mockResolvedValue(undefined)
|
||||
const remove = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => firstReleased)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
await expect(
|
||||
bulkClearCookiesExcept(
|
||||
{ cookies: { get, set }, clearStorageData },
|
||||
(existingCookie) => existingCookie.domain === '.google.com'
|
||||
)
|
||||
).rejects.toThrow('Cookie preservation and rollback failed')
|
||||
const clearing = removeAllCookiesExcept({ get, remove }, () => false)
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce())
|
||||
releaseFirst?.()
|
||||
await clearing
|
||||
|
||||
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
|
||||
expect(remove.mock.calls).toEqual([
|
||||
['https://example.com/', 'session'],
|
||||
['https://example.com/', 'session']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Cookie, Cookies, Session } from 'electron'
|
||||
import type { Cookie, Cookies } from 'electron'
|
||||
import { parse as parseDomain } from 'psl'
|
||||
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
|
||||
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
|
||||
'SIDCC',
|
||||
@@ -63,6 +64,7 @@ export function normalizeCookieImportDomain(domain: string): string | null {
|
||||
// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports
|
||||
// users actually asked for.
|
||||
const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const
|
||||
const COOKIE_CLEAR_CONCURRENCY = 8
|
||||
|
||||
export function isNonTransplantableCookieDomain(domain: string): boolean {
|
||||
const normalized = normalizeCookieDomain(domain)
|
||||
@@ -194,53 +196,50 @@ export async function restoreImportedDomainCookies(
|
||||
}
|
||||
}
|
||||
|
||||
type CookieClearSession = {
|
||||
cookies: Pick<Cookies, 'get' | 'set'>
|
||||
clearStorageData: Session['clearStorageData']
|
||||
}
|
||||
|
||||
async function restoreCookieClearSnapshot(
|
||||
store: Pick<Cookies, 'set'>,
|
||||
snapshot: readonly Cookie[],
|
||||
originalError: unknown,
|
||||
rollbackMessage: string
|
||||
): Promise<never> {
|
||||
try {
|
||||
await restoreImportedDomainCookies(store, snapshot)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([originalError, rollbackError], rollbackMessage)
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
|
||||
// Why: after a bulk clear starts, Electron cannot reveal whether a rejected operation mutated
|
||||
// the jar, so keep the complete snapshot until excluded cookies have been restored.
|
||||
export async function bulkClearCookiesExcept(
|
||||
targetSession: CookieClearSession,
|
||||
// Why: Electron cannot round-trip partition identity, so excluded cookies must never be removed.
|
||||
// Why (STA-4061): the same gap forbids rolling a partial clear back. cookies.get() omits
|
||||
// partitionKey and cookies.set() silently drops it, so every reconstruction is a coin flip that
|
||||
// can downgrade a partitioned (CHIPS) cookie into an unpartitioned one — and nothing in the
|
||||
// snapshot says which cookies are at risk. A partially cleared jar is a retryable import failure;
|
||||
// a downgraded cookie is unrecoverable auth-state corruption that survives restart.
|
||||
export async function removeAllCookiesExcept(
|
||||
store: Pick<Cookies, 'get' | 'remove'>,
|
||||
isExcluded: (cookie: Cookie) => boolean
|
||||
): Promise<void> {
|
||||
const snapshot = await targetSession.cookies.get({})
|
||||
const excludedCookies = snapshot.filter(isExcluded)
|
||||
|
||||
try {
|
||||
await targetSession.clearStorageData({ storages: ['cookies'] })
|
||||
} catch (clearError) {
|
||||
await restoreCookieClearSnapshot(
|
||||
targetSession.cookies,
|
||||
snapshot,
|
||||
new AggregateError([clearError], 'Could not clear existing cookies'),
|
||||
'Cookie bulk clear and rollback failed'
|
||||
)
|
||||
const existingCookies = await store.get({})
|
||||
const removableGroups = new Map<string, { cookie: Cookie; url: string }[]>()
|
||||
for (const cookie of existingCookies) {
|
||||
if (isExcluded(cookie)) {
|
||||
continue
|
||||
}
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
const url = domain ? cookieRemovalUrl(cookie, domain) : null
|
||||
if (!url) {
|
||||
continue
|
||||
}
|
||||
const key = JSON.stringify([url, cookie.name])
|
||||
const group = removableGroups.get(key) ?? []
|
||||
group.push({ cookie, url })
|
||||
removableGroups.set(key, group)
|
||||
}
|
||||
|
||||
try {
|
||||
await restoreImportedDomainCookies(targetSession.cookies, excludedCookies)
|
||||
} catch (preservationError) {
|
||||
await restoreCookieClearSnapshot(
|
||||
targetSession.cookies,
|
||||
snapshot,
|
||||
new AggregateError([preservationError], 'Could not preserve excluded cookies'),
|
||||
'Cookie preservation and rollback failed'
|
||||
const results = await mapSettledWithConcurrency(
|
||||
[...removableGroups.values()],
|
||||
COOKIE_CLEAR_CONCURRENCY,
|
||||
async (group) => {
|
||||
// Why: identical removal coordinates must stay ordered instead of racing.
|
||||
for (const { cookie, url } of group) {
|
||||
await store.remove(url, cookie.name)
|
||||
}
|
||||
}
|
||||
)
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
)
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
'Could not clear existing cookies; the session was left partially cleared'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,8 +511,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix))
|
||||
).toEqual(sourceFilesBefore)
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(clearStorageDataMock).toHaveBeenCalledOnce()
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
|
||||
expect(clearStorageDataMock).not.toHaveBeenCalled()
|
||||
// Why: STA-3514 — imports must never impersonate the source browser; the
|
||||
// session keeps the engine UA the registry set at startup.
|
||||
expect(setUserAgentMock).not.toHaveBeenCalled()
|
||||
|
||||
@@ -75,12 +75,12 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import {
|
||||
bulkClearCookiesExcept,
|
||||
isGoogleSourceBoundCookie,
|
||||
isNonTransplantableCookieDomain,
|
||||
NON_TRANSPLANTABLE_HOST_KEY_SQL,
|
||||
normalizeCookieDomain,
|
||||
normalizeCookieImportDomain,
|
||||
removeAllCookiesExcept,
|
||||
replaceCookiesForImportedDomains,
|
||||
restoreImportedDomainCookies,
|
||||
type CookieImportMode
|
||||
@@ -1780,11 +1780,9 @@ export async function importCookiesFromBrowser(
|
||||
// Why: clear stale cookies first; mixing them with the imported set makes sites reject the
|
||||
// session. Non-transplantable families are exempt — nothing was imported for them, and their
|
||||
// live session is the only one that works.
|
||||
await bulkClearCookiesExcept(targetSession, (cookie) =>
|
||||
await removeAllCookiesExcept(targetSession.cookies, (cookie) =>
|
||||
isNonTransplantableCookieDomain(cookie.domain ?? '')
|
||||
)
|
||||
// Why: after excluded cookies survive this boundary, restart staging owns later set failures;
|
||||
// restoring stale non-Google cookies would recreate the mixed jar this import must replace.
|
||||
diag(
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
|
||||
@@ -167,7 +167,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
|
||||
compactWorktreeCards: false,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides,
|
||||
diffWordWrap: overrides.diffWordWrap ?? false,
|
||||
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
|
||||
|
||||
@@ -159,7 +159,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
compactWorktreeCards: false,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides,
|
||||
diffWordWrap: overrides.diffWordWrap ?? false,
|
||||
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
|
||||
|
||||
@@ -335,6 +335,30 @@ describe('DaemonClient', () => {
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await expect(client.ensureConnected()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('fails a retired endpoint at the connect, not at the token read', async () => {
|
||||
rmSync(tokenPath)
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
|
||||
const error = (await client
|
||||
.ensureConnected()
|
||||
.catch((err: unknown) => err)) as NodeJS.ErrnoException
|
||||
|
||||
expect(error.syscall).toBe('connect')
|
||||
expect(['ENOENT', 'ECONNREFUSED']).toContain(error.code)
|
||||
})
|
||||
|
||||
it('still attempts the handshake when the token is gone but a daemon is listening', async () => {
|
||||
const hellos: HelloMessage[] = []
|
||||
await startMockDaemon({ onHello: (msg) => hellos.push(msg) })
|
||||
rmSync(tokenPath)
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(hellos.length).toBeGreaterThan(0)
|
||||
expect(hellos[0]?.token).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RPC', () => {
|
||||
|
||||
@@ -114,12 +114,24 @@ 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<void> {
|
||||
const token = readFileSync(this.tokenPath, 'utf-8').trim()
|
||||
const token = this.readToken()
|
||||
const deadlineMs = Date.now() + timeoutMs
|
||||
const remainingMs = (): number =>
|
||||
sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs
|
||||
|
||||
@@ -595,6 +595,42 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('respawns after a retired daemon regardless of how the connection ended', async () => {
|
||||
// Why this shape: the daemon retires when its last authenticated client drops, and that drop
|
||||
// is often our own disconnect() — which observes no socket close. Once the token read stops
|
||||
// preempting the connect, the retired endpoint fails as a connect and isDaemonGoneError
|
||||
// classifies it, so recovery no longer depends on having witnessed the drop.
|
||||
let respawnServer: DaemonServer | undefined
|
||||
const respawn = vi.fn(async () => {
|
||||
respawnServer = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await respawnServer.start()
|
||||
})
|
||||
const healingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const { id } = await healingAdapter.spawn({ cols: 80, rows: 24 })
|
||||
const client = (healingAdapter as unknown as { client: DaemonClient }).client
|
||||
|
||||
client.disconnect()
|
||||
await server.shutdown()
|
||||
expect(existsSync(tokenPath)).toBe(false)
|
||||
expect(client.hasObservedAuthenticatedDisconnect()).toBe(false)
|
||||
|
||||
await expect(
|
||||
healingAdapter.spawn({ sessionId: id, cols: 80, rows: 24 })
|
||||
).resolves.toMatchObject({ id })
|
||||
expect(respawn).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
healingAdapter.dispose()
|
||||
await respawnServer?.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not spawn a daemon per keystroke after respawn fails', async () => {
|
||||
const respawn = vi.fn(async () => {
|
||||
throw new Error('daemon unavailable')
|
||||
|
||||
@@ -1475,8 +1475,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
)
|
||||
return processes
|
||||
} catch (error) {
|
||||
const missingAuthenticatedToken =
|
||||
isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect()
|
||||
const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing()
|
||||
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
|
||||
this.observeAuditFailure(
|
||||
missingAuthenticatedToken
|
||||
@@ -1960,7 +1959,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Why final=true not teardown: clean disconnect needs the full-depth snapshot as the restore source, but the
|
||||
// Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the
|
||||
// detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact.
|
||||
private async checkpointAllSessions(): Promise<void> {
|
||||
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
|
||||
@@ -2122,14 +2121,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
// Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart.
|
||||
// Why: the token read no longer throws, so audit its absence directly after an authenticated drop.
|
||||
private isRetiredEndpointTokenMissing(): boolean {
|
||||
return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath)
|
||||
}
|
||||
|
||||
private async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
// Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon.
|
||||
const missingRetiredEndpointToken =
|
||||
isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
|
||||
const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing()
|
||||
if (missingRetiredEndpointToken) {
|
||||
this.observeAuditFailure(
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
@@ -2137,11 +2138,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
['token_file']
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.respawnAdoptionClosed ||
|
||||
!this.respawnFn ||
|
||||
(!isDaemonGoneError(err) && !missingRetiredEndpointToken)
|
||||
) {
|
||||
if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) {
|
||||
throw err
|
||||
}
|
||||
if (!this.respawnPromise) {
|
||||
@@ -2535,14 +2532,6 @@ export function isDaemonGoneError(err: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function isMissingTokenFileError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const errno = err as NodeJS.ErrnoException
|
||||
return errno.code === 'ENOENT' && errno.syscall === 'open'
|
||||
}
|
||||
|
||||
function isMissingWindowsNamedPipeError(err: unknown): boolean {
|
||||
if (process.platform !== 'win32' || !(err instanceof Error)) {
|
||||
return false
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
|
||||
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type { SubprocessHandle } from './session'
|
||||
|
||||
@@ -93,14 +94,21 @@ describe('DaemonPtyRouter history handoff', () => {
|
||||
}
|
||||
await Promise.all([legacyServer?.shutdown(), currentServer?.shutdown()])
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('moves a slept v29 session to v30 with its full large history', async () => {
|
||||
const sessionId = 'large-legacy-session'
|
||||
const marker = 'V29-HISTORY-HANDOFF-MARKER'
|
||||
// Why: this fixture plays an OLD daemon binary, whose sessions retained ~5000 rows. New code
|
||||
// applies the flat window at session create, which would shrink the history below the chunked
|
||||
// seed threshold and silently skip the transfer path this test exists to cover.
|
||||
vi.stubEnv('ORCA_DAEMON_SESSION_SCROLLBACK_ROWS', '5000')
|
||||
await legacyAdapter.spawn({ sessionId, cols: 400, rows: 24 })
|
||||
legacySubprocess.emitData(`${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}${marker}`)
|
||||
// Keep only the played legacy session deep; the receiving daemon must use today's flat window.
|
||||
vi.stubEnv('ORCA_DAEMON_SESSION_SCROLLBACK_ROWS', String(DAEMON_SESSION_SCROLLBACK_ROWS))
|
||||
router = new DaemonPtyRouter({ current: currentAdapter, legacy: [legacyAdapter] })
|
||||
await router.discoverLegacySessions()
|
||||
const client = (
|
||||
|
||||
@@ -213,17 +213,52 @@ describe('daemon self-retirement respawn', () => {
|
||||
expect(respawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat an initial missing token as respawn authority', async () => {
|
||||
it('does not respawn a listening daemon whose token is absent', async () => {
|
||||
await startServer()
|
||||
rmSync(tokenPath)
|
||||
const respawn = vi.fn(async () => {})
|
||||
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
|
||||
|
||||
await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject(
|
||||
{
|
||||
code: 'ENOENT'
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
adapter.spawn({ sessionId: 'startup-window', cols: 80, rows: 24 })
|
||||
).rejects.toThrow(/Invalid token/i)
|
||||
|
||||
expect(respawn).not.toHaveBeenCalled()
|
||||
adapter.dispose()
|
||||
})
|
||||
|
||||
it('does not let stale disconnect evidence authorize respawning a listening daemon', async () => {
|
||||
const original = await startServer()
|
||||
const respawn = vi.fn(async () => {})
|
||||
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
|
||||
await adapter.listProcesses()
|
||||
const client = (
|
||||
adapter as unknown as {
|
||||
client: { hasObservedAuthenticatedDisconnect(): boolean }
|
||||
}
|
||||
).client
|
||||
|
||||
await original.shutdown()
|
||||
await waitFor(() => client.hasObservedAuthenticatedDisconnect())
|
||||
await startServer()
|
||||
rmSync(tokenPath)
|
||||
|
||||
await expect(
|
||||
adapter.spawn({ sessionId: 'replacement-startup-window', cols: 80, rows: 24 })
|
||||
).rejects.toThrow(/Invalid token/i)
|
||||
|
||||
expect(respawn).not.toHaveBeenCalled()
|
||||
adapter.dispose()
|
||||
})
|
||||
|
||||
it('respawns when nothing is accepting on the endpoint', async () => {
|
||||
const respawn = vi.fn(async () => {})
|
||||
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
|
||||
|
||||
// Fails after the respawn attempt because the mock respawn starts no replacement.
|
||||
await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toThrow()
|
||||
|
||||
expect(respawn).toHaveBeenCalledTimes(1)
|
||||
adapter.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Socket } from 'node:net'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host'
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle {
|
||||
let onExit: ((code: number) => void) | undefined
|
||||
return {
|
||||
pid: 55_555,
|
||||
getForegroundProcess: () => null,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit(callback) {
|
||||
onExit = callback
|
||||
},
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
type DaemonAttachmentPrivate = {
|
||||
host: {
|
||||
detach: (sessionId: string, token: symbol) => void
|
||||
detachClients: (attachments: readonly { sessionId: string; token: symbol }[]) => void
|
||||
createOrAttach: (options: CreateOrAttachOptions) => Promise<CreateOrAttachResult>
|
||||
sessions: Map<string, { hasAttachedClients: boolean }>
|
||||
}
|
||||
clients: Map<string, { streamSocket: Socket | null }>
|
||||
streamClientIdBySessionId: Map<string, string>
|
||||
attachTokenBySessionId: Map<string, symbol>
|
||||
}
|
||||
|
||||
describe('DaemonServer attachment lifecycle', () => {
|
||||
let directory: string
|
||||
let server: DaemonServer
|
||||
let client: DaemonClient
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'daemon-attachment-lifecycle-'))
|
||||
const socketPath = getDaemonSocketPath(directory)
|
||||
const tokenPath = join(directory, 'daemon.token')
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: createMockSubprocess
|
||||
})
|
||||
await server.start()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
client.disconnect()
|
||||
await server.shutdown()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function createSession(sessionId: string): Promise<void> {
|
||||
await client.request('createOrAttach', { sessionId, cols: 80, rows: 24 })
|
||||
}
|
||||
|
||||
it('parks attached sessions when their stream transport closes', async () => {
|
||||
await createSession('transport-owned-session')
|
||||
const daemon = server as unknown as DaemonAttachmentPrivate
|
||||
const detachClients = vi.spyOn(daemon.host, 'detachClients')
|
||||
const streamSocket = [...daemon.clients.values()][0]?.streamSocket
|
||||
|
||||
streamSocket?.destroy()
|
||||
|
||||
await vi.waitFor(() => expect(detachClients).toHaveBeenCalledOnce())
|
||||
expect(detachClients.mock.calls[0]?.[0]).toEqual([
|
||||
{ sessionId: 'transport-owned-session', token: expect.any(Symbol) }
|
||||
])
|
||||
expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false)
|
||||
expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false)
|
||||
expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false)
|
||||
})
|
||||
|
||||
it('parks only the requesting client attachment on explicit detach', async () => {
|
||||
await createSession('explicitly-detached-session')
|
||||
const daemon = server as unknown as DaemonAttachmentPrivate
|
||||
const detach = vi.spyOn(daemon.host, 'detach')
|
||||
|
||||
await client.request('detach', { sessionId: 'explicitly-detached-session' })
|
||||
|
||||
expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol))
|
||||
expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false)
|
||||
expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false)
|
||||
expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false)
|
||||
})
|
||||
|
||||
it('parks an attachment completed after its transport already closed', async () => {
|
||||
const daemon = server as unknown as DaemonAttachmentPrivate
|
||||
const originalCreateOrAttach = daemon.host.createOrAttach.bind(daemon.host)
|
||||
let attachmentCreated!: () => void
|
||||
let finishRequest!: () => void
|
||||
const created = new Promise<void>((resolve) => {
|
||||
attachmentCreated = resolve
|
||||
})
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finishRequest = resolve
|
||||
})
|
||||
vi.spyOn(daemon.host, 'createOrAttach').mockImplementation(async (options) => {
|
||||
const result = await originalCreateOrAttach(options)
|
||||
attachmentCreated()
|
||||
await gate
|
||||
return result
|
||||
})
|
||||
const request = client
|
||||
.request('createOrAttach', { sessionId: 'close-race-session', cols: 80, rows: 24 })
|
||||
.catch(() => undefined)
|
||||
await created
|
||||
|
||||
client.disconnect()
|
||||
await vi.waitFor(() => expect(daemon.clients.size).toBe(0))
|
||||
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true)
|
||||
finishRequest()
|
||||
|
||||
await request
|
||||
await vi.waitFor(() =>
|
||||
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false)
|
||||
)
|
||||
expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false)
|
||||
expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -184,6 +184,7 @@ export class DaemonServer {
|
||||
}
|
||||
})
|
||||
private streamClientIdBySessionId = new Map<string, string>()
|
||||
private attachTokenBySessionId = new Map<string, symbol>()
|
||||
private lastInputAtBySessionId = new Map<string, number>()
|
||||
private pendingPtySpawnPreparations = new Map<string, Set<PendingPtySpawnPreparation>>()
|
||||
private historySeedTransfers = new TerminalHistorySeedTransferRegistry()
|
||||
@@ -227,7 +228,17 @@ export class DaemonServer {
|
||||
this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {})
|
||||
this.host = new TerminalHost({
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {})
|
||||
// Why host-level and not the attach callback: a session whose client transport already dropped
|
||||
// has no attachment left to fire exit bookkeeping, and the daemon must still notice it can idle.
|
||||
onSessionReaped: (sessionId) => {
|
||||
this.streamClientIdBySessionId.delete(sessionId)
|
||||
this.attachTokenBySessionId.delete(sessionId)
|
||||
this.lastInputAtBySessionId.delete(sessionId)
|
||||
this.transientFactRelay.onSessionExit(sessionId)
|
||||
this.streamDataBatcher.refreshSessionDroppability(sessionId)
|
||||
opts.onPtySessionExit?.(sessionId)
|
||||
this.reevaluateIdleShutdown()
|
||||
}
|
||||
})
|
||||
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
||||
this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve())
|
||||
@@ -774,6 +785,7 @@ export class DaemonServer {
|
||||
this.historySeedTransfers.clearOwner(clientId)
|
||||
const wasFullyAuthenticated = client.authenticatedPairEstablished
|
||||
this.streamDataBatcher.clear(clientId)
|
||||
this.detachClientSessions(clientId)
|
||||
client.streamSocket?.destroy()
|
||||
this.clients.delete(clientId)
|
||||
this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
|
||||
@@ -811,6 +823,7 @@ export class DaemonServer {
|
||||
// Why: a preflight that outlives its output channel would create an unattached daemon PTY.
|
||||
this.cancelPendingPtySpawnPreparationsForClient(client.clientId)
|
||||
this.streamDataBatcher.clear(client.clientId)
|
||||
this.detachClientSessions(client.clientId)
|
||||
client.streamSocket = null
|
||||
}
|
||||
|
||||
@@ -936,6 +949,36 @@ export class DaemonServer {
|
||||
}
|
||||
}
|
||||
|
||||
private detachClientSessions(clientId: string): void {
|
||||
const attachments: { sessionId: string; token: symbol }[] = []
|
||||
for (const [sessionId, attachedClientId] of this.streamClientIdBySessionId) {
|
||||
if (attachedClientId !== clientId) {
|
||||
continue
|
||||
}
|
||||
const token = this.attachTokenBySessionId.get(sessionId)
|
||||
if (token) {
|
||||
attachments.push({ sessionId, token })
|
||||
}
|
||||
this.streamClientIdBySessionId.delete(sessionId)
|
||||
this.attachTokenBySessionId.delete(sessionId)
|
||||
}
|
||||
if (attachments.length > 0) {
|
||||
this.host.detachClients(attachments)
|
||||
}
|
||||
}
|
||||
|
||||
private detachSessionForClient(sessionId: string, clientId: string): void {
|
||||
if (this.streamClientIdBySessionId.get(sessionId) !== clientId) {
|
||||
return
|
||||
}
|
||||
const token = this.attachTokenBySessionId.get(sessionId)
|
||||
if (token) {
|
||||
this.host.detach(sessionId, token)
|
||||
}
|
||||
this.streamClientIdBySessionId.delete(sessionId)
|
||||
this.attachTokenBySessionId.delete(sessionId)
|
||||
}
|
||||
|
||||
private async routeRequest(clientId: string, request: DaemonRequest): Promise<unknown> {
|
||||
const client = this.clients.get(clientId)
|
||||
|
||||
@@ -1071,6 +1114,7 @@ export class DaemonServer {
|
||||
this.transientFactRelay.onSessionExit(routedSessionId)
|
||||
this.streamDataBatcher.refreshSessionDroppability(routedSessionId)
|
||||
this.streamClientIdBySessionId.delete(routedSessionId)
|
||||
this.attachTokenBySessionId.delete(routedSessionId)
|
||||
this.lastInputAtBySessionId.delete(routedSessionId)
|
||||
this.reevaluateIdleShutdown()
|
||||
}
|
||||
@@ -1081,7 +1125,16 @@ export class DaemonServer {
|
||||
this.reevaluateIdleShutdown()
|
||||
}
|
||||
routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId
|
||||
if (
|
||||
this.clients.get(clientId) !== client ||
|
||||
!client.authenticatedPairEstablished ||
|
||||
client.streamSocket === null
|
||||
) {
|
||||
this.host.detach(routedSessionId, result.attachToken)
|
||||
throw new TerminalAttachCanceledError(routedSessionId)
|
||||
}
|
||||
this.streamClientIdBySessionId.set(routedSessionId, clientId)
|
||||
this.attachTokenBySessionId.set(routedSessionId, result.attachToken)
|
||||
this.streamDataBatcher.refreshSessionDroppability(routedSessionId)
|
||||
// Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head.
|
||||
if (this.transientFactRelay.isBackgrounded(routedSessionId)) {
|
||||
@@ -1228,7 +1281,7 @@ export class DaemonServer {
|
||||
return {}
|
||||
|
||||
case 'detach':
|
||||
// Note: detach token handling simplified — full impl would track tokens per client
|
||||
this.detachSessionForClient(request.payload.sessionId, clientId)
|
||||
this.log.log('session-detached', {
|
||||
sessionId: request.payload.sessionId
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* OOM regression: a daemon owning 100+ terminals retained ~5000 rows of grid per session with no
|
||||
* bound, grew to ~1.9 GB, and was killed under system memory pressure — losing every session it
|
||||
* owned. Sessions now retain a flat window; deep scrolling on an open terminal is the renderer's
|
||||
* live buffer, and the window is what a rebuild (reload/remount/restart/remote attach) restores.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import {
|
||||
DAEMON_SESSION_SCROLLBACK_ROWS,
|
||||
resolveDaemonSessionScrollbackRows
|
||||
} from './daemon-session-scrollback-window'
|
||||
|
||||
describe('resolveDaemonSessionScrollbackRows', () => {
|
||||
it('defaults to the flat window', () => {
|
||||
expect(resolveDaemonSessionScrollbackRows({} as NodeJS.ProcessEnv)).toBe(
|
||||
DAEMON_SESSION_SCROLLBACK_ROWS
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the inclusive override bounds and rejects everything outside them', () => {
|
||||
for (const raw of ['100', '2500', '5000']) {
|
||||
const env = { ORCA_DAEMON_SESSION_SCROLLBACK_ROWS: raw } as NodeJS.ProcessEnv
|
||||
expect(resolveDaemonSessionScrollbackRows(env)).toBe(Number(raw))
|
||||
}
|
||||
// Why bounded: 0 loses the visible screen's context; huge values silently reintroduce the
|
||||
// unbounded retention this window exists to prevent.
|
||||
for (const raw of ['0', '50', '99', '5001', '50000', '-1', '3.5', 'nonsense', '']) {
|
||||
const env = { ORCA_DAEMON_SESSION_SCROLLBACK_ROWS: raw } as NodeJS.ProcessEnv
|
||||
expect(resolveDaemonSessionScrollbackRows(env)).toBe(DAEMON_SESSION_SCROLLBACK_ROWS)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('daemon session scrollback window', () => {
|
||||
let host: TerminalHost
|
||||
let dataCb: ((data: string) => void) | null
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle {
|
||||
let onExitCb: ((code: number) => void) | null = null
|
||||
return {
|
||||
pid: 4242,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
setTimeout(() => onExitCb?.(0), 1)
|
||||
}),
|
||||
forceKill: vi.fn(() => onExitCb?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData(cb) {
|
||||
dataCb = cb
|
||||
},
|
||||
onExit(cb) {
|
||||
onExitCb = cb
|
||||
},
|
||||
dispose: vi.fn()
|
||||
} as SubprocessHandle
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dataCb = null
|
||||
host = new TerminalHost({ spawnSubprocess: () => createMockSubprocess() })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('caps retained rows at the window while keeping the newest content', async () => {
|
||||
await host.createOrAttach({
|
||||
sessionId: 'windowed',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
const total = DAEMON_SESSION_SCROLLBACK_ROWS + 500
|
||||
for (let i = 1; i <= total; i += 1) {
|
||||
dataCb?.(`LINE_${String(i).padStart(5, '0')}\r\n`)
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
const snapshot = host.getSnapshot('windowed')
|
||||
expect(snapshot?.snapshotAnsi ?? snapshot?.scrollbackAnsi).toBeTruthy()
|
||||
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.snapshotAnsi ?? ''}`
|
||||
// Newest line always present; the oldest has scrolled past the window.
|
||||
expect(text).toContain(`LINE_${String(total).padStart(5, '0')}`)
|
||||
expect(text).not.toContain('LINE_00001')
|
||||
const retainedMatches = text.match(/LINE_\d{5}/g) ?? []
|
||||
expect(retainedMatches.length).toBeLessThanOrEqual(DAEMON_SESSION_SCROLLBACK_ROWS + 24)
|
||||
expect(retainedMatches.length).toBeGreaterThan(DAEMON_SESSION_SCROLLBACK_ROWS - 50)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
const DAEMON_SESSION_SCROLLBACK_ENV_VAR = 'ORCA_DAEMON_SESSION_SCROLLBACK_ROWS'
|
||||
|
||||
// Why a flat window and not full renderer depth: retained grid is the daemon's dominant heap term and
|
||||
// session count is unbounded — a host owning 100+ terminals at full depth retained ~1 GB of grid and
|
||||
// was OOM-killed, taking every session it owned with it. A terminal the user has open scrolls its full
|
||||
// live renderer buffer; the daemon window is only what a REBUILD (reload, remount, restart, remote
|
||||
// attach) restores, and 1000 rows is the generous end of what terminal products restore there.
|
||||
export const DAEMON_SESSION_SCROLLBACK_ROWS = 1000
|
||||
// Why: keep any override within sane terminal bounds — 0 would lose the visible screen's context and
|
||||
// huge values silently reintroduce the unbounded-retention failure this window exists to prevent.
|
||||
const MIN_OVERRIDE_ROWS = 100
|
||||
const MAX_OVERRIDE_ROWS = 5000
|
||||
|
||||
export function resolveDaemonSessionScrollbackRows(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env[DAEMON_SESSION_SCROLLBACK_ENV_VAR]?.trim()
|
||||
if (!raw || !/^\d+$/.test(raw)) {
|
||||
return DAEMON_SESSION_SCROLLBACK_ROWS
|
||||
}
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < MIN_OVERRIDE_ROWS || parsed > MAX_OVERRIDE_ROWS) {
|
||||
return DAEMON_SESSION_SCROLLBACK_ROWS
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Attachment-leak regression: an attachment that outlives its transport leaves the session looking
|
||||
* viewed forever — producer pause/resume and any attachment-gated behavior then act on a client that
|
||||
* no longer exists. A dropped client transport must release exactly that client's attachments, and
|
||||
* protocol detach must really detach (it was previously logging-only).
|
||||
*/
|
||||
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 { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { Session, SubprocessHandle } from './session'
|
||||
|
||||
function fixtureSubprocess(): SubprocessHandle {
|
||||
let onExitCb: ((code: number) => void) | null = null
|
||||
return {
|
||||
pid: 1234,
|
||||
getForegroundProcess: () => null,
|
||||
write: () => {},
|
||||
resize: () => {},
|
||||
kill: () => {
|
||||
setTimeout(() => onExitCb?.(0), 1)
|
||||
},
|
||||
forceKill: () => onExitCb?.(137),
|
||||
signal: () => {},
|
||||
onData: () => {},
|
||||
onExit: (cb) => {
|
||||
onExitCb = cb
|
||||
},
|
||||
dispose: () => {}
|
||||
} as SubprocessHandle
|
||||
}
|
||||
|
||||
describe('transport-drop attachment release', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer | null
|
||||
let adapter: DaemonPtyAdapter | null
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-attachment-release-'))
|
||||
socketPath = getDaemonSocketPath(dir)
|
||||
tokenPath = join(dir, 'daemon.token')
|
||||
server = null
|
||||
adapter = null
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.disconnectOnly().catch(() => {})
|
||||
await server?.shutdown().catch(() => {})
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionOf(started: DaemonServer, sessionId: string): Session | undefined {
|
||||
const host = (started as unknown as { host: { sessions: Map<string, Session> } }).host
|
||||
return host.sessions.get(sessionId)
|
||||
}
|
||||
|
||||
it('releases a dropped client transport instead of leaving a phantom attachment', async () => {
|
||||
const started = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => fixtureSubprocess()
|
||||
})
|
||||
await started.start()
|
||||
server = started
|
||||
|
||||
adapter = new DaemonPtyAdapter({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
profileScope: dir,
|
||||
historyPath: join(dir, 'history')
|
||||
})
|
||||
await adapter.spawn({ sessionId: 'viewed-then-dropped', cols: 80, rows: 24 })
|
||||
|
||||
const session = sessionOf(started, 'viewed-then-dropped')
|
||||
expect(session).toBeDefined()
|
||||
expect(session!.hasAttachedClients).toBe(true)
|
||||
|
||||
// Drop the transport without any protocol detach — the crash/abrupt-exit case.
|
||||
const client = (adapter as unknown as { client: { disconnect(): void } }).client
|
||||
client.disconnect()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session!.hasAttachedClients).toBe(false)
|
||||
})
|
||||
// The session itself must survive the disconnect — only the attachment is released.
|
||||
expect(session!.isAlive).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import type { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
import type { TerminalModes } from './types'
|
||||
|
||||
type TerminalWithKittyKeyboard = Terminal & {
|
||||
// Why: kitty keyboard flags aren't on the public IModes; read the core service the CSI u handlers mutate.
|
||||
_core?: { coreService?: { kittyKeyboard?: { flags?: number } } }
|
||||
}
|
||||
|
||||
export function readKittyKeyboardFlags(terminal: Terminal): number {
|
||||
const flags = (terminal as TerminalWithKittyKeyboard)._core?.coreService?.kittyKeyboard?.flags
|
||||
return typeof flags === 'number' ? flags : 0
|
||||
}
|
||||
|
||||
/** Mode state a snapshot must carry so a restored pane behaves like the live one. */
|
||||
export function readTerminalModes(
|
||||
terminal: Terminal,
|
||||
mouseModes: TerminalMouseModeMirror
|
||||
): TerminalModes {
|
||||
const buffer = terminal.buffer.active
|
||||
const mouseTrackingMode = mouseModes.mouseTrackingMode
|
||||
return {
|
||||
bracketedPaste: terminal.modes.bracketedPasteMode,
|
||||
mouseTracking: mouseTrackingMode !== 'none',
|
||||
mouseTrackingMode,
|
||||
sgrMouseMode: mouseModes.sgrMouseMode,
|
||||
sgrMousePixelsMode: mouseModes.sgrMousePixelsMode,
|
||||
applicationCursor: buffer.type === 'normal' ? terminal.modes.applicationCursorKeysMode : false,
|
||||
alternateScreen: buffer.type === 'alternate',
|
||||
kittyKeyboardFlags: readKittyKeyboardFlags(terminal)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
|
||||
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
||||
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
|
||||
import { readTerminalModes } from './headless-emulator-modes'
|
||||
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
|
||||
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
|
||||
@@ -338,24 +339,6 @@ export class HeadlessEmulator {
|
||||
}
|
||||
|
||||
private getModes(): TerminalModes {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const mouseTrackingMode = this.mouseModes.mouseTrackingMode
|
||||
return {
|
||||
bracketedPaste: this.terminal.modes.bracketedPasteMode,
|
||||
mouseTracking: mouseTrackingMode !== 'none',
|
||||
mouseTrackingMode,
|
||||
sgrMouseMode: this.mouseModes.sgrMouseMode,
|
||||
sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode,
|
||||
applicationCursor:
|
||||
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
|
||||
alternateScreen: buffer.type === 'alternate',
|
||||
kittyKeyboardFlags: this.getKittyKeyboardFlags()
|
||||
}
|
||||
}
|
||||
|
||||
private getKittyKeyboardFlags(): number {
|
||||
const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard
|
||||
?.flags
|
||||
return typeof flags === 'number' ? flags : 0
|
||||
return readTerminalModes(this.terminal, this.mouseModes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,6 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
|
||||
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
|
||||
|
||||
const ORCA_SHELL_WRAPPER_ENV = [
|
||||
'ORCA_ATTRIBUTION_SHIM_DIR',
|
||||
'ORCA_OPENCODE_CONFIG_DIR',
|
||||
'ORCA_MIMOCODE_HOME',
|
||||
'ORCA_PI_CODING_AGENT_DIR',
|
||||
@@ -1760,7 +1759,7 @@ describe('createPtySubprocess', () => {
|
||||
expect(spawnEnv.MY_VAR).toBe('test-value')
|
||||
})
|
||||
|
||||
it('uses shell wrapper when attribution shims must survive shell startup', () => {
|
||||
it('uses shell wrapper when managed env must survive shell startup', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
@@ -1773,7 +1772,7 @@ describe('createPtySubprocess', () => {
|
||||
rows: 24,
|
||||
env: {
|
||||
SHELL: '/bin/zsh',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/orca-terminal-attribution/posix'
|
||||
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
@@ -2165,7 +2164,7 @@ describe('createPtySubprocess', () => {
|
||||
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
|
||||
},
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
envToDelete: ['TERM_PROGRAM']
|
||||
})
|
||||
|
||||
const lastCall = spawnMock.mock.calls.at(-1)!
|
||||
@@ -2173,7 +2172,6 @@ describe('createPtySubprocess', () => {
|
||||
expect(lastCall[2].env.TERM).toBe('screen-256color')
|
||||
expect(lastCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(lastCall[2].env.TERM_PROGRAM).toBeUndefined()
|
||||
expect(lastCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('collapses its own env merge onto the requested Windows `Path` spelling', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { delimiter, win32 as pathWin32 } from 'node:path'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { DaemonProtocolError } from './types'
|
||||
import {
|
||||
getAttributionShellLaunchConfig,
|
||||
getMarkerlessShellLaunchConfig,
|
||||
getShellReadyLaunchConfig,
|
||||
resolvePtyShellPath
|
||||
} from './shell-ready'
|
||||
@@ -800,18 +800,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
// Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path.
|
||||
shellLaunch = shouldWaitForShellReady
|
||||
? getShellReadyLaunchConfig(shellPath)
|
||||
: getAttributionShellLaunchConfig(shellPath)
|
||||
: getMarkerlessShellLaunchConfig(shellPath)
|
||||
} else if (opts.command) {
|
||||
shellLaunch = getShellReadyLaunchConfig(shellPath)
|
||||
} else {
|
||||
shellLaunch =
|
||||
env.ORCA_ATTRIBUTION_SHIM_DIR ||
|
||||
env.ORCA_OPENCODE_CONFIG_DIR ||
|
||||
env.ORCA_MIMOCODE_HOME ||
|
||||
env.ORCA_OMP_STATUS_EXTENSION ||
|
||||
env.ORCA_CODEX_HOME ||
|
||||
env.ORCA_AGENT_TEAMS_SHIM_DIR
|
||||
? getAttributionShellLaunchConfig(shellPath)
|
||||
? getMarkerlessShellLaunchConfig(shellPath)
|
||||
: null
|
||||
}
|
||||
if (shellLaunch) {
|
||||
@@ -1062,6 +1061,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
return {
|
||||
pid: proc.pid,
|
||||
shellPath,
|
||||
shellCwd: spawnCwd,
|
||||
shellPathEnv: env.PATH,
|
||||
...(slavePath ? { slavePath } : {}),
|
||||
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
|
||||
getForegroundProcess: () => {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { Session } from './session'
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const hasZsh = process.platform !== 'win32' && spawnSync('/bin/zsh', ['--version']).status === 0
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('/bin/bash', ['--version']).status === 0
|
||||
const COMMAND_OUTPUT = 'ORCA_STARTUP_COMMAND_RAN'
|
||||
const READ_STARTED_FILE = '.orca-read-started'
|
||||
|
||||
type ShellFixture = {
|
||||
name: string
|
||||
shellPath: string
|
||||
startupFile: string
|
||||
replacement: string
|
||||
command: string
|
||||
instrumentationOutput: string
|
||||
secretRead: string
|
||||
childRead: string
|
||||
}
|
||||
|
||||
const FIXTURES: ShellFixture[] = [
|
||||
{
|
||||
name: 'zsh profile',
|
||||
shellPath: '/bin/zsh',
|
||||
startupFile: '.zprofile',
|
||||
replacement: 'exec -a kiro-cli-term /bin/zsh -o noglobalrcs -l -i',
|
||||
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
|
||||
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
|
||||
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
|
||||
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
|
||||
},
|
||||
{
|
||||
name: 'zsh environment',
|
||||
shellPath: '/bin/zsh',
|
||||
startupFile: '.zshenv',
|
||||
replacement: 'exec /bin/zsh -o noglobalrcs -l -i',
|
||||
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
|
||||
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
|
||||
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
|
||||
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
|
||||
},
|
||||
{
|
||||
name: 'bash profile',
|
||||
shellPath: '/bin/bash',
|
||||
startupFile: '.bash_profile',
|
||||
replacement: 'exec -a figterm-test /bin/bash --noprofile --norc -l -i',
|
||||
command: `printf 'ORCA_STARTUP_%s:PC=%s\\n' COMMAND_RAN "$PROMPT_COMMAND"\r`,
|
||||
instrumentationOutput: `${COMMAND_OUTPUT}:PC=`,
|
||||
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -s -n 1\n`,
|
||||
childRead: `/bin/bash --noprofile --norc -c ': > "$HOME/${READ_STARTED_FILE}"; read -s -n 1'\n`
|
||||
}
|
||||
]
|
||||
|
||||
function count(text: string, needle: string): number {
|
||||
return text.split(needle).length - 1
|
||||
}
|
||||
|
||||
function waitForOutput(
|
||||
subscribe: (settle: () => void) => void,
|
||||
isDone: () => boolean,
|
||||
timeoutMs = 5_000
|
||||
): Promise<void> {
|
||||
if (isDone()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const deadline = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for PTY output')),
|
||||
timeoutMs
|
||||
)
|
||||
const settle = (): void => {
|
||||
if (!isDone()) {
|
||||
return
|
||||
}
|
||||
clearTimeout(deadline)
|
||||
resolve()
|
||||
}
|
||||
subscribe(settle)
|
||||
settle()
|
||||
})
|
||||
}
|
||||
|
||||
function waitForCondition(isDone: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isDone()) {
|
||||
return
|
||||
}
|
||||
clearInterval(interval)
|
||||
clearTimeout(deadline)
|
||||
resolve()
|
||||
}, 10)
|
||||
const deadline = setTimeout(() => {
|
||||
clearInterval(interval)
|
||||
reject(new Error('Timed out waiting for fixture state'))
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
function runCleanupActions(...actions: (() => void)[]): void {
|
||||
for (const action of actions) {
|
||||
try {
|
||||
action()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
type RunningFixture = {
|
||||
session: Session
|
||||
subprocess: ReturnType<typeof createPtySubprocess>
|
||||
output: () => string
|
||||
readStarted: () => boolean
|
||||
subscribe: (settle: () => void) => void
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
function startFixture(
|
||||
fixture: ShellFixture,
|
||||
startupContent: string,
|
||||
extraFiles: Record<string, string> = {}
|
||||
): RunningFixture {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'orca-shell-ready-exec-'))
|
||||
const previousHome = process.env.HOME
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
|
||||
let subprocess: ReturnType<typeof createPtySubprocess> | undefined
|
||||
let session: Session | undefined
|
||||
let consoleWarnSpy: { mockRestore: () => void } | undefined
|
||||
try {
|
||||
writeFileSync(join(tempHome, fixture.startupFile), startupContent)
|
||||
for (const [fileName, content] of Object.entries(extraFiles)) {
|
||||
writeFileSync(join(tempHome, fileName), content)
|
||||
}
|
||||
process.env.HOME = tempHome
|
||||
delete process.env.ZDOTDIR
|
||||
delete process.env.ORCA_ORIG_ZDOTDIR
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
subprocess = createPtySubprocess({
|
||||
sessionId: `repro-13767-${fixture.startupFile}`,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: tempHome,
|
||||
command: fixture.command,
|
||||
shellOverride: fixture.shellPath,
|
||||
env: {
|
||||
HOME: tempHome,
|
||||
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||
SHELL: fixture.shellPath,
|
||||
TERM: 'xterm-256color'
|
||||
},
|
||||
envToDelete: ['ORCA_EXEC_REPRO_DONE', 'ORCA_ORIG_ZDOTDIR', 'ZDOTDIR']
|
||||
})
|
||||
session = new Session({
|
||||
sessionId: `repro-13767-${fixture.startupFile}`,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
subprocess,
|
||||
shellReadySupported: true
|
||||
})
|
||||
let output = ''
|
||||
let onOutput = (): void => {}
|
||||
session.attachClient({
|
||||
onData: (data) => {
|
||||
output += data
|
||||
onOutput()
|
||||
},
|
||||
onExit: () => {}
|
||||
})
|
||||
session.write(fixture.command)
|
||||
const runningSubprocess = subprocess
|
||||
const runningSession = session
|
||||
const activeConsoleWarnSpy = consoleWarnSpy
|
||||
|
||||
return {
|
||||
session: runningSession,
|
||||
subprocess: runningSubprocess,
|
||||
output: () => output,
|
||||
readStarted: () => existsSync(join(tempHome, READ_STARTED_FILE)),
|
||||
subscribe: (settle) => {
|
||||
onOutput = settle
|
||||
},
|
||||
cleanup: async () => {
|
||||
if (runningSession.isAlive) {
|
||||
try {
|
||||
await runningSession.forceKillAndWaitForExit(3_000)
|
||||
} catch {
|
||||
runCleanupActions(() => runningSubprocess.forceKill())
|
||||
}
|
||||
}
|
||||
runCleanupActions(
|
||||
() => runningSession.dispose(),
|
||||
() => activeConsoleWarnSpy.mockRestore(),
|
||||
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
|
||||
() => rmSync(tempHome, { recursive: true, force: true })
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
runCleanupActions(
|
||||
() => subprocess?.forceKill(),
|
||||
() => session?.dispose(),
|
||||
() => !session && subprocess?.dispose(),
|
||||
() => consoleWarnSpy?.mockRestore(),
|
||||
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
|
||||
() => rmSync(tempHome, { recursive: true, force: true })
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnvironment(
|
||||
home: string | undefined,
|
||||
zdotdir: string | undefined,
|
||||
originalZdotdir: string | undefined
|
||||
): void {
|
||||
setEnvironmentValue('HOME', home)
|
||||
setEnvironmentValue('ZDOTDIR', zdotdir)
|
||||
setEnvironmentValue('ORCA_ORIG_ZDOTDIR', originalZdotdir)
|
||||
}
|
||||
|
||||
function setEnvironmentValue(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function runExecOracle(fixture: ShellFixture): Promise<void> {
|
||||
const running = startFixture(
|
||||
fixture,
|
||||
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
|
||||
export ORCA_EXEC_REPRO_DONE=1
|
||||
${fixture.replacement}
|
||||
fi
|
||||
`
|
||||
)
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
|
||||
expect(running.session.shellState).toBe('ready')
|
||||
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
|
||||
expect(running.output()).toContain(fixture.instrumentationOutput)
|
||||
expect(running.output()).not.toContain('orca-shell-start')
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
async function runReadOracle(fixture: ShellFixture, child: boolean): Promise<void> {
|
||||
const running = startFixture(fixture, child ? fixture.childRead : fixture.secretRead)
|
||||
try {
|
||||
await waitForCondition(running.readStarted)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
|
||||
running.subprocess.write('x\r')
|
||||
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
|
||||
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
|
||||
expect(running.output()).not.toContain('orca-shell-start')
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
describePosix('#13767 shell-ready marker loss across exec', () => {
|
||||
it('continues setup cleanup after force-kill failure', () => {
|
||||
const dispose = vi.fn()
|
||||
const restore = vi.fn()
|
||||
runCleanupActions(
|
||||
() => {
|
||||
throw new Error('force kill failed')
|
||||
},
|
||||
dispose,
|
||||
restore
|
||||
)
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(restore).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
for (const fixture of FIXTURES) {
|
||||
const runnable = fixture.shellPath.endsWith('zsh') ? hasZsh : hasBash
|
||||
const testCase = runnable ? it : it.skip
|
||||
testCase(
|
||||
`releases at the real ${fixture.name} prompt after exec`,
|
||||
() => runExecOracle(fixture),
|
||||
10_000
|
||||
)
|
||||
testCase(
|
||||
`keeps queued input out of a real ${fixture.name} silent read`,
|
||||
() => runReadOracle(fixture, false),
|
||||
10_000
|
||||
)
|
||||
testCase(
|
||||
`keeps queued input out of a child read during ${fixture.name} startup`,
|
||||
() => runReadOracle(fixture, true),
|
||||
10_000
|
||||
)
|
||||
}
|
||||
|
||||
const zshFixture = FIXTURES[0] as ShellFixture
|
||||
const zshTest = hasZsh ? it : it.skip
|
||||
zshTest(
|
||||
'keeps queued input out of a zle-line-init read after exec',
|
||||
async () => {
|
||||
const running = startFixture(
|
||||
zshFixture,
|
||||
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
|
||||
export ORCA_EXEC_REPRO_DONE=1
|
||||
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
|
||||
fi
|
||||
`,
|
||||
{
|
||||
'.zshrc': `zle-line-init() {
|
||||
print -n 'HOOK_SECRET> '
|
||||
read -sk 1
|
||||
print -r -- HOOK_DONE
|
||||
}
|
||||
zle -N zle-line-init
|
||||
`
|
||||
}
|
||||
)
|
||||
try {
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes('HOOK_SECRET> '))
|
||||
} catch (error) {
|
||||
throw new Error(`ZLE hook startup output: ${JSON.stringify(running.output())}`, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
|
||||
running.subprocess.write('x')
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
|
||||
} catch (error) {
|
||||
throw new Error(`ZLE hook output: ${JSON.stringify(running.output())}`, { cause: error })
|
||||
}
|
||||
expect(running.output()).toContain('HOOK_DONE')
|
||||
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
const sqliteTest = hasZsh && existsSync('/usr/bin/sqlite3') ? it : it.skip
|
||||
sqliteTest(
|
||||
'does not treat an exec-replaced readline program as the shell prompt',
|
||||
async () => {
|
||||
const running = startFixture(zshFixture, 'exec /usr/bin/sqlite3\n')
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
sqliteTest(
|
||||
'does not trust a non-shell executable renamed to the shell basename',
|
||||
async () => {
|
||||
const running = startFixture(
|
||||
zshFixture,
|
||||
'ln -s /usr/bin/sqlite3 "$HOME/zsh" && exec "$HOME/zsh"\n'
|
||||
)
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
zshTest(
|
||||
'retains the timeout backstop when zsh disables bracketed paste',
|
||||
async () => {
|
||||
const running = startFixture(
|
||||
zshFixture,
|
||||
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
|
||||
export ORCA_EXEC_REPRO_DONE=1
|
||||
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
|
||||
fi
|
||||
`,
|
||||
{
|
||||
'.zshrc': "zmodload zsh/zle\nunset zle_bracketed_paste\nPS1='NO_BRACKET_PROMPT> '\n"
|
||||
}
|
||||
)
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () =>
|
||||
running.output().includes('NO_BRACKET_PROMPT> ')
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
})
|
||||
@@ -13,6 +13,16 @@ import {
|
||||
scanForShellReady,
|
||||
type ShellReadyScanState
|
||||
} from '../shell-ready-marker-scanner'
|
||||
import {
|
||||
createShellStartupIdentityScanState,
|
||||
drainShellStartupIdentityHeldBytes,
|
||||
scanForShellStartupIdentity,
|
||||
type ShellStartupIdentityScanState
|
||||
} from '../shell-startup-identity-scanner'
|
||||
import {
|
||||
createShellPromptReadinessProbe,
|
||||
type ShellPromptReadinessProbe
|
||||
} from '../shell-prompt-readiness-probe'
|
||||
import { isPowerShellProcess } from '../../shared/shell-process-detection'
|
||||
import { killWithDescendantSweep } from '../pty-descendant-termination'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
@@ -60,6 +70,8 @@ export type SubprocessHandle = {
|
||||
/** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready
|
||||
* assumption against it so a fallback shell without a ready marker never gates startup commands. */
|
||||
shellPath?: string
|
||||
shellCwd?: string
|
||||
shellPathEnv?: string
|
||||
/** Slave device path, so startup replies can read the line discipline's ECHO bit before
|
||||
* writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */
|
||||
slavePath?: string
|
||||
@@ -125,6 +137,9 @@ export class Session {
|
||||
private releaseStartupDeviceAttributesResponder: (() => void) | null = null
|
||||
private startupDeviceAttributesQueryFilter: StartupDeviceAttributesQueryFilter | null = null
|
||||
private shellReadyScanState: ShellReadyScanState | null = null
|
||||
private shellStartupIdentityScanState: ShellStartupIdentityScanState | null = null
|
||||
private shellStartupPid: number | null = null
|
||||
private shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
|
||||
private shellReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private killTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private postReadyFlushGate: PostReadyFlushGate
|
||||
@@ -170,6 +185,7 @@ export class Session {
|
||||
if (opts.shellReadySupported) {
|
||||
this._shellState = 'pending'
|
||||
this.shellReadyScanState = createShellReadyScanState()
|
||||
this.shellStartupIdentityScanState = createShellStartupIdentityScanState()
|
||||
// Why: `write` queues everything until the ready marker, including the renderer's DA1
|
||||
// reply — and a shell that withholds its first prompt until DA1 is answered (fish) then
|
||||
// never emits the marker that would release it. Answer from the daemon, past the queue.
|
||||
@@ -195,6 +211,16 @@ export class Session {
|
||||
onEmission: (emission) => this.emitSubprocessOutput(emission),
|
||||
...(echoProbe ? { echoProbe } : {})
|
||||
})
|
||||
if (this._shellState === 'pending') {
|
||||
this.shellPromptReadinessProbe = createShellPromptReadinessProbe({
|
||||
slavePath: this.subprocess.slavePath,
|
||||
shellPath: this.subprocess.shellPath,
|
||||
shellCwd: this.subprocess.shellCwd,
|
||||
shellPathEnv: this.subprocess.shellPathEnv,
|
||||
getShellPid: () => this.shellStartupPid,
|
||||
onPromptReady: () => this.onShellPromptReady()
|
||||
})
|
||||
}
|
||||
this.subprocess.onData((data) => this.handleSubprocessData(data))
|
||||
this.subprocess.onExit((code) => this.handleSubprocessExit(code))
|
||||
}
|
||||
@@ -219,6 +245,11 @@ export class Session {
|
||||
return this._state !== 'exited'
|
||||
}
|
||||
|
||||
/** A viewing client is attached; a dropped transport must clear this or pause/resume semantics leak. */
|
||||
get hasAttachedClients(): boolean {
|
||||
return this.attachedClients.length > 0
|
||||
}
|
||||
|
||||
get isTerminating(): boolean {
|
||||
return this._isTerminating
|
||||
}
|
||||
@@ -603,6 +634,8 @@ export class Session {
|
||||
clearTimeout(this.shellReadyTimer)
|
||||
this.shellReadyTimer = null
|
||||
}
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
this.shellReadyScanState = null
|
||||
this.preReadyStdinQueue = []
|
||||
this.postReadyFlushGate.clear()
|
||||
@@ -649,6 +682,14 @@ export class Session {
|
||||
}
|
||||
|
||||
let releaseStartupDeviceAttributes = false
|
||||
if (this._shellState === 'pending' && this.shellStartupIdentityScanState) {
|
||||
const scanned = scanForShellStartupIdentity(this.shellStartupIdentityScanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.shellPid) {
|
||||
this.shellStartupPid = scanned.shellPid
|
||||
this.shellStartupIdentityScanState = null
|
||||
}
|
||||
}
|
||||
if (this._shellState === 'pending' && this.shellReadyScanState) {
|
||||
const scanned = scanForShellReady(this.shellReadyScanState, data)
|
||||
data = scanned.output
|
||||
@@ -661,6 +702,9 @@ export class Session {
|
||||
}
|
||||
|
||||
this.startupIngress.accept(data)
|
||||
if (this._shellState === 'pending' && data.length > 0) {
|
||||
this.shellPromptReadinessProbe?.notifyOutput(data)
|
||||
}
|
||||
if (releaseStartupDeviceAttributes) {
|
||||
this.releaseStartupDeviceAttributes()
|
||||
}
|
||||
@@ -696,6 +740,8 @@ export class Session {
|
||||
}
|
||||
|
||||
this.releaseStartupDeviceAttributes()
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
this.releaseHeldShellReadyBytes()
|
||||
this.startupIngress.drainAndClose()
|
||||
this._exitCode = code
|
||||
@@ -727,10 +773,19 @@ export class Session {
|
||||
}
|
||||
|
||||
private releaseHeldShellReadyBytes(): string {
|
||||
if (!this.shellReadyScanState) {
|
||||
if (!this.shellReadyScanState && !this.shellStartupIdentityScanState) {
|
||||
return ''
|
||||
}
|
||||
const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState)
|
||||
let heldBytes = this.shellStartupIdentityScanState
|
||||
? drainShellStartupIdentityHeldBytes(this.shellStartupIdentityScanState)
|
||||
: ''
|
||||
this.shellStartupIdentityScanState = null
|
||||
if (this.shellReadyScanState && heldBytes) {
|
||||
heldBytes = scanForShellReady(this.shellReadyScanState, heldBytes).output
|
||||
}
|
||||
if (this.shellReadyScanState) {
|
||||
heldBytes += drainShellReadyHeldBytes(this.shellReadyScanState)
|
||||
}
|
||||
this.shellReadyScanState = null
|
||||
// Why: scanning strips marker bytes before fan-out; if readiness never completes, release any held prefix before timeout/exit discards it.
|
||||
this.startupIngress.accept(heldBytes)
|
||||
@@ -759,6 +814,9 @@ export class Session {
|
||||
private transitionToReady(postMarkerBytesObserved = false): void {
|
||||
this._shellState = 'ready'
|
||||
this.shellReadyScanState = null
|
||||
this.shellStartupIdentityScanState = null
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
if (this.shellReadyTimer) {
|
||||
clearTimeout(this.shellReadyTimer)
|
||||
this.shellReadyTimer = null
|
||||
@@ -775,11 +833,25 @@ export class Session {
|
||||
return
|
||||
}
|
||||
this._shellState = 'timed_out'
|
||||
this.shellPromptReadinessProbe?.dispose()
|
||||
this.shellPromptReadinessProbe = null
|
||||
this.releaseStartupDeviceAttributes()
|
||||
this.releaseHeldShellReadyBytes()
|
||||
this.flushPreReadyQueue()
|
||||
}
|
||||
|
||||
private onShellPromptReady(): void {
|
||||
if (this._shellState !== 'pending') {
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[Session] ${this.sessionId}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.`
|
||||
)
|
||||
this.releaseHeldShellReadyBytes()
|
||||
this.transitionToReady(true)
|
||||
this.releaseStartupDeviceAttributes()
|
||||
}
|
||||
|
||||
private flushPreReadyQueue(): void {
|
||||
const queued = this.preReadyStdinQueue
|
||||
this.preReadyStdinQueue = []
|
||||
|
||||
@@ -258,10 +258,10 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
expect(init).toContain('functions -e __orca_shell_ready_marker')
|
||||
})
|
||||
|
||||
it('keeps attribution-only fish spawns unwrapped', async () => {
|
||||
const { getAttributionShellLaunchConfig } = await importFreshShellReady()
|
||||
it('keeps markerless fish spawns unwrapped', async () => {
|
||||
const { getMarkerlessShellLaunchConfig } = await importFreshShellReady()
|
||||
|
||||
const config = getAttributionShellLaunchConfig('/opt/homebrew/bin/fish')
|
||||
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
|
||||
|
||||
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
|
||||
})
|
||||
@@ -462,6 +462,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
|
||||
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
||||
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
|
||||
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
|
||||
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
|
||||
expectZdotdirSourceContext(zprofile, '.zprofile')
|
||||
@@ -650,6 +651,7 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
|
||||
|
||||
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
|
||||
expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
expect(bashRc).toContain('printf "\\033]133;C\\007"')
|
||||
// precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them.
|
||||
expect(bashRc).toContain(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../shell-templates'
|
||||
|
||||
@@ -88,6 +89,7 @@ function shellReadyWrappersExist(): boolean {
|
||||
|
||||
export function getDaemonBashShellReadyRcfileContent(): string {
|
||||
return `# Orca daemon bash shell-ready wrapper
|
||||
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
@@ -101,14 +103,6 @@ fi
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_attribution_path
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
@@ -222,14 +216,6 @@ ${getZshStartupFileSourceBlock({
|
||||
interactiveOnly: true,
|
||||
skipWhenHomeIsCurrentZdotdir: true
|
||||
})}
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
|
||||
}
|
||||
[[ ! -o login ]] && __orca_restore_attribution_path
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
@@ -286,14 +272,6 @@ ${getZshStartupFileSourceBlock({ fileName: '.zprofile' })}
|
||||
const zshRc = getDaemonZshShellReadyRcfileContent()
|
||||
const zshLogin = `# Orca daemon zsh shell-ready wrapper
|
||||
${getZshStartupFileSourceBlock({ fileName: '.zlogin', interactiveOnly: true })}
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_attribution_path
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
@@ -384,7 +362,8 @@ function getWrappedShellLaunchConfig(
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
|
||||
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
|
||||
ZDOTDIR: join(root, 'zsh'),
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
@@ -396,7 +375,8 @@ function getWrappedShellLaunchConfig(
|
||||
return {
|
||||
args: ['--rcfile', join(root, 'bash', 'rcfile')],
|
||||
env: {
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
@@ -415,7 +395,7 @@ function getWrappedShellLaunchConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: mirrors local-pty-shell-ready.ts; attribution-only fish stays unwrapped.
|
||||
// Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped.
|
||||
if (shellName === 'fish' && options.emitReadyMarker) {
|
||||
return {
|
||||
args: ['-l', '-C', getFishShellReadyInitCommand(SHELL_READY_MARKER)],
|
||||
@@ -435,6 +415,6 @@ export function getShellReadyLaunchConfig(shellPath: string): ShellLaunchConfig
|
||||
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: true })
|
||||
}
|
||||
|
||||
export function getAttributionShellLaunchConfig(shellPath: string): ShellLaunchConfig {
|
||||
export function getMarkerlessShellLaunchConfig(shellPath: string): ShellLaunchConfig {
|
||||
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: false })
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
import type { TerminalHostOptions } from './terminal-host-options'
|
||||
import type { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
import type { TerminalSessionTeardown } from './terminal-session-teardown'
|
||||
import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window'
|
||||
import { SessionNotFoundError } from './types'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
|
||||
@@ -107,6 +108,7 @@ export async function createOrAttachTerminalSession(
|
||||
wslDistro
|
||||
}),
|
||||
shellReadySupported,
|
||||
scrollback: resolveDaemonSessionScrollbackRows(),
|
||||
historySeedChunks: opts.historySeedChunks,
|
||||
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
|
||||
wslDistro,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* SessionIds are minted fresh per pane and never reused, so a `TerminalHost`
|
||||
* that never removes exited sessions from its `sessions` map leaks one dead
|
||||
* `Session` — each pinning a `@xterm/headless` emulator with ~5000 rows of
|
||||
* scrollback — per terminal for the lifetime of the long-lived daemon process.
|
||||
* `Session` and its `@xterm/headless` scrollback grid per terminal for the
|
||||
* lifetime of the long-lived daemon process.
|
||||
*
|
||||
* The fix wires a Session `onExit` hook to `TerminalHost.reapSession`, which
|
||||
* disposes the emulator and drops the entry from the map. These tests assert the
|
||||
|
||||
@@ -57,7 +57,7 @@ export class TerminalHost {
|
||||
if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) {
|
||||
throw new Error('agent_session_claim_unavailable')
|
||||
}
|
||||
return await createOrAttachTerminalSession(options, {
|
||||
const result = await createOrAttachTerminalSession(options, {
|
||||
sessions: this.sessions,
|
||||
sessionTeardown: this.sessionTeardown,
|
||||
killedTombstones: this.killedTombstones,
|
||||
@@ -72,6 +72,7 @@ export class TerminalHost {
|
||||
this.reapSession(sessionId)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -114,7 +115,7 @@ export class TerminalHost {
|
||||
return Promise.resolve(killed)
|
||||
}
|
||||
|
||||
// Why: dispose a dead session's emulator so exited terminals don't pin ~5000 rows of scrollback for the daemon's life.
|
||||
// Why: dispose a dead session's emulator so exited terminals don't pin their scrollback window for the daemon's life.
|
||||
private reapSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || session.isAlive) {
|
||||
@@ -130,8 +131,13 @@ export class TerminalHost {
|
||||
}
|
||||
|
||||
detach(sessionId: string, token: symbol): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
session?.detachClient(token)
|
||||
this.detachClients([{ sessionId, token }])
|
||||
}
|
||||
|
||||
detachClients(attachments: readonly { sessionId: string; token: symbol }[]): void {
|
||||
for (const { sessionId, token } of attachments) {
|
||||
this.sessions.get(sessionId)?.detachClient(token)
|
||||
}
|
||||
}
|
||||
|
||||
async getCwd(sessionId: string): Promise<string | null> {
|
||||
|
||||
@@ -216,6 +216,95 @@ describe('EmulatorBridge helper ownership', () => {
|
||||
expect(bridge.getActiveForWorktree('wt-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clean up a failed attach while another start claim can register the device', async () => {
|
||||
const waitForEndpointReady = vi.fn(async () => true)
|
||||
execServeSimCommandMock.mockResolvedValue({
|
||||
device: 'device-1',
|
||||
streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3102'
|
||||
})
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
|
||||
const failedLease = await bridge.acquireHelperForDevice('device-1')
|
||||
const survivingLease = await bridge.acquireHelperForDevice('device-1')
|
||||
|
||||
await failedLease.release({ cleanupIfUnused: true })
|
||||
bridge.registerActiveEmulator('wt-surviving', survivingLease.info, { managed: true })
|
||||
await survivingLease.release()
|
||||
|
||||
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
|
||||
expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled()
|
||||
expect(bridge.getActiveForWorktree('wt-surviving')).toMatchObject({
|
||||
deviceUdid: 'device-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up a failed attach when no start claim or registered workspace remains', async () => {
|
||||
const waitForEndpointReady = vi.fn(async () => true)
|
||||
execServeSimCommandMock.mockResolvedValue({
|
||||
device: 'device-1',
|
||||
streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3102'
|
||||
})
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
|
||||
const lease = await bridge.acquireHelperForDevice('device-1')
|
||||
await lease.release({ cleanupIfUnused: true })
|
||||
|
||||
expect(killServeSimHelperProcessesForDeviceMock).toHaveBeenCalledWith('device-1', {
|
||||
helperPid: undefined,
|
||||
includeOrphaned: true
|
||||
})
|
||||
expect(shutdownSimulatorDeviceMock).toHaveBeenCalledWith('device-1')
|
||||
})
|
||||
|
||||
it('keeps a shared device alive when one registered workspace detaches', async () => {
|
||||
const bridge = new EmulatorBridge()
|
||||
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
|
||||
bridge.registerActiveEmulator('wt-2', session('device-1'), { managed: true })
|
||||
|
||||
await bridge.stopActiveManagedForWorktree('wt-1', { shutdownDevice: true })
|
||||
|
||||
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
|
||||
expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled()
|
||||
expect(bridge.getActiveForWorktree('wt-1')).toBeNull()
|
||||
expect(bridge.getActiveForWorktree('wt-2')).toMatchObject({ deviceUdid: 'device-1' })
|
||||
})
|
||||
|
||||
it('keeps a device alive when its last registered workspace closes during another attach', async () => {
|
||||
let finishStart:
|
||||
| ((info: Awaited<ReturnType<typeof execServeSimCommandMock>>) => void)
|
||||
| undefined
|
||||
execServeSimCommandMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishStart = resolve
|
||||
})
|
||||
)
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady: vi.fn(async () => true) })
|
||||
bridge.registerActiveEmulator('wt-closing', session('device-1'), { managed: true })
|
||||
|
||||
const leasePromise = bridge.acquireHelperForDevice('device-1')
|
||||
await vi.waitFor(() => expect(execServeSimCommandMock).toHaveBeenCalledOnce())
|
||||
const shutdown = bridge.shutdownActiveManagedForWorktree('wt-closing')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
|
||||
finishStart?.({
|
||||
device: 'device-1',
|
||||
streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3102'
|
||||
})
|
||||
const lease = await leasePromise
|
||||
bridge.registerActiveEmulator('wt-attaching', lease.info, { managed: true })
|
||||
await lease.release()
|
||||
await shutdown
|
||||
|
||||
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
|
||||
expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled()
|
||||
expect(bridge.getActiveForWorktree('wt-attaching')).toMatchObject({ deviceUdid: 'device-1' })
|
||||
})
|
||||
|
||||
it('reuses the active helper for the same requested device', async () => {
|
||||
const waitForEndpointReady = vi.fn(async () => true)
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
@@ -267,7 +356,7 @@ describe('EmulatorBridge helper ownership', () => {
|
||||
})
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
|
||||
const info = await bridge.startHelperForDevice('device-1')
|
||||
const { info } = await bridge.acquireHelperForDevice('device-1')
|
||||
|
||||
expect(info.deviceUdid).toBe('device-1')
|
||||
expect(waitForEndpointReady).toHaveBeenCalledTimes(2)
|
||||
@@ -309,7 +398,7 @@ describe('EmulatorBridge helper ownership', () => {
|
||||
})
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
|
||||
await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({
|
||||
await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({
|
||||
code: 'emulator_helper_failed'
|
||||
})
|
||||
|
||||
@@ -334,7 +423,7 @@ describe('EmulatorBridge helper ownership', () => {
|
||||
})
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
|
||||
await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({
|
||||
await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({
|
||||
code: 'emulator_helper_failed'
|
||||
})
|
||||
|
||||
@@ -368,7 +457,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -401,7 +491,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -435,7 +526,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
bridge.registerActiveEmulator('wt-other', session('device-1'), { managed: true })
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -465,7 +557,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
bridge.registerActiveEmulator('wt-1', session('device-active'), { managed: true })
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -499,7 +592,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
)
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -530,7 +624,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
)
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -551,7 +646,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -575,7 +671,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
const bridge = new EmulatorBridge()
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: false,
|
||||
@@ -599,7 +696,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
@@ -641,7 +739,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
|
||||
const bridge = new EmulatorBridge({ waitForEndpointReady })
|
||||
const commands = new RuntimeEmulatorCommands({
|
||||
getEmulatorBridge: () => bridge,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
|
||||
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
|
||||
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
|
||||
getSettings: () => ({
|
||||
mobileEmulatorEnabled: true,
|
||||
|
||||
@@ -5,6 +5,11 @@ import type { SimulatorDevice } from './simctl-simulator-devices'
|
||||
import type { EmulatorBridgeOptions } from './emulator-bridge-types'
|
||||
import type { EmulatorGesturePoint } from './emulator-gesture-sender'
|
||||
import { EmulatorSessionRegistry } from './emulator-session-registry'
|
||||
import {
|
||||
EmulatorStartLeaseRegistry,
|
||||
type EmulatorStartLease
|
||||
} from './emulator-start-lease-registry'
|
||||
import { listAvailableEmulatorDevices } from './emulator-device-inventory'
|
||||
import { deriveAxUrlFromStreamUrl } from './serve-sim-detached-session'
|
||||
import { IosEmulatorBackend } from './backends/ios-emulator-backend'
|
||||
import { AndroidEmulatorBackend } from './backends/android-emulator-backend'
|
||||
@@ -22,6 +27,7 @@ import type {
|
||||
// backend a command targets (by the session's recorded backend, else by device).
|
||||
export class EmulatorBridge {
|
||||
private readonly sessionRegistry = new EmulatorSessionRegistry()
|
||||
private readonly startLeases = new EmulatorStartLeaseRegistry()
|
||||
private readonly backends: EmulatorBackend[]
|
||||
private readonly iosBackend: IosEmulatorBackend
|
||||
private readonly androidBackend: AndroidEmulatorBackend
|
||||
@@ -41,19 +47,7 @@ export class EmulatorBridge {
|
||||
// Aggregated device list across host-supported backends (iOS simulators +
|
||||
// Android devices/AVDs), for the unified `orca emulator list`.
|
||||
async listAllDevices(): Promise<EmulatorDevice[]> {
|
||||
const perBackend = await Promise.all(
|
||||
this.backends.map(async (backend) => {
|
||||
if (!backend.isSupportedOnHost()) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await backend.listDevices()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
)
|
||||
return perBackend.flat()
|
||||
return listAvailableEmulatorDevices(this.backends)
|
||||
}
|
||||
|
||||
// iOS-specific passthroughs kept for back-compat with the runtime + availability code.
|
||||
@@ -85,14 +79,10 @@ export class EmulatorBridge {
|
||||
return this.sessionRegistry.getActiveForWorktree(worktreeId)
|
||||
}
|
||||
|
||||
getActiveBackendKind(worktreeId: string): EmulatorBackendKind | null {
|
||||
return this.backendForActiveWorktree(worktreeId)?.kind ?? null
|
||||
}
|
||||
|
||||
// On a device switch, keep slow-to-boot Android emulators running for instant
|
||||
// switch-back; shut down other backends' devices so they are not leaked.
|
||||
async stopActiveForSwitch(worktreeId: string): Promise<string | null> {
|
||||
const keepAlive = this.getActiveBackendKind(worktreeId) === 'android'
|
||||
const keepAlive = this.backendForActiveWorktree(worktreeId)?.kind === 'android'
|
||||
return this.stopActiveForWorktreeInternal(worktreeId, { shutdownDevice: !keepAlive })
|
||||
}
|
||||
|
||||
@@ -146,18 +136,26 @@ export class EmulatorBridge {
|
||||
if (!session || (options.managedOnly && !session.managed)) {
|
||||
return null
|
||||
}
|
||||
if (this.sessionRegistry.hasActiveWorktreeForSession(key)) {
|
||||
return session.deviceUdid
|
||||
}
|
||||
const backend = this.backendForKind(session.backend)
|
||||
if (!backend) {
|
||||
return null
|
||||
}
|
||||
await backend.stopHelperForDevice(session.deviceUdid, {
|
||||
helperPid: session.pid,
|
||||
includeOrphaned: !options.managedOnly
|
||||
})
|
||||
if (options.shutdownDevice) {
|
||||
await backend.shutdownDevice(session.deviceUdid).catch(() => {})
|
||||
const sessionInfo = this.sessionRegistry.toSessionInfo(session)
|
||||
await this.startLeases.cleanupWhenIdle(
|
||||
backend,
|
||||
sessionInfo,
|
||||
(info) => this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid),
|
||||
{
|
||||
includeOrphaned: !options.managedOnly,
|
||||
shutdownDevice: options.shutdownDevice
|
||||
}
|
||||
)
|
||||
if (!this.sessionRegistry.hasActiveWorktreeForSession(key)) {
|
||||
this.sessionRegistry.clearSessionAndWorktrees(key)
|
||||
}
|
||||
this.sessionRegistry.clearSessionAndWorktrees(key)
|
||||
return session.deviceUdid
|
||||
}
|
||||
|
||||
@@ -243,9 +241,11 @@ export class EmulatorBridge {
|
||||
return run(backend, device)
|
||||
}
|
||||
|
||||
async startHelperForDevice(device: string): Promise<EmulatorSessionInfo> {
|
||||
async acquireHelperForDevice(device: string): Promise<EmulatorStartLease> {
|
||||
const backend = await this.backendForDevice(device)
|
||||
return backend.startSession(device)
|
||||
return this.startLeases.acquire(backend, device, (info) =>
|
||||
this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid)
|
||||
)
|
||||
}
|
||||
|
||||
async kill(device?: string, worktreeId?: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EmulatorBackend, EmulatorDevice } from './backends/emulator-backend'
|
||||
|
||||
export async function listAvailableEmulatorDevices(
|
||||
backends: EmulatorBackend[]
|
||||
): Promise<EmulatorDevice[]> {
|
||||
const perBackend = await Promise.all(
|
||||
backends.map(async (backend) => {
|
||||
if (!backend.isSupportedOnHost()) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await backend.listDevices()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
)
|
||||
return perBackend.flat()
|
||||
}
|
||||
@@ -51,6 +51,14 @@ export class EmulatorSessionRegistry {
|
||||
return this.sessions.get(key)
|
||||
}
|
||||
|
||||
toSessionInfo(session: EmulatorSessionState): EmulatorSessionInfo {
|
||||
return toSessionInfo(session)
|
||||
}
|
||||
|
||||
hasActiveWorktreeForSession(key: string): boolean {
|
||||
return [...this.activeByWorktree.values()].some((activeKey) => activeKey === key)
|
||||
}
|
||||
|
||||
listSessions(): EmulatorSessionState[] {
|
||||
return [...this.sessions.values()]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { EmulatorBackend } from './backends/emulator-backend'
|
||||
import { EmulatorStartLeaseRegistry } from './emulator-start-lease-registry'
|
||||
import type { EmulatorSessionInfo } from './emulator-types'
|
||||
|
||||
function makeBackend(): {
|
||||
backend: EmulatorBackend
|
||||
resolveDeviceId: ReturnType<typeof vi.fn>
|
||||
startSession: ReturnType<typeof vi.fn>
|
||||
stopHelperForDevice: ReturnType<typeof vi.fn>
|
||||
shutdownDevice: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const resolveDeviceId = vi.fn(async () => {
|
||||
throw new Error('shutdown AVD cannot resolve before boot')
|
||||
})
|
||||
const startSession = vi.fn(
|
||||
async (): Promise<EmulatorSessionInfo> => ({
|
||||
deviceUdid: 'emulator-5554',
|
||||
streamUrl: 'scrcpy://emulator-5554',
|
||||
wsUrl: '',
|
||||
streamCodec: 'h264',
|
||||
backend: 'android'
|
||||
})
|
||||
)
|
||||
const stopHelperForDevice = vi.fn(async () => {})
|
||||
const shutdownDevice = vi.fn(async () => {})
|
||||
const backend: EmulatorBackend = {
|
||||
kind: 'android',
|
||||
streamCodec: 'h264',
|
||||
capabilities: {
|
||||
install: false,
|
||||
launch: false,
|
||||
permissions: false,
|
||||
accessibilityTree: false,
|
||||
logcat: false
|
||||
},
|
||||
isSupportedOnHost: () => true,
|
||||
checkAvailability: vi.fn(),
|
||||
listDevices: vi.fn(async () => []),
|
||||
ownsDevice: vi.fn(async () => true),
|
||||
resolveDeviceId,
|
||||
startSession,
|
||||
stopHelperForDevice,
|
||||
shutdownDevice,
|
||||
isSessionReusable: vi.fn(async () => true),
|
||||
tap: vi.fn(async () => {}),
|
||||
gesture: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
button: vi.fn(async () => {}),
|
||||
rotate: vi.fn(async () => {}),
|
||||
exec: vi.fn(async () => undefined)
|
||||
}
|
||||
return { backend, resolveDeviceId, startSession, stopHelperForDevice, shutdownDevice }
|
||||
}
|
||||
|
||||
describe('EmulatorStartLeaseRegistry', () => {
|
||||
it('lets the backend boot a shutdown device before a canonical id exists', async () => {
|
||||
const { backend, resolveDeviceId, startSession } = makeBackend()
|
||||
const registry = new EmulatorStartLeaseRegistry()
|
||||
|
||||
const lease = await registry.acquire(backend, 'Pixel_Tablet', () => false)
|
||||
await lease.release()
|
||||
|
||||
expect(resolveDeviceId).not.toHaveBeenCalled()
|
||||
expect(startSession).toHaveBeenCalledWith('Pixel_Tablet')
|
||||
expect(lease.info.deviceUdid).toBe('emulator-5554')
|
||||
})
|
||||
|
||||
it('waits for pending cleanup before starting a new lease', async () => {
|
||||
const { backend, startSession, stopHelperForDevice, shutdownDevice } = makeBackend()
|
||||
let finishCleanup: (() => void) | undefined
|
||||
stopHelperForDevice.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve
|
||||
})
|
||||
)
|
||||
const registry = new EmulatorStartLeaseRegistry()
|
||||
const first = await registry.acquire(backend, 'Pixel_Tablet', () => false)
|
||||
|
||||
const cleanup = first.release({ cleanupIfUnused: true })
|
||||
await vi.waitFor(() => expect(stopHelperForDevice).toHaveBeenCalledOnce())
|
||||
const second = registry.acquire(backend, 'Pixel_Tablet', () => false)
|
||||
await Promise.resolve()
|
||||
expect(startSession).toHaveBeenCalledOnce()
|
||||
|
||||
finishCleanup?.()
|
||||
await cleanup
|
||||
const secondLease = await second
|
||||
|
||||
expect(shutdownDevice).toHaveBeenCalledOnce()
|
||||
expect(startSession).toHaveBeenCalledTimes(2)
|
||||
await secondLease.release()
|
||||
})
|
||||
|
||||
it('claims synchronously before an earlier lease can start cleanup', async () => {
|
||||
const { backend, stopHelperForDevice, shutdownDevice } = makeBackend()
|
||||
const registry = new EmulatorStartLeaseRegistry()
|
||||
const first = await registry.acquire(backend, 'Pixel_Tablet', () => false)
|
||||
|
||||
const second = registry.acquire(backend, 'Pixel_Tablet', () => false)
|
||||
await first.release({ cleanupIfUnused: true })
|
||||
|
||||
expect(stopHelperForDevice).not.toHaveBeenCalled()
|
||||
const secondLease = await second
|
||||
await secondLease.release()
|
||||
|
||||
expect(stopHelperForDevice).toHaveBeenCalledOnce()
|
||||
expect(shutdownDevice).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { EmulatorBackend } from './backends/emulator-backend'
|
||||
import type { EmulatorSessionInfo } from './emulator-types'
|
||||
|
||||
export type EmulatorStartLease = {
|
||||
info: EmulatorSessionInfo
|
||||
release(options?: { cleanupIfUnused?: boolean }): Promise<void>
|
||||
}
|
||||
|
||||
type PendingCleanup = {
|
||||
info: EmulatorSessionInfo
|
||||
isRegistered: (info: EmulatorSessionInfo) => boolean
|
||||
includeOrphaned: boolean
|
||||
shutdownDevice: boolean
|
||||
}
|
||||
|
||||
export class EmulatorStartLeaseRegistry {
|
||||
private readonly claimsByBackend = new Map<EmulatorBackend, number>()
|
||||
private readonly cleanupByBackend = new Map<EmulatorBackend, Promise<void>>()
|
||||
private readonly pendingCleanupByBackend = new Map<EmulatorBackend, Map<string, PendingCleanup>>()
|
||||
|
||||
async acquire(
|
||||
backend: EmulatorBackend,
|
||||
device: string,
|
||||
isRegistered: (info: EmulatorSessionInfo) => boolean
|
||||
): Promise<EmulatorStartLease> {
|
||||
this.claimsByBackend.set(backend, (this.claimsByBackend.get(backend) ?? 0) + 1)
|
||||
try {
|
||||
await this.cleanupByBackend.get(backend)
|
||||
const info = await backend.startSession(device)
|
||||
let released = false
|
||||
return {
|
||||
info,
|
||||
release: async (options = {}) => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if (options.cleanupIfUnused) {
|
||||
this.addPendingCleanup(backend, info, isRegistered, {
|
||||
includeOrphaned: true,
|
||||
shutdownDevice: true
|
||||
})
|
||||
}
|
||||
await this.release(backend)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await this.release(backend)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupWhenIdle(
|
||||
backend: EmulatorBackend,
|
||||
info: EmulatorSessionInfo,
|
||||
isRegistered: (info: EmulatorSessionInfo) => boolean,
|
||||
options: { includeOrphaned?: boolean; shutdownDevice?: boolean } = {}
|
||||
): Promise<void> {
|
||||
this.addPendingCleanup(backend, info, isRegistered, options)
|
||||
await this.drainCleanup(backend)
|
||||
}
|
||||
|
||||
private async release(backend: EmulatorBackend): Promise<void> {
|
||||
const remaining = this.decrement(backend)
|
||||
if (remaining > 0) {
|
||||
return
|
||||
}
|
||||
await this.drainCleanup(backend)
|
||||
}
|
||||
|
||||
private async drainCleanup(backend: EmulatorBackend): Promise<void> {
|
||||
await this.cleanupByBackend.get(backend)
|
||||
if ((this.claimsByBackend.get(backend) ?? 0) > 0) {
|
||||
return
|
||||
}
|
||||
const pending = this.pendingCleanupByBackend.get(backend)
|
||||
this.pendingCleanupByBackend.delete(backend)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
const cleanup = Promise.allSettled(
|
||||
[...pending.values()].map(async ({ info, isRegistered, includeOrphaned, shutdownDevice }) => {
|
||||
if (isRegistered(info)) {
|
||||
return
|
||||
}
|
||||
await backend.stopHelperForDevice(info.deviceUdid, {
|
||||
helperPid: info.helperPid,
|
||||
includeOrphaned
|
||||
})
|
||||
if (shutdownDevice) {
|
||||
await backend.shutdownDevice(info.deviceUdid)
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(() => undefined)
|
||||
.finally(() => this.cleanupByBackend.delete(backend))
|
||||
this.cleanupByBackend.set(backend, cleanup)
|
||||
await cleanup
|
||||
}
|
||||
|
||||
private addPendingCleanup(
|
||||
backend: EmulatorBackend,
|
||||
info: EmulatorSessionInfo,
|
||||
isRegistered: (info: EmulatorSessionInfo) => boolean,
|
||||
options: { includeOrphaned?: boolean; shutdownDevice?: boolean }
|
||||
): void {
|
||||
const pending = this.pendingCleanupByBackend.get(backend) ?? new Map()
|
||||
const existing = pending.get(info.deviceUdid)
|
||||
pending.set(info.deviceUdid, {
|
||||
info,
|
||||
isRegistered,
|
||||
includeOrphaned: options.includeOrphaned === true || existing?.includeOrphaned === true,
|
||||
shutdownDevice: options.shutdownDevice === true || existing?.shutdownDevice === true
|
||||
})
|
||||
this.pendingCleanupByBackend.set(backend, pending)
|
||||
}
|
||||
|
||||
private decrement(backend: EmulatorBackend): number {
|
||||
const next = Math.max(0, (this.claimsByBackend.get(backend) ?? 1) - 1)
|
||||
if (next === 0) {
|
||||
this.claimsByBackend.delete(backend)
|
||||
} else {
|
||||
this.claimsByBackend.set(backend, next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ import {
|
||||
configureElectronNetworkCompatibility,
|
||||
configureDevUserDataPath,
|
||||
configureOrcaUserDataPathEnv,
|
||||
disableUnsupportedChromiumFeatures,
|
||||
enableMainProcessGpuFeatures,
|
||||
installDevParentDisconnectQuit,
|
||||
installDevParentSignalQuit,
|
||||
@@ -184,6 +185,7 @@ import {
|
||||
logStartupMilestone
|
||||
} from './startup/startup-diagnostics'
|
||||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { removeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
import {
|
||||
createServeDesktopActivationGate,
|
||||
@@ -846,6 +848,7 @@ if (hasSingleInstanceLock) {
|
||||
platform: process.platform,
|
||||
...getMainProcessLifecycleIdentity()
|
||||
})
|
||||
disableUnsupportedChromiumFeatures()
|
||||
configureElectronNetworkCompatibility()
|
||||
enableRendererHeapHeadroom()
|
||||
maybeApplyGpuFallbackForThisLaunch()
|
||||
@@ -2168,6 +2171,8 @@ void app.whenReady().then(async () => {
|
||||
|
||||
const activeOrcaProfile = ensureActiveOrcaProfile()
|
||||
store = new Store({ dataFile: activeOrcaProfile.dataFile })
|
||||
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
|
||||
removeLegacyTerminalShimDir(app.getPath('userData'))
|
||||
const windowsShellPathHydration = createWindowsShellPathHydration()
|
||||
configureWindowsHostGitEnvironmentReadiness(
|
||||
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
|
||||
|
||||
+75
-128
@@ -26,14 +26,6 @@ const expectedOmpStatusExtension = posix.join(
|
||||
'omp-managed-status-extension',
|
||||
'orca-agent-status.ts'
|
||||
)
|
||||
function expectedAttributionShimDir(): string {
|
||||
return join(
|
||||
'/tmp/orca-user-data',
|
||||
'orca-terminal-attribution',
|
||||
process.platform === 'win32' ? 'win32' : 'posix'
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
onMock,
|
||||
@@ -1874,7 +1866,6 @@ describe('registerPtyHandlers', () => {
|
||||
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
|
||||
) => string | null,
|
||||
getSettings?: () => {
|
||||
enableGitHubAttribution?: boolean
|
||||
agentStatusHooksEnabled?: boolean
|
||||
httpProxyUrl?: string
|
||||
httpProxyBypassRules?: string
|
||||
@@ -1972,7 +1963,6 @@ describe('registerPtyHandlers', () => {
|
||||
isPackaged: true,
|
||||
userDataPath: '/tmp/orca-user-data',
|
||||
selectedCodexHomePath: null,
|
||||
githubAttributionEnabled: false,
|
||||
agentStatusHooksEnabled: false,
|
||||
isWsl: context?.isWsl,
|
||||
wslDistro: context?.wslDistro
|
||||
@@ -3136,59 +3126,6 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
|
||||
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
|
||||
enableGitHubAttribution: true
|
||||
}))
|
||||
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca <help@stably.ai>')
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('skips git/gh attribution shims when attribution is disabled', async () => {
|
||||
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
|
||||
enableGitHubAttribution: false
|
||||
}))
|
||||
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
|
||||
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => {
|
||||
const daemonSpawn = vi.fn(async (options) => ({ id: 'daemon-pty', pid: 123, ...options }))
|
||||
setLocalPtyProvider({
|
||||
spawn: daemonSpawn,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: vi.fn(() => vi.fn()),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
getForegroundProcess: vi.fn(async () => null)
|
||||
} as never)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({
|
||||
enableGitHubAttribution: true
|
||||
})) as never)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {}
|
||||
})
|
||||
|
||||
const env = daemonSpawn.mock.calls.at(-1)![0].env
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => {
|
||||
const env = await spawnAndGetEnv(
|
||||
undefined,
|
||||
@@ -3490,7 +3427,6 @@ describe('registerPtyHandlers', () => {
|
||||
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
|
||||
) => string | null,
|
||||
getSettings?: () => {
|
||||
enableGitHubAttribution?: boolean
|
||||
httpProxyUrl?: string
|
||||
httpProxyBypassRules?: string
|
||||
},
|
||||
@@ -3552,7 +3488,6 @@ describe('registerPtyHandlers', () => {
|
||||
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
|
||||
) => string | null,
|
||||
getSettings?: () => {
|
||||
enableGitHubAttribution?: boolean
|
||||
httpProxyUrl?: string
|
||||
httpProxyBypassRules?: string
|
||||
},
|
||||
@@ -4877,33 +4812,38 @@ describe('registerPtyHandlers', () => {
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never, undefined, (() => ({
|
||||
enableGitHubAttribution: true
|
||||
})) as never)
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
|
||||
|
||||
await controller.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'wt-runtime',
|
||||
command: 'claude',
|
||||
env: {
|
||||
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
|
||||
TERM_PROGRAM: 'Orca',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
|
||||
},
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
})
|
||||
// Why: dev mode makes buildPtyHostEnv prepend its own CLI shim on every platform, so
|
||||
// the promotion below has something to beat instead of passing trivially.
|
||||
const { app } = await import('electron')
|
||||
const mockedApp = app as unknown as { isPackaged: boolean }
|
||||
const prevPackaged = mockedApp.isPackaged
|
||||
mockedApp.isPackaged = false
|
||||
try {
|
||||
await controller.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'wt-runtime',
|
||||
command: 'claude',
|
||||
env: {
|
||||
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
|
||||
TERM_PROGRAM: 'Orca'
|
||||
},
|
||||
envToDelete: ['TERM_PROGRAM']
|
||||
})
|
||||
} finally {
|
||||
mockedApp.isPackaged = prevPackaged
|
||||
}
|
||||
|
||||
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
|
||||
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
|
||||
const spawnedPath = spawnOptions.env.PATH.split(delimiter)
|
||||
expect(spawnedPath[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(spawnedPath.some((entry) => entry.includes(join('cli', 'bin')))).toBe(true)
|
||||
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
|
||||
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(spawnOptions.envToDelete).toEqual(
|
||||
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
|
||||
)
|
||||
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['TERM_PROGRAM']))
|
||||
})
|
||||
|
||||
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
|
||||
@@ -4923,38 +4863,38 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('prepends attribution shims on the daemon path', async () => {
|
||||
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
|
||||
enableGitHubAttribution: true
|
||||
}))
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
|
||||
expect(env.PATH).toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
|
||||
const spawnOptions = await daemonSpawnAndGetOptions(
|
||||
{
|
||||
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
|
||||
TERM_PROGRAM: 'Orca',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
|
||||
},
|
||||
undefined,
|
||||
() => ({ enableGitHubAttribution: true }),
|
||||
undefined,
|
||||
{
|
||||
command: 'claude',
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
}
|
||||
)
|
||||
// Why: dev mode makes buildPtyHostEnv prepend its own CLI shim on every platform, so
|
||||
// the promotion below has something to beat instead of passing trivially.
|
||||
const { app } = await import('electron')
|
||||
const mockedApp = app as unknown as { isPackaged: boolean }
|
||||
const prevPackaged = mockedApp.isPackaged
|
||||
mockedApp.isPackaged = false
|
||||
let spawnOptions: Awaited<ReturnType<typeof daemonSpawnAndGetOptions>>
|
||||
try {
|
||||
spawnOptions = await daemonSpawnAndGetOptions(
|
||||
{
|
||||
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
|
||||
TERM_PROGRAM: 'Orca'
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
command: 'claude',
|
||||
envToDelete: ['TERM_PROGRAM']
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
mockedApp.isPackaged = prevPackaged
|
||||
}
|
||||
|
||||
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
|
||||
const spawnedPath = spawnOptions.env.PATH.split(delimiter)
|
||||
expect(spawnedPath[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(spawnedPath.some((entry) => entry.includes(join('cli', 'bin')))).toBe(true)
|
||||
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
|
||||
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(spawnOptions.envToDelete).toEqual(
|
||||
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
|
||||
)
|
||||
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['TERM_PROGRAM']))
|
||||
})
|
||||
|
||||
it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => {
|
||||
@@ -4990,6 +4930,24 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a legacy shim PATH entry inherited from the host process on the daemon path', async () => {
|
||||
// Why: the daemon path passes a sparse env, so the prepends re-read PATH from
|
||||
// process.env — the scrub must outlive that fallback (pre-upgrade host or parent pane).
|
||||
const { app } = await import('electron')
|
||||
const mockedApp = app as unknown as { isPackaged: boolean }
|
||||
const prev = mockedApp.isPackaged
|
||||
mockedApp.isPackaged = false
|
||||
try {
|
||||
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
|
||||
PATH: `/tmp/orca-user-data/orca-terminal-attribution/posix${delimiter}/system/bin`
|
||||
})
|
||||
expect(env.PATH).not.toContain('orca-terminal-attribution')
|
||||
expect(env.PATH).toContain('/system/bin')
|
||||
} finally {
|
||||
mockedApp.isPackaged = prev
|
||||
}
|
||||
})
|
||||
|
||||
it('defers indexed Git prompt guards from the daemon wire environment', async () => {
|
||||
const env = await daemonSpawnAndGetEnv(
|
||||
{
|
||||
@@ -5169,14 +5127,6 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/ambient/pi/agent')
|
||||
})
|
||||
|
||||
it('skips attribution shims on the daemon path when the setting is disabled', async () => {
|
||||
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }, undefined, () => ({
|
||||
enableGitHubAttribution: false
|
||||
}))
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
|
||||
})
|
||||
|
||||
it('does not mutate the caller-provided args.env on the daemon path', async () => {
|
||||
// Why: the handler clones baseEnv so IPC-provided env stays pristine; a regression would leak Orca host env back into the renderer's reused copy.
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
@@ -5357,7 +5307,6 @@ describe('registerPtyHandlers', () => {
|
||||
// Why: host-local vars must be absent over SSH (they point at the local host/disk) — shipping them is useless or a credential leak.
|
||||
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
|
||||
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
|
||||
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
|
||||
@@ -8330,8 +8279,7 @@ describe('registerPtyHandlers', () => {
|
||||
TMUX: '/tmp/orca-claude-agent-teams/team-stale,0,1',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale',
|
||||
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
|
||||
TERM_PROGRAM: 'Orca',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
|
||||
TERM_PROGRAM: 'Orca'
|
||||
},
|
||||
launchConfig: {
|
||||
agentCommand: 'claude --teammate-mode auto',
|
||||
@@ -8364,7 +8312,6 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/fresh-agent-teams')
|
||||
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
|
||||
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(result.launchConfig?.agentEnv).toMatchObject({
|
||||
CLAUDE_PROFILE: 'captured',
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
|
||||
+11
-32
@@ -111,11 +111,11 @@ import {
|
||||
markClaudePtyExited,
|
||||
markClaudePtySpawned
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import {
|
||||
applyTerminalAttributionEnv,
|
||||
resolveAttributionShellFamily
|
||||
} from '../attribution/terminal-attribution'
|
||||
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
|
||||
import {
|
||||
isLegacyTerminalShimPathEntry,
|
||||
stripLegacyTerminalShimEnv
|
||||
} from '../pty/legacy-terminal-shim-dir'
|
||||
import { registerPty, unregisterPty } from '../memory/pty-registry'
|
||||
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { track } from '../telemetry/client'
|
||||
@@ -1218,14 +1218,12 @@ export type BuildPtyHostEnvOptions = {
|
||||
* and strip only an inherited Orca-owned override so nested Orca panes do not
|
||||
* leak the parent's managed home. A user-set CODEX_HOME is preserved. */
|
||||
stripInheritedOrcaCodexHome?: boolean
|
||||
githubAttributionEnabled: boolean
|
||||
/** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent
|
||||
* extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from
|
||||
* disk presence (cross-agent shadowing when both dirs exist). */
|
||||
launchCommand?: string
|
||||
/** Trusted agent identity for wrapped commands that cannot be recognized from text. */
|
||||
launchAgent?: TuiAgent
|
||||
shellPath?: string
|
||||
isWsl?: boolean
|
||||
/** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */
|
||||
wslDistro?: string | null
|
||||
@@ -1253,7 +1251,9 @@ function promoteAgentTeamsShimPath(
|
||||
return
|
||||
}
|
||||
const shimPath = firstPathEntry(requestedPath)
|
||||
if (!shimPath) {
|
||||
// Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that
|
||||
// reached the front would be re-prepended here and outlive the scrub.
|
||||
if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) {
|
||||
return
|
||||
}
|
||||
const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path'
|
||||
@@ -1987,22 +1987,9 @@ export function buildPtyHostEnv(
|
||||
: bundledCliBin
|
||||
}
|
||||
|
||||
// Why: PATH shims keep GitHub attribution scoped to Orca's own PTYs without rewriting user git config.
|
||||
if (!opts.githubAttributionEnabled) {
|
||||
delete baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION
|
||||
delete baseEnv.ORCA_GIT_COMMIT_TRAILER
|
||||
delete baseEnv.ORCA_GH_PR_FOOTER
|
||||
delete baseEnv.ORCA_GH_ISSUE_FOOTER
|
||||
delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR
|
||||
}
|
||||
applyTerminalAttributionEnv(baseEnv, {
|
||||
enabled: opts.githubAttributionEnabled,
|
||||
userDataPath: opts.userDataPath,
|
||||
shellFamily: resolveAttributionShellFamily({
|
||||
shellPath: opts.shellPath,
|
||||
isWsl: opts.isWsl
|
||||
})
|
||||
})
|
||||
// Why: must run after the prepends above — they re-read PATH from the unscrubbed
|
||||
// process.env when baseEnv carries none, which is the daemon path's normal shape.
|
||||
stripLegacyTerminalShimEnv(baseEnv, process.platform)
|
||||
|
||||
return baseEnv
|
||||
}
|
||||
@@ -2549,10 +2536,8 @@ export function registerPtyHandlers(
|
||||
skipCodexHomeEnv,
|
||||
settings: getSettings?.()
|
||||
}),
|
||||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
||||
launchCommand: ctx?.command,
|
||||
launchAgent: ctx?.launchAgent,
|
||||
shellPath: ctx?.shellPath,
|
||||
isWsl: ctx?.isWsl,
|
||||
wslDistro: ctx?.wslDistro ?? null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
@@ -4778,10 +4763,8 @@ export function registerPtyHandlers(
|
||||
selectedCodexHomePath,
|
||||
skipCodexHomeEnv,
|
||||
stripInheritedOrcaCodexHome,
|
||||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
||||
launchCommand,
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: daemonShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
@@ -6293,9 +6276,7 @@ export function registerPtyHandlers(
|
||||
const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID
|
||||
? baseEnv[resolvePathEnvKey(baseEnv, process.platform)]
|
||||
: undefined
|
||||
const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv
|
||||
? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
: undefined
|
||||
const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined
|
||||
if (baseEnv && stablePaneKey) {
|
||||
baseEnv.ORCA_PANE_KEY = stablePaneKey
|
||||
if (typeof args.tabId === 'string') {
|
||||
@@ -6434,10 +6415,8 @@ export function registerPtyHandlers(
|
||||
selectedCodexHomePath,
|
||||
skipCodexHomeEnv,
|
||||
stripInheritedOrcaCodexHome,
|
||||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
||||
launchCommand,
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
|
||||
@@ -2167,6 +2167,28 @@ describe('repos:add + repos:clone', () => {
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists agent worktree visibility through local repos:update', () => {
|
||||
const updated = {
|
||||
id: 'repo-agent-visibility',
|
||||
path: '/tmp/repo-agent-visibility',
|
||||
displayName: 'repo-agent-visibility',
|
||||
kind: 'git',
|
||||
badgeColor: '#22c55e',
|
||||
agentWorktreeVisibility: 'show'
|
||||
}
|
||||
mockStore.updateRepo.mockReturnValue(updated)
|
||||
|
||||
const result = handlers.get('repos:update')!(null, {
|
||||
repoId: updated.id,
|
||||
updates: { agentWorktreeVisibility: 'show' }
|
||||
})
|
||||
|
||||
expect(result).toBe(updated)
|
||||
expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, {
|
||||
agentWorktreeVisibility: 'show'
|
||||
})
|
||||
})
|
||||
|
||||
it('prepares and invalidates roots when project host setup update changes worktree base path', () => {
|
||||
const repo = {
|
||||
id: 'repo-setup-update-root',
|
||||
|
||||
+12
-1
@@ -41,6 +41,7 @@ import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { TaskSourceContextSchema } from '../../shared/task-source-context-schema'
|
||||
import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context'
|
||||
import { DiffCommentSchema } from '../../shared/diff-comment-schema'
|
||||
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { access, mkdir, readdir, rm } from 'node:fs/promises'
|
||||
@@ -951,7 +952,8 @@ const FolderWorkspaceUpdateArgs = z.object({
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional(),
|
||||
firstAgentMessageRenameError: z.string().nullable().optional(),
|
||||
lastActivityAt: z.number().finite().optional()
|
||||
lastActivityAt: z.number().finite().optional(),
|
||||
diffComments: z.array(DiffCommentSchema).optional()
|
||||
})
|
||||
.superRefine(assertFolderWorkspaceLinkedSourceContextMatch)
|
||||
})
|
||||
@@ -2124,6 +2126,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
| 'agentWorktreeVisibility'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
>
|
||||
@@ -2194,6 +2197,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
) {
|
||||
delete updates.externalWorktreeVisibility
|
||||
}
|
||||
if (
|
||||
'agentWorktreeVisibility' in updates &&
|
||||
updates.agentWorktreeVisibility !== undefined &&
|
||||
updates.agentWorktreeVisibility !== 'hide' &&
|
||||
updates.agentWorktreeVisibility !== 'show'
|
||||
) {
|
||||
delete updates.agentWorktreeVisibility
|
||||
}
|
||||
if (
|
||||
'externalWorktreeVisibilityPromptDismissedAt' in updates &&
|
||||
updates.externalWorktreeVisibilityPromptDismissedAt !== undefined &&
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createLineEditorReadyOutputScanState,
|
||||
scanForLineEditorReadyOutput
|
||||
} from './line-editor-ready-output-scanner'
|
||||
|
||||
describe('line editor ready output scanner', () => {
|
||||
it.each(['\x1b[?2004h', '\x1b[?1034h'])('detects %j across chunks', (sequence) => {
|
||||
const state = createLineEditorReadyOutputScanState()
|
||||
expect(scanForLineEditorReadyOutput(state, `prompt${sequence.slice(0, 4)}`)).toBe(false)
|
||||
expect(scanForLineEditorReadyOutput(state, sequence.slice(4))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores ordinary prompt output', () => {
|
||||
expect(
|
||||
scanForLineEditorReadyOutput(createLineEditorReadyOutputScanState(), 'user@host % ')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
const LINE_EDITOR_READY_SEQUENCES = ['\x1b[?2004h', '\x1b[?1034h'] as const
|
||||
const MAX_SEQUENCE_LENGTH = Math.max(...LINE_EDITOR_READY_SEQUENCES.map((value) => value.length))
|
||||
|
||||
export type LineEditorReadyOutputScanState = {
|
||||
tail: string
|
||||
}
|
||||
|
||||
export function createLineEditorReadyOutputScanState(): LineEditorReadyOutputScanState {
|
||||
return { tail: '' }
|
||||
}
|
||||
|
||||
export function scanForLineEditorReadyOutput(
|
||||
state: LineEditorReadyOutputScanState,
|
||||
data: string
|
||||
): boolean {
|
||||
const combined = state.tail + data
|
||||
state.tail = combined.slice(-(MAX_SEQUENCE_LENGTH - 1))
|
||||
return LINE_EDITOR_READY_SEQUENCES.some((sequence) => combined.includes(sequence))
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resetHostReadableTranscriptPathCacheForTests,
|
||||
toHostReadableTranscriptPath
|
||||
} from './host-readable-transcript-path'
|
||||
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
|
||||
|
||||
type AccessControl = {
|
||||
resolve: () => void
|
||||
@@ -97,4 +98,27 @@ describe('WSL transcript filesystem gate', () => {
|
||||
releaseWsl?.()
|
||||
await expect(wslProbe).resolves.toBe(firstUncPath)
|
||||
})
|
||||
|
||||
// Last on purpose: the never-settling Ubuntu probe leaves its gate permit
|
||||
// stuck for the rest of this module's lifetime.
|
||||
it('falls through to the next distro when a probe exceeds the gate deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\ada'
|
||||
const debianUncPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\first.jsonl'
|
||||
try {
|
||||
fsMocks.access.mockImplementation((path) =>
|
||||
path === firstUncPath ? new Promise<void>(() => {}) : Promise.resolve()
|
||||
)
|
||||
const resolved = toHostReadableTranscriptPath(firstGuestPath, {
|
||||
platform: 'win32',
|
||||
listWslHomeDirs: async () => [UBUNTU_HOME, DEBIAN_HOME]
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledWith(firstUncPath))
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
|
||||
await expect(resolved).resolves.toBe(debianUncPath)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
toHostReadableTranscriptPath,
|
||||
wslCodexSessionsDirs
|
||||
} from './host-readable-transcript-path'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
|
||||
const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
|
||||
const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\other'
|
||||
@@ -109,6 +110,38 @@ describe('toHostReadableTranscriptPath', () => {
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('prefers a later distro hit over an earlier gate refusal', async () => {
|
||||
// Guest path under Debian's $HOME so the refusing Debian probe ranks first.
|
||||
await expect(
|
||||
toHostReadableTranscriptPath('/home/other/x.jsonl', {
|
||||
platform: 'win32',
|
||||
pathExists: async (candidate) => {
|
||||
if (candidate.includes('Debian')) {
|
||||
throw new WslTranscriptFsError('timeout', 'slow share')
|
||||
}
|
||||
return candidate.includes('Ubuntu')
|
||||
},
|
||||
listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME]
|
||||
})
|
||||
).resolves.toBe('\\\\wsl.localhost\\Ubuntu\\home\\other\\x.jsonl')
|
||||
})
|
||||
|
||||
it('reports unavailability, not a miss, when a refused distro was never probed', async () => {
|
||||
const refusal = new WslTranscriptFsError('timeout', 'slow share')
|
||||
await expect(
|
||||
toHostReadableTranscriptPath('/home/other/x.jsonl', {
|
||||
platform: 'win32',
|
||||
pathExists: async (candidate) => {
|
||||
if (candidate.includes('Debian')) {
|
||||
throw refusal
|
||||
}
|
||||
return false
|
||||
},
|
||||
listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME]
|
||||
})
|
||||
).rejects.toBe(refusal)
|
||||
})
|
||||
|
||||
it('does not translate guest paths off Windows', async () => {
|
||||
await expect(
|
||||
toHostReadableTranscriptPath('/home/ada/rollout.jsonl', {
|
||||
|
||||
@@ -4,7 +4,11 @@ import { isAbsolute } from 'node:path'
|
||||
import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
|
||||
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate'
|
||||
import {
|
||||
runWslTranscriptFsTask,
|
||||
wslTranscriptFsRefusal,
|
||||
type WslTranscriptFsError
|
||||
} from './wsl-transcript-fs-gate'
|
||||
|
||||
/**
|
||||
* True for guest-absolute Linux paths that Win32 cannot open as-is.
|
||||
@@ -136,12 +140,22 @@ export async function toHostReadableTranscriptPath(
|
||||
const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs)
|
||||
// Sequential on purpose: the ranked order picks the owning distro, and probing
|
||||
// every distro at once would fan out 9P calls to ones the user left stopped.
|
||||
let unavailable: WslTranscriptFsError | undefined
|
||||
for (const distro of rankDistrosForGuestPath(homeDirs, path)) {
|
||||
const uncPath = toWindowsWslPath(path, distro)
|
||||
if (await pathExists(uncPath)) {
|
||||
return uncPath
|
||||
try {
|
||||
if (await pathExists(uncPath)) {
|
||||
return uncPath
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: one stalled distro must not hide another distro's hit.
|
||||
unavailable = wslTranscriptFsRefusal(error)
|
||||
}
|
||||
}
|
||||
// No hit and at least one distro never probed: "couldn't look", not "absent".
|
||||
if (unavailable) {
|
||||
throw unavailable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslTranscriptFsGateModule from './wsl-transcript-fs-gate'
|
||||
|
||||
const WSL_SESSIONS_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions'
|
||||
const DEBIAN_SESSIONS_DIR = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions'
|
||||
const LOCAL_SESSIONS_DIR = 'C:\\Users\\ada\\.codex\\sessions'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
gate: vi.fn(async () => []),
|
||||
gate: vi.fn(async (_options: { path: string }) => []),
|
||||
walk: vi.fn(
|
||||
async (
|
||||
dir: string,
|
||||
@@ -18,7 +20,8 @@ const mocks = vi.hoisted(() => ({
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./wsl-transcript-fs-gate', () => ({
|
||||
vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslTranscriptFsGateModule>()),
|
||||
runWslTranscriptFsTask: mocks.gate
|
||||
}))
|
||||
vi.mock('../ai-vault/session-scanner-discovery', () => ({
|
||||
@@ -26,6 +29,7 @@ vi.mock('../ai-vault/session-scanner-discovery', () => ({
|
||||
}))
|
||||
|
||||
import { resolveSessionFilePath } from './session-file-resolver'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.gate.mockClear()
|
||||
@@ -53,4 +57,77 @@ describe('Codex WSL scan gate', () => {
|
||||
expect(mocks.gate).not.toHaveBeenCalled()
|
||||
expect(mocks.walk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("still surfaces a later root's hit when an earlier root is gate-refused", async () => {
|
||||
const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`
|
||||
mocks.gate.mockImplementation(async (options) => {
|
||||
if (options.path.includes('Ubuntu')) {
|
||||
throw new WslTranscriptFsError('timeout', 'slow share')
|
||||
}
|
||||
return []
|
||||
})
|
||||
mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => {
|
||||
await options.readDirectory?.(dir)
|
||||
return dir === DEBIAN_SESSIONS_DIR ? [hit] : []
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveSessionFilePath('codex', 'session-id', {
|
||||
codexSessionsDirs: [WSL_SESSIONS_DIR, DEBIAN_SESSIONS_DIR]
|
||||
})
|
||||
).resolves.toBe(hit)
|
||||
})
|
||||
|
||||
it('reports unavailability, not a miss, when every scanned root is gate-refused', async () => {
|
||||
const refusal = new WslTranscriptFsError('unavailable', 'stuck permits')
|
||||
mocks.gate.mockRejectedValue(refusal)
|
||||
|
||||
await expect(
|
||||
resolveSessionFilePath('codex', 'session-id', {
|
||||
codexSessionsDirs: [WSL_SESSIONS_DIR]
|
||||
})
|
||||
).rejects.toBe(refusal)
|
||||
})
|
||||
|
||||
it('falls through a gate-refused hook path to an id-based hit', async () => {
|
||||
const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`
|
||||
mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => {
|
||||
if (options.operation === 'access') {
|
||||
throw new WslTranscriptFsError('timeout', 'slow share')
|
||||
}
|
||||
return []
|
||||
})
|
||||
mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => {
|
||||
await options.readDirectory?.(dir)
|
||||
return dir === DEBIAN_SESSIONS_DIR ? [hit] : []
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveSessionFilePath('codex', 'session-id', {
|
||||
transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`,
|
||||
codexSessionsDirs: [DEBIAN_SESSIONS_DIR]
|
||||
})
|
||||
).resolves.toBe(hit)
|
||||
})
|
||||
|
||||
it('surfaces the hook-path refusal when the id search also misses', async () => {
|
||||
const refusal = new WslTranscriptFsError('unavailable', 'stuck permits')
|
||||
mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => {
|
||||
if (options.operation === 'access') {
|
||||
throw refusal
|
||||
}
|
||||
return []
|
||||
})
|
||||
mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => {
|
||||
await options.readDirectory?.(dir)
|
||||
return []
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveSessionFilePath('codex', 'session-id', {
|
||||
transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`,
|
||||
codexSessionsDirs: [LOCAL_SESSIONS_DIR]
|
||||
})
|
||||
).rejects.toBe(refusal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import type { AgentType } from '../../shared/native-chat-types'
|
||||
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
||||
import {
|
||||
resolveNativeChatTranscriptAgent,
|
||||
type NativeChatTranscriptAgent
|
||||
} from '../../shared/native-chat-agent-support'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { walkSessionFiles } from '../ai-vault/session-scanner-discovery'
|
||||
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts'
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
} from '../../shared/grok-session-paths'
|
||||
import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path'
|
||||
import { findWslCodexSessionPath } from './wsl-codex-session-path-scan'
|
||||
import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
|
||||
// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads
|
||||
// run in the main process against the runtime's own home directory; over SSH
|
||||
@@ -95,14 +99,34 @@ export async function resolveSessionFilePath(
|
||||
// beats reconstructing a path from the session id. Route it through the host
|
||||
// readability check so a WSL guest path becomes an openable UNC on Windows;
|
||||
// stale/missing paths fall through to the id-based search.
|
||||
let unavailable: WslTranscriptFsError | undefined
|
||||
const hookPath = options.transcriptPath?.trim()
|
||||
if (hookPath && extname(hookPath) === '.jsonl') {
|
||||
const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal })
|
||||
if (hostReadable) {
|
||||
return hostReadable
|
||||
try {
|
||||
const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal })
|
||||
if (hostReadable) {
|
||||
return hostReadable
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: the id-based search may still hit; surface the refusal only when
|
||||
// it does not, so a stalled distro reads as unavailable, never "missing".
|
||||
unavailable = wslTranscriptFsRefusal(error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = await resolveSessionFileById(transcriptAgent, sessionId, options, signal)
|
||||
if (!resolved && unavailable) {
|
||||
throw unavailable
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function resolveSessionFileById(
|
||||
transcriptAgent: NativeChatTranscriptAgent,
|
||||
sessionId: string,
|
||||
options: ResolveSessionFileOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
const trimmedId = sessionId.trim()
|
||||
if (!trimmedId) {
|
||||
return null
|
||||
@@ -132,6 +156,10 @@ export async function resolveSessionFilePath(
|
||||
if (transcriptAgent === 'omp') {
|
||||
return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal)
|
||||
}
|
||||
// Why: a new transcript agent must pick its own resolver. Falling through to
|
||||
// OMP's scan would search the wrong root with a foreign session id, so fail
|
||||
// the build here instead of resolving silently wrong at runtime.
|
||||
transcriptAgent satisfies never
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -178,6 +206,7 @@ async function findCodexRolloutInDirs(
|
||||
sessionsDirs: string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
let unavailable: WslTranscriptFsError | undefined
|
||||
for (const sessionsDir of sessionsDirs) {
|
||||
// Why: no existence pre-check — walkSessionFiles already yields [] for a
|
||||
// missing/unreadable root, and a sync probe would block the main thread on a
|
||||
@@ -190,18 +219,27 @@ async function findCodexRolloutInDirs(
|
||||
}
|
||||
}
|
||||
const isWslRoot = isWslUncPath(sessionsDir)
|
||||
const files = isWslRoot
|
||||
? await findWslCodexSessionPath(sessionsDir, sessionId, signal)
|
||||
: (
|
||||
await walkSessionFiles(sessionsDir, 'codex', [], {
|
||||
...scanOptions,
|
||||
signal
|
||||
})
|
||||
)[0]
|
||||
if (files) {
|
||||
return files
|
||||
try {
|
||||
const files = isWslRoot
|
||||
? await findWslCodexSessionPath(sessionsDir, sessionId, signal)
|
||||
: (
|
||||
await walkSessionFiles(sessionsDir, 'codex', [], {
|
||||
...scanOptions,
|
||||
signal
|
||||
})
|
||||
)[0]
|
||||
if (files) {
|
||||
return files
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: one stalled distro must not hide another root's hit.
|
||||
unavailable = wslTranscriptFsRefusal(error)
|
||||
}
|
||||
}
|
||||
// No hit and at least one root never scanned: "couldn't look", not "missing".
|
||||
if (unavailable) {
|
||||
throw unavailable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises'
|
||||
import type { AgentType } from '../../shared/native-chat-types'
|
||||
import { resolveSessionFilePath } from './session-file-resolver'
|
||||
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
|
||||
// Why: both the desktop IPC handler and the runtime RPC handler read the same
|
||||
// host-filesystem transcript, so a single process-global cache keyed by the
|
||||
@@ -89,7 +90,14 @@ export async function readNativeChatTranscriptCached(
|
||||
/** Hook-reported authoritative transcript path, preferred over the id glob. */
|
||||
transcriptPath?: string
|
||||
): Promise<ReadTranscriptResult> {
|
||||
const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
|
||||
let filePath: string | null
|
||||
try {
|
||||
filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
|
||||
} catch (err) {
|
||||
// Why: gate refusal is transient unavailability — report it without caching
|
||||
// or `notFound` so the next call re-resolves.
|
||||
return { error: wslTranscriptFsRefusal(err).message }
|
||||
}
|
||||
if (!filePath) {
|
||||
// Not cached (see below): a not-yet-flushed transcript should be re-checked
|
||||
// on the next call, not pinned as a settled miss (#8401).
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
||||
import { errorMessage } from '../ai-vault/session-scanner-values'
|
||||
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
import {
|
||||
decodeClaudeTranscriptLine,
|
||||
decodeCodexTranscriptLine,
|
||||
@@ -41,7 +42,14 @@ export async function readNativeChatTranscript(
|
||||
sessionId: string,
|
||||
options: ReadTranscriptOptions = {}
|
||||
): Promise<ReadTranscriptResult> {
|
||||
const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
|
||||
let filePath: string | null
|
||||
try {
|
||||
filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
|
||||
} catch (err) {
|
||||
// Why: gate refusal is transient unavailability with retry guidance —
|
||||
// `notFound` would settle callers into a false "missing" state.
|
||||
return { error: wslTranscriptFsRefusal(err).message }
|
||||
}
|
||||
if (!filePath) {
|
||||
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolve: vi.fn<() => Promise<string | null>>()
|
||||
}))
|
||||
|
||||
vi.mock('./session-file-resolver', () => ({
|
||||
resolveSessionFilePath: mocks.resolve
|
||||
}))
|
||||
|
||||
import { readNativeChatTranscriptTail } from './transcript-tail-reader'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
|
||||
describe('native chat transcript tail under WSL gate refusals', () => {
|
||||
beforeEach(() => {
|
||||
mocks.resolve.mockReset()
|
||||
})
|
||||
|
||||
it('reports a gate refusal as a retryable error, not a missing transcript', async () => {
|
||||
mocks.resolve.mockRejectedValueOnce(new WslTranscriptFsError('timeout', 'slow share'))
|
||||
|
||||
await expect(
|
||||
readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 })
|
||||
).resolves.toEqual({ error: 'slow share' })
|
||||
})
|
||||
|
||||
it('rethrows non-gate resolver failures', async () => {
|
||||
mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed'))
|
||||
|
||||
await expect(
|
||||
readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 })
|
||||
).rejects.toThrow('resolver crashed')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
nativeChatTurnLifecycleDecoderForAgent,
|
||||
type NativeChatTurnLifecycleDecoder
|
||||
} from './transcript-turn-lifecycle'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
|
||||
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
|
||||
const TAIL_CHUNK_BYTES = 64 * 1024
|
||||
@@ -231,12 +232,20 @@ export async function readNativeChatTranscriptTail(
|
||||
> {
|
||||
const decode = nativeChatLineDecoderForAgent(args.agent)
|
||||
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
|
||||
const filePath =
|
||||
args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal))
|
||||
signal?.throwIfAborted()
|
||||
if (!decode) {
|
||||
return { error: 'Transcript unavailable' }
|
||||
}
|
||||
let filePath: string | null
|
||||
try {
|
||||
filePath =
|
||||
args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal))
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
// Why: gate refusal is transient unavailability with retry guidance —
|
||||
// `notFound` would settle callers into a false "missing" state.
|
||||
return { error: wslTranscriptFsRefusal(error).message }
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
// Why: a new agent session can report its id before the first JSONL flush;
|
||||
// callers keep that miss in loading/retry rather than showing a false error.
|
||||
if (!filePath) {
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('./host-readable-transcript-path', async (importOriginal) => ({
|
||||
}))
|
||||
|
||||
import { subscribeNativeChatTranscript } from './transcript-watch'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
|
||||
const realPlatform = process.platform
|
||||
|
||||
@@ -125,6 +126,41 @@ describe('native chat transcript resolve polling', () => {
|
||||
subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('degrades a gate-refused initial resolve to the poll fallback instead of failing', async () => {
|
||||
const engine = { unsubscribe: vi.fn(), watching: true }
|
||||
mocks.resolve
|
||||
.mockRejectedValueOnce(new WslTranscriptFsError('unavailable', 'stuck permits'))
|
||||
.mockResolvedValue('/home/ada/found.jsonl')
|
||||
mocks.install.mockImplementation((filePath: string) => (filePath ? engine : null))
|
||||
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'session-id',
|
||||
resolvePollIntervalMs: 10,
|
||||
onAppend: () => {}
|
||||
})
|
||||
expect(subscription.watching).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(
|
||||
mocks.install.mock.calls.some(([filePath]) => filePath === '/home/ada/found.jsonl')
|
||||
).toBe(true)
|
||||
subscription.unsubscribe()
|
||||
expect(engine.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still fails subscribe on non-gate resolver errors', async () => {
|
||||
mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed'))
|
||||
|
||||
await expect(
|
||||
subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'session-id',
|
||||
onAppend: () => {}
|
||||
})
|
||||
).rejects.toThrow('resolver crashed')
|
||||
})
|
||||
|
||||
it('cancels queued WSL resolution when the subscription closes', async () => {
|
||||
setPlatform('win32')
|
||||
let receivedSignal: AbortSignal | undefined
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SubscribeNativeChatTranscriptArgs
|
||||
} from './transcript-watch-contract'
|
||||
import { nativeChatLineDecoderForAgent } from './transcript-tail-reader'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
|
||||
export { readNativeChatTranscriptTail } from './transcript-tail-reader'
|
||||
export { getActiveNativeChatWatcherCount } from './transcript-watch-engine'
|
||||
@@ -207,7 +208,16 @@ export async function subscribeNativeChatTranscript(
|
||||
return { unsubscribe: () => {}, watching: false }
|
||||
}
|
||||
|
||||
const installed = await attemptInstall(args, decode, setupSignal)
|
||||
let installed: NativeChatTranscriptSubscription | null
|
||||
try {
|
||||
installed = await attemptInstall(args, decode, setupSignal)
|
||||
} catch (error) {
|
||||
setupSignal?.throwIfAborted()
|
||||
// Why: a gate-refused resolve (stalled WSL distro) must degrade to the
|
||||
// resolve-poll fallback below, not fail the subscribe outright.
|
||||
void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal
|
||||
installed = null
|
||||
}
|
||||
if (installed) {
|
||||
return installed
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import {
|
||||
runWslTranscriptFsTask,
|
||||
WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS,
|
||||
WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS,
|
||||
WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK,
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
} from './wsl-transcript-fs-gate'
|
||||
|
||||
const SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
const CAPACITY_MESSAGE =
|
||||
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
@@ -28,6 +40,16 @@ function run(
|
||||
}
|
||||
|
||||
describe('WSL transcript filesystem task scheduling', () => {
|
||||
let warnSpy: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not block an exact probe behind a running scan on the same route', async () => {
|
||||
const stalled = deferred<string>()
|
||||
const scanTask = vi.fn(() => stalled.promise)
|
||||
@@ -97,8 +119,9 @@ describe('WSL transcript filesystem task scheduling', () => {
|
||||
const abandonedTask = vi.fn(async () => 'unused')
|
||||
const abandoned = run('\\\\wsl.localhost\\Fedora\\a', 'scan', abandonedTask, controller.signal)
|
||||
|
||||
controller.abort(new Error('closed subscription'))
|
||||
await expect(abandoned).rejects.toThrow('closed subscription')
|
||||
const reason = new Error('closed subscription')
|
||||
controller.abort(reason)
|
||||
await expect(abandoned).rejects.toBe(reason)
|
||||
ubuntu.resolve('ubuntu')
|
||||
debian.resolve('debian')
|
||||
await Promise.all([first, second])
|
||||
@@ -125,8 +148,9 @@ describe('WSL transcript filesystem task scheduling', () => {
|
||||
const second = run('\\\\wsl.localhost\\Ubuntu\\shared', 'exact', task, secondController.signal)
|
||||
await vi.waitFor(() => expect(task).toHaveBeenCalledOnce())
|
||||
|
||||
firstController.abort(new Error('first closed'))
|
||||
await expect(first).rejects.toThrow('first closed')
|
||||
const reason = new Error('first closed')
|
||||
firstController.abort(reason)
|
||||
await expect(first).rejects.toBe(reason)
|
||||
pending.resolve('second')
|
||||
await expect(second).resolves.toBe('second')
|
||||
expect(task).toHaveBeenCalledOnce()
|
||||
@@ -175,4 +199,436 @@ describe('WSL transcript filesystem task scheduling', () => {
|
||||
|
||||
await expect(Promise.all(tasks)).resolves.toEqual(['0', '1', '2', '3'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS],
|
||||
['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS]
|
||||
] as const)('rejects a stalled %s waiter at its deadline', async (priority, deadlineMs) => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
try {
|
||||
const task = vi.fn(() => stalled.promise)
|
||||
const pending = run(`\\\\wsl.localhost\\Deadline-${priority}\\a`, priority, task)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
name: 'WslTranscriptFsError',
|
||||
code: 'timeout',
|
||||
message: SLOW_MESSAGE
|
||||
})
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true)
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(task).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(deadlineMs - 1)
|
||||
expect(settled).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await rejected
|
||||
} finally {
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS],
|
||||
['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS]
|
||||
] as const)('completes healthy %s work below its deadline', async (priority, deadlineMs) => {
|
||||
vi.useFakeTimers()
|
||||
const work = deferred<string>()
|
||||
try {
|
||||
const pending = run(`\\\\wsl.localhost\\Healthy-${priority}\\a`, priority, () => work.promise)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(deadlineMs - 1)
|
||||
work.resolve('healthy')
|
||||
await expect(pending).resolves.toBe('healthy')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(deadlineMs)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
work.resolve('healthy')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let one timed-out waiter settle a later shared waiter', async () => {
|
||||
vi.useFakeTimers()
|
||||
const work = deferred<string>()
|
||||
const task = vi.fn(() => work.promise)
|
||||
try {
|
||||
const path = '\\\\wsl.localhost\\Ubuntu\\staggered'
|
||||
const first = run(path, 'exact', task)
|
||||
const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
const second = run(path, 'exact', task)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000)
|
||||
await firstRejected
|
||||
work.resolve('shared')
|
||||
await expect(second).resolves.toBe('shared')
|
||||
expect(task).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
work.resolve('shared')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects newest work when the pending queue is full', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ubuntu = deferred<string>()
|
||||
const debian = deferred<string>()
|
||||
const queuedController = new AbortController()
|
||||
try {
|
||||
const ubuntuTask = vi.fn(() => ubuntu.promise)
|
||||
const debianTask = vi.fn(() => debian.promise)
|
||||
const first = run('\\\\wsl.localhost\\Ubuntu\\occupied', 'exact', ubuntuTask)
|
||||
const second = run('\\\\wsl.localhost\\Debian\\occupied', 'exact', debianTask)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const queued = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS }, (_, index) =>
|
||||
run(
|
||||
`\\\\wsl.localhost\\Queued-${index}\\a`,
|
||||
'exact',
|
||||
async () => String(index),
|
||||
index === 0 ? queuedController.signal : undefined
|
||||
)
|
||||
)
|
||||
const overflowTask = vi.fn(async () => 'overflow')
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Overflow\\a', 'exact', overflowTask)
|
||||
).rejects.toMatchObject({ code: 'capacity', message: CAPACITY_MESSAGE })
|
||||
expect(overflowTask).not.toHaveBeenCalled()
|
||||
|
||||
const reason = new Error('queued caller closed')
|
||||
queuedController.abort(reason)
|
||||
await expect(queued[0]).rejects.toBe(reason)
|
||||
const replacement = run('\\\\wsl.localhost\\Replacement\\a', 'exact', async () => 'new')
|
||||
|
||||
ubuntu.resolve('ubuntu')
|
||||
debian.resolve('debian')
|
||||
await expect(
|
||||
Promise.all([first, second, ...queued.slice(1), replacement])
|
||||
).resolves.toHaveLength(WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS + 2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
ubuntu.resolve('ubuntu')
|
||||
debian.resolve('debian')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('caps callers waiting on shared work', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
const task = vi.fn(() => stalled.promise)
|
||||
const firstController = new AbortController()
|
||||
try {
|
||||
const path = '\\\\wsl.localhost\\Ubuntu\\shared-cap'
|
||||
const waiters = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK }, (_, index) =>
|
||||
run(path, 'exact', task, index === 0 ? firstController.signal : undefined)
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await expect(run(path, 'exact', task)).rejects.toMatchObject({
|
||||
code: 'capacity',
|
||||
message: CAPACITY_MESSAGE
|
||||
})
|
||||
const reason = new Error('first waiter closed')
|
||||
firstController.abort(reason)
|
||||
await expect(waiters[0]).rejects.toBe(reason)
|
||||
const replacement = run(path, 'exact', task)
|
||||
stalled.resolve('shared')
|
||||
await expect(Promise.all([...waiters.slice(1), replacement])).resolves.toEqual(
|
||||
Array(WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK).fill('shared')
|
||||
)
|
||||
expect(task).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
stalled.resolve('shared')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails new work fast after both running permits stall', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ubuntu = deferred<string>()
|
||||
const debian = deferred<string>()
|
||||
try {
|
||||
const first = run('\\\\wsl.localhost\\Ubuntu\\stalled', 'exact', () => ubuntu.promise)
|
||||
const second = run('\\\\wsl.localhost\\Debian\\stalled', 'exact', () => debian.promise)
|
||||
const firstRejected = expect(first).rejects.toBeInstanceOf(WslTranscriptFsError)
|
||||
const secondRejected = expect(second).rejects.toBeInstanceOf(WslTranscriptFsError)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
|
||||
await Promise.all([firstRejected, secondRejected])
|
||||
const laterTask = vi.fn(async () => 'later')
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Fedora\\later', 'exact', laterTask)
|
||||
).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE })
|
||||
expect(laterTask).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
ubuntu.resolve('ubuntu')
|
||||
debian.resolve('debian')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails only work needing a stuck lane fast, keeping the other permit usable', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
try {
|
||||
const stuck = run('\\\\wsl.localhost\\Ubuntu\\lane-stuck', 'exact', () => stalled.promise)
|
||||
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
|
||||
await stuckRejected
|
||||
|
||||
const sameLaneTask = vi.fn(async () => 'same-lane')
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Ubuntu\\lane-stuck-sibling', 'exact', sameLaneTask)
|
||||
).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE })
|
||||
expect(sameLaneTask).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Debian\\healthy-lane', 'exact', async () => 'debian')
|
||||
).resolves.toBe('debian')
|
||||
} finally {
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails scans and same-route probes fast while a scan is stuck', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
try {
|
||||
const stuckScan = run('\\\\wsl.localhost\\Ubuntu\\stuck-tree', 'scan', () => stalled.promise)
|
||||
const stuckRejected = expect(stuckScan).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS)
|
||||
await stuckRejected
|
||||
|
||||
const otherScanTask = vi.fn(async () => 'other-scan')
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Debian\\other-tree', 'scan', otherScanTask)
|
||||
).rejects.toMatchObject({ code: 'unavailable' })
|
||||
expect(otherScanTask).not.toHaveBeenCalled()
|
||||
// The whole Ubuntu mount is hung — an exact probe there would only burn
|
||||
// the remaining permit on it.
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Ubuntu\\live-probe', 'exact', async () => 'exact')
|
||||
).rejects.toMatchObject({ code: 'unavailable' })
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Debian\\live-probe', 'exact', async () => 'exact')
|
||||
).resolves.toBe('exact')
|
||||
} finally {
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a stuck exact probe from feeding a doomed scan the second permit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
try {
|
||||
const stuck = run('\\\\wsl.localhost\\Ubuntu\\hung-file', 'exact', () => stalled.promise)
|
||||
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
|
||||
await stuckRejected
|
||||
|
||||
const doomedScanTask = vi.fn(async () => 'doomed')
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Ubuntu\\tree', 'scan', doomedScanTask)
|
||||
).rejects.toMatchObject({ code: 'unavailable' })
|
||||
expect(doomedScanTask).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Debian\\tree', 'scan', async () => 'debian-scan')
|
||||
).resolves.toBe('debian-scan')
|
||||
} finally {
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('admits new work again once stuck work finally settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
try {
|
||||
const stuck = run('\\\\wsl.localhost\\Ubuntu\\recovering', 'exact', () => stalled.promise)
|
||||
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
|
||||
await stuckRejected
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'blocked')
|
||||
).rejects.toMatchObject({ code: 'unavailable' })
|
||||
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await expect(
|
||||
run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'recovered')
|
||||
).resolves.toBe('recovered')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to join in-flight work already past its own deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const work = deferred<string>()
|
||||
const task = vi.fn(() => work.promise)
|
||||
try {
|
||||
const path = '\\\\wsl.localhost\\Ubuntu\\join-stuck'
|
||||
const first = run(path, 'exact', task)
|
||||
const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
const second = run(path, 'exact', task)
|
||||
const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000)
|
||||
await firstRejected
|
||||
const joinTask = vi.fn(async () => 'join')
|
||||
await expect(run(path, 'exact', joinTask)).rejects.toMatchObject({ code: 'unavailable' })
|
||||
expect(joinTask).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await secondRejected
|
||||
} finally {
|
||||
work.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails joins onto queued work fast when stuck I/O keeps it from running', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
const queuedTask = vi.fn(async () => 'queued')
|
||||
try {
|
||||
const stuck = run('\\\\wsl.localhost\\Ubuntu\\trap-hung', 'exact', () => stalled.promise)
|
||||
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
// Queued behind the same route before the hang is detected.
|
||||
const queuedPath = '\\\\wsl.localhost\\Ubuntu\\trap-queued'
|
||||
const queued = run(queuedPath, 'exact', queuedTask)
|
||||
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'timeout' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 5_000)
|
||||
await stuckRejected
|
||||
// Re-requests must not keep the doomed queued task alive with fresh
|
||||
// deadlines — that would defeat the fail-fast for as long as I/O hangs.
|
||||
await expect(run(queuedPath, 'exact', queuedTask)).rejects.toMatchObject({
|
||||
code: 'unavailable'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await queuedRejected
|
||||
expect(queuedTask).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not feed a stuck route the other permit from the queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
const scanWork = deferred<string>()
|
||||
const debianWork = deferred<string>()
|
||||
const ubuntuTask = vi.fn(async () => 'ubuntu')
|
||||
try {
|
||||
const scanU = run(
|
||||
'\\\\wsl.localhost\\Ubuntu\\queued-route-tree',
|
||||
'scan',
|
||||
() => scanWork.promise
|
||||
)
|
||||
const exactD = run(
|
||||
'\\\\wsl.localhost\\Debian\\queued-route-d',
|
||||
'exact',
|
||||
() => debianWork.promise
|
||||
)
|
||||
const scanRejected = expect(scanU).rejects.toMatchObject({ code: 'timeout' })
|
||||
const exactDRejected = expect(exactD).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(35_000)
|
||||
await exactDRejected
|
||||
|
||||
// Queued before Ubuntu's hang is detected at the 60s scan deadline.
|
||||
const queuedU = run('\\\\wsl.localhost\\Ubuntu\\queued-route-file', 'exact', ubuntuTask)
|
||||
const queuedURejected = expect(queuedU).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
await scanRejected
|
||||
|
||||
debianWork.resolve('debian')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(ubuntuTask).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await queuedURejected
|
||||
expect(ubuntuTask).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
scanWork.resolve('late')
|
||||
debianWork.resolve('debian')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('warns once per task that outlives its deadline while running', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stalled = deferred<string>()
|
||||
try {
|
||||
const stuck = run('\\\\wsl.localhost\\Ubuntu\\logged', 'exact', () => stalled.promise)
|
||||
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS * 3)
|
||||
await stuckRejected
|
||||
expect(warnSpy).toHaveBeenCalledOnce()
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('exact')
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('logged')
|
||||
} finally {
|
||||
stalled.resolve('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not mark a scan permit stuck before the scan deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const exactWork = deferred<string>()
|
||||
const scanWork = deferred<string>()
|
||||
try {
|
||||
const exact = run(
|
||||
'\\\\wsl.localhost\\Ubuntu\\stalled-exact',
|
||||
'exact',
|
||||
() => exactWork.promise
|
||||
)
|
||||
const scanTask = vi.fn(() => scanWork.promise)
|
||||
const scanPath = '\\\\wsl.localhost\\Debian\\healthy-scan'
|
||||
const scan = run(scanPath, 'scan', scanTask)
|
||||
const exactRejected = expect(exact).rejects.toMatchObject({ code: 'timeout' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
|
||||
await exactRejected
|
||||
const duplicateTask = vi.fn(async () => 'duplicate')
|
||||
const duplicate = run(scanPath, 'scan', duplicateTask)
|
||||
|
||||
scanWork.resolve('scan')
|
||||
await expect(Promise.all([scan, duplicate])).resolves.toEqual(['scan', 'scan'])
|
||||
expect(scanTask).toHaveBeenCalledOnce()
|
||||
expect(duplicateTask).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
exactWork.resolve('late')
|
||||
scanWork.resolve('scan')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2
|
||||
export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000
|
||||
export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000
|
||||
// Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely.
|
||||
export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64
|
||||
export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64
|
||||
const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
|
||||
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable'
|
||||
|
||||
export class WslTranscriptFsError extends Error {
|
||||
constructor(
|
||||
readonly code: WslTranscriptFsFailureCode,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'WslTranscriptFsError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow a caught error to a gate refusal, rethrowing anything else. */
|
||||
export function wslTranscriptFsRefusal(error: unknown): WslTranscriptFsError {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
return error
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
export type WslTranscriptFsTaskPriority = 'exact' | 'scan'
|
||||
|
||||
@@ -9,30 +38,39 @@ type TaskWaiter<T> = {
|
||||
reject: (error: unknown) => void
|
||||
signal?: AbortSignal
|
||||
onAbort?: () => void
|
||||
timeout?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type ScheduledTask<T> = {
|
||||
key: string
|
||||
laneKey: string
|
||||
route: string
|
||||
priority: WslTranscriptFsTaskPriority
|
||||
operation: (signal: AbortSignal) => Promise<T>
|
||||
controller: AbortController
|
||||
waiters: Set<TaskWaiter<T>>
|
||||
state: 'queued' | 'running' | 'settled'
|
||||
// Set by the deadline timer, sharing the waiters' monotonic clock — wall time
|
||||
// would misjudge stuckness across laptop sleep or NTP steps.
|
||||
stuck: boolean
|
||||
stuckTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type UnknownScheduledTask = ScheduledTask<unknown>
|
||||
|
||||
let activeTaskCount = 0
|
||||
let activeScanCount = 0
|
||||
const activeLaneKeys = new Set<string>()
|
||||
const queuedTasks: UnknownScheduledTask[] = []
|
||||
const inFlightTasks = new Map<string, UnknownScheduledTask>()
|
||||
const activeTasks = new Set<UnknownScheduledTask>()
|
||||
|
||||
function abortReason(signal: AbortSignal): unknown {
|
||||
return signal.reason ?? new Error('WSL transcript filesystem task aborted')
|
||||
}
|
||||
|
||||
// wsl$ and wsl.localhost spellings of one distro stay distinct routes on
|
||||
// purpose (provider spelling can change behavior), so a stuck spelling never
|
||||
// fast-fails its twin — the twin is bounded by its own waiter deadlines.
|
||||
function routeKey(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i)
|
||||
@@ -55,11 +93,11 @@ function clearTask(task: UnknownScheduledTask): void {
|
||||
}
|
||||
}
|
||||
|
||||
function abandonTaskIfUnused(task: UnknownScheduledTask): void {
|
||||
function abandonTaskIfUnused(task: UnknownScheduledTask, reason?: unknown): void {
|
||||
if (task.waiters.size > 0 || task.state === 'settled') {
|
||||
return
|
||||
}
|
||||
task.controller.abort()
|
||||
task.controller.abort(reason)
|
||||
// Running I/O keeps its permit; new callers need a reusable controller.
|
||||
clearTask(task)
|
||||
if (task.state === 'queued') {
|
||||
@@ -69,24 +107,91 @@ function abandonTaskIfUnused(task: UnknownScheduledTask): void {
|
||||
}
|
||||
}
|
||||
|
||||
function attachWaiter<T>(task: ScheduledTask<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (signal?.aborted) {
|
||||
abandonTaskIfUnused(task as UnknownScheduledTask)
|
||||
return Promise.reject(abortReason(signal))
|
||||
function removeWaiter<T>(task: ScheduledTask<T>, waiter: TaskWaiter<T>): boolean {
|
||||
if (!task.waiters.delete(waiter)) {
|
||||
return false
|
||||
}
|
||||
if (waiter.signal && waiter.onAbort) {
|
||||
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
||||
}
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function timeoutMs(priority: WslTranscriptFsTaskPriority): number {
|
||||
return priority === 'exact'
|
||||
? WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS
|
||||
: WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS
|
||||
}
|
||||
|
||||
function timeoutError(): WslTranscriptFsError {
|
||||
return new WslTranscriptFsError('timeout', WSL_TRANSCRIPT_FS_SLOW_MESSAGE)
|
||||
}
|
||||
|
||||
function capacityError(): WslTranscriptFsError {
|
||||
return new WslTranscriptFsError('capacity', WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE)
|
||||
}
|
||||
|
||||
function unavailableError(): WslTranscriptFsError {
|
||||
return new WslTranscriptFsError('unavailable', WSL_TRANSCRIPT_FS_SLOW_MESSAGE)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stuck running task that would doom a new task's admission: one on the
|
||||
* same route (the whole distro mount is hung, whatever the priority — spending
|
||||
* the other permit on it escalates one bad distro into a global outage), one
|
||||
* holding the single scan slot, or (with every permit stuck) any permit.
|
||||
*/
|
||||
function stuckBlocker(
|
||||
route: string,
|
||||
priority: WslTranscriptFsTaskPriority
|
||||
): UnknownScheduledTask | undefined {
|
||||
const stuck = [...activeTasks].filter((task) => task.stuck)
|
||||
if (stuck.length === MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) {
|
||||
return stuck[0]
|
||||
}
|
||||
return stuck.find(
|
||||
(task) => task.route === route || (priority === 'scan' && task.priority === 'scan')
|
||||
)
|
||||
}
|
||||
|
||||
// Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields
|
||||
// before the waiter is attached, so no aborted-signal recheck is needed.
|
||||
function attachWaiter<T>(
|
||||
task: ScheduledTask<T>,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (task.waiters.size >= WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK) {
|
||||
return Promise.reject(capacityError())
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const waiter: TaskWaiter<T> = { resolve, reject, signal }
|
||||
task.waiters.add(waiter)
|
||||
if (signal) {
|
||||
waiter.onAbort = () => {
|
||||
if (!task.waiters.delete(waiter)) {
|
||||
const reason = abortReason(signal)
|
||||
if (!removeWaiter(task, waiter)) {
|
||||
return
|
||||
}
|
||||
reject(abortReason(signal))
|
||||
abandonTaskIfUnused(task as UnknownScheduledTask)
|
||||
reject(reason)
|
||||
abandonTaskIfUnused(task as UnknownScheduledTask, reason)
|
||||
}
|
||||
signal.addEventListener('abort', waiter.onAbort, { once: true })
|
||||
}
|
||||
task.waiters.add(waiter)
|
||||
// UNC/9P calls cannot be aborted; two blocked calls can retain both slots until settling or
|
||||
// Orca restarts, while caller and queue retention remains bounded.
|
||||
waiter.timeout = setTimeout(() => {
|
||||
const error = timeoutError()
|
||||
if (!removeWaiter(task, waiter)) {
|
||||
return
|
||||
}
|
||||
reject(error)
|
||||
abandonTaskIfUnused(task as UnknownScheduledTask, error)
|
||||
}, timeoutMs(priority))
|
||||
waiter.timeout.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,16 +200,15 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
|
||||
return
|
||||
}
|
||||
task.state = 'settled'
|
||||
activeTaskCount -= 1
|
||||
if (task.priority === 'scan') {
|
||||
activeScanCount -= 1
|
||||
}
|
||||
activeLaneKeys.delete(task.laneKey)
|
||||
activeTasks.delete(task as UnknownScheduledTask)
|
||||
clearTimeout(task.stuckTimer)
|
||||
clearTask(task as UnknownScheduledTask)
|
||||
for (const waiter of task.waiters) {
|
||||
if (waiter.signal && waiter.onAbort) {
|
||||
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
||||
}
|
||||
removeWaiter(task, waiter)
|
||||
if ('value' in result) {
|
||||
waiter.resolve(result.value)
|
||||
} else {
|
||||
@@ -116,13 +220,21 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
|
||||
}
|
||||
|
||||
function nextTaskIndex(): number {
|
||||
// Tasks queued before their route hung must not be fed the other permit;
|
||||
// their waiters drain by deadline while healthy routes use the slot.
|
||||
const stuckRoutes = new Set(
|
||||
[...activeTasks].filter((task) => task.stuck).map((task) => task.route)
|
||||
)
|
||||
for (const priority of ['exact', 'scan'] as const) {
|
||||
// Why: keep one libuv slot available for a live transcript probe.
|
||||
if (priority === 'scan' && activeScanCount > 0) {
|
||||
continue
|
||||
}
|
||||
const index = queuedTasks.findIndex(
|
||||
(task) => task.priority === priority && !activeLaneKeys.has(task.laneKey)
|
||||
(task) =>
|
||||
task.priority === priority &&
|
||||
!activeLaneKeys.has(task.laneKey) &&
|
||||
!stuckRoutes.has(task.route)
|
||||
)
|
||||
if (index !== -1) {
|
||||
return index
|
||||
@@ -132,7 +244,7 @@ function nextTaskIndex(): number {
|
||||
}
|
||||
|
||||
function pumpTasks(): void {
|
||||
while (activeTaskCount < MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) {
|
||||
while (activeTasks.size < MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) {
|
||||
const index = nextTaskIndex()
|
||||
if (index === -1) {
|
||||
return
|
||||
@@ -142,11 +254,21 @@ function pumpTasks(): void {
|
||||
continue
|
||||
}
|
||||
task.state = 'running'
|
||||
activeTaskCount += 1
|
||||
if (task.priority === 'scan') {
|
||||
activeScanCount += 1
|
||||
}
|
||||
activeLaneKeys.add(task.laneKey)
|
||||
activeTasks.add(task)
|
||||
// Anchored at run start: queue wait says nothing about the I/O itself, so
|
||||
// the fail-fast may lag admission by at most one deadline period.
|
||||
task.stuckTimer = setTimeout(() => {
|
||||
task.stuck = true
|
||||
console.warn(
|
||||
`[wsl-transcript-fs-gate] ${task.priority} filesystem task still running after ` +
|
||||
`${timeoutMs(task.priority)}ms and holding a permit: ${task.key}`
|
||||
)
|
||||
}, timeoutMs(task.priority))
|
||||
task.stuckTimer.unref?.()
|
||||
void Promise.resolve()
|
||||
.then(() => {
|
||||
task.controller.signal.throwIfAborted()
|
||||
@@ -169,28 +291,49 @@ export function runWslTranscriptFsTask<T>(
|
||||
},
|
||||
task: (signal: AbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (options.signal?.aborted) {
|
||||
return Promise.reject(abortReason(options.signal))
|
||||
}
|
||||
// Why: route spelling and Linux path case can change provider behavior, so
|
||||
// only byte-identical filesystem requests are safe to share.
|
||||
const key = JSON.stringify([options.operation, options.path])
|
||||
const existing = inFlightTasks.get(key) as ScheduledTask<T> | undefined
|
||||
if (existing) {
|
||||
return attachWaiter(existing, options.signal)
|
||||
// A stuck target — or a queued target that stuck I/O keeps from ever
|
||||
// running — dooms every joiner to a slow timeout, and each fresh joiner
|
||||
// would keep the queued task alive past its own abandonment. Fail fast.
|
||||
if (
|
||||
existing.stuck ||
|
||||
(existing.state === 'queued' && stuckBlocker(existing.route, existing.priority))
|
||||
) {
|
||||
return Promise.reject(unavailableError())
|
||||
}
|
||||
return attachWaiter(existing, options.priority, options.signal)
|
||||
}
|
||||
const route = routeKey(options.path)
|
||||
// Fail fast when the permit, route, or scan slot this task needs is held by
|
||||
// stuck I/O — queueing would only burn the caller's full deadline.
|
||||
if (stuckBlocker(route, options.priority)) {
|
||||
return Promise.reject(unavailableError())
|
||||
}
|
||||
if (queuedTasks.length >= WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS) {
|
||||
return Promise.reject(capacityError())
|
||||
}
|
||||
|
||||
const scheduled: ScheduledTask<T> = {
|
||||
key,
|
||||
laneKey: `${routeKey(options.path)}:${options.priority}`,
|
||||
laneKey: `${route}:${options.priority}`,
|
||||
route,
|
||||
priority: options.priority,
|
||||
operation: task,
|
||||
controller: new AbortController(),
|
||||
waiters: new Set(),
|
||||
state: 'queued'
|
||||
state: 'queued',
|
||||
stuck: false
|
||||
}
|
||||
inFlightTasks.set(key, scheduled as UnknownScheduledTask)
|
||||
const result = attachWaiter(scheduled, options.signal)
|
||||
if (scheduled.state === 'queued' && scheduled.waiters.size > 0) {
|
||||
queuedTasks.push(scheduled as UnknownScheduledTask)
|
||||
queueMicrotask(pumpTasks)
|
||||
}
|
||||
const result = attachWaiter(scheduled, options.priority, options.signal)
|
||||
queuedTasks.push(scheduled as UnknownScheduledTask)
|
||||
queueMicrotask(pumpTasks)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -4738,6 +4738,25 @@ describe('Store', () => {
|
||||
expect(updated!.externalWorktreeVisibilityLegacy).toBe(true)
|
||||
})
|
||||
|
||||
it('persists agent worktree visibility independently from external visibility', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
|
||||
|
||||
const updated = store.updateRepo('r1', { agentWorktreeVisibility: 'show' })
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
agentWorktreeVisibility: 'show'
|
||||
})
|
||||
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getRepo('r1')).toMatchObject({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
agentWorktreeVisibility: 'show'
|
||||
})
|
||||
})
|
||||
|
||||
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
@@ -5044,7 +5063,19 @@ describe('Store', () => {
|
||||
const updated = store.updateFolderWorkspace(workspace.id, {
|
||||
comment: 'Coordinate api and web',
|
||||
isPinned: true,
|
||||
lastActivityAt: 123
|
||||
lastActivityAt: 123,
|
||||
diffComments: [
|
||||
{
|
||||
id: 'note-1',
|
||||
worktreeId: folderWorkspaceKey(workspace.id),
|
||||
filePath: 'README.md',
|
||||
source: 'markdown',
|
||||
lineNumber: 1,
|
||||
body: 'Review this paragraph',
|
||||
createdAt: 100,
|
||||
side: 'modified'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(workspace.folderPath).toBe('/workspace/platform')
|
||||
@@ -5056,9 +5087,16 @@ describe('Store', () => {
|
||||
linkedTask,
|
||||
comment: 'Coordinate api and web',
|
||||
isPinned: true,
|
||||
lastActivityAt: 123
|
||||
lastActivityAt: 123,
|
||||
diffComments: [expect.objectContaining({ id: 'note-1', body: 'Review this paragraph' })]
|
||||
})
|
||||
expect(store.getFolderWorkspaces()).toHaveLength(1)
|
||||
store.flush()
|
||||
|
||||
const restored = await createStore()
|
||||
expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([
|
||||
expect.objectContaining({ id: 'note-1', body: 'Review this paragraph' })
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips Jira item and source context for repo-less folder workspaces', async () => {
|
||||
|
||||
@@ -4574,6 +4574,7 @@ export class Store {
|
||||
| 'pendingFirstAgentMessageRename'
|
||||
| 'firstAgentMessageRenameError'
|
||||
| 'lastActivityAt'
|
||||
| 'diffComments'
|
||||
>
|
||||
>
|
||||
): FolderWorkspace | null {
|
||||
@@ -4647,6 +4648,9 @@ export class Store {
|
||||
if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) {
|
||||
workspace.lastActivityAt = updates.lastActivityAt
|
||||
}
|
||||
if (updates.diffComments !== undefined) {
|
||||
workspace.diffComments = updates.diffComments
|
||||
}
|
||||
workspace.updatedAt = Date.now()
|
||||
this.scheduleSave()
|
||||
return workspace
|
||||
@@ -4938,6 +4942,7 @@ export class Store {
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
| 'agentWorktreeVisibility'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'projectHostSetupMethod'
|
||||
|
||||
@@ -62,6 +62,8 @@ export async function shellScriptTest(
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...config.env,
|
||||
// Why: these examples inspect startup-file discovery, not the PTY-owner protocol stream.
|
||||
ORCA_SHELL_STARTUP_IDENTITY: '0',
|
||||
// Why: the framework creates user startup files under testHome after
|
||||
// computing the wrapper config; route wrapper discovery to that fixture.
|
||||
HOME: testHome,
|
||||
|
||||
@@ -15,7 +15,8 @@ const {
|
||||
readWindowsConptyProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsMock
|
||||
wslUncDirectoryExistsMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
@@ -28,7 +29,8 @@ const {
|
||||
readWindowsConptyProcessIdsMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
isWslAvailableAsyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsMock: vi.fn()
|
||||
wslUncDirectoryExistsMock: vi.fn(),
|
||||
createShellPromptReadinessProbeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
@@ -107,6 +109,10 @@ vi.mock('../wsl', () => ({
|
||||
wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetLocalPtyProviderStateForTest,
|
||||
LOCAL_PTY_FORCE_KILL_RETRY_MS,
|
||||
@@ -174,6 +180,7 @@ describe('LocalPtyProvider', () => {
|
||||
isWslAvailableAsyncMock.mockResolvedValue(true)
|
||||
wslUncDirectoryExistsMock.mockReset()
|
||||
wslUncDirectoryExistsMock.mockReturnValue(true)
|
||||
createShellPromptReadinessProbeMock.mockReset()
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = {
|
||||
@@ -534,6 +541,24 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
|
||||
})
|
||||
|
||||
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' })
|
||||
)
|
||||
})
|
||||
|
||||
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.
|
||||
@@ -685,8 +710,8 @@ describe('LocalPtyProvider', () => {
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.TERM_PROGRAM = 'Orca'
|
||||
env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-attribution'
|
||||
env.PATH = `/tmp/orca-attribution:${env.PATH ?? ''}`
|
||||
env.ORCA_STALE_TEST_ENV = '/tmp/orca-stale'
|
||||
env.PATH = `/tmp/orca-stale:${env.PATH ?? ''}`
|
||||
return env
|
||||
}
|
||||
})
|
||||
@@ -699,7 +724,7 @@ describe('LocalPtyProvider', () => {
|
||||
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
|
||||
},
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
@@ -707,7 +732,7 @@ describe('LocalPtyProvider', () => {
|
||||
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_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_STALE_TEST_ENV).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
|
||||
@@ -817,9 +842,9 @@ describe('LocalPtyProvider', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
// Why: attribution collapses Windows PATH onto `Path` and prepends its own shim dir.
|
||||
// Why: host env collapses Windows PATH onto `Path` and prepends its own shim dir.
|
||||
delete env.PATH
|
||||
env.Path = `/tmp/orca-attribution:${env.Path ?? ''}`
|
||||
env.Path = `/tmp/orca-stale:${env.Path ?? ''}`
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from './local-pty-utils'
|
||||
import { prepareMacosTccLoginShell } from './macos-tcc-login-shell'
|
||||
import {
|
||||
getAttributionShellLaunchConfig,
|
||||
getMarkerlessShellLaunchConfig,
|
||||
getShellReadyLaunchConfig,
|
||||
createShellReadyScanState,
|
||||
drainShellReadyHeldBytes,
|
||||
@@ -75,6 +75,15 @@ import {
|
||||
createPtySlaveEchoProbe,
|
||||
readPtySlavePath
|
||||
} from '../../shared/pty-slave-line-discipline-echo'
|
||||
import {
|
||||
createShellStartupIdentityScanState,
|
||||
drainShellStartupIdentityHeldBytes,
|
||||
scanForShellStartupIdentity
|
||||
} from '../shell-startup-identity-scanner'
|
||||
import {
|
||||
createShellPromptReadinessProbe,
|
||||
type ShellPromptReadinessProbe
|
||||
} from '../shell-prompt-readiness-probe'
|
||||
import {
|
||||
expandWindowsEnvironmentVariables,
|
||||
expandWindowsPathEnvironmentVariables
|
||||
@@ -782,7 +791,6 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
if (!wslInfo && process.platform !== 'win32') {
|
||||
// Why: OpenCode/Codex PATH restoration and OMP's status wrapper need shell-ready code after user startup files run.
|
||||
const needsNoMarkerWrapper =
|
||||
finalEnv.ORCA_ATTRIBUTION_SHIM_DIR ||
|
||||
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
|
||||
finalEnv.ORCA_MIMOCODE_HOME ||
|
||||
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
|
||||
@@ -799,16 +807,16 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
getFallbackShellReadyConfig = (shell) =>
|
||||
shouldWaitForShellReady
|
||||
? getShellReadyLaunchConfig(shell)
|
||||
: getAttributionShellLaunchConfig(shell)
|
||||
: getMarkerlessShellLaunchConfig(shell)
|
||||
shellLaunch = shouldWaitForShellReady
|
||||
? getShellReadyLaunchConfig(shellPath)
|
||||
: getAttributionShellLaunchConfig(shellPath)
|
||||
: getMarkerlessShellLaunchConfig(shellPath)
|
||||
} else if (args.command) {
|
||||
getFallbackShellReadyConfig = (shell) => getShellReadyLaunchConfig(shell)
|
||||
shellLaunch = getShellReadyLaunchConfig(shellPath)
|
||||
} else if (needsNoMarkerWrapper) {
|
||||
getFallbackShellReadyConfig = (shell) => getAttributionShellLaunchConfig(shell)
|
||||
shellLaunch = getAttributionShellLaunchConfig(shellPath)
|
||||
getFallbackShellReadyConfig = (shell) => getMarkerlessShellLaunchConfig(shell)
|
||||
shellLaunch = getMarkerlessShellLaunchConfig(shellPath)
|
||||
} else {
|
||||
getFallbackShellReadyConfig = undefined
|
||||
}
|
||||
@@ -951,6 +959,11 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
// Shell-ready startup command support
|
||||
let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null
|
||||
let shellReadyTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let shellStartupPid: number | null = null
|
||||
let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
|
||||
let shellStartupIdentityScanState = shellReadyLaunch?.supportsReadyMarker
|
||||
? createShellStartupIdentityScanState()
|
||||
: null
|
||||
const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker
|
||||
? createShellReadyScanState()
|
||||
: null
|
||||
@@ -967,6 +980,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
clearTimeout(shellReadyTimeout)
|
||||
shellReadyTimeout = null
|
||||
}
|
||||
shellPromptReadinessProbe?.dispose()
|
||||
shellPromptReadinessProbe = null
|
||||
const resolve = resolveShellReady
|
||||
resolveShellReady = null
|
||||
resolve(signal)
|
||||
@@ -975,12 +990,35 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
if (!shellReadyScanState) {
|
||||
return
|
||||
}
|
||||
const heldBytes = drainShellReadyHeldBytes(shellReadyScanState)
|
||||
let heldBytes = shellStartupIdentityScanState
|
||||
? drainShellStartupIdentityHeldBytes(shellStartupIdentityScanState)
|
||||
: ''
|
||||
shellStartupIdentityScanState = null
|
||||
if (heldBytes) {
|
||||
heldBytes = scanForShellReady(shellReadyScanState, heldBytes).output
|
||||
}
|
||||
heldBytes += drainShellReadyHeldBytes(shellReadyScanState)
|
||||
if (heldBytes.length === 0) {
|
||||
return
|
||||
}
|
||||
startupIngress.accept(heldBytes)
|
||||
}
|
||||
if (shellReadyScanState) {
|
||||
shellPromptReadinessProbe = createShellPromptReadinessProbe({
|
||||
slavePath: readPtySlavePath(proc),
|
||||
shellPath,
|
||||
shellCwd: effectiveCwd,
|
||||
shellPathEnv: finalEnv.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 (args.command) {
|
||||
if (shellReadyLaunch?.supportsReadyMarker) {
|
||||
shellReadyTimeout = setTimeout(() => {
|
||||
@@ -1002,20 +1040,33 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
startupCommandCleanup?.()
|
||||
startupCommandCleanup = null
|
||||
resolveShellReady = null
|
||||
shellPromptReadinessProbe?.dispose()
|
||||
shellPromptReadinessProbe = null
|
||||
})
|
||||
}
|
||||
|
||||
const disposables: { dispose: () => void }[] = []
|
||||
const onDataDisposable = proc.onData((rawData) => {
|
||||
let data = rawData
|
||||
if (shellStartupIdentityScanState && resolveShellReady) {
|
||||
const scanned = scanForShellStartupIdentity(shellStartupIdentityScanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.shellPid) {
|
||||
shellStartupPid = scanned.shellPid
|
||||
shellStartupIdentityScanState = null
|
||||
}
|
||||
}
|
||||
if (shellReadyScanState && resolveShellReady) {
|
||||
const scanned = scanForShellReady(shellReadyScanState, rawData)
|
||||
const scanned = scanForShellReady(shellReadyScanState, data)
|
||||
data = scanned.output
|
||||
if (scanned.matched) {
|
||||
finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved })
|
||||
}
|
||||
}
|
||||
startupIngress.accept(data)
|
||||
if (resolveShellReady && data.length > 0) {
|
||||
shellPromptReadinessProbe?.notifyOutput(data)
|
||||
}
|
||||
})
|
||||
if (onDataDisposable) {
|
||||
disposables.push(onDataDisposable)
|
||||
@@ -1033,6 +1084,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
shellReadyTimeout = null
|
||||
}
|
||||
startupCommandCleanup?.()
|
||||
shellPromptReadinessProbe?.dispose()
|
||||
shellPromptReadinessProbe = null
|
||||
clearPtyState(id)
|
||||
startupIngress.drainAndClose()
|
||||
startupIngressByPty.delete(id)
|
||||
|
||||
@@ -380,10 +380,10 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
expect(init).toContain('functions -e __orca_shell_ready_marker')
|
||||
})
|
||||
|
||||
it('keeps attribution-only fish spawns unwrapped', async () => {
|
||||
const { getAttributionShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
it('keeps markerless fish spawns unwrapped', async () => {
|
||||
const { getMarkerlessShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
const config = getAttributionShellLaunchConfig('/opt/homebrew/bin/fish')
|
||||
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
|
||||
|
||||
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
|
||||
})
|
||||
@@ -485,6 +485,7 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
|
||||
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
||||
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
|
||||
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
|
||||
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
|
||||
expectZdotdirSourceContext(zprofile, '.zprofile')
|
||||
@@ -815,8 +816,6 @@ path=(/custom/bin $path)
|
||||
}
|
||||
delete cleanEnv.ZDOTDIR
|
||||
delete cleanEnv.ORCA_ORIG_ZDOTDIR
|
||||
// Why: this test isolates zsh top-level path scoping, not attribution shim ordering.
|
||||
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
|
||||
cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir
|
||||
|
||||
const result = spawnSync(
|
||||
@@ -855,7 +854,6 @@ path=(/custom/bin $path)
|
||||
}
|
||||
delete cleanEnv.ZDOTDIR
|
||||
delete cleanEnv.ORCA_ORIG_ZDOTDIR
|
||||
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
|
||||
delete cleanEnv.USER_ZSHRC_LOADED
|
||||
cleanEnv.ZDOTDIR = join(movedUserData, 'shell-ready', 'zsh')
|
||||
|
||||
@@ -903,7 +901,6 @@ path=(/custom/bin $path)
|
||||
}
|
||||
delete cleanEnv.ZDOTDIR
|
||||
delete cleanEnv.ORCA_ORIG_ZDOTDIR
|
||||
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
|
||||
delete cleanEnv.USER_ZSHRC_LOADED
|
||||
cleanEnv.ZDOTDIR = join(nonAsciiUserData, 'shell-ready', 'zsh')
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../shell-templates'
|
||||
export {
|
||||
@@ -92,6 +93,7 @@ function resolveOriginalZshenvSourceDir(): string {
|
||||
|
||||
export function getBashShellReadyRcfileContent(): string {
|
||||
return `# Orca bash shell-ready wrapper
|
||||
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
@@ -108,14 +110,6 @@ fi
|
||||
# Why: preserve bash's normal login-shell contract. Many users already source
|
||||
# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate
|
||||
# PATH edits, hooks, and prompt init in Orca startup-command shells.
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_attribution_path
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
@@ -224,14 +218,6 @@ ${getZshStartupFileSourceBlock({
|
||||
interactiveOnly: true,
|
||||
skipWhenHomeIsCurrentZdotdir: true
|
||||
})}
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
|
||||
}
|
||||
[[ ! -o login ]] && __orca_restore_attribution_path
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
@@ -285,14 +271,6 @@ ${getZshStartupFileSourceBlock({ fileName: '.zprofile' })}
|
||||
const zshRc = getZshShellReadyRcfileContent()
|
||||
const zshLogin = `# Orca zsh shell-ready wrapper
|
||||
${getZshStartupFileSourceBlock({ fileName: '.zlogin', interactiveOnly: true })}
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_attribution_path
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
@@ -368,7 +346,8 @@ function getWrappedShellLaunchConfig(
|
||||
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
|
||||
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
|
||||
ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`,
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
@@ -379,7 +358,8 @@ function getWrappedShellLaunchConfig(
|
||||
return {
|
||||
args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`],
|
||||
env: {
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
|
||||
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
|
||||
},
|
||||
supportsReadyMarker: options.emitReadyMarker
|
||||
}
|
||||
@@ -398,7 +378,7 @@ function getWrappedShellLaunchConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: mirrors daemon/shell-ready.ts; attribution-only fish stays unwrapped.
|
||||
// Why: mirrors daemon/shell-ready.ts; markerless fish stays unwrapped.
|
||||
if (shellName === 'fish' && options.emitReadyMarker) {
|
||||
return {
|
||||
args: ['-l', '-C', getFishShellReadyInitCommand(SHELL_READY_MARKER_ESCAPED)],
|
||||
@@ -418,7 +398,7 @@ export function getShellReadyLaunchConfig(shellPath: string): ShellReadyLaunchCo
|
||||
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: true })
|
||||
}
|
||||
|
||||
export function getAttributionShellLaunchConfig(shellPath: string): ShellReadyLaunchConfig {
|
||||
export function getMarkerlessShellLaunchConfig(shellPath: string): ShellReadyLaunchConfig {
|
||||
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: false })
|
||||
}
|
||||
|
||||
|
||||
@@ -428,7 +428,7 @@ describe('SshPtyProvider', () => {
|
||||
|
||||
it('preserves explicit TERM and forwards final env deletions to the relay', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-env-precedence' })
|
||||
const envToDelete = ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
const envToDelete = ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
|
||||
|
||||
await provider.spawn({
|
||||
cols: 120,
|
||||
@@ -436,7 +436,7 @@ describe('SshPtyProvider', () => {
|
||||
env: {
|
||||
TERM: 'screen-256color',
|
||||
TERM_PROGRAM: 'stale-terminal',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
|
||||
ORCA_STALE_TEST_ENV: '/tmp/stale-env'
|
||||
},
|
||||
envToDelete
|
||||
})
|
||||
@@ -453,7 +453,7 @@ describe('SshPtyProvider', () => {
|
||||
})
|
||||
const spawnCall = mux.request.mock.calls.find((call) => call[0] === 'pty.spawn')
|
||||
expect(spawnCall?.[1]?.env).not.toHaveProperty('TERM_PROGRAM')
|
||||
expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_ATTRIBUTION_SHIM_DIR')
|
||||
expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_STALE_TEST_ENV')
|
||||
})
|
||||
|
||||
it('forwards provider command delivery to the relay', async () => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
__resetLegacyTerminalShimRemovalForTests,
|
||||
removeLegacyTerminalShimDir,
|
||||
stripLegacyTerminalShimEnv
|
||||
} from './legacy-terminal-shim-dir'
|
||||
|
||||
// Why: the failure case is produced with a read-only parent dir, which Windows ignores and root bypasses.
|
||||
const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it
|
||||
|
||||
describe('legacy terminal shim removal', () => {
|
||||
beforeEach(() => {
|
||||
__resetLegacyTerminalShimRemovalForTests()
|
||||
})
|
||||
|
||||
it('deletes the orphaned wrapper directory left by older installs', () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
|
||||
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
mkdirSync(shimDir, { recursive: true })
|
||||
writeFileSync(join(shimDir, 'git'), '#!/usr/bin/env bash\n')
|
||||
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
|
||||
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
|
||||
expect(existsSync(userData)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not throw when nothing is left to remove', () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
|
||||
expect(() => removeLegacyTerminalShimDir(userData)).not.toThrow()
|
||||
})
|
||||
|
||||
itOnPosixNonRoot('stays retryable when the removal fails, and latches once it succeeds', () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
|
||||
const legacyRoot = join(userData, 'orca-terminal-attribution')
|
||||
mkdirSync(join(legacyRoot, 'posix'), { recursive: true })
|
||||
writeFileSync(join(legacyRoot, 'posix', 'git'), '#!/usr/bin/env bash\n')
|
||||
// Why: a read-only parent makes the unlink fail the way a locked Windows wrapper does.
|
||||
chmodSync(userData, 0o500)
|
||||
try {
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
expect(existsSync(legacyRoot)).toBe(true)
|
||||
} finally {
|
||||
chmodSync(userData, 0o700)
|
||||
}
|
||||
|
||||
// The failed attempt must not have latched the guard.
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
expect(existsSync(legacyRoot)).toBe(false)
|
||||
|
||||
// A success latches it: a directory recreated afterwards is left alone.
|
||||
mkdirSync(legacyRoot, { recursive: true })
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
expect(existsSync(legacyRoot)).toBe(true)
|
||||
})
|
||||
|
||||
it('drops inherited shim env and its PATH entry without touching real entries', () => {
|
||||
// Why: a daemon predating the removal reseeds these from its own process.env.
|
||||
const env: Record<string, string> = {
|
||||
PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`,
|
||||
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
|
||||
ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca <help@stably.ai>',
|
||||
ORCA_GH_PR_FOOTER: 'footer',
|
||||
ORCA_GH_ISSUE_FOOTER: 'footer',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/home/u/.orca/orca-terminal-attribution/posix',
|
||||
ORCA_REAL_GIT: '/usr/bin/git',
|
||||
ORCA_REAL_GH: '/usr/bin/gh',
|
||||
HOME: '/home/u'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
|
||||
expect(env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(env.ORCA_REAL_GIT).toBeUndefined()
|
||||
expect(env.ORCA_REAL_GH).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/u')
|
||||
})
|
||||
|
||||
it('strips the Windows shim entry on the inherited Path spelling', () => {
|
||||
const env: Record<string, string> = {
|
||||
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32`
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
|
||||
expect(env.Path).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('matches a re-cased shim entry on case-insensitive filesystems', () => {
|
||||
const env: Record<string, string> = {
|
||||
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\Orca-Terminal-Attribution\\win32;C:\\Windows\\System32`
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
|
||||
expect(env.Path).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('keeps neighbouring directories that merely share the name prefix', () => {
|
||||
const env: Record<string, string> = {
|
||||
PATH: '/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env.PATH).toBe(
|
||||
'/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an unrelated PATH untouched', () => {
|
||||
const env: Record<string, string> = { PATH: '/usr/local/bin:/usr/bin' }
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { resolvePathEnvKey } from './windows-environment-path'
|
||||
|
||||
const LEGACY_SHIM_ROOT_DIR = 'orca-terminal-attribution'
|
||||
const LEGACY_SHIM_ENV_KEYS = [
|
||||
'ORCA_ENABLE_GIT_ATTRIBUTION',
|
||||
'ORCA_GIT_COMMIT_TRAILER',
|
||||
'ORCA_GH_PR_FOOTER',
|
||||
'ORCA_GH_ISSUE_FOOTER',
|
||||
'ORCA_ATTRIBUTION_SHIM_DIR',
|
||||
'ORCA_REAL_GIT',
|
||||
'ORCA_REAL_GH'
|
||||
] as const
|
||||
|
||||
let removed = false
|
||||
|
||||
/** Why: a daemon that outlives an upgrade keeps seeding these from its own process.env
|
||||
* (pty-subprocess reads process.env as the authoritative base), and the wrappers they
|
||||
* point at are gated only on inherited env. Deleting the scripts makes both inert. */
|
||||
export function removeLegacyTerminalShimDir(userDataPath: string): void {
|
||||
if (removed) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: a surviving pre-upgrade pane can hold the wrapper open on Windows; retry like
|
||||
// the other userData removals rather than forfeiting cleanup for the whole run.
|
||||
rmSync(join(userDataPath, LEGACY_SHIM_ROOT_DIR), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 50
|
||||
})
|
||||
removed = true
|
||||
} catch {
|
||||
// Best-effort: a locked file must not block startup, and the next launch retries.
|
||||
}
|
||||
}
|
||||
|
||||
export function isLegacyTerminalShimPathEntry(entry: string): boolean {
|
||||
return entry.replaceAll('\\', '/').toLowerCase().includes(`/${LEGACY_SHIM_ROOT_DIR}/`)
|
||||
}
|
||||
|
||||
export function stripLegacyTerminalShimEnv(
|
||||
env: Record<string, string>,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): void {
|
||||
for (const key of LEGACY_SHIM_ENV_KEYS) {
|
||||
delete env[key]
|
||||
}
|
||||
const pathKey = resolvePathEnvKey(env, platform)
|
||||
const current = env[pathKey]
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const delimiter = platform === 'win32' ? ';' : ':'
|
||||
const cleaned = current
|
||||
.split(delimiter)
|
||||
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
|
||||
.join(delimiter)
|
||||
if (cleaned) {
|
||||
env[pathKey] = cleaned
|
||||
} else {
|
||||
delete env[pathKey]
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: the module-level once-guard would otherwise leak across cases. */
|
||||
export function __resetLegacyTerminalShimRemovalForTests(): void {
|
||||
removed = false
|
||||
}
|
||||
@@ -61,7 +61,7 @@ describe('claude agent teams shim env', () => {
|
||||
expect(plan).toMatchObject({
|
||||
command: "claude --teammate-mode auto 'hello'",
|
||||
env: expect.objectContaining({ TMUX_PANE: '%1' }),
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
envToDelete: ['TERM_PROGRAM']
|
||||
})
|
||||
expect(capturedShimBin).toBe(cliPath)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: {
|
||||
return {
|
||||
command: addClaudeTeammateModeAuto(args.command),
|
||||
env,
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
|
||||
envToDelete: ['TERM_PROGRAM']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export class ClaudeAgentTeamsTmuxDispatcher {
|
||||
direction: splitTarget.direction,
|
||||
command: parsed.positional.join(' ') || undefined,
|
||||
env: paneEnv(team, fakePaneId),
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'],
|
||||
envToDelete: ['TERM_PROGRAM'],
|
||||
activate: false
|
||||
})
|
||||
const pane: TeamPane = {
|
||||
@@ -168,7 +168,7 @@ export class ClaudeAgentTeamsTmuxDispatcher {
|
||||
direction: pane.splitDirection ?? 'horizontal',
|
||||
command,
|
||||
env: paneEnv(team, pane.fakePaneId),
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'],
|
||||
envToDelete: ['TERM_PROGRAM'],
|
||||
activate: false
|
||||
})
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user