diff --git a/.gitattributes b/.gitattributes index 8157af1df2c..de50942d6f2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,7 +19,6 @@ # .version, which names the immutable remote install dir. A CRLF checkout makes a # Windows-built client disagree with a mac/Linux-built one on the same release, # so one host ends up with two relay trees (#17886 review). -/config/relay-assets/** text eol=lf # Pin the bytes so a patch reads and diffs identically on every host. It is NOT # what makes the hash right: pnpm hashes a patch LF-normalized, so a CRLF checkout # cannot change it. Believing otherwise put a hand-computed raw digest in the diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml index 28ece6796e7..e3f952fd7af 100644 --- a/.github/workflows/performance-contracts.yml +++ b/.github/workflows/performance-contracts.yml @@ -22,7 +22,6 @@ on: - 'src/main/codex-usage/scanner-incremental-append.test.ts' - 'src/main/usage/jsonl-file-reader.test.ts' - 'src/main/usage/jsonl-file-snapshot.test.ts' - - 'src/relay/fs-path-metadata-symlink-concurrency.test.ts' - 'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts' - 'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts' - 'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e1b31592aca..68acd38e40d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -503,8 +503,7 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/shared/git-binary-compatibility.test.ts \ src/main/git/worktree-safety-real-git.test.ts \ - src/main/git/worktree-rebase-update-refs-real-git.test.ts \ - src/relay/git-review-draft-binary-compatibility.test.ts + src/main/git/worktree-rebase-update-refs-real-git.test.ts ) & pids+=("$!") @@ -516,8 +515,7 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/shared/git-binary-compatibility.test.ts \ src/main/git/worktree-safety-real-git.test.ts \ - src/main/git/worktree-rebase-update-refs-real-git.test.ts \ - src/relay/git-review-draft-binary-compatibility.test.ts + src/main/git/worktree-rebase-update-refs-real-git.test.ts ) & pids+=("$!") done @@ -792,7 +790,6 @@ jobs: src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ @@ -969,17 +966,11 @@ jobs: - uses: ./.github/actions/install-node-dependencies - - name: Build relay companions - run: pnpm run build:relay - - name: Setup Node 18 runtime uses: actions/setup-node@v6 with: node-version: '18' - - name: Smoke managed-hook companions - run: node config/scripts/smoke-managed-hook-runtime-node18.mjs - # The process-host package declares engines >=18; load its staged public output on Node 18. - name: Smoke process-host package run: node config/scripts/smoke-process-host-node18.mjs @@ -1270,9 +1261,7 @@ jobs: src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts src/main/ipc/preflight-provider-command-selection.test.ts src/main/ipc/preflight-runnable-local-cli.test.ts - src/relay/windows-port-scan.win32.test.ts src/main/ssh/remote-node-runtime-store-windows.test.ts - config/scripts/node-pty-windows-pty-teardown-patch.test.mjs # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index d6282a804a0..174b2e3c011 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1520,12 +1520,6 @@ jobs: node config/scripts/runtime-file-watcher-fault-harness.mjs ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs - - name: Gate SSH relay watcher process isolation - run: | - # Why: the remote native watcher shares a daemon with live PTYs. - # Kill only its child and require both PTY and watch recovery before packaging. - node config/scripts/relay-watcher-fault-harness.mjs - # Why: main ships minified with sourcemap:'hidden' and packaging drops # out/**/*.map from app.asar, so a crash trace from a released build is # otherwise undecodable. The main bundle is platform-independent, so one diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 22558411c8c..ee8a183f9e4 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -195,12 +195,6 @@ jobs: node config/scripts/runtime-file-watcher-fault-harness.mjs ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs - - name: Gate SSH relay watcher process isolation - run: | - # Why: the remote native watcher shares a daemon with live PTYs. - # Kill only its child and require both PTY and watch recovery before packaging. - node config/scripts/relay-watcher-fault-harness.mjs - - name: Abort if the parent release-cut run was cancelled # Why: this workflow is dispatched separately, so cancelling release-cut # does not stop mac `--publish always`. A cancelled parent left v1.4.206 diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index 9ca33af2d4d..368a97ee287 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -16,7 +16,6 @@ on: - 'src/main/ssh/ssh-remote-*' - 'src/main/ssh/ssh-hostile-host-*' - 'src/main/ssh/sftp-*' - - 'src/relay/**' - 'src/wsl-guest/**' - 'src/shared/node-runtime-pin.ts' - 'src/shared/orcad-artifacts.ts' diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 21ca046fc59..e70fd5a8d6e 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -17,7 +17,6 @@ on: - 'src/main/ssh/ssh-hostile-host-*' - 'src/main/ssh/ssh-windows-host-*' - 'src/main/ssh/ssh-session-command-audit.ts' - - 'src/relay/**' - 'src/wsl-guest/**' - 'src/packages/process-host/**' - 'pnpm-workspace.yaml' @@ -35,7 +34,6 @@ on: - 'config/patches/node-pty*' - 'config/patches/@vscode__windows-process-tree*' - 'config/scripts/build-windows-process-tree-relay-addon.mjs' - - 'config/scripts/relay-windows-process-tree-staging.mjs' - 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs' - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' - 'src/shared/relay-windows-breakaway-launch.ts' diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 index 23f750ee8ea..d9ebbf1f19d 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -92,7 +92,6 @@ function Invoke-AppCell($Account,[string]$Descriptor,[string]$Log,[string]$Spec, New-Item -ItemType Directory -Force -Path $ReceiptRoot | Out-Null Push-Location $SourceRoot try { - if(-not (Test-Path -LiteralPath "out\relay\$Target\relay.js")){throw 'Build the relay before provisioning'} if(-not (Test-Path -LiteralPath 'out\orcad-template')){throw 'Build the orcad template before provisioning'} # ssh2 consults the runner's known_hosts: pin only this private endpoint, restored below. New-Item -ItemType Directory -Force -Path (Split-Path $knownPath) | Out-Null diff --git a/config/knip.json b/config/knip.json index 51212970751..8bb86d8d261 100644 --- a/config/knip.json +++ b/config/knip.json @@ -20,7 +20,6 @@ "src/renderer/src/web/main.tsx", "src/renderer/src/**/*.worker.ts", "src/cli/index.ts", - "src/relay/relay.ts", "src/wsl-guest/wsl-agent-hook-relay.ts", "config/scripts/**/*.{mjs,cjs,js,ts}", "config/build-plugins/**/*.ts", diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 33aae842954..5e8ac38c890 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -5,5 +5,4 @@ inline src/main/ipc/worktree-remote.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts -inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 8818c19ca4b..b85ebb00efa 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -121,16 +121,6 @@ src/main/wsl-availability.ts src/main/wsl-unc-delete.ts src/main/wsl.ts src/main/zsh-startup-hook-pty-harness.ts -src/relay/agent-exec-handler.ts -src/relay/ai-vault-service-client-state.ts -src/relay/ai-vault-service-client.ts -src/relay/ai-vault-service-spawn.ts -src/relay/external-automations-handler.ts -src/relay/fs-handler-git-fallback.ts -src/relay/fs-handler-list-files.ts -src/relay/pty-shell-utils.ts -src/relay/subprocess-tree-termination.ts -src/relay/workspace-space-scan.ts src/shared/ephemeral-vm-recipe-process.ts src/shared/ephemeral-vm-recipe-runner.ts src/shared/fish-binary-requirement.ts diff --git a/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs b/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs deleted file mode 100644 index 051ea13cc46..00000000000 --- a/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs +++ /dev/null @@ -1,71 +0,0 @@ -const { createHash } = require('node:crypto') -const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs') -const { join, resolve } = require('node:path') - -const EXPECTED_NODE_PTY_VERSION = '1.1.0' -const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52' -const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857' -const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);' -const PATCHED_BODY = `var consoleProcessList; -try { - consoleProcessList = getConsoleProcessList(shellPid); -} -catch (_a) { - // Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately. - consoleProcessList = [shellPid]; -}` - -function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) { - const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty') - const packageJsonPath = join(nodePtyDir, 'package.json') - const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js') - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) - if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) { - throw new Error( - `Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}` - ) - } - const source = readFileSync(agentPath, 'utf8') - return { agentPath, source } -} - -function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) { - const inspected = inspectNodePtyConsoleListAgent(relayDir) - if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) { - throw new Error('node-pty ConPTY console-list fallback is not installed') - } -} - -function patchNodePtyConsoleListAgent(relayDir = process.cwd()) { - const inspected = inspectNodePtyConsoleListAgent(relayDir) - const sourceHash = sourceSha256(inspected.source) - if (sourceHash === PATCHED_SOURCE_SHA256) { - return - } - if (sourceHash !== ORIGINAL_SOURCE_SHA256) { - throw new Error('Refusing to patch unexpected node-pty console-list agent source') - } - const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY) - const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}` - // Why: a terminated remote install must leave either known source version recoverable on reconnect. - try { - writeFileSync(temporaryPath, patchedSource) - renameSync(temporaryPath, inspected.agentPath) - } finally { - rmSync(temporaryPath, { force: true }) - } - assertPatchedNodePtyConsoleListAgent(relayDir) -} - -function sourceSha256(source) { - return createHash('sha256').update(source).digest('hex') -} - -if (require.main === module) { - patchNodePtyConsoleListAgent() -} - -module.exports = { - assertPatchedNodePtyConsoleListAgent, - patchNodePtyConsoleListAgent -} diff --git a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs deleted file mode 100644 index 40bc0350d86..00000000000 --- a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Relay-side pty fd-leak patch for node-pty 1.1.0 (#17915). - * - * The app gets this through pnpm `patchedDependencies`; the relay installs stock - * node-pty from npm onto the host, where no pnpm patch reaches. Stock 1.1.0 leaks - * a pty fd on both Unix relay platforms, by two unrelated bugs on two code paths. - * - * Linux takes forkpty(), which has no atomic O_CLOEXEC, so every later child of - * the relay -- pty children, git helpers, probes, agent CLIs -- inherits each live - * master and keeps its /dev/pts device alive for the life of the relay (#8362). - * - * macOS takes pty_posix_spawn(), which opens up to three throwaway ptys to push - * the real master off fds 0-2 and then never closes them: the cleanup loop is - * `for (; count > 0; count--)`, but in any running process the first posix_openpt() - * already returns >= 2, so the loop breaks with count == 0 and its body never runs - * -- and where it does run it closes low_fds[count], never low_fds[0]. Measured on - * darwin-arm64: one orphaned /dev/ptmx fd per terminal, never returned. - * - * macOS does not inherit the master into spawned children today, but not because it - * is marked: FD_CLOEXEC is not set on it (`lsof +fg` shows R,W,NB, no CX). What - * closes it is POSIX_SPAWN_CLOEXEC_DEFAULT in pty_posix_spawn's spawn flags, an - * Apple-only flag that closes every fd in the child. That is one option away from - * gone -- setting uid/gid drops libuv back to fork()/exec(), which honors nothing - * but FD_CLOEXEC -- so the master is marked on the Apple path too, exactly as the - * app's pnpm patch marks it. Windows has no fds and is excluded. - * - * The compile it buys differs by platform. Linux relays already run node-gyp at - * install time (1.1.0 ships no linux prebuild), so this is a second compile on a - * path that already compiles. macOS runs the shipped darwin prebuild and has no - * build/ at all, so this is its first compile -- the price of the only fix there - * is, since the bug is in the source that prebuild was built from. - * - * Non-fatal by construction: the working build is moved aside before anything is - * touched and moved back on any failure, and a failed attempt drops a skip marker - * so the compile is attempted at most once per relay directory. - */ - -const { spawnSync } = require('node:child_process') -const { createHash } = require('node:crypto') -const { - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync -} = require('node:fs') -const { dirname, join, resolve } = require('node:path') - -const EXPECTED_NODE_PTY_VERSION = '1.1.0' -const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6' -const PATCHED_SOURCE_SHA256 = '3e6bc1a688aae187d231687130cfc0a11781c672f5f616d73183d471ee8ee65c' - -const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' -const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip' -const BACKUP_DIRNAME = '.orca-cloexec-prepatch-release' -// Under the caller's 240s SSH command timeout, so the rollback below still runs. -const REBUILD_TIMEOUT_MS = 200000 -const VERIFY_TIMEOUT_MS = 15000 - -const FORWARD_DECLARATION = [ - 'static int\npty_nonblock(int);\n', - 'static int\npty_nonblock(int);\n\nstatic int\npty_cloexec(int);\n' -] - -const DEFINITION = [ - `static int -pty_nonblock(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - if (flags == -1) return -1; - return fcntl(fd, F_SETFL, flags | O_NONBLOCK); -} -`, - `static int -pty_nonblock(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - if (flags == -1) return -1; - return fcntl(fd, F_SETFL, flags | O_NONBLOCK); -} - -/** - * Orca: close-on-exec FD - * - * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without - * FD_CLOEXEC is inherited by every later child of this process -- including - * later pty children -- which keeps its /dev/pts device and buffers alive long - * after its own session ends (#8362). - */ - -static int -pty_cloexec(int fd) { - int flags = fcntl(fd, F_GETFD); - if (flags == -1) return -1; - if (flags & FD_CLOEXEC) return 0; - return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); -} -` -] - -const FORKPTY_CALL_SITE = [ - ` default: - if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } - } -`, - ` default: - if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } - if (pty_cloexec(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); - } - } -` -] - -// Apple never reaches FORKPTY_CALL_SITE: `default:` sits in the `#else` arm of PtyFork's -// `#if defined(__APPLE__)`, so before this pair the asset patched nothing macOS executes. -const POSIX_SPAWN_CALL_SITE = [ - ` if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } -#else -`, - ` if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } - if (pty_cloexec(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); - } -#else -` -] - -// The throwaway ptys pty_posix_spawn opens to keep the real master off fds 0-2. Byte-identical to -// the app's pnpm patch, so both trees compile the same cleanup. -const LOW_FDS_DECLARATION = [ - ` int low_fds[3]; - size_t count = 0; -`, - ` int low_fds[3] = {-1, -1, -1}; - size_t count = 0; -` -] - -const LOW_FDS_CLEANUP = [ - ` for (; count > 0; count--) { - close(low_fds[count]); - } -`, - ` for (size_t i = 0; i <= count && i < 3; i++) { - if (low_fds[i] != -1) { - close(low_fds[i]); - } - } -` -] - -const REPLACEMENTS = [ - FORWARD_DECLARATION, - DEFINITION, - POSIX_SPAWN_CALL_SITE, - FORKPTY_CALL_SITE, - LOW_FDS_DECLARATION, - LOW_FDS_CLEANUP -] - -function sourceSha256(source) { - return createHash('sha256').update(source).digest('hex') -} - -function nodePtyDir(relayDir) { - return resolve(relayDir, 'node_modules', 'node-pty') -} - -function inspectNodePtyUnixSource(relayDir) { - const ptyDir = nodePtyDir(relayDir) - const sourcePath = join(ptyDir, 'src', 'unix', 'pty.cc') - const version = JSON.parse(readFileSync(join(ptyDir, 'package.json'), 'utf8')).version - if (version !== EXPECTED_NODE_PTY_VERSION) { - throw new Error(`Refusing to patch node-pty ${version}; expected ${EXPECTED_NODE_PTY_VERSION}`) - } - return { ptyDir, sourcePath, source: readFileSync(sourcePath, 'utf8') } -} - -function writeSourceAtomically(sourcePath, contents) { - const temporaryPath = `${sourcePath}.orca-patch-${process.pid}` - // Why: a terminated install must leave one of the two known source versions on disk. - try { - writeFileSync(temporaryPath, contents) - renameSync(temporaryPath, sourcePath) - } finally { - rmSync(temporaryPath, { force: true }) - } -} - -function rewriteSource(source, reverse) { - let rewritten = source - for (const [original, patched] of REPLACEMENTS) { - const from = reverse ? patched : original - const to = reverse ? original : patched - if (rewritten.split(from).length - 1 !== 1) { - throw new Error('Refusing to rewrite unexpected node-pty pty.cc source') - } - rewritten = rewritten.replace(from, to) - } - return rewritten -} - -/** True when the patch was applied, false when it was already installed. */ -function patchNodePtyMasterCloexecSource(relayDir = process.cwd()) { - const inspected = inspectNodePtyUnixSource(relayDir) - const hash = sourceSha256(inspected.source) - if (hash === PATCHED_SOURCE_SHA256) { - return false - } - if (hash !== ORIGINAL_SOURCE_SHA256) { - throw new Error('Refusing to patch unexpected node-pty pty.cc source') - } - writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, false)) - assertPatchedNodePtyMasterCloexecSource(relayDir) - return true -} - -function assertPatchedNodePtyMasterCloexecSource(relayDir = process.cwd()) { - const inspected = inspectNodePtyUnixSource(relayDir) - if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) { - throw new Error('node-pty pty master close-on-exec patch is not installed') - } -} - -function revertNodePtyMasterCloexecSource(relayDir = process.cwd()) { - const inspected = inspectNodePtyUnixSource(relayDir) - if (sourceSha256(inspected.source) === ORIGINAL_SOURCE_SHA256) { - return false - } - writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, true)) - return true -} - -function rebuildNodePty(relayDir) { - const result = spawnSync('npm', ['rebuild', '--ignore-scripts=false', 'node-pty'], { - cwd: relayDir, - encoding: 'utf8', - timeout: REBUILD_TIMEOUT_MS, - windowsHide: true - }) - if (result.error) { - throw new Error(`npm rebuild node-pty failed: ${result.error.message}`) - } - if (result.status !== 0) { - const tail = `${result.stdout || ''}${result.stderr || ''}`.trim().slice(-300) - throw new Error(`npm rebuild node-pty exited ${result.status ?? result.signal}: ${tail}`) - } -} - -// Why a child, for both scripts below: a bad build can abort the process on require, which would -// strand the moved-aside working build. Why each ends in a reachability check: a host that cannot -// show its fds says nothing, and an unobservable flag is not evidence the rebuild was wrong. -// -// Linux's leak is inheritance, so the observation is a later plain child's /proc/self/fd. -const VERIFY_INHERITANCE_SCRIPT = ` -const pty = require(process.argv[1]); -const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env -}); -const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l /proc/self/fd'], { encoding: 'utf8' }); -try { term.kill() } catch {} -const listing = probe.stdout || ''; -if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) } -console.log(listing.includes('ptmx') ? 'LEAKED' : 'ISOLATED'); -process.exit(0); -` - -// Apple's leak is self-held, not inherited, so the observation is this process's own fd table: -// N live ptys must account for exactly N /dev/ptmx rows. A stock build shows 2N -- the master plus -// the throwaway pty_posix_spawn opened and never closed. lsof, not /proc, because macOS has no -// /proc; a host without lsof cannot say, which is 'unverified', not a failed patch. -const VERIFY_SELF_FDS_SCRIPT = ` -const pty = require(process.argv[1]); -const terms = []; -for (let i = 0; i < 3; i++) { - terms.push(pty.spawn('/bin/sh', ['-c', 'sleep 30'], { - name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env - })); -} -const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'lsof -p ' + process.pid], { encoding: 'utf8', maxBuffer: 1 << 24 }); -for (const term of terms) { try { term.kill() } catch {} } -const rows = (probe.stdout || '').split('\\n').filter((line) => line.includes('/dev/ptmx')); -if (probe.status !== 0 || rows.length < terms.length) { console.log('UNVERIFIED'); process.exit(0) } -console.log(rows.length > terms.length ? 'LEAKED' : 'ISOLATED'); -process.exit(0); -` - -const LEAK_MESSAGE = { - darwin: 'rebuilt node-pty still leaks a throwaway pty fd per spawn', - linux: 'rebuilt node-pty still leaks the pty master into later children' -} - -/** 'isolated' when the platform's leak is gone, 'unverified' when the host cannot show it. */ -function verifyNoPtyFdLeak(relayDir, platform) { - const script = platform === 'darwin' ? VERIFY_SELF_FDS_SCRIPT : VERIFY_INHERITANCE_SCRIPT - const result = spawnSync(process.execPath, ['-e', script, nodePtyDir(relayDir)], { - cwd: relayDir, - encoding: 'utf8', - timeout: VERIFY_TIMEOUT_MS, - windowsHide: true - }) - const output = `${result.stdout || ''}` - if (result.status !== 0 || result.error) { - const tail = `${output}${result.stderr || ''}`.trim().slice(-300) - throw new Error( - `rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}` - ) - } - if (output.includes('LEAKED')) { - throw new Error(LEAK_MESSAGE[platform] || LEAK_MESSAGE.linux) - } - return output.includes('ISOLATED') ? 'isolated' : 'unverified' -} - -/** - * What gets moved aside before the compile, and where the compile writes. - * - * Linux ships no prebuild, so `build/Release` is both the working build and the compile's output, - * and moving it aside only arms the rollback. macOS runs `prebuilds/darwin-` and has no - * `build/` at all, so the compile writes a new `build/Release` -- which node-pty's loader checks - * ahead of `prebuilds`. Moving `prebuilds` aside does double duty there: it arms the rollback and - * it is what makes node-pty's install script fall through from "prebuild found" to `node-gyp - * rebuild`. Deliberately not `npm_config_build_from_source`, which deletes the prebuilds outright - * and would leave nothing to roll back to. - */ -function buildLayout(relayDir, platform, arch) { - const ptyDir = nodePtyDir(relayDir) - const compiledDir = join(ptyDir, 'build', 'Release') - if (platform === 'darwin') { - const prebuildsDir = join(ptyDir, 'prebuilds') - return { - compiledDir, - movedDir: prebuildsDir, - workingBuildPath: join(prebuildsDir, `darwin-${arch}`, 'pty.node'), - missingStatus: 'skipped:no-prebuild' - } - } - return { - compiledDir, - movedDir: compiledDir, - workingBuildPath: join(compiledDir, 'pty.node'), - missingStatus: 'skipped:no-compiled-build' - } -} - -function rollback(relayDir, layout, backupDir) { - rmSync(layout.compiledDir, { recursive: true, force: true }) - try { - revertNodePtyMasterCloexecSource(relayDir) - } catch { - // The build that is about to be restored predates the patch either way. - } - if (existsSync(backupDir)) { - mkdirSync(dirname(layout.movedDir), { recursive: true }) - renameSync(backupDir, layout.movedDir) - } -} - -/** - * Patch and rebuild the host's node-pty, or leave it exactly as found. - * Never throws: the caller is on the connect path and a leaky relay beats no relay. - */ -function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) { - const platform = options.platform || process.platform - const arch = options.arch || process.arch - const rebuild = options.rebuild || rebuildNodePty - const verify = options.verify || verifyNoPtyFdLeak - if (platform !== 'linux' && platform !== 'darwin') { - return 'skipped:unsupported-platform' - } - const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME) - if (existsSync(skipMarkerPath)) { - return 'skipped:earlier-attempt-failed' - } - const layout = buildLayout(relayDir, platform, arch) - const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME) - // A backup stranded by a connection that died mid-rebuild is stale by definition: - // whatever repaired node-pty since built from the source now on disk. - rmSync(backupDir, { recursive: true, force: true }) - - let inspected - try { - inspected = inspectNodePtyUnixSource(relayDir) - } catch (err) { - return `skipped:${err.message}` - } - const hash = sourceSha256(inspected.source) - if (hash === PATCHED_SOURCE_SHA256) { - return 'already-patched' - } - if (hash !== ORIGINAL_SOURCE_SHA256) { - return 'skipped:unexpected-source' - } - // Nothing to fall back on means the host runs neither a compile nor the prebuild - // this platform expects; rebuilding could only take away the artifact the probe - // just proved loadable. - if (!existsSync(layout.workingBuildPath)) { - return layout.missingStatus - } - - try { - renameSync(layout.movedDir, backupDir) - } catch (err) { - return `skipped:${err.message}` - } - try { - patchNodePtyMasterCloexecSource(relayDir) - rebuild(relayDir) - const verdict = verify(relayDir, platform) - // Discarded, not restored: a tree that gets published must hold no unpatched binary the - // loader could still fall back to. A later repair recompiles from the patched source. - rmSync(backupDir, { recursive: true, force: true }) - return verdict === 'isolated' ? 'patched' : 'patched-unverified' - } catch (err) { - rollback(relayDir, layout, backupDir) - // Bounded on purpose: one compile attempt per relay directory, never a retry loop. - try { - writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`) - } catch { - // A relay dir we cannot write to will fail the cheap checks above next time anyway. - } - return `failed:${err.message}` - } -} - -if (require.main === module) { - console.log(`${STATUS_PREFIX}${applyNodePtyMasterCloexecPatch()}`) -} - -module.exports = { - EXPECTED_NODE_PTY_VERSION, - ORIGINAL_SOURCE_SHA256, - PATCHED_SOURCE_SHA256, - SKIP_MARKER_FILENAME, - STATUS_PREFIX, - applyNodePtyMasterCloexecPatch, - assertPatchedNodePtyMasterCloexecSource, - patchNodePtyMasterCloexecSource, - revertNodePtyMasterCloexecSource -} diff --git a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs deleted file mode 100644 index 4c7b5b9939d..00000000000 --- a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs +++ /dev/null @@ -1,362 +0,0 @@ -const { createHash } = require('node:crypto') -const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs') -const { join, resolve } = require('node:path') - -/** - * Release the ConPTY teardown handles a relay's npm-installed node-pty never releases. - * - * Two files, and the ORDER of one of the edits is the whole fix. - * - * `windowsPtyAgent.js` -- `kill()` flips `readable` on both sockets and destroys neither. - * `_cleanUpProcess` destroys `_outSocket`, so the conout handle comes back; nothing ever destroys - * `_inSocket`, and it wraps a real Windows named-pipe handle from `fs.openSync(term.conin, 'w')`. - * Every terminal leaks one File handle for the life of the host process. - * - * The obvious fix -- and the placement `config/patches/node-pty@1.1.0.patch` uses -- releases it at - * the TOP of the branch, before `_getConsoleProcessList()` forks and before the native kill. That is - * measurably worse than leaving the leak alone: teardown aborts partway, the forked console-list - * agent is never reaped, and both pipe handles stay alive instead of one. This asset releases it at - * the END of the branch instead, after the fork and the kill have already happened. - * - * Measured on a Windows SSH host, 20 spawn/kill cycles, handles bucketed by NT object type - * (identical numbers standalone and through a real relay). Every row is the NON-DLL branch, which - * is the branch a relay runs -- see the divergence note below for why that matters: - * - * published node-pty File +1/terminal, Process flat - * desktop patch placement File +2/terminal, Process +1/terminal <-- 3x WORSE - * released last (here) File flat, Process flat - * - * `windowsTerminal.js` carries the desktop's error-listener hunks verbatim. The conin listener is - * what keeps a pipe error retiring one terminal instead of the host -- its own comment names the - * failure mode: "Without a listener, Node promotes errors such as write EAGAIN to uncaughtException". - * It is not what fixes the leak (adding it changed nothing on its own), but it is the guard that - * makes destroying conin safe at all. - * - * Why this ships as a relay asset rather than only in config/patches/node-pty@1.1.0.patch: pnpm - * patches do not cross the SSH boundary -- a relay host runs the tree `npm install` put there. - * - * DELIBERATE DIVERGENCE FROM THE DESKTOP, AND WHY IT IS NOT A DESKTOP-TERMINAL BUG: the two hosts - * do not run the same branch of `kill()`. node-pty defaults `_useConptyDll` to false - * (`windowsPtyAgent.js`). Every desktop site that opens a terminal pane sets it true -- - * `local-pty-utils.ts` (two) and `native-pty-spawn.ts` -- as does the `windows-conpty-warmup.ts` - * warm-up, so all of those take the `else` branch, where UPSTREAM ALREADY destroys the input - * socket. The relay passes no such option (`src/relay/pty-handler.ts`), so it takes the - * `!useConptyDll` branch -- the one this asset and the desktop patch both edit. - * - * THE DESKTOP IS NOT ENTIRELY OFF THAT BRANCH. Two desktop sites omit the option and so run it - * too: the hidden rate-limit probes in `src/main/rate-limits/claude-pty.ts` and - * `codex-pty-rate-limit-probe.ts`. Both recur -- their fetchers poll -- and both tear down through - * `kill()`, so this hunk is live on the desktop, just never for a pane a user can see. Do not - * restate this as "the desktop never executes that branch": that sentence stood here for two - * revisions and is false. - * - * What the numbers above therefore do NOT cover: they were measured on relay-style spawn/kill - * cycles. Whether the early placement costs the same +2 File / +1 Process across a probe's - * lifecycle is UNMEASURED -- plausible, not established, and worth measuring before anyone quotes - * a desktop figure. What IS settled is the claim this comment replaced: that the desktop patch made - * every Windows user worse off ON EVERY TERMINAL. Terminals take the DLL branch, and the harness - * that produced that claim defaulted into the branch it was not trying to measure. - * - * The divergence is therefore about which branch each host runs for the workload that matters, not - * about a regression in the terminals users open. The test still pins it, because a future "sync - * the patches" would put the early placement onto the relay's branch, where it does cost +2 File - * and +1 Process per terminal. - * - * If you extend this enumeration, grep for `node-pty` rather than for a static import: those two - * probes were missed three times because they use `await import('node-pty')`. - * - * THE SELF-EXIT LEAK: FIXED FOR THE DESKTOP BY #18635, STILL LIVE ON A RELAY. A terminal that exits - * on its own is also torn down through `kill()` -- both hosts call `destroy()` on natural exit and - * `WindowsTerminal.destroy()` is `kill()` -- but the shell is already gone by then, so the ordering - * this asset relies on does not hold. Measured over 20 self-exit cycles on the NON-DLL branch: - * published +3 File/+1 Process per terminal, desktop patch placement +2/+1, this tree +2/+1. This - * asset does not close it. - * - * #18635 does, in `config/patches/node-pty@1.1.0.patch`: the baton outlives the shell so `PtyKill` - * still reaches `ClosePseudoConsole`, plus an unconditional conout dispose on the DLL branch. That - * fix does not reach a Windows relay, and no hunk in THIS file can carry it, because it is mostly - * NATIVE (`src/win/conpty.cc`) and this asset only rewrites `lib/*.js`. Three delivery paths exist - * and none currently covers Windows: - * - * - the pnpm patch does not cross the SSH boundary -- the remote `npm install` yields upstream's - * unpatched node-pty; - * - the orcad prebuild matrix now compiles win32 slots from patched source - * (`config/scripts/build-orcad-prebuilds.mjs`), but only standalone orcad consumes them; - * nothing ships them to a Windows relay host yet; - * - a relay asset CAN patch native source and rebuild on the host -- that is exactly what - * `node-pty-1.1.0-master-cloexec-patch.cjs` does -- but it returns - * `skipped:unsupported-platform` for anything but linux/darwin. Extending it to win32 means - * requiring an MSVC toolchain on the relay host, a far heavier precondition than on Linux, - * where node-gyp already runs at install time. - * - * So a Windows SSH relay still leaks a pseudoconsole per self-exiting terminal, and closing it is a - * DELIVERY problem, not another hunk here. Do not read #18635's flat self-exit relay numbers as - * covering deployed relays: they were measured against a locally rebuilt binary, so they describe - * the relay CODE PATH on a patched tree, not the tree a relay host actually installs. - */ - -const EXPECTED_NODE_PTY_VERSION = '1.1.0' - -const WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS = [ - [ - ' var parsedEnv = _this._parseEnv(env);\n // If the terminal is ready\n _this._isReady = false;\n // Functions that need to run after `ready` event is emitted.\n _this._deferreds = [];\n // Create new termal.\n', - ' var parsedEnv = _this._parseEnv(env);\n // If the terminal is ready\n _this._isReady = false;\n _this._killRequested = false;\n _this._killComplete = false;\n _this._isPipeReady = false;\n // Functions that need to run after `ready` event is emitted.\n _this._deferreds = [];\n // Create new termal.\n' - ], - [ - " _this._pid = _this._agent.innerPid;\n _this._fd = _this._agent.fd;\n _this._pty = _this._agent.pty;\n // The forked windows terminal is not available until `ready` event is\n // emitted.\n _this._socket.on('ready_datapipe', function () {\n // Run deferreds and set ready state once the first data event is received.\n _this._socket.once('data', function () {\n // Wait until the first data event is fired then we can run deferreds.\n if (!_this._isReady) {\n // Terminal is now ready and we can avoid having to defer method\n // calls.\n _this._isReady = true;\n", - " _this._pid = _this._agent.innerPid;\n _this._fd = _this._agent.fd;\n _this._pty = _this._agent.pty;\n // A pre-output teardown must still publish the actual pipe close.\n _this._socket.once('close', function () {\n if (_this._isPipeReady) {\n _this.emit('exit', _this._agent.exitCode);\n }\n _this._close();\n });\n // The forked windows terminal is not available until `ready` event is\n // emitted.\n _this._socket.on('ready_datapipe', function () {\n _this._isPipeReady = true;\n if (_this._killRequested) {\n _this.kill();\n return;\n }\n // Run deferreds and set ready state once the first data event is received.\n _this._socket.once('data', function () {\n // Wait until the first data event is fired then we can run deferreds.\n if (!_this._isReady && !_this._killRequested) {\n // Terminal is now ready and we can avoid having to defer method\n // calls.\n _this._isReady = true;\n" - ], - [ - " _this._deferreds = [];\n }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {\n _this.emit('exit', _this._agent.exitCode);\n _this._close();\n });\n });\n _this._file = file;\n _this._name = name;\n", - ' _this._deferreds = [];\n }\n });\n });\n _this._file = file;\n _this._name = name;\n' - ], - [ - " });\n };\n WindowsTerminal.prototype.destroy = function () {\n var _this = this;\n this._deferNoArgs(function () {\n _this.kill();\n });\n };\n WindowsTerminal.prototype.kill = function (signal) {\n var _this = this;\n this._deferNoArgs(function () {\n if (signal) {\n throw new Error('Signals not supported on windows.');\n }\n _this._close();\n _this._agent.kill();\n });\n };\n WindowsTerminal.prototype._deferNoArgs = function (deferredFn) {\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n", - " });\n };\n WindowsTerminal.prototype.destroy = function () {\n this.kill();\n };\n WindowsTerminal.prototype.kill = function (signal) {\n if (signal) {\n throw new Error('Signals not supported on windows.');\n }\n // Retire input now; native close requires the forwarding pipe, not first output.\n this._killRequested = true;\n this._deferreds = [];\n this._close();\n if (!this._isPipeReady || this._killComplete) {\n return;\n }\n this._agent.kill();\n this._killComplete = true;\n };\n WindowsTerminal.prototype._deferNoArgs = function (deferredFn) {\n if (this._killRequested) {\n return;\n }\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n" - ], - [ - ' });\n };\n WindowsTerminal.prototype._defer = function (deferredFn, arg) {\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n', - ' });\n };\n WindowsTerminal.prototype._defer = function (deferredFn, arg) {\n if (this._killRequested) {\n return;\n }\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n' - ] -] - -const PRECONNECT_AGENT_REPLACEMENTS = [ - [ - 'var utils_1 = require("./utils");', - 'var utils_1 = require("./utils");\nvar eventEmitter2_1 = require("./eventEmitter2");' - ], - [ - ' this._innerPid = 0;', - ' this._innerPid = 0;\n this._onProcessExit = new eventEmitter2_1.EventEmitter2();' - ], - [ - ' WindowsPtyAgent.prototype.kill = function () {', - ' Object.defineProperty(WindowsPtyAgent.prototype, "onProcessExit", {\n get: function () { return this._onProcessExit.event; },\n enumerable: false,\n configurable: true\n });\n Object.defineProperty(WindowsPtyAgent.prototype, "isConpty", {\n get: function () { return this._useConpty === true; },\n enumerable: false,\n configurable: true\n });\n WindowsPtyAgent.prototype.killAfterOutputClosed = function () {\n var _this = this;\n return this._conoutSocketWorker.disposeImmediately().then(function () { return _this.kill(); });\n };\n WindowsPtyAgent.prototype.kill = function () {' - ], - [ - " this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });\n }\n };", - " this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });\n }\n this._onProcessExit.fire(exitCode);\n };" - ] -] - -const PRECONNECT_TERMINAL_REPLACEMENTS = [ - [ - ' _this._isPipeReady = false;', - ' _this._isPipeReady = false;\n _this._exitEmitted = false;\n _this._preconnectCleanupRequested = false;' - ], - [ - " // A pre-output teardown must still publish the actual pipe close.\n _this._socket.once('close', function () {\n if (_this._isPipeReady) {\n _this.emit('exit', _this._agent.exitCode);\n }\n _this._close();\n });", - " _this._agent.onProcessExit(function () {\n if (_this._killRequested) _this._emitExit();\n });\n _this._socket.once('close', function () {\n if (_this._isPipeReady && !_this._preconnectCleanupRequested && (!_this._killRequested || !_this._agent.isConpty || _this._agent.exitCode !== undefined)) _this._emitExit();\n _this._close();\n if (_this._killRequested && !_this._isPipeReady) _this._killAfterOutputClosed();\n });" - ], - [ - ' if (!this._isPipeReady || this._killComplete) {\n return;\n }', - ' if (this._killComplete || this._preconnectCleanup) return;\n if (this._preconnectCleanupRequested || (!this._isPipeReady && this._socket.destroyed)) {\n this._killAfterOutputClosed();\n return;\n }\n if (!this._isPipeReady) return;' - ], - [ - ' WindowsTerminal.prototype._deferNoArgs =', - " WindowsTerminal.prototype._emitExit = function () {\n if (this._exitEmitted) return;\n this._exitEmitted = true;\n this.emit('exit', this._agent.exitCode);\n };\n WindowsTerminal.prototype._killAfterOutputClosed = function () {\n var _this = this;\n if (!this._agent.isConpty || this._killComplete || this._preconnectCleanup) return;\n this._preconnectCleanupRequested = true;\n this._preconnectCleanup = this._agent.killAfterOutputClosed().then(function () {\n _this._killComplete = true;\n if (_this._agent.exitCode !== undefined) _this._emitExit();\n }, function (error) {\n _this._preconnectCleanup = undefined;\n if (_this.listeners('error').length > 1) {\n _this.emit('error', error);\n }\n });\n if (this._agent.exitCode !== undefined) this._emitExit();\n };\n WindowsTerminal.prototype._deferNoArgs =" - ] -] - -const PRECONNECT_CONOUT_REPLACEMENTS = [ - [ - ' case 1 /* READY */:\n _this._onReady.fire();', - ' case 1 /* READY */:\n if (_this._termination) return;\n _this._onReady.fire();' - ], - [ - ' ConoutConnection.prototype.connectSocket = function (socket) {\n socket.connect', - ' ConoutConnection.prototype.disposeImmediately = function () {\n this._isDisposed = true;\n if (this._drainTimeout) clearTimeout(this._drainTimeout);\n return this._destroySocket();\n };\n ConoutConnection.prototype.connectSocket = function (socket) {\n if (this._termination) return;\n socket.connect' - ], - [ - ' ConoutConnection.prototype._destroySocket = function () {\n return __awaiter(this, void 0, void 0, function () {\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0: return [4 /*yield*/, this._worker.terminate()];\n case 1:\n _a.sent();\n return [2 /*return*/];\n }\n });\n });\n };\n', - ' ConoutConnection.prototype._destroySocket = function () {\n if (!this._termination) {\n this._termination = this._worker.terminate().then(function () { return undefined; });\n }\n return this._termination;\n };\n' - ] -] - -/** Each entry is one published file, its patched form, and the edits between them. */ -const PREVIOUS_PATCH_TARGETS = [ - { - relativePath: ['lib', 'windowsConoutConnection.js'], - originalSha256: '1440f70908fb1f55911ac8e936a1230f68a9c00c03096fcef6788eac6aad9d62', - patchedSha256: '37fab0688764326444509f7ffb24369f99fda4fbe29db72dccfad52c457ba244', - replacements: PRECONNECT_CONOUT_REPLACEMENTS - }, - { - relativePath: ['lib', 'windowsPtyAgent.js'], - originalSha256: '8636d16b38266112204061a22b135734177c242837982fd3a4055be726efa64a', - patchedSha256: '3c14daf8d0ec2d1e2d66435caa5fb2b629b230e237873594e623e79e6a7d1223', - previousPatchedSha256: '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280', - previousReplacements: PRECONNECT_AGENT_REPLACEMENTS, - replacements: [ - [ - ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n', - ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n // Orca: released AFTER the console-list fork and the native kill, not before them.\n // Destroying conin first aborts teardown partway -- measured on a Windows SSH relay\n // as +2 File and +1 Process handles per terminal, against +1 File unpatched.\n this._inSocket.destroy();\n' - ], - ...PRECONNECT_AGENT_REPLACEMENTS - ] - }, - { - relativePath: ['lib', 'windowsTerminal.js'], - originalSha256: 'c3a65716f53fed0135a8a633373d5f9c2ab092544d651f27ef0a67096dd3bcd9', - patchedSha256: '5dfeb1dda46645e1d77964ad4d07072b34f0a897dfc7e785137cc39e755b4641', - additionalPreviousVariants: [ - { - sha256: '3060c6514a8e9e3285f91b9b549930e7d25d59d4cf7e1ed3a25b9a680dd1ded5', - replacements: PRECONNECT_TERMINAL_REPLACEMENTS - }, - { - sha256: '598755ee75307d041a72cd7c7c4e12ae4a4bbf35eee19da67b42b61fdae0d4d6', - replacements: [ - [ - " _this._preconnectCleanup = undefined;\n _this.emit('error', error);", - " _this._preconnectCleanup = undefined;\n if (_this.listeners('error').length > 1) {\n _this.emit('error', error);\n }" - ], - [ - ' if (_this._preconnectCleanupRequested) _this._emitExit();', - ' if (_this._killRequested) _this._emitExit();' - ], - [ - ' if (_this._isPipeReady && !_this._preconnectCleanupRequested) _this._emitExit();', - ' if (_this._isPipeReady && !_this._preconnectCleanupRequested && (!_this._killRequested || !_this._agent.isConpty || _this._agent.exitCode !== undefined)) _this._emitExit();' - ] - ] - } - ], - previousPatchedSha256: '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', - previousReplacements: [ - ...WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS, - ...PRECONNECT_TERMINAL_REPLACEMENTS - ], - replacements: [ - [ - ' _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;', - " _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.\n _this._socket.on('error', function (err) {\n var code = err && err.code;\n // PTY output can report EPIPE before `_close()` wins the race.\n _this._close();\n if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {\n return;\n }\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (typeof code === 'string') {\n if (~code.indexOf('errno 5') || ~code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;" - ], - [ - " }\n });\n // Shutdown if `error` event is emitted.\n _this._socket.on('error', function (err) {\n // Close terminal session.\n _this._close();\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (err.code) {\n if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {", - " }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {" - ], - [ - ' _this._readable = true;\n _this._writable = true;\n _this._forwardEvents();\n return _this;', - " _this._readable = true;\n _this._writable = true;\n // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes\n // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.\n _this._agent.inSocket.on('error', function () {\n if (!_this._writable) {\n return;\n }\n _this._close();\n try {\n _this._agent.kill();\n }\n catch (_a) {\n // The failing pipe may have raced process exit; the terminal is already unwritable.\n }\n });\n _this._forwardEvents();\n return _this;" - ], - [ - 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map', - 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map\n' - ], - ...WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS, - ...PRECONNECT_TERMINAL_REPLACEMENTS - ] - } -] - -// Retire the entrypoint before removing the APIs it required. -const PATCH_TARGETS = [ - 'windowsTerminal.js', - 'windowsPtyAgent.js', - 'windowsConoutConnection.js' -].map((file) => { - const previous = PREVIOUS_PATCH_TARGETS.find((target) => target.relativePath.at(-1) === file) - const inverse = (replacements) => replacements.toReversed().map(([from, to]) => [to, from]) - const isConout = file === 'windowsConoutConnection.js' - const retiredReplacements = isConout ? previous.replacements : previous.previousReplacements - return { - relativePath: previous.relativePath, - originalSha256: previous.originalSha256, - patchedSha256: isConout ? previous.originalSha256 : previous.previousPatchedSha256, - replacements: isConout - ? [] - : previous.replacements.slice(0, previous.replacements.length - retiredReplacements.length), - previousPatchedSha256: previous.patchedSha256, - previousReplacements: inverse(retiredReplacements), - additionalPreviousVariants: previous.additionalPreviousVariants?.map((variant) => ({ - sha256: variant.sha256, - replacements: [...variant.replacements, ...inverse(retiredReplacements)] - })) - } -}) - -function inspectTarget(relayDir, target) { - const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty') - const packageJson = JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')) - if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) { - throw new Error( - `Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}` - ) - } - const filePath = join(nodePtyDir, ...target.relativePath) - return { filePath, source: readFileSync(filePath, 'utf8') } -} - -function assertPatchedNodePtyWindowsTeardown(relayDir = process.cwd()) { - for (const target of PATCH_TARGETS) { - const inspected = inspectTarget(relayDir, target) - if (sourceSha256(inspected.source) !== target.patchedSha256) { - throw new Error( - `node-pty ConPTY teardown release is not installed in ${target.relativePath.join('/')}` - ) - } - } -} - -function patchNodePtyWindowsTeardown(relayDir = process.cwd()) { - const pending = [] - for (const target of PATCH_TARGETS) { - const inspected = inspectTarget(relayDir, target) - const sourceHash = sourceSha256(inspected.source) - if (sourceHash === target.patchedSha256) { - continue - } - const replacements = - sourceHash === target.originalSha256 - ? target.replacements - : sourceHash === target.previousPatchedSha256 - ? target.previousReplacements - : target.additionalPreviousVariants?.find((entry) => entry.sha256 === sourceHash) - ?.replacements - if (!replacements) { - throw new Error( - `Refusing to patch unexpected node-pty source in ${target.relativePath.join('/')}` - ) - } - let patchedSource = inspected.source - for (const [from, to] of replacements) { - // Why the count check: an anchor that matched twice would patch the wrong site silently, and - // the hash below would then reject a tree this script had already rewritten. - if (patchedSource.split(from).length - 1 !== 1) { - throw new Error(`Refusing to patch ${target.relativePath.join('/')}; anchor is not unique`) - } - patchedSource = patchedSource.replace(from, to) - } - if (sourceSha256(patchedSource) !== target.patchedSha256) { - throw new Error( - `Refusing to install unexpected patched node-pty source in ${target.relativePath.join('/')}` - ) - } - pending.push({ filePath: inspected.filePath, patchedSource }) - } - for (const { filePath, patchedSource } of pending) { - const temporaryPath = `${filePath}.orca-patch-${process.pid}` - // Why: a terminated remote install must leave either known source version recoverable on reconnect. - try { - writeFileSync(temporaryPath, patchedSource) - renameSync(temporaryPath, filePath) - } finally { - rmSync(temporaryPath, { force: true }) - } - } - assertPatchedNodePtyWindowsTeardown(relayDir) -} - -function sourceSha256(source) { - return createHash('sha256').update(source).digest('hex') -} - -if (require.main === module) { - patchNodePtyWindowsTeardown() -} - -module.exports = { - assertPatchedNodePtyWindowsTeardown, - patchNodePtyWindowsTeardown -} diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index be9a7c26048..c7e1d657a19 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -854,16 +854,13 @@ "invariant": "A fresh PTY launch of an agent with a trust preset pre-trusts its workspace root (for Codex, whose lookup keys on its start folder, the folder it starts in, including a floating terminal's) before the provider spawn, in both spawn builders, on the host that runs the agent: main for local and WSL launches, the SSH relay on its own disk for SSH launches. It never waits past the preset's deadline: 20 s for local Codex, the short budget for every other write, local or on the relay. A failed or abandoned write lets the launch proceed untrusted, so the agent asks. Restored or reattached panes, spawns with no launch command and the setting turned off are never trusted; for an agent whose trust on a home covers the folders below it (Claude, Copilot, Qoder), no writer stores a path that is a root, a home or a folder above one, and an unknown home writes nothing; Codex, Cursor and Antigravity, whose trust on a home covers only the home, trust it as they did before; and a WSL launch never writes the Windows home. A structured Codex chat, which has no PTY, pre-trusts its folder the same way when it is created, under the same setting.", "oracle": "A trust write held pending keeps each builder from returning until it settles. A never-settling local Codex write resolves the dispatcher only after the long deadline; a never-settling local Claude write resolves after the short budget with a named warning. Rejected and throwing writes, and a throwing guard, resolve with a warning. Restored panes, spawns without a command and the setting off make no dispatcher call; Codex in a floating terminal or a workspace subfolder is trusted at that folder, and every other preset at the workspace root. Creating a structured Codex chat trusts its folder before launch preparation, and nothing with the setting off. An SSH launch returns the relay field for every preset and calls no writer, and the relay's pty.spawn starts no process until that launch's trust call settles. A home, a folder above one, a root, a symlink to a home and a missing path that climbs back to the home through `..` write no Claude, Copilot or Qoder trust file, on this machine and on the relay, and the home writes Codex, Cursor and Antigravity trust.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" ], "testFiles": [ "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts", "src/main/agent-workspace-trust-spawn.test.ts", "src/main/agent-workspace-trust.test.ts", "src/main/execution-host-workspace-trust.test.ts", - "src/relay/agent-workspace-trust-spawn.test.ts", - "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", - "src/relay/pty-handler-agent-workspace-trust.test.ts", "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" ], "assertionRefs": [ @@ -904,24 +901,6 @@ "writes no %s trust when the host knows no home" ] }, - { - "file": "src/relay/agent-workspace-trust-spawn.test.ts", - "assertions": [ - "writes %s trust on the relay host's own disk", - "trusts the relay home for %s, whose trust there covers only the home", - "leaves Antigravity to ask, since its writer is unverified on SSH hosts" - ] - }, - { - "file": "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", - "assertions": ["skips %s trust and never fails the spawn"] - }, - { - "file": "src/relay/pty-handler-agent-workspace-trust.test.ts", - "assertions": [ - "writes the launch's trust with its final env before the agent's process starts" - ] - }, { "file": "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", "assertions": [ @@ -935,7 +914,7 @@ "date": "2026-09-29", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", "result": "passed", "durationSeconds": 4.16, "summary": "201 tests across eight suites pass, run through node_modules/.bin/vitest with a throwaway HOME. No native launch, PTY or live SSH host." @@ -1213,81 +1192,6 @@ "promotionCriteria": ["Retain red/green heap and value assertions and complete CI soak."], "demotionRule": "Keep experimental until cross-platform and soak evidence; investigate failures without weakening content or memory assertions." }, - { - "id": "relay-performance.ai-vault-ready-ownership", - "title": "AI Vault readiness releases canceled request owners", - "maturity": "experimental", - "protection": "partial", - "owner": "desktop-runtime", - "layer": "relay-service-client-contract", - "surfaces": ["AI Vault sidecar startup", "SSH title lookup cancellation"], - "platforms": ["macos", "linux", "windows"], - "providers": ["ssh"], - "coveredPlatforms": ["macos"], - "coveredProviders": ["ssh"], - "coverageNotes": "Production relay client with synthetic child events and real forced GC. Native remote process execution and live SSH remain untested. Local/daemon PTY, WSL shell selection and mobile terminal stream are unaffected.", - "motivatingLinks": ["https://github.com/stablyai/orca/issues/23024"], - "invariant": "Canceled unsent calls release request payloads before sidecar readiness, disposal releases all pending readiness owners, and live calls still start exactly once through cancellation and startup failures.", - "oracle": "Hold ready and cancel 1,000 title calls; require zero retained request arrays after GC and one child. Dispose before ready and require uncanceled payload collection and zero timers. Race ready with abort and spawn failure with a second lane; require only live calls delivered and successful results. Preserve existing restart and cancel-ack behavior.", - "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts" - ], - "testFiles": [ - "src/relay/ai-vault-service-ready-retention.test.ts", - "src/relay/ai-vault-service-client.test.ts", - "src/relay/ai-vault-service-restart-policy.test.ts", - "src/relay/ai-vault-service-spawn.test.ts", - "src/relay/ai-vault-handler.test.ts", - "src/shared/promise-settlement-waiters.test.ts" - ], - "assertionRefs": [ - { - "file": "src/relay/ai-vault-service-ready-retention.test.ts", - "assertions": [ - "releases canceled request payloads while readiness remains pending", - "releases uncanceled readiness payloads when the client is disposed", - "does not send a call canceled in the turn that readiness arrives", - "retries an unsent lane after spawn fails while the other lane starts a child" - ] - } - ], - "evidenceRuns": [ - { - "date": "2026-09-25", - "runner": "local", - "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts", - "result": "passed", - "durationSeconds": 0.616, - "summary": "38 tests across six suites passed, including real forced-GC retention and startup/cancel races." - } - ], - "runtimeBudget": { - "p95Seconds": 10, - "scope": "focused Node contract tests with forced GC" - }, - "flakeHistory": { - "status": "unknown", - "evidence": "Local runs passed; CI/soak history not collected." - }, - "redGreenEvidence": { - "status": "complete", - "evidence": "Baseline retains all 1,000 canceled arrays and the disposed uncanceled payload; both permanent regressions fail there and pass after the fix. Independent spawn-failure race failed on the initial prototype, passed on baseline and passes after correction." - }, - "performanceBudget": { - "required": true, - "evidence": "Synthetic cold-start churn retains 1,000 to zero canceled arrays; incremental heap 34,771,464 to 492,504 bytes. One shared startup reaction, no new polling, timers, process starts, timeout changes or wire fields." - }, - "promotionCriteria": [ - "Collect 100 consecutive CI passes or 14 days of soak history.", - "Exercise real SSH sidecar cancellation and restart on Linux and Windows." - ], - "knownGaps": [ - "Synthetic churn does not measure typical user frequency or UI latency.", - "Physical sidecar/native Windows/Linux and live SSH paths were not exercised." - ], - "demotionRule": "Keep experimental or demote if canceled payloads survive GC, disposal retains startup owners, or live lanes stall/duplicate after startup failure." - }, { "id": "runtime.connection-owned-host-status", "title": "Host status recovers with its owning connection", @@ -3283,8 +3187,8 @@ "invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Bash scalar and supported array PROMPT_COMMAND values preserve user bytes, hook order, and foreground exit status while emitting exactly one OSC 133 A/C/D lifecycle without syntax errors. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, proof-based recovery releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger that proof. The existing bounded compatibility timeout may enqueue input without proof, but must not publish readiness, lose or duplicate that input across reconnect, or execute it before the replacement shell can accept it.", "oracle": "Split Fish output immediately after the ready marker and after the following ESC introducer; local and daemon owners must emit the exact ESC [ ? 2004 h bytes and headless xterm must not render literal control text. Run the real generated wrappers under node-pty with disposable zsh, bash, and Fish startup files. Exercise Bash scalar and array PROMPT_COMMAND values with empty, repeated, escaped, whitespace, control-byte, odd-backslash, status-mutating, DEBUG-trap, zoxide, and RHEL-style hooks; require exact A,C,D:0,A,C,D:1,A,C ordering and byte preservation. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must use the same lossless scanner composition and release through the same prompt contract.", "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/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1" ], @@ -3303,8 +3207,6 @@ "src/main/providers/local-pty-shell-ready-startup-command.test.ts", "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts", "src/main/providers/local-pty-provider-shell-readiness.test.ts", - "src/relay/pty-shell-launch.test.ts", - "src/relay/pty-handler-startup-command-delivery.test.ts", "tests/e2e/paired-startup-exec-readiness.spec.ts" ], "assertionRefs": [ @@ -3348,14 +3250,6 @@ "zsh configurations without a line-editor enable sequence retain the existing timeout backstop" ] }, - { - "file": "src/relay/pty-handler-startup-command-delivery.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" - ] - }, { "file": "tests/e2e/paired-startup-exec-readiness.spec.ts", "assertions": [ @@ -3378,7 +3272,7 @@ "date": "2026-08-12", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", "result": "passed", "durationSeconds": 6, "summary": "473 tests passed with one local Fish skip, including every-boundary scanner composition, real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles." @@ -3387,7 +3281,7 @@ "date": "2026-08-15", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", "result": "passed", "durationSeconds": 6, "summary": "233 tests passed with one local Fish skip, including exact Bash PROMPT_COMMAND byte/status/lifecycle composition across daemon, local, and relay owners." @@ -10077,15 +9971,14 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/remote-agent-session-focus-authority.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ "src/main/providers/pty-process-inspection.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-router.test.ts", "src/main/daemon/degraded-daemon-pty-provider.test.ts", - "src/relay/pty-handler-spawn-admission.test.ts", "src/main/runtime/orca-runtime.test.ts", "tests/e2e/remote-agent-completion-authority.unit.test.ts", "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", @@ -10142,10 +10035,6 @@ "completion inspection rejects an unmapped session instead of borrowing the local fallback" ] }, - { - "file": "src/relay/pty-handler-spawn-admission.test.ts", - "assertions": ["strict relay inspection rejects a missing PTY"] - }, { "file": "tests/e2e/remote-agent-completion-authority.unit.test.ts", "assertions": [ @@ -10327,7 +10216,7 @@ "date": "2026-07-23", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", "result": "passed", "durationSeconds": 11.96, "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane." @@ -10776,7 +10665,7 @@ "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.", "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", @@ -10785,8 +10674,7 @@ "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/main/ipc/pty-applied-size-reporting.test.ts", "src/main/providers/ssh-pty-provider.test.ts", - "src/main/runtime/orca-runtime.test.ts", - "src/relay/pty-handler-output-streaming.test.ts" + "src/main/runtime/orca-runtime.test.ts" ], "assertionRefs": [ { @@ -10840,13 +10728,6 @@ "the headless mirror resizes after an accepted desktop PTY resize (#7192)", "headless mirror resizes are ordered behind queued PTY writes (#7192)" ] - }, - { - "file": "src/relay/pty-handler-output-streaming.test.ts", - "assertions": [ - "relay readback reports the grid actually applied by node-pty", - "missing relay PTYs return an unverified null size" - ] } ], "evidenceRuns": [ @@ -10854,7 +10735,7 @@ "date": "2026-07-21", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts", "result": "passed", "durationSeconds": 18.54, "summary": "8 test files passed, 1,272 tests passed on the PR branch." @@ -12302,7 +12183,7 @@ "invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.", "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, bounds scanner-service calls at sixteen, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts --maxWorkers=2 --reporter=dot" ], "testFiles": [ @@ -12314,8 +12195,7 @@ "src/main/ai-vault/session-title-file-reader.test.ts", "src/main/ai-vault/session-parse-cache-persistence.test.ts", "src/main/ipc/ai-vault.test.ts", - "src/main/runtime/rpc/methods/ai-vault.test.ts", - "src/relay/ai-vault-handler.test.ts" + "src/main/runtime/rpc/methods/ai-vault.test.ts" ], "assertionRefs": [ { @@ -12381,7 +12261,7 @@ "date": "2026-10-02", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts", "result": "passed", "durationSeconds": 5.3, "summary": "The focused run passed 138 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, per-lane scanner-service lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." @@ -12541,20 +12421,18 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-recovery-interleavings.test.ts src/relay/relay-pty-source-recovery-completion.test.ts src/relay/relay-pty-source-restore-retry.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/dispatcher.test.ts src/relay/pty-handler-source-publication.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/dispatcher.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-legacy-projection.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/ssh-pty-consumer-session-adapter.test.ts src/main/persistence-ssh-targets-and-pane-keys.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/persistence-ssh-targets-and-pane-keys.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts --reporter=dot" // Historical evidence commands; the override is ignored after the always-on transition. ], @@ -12575,17 +12453,7 @@ "src/main/providers/ssh-pty-provider-exit-race.test.ts", "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", "src/wsl-guest/protocol-backpressure.test.ts", - "src/relay/git-response-stream-ownership.test.ts", - "src/relay/pty-handler-output-streaming.test.ts", - "src/relay/pty-source-credit-ledger.test.ts", - "src/relay/pty-source-credit-scheduler.test.ts", - "src/relay/relay-pty-source-publication.test.ts", - "src/relay/relay-pty-source-recovery-interleavings.test.ts", - "src/relay/relay-pty-source-recovery-completion.test.ts", - "src/relay/relay-pty-source-restore-retry.test.ts", "src/wsl-guest/dispatcher.test.ts", - "src/relay/pty-handler-source-publication.test.ts", - "src/relay/ssh-pty-source-credit-adapter.test.ts", "src/shared/pty-consumer-session.test.ts", "src/main/ipc/ssh-pty-model-admission.test.ts", "src/main/ipc/ssh-pty-output-intake.test.ts", @@ -12701,66 +12569,12 @@ "retired per-target source-credit selections are removed during normalization" ] }, - { - "file": "src/relay/pty-source-credit-ledger.test.ts", - "assertions": [ - "an exact early cumulative ACK reserves eligibility without advancing credit before send settlement and survives only an exact same-token retry", - "source retention charges max UTF-8 or UTF-16 storage plus one record", - "fragmented multi-PTY source, charged-byte, and frame caps bind independently" - ] - }, - { - "file": "src/relay/pty-source-credit-scheduler.test.ts", - "assertions": [ - "one rejecting delivery is removed after one reservation attempt while prior and later peer reservations remain committed without head-of-line blocking" - ] - }, - { - "file": "src/relay/git-response-stream-ownership.test.ts", - "assertions": [ - "an 8-byte encoded producer capacity emits exactly two 6-byte payload chunks and one end call while the first bulk write is saturated" - ] - }, - { - "file": "src/relay/pty-handler-output-streaming.test.ts", - "assertions": [ - "a fresh plain pending entry after transformed source-only output omits inherited rawLength and completes in exactly three admission calls" - ] - }, { "file": "src/wsl-guest/dispatcher.test.ts", "assertions": [ "a saturated legacy primary remains required backpressure and is never detached as an additional subscriber" ] }, - { - "file": "src/relay/pty-handler-source-publication.test.ts", - "assertions": [ - "immutable spawn and attach activation identity settles before the first source frame in the response/data interleaving", - "one saturated additional subscriber is bounded and detached without pausing the native PTY or negotiated owner" - ] - }, - { - "file": "src/relay/relay-pty-source-recovery-interleavings.test.ts", - "assertions": [ - "failed exit publication retains sealed delivery for late cumulative ACK and exact token recovery", - "exact owner recovery republishes exit without reopening source admission", - "a failed recovery-completion frame rolls back its fence and the next exact owner republishes it before live admission", - "failed activation response retains private retry authority, and exact retry clears activation only after successful settlement" - ] - }, - { - "file": "src/relay/relay-pty-source-recovery-completion.test.ts", - "assertions": [ - "unadmitted recovery completion retries once writer capacity returns, stays single-flight, and releases its bounded capacity listener" - ] - }, - { - "file": "src/relay/relay-pty-source-restore-retry.test.ts", - "assertions": [ - "invalid-checkpoint cancellation retains the exact delivery until restore response settlement, then retry mints a fresh activation and emits one live source frame" - ] - }, { "file": "src/wsl-guest/protocol-backpressure.test.ts", "assertions": [ @@ -12916,20 +12730,11 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot", "result": "passed", "durationSeconds": 2.24, "summary": "Sixteen deterministic SSH relay/session/source/decoder files passed 192 tests, including exit-sealed private recovery, retained stale-transfer cancellation authority, exact private-frame proof watermarks, one retirement record across 10,000 same-PTY token rotations, stale-owner fallback, and scheduler rejection isolation; no live topology was exercised." }, - { - "date": "2026-07-29", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", - "result": "passed", - "durationSeconds": 11.81, - "summary": "Two deterministic relay stream and PTY batching files passed 120 tests, including exact encoded chunk capacity and transformed-to-plain metadata isolation." - }, { "date": "2026-07-28", "runner": "local", @@ -12943,7 +12748,7 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", "result": "passed", "durationSeconds": 6.89, "summary": "Thirteen current-head deterministic files passed 150 tests across exit-sealed private recovery, stale-transfer cancellation authority, bounded latest-token retirement, restore retirement, fail-closed recovery cancellation, partial ACK and token rotation, reclaimed-span replacement, mux lane fairness, provisional activation, and exit-proof ordering; no live topology was exercised." @@ -12961,7 +12766,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.63, "summary": "Five deterministic provider, relay, deploy, and session files passed 70 tests after red seams proved that source frames mutated legacy incarnation state and generic restore-required reattach returned an outputless live PTY. The candidate keeps exact source identity side-effect free, fails generic restore-required reattach as expired, offers V1 through reconnect, and proves a failed recovery activation stays private and exact-retryable until successful response settlement; no live topology was exercised." @@ -18418,24 +18223,20 @@ "oracle": "Fill the global watcher-child reservation, require native desktop, paired-runtime, and WSL setup to retain typed transient capacity and retry after one serialized release, then cancel pending desktop and paired-client watches and require no retry. Hold one live anchor while aborting 10,000 callers at the capacity, quarantine-assignment, runtime-root, relay-setup, and SSH-registration layers; require exactly one explicit waiter and one base-promise reaction to remain, then zero after settlement. Hold an old relay unsubscribe, queue one same-root setup leader and one anchor, then abort 10,000 joiners; require every cancellation to settle before teardown releases, exactly one anchor waiter, and only one later physical install. Advance the former paired-web callback deadline while capacity remains full, require cancellation ownership to stay live, publish the server token before ready, and require files.unwatch to abort setup before a later slot release can install a native root. Reject the first ready-watch unwatch with a physical-exit promise, resolve that exit later, and require a fresh physical unwatch attempt, registry release, and exactly one stream end. Fill one healthy shared child with multiple roots, map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor, then inject IPC disconnect-before-exit, queued and active cancellation, operations during termination, kill-without-exit, repeated unkillable generations, initial and recovery timeouts, and shard failure. During local and SSH deletion, attempt a late terminal spawn whose worktree identity and cwd name different roots, enumerate a non-prefix remote PTY by authoritative worktree ownership, withhold daemon/relay exit callbacks, make attach positively prove a concurrent-shutdown PID dead, and make the provider unavailable; require every acquired admission to roll back on rejection, dead-PID proof to settle physical exit before reaping, timeout to retain ownership, and Git/filesystem deletion never to start. Require bounded watcher process count, physical-exit settlement, healthy-root recovery, stale-generation rejection, overflow plus later events, and fail-closed destructive cleanup. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-pending-setup-waiters.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-parent-removal.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "pnpm run build:electron-vite", "node config/scripts/runtime-file-watcher-resource-probe.mjs", "node config/scripts/runtime-file-watcher-fault-harness.mjs", "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", - "pnpm run build:relay", - "node config/scripts/relay-watcher-fault-harness.mjs" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs" ], "testFiles": [ "src/main/daemon/daemon-pty-adapter.test.ts", @@ -18482,21 +18283,8 @@ "src/shared/promise-settlement-waiters.test.ts", "config/scripts/runtime-file-watcher-fault-harness.mjs", "config/scripts/runtime-file-watcher-resource-probe.mjs", - "src/relay/relay-filesystem-watch-registry.test.ts", - "src/relay/relay-watcher-pending-setup-waiters.test.ts", - "src/relay/relay-watcher-parent-removal.test.ts", - "src/relay/relay-watcher-setup-wait.test.ts", - "src/relay/fs-handler.test.ts", - "src/relay/pty-handler-dispose-lifecycle.test.ts", - "src/relay/pty-handler-shutdown-signals.test.ts", - "src/relay/pty-handler-spawn-admission.test.ts", - "src/relay/pty-handler-attach-replay.test.ts", - "src/relay/pty-handler-revive.test.ts", - "src/relay/git-handler.test.ts", - "src/relay/subprocess.test.ts", "src/main/ssh/ssh-relay-versioned-install.test.ts", - "config/scripts/package-electron-runtime-contract.test.mjs", - "config/scripts/relay-watcher-fault-harness.mjs" + "config/scripts/package-electron-runtime-contract.test.mjs" ], "assertionRefs": [ { @@ -18815,72 +18603,6 @@ "a later same-root listener opens a fresh server subscription instead of reusing a dead stream" ] }, - { - "file": "src/relay/relay-watcher-parent-removal.test.ts", - "assertions": [ - "POSIX and Windows parent deletion closes and terminally notifies descendant watches before removal while preserving path siblings", - "parent deletion waits for descendant setup publication and already-retiring descendant teardown before removal" - ] - }, - { - "file": "src/relay/relay-watcher-pending-setup-waiters.test.ts", - "assertions": [ - "10,000 aborted relay setup callers detach while one same-root teardown anchor remains and only one replacement installs" - ] - }, - { - "file": "src/relay/relay-watcher-setup-wait.test.ts", - "assertions": [ - "10,000 cancelled relay setup callers leave exactly one explicit anchor and one base-promise reaction" - ] - }, - { - "file": "src/relay/relay-filesystem-watch-registry.test.ts", - "assertions": [ - "each SSH host keeps its own healthy watcher supervisor while a standard repository's three distinct roots share that one child", - "shared-child failure moves those roots into separate quarantine supervisors with overflow and resumed events", - "replacement emits overflow before later ordered events", - "terminal shard failure preserves same-root clients through recovery", - "pending setup aborts only after the last same-root client leaves", - "relay-side removal sends every client a terminal failure, closes every watch, and fences new same-root watcher or PTY setup even when deletion fails", - "a missing child fails closed even if the relay inherits a VITEST environment" - ] - }, - { - "file": "src/relay/fs-handler.test.ts", - "assertions": [ - "shared watches retain the twenty-root budget and release after every client leaves", - "recursive relay deletion closes and terminally notifies same-root watches before removing the directory" - ] - }, - { - "file": "src/relay/pty-handler-dispose-lifecycle.test.ts", - "assertions": [ - "immediate relay shutdown stays pending with the PTY map entry retained until onExit", - "a missing exit rejects at the bounded deadline, preserves the physical owner, and a retry joins without duplicate SIGKILL", - "worktree removal waits explicit and default-cwd PTY creation, rejects later creation, and physically stops only authoritative matching PTYs before deletion" - ] - }, - { - "file": "src/relay/pty-handler-shutdown-signals.test.ts", - "assertions": [ - "Windows relay graceful, fallback, immediate, stale-spawn, exit-dispose, and handler-dispose paths issue at most one ConPTY kill" - ] - }, - { - "file": "src/relay/pty-handler-spawn-admission.test.ts", - "assertions": [ - "relay PTY admission succeeds when post-Node-18 array copy methods are unavailable", - "concurrent spawn and revive admission is atomic, capped at fifty physical PTYs, and same-id revive is deduplicated", - "relay spawn and revive fence distinct worktree-ID and cwd roots, rolling back earlier admissions in reverse when either root is removing" - ] - }, - { - "file": "src/relay/pty-handler-attach-replay.test.ts", - "assertions": [ - "attach dead-PID proof settles a concurrent immediate shutdown before the stale PTY entry is reaped" - ] - }, { "file": "src/renderer/src/web/web-runtime-client.test.ts", "assertions": [ @@ -18888,19 +18610,6 @@ "transient shared-socket loss replays an active file watch under a new transport id while preserving its logical handle and never revives a stopped watch" ] }, - { - "file": "src/relay/git-handler.test.ts", - "assertions": [ - "remote Git worktree removal executes inside the relay filesystem watcher fence" - ] - }, - { - "file": "src/relay/subprocess.test.ts", - "assertions": [ - "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child", - "the Node-18-targeted relay bundle contains no unsupported array copy methods" - ] - }, { "file": "src/main/ssh/ssh-relay-versioned-install.test.ts", "assertions": [ @@ -18927,13 +18636,6 @@ "the harness parent survives watcher-child SIGSEGV", "the real supervisor automatically replaces the child, resubscribes, and delivers a post-crash event" ] - }, - { - "file": "config/scripts/relay-watcher-fault-harness.mjs", - "assertions": [ - "the built relay and existing PTY survive watcher-child fault injection", - "replacement emits overflow and a precise post-crash event" - ] } ], "evidenceRuns": [ @@ -18941,7 +18643,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.98, "summary": "Thirty-nine watcher, cancellation, desktop/serve, paired-web, WSL, SSH/relay, removal, and PTY physical-exit files passed with 2,008 tests. Capacity, quarantine, runtime-root, relay, and SSH setup layers each prove that 10,000 aborted callers detach while one anchor remains and zero waiters remain after settlement." @@ -18950,7 +18652,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.59, "summary": "Thirty-four watcher, cancellation, desktop/serve, paired-web, WSL, removal, PTY physical-exit, provider, renderer, and path files passed with 1,999 tests; paired-web cancellation now owns a server token before ready and aborts pending setup without a late native install." @@ -18968,7 +18670,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.01, "summary": "Twenty-seven watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,886 tests. The suite includes nested removal fencing, renderer/runtime cwd-only admission, legacy provider rows, rejected kill retries, transient WSL capacity, and synchronous-startup reservation cleanup." @@ -18986,7 +18688,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 10.01, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests." @@ -19022,25 +18724,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm run build:relay", - "result": "passed", - "durationSeconds": 0.38, - "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "node config/scripts/relay-watcher-fault-harness.mjs", - "result": "passed", - "durationSeconds": 0.12, - "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", "result": "passed", "durationSeconds": 9.12, "summary": "Twenty-five watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,762 tests. The suite covers operations joining in-flight termination, kill failures retaining ownership, bounded timeouts, atomic relay spawn/revive caps, and PTY-removal admission." @@ -19058,7 +18742,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 10.69, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests, including watcher/PTY removal fencing and recursive deletion failure behavior." @@ -19094,25 +18778,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm run build:relay", - "result": "passed", - "durationSeconds": 0.39, - "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "node config/scripts/relay-watcher-fault-harness.mjs", - "result": "passed", - "durationSeconds": 0.12, - "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", "result": "passed", "durationSeconds": 9.03, "summary": "Twenty-three watcher, desktop/serve removal, terminal-spawn gate, daemon/SSH physical PTY exit, provider ownership, RPC, renderer, and cross-platform path files passed with 1,630 tests. The suite includes same-turn watcher replacement, authoritative SSH teardown before Git, bounded kill-without-exit failures that retain ownership, and local/SSH deletion fail-closed behavior." @@ -19130,20 +18796,11 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 9.79, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 225 tests, including terminal removal notification and recursive deletion fencing." }, - { - "date": "2026-07-12", - "runner": "local", - "platform": "macos", - "command": "node config/scripts/relay-watcher-fault-harness.mjs", - "result": "passed", - "durationSeconds": 0.61, - "summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child." - }, { "date": "2026-07-13", "runner": "local", @@ -19232,12 +18889,9 @@ "invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.", "oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts" - ], - "testFiles": [ - "src/main/plugins/plugin-host-methods.test.ts", - "src/main/plugins/plugin-host-conformance.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts" ], + "testFiles": ["src/main/plugins/plugin-host-methods.test.ts"], "assertionRefs": [ { "file": "src/main/plugins/plugin-host-methods.test.ts", @@ -19246,14 +18900,6 @@ "matching local- and SSH-shaped opaque ids each perform one capped list and one exact send", "workspace.readContext drops provider paths, path-bearing internal worktree ids, and terminal titles while capping its terminal projection" ] - }, - { - "file": "src/main/plugins/plugin-host-conformance.test.ts", - "assertions": [ - "all 13 v0 methods succeed with the required consented capability through desktop-main and relay adapters", - "missing consent, missing capability, unknown method, malformed params, panel-forbidden access, malformed results, and mutation-audit failure return identical codes", - "malformed qualified keys, client-supplied grants, and client-supplied transport flags are rejected before host policy resolution" - ] } ], "evidenceRuns": [ @@ -19261,7 +18907,7 @@ "date": "2026-07-10", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts", "result": "passed", "durationSeconds": 0.18, "summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts." @@ -19794,88 +19440,6 @@ ], "demotionRule": "Keep experimental or demote if any case terminates on a signal, the preconditions stop holding on the CI runner, or an assertion can be satisfied by anything other than the command's own output." }, - { - "id": "ssh-managed-hooks.node18-runtime-compatibility", - "title": "SSH managed-hook companions load and install hooks on Node 18", - "maturity": "experimental", - "protection": "partial", - "owner": "agent-hooks", - "layer": "ssh-relay-companion-runtime", - "surfaces": [ - "SSH managed-hook companion startup", - "remote Codex hook installation", - "remote Claude hook installation" - ], - "platforms": ["macos", "linux", "windows"], - "providers": ["ssh"], - "coveredPlatforms": ["macos"], - "coveredProviders": ["ssh"], - "coverageNotes": "A real Node 18 process loads every platform's built companion and runs the bundled Codex and Claude installers against an isolated temporary home. The six artifacts share one platform-neutral source bundle; live SSH transport and physical remote hosts remain uncollected.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/12617"], - "invariant": "Every managed-hook companion shipped for Node 18 must load before agent installers execute, and importing desktop-only state inspection must not make unrelated remote installers depend on node:sqlite.", - "oracle": "Build all relay artifacts, require each managed-hook-runtime.js in a real Node 18 process, assert its installer export, then install only Codex and Claude into a fresh temporary home. Require two successful installer results, executable hook scripts, and both agent configs to reference their managed scripts without touching the user's home.", - "commands": [ - "pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs", - "pnpm exec vitest run --config config/vitest.config.ts config/scripts/pr-workflow-parallelism.test.mjs" - ], - "testFiles": [ - "config/scripts/smoke-managed-hook-runtime-node18.mjs", - "config/scripts/pr-workflow-parallelism.test.mjs" - ], - "assertionRefs": [ - { - "file": "config/scripts/smoke-managed-hook-runtime-node18.mjs", - "assertions": [ - "all six built companions load and export installManagedHooks under Node 18", - "Codex and Claude installations both succeed in an isolated home", - "both generated configs reference executable managed hook scripts" - ] - }, - { - "file": "config/scripts/pr-workflow-parallelism.test.mjs", - "assertions": [ - "PR CI builds the companions before switching to Node 18 and running the smoke oracle" - ] - } - ], - "evidenceRuns": [ - { - "date": "2026-08-08", - "runner": "local", - "platform": "macos", - "result": "passed", - "command": "pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs", - "durationSeconds": 4, - "summary": "All six companions loaded under Node 18.20.8; Codex and Claude produced two successful results, executable hook scripts, and managed configs in a temporary home." - } - ], - "runtimeBudget": { - "p95Seconds": 30, - "scope": "six built-companion loads plus two isolated local-filesystem installer executions" - }, - "flakeHistory": { - "status": "not-started", - "evidence": "New deterministic artifact smoke with no timing oracle; CI history is not yet available." - }, - "redGreenEvidence": { - "status": "complete", - "evidence": "On main de4f272b31, the same Node 18 require exited 1 before exports with ERR_UNKNOWN_BUILTIN_MODULE for node:sqlite. The candidate passes all six loads and both installers; restoring the eager node:sqlite value import reproduces the original exit." - }, - "performanceBudget": { - "required": false, - "evidence": "The product change replaces one eager built-in import with a constructor-time built-in lookup. It adds no polling, IPC, filesystem scan, subprocess, listener, or recurring work." - }, - "promotionCriteria": [ - "Collect 100 consecutive focused CI passes or 14 days of soak history.", - "Collect one live SSH install on Linux and one on Windows OpenSSH.", - "Keep all-platform load, selected installer, isolated-home, and workflow-order assertions green." - ], - "knownGaps": [ - "The artifact journey uses local filesystem calls rather than a live SSH transport.", - "Platform-named bundles are loaded on macOS locally; physical Linux and Windows evidence is not yet recorded." - ], - "demotionRule": "Demote if any shipped companion stops loading on Node 18, selected installers mutate outside the isolated home, or the CI lane no longer runs the built artifact with Node 18." - }, { "id": "terminal-history.daemon-checkpoint-session-isolation", "title": "Daemon history checkpoints isolate stalled terminal sessions", @@ -20002,17 +19566,12 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts --reporter=verbose", "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_SKILL_UPLOAD_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose" + "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose" ], "testFiles": [ "src/main/skills/skill-upload-session-service.test.ts", "src/main/skills/skill-upload-process-restart.integration.test.ts", - "src/main/skills/skill-upload-process-restart-child.test.ts", - "src/relay/skill-upload-multi-relay.integration.test.ts" + "src/main/skills/skill-upload-process-restart-child.test.ts" ], "assertionRefs": [ { @@ -20035,14 +19594,6 @@ "begun, partial, uploaded, and committed crash residue is reclaimed", "the replacement transfer completes and leaves no owner directory" ] - }, - { - "file": "src/relay/skill-upload-multi-relay.integration.test.ts", - "assertions": [ - "two bundled relay PIDs retain separate archives under one account home", - "current and originating relay bundles retain separate archives in both launch orders", - "relay cancellation and live-upload graceful shutdown clean only the exact owner" - ] } ], "evidenceRuns": [ @@ -20051,36 +19602,9 @@ "runner": "local", "platform": "macos", "result": "passed", - "command": "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", + "command": "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", "durationSeconds": 5.95, "summary": "Fifteen service tests, four crash-boundary cases, and one two-current-relay topology passed; the child fixture and two explicitly separate mixed-version cases skipped." - }, - { - "date": "2026-08-20", - "runner": "local", - "platform": "macos", - "result": "passed", - "command": "ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "durationSeconds": 0.9, - "summary": "The current/current case and both originating/current mixed-version launch orders passed with exact per-root archive bytes." - }, - { - "date": "2026-08-20", - "runner": "local", - "platform": "macos", - "result": "failed", - "command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "durationSeconds": 0.4, - "summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing." - }, - { - "date": "2026-08-20", - "runner": "local", - "platform": "macos", - "result": "failed", - "command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "durationSeconds": 0.4, - "summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing." } ], "runtimeBudget": { diff --git a/config/scripts/benchmark-sentinel-retention.mjs b/config/scripts/benchmark-sentinel-retention.mjs deleted file mode 100644 index 93564eeac01..00000000000 --- a/config/scripts/benchmark-sentinel-retention.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { strict as assert } from 'node:assert' -import { EventEmitter } from 'node:events' -import { mkdtemp, rm } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { build } from 'esbuild' - -if (!global.gc) { - throw new Error('Run with node --expose-gc') -} -const root = resolve(import.meta.dirname, '../..') -const directory = await mkdtemp(join(tmpdir(), 'orca-sentinel-retention-')) -const output = join(directory, 'sentinel.cjs') -try { - await build({ - stdin: { - contents: `export {waitForSentinel} from './src/main/ssh/ssh-relay-deploy-helpers'; -export {RELAY_SENTINEL} from './src/main/ssh/relay-protocol';`, - resolveDir: root, - loader: 'ts' - }, - bundle: true, - platform: 'node', - format: 'cjs', - packages: 'external', - banner: { - js: `var require = require('node:module').createRequire(${JSON.stringify(join(root, 'package.json'))});` - }, - outfile: output - }) - const { waitForSentinel, RELAY_SENTINEL } = createRequire(import.meta.url)(output) - const held = [] - const banners = [] - for (let i = 0; i < 100; i++) { - const channel = Object.assign(new EventEmitter(), { - stderr: new EventEmitter(), - stdin: { write: () => true }, - close: () => {} - }) - const pending = waitForSentinel(channel) - banners.push(feedBanner(channel)) - channel.emit('data', Buffer.from(RELAY_SENTINEL)) - const transport = await pending - const received = [] - transport.onData((bytes) => received.push(bytes.toString())) - channel.emit('data', Buffer.from('frame')) - assert.deepEqual(received, ['frame']) - held.push({ channel, transport }) - } - await new Promise((resolve) => setImmediate(resolve)) - for (let i = 0; i < 5; i++) { - global.gc() - } - const retained = banners.filter((reference) => reference.deref() !== undefined).length - console.log( - JSON.stringify({ - connections: held.length, - bannerBytes: 65536, - retainedBannerBuffers: retained, - retainedBannerBytes: retained * 65536 - }) - ) -} finally { - await rm(directory, { recursive: true, force: true }) -} - -function feedBanner(channel) { - const banner = Buffer.alloc(65536, 120) - channel.emit('data', banner) - return new WeakRef(banner.buffer) -} diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index fcff37a8254..320a88c30bd 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -1,349 +1,72 @@ #!/usr/bin/env node /** - * Bundle the relay daemon and its crash-isolated watcher child per platform. - * - * The relay runs on remote hosts via `node relay.js`, so both outputs use - * self-contained CommonJS bundles with no external dependencies beyond - * Node.js built-ins. Native addons (node-pty, @parcel/watcher) are - * marked external and expected to be installed on the remote or - * gracefully degraded. + * Bundle the programs Orca runs inside WSL distros: the WSL agent-hook relay, the WSL browser + * network relay, the Claude profile helper, and the OpenCode SQLite reader. Each is a + * self-contained CommonJS bundle with only Node.js built-ins; the Windows app ships them from + * out/relay/wsl via the relay extraResources mapping. */ import { build } from 'esbuild' import { JSONC_PARSER_ESM_ALIAS } from '../build-plugins/jsonc-parser-esm.ts' import { createHash } from 'node:crypto' -import { - copyFileSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { - RELAY_BUILD_PLATFORMS, - RELAY_VERSION_FILENAME, RELAY_OPENCODE_SQLITE_READER_FILENAME, - WSL_CLAUDE_PROFILE_HELPER_FILENAME, - relayOptionalArtifactFilenames, - isWindowsRelayPlatform, - relayArtifactFilenames + WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../src/shared/relay-artifacts.ts' -import { - parseRequiredRelayAddonArches, - stageRelayWindowsProcessTreeAddon -} from './relay-windows-process-tree-staging.mjs' const __dirname = import.meta.dirname // Why: the script lives under config/scripts, so go two levels up to reach the repo root. const ROOT = join(__dirname, '..', '..') -const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') -const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') -const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts') -const OPENCODE_SQLITE_READER_ENTRY = join( - ROOT, - 'src', - 'main', - 'ai-vault', - 'session-scanner-opencode-sqlite-process-entry.ts' -) -const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join( - ROOT, - 'src', - 'main', - 'native-chat', - 'wsl-transcript-fs-process-entry.ts' -) -const MANAGED_HOOK_RUNTIME_ENTRY = join( - ROOT, - 'src', - 'main', - 'agent-hooks', - 'managed-hook-runtime.ts' -) -const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' -const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( - ROOT, - 'config', - 'relay-assets', - NODE_PTY_CONSOLE_LIST_PATCH_FILENAME -) -const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs' -const NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE = join( - ROOT, - 'config', - 'relay-assets', - NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME -) -const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' -const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join( - ROOT, - 'config', - 'relay-assets', - NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME -) -// Written by build-windows-process-tree-relay-addon.mjs on Windows, or downloaded -// from CI's relay-windows-process-tree artifact on other OSes. -const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree') - -// Per-arch rather than a flag because arm64 needs the MSVC ARM64 cross toolset, -// an optional VS component: where it is absent that relay should fall back to -// the scan, not fail the release the x64 relay is riding on. -const REQUIRED_ADDON_ARCHES = parseRequiredRelayAddonArches( - process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS -) - -// Why: lets the packaging contract test build into a temp tree instead of -// clobbering a developer's out/relay or racing tests that read it. +// Why: lets a contract test build into a temp tree instead of clobbering a developer's out/relay. const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay') - const RELAY_VERSION = '0.1.0' -async function buildRelayBundles(outDir) { - await build({ - entryPoints: [RELAY_ENTRY], - alias: JSONC_PARSER_ESM_ALIAS, +const outDir = join(OUT_ROOT, 'wsl') +// Why: the SSH relay's per-platform directories are gone; a stale one must not ship. +rmSync(OUT_ROOT, { recursive: true, force: true }) +mkdirSync(outDir, { recursive: true }) + +const bundle = (entry, outfile, extra = {}) => + build({ + entryPoints: [join(ROOT, entry)], bundle: true, platform: 'node', target: 'node18', format: 'cjs', - outfile: join(outDir, 'relay.js'), - // Native addons cannot be bundled — they must exist on the remote host. - // The relay gracefully degrades when they are absent. - external: ['node-pty', '@parcel/watcher', 'electron'], + outfile: join(outDir, outfile), sourcemap: false, minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } + define: { 'process.env.NODE_ENV': '"production"' }, + ...extra }) - await build({ - entryPoints: [WATCHER_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'relay-watcher.js'), - external: ['@parcel/watcher'], - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - - await build({ - entryPoints: [AI_VAULT_SERVICE_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'relay-ai-vault-service.js'), - external: ['electron'], - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - - await build({ - entryPoints: [OPENCODE_SQLITE_READER_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, RELAY_OPENCODE_SQLITE_READER_FILENAME), - external: ['electron'], - sourcemap: false, - minify: true, - define: { 'process.env.NODE_ENV': '"production"' } - }) - - // Why beside the service: the spawn resolves this child next to its own - // bundle, and a relay host has no desktop out/main to fall back to. - await build({ - entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'), - external: ['electron'], - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - - await build({ - entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'managed-hook-runtime.js'), - // Why: jsonc-parser's default UMD build keeps relative dynamic requires - // that break after bundling; its ESM entry is equivalent and self-contained. - alias: JSONC_PARSER_ESM_ALIAS, - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) -} - -let bundledSourceDir -let bundledFilenames = [] - -for (const platform of RELAY_BUILD_PLATFORMS) { - const outDir = join(OUT_ROOT, platform) - // Why: a stale companion left by an earlier build would otherwise satisfy the - // manifest check and be hashed into .version, shipping mixed-generation bytes. - rmSync(outDir, { recursive: true, force: true }) - mkdirSync(outDir, { recursive: true }) - - // The JavaScript selects its host at runtime; only native addons and patches vary. - if (bundledSourceDir) { - for (const filename of bundledFilenames) { - copyFileSync(join(bundledSourceDir, filename), join(outDir, filename)) - } - } else { - await buildRelayBundles(outDir) - bundledSourceDir = outDir - bundledFilenames = readdirSync(outDir) - } - - if (isWindowsRelayPlatform(platform)) { - copyFileSync( - NODE_PTY_CONSOLE_LIST_PATCH_SOURCE, - join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) - ) - copyFileSync( - NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE, - join(outDir, NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME) - ) - } - copyFileSync( - NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE, - join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME) - ) - stageRelayWindowsProcessTreeAddon({ - platform, - outDir, - buildDir: WINDOWS_PROCESS_TREE_BUILD_DIR, - requiredArches: REQUIRED_ADDON_ARCHES - }) - - // Why: include a content hash so the deploy check detects code changes even - // when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a - // companion-only change still selects a fresh immutable relay directory. - const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) +function stampVersion(bundleFile, versionFile) { const hash = createHash('sha256') - for (const filename of expected) { - const artifactPath = join(outDir, filename) - if (!existsSync(artifactPath)) { - throw new Error( - `Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` + - 'Add the build step, or drop it from src/shared/relay-artifacts.ts.' - ) - } - hash.update(readFileSync(artifactPath)) - } - // Why hashed only when present: a relay carrying the native addon answers - // differently from one that falls back to the scan, so the two must not share - // an immutable directory -- but a build without it is still valid. - for (const filename of relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))) { - const artifactPath = join(outDir, filename) - if (existsSync(artifactPath)) { - hash.update(readFileSync(artifactPath)) - } - } - const contentHash = hash.digest('hex').slice(0, 12) - - // Close the loop: an artifact emitted here but absent from the manifest would - // ship unhashed and unprobed — exactly how the WSL helper went missing. - const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME) - const declared = [ - ...expected, - ...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)) - ] - const undeclared = emitted.filter((name) => !declared.includes(name)) - if (undeclared.length > 0) { - throw new Error( - `Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` + - 'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.' - ) - } - writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`) - - console.log(`Built relay for ${platform} → ${outDir}/relay.js`) -} - -// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL -// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher), -// so a single platform-independent bundle suffices; it ships inside the -// Windows app via the same out/relay extraResources mapping. -{ - const wslHookEntry = join(ROOT, 'src', 'wsl-guest', 'wsl-agent-hook-relay.ts') - const wslBrowserNetworkEntry = join(ROOT, 'src', 'wsl-guest', 'wsl-browser-network-relay.ts') - const outDir = join(OUT_ROOT, 'wsl') - mkdirSync(outDir, { recursive: true }) - await build({ - entryPoints: [wslHookEntry], - alias: JSONC_PARSER_ESM_ALIAS, - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'wsl-agent-hook-relay.js'), - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js')) - const hash = createHash('sha256').update(content).digest('hex').slice(0, 12) - writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`) - console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`) - - await build({ - entryPoints: [wslBrowserNetworkEntry], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'wsl-browser-network-relay.js'), - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - const browserNetworkContent = readFileSync(join(outDir, 'wsl-browser-network-relay.js')) - const browserNetworkHash = createHash('sha256') - .update(browserNetworkContent) + .update(readFileSync(join(outDir, bundleFile))) .digest('hex') .slice(0, 12) - writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`) - console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`) - - // Why here, not the relay dirs: only the desktop runs it, inside WSL; SSH hosts never upload it. - await build({ - entryPoints: [join(ROOT, 'src/main/claude-accounts/claude-profile-wsl-entry.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, WSL_CLAUDE_PROFILE_HELPER_FILENAME), - minify: true, - define: { 'process.env.NODE_ENV': '"production"' } - }) + writeFileSync(join(outDir, versionFile), `${RELAY_VERSION}+${hash}`) } -console.log('Relay build complete.') +await bundle('src/wsl-guest/wsl-agent-hook-relay.ts', 'wsl-agent-hook-relay.js', { + alias: JSONC_PARSER_ESM_ALIAS +}) +stampVersion('wsl-agent-hook-relay.js', '.version') +console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`) + +await bundle('src/wsl-guest/wsl-browser-network-relay.ts', 'wsl-browser-network-relay.js') +stampVersion('wsl-browser-network-relay.js', '.browser-network-version') +console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`) + +await bundle( + 'src/main/claude-accounts/claude-profile-wsl-entry.ts', + WSL_CLAUDE_PROFILE_HELPER_FILENAME +) +await bundle( + 'src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts', + RELAY_OPENCODE_SQLITE_READER_FILENAME, + { external: ['electron'] } +) + +console.log('WSL guest build complete.') diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index f02525a259b..faae98eb800 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -193,9 +193,6 @@ "config/scripts/msbuild-file-tracking.test.mjs": 44, "config/scripts/native-chat-compaction-pagination.test.mjs": 446, "config/scripts/node-old-space-limit.test.mjs": 50, - "config/scripts/node-pty-console-list-agent-patch.test.mjs": 57, - "config/scripts/node-pty-master-cloexec-patch.test.mjs": 121, - "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 140, "config/scripts/node-server-change-scope.test.mjs": 2687, "config/scripts/node-server-concurrency.test.mjs": 173, "config/scripts/node-server-qualification.test.mjs": 62, @@ -255,10 +252,7 @@ "config/scripts/rebuild-native-deps.test.mjs": 1263, "config/scripts/reclaim-dev-electron-bundles.test.ts": 64, "config/scripts/regenerate-xterm-patches.test.mjs": 249, - "config/scripts/relay-artifact-manifest.test.mjs": 1065, - "config/scripts/relay-asset-line-ending-pin.test.mjs": 144, "config/scripts/relay-windows-process-tree-prepared-addon.test.mjs": 168, - "config/scripts/relay-windows-process-tree-staging.test.mjs": 82, "config/scripts/release-cut-token-permissions.test.mjs": 199, "config/scripts/release-javascript-artifact.test.mjs": 253, "config/scripts/release-javascript-parity.test.mjs": 98, @@ -286,7 +280,6 @@ "config/scripts/sort-comparator-performance-plugin.test.mjs": 283, "config/scripts/space-sharing-copy.test.ts": 96, "config/scripts/ssh-browser-e2e-routing.test.mjs": 176, - "config/scripts/ssh-relay-import-boundary.test.mjs": 5796, "config/scripts/stable-release-tag-selection-budget.test.mjs": 149, "config/scripts/static-appimage-package-contract.test.mjs": 83, "config/scripts/static-module-specifiers.test.mjs": 277, @@ -586,9 +579,7 @@ "src/main/agent-hooks/managed-hook-command-contract.test.ts": 389, "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 68, "src/main/agent-hooks/managed-hook-install-lock.test.ts": 332, - "src/main/agent-hooks/managed-hook-local-filesystem.test.ts": 547, "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 99, - "src/main/agent-hooks/managed-hook-runtime.test.ts": 464, "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 410, "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 498, "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 1579, @@ -758,7 +749,6 @@ "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 55, "src/main/ai-vault/antigravity-ide-history.test.ts": 322, "src/main/ai-vault/antigravity-index-file-admission.test.ts": 115, - "src/main/ai-vault/antigravity-index-reader.test.ts": 179, "src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts": 157, "src/main/ai-vault/antigravity-recorded-history.test.ts": 281, "src/main/ai-vault/cached-session-list-wsl-probe.test.ts": 472, @@ -771,17 +761,6 @@ "src/main/ai-vault/openclaw-session-layout.test.ts": 172, "src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts": 1150, "src/main/ai-vault/remote-session-content-lines.test.ts": 84, - "src/main/ai-vault/remote-session-large-transcripts.test.ts": 1388, - "src/main/ai-vault/remote-session-parse-cache.test.ts": 305, - "src/main/ai-vault/remote-session-scan-batching.test.ts": 61, - "src/main/ai-vault/remote-session-scanner-kiro.test.ts": 168, - "src/main/ai-vault/remote-session-scanner-muse.test.ts": 234, - "src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts": 261, - "src/main/ai-vault/remote-session-scanner-opencode.test.ts": 235, - "src/main/ai-vault/remote-session-scanner-sources.test.ts": 187, - "src/main/ai-vault/remote-session-scanner.test.ts": 388, - "src/main/ai-vault/remote-session-sidecar-observation.test.ts": 235, - "src/main/ai-vault/remote-session-stream-lifecycle.test.ts": 98, "src/main/ai-vault/runtime-session-scanner.test.ts": 256, "src/main/ai-vault/runtime-session-search-call.test.ts": 44, "src/main/ai-vault/session-cwd-bucket-layouts.test.ts": 59, @@ -2133,7 +2112,6 @@ "src/main/git/settled-diff-cache-bounds.test.ts": 76, "src/main/git/source-control/blob-absence-real-git.test.ts": 447, "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 192, - "src/main/git/source-control/bulk-pathspec-stdin.test.ts": 492, "src/main/git/source-control/resolve-git-dir.test.ts": 59, "src/main/git/source-control/stage-worktree-changes.test.ts": 1305, "src/main/git/source-control/staging-discard-index-safety.test.ts": 941, @@ -2142,9 +2120,7 @@ "src/main/git/status-branch-compare.test.ts": 239, "src/main/git/status-branch-line-total-exec-contract.test.ts": 892, "src/main/git/status-branch-line-total-real-git.test.ts": 836, - "src/main/git/status-branch-line-total-relay-parity.test.ts": 528, "src/main/git/status-conflict-operations.test.ts": 163, - "src/main/git/status-conflict-overlap.bench.test.ts": 312, "src/main/git/status-cquoted-paths.test.ts": 374, "src/main/git/status-diff-settled-cache.test.ts": 322, "src/main/git/status-diff.test.ts": 260, @@ -2880,7 +2856,6 @@ "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 1390, "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 1298, "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 1305, - "src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts": 1327, "src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts": 1478, "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 1443, "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 1459, @@ -3829,7 +3804,6 @@ "src/main/plugins/plugin-dev-watcher.test.ts": 509, "src/main/plugins/plugin-discovery.test.ts": 325, "src/main/plugins/plugin-enablement.test.ts": 366, - "src/main/plugins/plugin-host-conformance.test.ts": 363, "src/main/plugins/plugin-host-methods.test.ts": 300, "src/main/plugins/plugin-host-process.test.ts": 249, "src/main/plugins/plugin-host-runtime.test.ts": 258, @@ -3975,7 +3949,6 @@ "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 58, "src/main/providers/ssh-pty-spawn-env.test.ts": 49, "src/main/providers/ssh-pty-write.test.ts": 473, - "src/main/providers/ssh-review-draft-context.test.ts": 936, "src/main/providers/ssh-worktree-catalog-authority.test.ts": 1068, "src/main/providers/stable-foreground-process.test.ts": 68, "src/main/providers/startup-line-typed-length.live-shell.test.ts": 165, @@ -4965,7 +4938,6 @@ "src/main/runtime/runtime-extraction-regressions.test.ts": 4478, "src/main/runtime/runtime-failed-local-worktree-removal.test.ts": 723, "src/main/runtime/runtime-file-listing-producer-budgets.test.ts": 528, - "src/main/runtime/runtime-file-path-existence.test.ts": 911, "src/main/runtime/runtime-file-target-execution-host.test.ts": 4300, "src/main/runtime/runtime-file-upload-decode.test.ts": 421, "src/main/runtime/runtime-folder-workspace.test.ts": 65, @@ -5998,211 +5970,6 @@ "src/preload/ssh-authority-forwarding.test.ts": 1025, "src/preload/updater-package-recovery.test.ts": 867, "src/preload/usage-provider-api.test.ts": 52, - "src/relay/agent-exec-disposal.test.ts": 174, - "src/relay/agent-exec-handler-windows.test.ts": 139, - "src/relay/agent-exec-handler.test.ts": 179, - "src/relay/agent-exec-shell-environment.test.ts": 191, - "src/relay/agent-hook-retired-pane-suppression.test.ts": 647, - "src/relay/agent-status-store-relay-context.test.ts": 314, - "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts": 292, - "src/relay/agent-workspace-trust-spawn.test.ts": 337, - "src/relay/ai-vault-handler.test.ts": 811, - "src/relay/ai-vault-opencode-reader.test.ts": 438, - "src/relay/ai-vault-service-cancellation.test.ts": 437, - "src/relay/ai-vault-service-client.test.ts": 96, - "src/relay/ai-vault-service-ready-retention.test.ts": 222, - "src/relay/ai-vault-service-restart-policy.test.ts": 51, - "src/relay/ai-vault-service-spawn.test.ts": 60, - "src/relay/context.test.ts": 46, - "src/relay/external-automation-provider-catalog.test.ts": 77, - "src/relay/external-automations-handler-log-path.test.ts": 49, - "src/relay/external-automations-handler.test.ts": 73, - "src/relay/freebuff-status-projection.test.ts": 1180, - "src/relay/fs-file-stream-shutdown.test.ts": 289, - "src/relay/fs-handler-doc-preview.test.ts": 227, - "src/relay/fs-handler-file-range-dispatch.test.ts": 705, - "src/relay/fs-handler-file-range.test.ts": 217, - "src/relay/fs-handler-git-search-capacity.test.ts": 157, - "src/relay/fs-handler-git-search-real-capacity.test.ts": 131, - "src/relay/fs-handler-git-search.test.ts": 142, - "src/relay/fs-handler-list-files-cancel.test.ts": 137, - "src/relay/fs-handler-list-files-candidates.integration.test.ts": 1201, - "src/relay/fs-handler-list-files-candidates.test.ts": 655, - "src/relay/fs-handler-list-files-diagnosis-cancel.test.ts": 101, - "src/relay/fs-handler-list-files-ignored.test.ts": 1166, - "src/relay/fs-handler-list-files-path-framing.test.ts": 152, - "src/relay/fs-handler-list-files-result-limit.test.ts": 465, - "src/relay/fs-handler-readdir-fallback.test.ts": 73, - "src/relay/fs-handler-ripgrep-fallback.test.ts": 370, - "src/relay/fs-handler-stream.test.ts": 449, - "src/relay/fs-handler.test.ts": 617, - "src/relay/fs-list-files-cancel.integration.test.ts": 352, - "src/relay/fs-list-files-large-response.integration.test.ts": 865, - "src/relay/fs-list-files-scan-coordinator.test.ts": 64, - "src/relay/fs-markdown-document-launch.test.ts": 75, - "src/relay/fs-markdown-document-listing.test.ts": 231, - "src/relay/fs-path-metadata-requests.test.ts": 64, - "src/relay/fs-path-metadata-symlink-concurrency.test.ts": 63, - "src/relay/fs-search-cancellation.test.ts": 438, - "src/relay/fs-search-diagnosis-cancellation.test.ts": 164, - "src/relay/fs-search-errors-real.test.ts": 157, - "src/relay/fs-search-line-fragments.test.ts": 197, - "src/relay/fs-stream-pty-echo-backpressure.integration.test.ts": 555, - "src/relay/fs-stream-registry-shutdown.test.ts": 66, - "src/relay/git-branch-delete-refusal-parity.test.ts": 201, - "src/relay/git-command-admission.test.ts": 141, - "src/relay/git-diff-cancellation.test.ts": 302, - "src/relay/git-exec-validator.test.ts": 93, - "src/relay/git-handler-blob-readers.test.ts": 73, - "src/relay/git-handler-branch-cleanup.test.ts": 119, - "src/relay/git-handler-branch-compare.test.ts": 135, - "src/relay/git-handler-branch-diff-equivalence.test.ts": 1113, - "src/relay/git-handler-branch-diff.test.ts": 298, - "src/relay/git-handler-check-ignore.test.ts": 50, - "src/relay/git-handler-clone-progress-capture.test.ts": 318, - "src/relay/git-handler-commit-metadata.test.ts": 488, - "src/relay/git-handler-diff-blobs.test.ts": 309, - "src/relay/git-handler-diff-read-coalescing.test.ts": 695, - "src/relay/git-handler-diff-retry.test.ts": 204, - "src/relay/git-handler-file-diff.test.ts": 921, - "src/relay/git-handler-fork-remote-exec.test.ts": 245, - "src/relay/git-handler-pull-reconciliation.test.ts": 866, - "src/relay/git-handler-push-target.test.ts": 66, - "src/relay/git-handler-remote-sync.test.ts": 1449, - "src/relay/git-handler-review-draft.test.ts": 350, - "src/relay/git-handler-stage-worktree-scope.test.ts": 905, - "src/relay/git-handler-staging.test.ts": 355, - "src/relay/git-handler-status-ops.test.ts": 140, - "src/relay/git-handler-submodule-cache-invalidation.test.ts": 248, - "src/relay/git-handler-submodule-ops.test.ts": 138, - "src/relay/git-handler-submodule-status-cancellation.test.ts": 193, - "src/relay/git-handler-termination.test.ts": 219, - "src/relay/git-handler-utils.test.ts": 46, - "src/relay/git-handler-working-tree-changes.test.ts": 879, - "src/relay/git-handler-worktree-clean.test.ts": 83, - "src/relay/git-handler-worktree-existence-cancellation.test.ts": 72, - "src/relay/git-handler-worktree-git-capabilities.test.ts": 232, - "src/relay/git-handler-worktree-inspection.test.ts": 371, - "src/relay/git-handler-worktree-list-authority.test.ts": 209, - "src/relay/git-handler-worktree-list-cancellation.test.ts": 207, - "src/relay/git-handler-worktree-list.test.ts": 72, - "src/relay/git-handler-worktree-ops.test.ts": 117, - "src/relay/git-handler-worktree-paths.test.ts": 88, - "src/relay/git-handler-worktree-provisioning.test.ts": 1195, - "src/relay/git-handler.test.ts": 543, - "src/relay/git-porcelain-local-parity.test.ts": 476, - "src/relay/git-push-target-local-parity.test.ts": 335, - "src/relay/git-response-pty-echo-backpressure.integration.test.ts": 1073, - "src/relay/git-response-stream-ownership.test.ts": 66, - "src/relay/git-response-stream-retention.test.ts": 456, - "src/relay/git-response-stream-shutdown.test.ts": 85, - "src/relay/git-review-draft-binary-compatibility.test.ts": 133, - "src/relay/git-status-branch-line-total.test.ts": 329, - "src/relay/git-status-upstream-negative-cache.test.ts": 319, - "src/relay/git-stdout-stream.test.ts": 141, - "src/relay/git-working-file-read.test.ts": 74, - "src/relay/hermes-run-correlation.test.ts": 49, - "src/relay/hermes-run-history.test.ts": 120, - "src/relay/integration.test.ts": 1263, - "src/relay/managed-hook-installer.test.ts": 78, - "src/relay/node-pty-binding-survey.test.ts": 428, - "src/relay/node-pty-unavailable-diagnosis.test.ts": 294, - "src/relay/omp-fresh-launch-environment.test.ts": 1156, - "src/relay/opencode-hook-selection.test.ts": 872, - "src/relay/port-scan-handler.test.ts": 193, - "src/relay/pty-handler-agent-launch-token.test.ts": 597, - "src/relay/pty-handler-agent-workspace-trust.test.ts": 501, - "src/relay/pty-handler-attach-replay.test.ts": 615, - "src/relay/pty-handler-dispose-lifecycle.test.ts": 685, - "src/relay/pty-handler-grace-timer.test.ts": 558, - "src/relay/pty-handler-immediate-descendants.test.ts": 685, - "src/relay/pty-handler-inventory-process-evidence.test.ts": 843, - "src/relay/pty-handler-opencode-revive.test.ts": 554, - "src/relay/pty-handler-output-drain-differential.test.ts": 728, - "src/relay/pty-handler-output-streaming.test.ts": 972, - "src/relay/pty-handler-owner-inventory-clone-budget.test.ts": 571, - "src/relay/pty-handler-pi-owner-env.test.ts": 670, - "src/relay/pty-handler-resize-stale-pty.test.ts": 715, - "src/relay/pty-handler-retired-pane-surface.test.ts": 649, - "src/relay/pty-handler-revive.test.ts": 701, - "src/relay/pty-handler-shell-recovery.test.ts": 850, - "src/relay/pty-handler-shell-resolution.test.ts": 665, - "src/relay/pty-handler-shutdown-signals.test.ts": 646, - "src/relay/pty-handler-source-publication.test.ts": 1260, - "src/relay/pty-handler-spawn-admission.test.ts": 936, - "src/relay/pty-handler-spawn-cwd.test.ts": 692, - "src/relay/pty-handler-spawn-environment.test.ts": 700, - "src/relay/pty-handler-startup-command-delivery.test.ts": 780, - "src/relay/pty-handler-startup-command-staging.test.ts": 599, - "src/relay/pty-handler-windows-child-process-evidence.test.ts": 673, - "src/relay/pty-shell-launch.test.ts": 343, - "src/relay/pty-shell-utils-process-cycles.test.ts": 120, - "src/relay/pty-shell-utils.test.ts": 151, - "src/relay/pty-source-credit-ledger.test.ts": 238, - "src/relay/pty-source-credit-scheduler.test.ts": 68, - "src/relay/pty-source-sent-boundaries.test.ts": 106, - "src/relay/relay-agent-process-lifetime.test.ts": 135, - "src/relay/relay-bundled-ripgrep.test.ts": 128, - "src/relay/relay-daemon-fatal-reap.test.ts": 1498, - "src/relay/relay-diagnostic-log.test.ts": 51, - "src/relay/relay-endpoint-credential-publication.test.ts": 2888, - "src/relay/relay-filesystem-watch-registry.test.ts": 269, - "src/relay/relay-grace-branch.test.ts": 70, - "src/relay/relay-grace-lifecycle.test.ts": 319, - "src/relay/relay-handshake-roundtrip.test.ts": 474, - "src/relay/relay-launch-options.test.ts": 63, - "src/relay/relay-oversized-notification-survival.test.ts": 269, - "src/relay/relay-pid-publication.test.ts": 53, - "src/relay/relay-primary-channel.test.ts": 309, - "src/relay/relay-pty-consumer-owner-displacement.test.ts": 316, - "src/relay/relay-pty-publication-admission.test.ts": 405, - "src/relay/relay-pty-source-cancellation-exit.test.ts": 306, - "src/relay/relay-pty-source-exit-publication.test.ts": 78, - "src/relay/relay-pty-source-publication.test.ts": 430, - "src/relay/relay-pty-source-recovery-completion.test.ts": 46, - "src/relay/relay-pty-source-recovery-interleavings.test.ts": 334, - "src/relay/relay-pty-source-recovery-window.test.ts": 398, - "src/relay/relay-pty-source-restore-retry.test.ts": 430, - "src/relay/relay-pty-source-send-scheduler.test.ts": 86, - "src/relay/relay-pty-source-superseded-activation.test.ts": 273, - "src/relay/relay-reconnect-listener-credential-gate.test.ts": 92, - "src/relay/relay-reconnect-listener-peer-end.test.ts": 351, - "src/relay/relay-reconnect-listener-peer-write-error.test.ts": 358, - "src/relay/relay-ripgrep-cwd-env.test.ts": 138, - "src/relay/relay-runtime-owned-process-shutdown.test.ts": 1354, - "src/relay/relay-runtime-self-test.test.ts": 73, - "src/relay/relay-socket-peer-close.test.ts": 51, - "src/relay/relay-watch-root-capacity.test.ts": 256, - "src/relay/relay-watcher-event-emitter.test.ts": 686, - "src/relay/relay-watcher-frame-chunking.test.ts": 598, - "src/relay/relay-watcher-parent-removal.test.ts": 139, - "src/relay/relay-watcher-pending-setup-waiters.test.ts": 176, - "src/relay/relay-watcher-setup-wait.test.ts": 214, - "src/relay/relay-watcher-shared-byte-index.test.ts": 842, - "src/relay/relay-watcher-shutdown.test.ts": 244, - "src/relay/relay-windows-path-ripgrep-cache.test.ts": 84, - "src/relay/relay-windows-path-ripgrep.test.ts": 68, - "src/relay/remote-artifact-cli-input.test.ts": 122, - "src/relay/remote-cli-env.test.ts": 41, - "src/relay/remote-cli-stdin.test.ts": 46, - "src/relay/remote-cli-timeout.test.ts": 66, - "src/relay/retired-pane-surfaces.test.ts": 48, - "src/relay/rotating-log-writer.test.ts": 91, - "src/relay/session-search-transport.test.ts": 325, - "src/relay/skill-install-handler.test.ts": 746, - "src/relay/skill-upload-multi-relay.integration.test.ts": 956, - "src/relay/ssh-pty-consumer-session-adapter.test.ts": 317, - "src/relay/ssh-pty-source-credit-adapter.test.ts": 81, - "src/relay/subprocess-tree-termination.test.ts": 56, - "src/relay/subprocess.test.ts": 24561, - "src/relay/terminal-history-wsl.test.ts": 76, - "src/relay/terminal-history.test.ts": 75, - "src/relay/windows-port-scan.test.ts": 169, - "src/relay/windows-port-scan.win32.test.ts": 83, - "src/relay/workspace-session-handler.test.ts": 269, - "src/relay/workspace-snapshot-publication.test.ts": 271, - "src/relay/workspace-space-scan-du-capacity.test.ts": 83, - "src/relay/workspace-space-scan.test.ts": 72, "src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts": 1064, "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 1261, "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 646, @@ -11579,17 +11346,12 @@ "src/shared/pull-request-generation.test.ts": 71, "src/shared/qoder-agent.test.ts": 293, "src/shared/qoder-cn-launch-recipe.test.ts": 147, - "src/shared/quick-open-directory-reader.test.ts": 67, - "src/shared/quick-open-expansion-paths.test.ts": 72, "src/shared/quick-open-filter.renderer-safety.test.ts": 59, "src/shared/quick-open-filter.test.ts": 93, "src/shared/quick-open-fuzzy-scan.test.ts": 996, "src/shared/quick-open-git-directory-collapse.test.ts": 88, - "src/shared/quick-open-install-rg.test.ts": 55, "src/shared/quick-open-listing-limits.test.ts": 45, "src/shared/quick-open-query-target.test.ts": 64, - "src/shared/quick-open-readdir-memory.test.ts": 100, - "src/shared/quick-open-readdir-walk.test.ts": 230, "src/shared/quick-open-recent-candidates.test.ts": 63, "src/shared/quick-open-transport-budget.test.ts": 55, "src/shared/raster-image-base64-preview.test.ts": 391, @@ -11988,7 +11750,6 @@ "src/wsl-guest/dispatcher-timeout.test.ts": 247, "src/wsl-guest/dispatcher-writer-admission.test.ts": 111, "src/wsl-guest/dispatcher.test.ts": 414, - "src/wsl-guest/filesystem-capacity-error-wire.test.ts": 281, "src/wsl-guest/legacy-relay-publication-ledger.test.ts": 50, "src/wsl-guest/omp-config-root.test.ts": 177, "src/wsl-guest/opencode-startup-prompt-install.test.ts": 139, @@ -12081,7 +11842,6 @@ "tests/tools/omp-close-lifecycle.test.mjs": 283, "tests/tools/omp-config-root-local-shell.test.mjs": 249, "tests/tools/omp-config-root-relay-shell.test.mjs": 162, - "tests/tools/omp-relay-close-lifecycle.test.mjs": 581, "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 114, "tests/tools/relay-bench/region-probe-replay.test.mjs": 84, "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 102, diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 3e0cdb67cd8..3821d25e560 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -27,7 +27,6 @@ export const UNIT_EXCLUDE = [ 'src/main/terminal-history-fish-session.node-pty.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', - 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts', 'src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts', 'src/shared/fish-query-reply-child-stdin.node-pty.test.ts', diff --git a/config/scripts/hermes-run-correlation-benchmark.mjs b/config/scripts/hermes-run-correlation-benchmark.mjs index 364774577cf..f392136b5cf 100644 --- a/config/scripts/hermes-run-correlation-benchmark.mjs +++ b/config/scripts/hermes-run-correlation-benchmark.mjs @@ -17,11 +17,8 @@ const key = (seconds) => .replace('T', '_') .slice(0, 15) try { - for (const host of ['native', 'relay']) { - const entry = - host === 'native' - ? 'src/main/automations/hermes-cron-run-content.ts' - : 'src/relay/hermes-run-correlation.ts' + for (const host of ['native']) { + const entry = 'src/main/automations/hermes-cron-run-content.ts' const readers = [] for (const mode of baselineDirectory ? ['baseline', 'current'] : ['current']) { const bundle = join(fixture, `${host}-${mode}.cjs`) diff --git a/config/scripts/node-pty-console-list-agent-patch.test.mjs b/config/scripts/node-pty-console-list-agent-patch.test.mjs deleted file mode 100644 index 07e97c54832..00000000000 --- a/config/scripts/node-pty-console-list-agent-patch.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { createRequire } from 'node:module' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { - assertPatchedNodePtyConsoleListAgent, - patchNodePtyConsoleListAgent -} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs') -const projectDir = resolve(import.meta.dirname, '..', '..') -const cleanupDirs = [] - -afterEach(() => { - for (const dir of cleanupDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('Windows SSH relay node-pty console-list patch', () => { - it('installs and verifies the fallback idempotently', () => { - const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource()) - - patchNodePtyConsoleListAgent(fixture.root) - const once = readFileSync(fixture.agentPath, 'utf8') - expect(once).toContain('consoleProcessList = [shellPid];') - expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false) - expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow() - - patchNodePtyConsoleListAgent(fixture.root) - expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once) - }) - - it('refuses a different package version or unexpected agent source', () => { - const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource()) - expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0') - - const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`) - expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty') - - const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource()) - patchNodePtyConsoleListAgent(tamperedPatch.root) - writeFileSync( - tamperedPatch.agentPath, - `${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift` - ) - expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed') - }) -}) - -function writeNodePtyFixture(version, agentSource) { - const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-')) - cleanupDirs.push(root) - const nodePtyDir = join(root, 'node_modules', 'node-pty') - const libDir = join(nodePtyDir, 'lib') - const agentPath = join(libDir, 'conpty_console_list_agent.js') - mkdirSync(libDir, { recursive: true }) - writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) - writeFileSync(agentPath, agentSource) - return { root, agentPath } -} - -function publishedAgentSource() { - return [ - '"use strict";', - '/**', - ' * Copyright (c) 2019, Microsoft Corporation (MIT License).', - ' *', - ' * This module fetches the console process list for a particular PID. It must be', - ' * called from a different process (child_process.fork) as there can only be a', - ' * single console attached to a process.', - ' */', - 'Object.defineProperty(exports, "__esModule", { value: true });', - 'var utils_1 = require("./utils");', - "var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;", - 'var shellPid = parseInt(process.argv[2], 10);', - 'var consoleProcessList = getConsoleProcessList(shellPid);', - 'process.send({ consoleProcessList: consoleProcessList });', - 'process.exit(0);', - '//# sourceMappingURL=conpty_console_list_agent.js.map' - ].join('\n') -} diff --git a/config/scripts/node-pty-master-cloexec-patch.test.mjs b/config/scripts/node-pty-master-cloexec-patch.test.mjs deleted file mode 100644 index e323c5d61c8..00000000000 --- a/config/scripts/node-pty-master-cloexec-patch.test.mjs +++ /dev/null @@ -1,333 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { - SKIP_MARKER_FILENAME, - applyNodePtyMasterCloexecPatch, - assertPatchedNodePtyMasterCloexecSource, - patchNodePtyMasterCloexecSource, - revertNodePtyMasterCloexecSource -} = require('../relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs') - -// Byte-exact src/unix/pty.cc from the npm tarball the relay installs. The patch is keyed by its -// sha256, so a fixture that drifted from what npm ships would make every assertion below vacuous. -const STOCK_SOURCE = readFileSync( - resolve(import.meta.dirname, '__fixtures__', 'node-pty-1.1.0-unix-pty.cc'), - 'utf8' -) -const projectDir = resolve(import.meta.dirname, '..', '..') -const cleanupDirs = [] - -afterEach(() => { - for (const dir of cleanupDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('SSH relay node-pty pty fd-leak patch', () => { - it('adds the forkpty close-on-exec call and reverts to the published bytes', () => { - const fixture = writeRelayFixture() - - expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(true) - const patched = readFileSync(fixture.sourcePath, 'utf8') - expect(patched).toContain('pty_cloexec(int fd)') - expect(patched).toContain('if (pty_cloexec(master) == -1)') - expect(() => assertPatchedNodePtyMasterCloexecSource(fixture.root)).not.toThrow() - - expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(false) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(patched) - - expect(revertNodePtyMasterCloexecSource(fixture.root)).toBe(true) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('rewrites the Apple branch, which is the only one macOS executes', () => { - const fixture = writeRelayFixture() - patchNodePtyMasterCloexecSource(fixture.root) - const patched = readFileSync(fixture.sourcePath, 'utf8') - - // Stock's cleanup never runs: the first posix_openpt() already returns >= 2, so the loop - // breaks with count == 0 -- and where it does run it closes low_fds[count], never low_fds[0]. - expect(STOCK_SOURCE).toContain('for (; count > 0; count--) {') - expect(patched).not.toContain('for (; count > 0; count--) {') - expect(patched).toContain('int low_fds[3] = {-1, -1, -1};') - expect(patched).toContain('for (size_t i = 0; i <= count && i < 3; i++) {') - - // `default:` sits in the `#else` arm of PtyFork's `#if defined(__APPLE__)`, so marking only - // the forkpty call site left the master macOS actually opens unmarked. - expect(patched).toContain( - ' if (pty_cloexec(master) == -1) {\n' + - ' throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");\n' + - ' }\n#else\n' - ) - }) - - it('refuses a different node-pty version or an unrecognized source', () => { - const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' }) - expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0') - - const drifted = writeRelayFixture({ - source: `${STOCK_SOURCE}\n// drift\n` - }) - expect(() => patchNodePtyMasterCloexecSource(drifted.root)).toThrow('unexpected node-pty') - - const tampered = writeRelayFixture() - patchNodePtyMasterCloexecSource(tampered.root) - writeFileSync(tampered.sourcePath, `${readFileSync(tampered.sourcePath, 'utf8')}\n// drift\n`) - expect(() => assertPatchedNodePtyMasterCloexecSource(tampered.root)).toThrow('not installed') - }) - - it('keeps the rebuilt addon once a later child no longer inherits the master', () => { - const fixture = writeRelayFixture() - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => { - calls.push('rebuild') - writeBuild(fixture, 'patched-build') - }, - verify: () => 'isolated' - }) - - expect(status).toBe('patched') - expect(calls).toEqual(['rebuild']) - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') - expect(readFileSync(fixture.sourcePath, 'utf8')).not.toBe(STOCK_SOURCE) - expect(existsSync(fixture.backupDir)).toBe(false) - expect(existsSync(fixture.skipMarkerPath)).toBe(false) - }) - - it('keeps a rebuilt addon whose flag /proc could not confirm', () => { - const fixture = writeRelayFixture() - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => writeBuild(fixture, 'patched-build'), - verify: () => 'unverified' - }) - - expect(status).toBe('patched-unverified') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') - }) - - it('restores the working build when the compile fails, and never retries it', () => { - const fixture = writeRelayFixture() - const calls = [] - - const failed = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => { - calls.push('rebuild') - throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') - }, - verify: () => 'isolated' - }) - - expect(failed).toContain('failed:') - expect(failed).toContain('no C++ toolchain') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - expect(existsSync(fixture.backupDir)).toBe(false) - expect(existsSync(fixture.skipMarkerPath)).toBe(true) - - // Bounded, not backed off: a relay directory gets one compile attempt, ever. - const again = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - expect(again).toBe('skipped:earlier-attempt-failed') - expect(calls).toEqual(['rebuild']) - }) - - it('restores the working build when the rebuilt addon still leaks the master', () => { - const fixture = writeRelayFixture() - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => writeBuild(fixture, 'still-leaky-build'), - verify: () => { - throw new Error('rebuilt node-pty still leaks the pty master into later children') - } - }) - - expect(status).toContain('still leaks') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('never compiles on a platform with no pty fds to leak', () => { - const fixture = writeRelayFixture() - const calls = [] - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'win32', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - expect(status).toBe('skipped:unsupported-platform') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('compiles a macOS install out from under its shipped prebuild', () => { - // macOS has no build/ at all: node-pty runs `prebuilds/darwin-`, built from the leaky - // source. Moving `prebuilds` aside is what both arms the rollback and makes node-pty's own - // install script fall through from "prebuild found" to node-gyp. - const fixture = writeRelayFixture({ platform: 'darwin' }) - const prebuildsPresentDuringRebuild = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'darwin', - arch: fixture.arch, - rebuild: () => { - prebuildsPresentDuringRebuild.push(existsSync(fixture.prebuildsDir)) - writeCompiledBuild(fixture, 'patched-build') - }, - verify: () => 'isolated' - }) - - expect(status).toBe('patched') - expect(prebuildsPresentDuringRebuild).toEqual([false]) - expect(readFileSync(fixture.compiledPath, 'utf8')).toBe('patched-build') - // The published tree must hold no unpatched binary: node-pty's loader checks build/Release - // first, but falls back to a prebuild if that ever fails to load. - expect(existsSync(fixture.prebuildsDir)).toBe(false) - expect(existsSync(fixture.backupDir)).toBe(false) - }) - - it('restores the macOS prebuild when the first compile fails', () => { - // A macOS host has no toolchain guarantee at all, so this is the common failure, not the rare - // one -- and the relay has to come back on the prebuild exactly as it was installed. - const fixture = writeRelayFixture({ platform: 'darwin' }) - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'darwin', - arch: fixture.arch, - rebuild: () => { - writeCompiledBuild(fixture, 'half-built') - throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') - }, - verify: () => 'isolated' - }) - - expect(status).toContain('failed:') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') - expect(existsSync(fixture.compiledPath)).toBe(false) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - expect(existsSync(fixture.skipMarkerPath)).toBe(true) - }) - - it('will not rebuild a macOS install that has no prebuild to fall back on', () => { - const fixture = writeRelayFixture({ platform: 'darwin', build: false }) - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'darwin', - arch: fixture.arch, - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - - expect(status).toBe('skipped:no-prebuild') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('leaves an already patched install alone', () => { - const fixture = writeRelayFixture() - patchNodePtyMasterCloexecSource(fixture.root) - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - - expect(status).toBe('already-patched') - expect(calls).toEqual([]) - }) - - it('will not rebuild an install that has no compiled addon to fall back on', () => { - const fixture = writeRelayFixture({ build: false }) - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - - expect(status).toBe('skipped:no-compiled-build') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('discards a backup stranded by an interrupted rebuild', () => { - const fixture = writeRelayFixture() - mkdirSync(fixture.backupDir, { recursive: true }) - writeFileSync(join(fixture.backupDir, 'pty.node'), 'stranded-build') - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => writeBuild(fixture, 'patched-build'), - verify: () => 'isolated' - }) - - expect(status).toBe('patched') - expect(existsSync(fixture.backupDir)).toBe(false) - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') - }) -}) - -/** - * `buildPath` is the working build the patch has to be able to fall back on, which differs by - * platform: Linux compiles into build/Release at install time, macOS runs a shipped prebuild and - * has no build/ at all. `compiledPath` is where the rebuild writes on either. - */ -function writeRelayFixture({ - version = '1.1.0', - source = STOCK_SOURCE, - build = true, - platform = 'linux', - arch = 'arm64' -} = {}) { - const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-')) - cleanupDirs.push(root) - const nodePtyDir = join(root, 'node_modules', 'node-pty') - const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc') - const compiledPath = join(nodePtyDir, 'build', 'Release', 'pty.node') - const prebuildsDir = join(nodePtyDir, 'prebuilds') - mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true }) - writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) - writeFileSync(sourcePath, source) - const fixture = { - root, - arch, - sourcePath, - compiledPath, - prebuildsDir, - buildPath: - platform === 'darwin' ? join(prebuildsDir, `darwin-${arch}`, 'pty.node') : compiledPath, - backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'), - skipMarkerPath: join(root, SKIP_MARKER_FILENAME) - } - if (build) { - writeBuild(fixture, 'stock-build') - } - return fixture -} - -function writeBuild(fixture, contents) { - mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true }) - writeFileSync(fixture.buildPath, contents) -} - -function writeCompiledBuild(fixture, contents) { - mkdirSync(resolve(fixture.compiledPath, '..'), { recursive: true }) - writeFileSync(fixture.compiledPath, contents) -} diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs deleted file mode 100644 index 26428153c0b..00000000000 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ /dev/null @@ -1,364 +0,0 @@ -// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with -// `config/patches/node-pty@1.1.0.patch`. pnpm patches do not cross the SSH boundary, so a relay runs -// the tree `npm install` put there, and every terminal on a Windows SSH host leaked one File handle -// for the life of the relay process. -// -// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- the placement -// the desktop patch uses -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a -// new Process +1/terminal); releasing it after the console-list fork and the native kill is flat. -// -// Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop -// site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where -// upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/codex-pty-rate-limit-probe.ts`) does omit the option and so does run this -// hunk, but no user-visible pane does. The divergence pinned below is about which -// branch each host runs for terminals -- not about a regression in the panes users open. -import { createHash } from 'node:crypto' -import { createRequire } from 'node:module' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { - assertPatchedNodePtyWindowsTeardown, - patchNodePtyWindowsTeardown -} = require('../relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs') -const projectDir = resolve(import.meta.dirname, '..', '..') -const cleanupDirs = [] - -const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js', 'windowsConoutConnection.js'] - -/** The hunks config/patches/node-pty@1.1.0.patch adds to the installed desktop tree. */ -const DESKTOP_HUNKS = { - 'windowsPtyAgent.js': [ - [ - [ - ' this._inSocket.readable = false;', - ' // The non-DLL path previously only flipped `readable`, leaving the', - ' // conin PipeWrap alive until the host exited (#947).', - ' this._inSocket.destroy();', - ' this._outSocket.readable = false;', - '' - ].join('\n'), - [ - ' this._inSocket.readable = false;', - ' this._outSocket.readable = false;', - '' - ].join('\n') - ], - // The useConptyDll branch, which only the DESKTOP runs -- the relay takes the - // non-DLL branch above, where the dispose is already unconditional. Listed here - // so un-applying still yields published; the relay asset needs no counterpart. - [ - [ - ' // Orca: dispose unconditionally, as the non-DLL branch above does.', - " // Waiting for another 'data' event leaks the conout worker on every", - ' // self-exiting shell, because no more data ever arrives (F24).', - ' this._conoutSocketWorker.dispose();', - '' - ].join('\n'), - [ - " this._outSocket.on('data', function () {", - ' _this._conoutSocketWorker.dispose();', - ' });', - '' - ].join('\n') - ] - ], - 'windowsTerminal.js': [ - [ - ' // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.', - null - ], - [' // A ConPTY input-pipe error must retire only this terminal.', null] - ] -} - -function desktopPath(file) { - return join(projectDir, 'node_modules', 'node-pty', 'lib', file) -} - -afterEach(() => { - for (const dir of cleanupDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('Windows SSH relay node-pty ConPTY teardown patch', () => { - // Why reconstruct rather than vendor upstream: the installed tree IS the published file plus the - // desktop's hunks, so un-applying them yields upstream exactly -- and pinning that against this - // asset's own hashes is what fails loudly if either side of the pair moves. - it('takes the desktop error listeners verbatim', () => { - const fixture = writeNodePtyFixture('1.1.0') - patchNodePtyWindowsTeardown(fixture.root) - - expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe( - readFileSync(desktopPath('windowsTerminal.js'), 'utf8') - ) - }) - - // The one hunk that must NOT match the desktop patch, and the reason is measured, not stylistic: - // on the branch a relay runs, releasing conin before `_getConsoleProcessList()` forks aborts - // teardown partway. Desktop terminal panes take the other branch, so no pane is affected either - // way; what this guards is a patch sync putting the early placement onto the relay's branch. - it('releases conin after the console-list fork, unlike the desktop patch placement', () => { - const fixture = writeNodePtyFixture('1.1.0') - patchNodePtyWindowsTeardown(fixture.root) - const patched = readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'), 'utf8') - - const branch = patched.slice( - patched.indexOf('if (!this._useConptyDll) {'), - patched.indexOf('else {', patched.indexOf('if (!this._useConptyDll) {')) - ) - expect(branch).toContain('this._inSocket.destroy();') - expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan( - branch.indexOf('this._conoutSocketWorker.dispose();') - ) - expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan( - branch.indexOf('this._getConsoleProcessList()') - ) - // Pinned so a future "sync the relay asset to config/patches" cannot copy the early placement - // onto the relay's branch, where it costs +2 File and +1 Process per terminal. - expect(patched).not.toBe(readFileSync(desktopPath('windowsPtyAgent.js'), 'utf8')) - }) - - it('installs and verifies idempotently', () => { - const fixture = writeNodePtyFixture('1.1.0') - - patchNodePtyWindowsTeardown(fixture.root) - const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8')) - for (const file of PATCHED_FILES) { - expect(existsSync(`${join(fixture.libDir, file)}.orca-patch-${process.pid}`)).toBe(false) - } - expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() - - patchNodePtyWindowsTeardown(fixture.root) - expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual( - once - ) - }) - - it('refuses a different package version or unexpected source', () => { - const wrongVersion = writeNodePtyFixture('1.2.0-beta.11') - expect(() => patchNodePtyWindowsTeardown(wrongVersion.root)).toThrow('expected 1.1.0') - - for (const file of PATCHED_FILES) { - const drifted = writeNodePtyFixture('1.1.0') - const path = join(drifted.libDir, file) - writeFileSync(path, `${readFileSync(path, 'utf8')}\n// drift`) - expect(() => patchNodePtyWindowsTeardown(drifted.root)).toThrow('unexpected node-pty') - } - }) - - it('refuses a half-applied tree, so one file cannot pass for both', () => { - for (const file of PATCHED_FILES) { - const partial = writeNodePtyFixture('1.1.0') - const fixture = writeNodePtyFixture('1.1.0') - patchNodePtyWindowsTeardown(fixture.root) - writeFileSync(join(partial.libDir, file), readFileSync(join(fixture.libDir, file), 'utf8')) - expect(() => assertPatchedNodePtyWindowsTeardown(partial.root)).toThrow('is not installed') - } - }) - - it.each([ - '3060c6514a8e9e3285f91b9b549930e7d25d59d4cf7e1ed3a25b9a680dd1ded5', - '598755ee75307d041a72cd7c7c4e12ae4a4bbf35eee19da67b42b61fdae0d4d6', - '5dfeb1dda46645e1d77964ad4d07072b34f0a897dfc7e785137cc39e755b4641' - ])( - 'retires the known %s relay entrypoint without changing legacy input release', - (terminalHash) => { - const fixture = writePreviousNodePtyFixture(terminalHash) - patchNodePtyWindowsTeardown(fixture.root) - expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() - expect( - createHash('sha256') - .update(readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'))) - .digest('hex') - ).toBe('1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280') - expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe( - readFileSync(desktopPath('windowsTerminal.js'), 'utf8') - ) - expect(readFileSync(join(fixture.libDir, 'windowsConoutConnection.js'), 'utf8')).toBe( - readFileSync(desktopPath('windowsConoutConnection.js'), 'utf8') - ) - const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8')) - patchNodePtyWindowsTeardown(fixture.root) - expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual( - once - ) - } - ) - - it('finishes every interrupted entrypoint-first downgrade', () => { - const asset = readFileSync( - join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), - 'utf8' - ) - const expectedOrder = ['windowsTerminal.js', 'windowsPtyAgent.js', 'windowsConoutConnection.js'] - const expectedHashes = [ - '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', - '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280', - '1440f70908fb1f55911ac8e936a1230f68a9c00c03096fcef6788eac6aad9d62' - ] - for (const abortAfter of [1, 2, 3]) { - const fixture = writePreviousNodePtyFixture() - const renamed = [] - const actualFs = require('node:fs') - const interrupted = loadPatchTargets(asset, (name) => - name === 'node:fs' - ? { - ...actualFs, - renameSync(from, to) { - actualFs.renameSync(from, to) - renamed.push(expectedOrder.find((file) => join(fixture.libDir, file) === to)) - if (renamed.length === abortAfter && abortAfter < 3) { - throw new Error('interrupted after actual rename') - } - } - } - : require(name) - ) - if (abortAfter < 3) { - expect(() => interrupted.patchNodePtyWindowsTeardown(fixture.root)).toThrow( - 'interrupted after actual rename' - ) - } else { - interrupted.patchNodePtyWindowsTeardown(fixture.root) - } - expect(renamed).toEqual(expectedOrder.slice(0, abortAfter)) - for (const [index, file] of expectedOrder.entries()) { - const actualHash = createHash('sha256') - .update(readFileSync(join(fixture.libDir, file))) - .digest('hex') - if (index < abortAfter) { - expect(actualHash).toBe(expectedHashes[index]) - } else { - expect(actualHash).not.toBe(expectedHashes[index]) - } - } - patchNodePtyWindowsTeardown(fixture.root) - expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() - for (const [index, file] of expectedOrder.entries()) { - expect( - createHash('sha256') - .update(readFileSync(join(fixture.libDir, file))) - .digest('hex') - ).toBe(expectedHashes[index]) - } - for (const file of ['windowsTerminal.js', 'windowsConoutConnection.js']) { - expect(readFileSync(join(fixture.libDir, file), 'utf8')).toBe( - readFileSync(desktopPath(file), 'utf8') - ) - } - } - }) - - it('refuses unknown downgrade source before changing any file', () => { - for (const file of PATCHED_FILES) { - const fixture = writePreviousNodePtyFixture() - const path = join(fixture.libDir, file) - writeFileSync(path, `${readFileSync(path, 'utf8')}\n// unknown`) - const before = PATCHED_FILES.map((entry) => readFileSync(join(fixture.libDir, entry), 'utf8')) - expect(() => patchNodePtyWindowsTeardown(fixture.root)).toThrow('unexpected node-pty') - expect( - PATCHED_FILES.map((entry) => readFileSync(join(fixture.libDir, entry), 'utf8')) - ).toEqual(before) - } - }) -}) - -/** A published node-pty tree, rebuilt by un-applying the desktop hunks from the installed one. */ -function writeNodePtyFixture(version) { - const root = mkdtempSync(join(projectDir, '.node-pty-teardown-patch-test-')) - cleanupDirs.push(root) - const libDir = join(root, 'node_modules', 'node-pty', 'lib') - mkdirSync(libDir, { recursive: true }) - writeFileSync(join(root, 'node_modules', 'node-pty', 'package.json'), JSON.stringify({ version })) - for (const file of PATCHED_FILES) { - const desktop = readFileSync(desktopPath(file), 'utf8') - for (const [marker] of DESKTOP_HUNKS[file] || []) { - expect(desktop).toContain(marker) - } - writeFileSync(join(libDir, file), unapplyDesktopHunks(file, desktop)) - } - return { root, libDir } -} - -/** - * Reverse of the published-to-desktop transform. - * - * `windowsTerminal.js` is taken verbatim from the desktop, so the asset's own replacement table is - * the transform and reversing it is exact. `windowsPtyAgent.js` deliberately diverges, so its - * published form is rebuilt from the desktop hunk instead -- which is also what makes this file the - * place that notices if the desktop hunk itself ever moves. - */ -function unapplyDesktopHunks(file, desktop) { - if (file === 'windowsPtyAgent.js') { - let published = desktop - for (const [patched, original] of DESKTOP_HUNKS[file]) { - expect(published.split(patched).length - 1).toBe(1) - published = published.replace(patched, original) - } - return published - } - const asset = readFileSync( - join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), - 'utf8' - ) - const { PATCH_TARGETS } = loadPatchTargets(asset) - const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file) - expect(target).toBeDefined() - let published = desktop - for (const [from, to] of target.replacements.toReversed()) { - expect(published.split(to).length - 1).toBe(1) - published = published.replace(to, from) - } - return published -} - -function writePreviousNodePtyFixture( - terminalHash = '5dfeb1dda46645e1d77964ad4d07072b34f0a897dfc7e785137cc39e755b4641' -) { - const fixture = writeNodePtyFixture('1.1.0') - const asset = readFileSync( - join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), - 'utf8' - ) - const { PREVIOUS_PATCH_TARGETS } = loadPatchTargets(asset) - for (const target of PREVIOUS_PATCH_TARGETS) { - const path = join(fixture.libDir, ...target.relativePath.slice(1)) - let source = readFileSync(path, 'utf8') - for (const [from, to] of target.replacements) { - expect(source.split(from).length - 1).toBe(1) - source = source.replace(from, to) - } - let expectedHash = target.patchedSha256 - if (target.relativePath.at(-1) === 'windowsTerminal.js' && terminalHash !== expectedHash) { - const variant = target.additionalPreviousVariants.find( - (entry) => entry.sha256 === terminalHash - ) - expect(variant).toBeDefined() - for (const [from, to] of variant.replacements.toReversed()) { - expect(source.split(to).length - 1).toBe(1) - source = source.replace(to, from) - } - expectedHash = terminalHash - } - expect(createHash('sha256').update(source).digest('hex')).toBe(expectedHash) - writeFileSync(path, source) - } - return fixture -} - -function loadPatchTargets(assetSource, controlledRequire = require) { - const module = { exports: {} } - const factory = new Function( - 'module', - 'exports', - 'require', - `${assetSource}\nmodule.exports.PATCH_TARGETS = PATCH_TARGETS; module.exports.PREVIOUS_PATCH_TARGETS = PREVIOUS_PATCH_TARGETS` - ) - factory(module, module.exports, controlledRequire) - return module.exports -} diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index d8604299573..f63b7a9bd6f 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -17,7 +17,7 @@ const BUILD_PREFIXES = [ '.github/actions/prepare-orcad-prebuilds/' ] // A remote target's OS does not identify the client platform that builds its commands. -const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/', 'src/wsl-guest/'] +const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/wsl-guest/'] const PLATFORM_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 018d565e2b1..c320cc367b0 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -39,7 +39,6 @@ it.each([ 'src/main/sqlite/database.ts', 'src/main/orcad/entry.ts', 'src/main/daemon/entry.ts', - 'src/relay/index.ts', 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/orcad-prebuild-slot-contents.mjs', 'src/shared/node-runtime-pin.ts' @@ -124,7 +123,6 @@ it.each([ 'src/main/ssh/orcad-remote-node-runtime-windows.ts', 'src/main/ssh/ssh-posix-command-wrapper.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', - 'src/relay/windows-port-scan.ts', 'src/main/runtime/windows-firewall-remote-scope.ts', 'src/shared/remote-windows-path.ts' ])('qualifies every client platform for a remote execution input: %s', (file) => { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 89719c5fd5f..5ed417ba2a9 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -51,7 +51,6 @@ const GIT_COMPAT_PREFIXES = [ 'src/shared/review-head-tracking-ref', 'src/shared/worktree/local-base-branch-fast-forward', 'src/main/git/', - 'src/relay/git-', 'config/scripts/git-binary-compatibility' ] @@ -244,12 +243,10 @@ const CROSS_VERSION_WIRE_PREFIXES = [ ] const MANAGED_HOOK_PREFIXES = [ - 'config/scripts/smoke-managed-hook-runtime-node18', 'config/scripts/smoke-process-host-node18', 'config/scripts/packaged-process-host-fixture', 'src/packages/process-host/', 'config/scripts/build-relay', - 'src/relay/', 'src/wsl-guest/', 'src/shared/agent-hook', 'src/main/agent-hooks/' @@ -413,7 +410,6 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', 'src/main/ipc/preflight-provider-command-selection.test.ts', 'src/main/ipc/preflight-runnable-local-cli.test.ts', - 'src/relay/windows-port-scan.win32.test.ts', 'src/main/ssh/remote-node-runtime-store-windows.test.ts' ] diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 4d0bdcf94e9..6addbe03624 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -269,7 +269,7 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'], matches: (file) => isProductSource(file) && - /^(?:src\/main\/ipc\/filesystem-(?:list-files|search-file-paths)\.ts|src\/main\/ripgrep\/bundled-ripgrep-path\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain|relay-bundled-ripgrep)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability|bundled-ripgrep)\.ts)$/.test( + /^(?:src\/main\/ipc\/filesystem-(?:list-files|search-file-paths)\.ts|src\/main\/ripgrep\/bundled-ripgrep-path\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability|bundled-ripgrep)\.ts)$/.test( file ) }, diff --git a/config/scripts/pty-source-ack-boundary-benchmark.mjs b/config/scripts/pty-source-ack-boundary-benchmark.mjs deleted file mode 100644 index 2792146391b..00000000000 --- a/config/scripts/pty-source-ack-boundary-benchmark.mjs +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -// Benchmarks relay PTY sent-boundary cleanup across a cumulative ACK drain, using the shipped -// PtySourceSentBoundaries rather than a stub so V8's real Set/array costs are included. -import { performance } from 'node:perf_hooks' -import { createJiti } from 'jiti' - -const BOUNDARY_COUNTS = [1_024, 4_096, 16_384, 65_536] -// Set rehash timing swings run to run, so every row reports a median instead of a single sample. -const REPETITIONS = 3 -const jiti = createJiti(import.meta.url) -const { PtySourceSentBoundaries } = await jiti.import( - '../../src/relay/pty-source-sent-boundaries.ts' -) - -// Pre-change cleanup: rescan every retained boundary on every ACK. -function fullScanDrain(count) { - const boundaries = new Set() - for (let boundary = 0; boundary <= count; boundary += 1) { - boundaries.add(boundary) - } - const started = performance.now() - for (let creditedEndSu = 1; creditedEndSu <= count; creditedEndSu += 1) { - for (const boundary of boundaries) { - if (boundary < creditedEndSu) { - boundaries.delete(boundary) - } - } - } - return performance.now() - started -} - -// Early-break cleanup: stops at the first live boundary, but still rebuilds a Set iterator per ACK. -function earlyBreakDrain(count) { - const boundaries = new Set() - for (let boundary = 0; boundary <= count; boundary += 1) { - boundaries.add(boundary) - } - const started = performance.now() - for (let creditedEndSu = 1; creditedEndSu <= count; creditedEndSu += 1) { - for (const boundary of boundaries) { - if (boundary >= creditedEndSu) { - break - } - boundaries.delete(boundary) - } - } - return performance.now() - started -} - -// Shipped cleanup: a monotone cursor over ascending boundaries, so no boundary is ever revisited. -function cursorDrain(count) { - const boundaries = new PtySourceSentBoundaries(0) - for (let boundary = 1; boundary <= count; boundary += 1) { - boundaries.add(boundary) - } - const started = performance.now() - for (let creditedEndSu = 1; creditedEndSu <= count; creditedEndSu += 1) { - boundaries.dropBelow(creditedEndSu) - } - return performance.now() - started -} - -function medianMs(drain, count) { - const samples = Array.from({ length: REPETITIONS }, () => drain(count)).sort((a, b) => a - b) - return samples[(samples.length - 1) >> 1] -} - -function report(count) { - const fullScanMs = medianMs(fullScanDrain, count) - const earlyBreakMs = medianMs(earlyBreakDrain, count) - const cursorMs = medianMs(cursorDrain, count) - console.log( - `${String(count).padStart(6)} boundaries ` + - `full-scan ${fullScanMs.toFixed(2)}ms ` + - `early-break ${earlyBreakMs.toFixed(2)}ms ` + - `cursor ${cursorMs.toFixed(3)}ms ` + - `(cursor is ${(fullScanMs / cursorMs).toFixed(0)}x faster than full-scan, ` + - `${(earlyBreakMs / cursorMs).toFixed(0)}x faster than early-break)` - ) -} - -// Warm the three drains up first so the smallest row is not dominated by JIT tiering. -fullScanDrain(256) -earlyBreakDrain(256) -cursorDrain(256) - -for (const count of BOUNDARY_COUNTS) { - report(count) -} diff --git a/config/scripts/relay-artifact-manifest.test.mjs b/config/scripts/relay-artifact-manifest.test.mjs deleted file mode 100644 index 5f66ee7136f..00000000000 --- a/config/scripts/relay-artifact-manifest.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -// Packaged-relay contract: what `build:relay` actually writes to disk. -// -// Asserts against a real build, not the source tree — the unit suites cannot see -// this gap, because the WSL transcript dispatcher runs in-process under vitest -// and never forks. -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { - RELAY_BUILD_PLATFORMS, - RELAY_VERSION_FILENAME, - isWindowsRelayPlatform, - relayArtifactFilenames, - relayOptionalArtifactFilenames -} from '../../src/shared/relay-artifacts.ts' - -const projectDir = resolve(import.meta.dirname, '../..') -// Its own tree: building into out/relay would clobber a developer's build and -// race the suites that read it. -const relayOutDir = mkdtempSync(join(tmpdir(), 'orca-relay-contract-')) - -beforeAll(() => { - // A repeated build must not copy stale companions into the other platform bundles. - for (const platform of RELAY_BUILD_PLATFORMS) { - const outDir = join(relayOutDir, platform) - mkdirSync(outDir, { recursive: true }) - writeFileSync(join(outDir, 'stale-companion.js'), 'throw new Error("stale")') - } - execFileSync('node', [join(projectDir, 'config', 'scripts', 'build-relay.mjs')], { - cwd: projectDir, - stdio: 'pipe', - env: { ...process.env, ORCA_RELAY_OUT_ROOT: relayOutDir } - }) -}, 120_000) - -afterAll(() => { - rmSync(relayOutDir, { recursive: true, force: true }) -}) - -describe('packaged relay artifact manifest', () => { - it.each([...RELAY_BUILD_PLATFORMS])('emits exactly the declared artifacts for %s', (platform) => { - const outDir = join(relayOutDir, platform) - const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) - - for (const filename of expected) { - expect(existsSync(join(outDir, filename)), `${platform}/${filename} missing`).toBe(true) - } - // Exactly, not merely at least: an undeclared artifact ships unhashed and - // unprobed, which is the same gap in the other direction. - const emitted = readdirSync(outDir) - .filter((name) => name !== RELAY_VERSION_FILENAME) - .sort() - const optional = relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)).filter( - (filename) => existsSync(join(outDir, filename)) - ) - expect(emitted).toEqual([...expected, ...optional].sort()) - }) - - it.each([...RELAY_BUILD_PLATFORMS])('hashes every declared artifact for %s', (platform) => { - const outDir = join(relayOutDir, platform) - const hash = createHash('sha256') - const artifacts = [ - ...relayArtifactFilenames(isWindowsRelayPlatform(platform)), - ...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)).filter((filename) => - existsSync(join(outDir, filename)) - ) - ] - for (const filename of artifacts) { - hash.update(readFileSync(join(outDir, filename))) - } - const version = readFileSync(join(outDir, RELAY_VERSION_FILENAME), 'utf8') - - // A companion left out of the hash lets a changed relay reuse an existing - // immutable remote directory, serving a mixed-generation install forever. - expect(version.split('+')[1]).toBe(hash.digest('hex').slice(0, 12)) - }) - - it('ships the WSL transcript helper beside the service that forks it', () => { - for (const platform of RELAY_BUILD_PLATFORMS) { - const outDir = join(relayOutDir, platform) - const service = readFileSync(join(outDir, 'relay-ai-vault-service.js'), 'utf8') - - // The bundled service reaches the fork, so the entry must sit beside it: - // the spawn resolves the child relative to its own bundle directory. - expect(service, `${platform} service no longer forks the helper`).toContain( - 'wsl-transcript-fs-process-entry.js' - ) - expect( - existsSync(join(outDir, 'wsl-transcript-fs-process-entry.js')), - `${platform} forks a helper it does not ship` - ).toBe(true) - } - }) -}) diff --git a/config/scripts/relay-asset-line-ending-pin.test.mjs b/config/scripts/relay-asset-line-ending-pin.test.mjs deleted file mode 100644 index 3384aa9b88a..00000000000 --- a/config/scripts/relay-asset-line-ending-pin.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { resolve } from 'node:path' -import { RELAY_ARTIFACTS } from '../../src/shared/relay-artifacts.ts' -import { describe, expect, it } from 'vitest' - -/** - * Guard the `.gitattributes` pin that keeps `config/relay-assets` on LF. - * - * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a - * pin a Windows runner checks these out as CRLF. build-relay.mjs copies them - * verbatim into the bundle and hashes them byte-for-byte into `.version`, which - * names the immutable remote relay directory -- so a Windows-built client and a - * mac/Linux-built one disagree on the same release, and one SSH host ends up with - * two relay trees, each paying its own remote native-dep compile. - * - * Measured on v1.4.197: master-cloexec-patch.cjs shipped at 11229 bytes from the - * mac runner and 11547 (= 11229 + 318 lines) from the Windows one. - */ -const projectDir = resolve(import.meta.dirname, '../..') - -function git(args) { - return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' }) -} - -/** `git check-attr -z` emits NUL-separated path/attr/value triples. */ -function eolAttributes(paths) { - const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0') - const found = new Map() - for (let index = 0; index + 2 < fields.length; index += 3) { - found.set(fields[index], fields[index + 2]) - } - return found -} - -/** - * Keyed off the manifest, not a directory: build-relay refuses to emit an - * artifact absent from RELAY_ARTIFACTS, so relocating an asset cannot slip - * past this the way a path glob would. esbuild bundles have no tracked - * source and contribute no hits, so they need no classifying. - */ -function trackedManifestSources() { - const paths = new Set() - for (const { filename } of RELAY_ARTIFACTS) { - const hits = git(['ls-files', '-z', '--', `*/${filename}`]) - .split('\0') - .filter(Boolean) - for (const path of hits) { - paths.add(path) - } - } - return [...paths] -} - -describe('config/relay-assets line-ending pin', () => { - it('pins every tracked relay artifact source to LF', () => { - const assets = trackedManifestSources() - expect(assets.length).toBeGreaterThan(0) - - const attributes = eolAttributes(assets) - const unpinned = assets.filter((path) => attributes.get(path) !== 'lf') - - expect( - unpinned, - 'A relay asset left on the platform default gets CRLF on a Windows runner, ' + - 'which changes the .version hash and splits one release across two remote ' + - 'relay directories. Pin it in .gitattributes.' - ).toEqual([]) - }) - - // Why: the assertion above only sees files that exist today. These fix the - // pattern itself -- broad enough to cover a file added tomorrow, narrow enough - // not to claim neighbours. - it.each([ - ['config/relay-assets/example.cjs', 'lf'], - ['config/relay-assets/nested/deeper/example.cjs', 'lf'], - ['config/relay-assets/example.txt', 'lf'], - ['config/relay-assets-extra/example.cjs', 'unspecified'], - ['vendor/config/relay-assets/example.cjs', 'unspecified'] - ])('resolves %s to eol=%s', (path, expected) => { - expect(eolAttributes([path]).get(path)).toBe(expected) - }) -}) diff --git a/config/scripts/relay-replay-buffer-benchmark.mjs b/config/scripts/relay-replay-buffer-benchmark.mjs deleted file mode 100644 index 31091ede611..00000000000 --- a/config/scripts/relay-replay-buffer-benchmark.mjs +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -// Benchmark: the relay's per-PTY-chunk replay buffer append (src/relay/pty-handler.ts). -// -// appendReplayBuffer did `buffered += data` then, over the cap, `buffered.slice(-CAP)`. -// Once a PTY has produced CAP bytes -- which a long-lived shell does almost immediately -// -- every subsequent chunk flattened and copied the whole 100 KB window. The append is -// called per raw node-pty emission, before batching, so it is per chunk, not per flush. -// -// The fix reuses RecentPtyOutputBuffer: keep chunks, drop from the head, and defer the -// join to read(), which only attach/adopt/revive call. -// -// Both arms are compared for an identical retained tail before timing. -// -// Run with: node config/scripts/relay-replay-buffer-benchmark.mjs -import { readFileSync } from 'node:fs' -import { performance } from 'node:perf_hooks' - -const ROUNDS = 6 -const SECONDS = Number(process.env.ORCA_REPLAY_BENCH_SECONDS ?? '1') -if (!Number.isFinite(SECONDS) || SECONDS <= 0) { - throw new Error(`ORCA_REPLAY_BENCH_SECONDS must be positive, received ${SECONDS}`) -} - -// Why re-read the sources: the claim is that the relay now appends into a chunk deque -// with the relay's own cap. If either reverts, these numbers stop meaning what they say. -const HANDLER_SOURCE = readFileSync( - new URL('../../src/relay/pty-handler.ts', import.meta.url), - 'utf8' -) -if (!/managed\.buffered\.append\(/.test(HANDLER_SOURCE)) { - throw new Error('relay no longer appends into a chunk deque; this benchmark is stale') -} -const capMatch = HANDLER_SOURCE.match(/REPLAY_BUFFER_MAX = ([\d *]+)/) -if (!capMatch) { - throw new Error('REPLAY_BUFFER_MAX not found; this benchmark is stale') -} -// The regex admits only digits, spaces, and `*`, so the literal is a plain product. -const REPLAY_BUFFER_MAX = capMatch[1] - .split('*') - .map((factor) => Number(factor.trim())) - .reduce((product, factor) => product * factor, 1) -if (!Number.isSafeInteger(REPLAY_BUFFER_MAX) || REPLAY_BUFFER_MAX <= 0) { - throw new Error(`could not read REPLAY_BUFFER_MAX from source, got ${capMatch[1]}`) -} - -// Pre-fix: rolling string, re-sliced once over the cap. -function appendString(state, data) { - if (data.length === 0) { - return state - } - const next = state + data - return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next -} - -// Post-fix: mirrors RecentPtyOutputBuffer's append/read for the relay's options. -class ChunkDeque { - constructor(limit) { - this.chunks = [] - this.headIndex = 0 - this.headOffset = 0 - this.totalLen = 0 - this.limit = limit - } - - append(data) { - if (data.length === 0) { - return - } - if (data.length >= this.limit) { - this.chunks = [data.slice(-this.limit)] - this.headIndex = 0 - this.headOffset = 0 - this.totalLen = this.limit - return - } - this.chunks.push(data) - this.totalLen += data.length - while (this.totalLen > this.limit) { - const headRemaining = this.chunks[this.headIndex].length - this.headOffset - const excess = this.totalLen - this.limit - if (headRemaining <= excess) { - this.chunks[this.headIndex] = '' - this.headIndex += 1 - this.headOffset = 0 - this.totalLen -= headRemaining - } else { - this.headOffset += excess - this.totalLen -= excess - } - } - if (this.headIndex >= 1024) { - this.chunks = this.chunks.slice(this.headIndex) - this.headIndex = 0 - } - } - - read() { - if (this.chunks.length - this.headIndex > 1) { - const retained = this.chunks.slice(this.headIndex) - if (this.headOffset > 0) { - retained[0] = retained[0].slice(this.headOffset) - this.headOffset = 0 - } - this.chunks = [retained.join('')] - this.headIndex = 0 - } else if (this.headOffset > 0) { - this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset) - this.headOffset = 0 - } - return this.chunks[this.headIndex] ?? '' - } -} - -function makeChunks(chunkBytes, chunkCount) { - // Vary content so V8 cannot dedupe or treat the appends as loop-invariant. - return Array.from({ length: chunkCount }, (_value, index) => - `${index}:`.padEnd(chunkBytes, 'abcdefghijklmnopqrstuvwxyz') - ) -} - -// Why pre-saturate: the interesting regime is a PTY that has already filled the window, -// which is where the old form copied 100 KB on literally every chunk. Timing from empty -// would average in a cheap warm-up the real process leaves behind in milliseconds. -function saturate(chunks) { - let stringState = '' - const deque = new ChunkDeque(REPLAY_BUFFER_MAX) - const preload = 'p'.repeat(REPLAY_BUFFER_MAX) - stringState = appendString(stringState, preload) - deque.append(preload) - return { stringState, deque, chunks } -} - -function median(samples) { - const sorted = [...samples].sort((a, b) => a - b) - const mid = sorted.length / 2 - return (sorted[mid - 1] + sorted[mid]) / 2 -} - -function timeString(chunks) { - let state = 'p'.repeat(REPLAY_BUFFER_MAX) - const start = performance.now() - for (const chunk of chunks) { - state = appendString(state, chunk) - } - const elapsed = performance.now() - start - if (state.length !== REPLAY_BUFFER_MAX) { - throw new Error('string arm lost its window') - } - return elapsed -} - -function timeDeque(chunks) { - const deque = new ChunkDeque(REPLAY_BUFFER_MAX) - deque.append('p'.repeat(REPLAY_BUFFER_MAX)) - const start = performance.now() - for (const chunk of chunks) { - deque.append(chunk) - } - const elapsed = performance.now() - start - return elapsed -} - -// Arms alternate which one leads so within-round drift cannot favour either. -function measure(chunks) { - timeString(chunks) - timeDeque(chunks) - const stringSamples = [] - const dequeSamples = [] - for (let round = 0; round < ROUNDS; round += 1) { - if (round % 2 === 0) { - stringSamples.push(timeString(chunks)) - dequeSamples.push(timeDeque(chunks)) - } else { - dequeSamples.push(timeDeque(chunks)) - stringSamples.push(timeString(chunks)) - } - } - return { stringMs: median(stringSamples), dequeMs: median(dequeSamples) } -} - -const pad = (value, width) => String(value).padStart(width) -console.log('Relay PTY replay-buffer append, per second of output. Lower is better.') -console.log( - `cap=${(REPLAY_BUFFER_MAX / 1024).toFixed(0)} KiB rounds=${ROUNDS} (per-arm medians, pre-saturated)` -) -console.log( - `${pad('workload', 30)} ${pad('rolling str', 12)} ${pad('chunk deque', 12)} ${pad('speedup', 9)}` -) - -for (const [label, chunkBytes, chunksPerSecond] of [ - ['interactive shell 64B x200', 64, 200], - ['agent TUI 512B x400', 512, 400], - ['build log 4KiB x256 (1 MiB/s)', 4 * 1024, 256], - ['dump 8KiB x512 (4 MiB/s)', 8 * 1024, 512], - ['firehose 16KiB x1024 (16 MiB/s)', 16 * 1024, 1024] -]) { - const chunks = makeChunks(chunkBytes, Math.round(chunksPerSecond * SECONDS)) - const { stringState, deque } = saturate(chunks) - let stringTail = stringState - for (const chunk of chunks) { - stringTail = appendString(stringTail, chunk) - deque.append(chunk) - } - if (deque.read() !== stringTail) { - throw new Error(`retained tail differs for ${label}`) - } - if (stringTail.length !== REPLAY_BUFFER_MAX) { - throw new Error(`fixture never saturated the window for ${label}`) - } - const { stringMs, dequeMs } = measure(chunks) - console.log( - `${pad(label, 30)} ${pad(`${stringMs.toFixed(3)} ms`, 12)} ${pad(`${dequeMs.toFixed(3)} ms`, 12)} ${pad(`${(stringMs / dequeMs).toFixed(0)}x`, 9)}` - ) -} - -console.log( - "\nThis is per PTY, and the relay runs on the user's SSH host. Reads (attach, adopt,\nrevive) now pay the join instead, but those are rare and were already O(window)." -) diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs deleted file mode 100644 index 1f1f4510afd..00000000000 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ /dev/null @@ -1,385 +0,0 @@ -import { build } from 'esbuild' -import { spawn } from 'node:child_process' -import { randomBytes } from 'node:crypto' -import { createRequire } from 'node:module' -import { existsSync } from 'node:fs' -import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' - -const WAIT_TIMEOUT_MS = 20_000 -const require = createRequire(import.meta.url) - -// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated -// transport, and #12746 admits pty.data only after a consumer grant. -function writeEndpointCredential(path) { - return writeFile(path, randomBytes(32).toString('base64url'), 'utf8') -} - -function withTimeout(promise, label, stderr) { - return new Promise((resolvePromise, rejectPromise) => { - const timer = setTimeout(() => { - rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`)) - }, WAIT_TIMEOUT_MS) - promise.then( - (value) => { - clearTimeout(timer) - resolvePromise(value) - }, - (error) => { - clearTimeout(timer) - rejectPromise(error) - } - ) - }) -} - -function pollUntil(readValue, label, stderr) { - const deadline = Date.now() + WAIT_TIMEOUT_MS - return new Promise((resolveValue, rejectValue) => { - const poll = async () => { - try { - const value = await readValue() - if (value !== undefined) { - resolveValue(value) - return - } - } catch {} - if (Date.now() >= deadline) { - rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`)) - return - } - setTimeout(poll, 10) - } - void poll() - }) -} - -function waitForExit(proc) { - if (proc.exitCode !== null || proc.signalCode !== null) { - return Promise.resolve() - } - return new Promise((resolveExit) => proc.once('exit', resolveExit)) -} - -async function loadProtocol(bundleDir) { - const outfile = join(bundleDir, 'relay-protocol.cjs') - await build({ - entryPoints: [resolve('src/wsl-guest/protocol.ts')], - bundle: true, - platform: 'node', - format: 'cjs', - outfile, - logLevel: 'silent' - }) - return require(outfile) -} - -function attachProcessStreams(proc) { - let stderr = '' - proc.stderr.on('data', (chunk) => { - stderr = `${stderr}${String(chunk)}`.slice(-8_000) - }) - return { - stderr: () => stderr - } -} - -function waitForStdoutSentinel(proc, protocol, stderr) { - let stdoutBuffer = Buffer.alloc(0) - return withTimeout( - new Promise((resolvePromise, rejectPromise) => { - let settled = false - const onData = (chunk) => { - stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) - const sentinel = Buffer.from(protocol.RELAY_SENTINEL) - const index = stdoutBuffer.indexOf(sentinel) - if (index === -1) { - return - } - settled = true - proc.stdout.off('data', onData) - proc.off('exit', onExit) - resolvePromise(stdoutBuffer.subarray(index + sentinel.length)) - } - const onExit = (code, signal) => { - if (settled) { - return - } - settled = true - proc.stdout.off('data', onData) - rejectPromise( - new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) - ) - } - proc.stdout.on('data', onData) - proc.once('exit', onExit) - }), - 'relay sentinel', - stderr - ) -} - -function createRelayClient(entryPath, args, env, protocol) { - const proc = spawn(process.execPath, [entryPath, ...args], { - cwd: dirname(entryPath), - env, - stdio: ['pipe', 'pipe', 'pipe'] - }) - const streams = attachProcessStreams(proc) - const messages = [] - let nextSequence = 1 - let stdoutBuffer = Buffer.alloc(0) - let ready = false - let resolveReady - const sentinelReceived = new Promise((resolvePromise) => { - resolveReady = resolvePromise - }) - const decoder = new protocol.FrameDecoder((frame) => { - if (frame.type === protocol.MessageType.Regular) { - messages.push(protocol.parseJsonRpcMessage(frame.payload)) - } - }) - proc.stdout.on('data', (chunk) => { - if (ready) { - decoder.feed(chunk) - return - } - stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) - const sentinel = Buffer.from(protocol.RELAY_SENTINEL) - const index = stdoutBuffer.indexOf(sentinel) - if (index === -1) { - return - } - ready = true - resolveReady() - const remainder = stdoutBuffer.subarray(index + sentinel.length) - if (remainder.length > 0) { - decoder.feed(remainder) - } - }) - - const waitForMessage = (startIndex, predicate, label) => - pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) - - const request = async (method, params = {}) => { - const id = nextSequence++ - const startIndex = messages.length - proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) - const response = await waitForMessage( - startIndex, - (message) => message.id === id, - `response to ${method}` - ) - if (response.error) { - throw new Error(`${method} failed: ${response.error.message}`) - } - return response.result - } - - const notify = (method, params = {}) => { - const sequence = nextSequence++ - proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0)) - } - - return { - proc, - request, - notify, - sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr), - messageCount: () => messages.length, - waitForNotification: (startIndex, method, predicate = () => true) => - waitForMessage( - startIndex, - (message) => message.method === method && predicate(message.params ?? {}), - `${method} notification` - ), - stderr: streams.stderr - } -} - -async function stopProcess(proc, stderr, label) { - if (!proc || proc.exitCode !== null || proc.signalCode !== null) { - return - } - proc.kill('SIGTERM') - try { - await withTimeout(waitForExit(proc), `${label} shutdown`, stderr) - } catch { - proc.kill('SIGKILL') - await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr) - } -} - -async function waitForWatcherPid(pidFile, previousPid, stderr) { - return pollUntil( - async () => { - const pid = Number((await readFile(pidFile, 'utf8')).trim()) - if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) { - return undefined - } - // Why: replacement children reuse this exclusive path after fault injection. - await rm(pidFile, { force: true }) - return pid - }, - previousPid ? 'replacement watcher child pid' : 'initial watcher child pid', - stderr - ) -} - -function includesWatchPath(params, targetPath) { - return Array.isArray(params.events) - ? params.events.some((event) => event.absolutePath === targetPath) - : false -} - -async function main() { - const platform = `${process.platform}-${process.arch}` - const relayEntry = resolve('out', 'relay', platform, 'relay.js') - const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js') - if (!existsSync(relayEntry) || !existsSync(watcherEntry)) { - throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`) - } - - let tempRoot - let daemon - let daemonStreams - let relay - try { - tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-')) - const watchRoot = await realpath(tempRoot) - const pidFile = join(tempRoot, 'watcher.pid') - const credentialFile = join(tempRoot, 'endpoint.credential') - const protocol = await loadProtocol(tempRoot) - const socketPath = - process.platform === 'win32' - ? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}` - : join(tempRoot, 'relay.sock') - await writeEndpointCredential(credentialFile) - - // Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer - // session; only an endpoint-credential socket client is admitted for pty.data after #12746. - daemon = spawn( - process.execPath, - [ - relayEntry, - '--detached', - '--grace-time', - '0', - '--sock-path', - socketPath, - '--endpoint-dir', - join(tempRoot, 'agent-hooks'), - '--credential-file', - credentialFile - ], - { - cwd: dirname(relayEntry), - env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile }, - stdio: ['ignore', 'pipe', 'pipe'] - } - ) - daemonStreams = attachProcessStreams(daemon) - await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr) - - relay = createRelayClient( - relayEntry, - ['--connect', '--sock-path', socketPath, '--credential-file', credentialFile], - process.env, - protocol - ) - await relay.sentinelReceived - - // Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens. - const grant = await relay.request('pty.openClient', { - protocolVersion: 1, - clientInstanceId: `relay-watcher-fault-${process.pid}`, - requestedRole: 'session-owner' - }) - if (grant?.role !== 'session-owner') { - throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`) - } - - const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot }) - const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}` - let startIndex = relay.messageCount() - relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` }) - await relay.waitForNotification( - startIndex, - 'pty.data', - (params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker) - ) - - await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid( - pidFile, - undefined, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` - ) - const beforePath = join(watchRoot, 'before.txt') - startIndex = relay.messageCount() - await writeFile(beforePath, 'before') - await relay.waitForNotification(startIndex, 'fs.changed', (params) => - includesWatchPath(params, beforePath) - ) - - const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' - startIndex = relay.messageCount() - process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid( - pidFile, - firstWatcherPid, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` - ) - await relay.waitForNotification(startIndex, 'fs.changed', (params) => - Array.isArray(params.events) - ? params.events.some( - (event) => event.kind === 'overflow' && event.absolutePath === watchRoot - ) - : false - ) - - const status = await relay.request('relay.status') - if (status.pid !== daemon.pid) { - throw new Error('relay.status did not come from the original surviving relay process') - } - const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}` - startIndex = relay.messageCount() - relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` }) - await relay.waitForNotification( - startIndex, - 'pty.data', - (params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker) - ) - - const afterPath = join(watchRoot, 'after.txt') - startIndex = relay.messageCount() - await writeFile(afterPath, 'after') - await relay.waitForNotification(startIndex, 'fs.changed', (params) => - includesWatchPath(params, afterPath) - ) - - relay.notify('fs.unwatch', { rootPath: watchRoot }) - await relay.request('pty.shutdown', { id: spawned.id }) - console.log( - JSON.stringify({ - relayPid: daemon.pid, - killedWatcherPid: firstWatcherPid, - replacementWatcherPid, - faultSignal, - relaySurvived: true, - existingPtySurvived: true, - overflowRefreshDelivered: true, - postCrashEventDelivered: true - }) - ) - } finally { - await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge') - await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon') - if (tempRoot) { - await rm(tempRoot, { recursive: true, force: true }) - } - } -} - -await main() diff --git a/config/scripts/relay-windows-process-tree-staging.mjs b/config/scripts/relay-windows-process-tree-staging.mjs deleted file mode 100644 index 5abd1baf35a..00000000000 --- a/config/scripts/relay-windows-process-tree-staging.mjs +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Stage the prebuilt `@vscode/windows-process-tree` addon into a Windows relay bundle. - * - * Every desktop package ships relays for every host OS, so a macOS or Linux build - * needs the addon a Windows job compiled: without it a Windows SSH host cannot - * launch the relay outside sshd's job as a standard user. Release builds list the - * arches they require; a local build without the addon ships the scan fallback. - */ -import { copyFileSync } from 'node:fs' -import { join } from 'node:path' -import { - RELAY_WINDOWS_PROCESS_TREE_FILENAME, - isWindowsRelayPlatform -} from '../../src/shared/relay-artifacts.ts' -import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' - -/** `ORCA_REQUIRE_RELAY_NATIVE_ADDONS`: comma-separated arches, or `all`. */ -export function parseRequiredRelayAddonArches(value) { - return (value ?? '') - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) -} - -/** - * @param {{ - * platform: string, - * outDir: string, - * buildDir: string, - * requiredArches: string[], - * log?: (message: string) => void - * }} options - * @returns {'staged' | 'skipped' | 'not-windows'} - */ -export function stageRelayWindowsProcessTreeAddon({ - platform, - outDir, - buildDir, - requiredArches, - log = console.log -}) { - if (!isWindowsRelayPlatform(platform)) { - return 'not-windows' - } - const arch = platform.slice('win32-'.length) - const source = join(buildDir, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME) - const required = requiredArches.includes(arch) || requiredArches.includes('all') - const defect = relayWindowsProcessTreeAddonDefect(source, arch) - if (defect) { - const reason = `${source}: ${defect}` - if (required) { - throw new Error( - `Relay ${platform} needs ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}, but ${reason}. ` + - `On Windows run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch}. ` + - 'CI builds on other OSes download it from the relay-windows-process-tree artifact.' - ) - } - // Never ship a defective binary: the scan fallback beats a relay that loads the wrong addon. - log(`Relay ${platform}: ${reason}; relay will use the PowerShell scan.`) - return 'skipped' - } - copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME)) - return 'staged' -} diff --git a/config/scripts/relay-windows-process-tree-staging.test.mjs b/config/scripts/relay-windows-process-tree-staging.test.mjs deleted file mode 100644 index 07477965d43..00000000000 --- a/config/scripts/relay-windows-process-tree-staging.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import { 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 { - parseRequiredRelayAddonArches, - stageRelayWindowsProcessTreeAddon -} from './relay-windows-process-tree-staging.mjs' -import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' - -const MACHINE = { x64: 0x8664, arm64: 0xaa64 } -const LAUNCHER = 'spawnOutsideJob\0' - -const roots = [] -afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) - -function peImage(machine, body) { - const header = Buffer.alloc(0x90) - header.write('MZ') - header.writeUInt32LE(0x80, 0x3c) - header.write('PE\0\0', 0x80) - header.writeUInt16LE(machine, 0x84) - return Buffer.concat([header, Buffer.from(body, 'binary')]) -} - -function fixture(addons = {}) { - const root = mkdtempSync(join(tmpdir(), 'relay-process-tree-')) - roots.push(root) - const buildDir = join(root, 'build') - const outDir = join(root, 'out') - mkdirSync(outDir) - for (const [arch, bytes] of Object.entries(addons)) { - mkdirSync(join(buildDir, arch), { recursive: true }) - writeFileSync(join(buildDir, arch, 'windows-process-tree.node'), bytes) - } - const logs = [] - const stage = (platform, requiredArches = []) => - stageRelayWindowsProcessTreeAddon({ - platform, - outDir, - buildDir, - requiredArches, - log: (message) => logs.push(message) - }) - return { buildDir, outDir, logs, stage, staged: join(outDir, 'windows-process-tree.node') } -} - -describe('relay windows-process-tree addon check', () => { - it('accepts only a clean launcher build for the requested machine', () => { - const { buildDir } = fixture({ - x64: peImage(MACHINE.x64, `ntdll.dll\0${LAUNCHER}`), - arm64: peImage(MACHINE.x64, LAUNCHER) - }) - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') - ).toBeNull() - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'arm64/windows-process-tree.node'), 'arm64') - ).toContain('machine 0x8664, not arm64') - }) - - it('rejects a pre-launcher build that is otherwise clean and loadable', () => { - const { buildDir } = fixture({ x64: peImage(MACHINE.x64, 'NtQueryInformationProcess\0') }) - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') - ).toContain('does not export spawnOutsideJob') - }) - - it('rejects the unpatched command-line reader even when it has the launcher', () => { - const { buildDir } = fixture({ x64: peImage(MACHINE.x64, `ReadProcessMemory\0${LAUNCHER}`) }) - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') - ).toContain('ReadProcessMemory') - }) -}) - -describe('staging the relay windows-process-tree addon', () => { - it('copies a valid addon into each Windows relay and skips other hosts', () => { - const addon = peImage(MACHINE.arm64, LAUNCHER) - const { stage, staged } = fixture({ arm64: addon }) - expect(stage('linux-x64', ['all'])).toBe('not-windows') - expect(existsSync(staged)).toBe(false) - expect(stage('win32-arm64', ['x64', 'arm64'])).toBe('staged') - expect(readFileSync(staged)).toEqual(addon) - }) - - it('degrades to the scan when an unrequired addon is missing or stale', () => { - const { stage, staged, logs } = fixture({ x64: peImage(MACHINE.x64, 'no launcher') }) - expect(stage('win32-x64')).toBe('skipped') - expect(stage('win32-arm64')).toBe('skipped') - expect(existsSync(staged)).toBe(false) - expect(logs.join('\n')).toMatch(/does not export spawnOutsideJob[\s\S]*is missing/) - }) - - it('fails a release build whose required addon is missing or stale', () => { - const { stage, staged } = fixture({ x64: peImage(MACHINE.x64, 'no launcher') }) - expect(() => stage('win32-x64', ['x64', 'arm64'])).toThrow(/spawnOutsideJob/) - expect(() => stage('win32-arm64', ['all'])).toThrow(/is missing/) - expect(existsSync(staged)).toBe(false) - }) - - it('reads the per-arch requirement list', () => { - expect(parseRequiredRelayAddonArches(undefined)).toEqual([]) - expect(parseRequiredRelayAddonArches(' x64, arm64 ,')).toEqual(['x64', 'arm64']) - }) -}) diff --git a/config/scripts/smoke-managed-hook-runtime-node18.mjs b/config/scripts/smoke-managed-hook-runtime-node18.mjs deleted file mode 100644 index 40c44285f6d..00000000000 --- a/config/scripts/smoke-managed-hook-runtime-node18.mjs +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict' -import { constants } from 'node:fs' -import { access, mkdtemp, readFile, rm } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const PLATFORMS = [ - 'linux-x64', - 'linux-arm64', - 'darwin-x64', - 'darwin-arm64', - 'win32-x64', - 'win32-arm64' -] -const ROOT = fileURLToPath(new URL('../..', import.meta.url)) -const require = createRequire(import.meta.url) - -assert.match(process.versions.node, /^18\./, 'This smoke test must run under Node 18') - -const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-node18-')) -const originalHome = process.env.HOME -const originalUserProfile = process.env.USERPROFILE -const originalGetuid = process.getuid -process.env.HOME = home -process.env.USERPROFILE = home -process.getuid = undefined - -try { - const runtimes = PLATFORMS.map((platform) => { - const artifact = join(ROOT, 'out', 'relay', platform, 'managed-hook-runtime.js') - const runtime = require(artifact) - assert.equal(typeof runtime.installManagedHooks, 'function', `${platform} installer export`) - return runtime - }) - - const summary = await runtimes[0].installManagedHooks({ agents: ['codex', 'claude'] }) - assert.deepEqual(summary, { installers: 2, errors: 0 }) - - const codexHooks = await readFile(join(home, '.codex', 'hooks.json'), 'utf8') - const claudeSettings = await readFile(join(home, '.claude', 'settings.json'), 'utf8') - assert.match(codexHooks, /\.orca\/agent-hooks\/codex-hook\.sh/) - assert.match(claudeSettings, /\.orca\/agent-hooks\/claude-hook\.sh/) - await access(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'), constants.X_OK) - await access(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), constants.X_OK) -} finally { - if (originalHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = originalHome - } - if (originalUserProfile === undefined) { - delete process.env.USERPROFILE - } else { - process.env.USERPROFILE = originalUserProfile - } - process.getuid = originalGetuid - await rm(home, { recursive: true, force: true }) -} - -console.log('Node 18 managed-hook runtime smoke passed for all relay platforms.') diff --git a/config/scripts/ssh-relay-import-boundary.test.mjs b/config/scripts/ssh-relay-import-boundary.test.mjs deleted file mode 100644 index 05eaa62e302..00000000000 --- a/config/scripts/ssh-relay-import-boundary.test.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { globSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { expect, it } from 'vitest' -import { collectModuleSpecifiers } from './static-module-specifiers.mjs' -import { isTestOnlySourcePath } from './test-only-source-path.mjs' - -const ROOT = path.resolve(import.meta.dirname, '..', '..') -const RELAY_SOURCE = 'src/relay' - -// Why: the SSH relay is being retired; only its own tests may reach into it, and this list only shrinks. -const RELAY_TEST_IMPORTERS = [ - 'src/main/ai-vault-search/session-search-scope-entry-points.test.ts', - 'src/main/ai-vault/antigravity-index-file-admission.test.ts', - 'src/main/ai-vault/remote-session-large-transcripts.test.ts', - 'src/main/git/git-network-safety-real-git.test.ts', - 'src/main/git/source-control/blob-absence-real-git.test.ts', - 'src/main/git/source-control/bulk-pathspec-stdin.test.ts', - 'src/main/git/source-control/staging-discard-index-safety.test.ts', - 'src/main/git/status-branch-compare-real-ref.test.ts', - 'src/main/git/status-branch-line-total-relay-parity.test.ts', - 'src/main/git/status-conflict-overlap.bench.test.ts', - 'src/main/git/worktree-rebase-update-refs-real-git.test.ts', - 'src/main/git/worktree-safety-real-git.test.ts', - 'src/main/ipc/filesystem-quick-open-options.integration.test.ts', - 'src/main/ipc/worktree-push-target-cleanup.test.ts', - 'src/main/ipc/worktree-push-target-reconciliation.test.ts', - 'src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts', - 'src/main/plugins/plugin-host-conformance.test.ts', - 'src/main/providers/ssh-review-draft-context.test.ts', - 'src/main/runtime/runtime-file-path-existence.test.ts', - 'src/main/runtime/runtime-repository-ref-queries.test.ts', - 'src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts', - 'src/main/shell-wrapper-generated-file-snapshot.test.ts', - 'src/main/zsh-scoped-histfile.live-shell.test.ts', - 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', - 'tests/tools/omp-relay-close-lifecycle.test.mjs' -] - -function importsRelay(file, contents) { - // Why: parsing every source file overruns the test timeout on a loaded runner. - if (!contents.includes('relay')) { - return false - } - return collectModuleSpecifiers(file, contents).some((specifier) => { - if (!specifier.startsWith('.')) { - return false - } - const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)) - return target === RELAY_SOURCE || target.startsWith(`${RELAY_SOURCE}/`) - }) -} - -it('keeps everything outside the SSH relay from importing it', () => { - const importers = globSync( - ['src', 'config', 'tests', 'mobile/src', 'mobile/app', 'mobile/scripts'].map( - (directory) => `${directory}/**/*.{ts,tsx,mts,cts,js,mjs,cjs}` - ), - { - cwd: ROOT, - exclude: [`${RELAY_SOURCE}/**`, '**/node_modules/**', '**/dist/**', '**/out/**'] - } - ) - .map((file) => file.replaceAll('\\', '/')) - .filter((file) => importsRelay(file, readFileSync(path.join(ROOT, file), 'utf8'))) - .sort() - - expect(importers.filter((file) => !isTestOnlySourcePath(file))).toEqual([]) - expect(importers).toEqual(RELAY_TEST_IMPORTERS) -}) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 454fa3ad1e8..2519b78ef03 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -54,8 +54,6 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/computer/desktop-script-serve-channel.test.ts', 'src/main/providers/local-pty-child-process-verdict.test.ts', 'src/main/providers/working-directory-validation.test.ts', - 'src/relay/node-pty-binding-survey.test.ts', - 'src/relay/relay-runtime-self-test.test.ts', 'src/main/pty/posix-pty-process-groups.integration.test.ts', 'src/shared/ephemeral-vm-recipe-output-capture.test.ts', 'src/main/gitlab/gitlab-known-host-retirement.test.ts', @@ -83,8 +81,6 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-shared-server-probe.test.ts', 'src/main/opencode/opencode-launch-model-context-supervision.test.ts', 'src/main/hermes/hermes-config-remote-adapter.test.ts', - 'src/relay/relay-reconnect-listener-peer-write-error.test.ts', - 'src/relay/relay-primary-channel.test.ts', 'src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts', 'src/main/ipc/orca-profiles-switch-persistence.test.ts', 'src/main/ipc/settings-delayed-response.test.ts', diff --git a/config/scripts/windows-ssh-attach-console-repro.mjs b/config/scripts/windows-ssh-attach-console-repro.mjs deleted file mode 100644 index ce0b9b1d40a..00000000000 --- a/config/scripts/windows-ssh-attach-console-repro.mjs +++ /dev/null @@ -1,436 +0,0 @@ -import { spawn, spawnSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { createConnection } from 'node:net' -import { join, resolve } from 'node:path' - -const projectDir = resolve(import.meta.dirname, '..', '..') -const relayBuildDir = join(projectDir, 'out', 'relay', 'win32-x64') -const SENTINEL = Buffer.from('ORCA-RELAY v0.1.0 READY\n') -const HEADER_LENGTH = 13 -const REGRESSION_TIMEOUT_MS = 15_000 -const NODE_PTY_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' - -if (process.platform !== 'win32') { - console.log('SKIP: Windows ConPTY regression only runs on Windows.') - process.exit(0) -} - -const options = parseArgs(process.argv.slice(2)) -const nodePath = resolve(options.node ?? process.execPath) -const nodePtyDir = resolve(options.nodePty ?? join(projectDir, 'node_modules', 'node-pty')) -const expectFailure = options.expect === 'attach-console-failure' - -for (const required of [ - nodePath, - nodePtyDir, - join(relayBuildDir, 'relay.js'), - join(relayBuildDir, '.version') -]) { - if (!existsSync(required)) { - throw new Error(`Required regression input is missing: ${required}`) - } -} - -const runDir = mkdtempSync(join(projectDir, '.issue-9586-relay-repro-')) -const relayPath = join(runDir, 'relay.js') -const stdoutLog = join(runDir, 'relay.log') -const stderrLog = join(runDir, 'relay.err.log') -const socketPath = `\\\\.\\pipe\\orca-issue-9586-${process.pid}-${Date.now()}` -let relayPid - -try { - prepareRelayTree(runDir, nodePtyDir) - if (!options.skipRelayPatch) { - applyPackagedNodePtyPatch(nodePath, runDir) - } - relayPid = launchRelayWithoutConsole({ - nodePath, - relayPath, - runDir, - socketPath, - stdoutLog, - stderrLog - }) - await waitForPipe(socketPath, 5_000) - const observation = await exerciseRelayClient({ - nodePath, - relayPath, - runDir, - socketPath, - shell: options.shell - }) - await waitForExit(relayPid, 8_000) - - const relayStdout = readIfPresent(stdoutLog) - const relayStderr = readIfPresent(stderrLog) - const attachConsoleFailed = relayStderr.includes('Error: AttachConsole failed') - const installedNodePtyDir = join(runDir, 'node_modules', 'node-pty') - const agentPath = join(installedNodePtyDir, 'lib', 'conpty_console_list_agent.js') - const nativeBindingPath = join( - installedNodePtyDir, - 'prebuilds', - `${process.platform}-${process.arch}`, - 'conpty.node' - ) - const summary = { - node: observation.nodeVersion, - nodePty: JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')).version, - shell: options.shell, - relayPid, - handshake: observation.handshake, - ptySpawn: observation.ptySpawn, - ptyShutdown: observation.ptyShutdown, - relayAliveAfterPtyShutdown: observation.relayAliveAfterPtyShutdown, - bridgeExit: observation.bridgeExit, - relayPatchApplied: !options.skipRelayPatch, - agentSha256: fileSha256(agentPath), - nativeBindingPresent: existsSync(nativeBindingPath), - attachConsoleFailed, - relayExitedAfterClientDisconnect: !isProcessAlive(relayPid) - } - console.log(JSON.stringify(summary, null, 2)) - - if (expectFailure !== attachConsoleFailed) { - throw new Error( - expectFailure - ? `Expected the real console-list agent to fail AttachConsole. stderr:\n${relayStderr}` - : `The real console-list agent failed AttachConsole. stderr:\n${relayStderr}` - ) - } - if ( - !observation.handshake || - !observation.ptySpawn || - !observation.ptyShutdown || - !observation.relayAliveAfterPtyShutdown - ) { - throw new Error( - `Relay lifecycle did not complete. stdout:\n${relayStdout}\nstderr:\n${relayStderr}` - ) - } -} finally { - if (relayPid && isProcessAlive(relayPid)) { - stopExactProcess(relayPid, relayPath) - } - rmSync(runDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) -} - -function parseArgs(args) { - const parsed = { expect: 'clean', shell: 'cmd.exe', skipRelayPatch: false } - for (let index = 0; index < args.length; index++) { - const flag = args[index] - if (flag === '--skip-relay-patch') { - parsed.skipRelayPatch = true - continue - } - const value = args[index + 1] - if (!value || !['--node', '--node-pty', '--expect', '--shell'].includes(flag)) { - throw new Error( - 'Usage: node windows-ssh-attach-console-repro.mjs [--node PATH] [--node-pty DIR] [--shell PATH] [--skip-relay-patch] [--expect clean|attach-console-failure]' - ) - } - if (flag === '--node') { - parsed.node = value - } - if (flag === '--node-pty') { - parsed.nodePty = value - } - if (flag === '--expect') { - parsed.expect = value - } - if (flag === '--shell') { - parsed.shell = value - } - index++ - } - if (!['clean', 'attach-console-failure'].includes(parsed.expect)) { - throw new Error(`Unsupported expectation: ${parsed.expect}`) - } - return parsed -} - -function prepareRelayTree(runDir, nodePtyDir) { - for (const filename of [ - 'relay.js', - 'relay-watcher.js', - 'relay-ai-vault-service.js', - 'managed-hook-runtime.js', - NODE_PTY_PATCH_FILENAME, - '.version' - ]) { - copyFileSync(join(relayBuildDir, filename), join(runDir, filename)) - } - cpSync(nodePtyDir, join(runDir, 'node_modules', 'node-pty'), { recursive: true }) -} - -function applyPackagedNodePtyPatch(nodePath, runDir) { - const result = spawnSync(nodePath, [join(runDir, NODE_PTY_PATCH_FILENAME)], { - cwd: runDir, - encoding: 'utf8', - windowsHide: true - }) - if (result.status !== 0) { - throw new Error(`Packaged node-pty patch failed: ${result.stderr || result.stdout}`) - } -} - -function launchRelayWithoutConsole({ - nodePath, - relayPath, - runDir, - socketPath, - stdoutLog, - stderrLog -}) { - const relayArgs = [ - quoteWindowsArg(nodePath), - quoteWindowsArg(relayPath), - '--detached', - '--grace-time', - '5', - '--sock-path', - quoteWindowsArg(socketPath), - '--endpoint-dir', - quoteWindowsArg(join(runDir, 'endpoint')), - '--log-file', - quoteWindowsArg(stdoutLog), - `1>${quoteWindowsArg(stdoutLog)}`, - `2>${quoteWindowsArg(stderrLog)}` - ].join(' ') - const commandLine = `cmd.exe /d /s /c "${relayArgs}"` - const script = [ - `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(commandLine)}; CurrentDirectory = ${powerShellLiteral(runDir)} }`, - `if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`, - '$result.ProcessId' - ].join('; ') - const launched = runPowerShell(script) - const pid = Number(launched.stdout.trim()) - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error(`Could not parse detached relay pid from: ${launched.stdout}`) - } - return pid -} - -function exerciseRelayClient({ nodePath, relayPath, runDir, socketPath, shell }) { - return new Promise((resolvePromise, rejectPromise) => { - const bridge = spawn(nodePath, [relayPath, '--connect', '--sock-path', socketPath], { - cwd: runDir, - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'] - }) - const observation = { - nodeVersion: readNodeVersion(nodePath), - handshake: false, - ptySpawn: false, - ptyShutdown: false, - relayAliveAfterPtyShutdown: false, - bridgeExit: null - } - let stderr = '' - let buffer = Buffer.alloc(0) - let sentinelRead = false - let outgoingSequence = 1 - let settled = false - const timeout = setTimeout( - () => finish(new Error(`Timed out waiting for relay client lifecycle. stderr:\n${stderr}`)), - REGRESSION_TIMEOUT_MS - ) - - bridge.stderr.on('data', (data) => { - stderr += data.toString() - }) - bridge.on('error', finish) - bridge.on('close', (code, signal) => { - observation.bridgeExit = { code, signal } - if (!settled && observation.relayAliveAfterPtyShutdown) { - finish() - } else if (!settled) { - finish(new Error(`Relay bridge closed before lifecycle completed. stderr:\n${stderr}`)) - } - }) - bridge.stdout.on('data', (data) => { - try { - buffer = Buffer.concat([buffer, data]) - if (!sentinelRead) { - const sentinelIndex = buffer.indexOf(SENTINEL) - if (sentinelIndex === -1) { - return - } - buffer = buffer.subarray(sentinelIndex + SENTINEL.length) - sentinelRead = true - observation.handshake = true - request(1, 'pty.spawn', { - shellOverride: shell, - cwd: projectDir, - cols: 80, - rows: 24, - env: {} - }) - } - for (const message of drainMessages()) { - if (message.id === 1) { - throwResponseError(message) - observation.ptySpawn = true - request(2, 'pty.shutdown', { id: message.result.id, immediate: true }) - } else if (message.id === 2) { - throwResponseError(message) - observation.ptyShutdown = true - request(3, 'relay.status', {}) - } else if (message.id === 3) { - throwResponseError(message) - observation.relayAliveAfterPtyShutdown = message.result.ptys.active === 0 - bridge.stdin.end() - } - } - } catch (error) { - finish(error) - } - }) - - function request(id, method, params) { - bridge.stdin.write(encodeRequestFrame({ id, method, params }, outgoingSequence++)) - } - - function drainMessages() { - const messages = [] - while (buffer.length >= HEADER_LENGTH) { - const type = buffer[0] - const payloadLength = buffer.readUInt32BE(9) - if (buffer.length < HEADER_LENGTH + payloadLength) { - break - } - const payload = buffer.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength) - buffer = buffer.subarray(HEADER_LENGTH + payloadLength) - if (type === 1) { - messages.push(JSON.parse(payload.toString('utf8'))) - } - } - return messages - } - - function finish(error) { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - bridge.kill() - rejectPromise(error) - } else { - resolvePromise(observation) - } - } - }) -} - -function waitForPipe(socketPath, timeoutMs) { - const deadline = Date.now() + timeoutMs - return new Promise((resolvePromise, rejectPromise) => { - const attempt = () => { - const socket = createConnection(socketPath) - let settled = false - const retry = () => { - if (settled) { - return - } - settled = true - socket.destroy() - if (Date.now() >= deadline) { - rejectPromise(new Error(`Detached relay did not listen on ${socketPath}`)) - } else { - setTimeout(attempt, 50) - } - } - socket.once('connect', () => { - if (settled) { - return - } - settled = true - socket.destroy() - resolvePromise() - }) - socket.once('error', retry) - socket.setTimeout(250, retry) - } - attempt() - }) -} - -function encodeRequestFrame({ id, method, params }, sequence) { - const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, method, params })) - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = 1 - header.writeUInt32BE(sequence, 1) - header.writeUInt32BE(0, 5) - header.writeUInt32BE(payload.length, 9) - return Buffer.concat([header, payload]) -} - -function throwResponseError(message) { - if (message.error) { - throw new Error(`Relay RPC failed: ${message.error.message}`) - } -} - -function readNodeVersion(nodePath) { - return spawnSync(nodePath, ['--version'], { encoding: 'utf8' }).stdout.trim() -} - -function waitForExit(pid, timeoutMs) { - const deadline = Date.now() + timeoutMs - return new Promise((resolvePromise) => { - const poll = () => { - if (!isProcessAlive(pid) || Date.now() >= deadline) { - resolvePromise() - return - } - setTimeout(poll, 50) - } - poll() - }) -} - -function isProcessAlive(pid) { - const result = runPowerShell( - `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'ALIVE' }` - ) - return result.stdout.includes('ALIVE') -} - -function stopExactProcess(pid, relayPath) { - const script = [ - `$process = Get-CimInstance Win32_Process -Filter ${powerShellLiteral(`ProcessId = ${pid}`)}`, - `if ($process -and $process.CommandLine -like ${powerShellLiteral(`*${relayPath}*`)}) { Stop-Process -Id ${pid} -Force }` - ].join('; ') - runPowerShell(script) -} - -function runPowerShell(script) { - const encoded = Buffer.from(script, 'utf16le').toString('base64') - const result = spawnSync( - 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], - { encoding: 'utf8' } - ) - if (result.status !== 0) { - throw new Error(`PowerShell failed (${result.status}): ${result.stderr || result.stdout}`) - } - return result -} - -function quoteWindowsArg(value) { - return `"${value.replaceAll('"', '\\"')}"` -} - -function powerShellLiteral(value) { - return `'${value.replaceAll("'", "''")}'` -} - -function readIfPresent(path) { - return existsSync(path) ? readFileSync(path, 'utf8') : '' -} - -function fileSha256(path) { - return createHash('sha256').update(readFileSync(path)).digest('hex') -} diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 1898e9d86a5..738c5b55bcc 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -20,7 +20,6 @@ "../src/preload/**/*", "../src/shared/**/*", "../src/packages/process-host/src/__fixtures__/**/*", - "../src/relay/**/*", "../src/wsl-guest/**/*", "../src/types/**/*" ], diff --git a/config/tsconfig.relay.json b/config/tsconfig.relay.json deleted file mode 100644 index 7951c1ada46..00000000000 --- a/config/tsconfig.relay.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": [ - "../src/relay/**/*", - "../src/wsl-guest/**/*", - "../src/main/pty/overlay-mirror.ts", - "../src/main/pty/shell-startup-env.ts" - ], - "exclude": ["../src/relay/integration.test.ts"], - "compilerOptions": { - "composite": true, - "rootDir": "../src", - "outDir": "../out/relay" - } -} diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index 7bec6789172..8c0b623d4c7 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -13,7 +13,6 @@ const contracts = [ 'src/main/codex-usage/scanner-incremental-append.test.ts', 'src/main/usage/jsonl-file-reader.test.ts', 'src/main/usage/jsonl-file-snapshot.test.ts', - 'src/relay/fs-path-metadata-symlink-concurrency.test.ts', 'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts', 'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts', 'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts', diff --git a/package.json b/package.json index 48d6724b18a..315dbcb9d72 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/run-vitest.mjs run --config config/vitest.config.ts", "test:node": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:node-server": "node config/scripts/run-node-server-tests.mjs", - "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", + "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts similarity index 93% rename from src/main/agent-hooks/managed-hook-local-filesystem.ts rename to src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts index f299a85fda7..b19972b54cd 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts @@ -18,8 +18,7 @@ function finish(callback: Callback, error: NodeJS.ErrnoException | null, v callback(null, value) } -/** The managed installers only need this small callback-style SFTP surface. - * On the remote host it turns hundreds of WAN round trips into local fs calls. */ +/** Runs the managed installers' callback-style SFTP surface against the local disk in tests. */ export function createManagedHookLocalFilesystem(): SFTPWrapper { const adapter = { readFile(path: string, _encoding: string, callback: Callback): void { diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts deleted file mode 100644 index d2f8d68600b..00000000000 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - installRemoteManagedAgentHooks, - REMOTE_MANAGED_HOOK_INSTALLER_AGENTS -} from './remote-managed-hook-installers' -import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' - -const tempHomes: string[] = [] -const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' - -async function createTempHome(): Promise { - // Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries. - const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-')) - tempHomes.push(home) - return home -} - -async function listFiles(root: string): Promise { - const entries = await readdir(root, { withFileTypes: true }) - const nested = await Promise.all( - entries.map(async (entry) => { - const path = join(root, entry.name) - return entry.isDirectory() ? await listFiles(path) : [path] - }) - ) - return nested.flat() -} - -afterEach(async () => { - await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) -}) - -describe('managed-hook local filesystem', () => { - it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => { - const home = await createTempHome() - const filesystem = createManagedHookLocalFilesystem() - const options = { - grokHomeDir: join(home, '.grok'), - agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS - } - - const cold = await installRemoteManagedAgentHooks(filesystem, home, options) - const warm = await installRemoteManagedAgentHooks(filesystem, home, options) - - expect(cold).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) - expect(cold.filter((result) => result.state === 'error')).toEqual([]) - expect(warm).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) - expect(warm.filter((result) => result.state === 'error')).toEqual([]) - const files = await listFiles(home) - expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([]) - const scripts = files.filter((path) => /\.(?:sh|cmd)$/.test(path)) - expect(scripts.length).toBeGreaterThanOrEqual(10) - if (process.platform !== 'win32') { - for (const script of scripts) { - expect((await stat(script)).mode & 0o777).toBe(0o755) - } - } - }) - - it('isolates a malformed config while installing the remaining agents', async () => { - const home = await createTempHome() - const claudeConfig = join(home, '.claude', 'settings.json') - await mkdir(join(home, '.claude'), { recursive: true }) - await writeFile(claudeConfig, '{"hooks": }', 'utf8') - - const results = await installRemoteManagedAgentHooks(createManagedHookLocalFilesystem(), home, { - grokHomeDir: join(home, '.grok'), - agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS - }) - - expect(results).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) - expect(results.find((result) => result.agent === 'claude')?.state).toBe('error') - expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed') - expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed') - expect(results.find((result) => result.agent === 'jcode')?.state).toBe('installed') - expect(await readFile(claudeConfig, 'utf8')).toBe('{"hooks": }') - }) -}) diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts deleted file mode 100644 index 281126fae78..00000000000 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { installManagedHooks } = await import('./managed-hook-runtime') - -const tempHomes: string[] = [] -const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' -const SHELL_NAME = 'login-shell' -const SHELL_RUNS_NAME = 'login-shell-runs' - -async function createTempHome(): Promise { - const home = await mkdtemp(join(tempRoot, 'orca-managed-hook-runtime-')) - tempHomes.push(home) - return home -} - -/** Login shell that records each invocation, so a skipped GROK_HOME probe is observable. */ -async function stubLoginShell(home: string): Promise { - const shell = join(home, SHELL_NAME) - await writeFile( - shell, - `#!/bin/sh\necho ran >> "${join(home, SHELL_RUNS_NAME)}"\nexit 0\n`, - 'utf8' - ) - await chmod(shell, 0o755) - vi.stubEnv('HOME', home) - vi.stubEnv('SHELL', shell) -} - -afterEach(async () => { - vi.unstubAllEnvs() - await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) -}) - -describe.runIf(process.platform !== 'win32')('installManagedHooks', () => { - it.each([ - ['omitted', undefined], - ['empty', { agents: [] }] - ])( - 'writes nothing and runs no probe when the allowlist is %s (issue #11641)', - async (_label, options) => { - const home = await createTempHome() - await stubLoginShell(home) - - await expect(installManagedHooks(options)).resolves.toEqual({ installers: 0, errors: 0 }) - - // Why: no agent config home, no ~/.orca install lock, and no GROK_HOME login-shell probe. - expect(await readdir(home)).toEqual([SHELL_NAME]) - } - ) - - it('still rejects an aborted request rather than resolving an empty summary', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - installManagedHooks({ signal: controller.signal, agents: [] }) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('installs only the allowlisted agent, under the install lock', async () => { - const home = await createTempHome() - await stubLoginShell(home) - - await expect(installManagedHooks({ agents: ['claude'] })).resolves.toEqual({ - installers: 1, - errors: 0 - }) - - expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME]) - }) - - it('still probes the login shell when Grok is selected', async () => { - const home = await createTempHome() - await stubLoginShell(home) - - await expect(installManagedHooks({ agents: ['grok'] })).resolves.toEqual({ - installers: 1, - errors: 0 - }) - - expect((await readdir(home)).sort()).toEqual(['.grok', '.orca', SHELL_NAME, SHELL_RUNS_NAME]) - }) -}) diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts deleted file mode 100644 index 726cc5322c1..00000000000 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { homedir } from 'node:os' -import { resolveLoginShellGrokHome, resolveLoginShellKiroHome } from './agent-home-login-shell' -import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' -import type { AgentHookTarget } from '../../shared/agent-hook-types' -import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' -import { withManagedHookInstallLock } from './managed-hook-install-lock' -import { - readManagedHookHostIdentity, - scopeManagedHookHostIdentity -} from './managed-hook-owner-identity' - -export type ManagedHookInstallSummary = { - installers: number - errors: number -} - -export async function installManagedHooks(options?: { - signal?: AbortSignal - hostKeyFingerprint?: string - agents?: readonly AgentHookTarget[] - claudeVersion?: string -}): Promise { - options?.signal?.throwIfAborted() - // Why: empty/omitted allowlist fails closed before any home/host probes. - const agents = options?.agents ?? [] - if (agents.length === 0) { - return { installers: 0, errors: 0 } - } - const home = homedir() - // Why parallel: each probe may wait out its own login-shell timeout. - const [grokHomeDir, kiroHomeDir] = await Promise.all([ - agents.includes('grok') ? resolveLoginShellGrokHome(home, options?.signal) : undefined, - agents.includes('kiro') ? resolveLoginShellKiroHome(home, options?.signal) : undefined - ]) - options?.signal?.throwIfAborted() - const hostIdentity = scopeManagedHookHostIdentity( - await readManagedHookHostIdentity(), - options?.hostKeyFingerprint - ) - return await withManagedHookInstallLock( - home, - options?.signal, - async () => { - const results = await installRemoteManagedAgentHooks( - createManagedHookLocalFilesystem(), - home, - { - grokHomeDir, - kiroHomeDir, - signal: options?.signal, - agents, - ...(options?.claudeVersion ? { claudeVersion: options.claudeVersion } : {}) - } - ) - return { - installers: results.length, - errors: results.filter((result) => result.state === 'error').length - } - }, - hostIdentity - ) -} diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index 365e4d5fcd8..9df5013f525 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { WslHookRelayManager } from './wsl-hook-relay-manager' -import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem.test-fixture' import { codexHookService } from '../codex/hook-service' const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl') diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index e921445ef5c..0e25f326b14 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -1,10 +1,7 @@ import '../runtime/rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it } from 'vitest' -import { AiVaultHandler } from '../../relay/ai-vault-handler' -import type { RelayDispatcher } from '../../wsl-guest/dispatcher' import { createSessionSearchClient } from '../../shared/ai-vault-search-client' import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' -import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault' @@ -28,22 +25,6 @@ afterEach(() => { resetSessionSearchScopeCatalogForTests() }) -function relayHandler(): (params: Record) => Promise { - const handlers = new Map) => Promise>() - const dispatcher = { - onRequest: (method: string, handler: (params: Record) => Promise) => { - handlers.set(method, handler) - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls `onRequest`, and the test asserts the registration it makes. - new AiVaultHandler(dispatcher as unknown as RelayDispatcher) - const search = handlers.get('aiVault.searchSessions') - if (!search) { - throw new Error('relay registered no session search handler') - } - return search -} - describe('every search entry point carries the scope identity through', () => { it('resolves over the in-process IPC entry point', async () => { const service = fakeSearchService() @@ -76,40 +57,6 @@ describe('every search entry point carries the scope identity through', () => { }) }) - it('resolves over the relay entry point', async () => { - const service = fakeSearchService() - setSessionSearchService(service) - installSessionSearchScopeCatalogSource(() => CATALOG) - await relayHandler()({ - query: 'needle', - supportedAgents: [...AI_VAULT_AGENTS], - supportsQoderHistory: true, - supportsJcodeHistory: true, - within: WITHIN - }) - expect(service.search).toHaveBeenCalledWith(expect.anything(), { - kind: 'resolved', - paths: ['/work/app'] - }) - }) - - it('hands the relay’s own verdict down, that host carrying no repo catalog', async () => { - const service = fakeSearchService() - setSessionSearchService(service) - await relayHandler()({ - query: 'needle', - supportedAgents: [...AI_VAULT_AGENTS], - supportsQoderHistory: true, - supportsJcodeHistory: true, - within: WITHIN - }) - expect(service.search).toHaveBeenCalledWith( - { query: 'needle', limit: 20 }, - { - kind: 'unknown' - } - ) - }) }) describe('the shared remote client', () => { diff --git a/src/main/ai-vault/antigravity-ide-history.test.ts b/src/main/ai-vault/antigravity-ide-history.test.ts index 02787c85e83..2d40ef400d8 100644 --- a/src/main/ai-vault/antigravity-ide-history.test.ts +++ b/src/main/ai-vault/antigravity-ide-history.test.ts @@ -2,13 +2,10 @@ import { runProcess } from '@orca/process-host' import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, writeAntigravityTranscript } from './session-scanner-test-fixtures' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { dedupeScannedSessions } from './session-root-dedup' +import { jsonLines } from './remote-session-scanner-test-fixtures' import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin' import { readLocalAntigravityHistory } from './session-scanner-antigravity-history' import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata' @@ -190,39 +187,4 @@ describe('Antigravity IDE history discovery', () => { expect(await readLocalAntigravityHistory(file)).toBeNull() }) - it('reads only the execution host and never collapses equal origin IDs from different remote hosts', async () => { - const provider = new MemoryRemoteProvider() - const remoteHome = '/remote/home' - for (const origin of origins) { - const logs = `${remoteHome}/.gemini/${origin}/brain/${conversationId}/.system_generated/logs` - provider.addFile(`${logs}/transcript_full.jsonl`, jsonLines(records(origin)), 10) - provider.addFile(`${logs}/transcript.jsonl`, jsonLines(records(origin)), 11) - } - const read = vi.spyOn(provider, 'readFile') - const options = { - provider, - remoteHome, - hostPlatform: getRemoteHostPlatform('linux-x64'), - includeAntigravityIdeSessions: true - } - const first = await scanRemoteAiVaultSessions({ ...options, executionHostId: 'ssh:first-host' }) - const second = await scanRemoteAiVaultSessions({ - ...options, - executionHostId: 'ssh:second-host' - }) - expect(first.sessions).toHaveLength(3) - expect(second.sessions).toHaveLength(3) - expect(dedupeScannedSessions([...first.sessions, ...second.sessions])).toHaveLength(6) - expect(read.mock.calls.every(([path]) => path.startsWith(remoteHome))).toBe(true) - expect(provider.readDirPaths.every((path) => path.startsWith(remoteHome))).toBe(true) - expect( - first.sessions.every((session) => session.filePath.endsWith('transcript_full.jsonl')) - ).toBe(true) - const legacy = await scanRemoteAiVaultSessions({ - ...options, - executionHostId: 'ssh:first-host', - includeAntigravityIdeSessions: false - }) - expect(legacy.sessions).toHaveLength(1) - }) }) diff --git a/src/main/ai-vault/antigravity-index-file-admission.test.ts b/src/main/ai-vault/antigravity-index-file-admission.test.ts index 3675b1a28d3..95b3a9743cd 100644 --- a/src/main/ai-vault/antigravity-index-file-admission.test.ts +++ b/src/main/ai-vault/antigravity-index-file-admission.test.ts @@ -1,6 +1,6 @@ import type * as NodeFsPromises from 'node:fs/promises' import { constants } from 'node:fs' -import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -23,7 +23,6 @@ vi.mock('node:fs/promises', async (original) => { }) import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' import { readLocalAntigravityHistory } from './session-scanner-antigravity-history' -import { readRelayTranscriptBytes } from '../../relay/ai-vault-transcript-stream' const roots: string[] = [] async function filePath() { @@ -43,25 +42,6 @@ afterEach(async () => { }) describe('bounded regular metadata reads', () => { - it.skipIf(process.platform === 'win32')( - 'rejects real FIFO paths and symlinks without waiting for a writer', - async () => { - const file = await filePath() - await fifo(file) - const alias = `${file}.link` - await symlink(file, alias) - for (const path of [file, alias]) { - expect(await readLocalAntigravityHistory(path)).toBeNull() - await expect(readNodeFileWithinLimit(path, 64, { regularFileOnly: true })).rejects.toThrow( - 'regular file' - ) - await expect( - readRelayTranscriptBytes(path, undefined, { regularFileOnly: true, maxBytes: 64 }).next() - ).rejects.toThrow('regular file') - } - }, - 2000 - ) it.skipIf(process.platform === 'win32')( 'rejects a regular path replaced by a FIFO between stat and open', diff --git a/src/main/ai-vault/antigravity-index-reader.test.ts b/src/main/ai-vault/antigravity-index-reader.test.ts deleted file mode 100644 index 603c3401d13..00000000000 --- a/src/main/ai-vault/antigravity-index-reader.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { readRemoteAntigravityIndex } from './antigravity-index-reader' -import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' -import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' -import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata' - -function provider(): RemoteSessionFilesystemProvider { - return { - readDir: async () => [], - stat: async () => ({ type: 'file', size: 2, mtime: 0 }), - readFile: async () => ({ content: '{}', isBinary: false }) - } -} - -describe('execution-host Antigravity metadata reads', () => { - it('forwards regular-file admission and byte limits through the concurrency wrapper', async () => { - const host = provider() - const read = vi.fn(async function* () { - yield Buffer.from('{}') - }) - host.readTranscriptBytes = read - expect( - await readRemoteAntigravityIndex( - limitRemoteScanFilesystemConcurrency(host), - '/host/projects.json' - ) - ).toBe('{}') - expect(read).toHaveBeenCalledWith('/host/projects.json', undefined, { - regularFileOnly: true, - maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES - }) - }) - - it('preserves the existing bounded read-file options on legacy providers', async () => { - const host = provider() - const read = vi.spyOn(host, 'readFile') - expect( - await readRemoteAntigravityIndex( - limitRemoteScanFilesystemConcurrency(host), - '/host/projects.json' - ) - ).toBe('{}') - expect(read).toHaveBeenCalledWith('/host/projects.json', { - maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES - }) - }) - - it('does not read directories or symlinks from legacy providers', async () => { - for (const type of ['directory', 'symlink'] as const) { - const host = provider() - host.stat = async () => ({ type, size: 0, mtime: 0 }) - const read = vi.spyOn(host, 'readFile') - expect(await readRemoteAntigravityIndex(host, '/host/projects.json')).toBeNull() - expect(read).not.toHaveBeenCalled() - } - }) - - it('stops waiting for a legacy request that cannot cancel on the wire', async () => { - const host = provider() - host.readFile = () => new Promise(() => {}) - const controller = new AbortController() - const read = readRemoteAntigravityIndex(host, '/host/projects.json', controller.signal) - await Promise.resolve() - controller.abort() - await expect(read).rejects.toMatchObject({ name: 'AbortError' }) - }) -}) diff --git a/src/main/ai-vault/antigravity-index-reader.ts b/src/main/ai-vault/antigravity-index-reader.ts deleted file mode 100644 index a14d4cde503..00000000000 --- a/src/main/ai-vault/antigravity-index-reader.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - ANTIGRAVITY_INDEX_MAX_BYTES, - readBoundedAntigravityIndex -} from './session-scanner-antigravity-metadata' -import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' -import { - abandonRemoteSessionScanOnCancel, - throwIfAiVaultScanCancelled -} from './ai-vault-scan-cancellation' - -export async function readRemoteAntigravityIndex( - provider: RemoteSessionFilesystemProvider, - path: string, - signal?: AbortSignal -): Promise { - try { - throwIfAiVaultScanCancelled(signal) - if (provider.readTranscriptBytes) { - return await abandonRemoteSessionScanOnCancel( - readBoundedAntigravityIndex( - provider.readTranscriptBytes(path, signal, { - regularFileOnly: true, - maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES - }) - ), - signal - ) - } - const stat = await abandonRemoteSessionScanOnCancel(provider.stat(path), signal) - if (stat.type !== 'file' || stat.size > ANTIGRAVITY_INDEX_MAX_BYTES) { - return null - } - const read = await abandonRemoteSessionScanOnCancel( - provider.readFile(path, { maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES }), - signal - ) - throwIfAiVaultScanCancelled(signal) - return read.isBinary || Buffer.byteLength(read.content) > ANTIGRAVITY_INDEX_MAX_BYTES - ? null - : read.content - } catch (error) { - throwIfAiVaultScanCancelled(signal) - if (error instanceof Error && error.name === 'AbortError') { - throw error - } - return null - } -} diff --git a/src/main/ai-vault/openclaw-session-layout.test.ts b/src/main/ai-vault/openclaw-session-layout.test.ts index 842ca15a4e0..12c21b47c1b 100644 --- a/src/main/ai-vault/openclaw-session-layout.test.ts +++ b/src/main/ai-vault/openclaw-session-layout.test.ts @@ -1,11 +1,7 @@ -import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' -import { remoteSessionSources } from './remote-session-scanner-sources' -import type { RemoteScannerContext } from './remote-session-scanner-types' import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources' import { walkSessionFiles } from './session-scanner-discovery' @@ -49,44 +45,4 @@ describe('OpenClaw session discovery', () => { expect(relativeToAgents(files)).toEqual(EXPECTED) }) - it('never lists an embedded agent home over a remote filesystem', async () => { - const readDirs: string[] = [] - const provider: RemoteScannerContext['provider'] = { - readDir: async (dirPath) => { - readDirs.push(dirPath) - const entries = await readdir(dirPath, { withFileTypes: true }) - return entries.map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - }, - stat: async (path) => { - const info = await stat(path) - return { size: info.size, type: 'file', mtime: info.mtimeMs } - }, - readFile: async () => ({ content: '', isBinary: false }) - } - const hostPlatform = getRemoteHostPlatform( - process.platform === 'win32' ? 'win32-x64' : 'linux-x64' - ) - const source = remoteSessionSources(home, hostPlatform).find( - (candidate) => candidate.agent === 'openclaw' - ) - - const context: RemoteScannerContext = { - provider, - executionHostId: 'ssh:host', - hostPlatform, - titleCaches: new Map(), - antigravityWorkspaceResolver: { enrich: async (session) => session } - } - expect(source).toBeDefined() - const candidates = source - ? await discoverRemoteSourceCandidates({ source, context, issues: [] }) - : [] - - expect(relativeToAgents(candidates.map(({ file }) => file.path))).toEqual(EXPECTED) - expect(readDirs.some((dir) => dir.includes('codex-home'))).toBe(false) - }) }) diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index 923baf824d4..e2f8781202e 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -87,10 +87,7 @@ describe('WSL SQLite runtime preparation', () => { args: ['-e', expect.stringMatching(/require\('node:sqlite'\).*backup/)] }) ) - expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual([ - 'linux-x64', - 'linux-arm64' - ]) + expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual(['wsl']) expect(mocks.download).not.toHaveBeenCalled() }) diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index c107a308248..c039bae0f89 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -102,9 +102,7 @@ async function prepare(distro: string): Promise { return result.stdout.trim() } const app = getAppEnvironment() - // The reader is plain JavaScript; either packaged Linux architecture is usable. - const reader = (['linux-x64', 'linux-arm64'] as const) - .flatMap((platform) => relayBundleCandidates(platform, app.getAppPath())) + const reader = relayBundleCandidates('wsl', app.getAppPath()) .map((directory) => join(directory, RELAY_OPENCODE_SQLITE_READER_FILENAME)) .find(existsSync) if (!reader) { diff --git a/src/main/ai-vault/remote-session-document-parsers.ts b/src/main/ai-vault/remote-session-document-parsers.ts deleted file mode 100644 index 1dcd78ba08b..00000000000 --- a/src/main/ai-vault/remote-session-document-parsers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AiVaultAgent } from '../../shared/ai-vault-types' -import { parseDevinSessionDocument } from './session-scanner-devin-parser' -import { parseHermesSessionDocument } from './session-scanner-hermes-parser' -import { - parseGeminiSessionDocument, - parseGeminiJsonlSessionLines -} from './session-scanner-gemini-parsers' -import type { RemoteSessionSource } from './remote-session-scanner-types' - -export function remoteSessionDocumentParsers( - agent: AiVaultAgent -): Pick { - const parse = - agent === 'hermes' - ? parseHermesSessionDocument - : agent === 'devin' - ? parseDevinSessionDocument - : agent === 'gemini' - ? parseGeminiSessionDocument - : null - if (!parse) { - return {} - } - return { - parseDocument: (file, bytes, context) => - parse( - file, - bytes, - context.hostPlatform.os, - { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }, - context.signal - ), - ...(agent === 'gemini' - ? { - parseLines: (file, lines, context) => - parseGeminiJsonlSessionLines({ - file, - lines, - platform: context.hostPlatform.os, - options: { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - } - }) - } - : {}) - } -} diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts deleted file mode 100644 index e6bac42fd7e..00000000000 --- a/src/main/ai-vault/remote-session-large-transcripts.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { describe, it, expect } from 'vitest' -import { createRelayAiVaultFilesystemProvider } from '../../relay/ai-vault-service-filesystem' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' - -const platform = getRemoteHostPlatform( - process.platform === 'win32' - ? 'win32-x64' - : process.platform === 'darwin' - ? 'darwin-arm64' - : 'linux-x64' -) -const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).join('\n')}\n` -const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000) - -describe('large remote history through real relay filesystem', () => { - it('reports an oversized record without losing healthy sessions or publishing a partial session', async () => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-record-limit-')) - try { - const directory = join(home, '.codex', 'sessions') - await mkdir(directory, { recursive: true }) - const metadata = (id: string) => - jsonl([{ type: 'session_meta', payload: { id, cwd: '/repo' } }]) - const badPath = join(directory, 'bad.jsonl') - await writeFile(badPath, metadata('bad') + 'x'.repeat(11 * 1024 * 1024)) - await writeFile(join(directory, 'good.jsonl'), metadata('good')) - const result = await scanRemoteAiVaultSessions({ - provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }), - executionHostId: 'ssh:record-limit', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(result.sessions.map((session) => session.sessionId)).toEqual(['good']) - expect(result.issues).toEqual([ - expect.objectContaining({ - path: badPath.replace(/\\/g, '/'), - message: 'Session transcript record exceeds 10485760 byte limit' - }) - ]) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) - - it('lists a large Codex rollout with middle messages and usage intact', async () => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-')) - try { - const path = join(home, '.codex', 'sessions', 'large.jsonl') - await mkdir(dirname(path), { recursive: true }) - await writeFile( - path, - jsonl([ - { - type: 'session_meta', - timestamp: '2026-09-13T01:00:00Z', - payload: { id: 'large', cwd: '/repo' } - }, - { - type: 'response_item', - timestamp: '2026-09-13T01:00:01Z', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: 'Keep my history' }] - } - } - ]) + - filler.slice(0, filler.length / 2) + - jsonl([ - { - type: 'response_item', - timestamp: '2026-09-13T01:02:00Z', - payload: { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Middle answer' }] - } - }, - { - type: 'event_msg', - timestamp: '2026-09-13T01:03:00Z', - payload: { - type: 'token_count', - info: { - total_token_usage: { input_tokens: 123, output_tokens: 45, total_tokens: 168 } - } - } - } - ]) + - filler.slice(filler.length / 2) - ) - const result = await scanRemoteAiVaultSessions({ - provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }), - executionHostId: 'ssh:synthetic-17744', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - sessionId: 'large', - messageCount: 2, - totalTokens: 168, - title: 'Keep my history' - }) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) - - it.each(['hermes', 'devin', 'gemini', 'cline'] as const)( - 'lists large %s documents with every message counted', - async (agent) => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-document-')) - try { - const messages = Array.from({ length: 11000 }, () => ({ - role: 'assistant', - content: 'x'.repeat(1024) - })) - messages.splice(5000, 0, { role: 'user', content: 'A middle user turn' }) - let path: string, record: unknown - if (agent === 'hermes') { - path = join(home, '.hermes', 'sessions', 'large.json') - record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages } - } else if (agent === 'devin') { - path = - platform.os === 'win32' - ? join(home, 'AppData', 'Roaming', 'devin', 'cli', 'transcripts', 'large.json') - : join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json') - record = { - session_id: 'large', - working_directory: '/repo', - steps: messages.map((message) => ({ - ...message, - metadata: { - is_user_input: message.role === 'user', - metrics: { input_tokens: 2, output_tokens: 1 } - } - })) - } - } else if (agent === 'gemini') { - path = join(home, '.gemini', 'tmp', 'large.json') - record = { - sessionId: 'large', - messages: messages.map((message) => ({ - type: message.role === 'assistant' ? 'gemini' : 'user', - content: message.content - })) - } - } else { - path = join(home, '.cline', 'data', 'sessions', 'large', 'large.json') - record = { session_id: 'large', cwd: '/repo' } - await mkdir(dirname(path), { recursive: true }) - await writeFile(path.replace('.json', '.messages.json'), JSON.stringify({ messages })) - } - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(record)) - const result = await scanRemoteAiVaultSessions({ - provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }), - executionHostId: `ssh:large-${agent}`, - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ agent, sessionId: 'large', messageCount: 11001 }) - if (agent === 'devin') { - expect(result.sessions[0].totalTokens).toBe(33003) - } - } finally { - await rm(home, { recursive: true, force: true }) - } - } - ) - it('keeps normal-size reads on their existing path and supports providers without streaming', async () => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-legacy-')) - try { - const directory = join(home, '.codex', 'sessions') - await mkdir(directory, { recursive: true }) - const content = jsonl([{ type: 'session_meta', payload: { id: 'small', cwd: '/repo' } }]) - await writeFile(join(directory, 'small.jsonl'), content) - const provider = createRelayAiVaultFilesystemProvider({ - homeDirectory: home, - environment: {} - }) - provider.readTranscriptBytes = () => { - throw new Error('Small file must keep its existing read path') - } - const small = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:small-original', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(small.issues).toEqual([]) - expect(small.sessions.map((session) => session.sessionId)).toEqual(['small']) - await writeFile(join(directory, 'large.jsonl'), content + filler) - const legacy = { readDir: provider.readDir, readFile: provider.readFile, stat: provider.stat } - const fallback = await scanRemoteAiVaultSessions({ - provider: legacy, - executionHostId: 'ssh:legacy-original', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(fallback.sessions.map((session) => session.sessionId)).toEqual(['small']) - expect( - fallback.issues.some( - (issue) => issue.path.endsWith('large.jsonl') && issue.message.includes('10MB limit') - ) - ).toBe(true) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/ai-vault/remote-session-parse-cache.test.ts b/src/main/ai-vault/remote-session-parse-cache.test.ts deleted file mode 100644 index 446563c0652..00000000000 --- a/src/main/ai-vault/remote-session-parse-cache.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import type { FileReadResult } from '../providers/types' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' - -/** - * Counts whole-transcript reads, which is the cost #13753 is about. Codex's - * per-scan `session_index.jsonl` title lookup is one small file and is not part - * of the corpus term, so it is excluded rather than asserted on. - */ -class CountingRemoteProvider extends MemoryRemoteProvider { - readonly readFilePaths: string[] = [] - - override async readFile(filePath: string): Promise { - if (filePath.includes('/sessions/')) { - this.readFilePaths.push(filePath) - } - return await super.readFile(filePath) - } -} - -function transcript(sessionId: string, title: string, timestamp: string): string { - return jsonLines([ - { - timestamp, - type: 'session_meta', - payload: { id: sessionId, cwd: '/home/ada/repo' } - }, - { - timestamp, - type: 'response_item', - payload: { type: 'message', role: 'user', content: [{ type: 'text', text: title }] } - } - ]) -} - -function scan(provider: CountingRemoteProvider): ReturnType { - return scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) -} - -describe('remote AI Vault transcript re-reads', () => { - beforeEach(() => { - resetRemoteSessionParseCacheForTests() - }) - - it('does not re-read an unchanged corpus on the next scan', async () => { - const provider = new CountingRemoteProvider() - for (const day of ['07/07', '07/25', '08/10']) { - provider.addFile( - `/home/ada/.codex/sessions/2026/${day}/rollout-${day.replace('/', '')}.jsonl`, - transcript( - `session-${day.replace('/', '')}`, - `Work from ${day}`, - '2026-07-07T01:00:00.000Z' - ), - 1_000 - ) - } - - const first = await scan(provider) - expect(first.sessions).toHaveLength(3) - expect(provider.readFilePaths).toHaveLength(3) - - provider.readFilePaths.length = 0 - const second = await scan(provider) - - // Historical transcripts are immutable; a second pass must cost zero reads. - expect(provider.readFilePaths).toEqual([]) - expect(second.sessions.map((session) => session.title)).toEqual( - first.sessions.map((session) => session.title) - ) - }) - - it('re-reads a transcript that actually changed', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-live.jsonl' - provider.addFile( - path, - transcript('live-session', 'First prompt', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - - await scan(provider) - provider.readFilePaths.length = 0 - - provider.addFile( - path, - transcript('live-session', 'Second prompt', '2026-08-31T02:00:00.000Z'), - 2_000 - ) - const result = await scan(provider) - - expect(provider.readFilePaths).toEqual([path]) - expect(result.sessions[0]?.title).toBe('Second prompt') - }) - - it('re-reads when only the size changed under an unchanged mtime', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-grown.jsonl' - provider.addFile(path, transcript('grown-session', 'Short', '2026-08-31T01:00:00.000Z'), 1_000) - - await scan(provider) - provider.readFilePaths.length = 0 - - provider.addFile( - path, - transcript( - 'grown-session', - 'A much longer first prompt than before', - '2026-08-31T01:00:00.000Z' - ), - 1_000 - ) - const result = await scan(provider) - - expect(provider.readFilePaths).toEqual([path]) - expect(result.sessions[0]?.title).toBe('A much longer first prompt than before') - }) - - // Codex names threads in $CODEX_HOME/session_index.jsonl asynchronously, after - // the rollout's last append — so the transcript's mtime+size never changes to - // signal it. That file sits outside `sessions/`, hence outside the read count. - it('picks up a session_index title written after the transcript was cached', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-named-later.jsonl' - provider.addFile( - path, - transcript('named-later-session', 'First prompt', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - provider.addFile( - '/home/ada/.codex/session_index.jsonl', - jsonLines([{ id: 'some-other-session', thread_name: 'Unrelated thread' }]), - 1_000 - ) - - const first = await scan(provider) - expect(first.sessions[0]?.title).toBe('First prompt') - - provider.readFilePaths.length = 0 - provider.addFile( - '/home/ada/.codex/session_index.jsonl', - jsonLines([ - { id: 'some-other-session', thread_name: 'Unrelated thread' }, - { id: 'named-later-session', thread_name: 'Named by Codex after the fact' } - ]), - 2_000 - ) - const second = await scan(provider) - - expect(second.sessions[0]?.title).toBe('Named by Codex after the fact') - // The #13753 win is preserved: the index is read, the transcript is not. - expect(provider.readFilePaths).toEqual([]) - }) - - it('does not serve a cached parse to a different execution host', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-host.jsonl' - provider.addFile( - path, - transcript('host-session', 'Host scoped', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - - await scan(provider) - provider.readFilePaths.length = 0 - - const other = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:other-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(provider.readFilePaths).toEqual([path]) - expect(other.sessions[0]?.executionHostId).toBe('ssh:other-box') - }) - - it('does not cache a read that failed', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-flaky.jsonl' - provider.addFile( - path, - transcript('flaky-session', 'Recovered', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - - let failNextRead = true - const originalReadFile = provider.readFile.bind(provider) - provider.readFile = async (filePath: string): Promise => { - if (failNextRead && filePath === path) { - failNextRead = false - provider.readFilePaths.push(filePath) - throw new Error('EIO: transient relay read failure') - } - return await originalReadFile(filePath) - } - - const failed = await scan(provider) - expect(failed.sessions).toEqual([]) - expect(failed.issues).toHaveLength(1) - - provider.readFilePaths.length = 0 - const recovered = await scan(provider) - - expect(provider.readFilePaths).toEqual([path]) - expect(recovered.sessions[0]?.title).toBe('Recovered') - }) -}) diff --git a/src/main/ai-vault/remote-session-parse-cache.ts b/src/main/ai-vault/remote-session-parse-cache.ts deleted file mode 100644 index afa420c9294..00000000000 --- a/src/main/ai-vault/remote-session-parse-cache.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' -import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat' - -// Matches the local scanner's cap. The relay sidecar is forked with -// --max-old-space-size=384, and a retained session row is a title, a preview -// window and counters — orders of magnitude smaller than the transcript it was -// parsed from, which is what the cache stops us re-reading. -const MAX_CACHE_ENTRIES = 4096 - -type RemoteSessionParseCacheEntry = { - mtimeMs: number - sizeBytes: number | null - hostKey: string - sidecar?: SessionSidecarObservation - session: AiVaultSession | null -} - -// Module scope so it outlives one scan: the sidecar is retired only after 10 -// idle minutes, so it spans many passes of a 30s cadence. -const cache = new Map() - -export type RemoteSessionParseStats = { reused: number; parsed: number } - -export function resetRemoteSessionParseCacheForTests(): void { - cache.clear() -} - -/** Identity of the host a parse result belongs to; a result is not portable across either field. */ -export function remoteSessionParseHostKey(context: RemoteScannerContext): string { - return `${context.executionHostId}\u0000${context.hostPlatform.relayPlatform}` -} - -export function remoteSessionCandidateKey(candidate: RemoteSessionCandidate): string { - return `${candidate.source.agent}\u0000${candidate.file.path}` -} - -function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void { - cache.delete(path) - cache.set(path, entry) - if (cache.size > MAX_CACHE_ENTRIES) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } - } -} - -/** - * Parse a remote transcript, reusing the previous result when the file is - * provably unchanged. - * - * Why this exists: the remote scanner had no cache of any kind, so every pass - * re-read and re-parsed the whole corpus — up to 3000 whole-file reads, GBs of - * JSONL, including July transcripts that had not changed in a month — which is - * what pegged the relay host on the renderer's 30s forced-rescan cadence - * (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly - * this reason; this is its remote counterpart. - * - * `(mtimeMs, sizeBytes)` covers the transcript, and the sidecar observation - * discovery records beside it (remote-session-scanner-discovery.ts) covers a - * source's companion file, so a metadata-only transcript whose companion - * changed still looks changed. Remote Cline is the only such source; remote - * Cursor streams transcript content with no sibling to read. Sources whose - * parse reads a file discovery does not stat — Codex looks its title up in - * `session_index.jsonl` — are not covered by either and pass - * `refreshReusedSession` to re-derive the uncovered part without touching the - * transcript. - * - * Only a completed parse is stored. A read that threw stays uncached so a - * transient filesystem failure cannot pin a wrong answer for the corpus's life. - */ -export async function parseRemoteSessionFileCached(args: { - candidate: RemoteSessionCandidate - hostKey: string - parse: () => Promise - // Applied to a reused session only; must not re-read the transcript. - refreshReusedSession?: (session: AiVaultSession) => Promise - stats?: RemoteSessionParseStats -}): Promise { - const { file } = args.candidate - const key = remoteSessionCandidateKey(args.candidate) - const entry = cache.get(key) - const unchanged = - entry !== undefined && - entry.hostKey === args.hostKey && - entry.mtimeMs === file.mtimeMs && - (entry.sizeBytes === null || - file.sizeBytes === undefined || - entry.sizeBytes === file.sizeBytes) && - sidecarUnchanged(entry.sidecar, file.sidecar) - if (unchanged) { - if (args.stats) { - args.stats.reused++ - } - if (entry.session && args.refreshReusedSession) { - entry.session = await args.refreshReusedSession(entry.session) - } - // Refresh recency without re-parsing so the LRU evicts cold paths first. - storeEntry(key, entry) - return entry.session - } - - const session = await args.parse() - if (args.stats) { - args.stats.parsed++ - } - storeEntry(key, { - mtimeMs: file.mtimeMs, - sizeBytes: file.sizeBytes ?? null, - hostKey: args.hostKey, - sidecar: file.sidecar, - session - }) - return session -} diff --git a/src/main/ai-vault/remote-session-scan-batching.test.ts b/src/main/ai-vault/remote-session-scan-batching.test.ts deleted file mode 100644 index da9ee87601c..00000000000 --- a/src/main/ai-vault/remote-session-scan-batching.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapRemoteScanBatches } from './remote-session-scan-batching' - -describe('mapRemoteScanBatches', () => { - it('observes an abort that lands while the final batch yields', async () => { - const controller = new AbortController() - - await expect( - mapRemoteScanBatches( - ['a', 'b'], - 2, - async (item) => { - controller.abort() - return item - }, - controller.signal - ) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('observes an abort for empty inputs that never enter the loop', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - mapRemoteScanBatches([], 8, async (item) => item, controller.signal) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('returns every batch result when nothing cancels', async () => { - expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6]) - }) -}) diff --git a/src/main/ai-vault/remote-session-scan-batching.ts b/src/main/ai-vault/remote-session-scan-batching.ts deleted file mode 100644 index d51703e1b15..00000000000 --- a/src/main/ai-vault/remote-session-scan-batching.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' - -// Batching bounds concurrent remote round trips; the yield between batches keeps -// the process responsive, and the batch boundary is where cancellation lands. -export async function mapRemoteScanBatches( - items: readonly T[], - concurrency: number, - mapper: (item: T) => Promise, - signal?: AbortSignal -): Promise { - const results: U[] = [] - for (let index = 0; index < items.length; index += concurrency) { - throwIfAiVaultScanCancelled(signal) - results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper)))) - await yieldToEventLoop() - } - // An abort can land while the last batch yields, and empty inputs never enter - // the loop at all — observe it here so neither path returns as a success. - throwIfAiVaultScanCancelled(signal) - return results -} diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts deleted file mode 100644 index 7fe45d9f1a0..00000000000 --- a/src/main/ai-vault/remote-session-scan-concurrency.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' - -// Why: discovery batches (8 sources) each stat in batches of 8, and parse -// batches read whole transcripts — nested fan-out put ~64 filesystem round -// trips on one SSH mux or relay event loop at once, blowing the scan budget and -// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling. -const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8 - -/** Wraps a provider so all of one scan's filesystem calls share a single - * in-flight ceiling, regardless of how the callers nest their batches. */ -export function limitRemoteScanFilesystemConcurrency( - provider: RemoteSessionFilesystemProvider, - maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY -): RemoteSessionFilesystemProvider { - const gate = createConcurrencyGate(maxInFlight) - return { - openCode: provider.openCode, - readDir: (dirPath) => gate(() => provider.readDir(dirPath)), - readFile: (filePath, limits) => gate(() => provider.readFile(filePath, limits)), - stat: (filePath) => gate(() => provider.stat(filePath)), - ...(provider.readTranscriptBytes - ? { - readTranscriptBytes: async function* ( - path: string, - signal?: AbortSignal, - options?: { regularFileOnly: true; maxBytes: number } - ) { - let enter!: () => void - let release!: () => void - const entered = new Promise((resolve) => { - enter = resolve - }) - const released = new Promise((resolve) => { - release = resolve - }) - const held = gate(async () => { - enter() - await released - }) - await entered - try { - yield* provider.readTranscriptBytes!(path, signal, options) - } finally { - release() - await held - } - } - } - : {}) - } -} - -function createConcurrencyGate(maxInFlight: number): (run: () => Promise) => Promise { - const limit = Math.max(1, Math.floor(maxInFlight)) - const waiting: (() => void)[] = [] - let inFlight = 0 - - return async (run: () => Promise): Promise => { - if (inFlight < limit) { - inFlight++ - } else { - // The releasing call hands its slot over directly, so a queued caller can - // never race a fresh one into an over-limit slot. - await new Promise((resolve) => waiting.push(resolve)) - } - try { - return await run() - } finally { - const next = waiting.shift() - if (next) { - next() - } else { - inFlight-- - } - } - } -} diff --git a/src/main/ai-vault/remote-session-scanner-cline-source.ts b/src/main/ai-vault/remote-session-scanner-cline-source.ts deleted file mode 100644 index f568aa06ce9..00000000000 --- a/src/main/ai-vault/remote-session-scanner-cline-source.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { isMissingRemoteSessionPathError } from './remote-session-file-stat' -import type { RemoteSessionSource } from './remote-session-scanner-types' -import { - clineMessagesPathForMetadata, - isClineSessionMetadataPath, - parseClineSessionDocuments, - parseClineSessionContent -} from './session-scanner-cline-parser' - -export function remoteClineSource( - remoteHome: string, - hostPlatform: RemoteHostPlatform -): RemoteSessionSource { - return { - agent: 'cline', - rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'), - extensions: ['.json'], - filePredicate: isClineSessionMetadataPath, - contentDependencyPath: clineMessagesPathForMetadata, - directoryPredicate: (_name, depth) => depth === 0, - parseDocument: (file, bytes, context) => - parseClineSessionDocuments( - file, - bytes, - () => - context.provider.readTranscriptBytes!( - clineMessagesPathForMetadata(file.path), - context.signal - ), - context.hostPlatform.os, - { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }, - context.signal - ), - parse: async (file, content, context) => { - let messagesContent: string | null = null - try { - throwIfAiVaultScanCancelled(context.signal) - const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path)) - throwIfAiVaultScanCancelled(context.signal) - messagesContent = read.isBinary ? null : read.content - } catch (error) { - throwIfAiVaultScanCancelled(context.signal) - if (!isMissingRemoteSessionPathError(error)) { - throw error - } - } - return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }) - } - } -} diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts deleted file mode 100644 index 89951110cdf..00000000000 --- a/src/main/ai-vault/remote-session-scanner-codex-index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' -import { remoteSessionContentLines } from './remote-session-content-lines' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import type { - RemoteScannerContext, - RemoteSessionFilesystemProvider -} from './remote-session-scanner-types' - -const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' - -// One index read per CODEX_HOME per scan (`context.titleCaches` is scan-scoped); -// used both by the transcript parse and by the parse cache's reuse path. -export function remoteCodexIndexedTitleReader( - codexHome: string, - context: RemoteScannerContext -): (sessionId: string) => Promise { - return async (sessionId) => - ( - await remoteCodexIndexTitles({ - provider: context.provider, - codexHome, - hostPlatform: context.hostPlatform, - titleCaches: context.titleCaches, - signal: context.signal - }) - ).get(sessionId) ?? null -} - -export async function remoteCodexIndexTitles(args: { - provider: RemoteSessionFilesystemProvider - codexHome: string - hostPlatform: RemoteHostPlatform - titleCaches: Map>> - signal?: AbortSignal -}): Promise> { - const cached = args.titleCaches.get(args.codexHome) - if (cached) { - return cached - } - const pending = readRemoteCodexIndexTitles( - args.provider, - args.codexHome, - args.hostPlatform, - args.signal - ) - args.titleCaches.set(args.codexHome, pending) - return pending -} - -async function readRemoteCodexIndexTitles( - provider: RemoteSessionFilesystemProvider, - codexHome: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise> { - const titleBySessionId = new Map() - try { - throwIfAiVaultScanCancelled(signal) - const { content, isBinary } = await provider.readFile( - joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE) - ) - throwIfAiVaultScanCancelled(signal) - if (isBinary) { - return titleBySessionId - } - for await (const line of remoteSessionContentLines(content, signal)) { - const record = parseJsonObject(line) - if (!record) { - continue - } - const sessionId = extractString(record.id) - const title = normalizeTitleText(extractString(record.thread_name) ?? '') - if (sessionId && title) { - titleBySessionId.set(sessionId, title) - } - } - } catch { - throwIfAiVaultScanCancelled(signal) - // Codex indexes are opportunistic; raw transcripts remain sufficient. - } - return titleBySessionId -} diff --git a/src/main/ai-vault/remote-session-scanner-codex-sources.ts b/src/main/ai-vault/remote-session-scanner-codex-sources.ts deleted file mode 100644 index 2e6175a445f..00000000000 --- a/src/main/ai-vault/remote-session-scanner-codex-sources.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { joinRemotePath, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { parseCodexSessionContent } from './session-scanner-codex-parser' -import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' -import type { RemoteSessionContent } from './remote-session-content-lines' -import type { FileWithMtime } from './session-scanner-types' -import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types' - -/** Codex is the one remote agent whose sessions live under two CODEX_HOME roots, so it - * yields a source per root. Split from `remote-session-scanner-sources.ts` so adding an - * agent does not push that module past its line budget. */ -export function remoteCodexSources( - remoteHome: string, - hostPlatform: RemoteHostPlatform -): RemoteSessionSource[] { - return [ - joinRemotePath(hostPlatform, remoteHome, '.codex'), - joinRemotePath( - hostPlatform, - remoteHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - ].map((codexHome) => { - const parse = ( - file: FileWithMtime, - content: RemoteSessionContent, - context: RemoteScannerContext - ) => - parseCodexSessionContent({ - file, - content, - platform: context.hostPlatform.os, - codexHome, - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os, - signal: context.signal, - readIndexedTitle: remoteCodexIndexedTitleReader(codexHome, context) - }) - return { - agent: 'codex', - rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), - codexHome, - extensions: ['.jsonl'], - parse, - parseLines: parse - } - }) -} diff --git a/src/main/ai-vault/remote-session-scanner-devin-source.ts b/src/main/ai-vault/remote-session-scanner-devin-source.ts deleted file mode 100644 index 8244dd6e625..00000000000 --- a/src/main/ai-vault/remote-session-scanner-devin-source.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { remoteSessionDocumentParsers } from './remote-session-document-parsers' -import type { RemoteSessionSource } from './remote-session-scanner-types' -import { parseDevinSessionContent } from './session-scanner-devin-parser' - -// Why: Devin CLI writes transcripts under %APPDATA% on a Windows host and -// ~/.local/share on posix ones. -function remoteDevinDataSegments(hostPlatform: RemoteHostPlatform): string[] { - return hostPlatform.os === 'win32' - ? ['AppData', 'Roaming', 'devin', 'cli'] - : ['.local', 'share', 'devin', 'cli'] -} - -export function remoteDevinSource( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - directory: 'transcripts' | 'agent_logs' = 'transcripts' -): RemoteSessionSource { - return { - agent: 'devin', - rootDir: joinRemotePath( - hostPlatform, - remoteHome, - ...remoteDevinDataSegments(hostPlatform), - directory - ), - extensions: ['.json'], - ...remoteSessionDocumentParsers('devin'), - parse: (file, content, context) => - Promise.resolve( - parseDevinSessionContent(file, content, context.hostPlatform.os, { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }) - ) - } -} diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts deleted file mode 100644 index 9f01855097c..00000000000 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { extname } from 'node:path' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../shared/execution-host' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' -import type { FileWithMtime } from './session-scanner-types' -import type { SessionSidecarObservation } from './session-sidecar-stat' -import { errorMessage } from './session-scanner-values' -import { mapRemoteScanBatches } from './remote-session-scan-batching' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordSessionScanIssue } from './session-scan-issues' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionSource -} from './remote-session-scanner-types' - -const REMOTE_DISCOVERY_CONCURRENCY = 8 - -export async function discoverRemoteSourceCandidates(args: { - source: RemoteSessionSource - context: RemoteScannerContext - issues: AiVaultScanIssue[] -}): Promise { - if (args.source.discover) { - const files = await args.source.discover(args.context, args.issues) - throwIfAiVaultScanCancelled(args.context.signal) - return files.map((file) => ({ source: args.source, file })) - } - const walked = args.source.fixedChildFileSegments - ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) - : await walkRemoteSessionFiles(args.source, args.context, args.issues) - const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null - const paths = partition ? partition.sessionFilePaths : walked - const files = await mapRemoteScanBatches( - paths, - REMOTE_DISCOVERY_CONCURRENCY, - (path) => statRemoteCandidateFile(path, args.source, args.context, args.issues), - args.context.signal - ) - return files - .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ - source: args.source, - file, - subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 - })) -} - -async function statRemoteCandidateFile( - path: string, - source: RemoteSessionSource, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - const file = await statRemoteSessionFile( - context.provider, - path, - source.agent, - context.executionHostId, - issues, - { - missingIsExpected: Boolean(source.fixedChildFileSegments), - signal: context.signal - } - ) - if (!file || !source.contentDependencyPath) { - return file - } - const sidecarPath = source.contentDependencyPath(path) - // Recorded beside the transcript's own stat, never folded into it: one key - // cannot mean both "the transcript grew" and "the sibling changed". - return { ...file, sidecar: await observeRemoteSidecar(source, context, sidecarPath, issues) } -} - -/** - * A stat that failed for any reason other than a missing path is `'unknown'`, - * not `'none'`: serving the cached session over an unreadable sibling would - * publish metadata nobody can currently see. `statRemoteSessionFile` already - * recorded the issue for the failure. - */ -async function observeRemoteSidecar( - source: RemoteSessionSource, - context: RemoteScannerContext, - sidecarPath: string, - issues: AiVaultScanIssue[] -): Promise { - try { - const sidecar = await statRemoteSessionFile( - context.provider, - sidecarPath, - source.agent, - context.executionHostId, - issues, - { missingIsExpected: true, signal: context.signal, rethrowFailures: true } - ) - return sidecar - ? { path: sidecarPath, mtimeMs: sidecar.mtimeMs, sizeBytes: sidecar.sizeBytes ?? 0 } - : 'none' - } catch { - return 'unknown' - } -} - -async function listRemoteFixedChildFiles( - source: RemoteSessionSource, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - throwIfAiVaultScanCancelled(context.signal) - let entries - try { - entries = await context.provider.readDir(source.rootDir) - } catch (err) { - throwIfAiVaultScanCancelled(context.signal) - recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) - return [] - } - const segments = source.fixedChildFileSegments ?? [] - // Why: Antigravity's transcript path is fixed. Constructing it avoids three - // serialized SSH readDir round trips for every conversation directory. - return entries - .filter((entry) => entry.isDirectory && !entry.isSymlink) - .flatMap((entry) => - [segments, ...(source.additionalFixedChildFileSegments ?? [])].map((fileSegments) => - joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...fileSegments) - ) - ) - .filter((path) => source.filePredicate?.(path) ?? true) -} - -async function walkRemoteSessionFiles( - source: RemoteSessionSource, - context: RemoteScannerContext, - issues: AiVaultScanIssue[], - dirPath = source.rootDir, - depth = 0 -): Promise { - throwIfAiVaultScanCancelled(context.signal) - let entries - try { - entries = await context.provider.readDir(dirPath) - } catch (err) { - throwIfAiVaultScanCancelled(context.signal) - recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) - return [] - } - - const extensions = new Set(source.extensions) - const files: string[] = [] - for (const entry of entries) { - throwIfAiVaultScanCancelled(context.signal) - const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) - if ( - entry.isDirectory && - !entry.isSymlink && - (source.directoryPredicate?.(entry.name, depth) ?? true) - ) { - files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) - continue - } - if ( - !entry.isSymlink && - extensions.has(extname(entry.name).toLowerCase()) && - (source.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) - } - } - return files -} - -function recordRemoteDirectoryIssue( - source: RemoteSessionSource, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[], - path: string, - err: unknown -): void { - if (!isMissingRemoteSessionPathError(err)) { - recordSessionScanIssue(issues, { - executionHostId, - agent: source.agent, - kind: 'host', - path, - message: errorMessage(err) - }) - } -} diff --git a/src/main/ai-vault/remote-session-scanner-kiro-source.ts b/src/main/ai-vault/remote-session-scanner-kiro-source.ts deleted file mode 100644 index 5362d4033f5..00000000000 --- a/src/main/ai-vault/remote-session-scanner-kiro-source.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { isMissingRemoteSessionPathError } from './remote-session-file-stat' -import { - remoteSessionContentLines, - streamedSessionContentLines -} from './remote-session-content-lines' -import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types' -import { - isKiroSessionMetadataPath, - kiroTranscriptPathForMetadata, - parseKiroSessionContent -} from './session-scanner-kiro-parser' - -async function readTranscriptLines( - metadataPath: string, - context: RemoteScannerContext -): Promise | AsyncIterable | null> { - const transcriptPath = kiroTranscriptPathForMetadata(metadataPath) - // Why: beside the execution host the transcript streams; megabyte tool outputs are common. - if (context.provider.readTranscriptBytes) { - return streamedSessionContentLines( - context.provider.readTranscriptBytes(transcriptPath, context.signal), - context.signal - ) - } - try { - throwIfAiVaultScanCancelled(context.signal) - const read = await context.provider.readFile(transcriptPath) - throwIfAiVaultScanCancelled(context.signal) - return read.isBinary ? null : remoteSessionContentLines(read.content, context.signal) - } catch (error) { - throwIfAiVaultScanCancelled(context.signal) - if (isMissingRemoteSessionPathError(error)) { - return null - } - throw error - } -} - -export function remoteKiroSource( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - kiroHomeDir?: string -): RemoteSessionSource { - return { - agent: 'kiro', - rootDir: kiroHomeDir - ? joinRemotePath(hostPlatform, kiroHomeDir, 'sessions', 'cli') - : joinRemotePath(hostPlatform, remoteHome, '.kiro', 'sessions', 'cli'), - extensions: ['.json'], - filePredicate: isKiroSessionMetadataPath, - contentDependencyPath: kiroTranscriptPathForMetadata, - directoryPredicate: () => false, - parse: async (file, content, context) => { - const options = { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - } - try { - const lines = await readTranscriptLines(file.path, context) - return await parseKiroSessionContent(file, content, lines, context.hostPlatform.os, options) - } catch (error) { - // A transcript that vanished mid-stream leaves a valid metadata-only session. - if (!isMissingRemoteSessionPathError(error)) { - throw error - } - return parseKiroSessionContent(file, content, null, context.hostPlatform.os, options) - } - } - } -} diff --git a/src/main/ai-vault/remote-session-scanner-kiro.test.ts b/src/main/ai-vault/remote-session-scanner-kiro.test.ts deleted file mode 100644 index 78700bbe370..00000000000 --- a/src/main/ai-vault/remote-session-scanner-kiro.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import { KIRO_FIXTURE_SESSION_ID } from './session-scanner-kiro-fixtures' - -afterEach(() => vi.unstubAllEnvs()) - -describe('remote Kiro history', () => { - it.each([ - ['linux-x64', '/home/ada', '/srv/kiro'], - ['win32-x64', 'C:/Users/Ada', 'D:/kiro'] - ] as const)( - 'reads the %s execution host override, independent of the client', - async (platform, remoteHome, kiroHomeDir) => { - vi.stubEnv('KIRO_HOME', '/client/must-not-be-read') - const provider = new MemoryRemoteProvider() - const path = `${kiroHomeDir}/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json` - provider.addFile(path, JSON.stringify({ cwd: remoteHome, title: 'Remote Kiro' }), 10) - provider.addFile( - `${path}l`, - jsonLines([ - { kind: 'Prompt', data: { content: [{ kind: 'text', data: 'Read the file' }] } }, - { kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'Done' }] } } - ]), - 11 - ) - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:kiro-host', - remoteHome, - kiroHomeDir, - hostPlatform: getRemoteHostPlatform(platform) - }) - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'kiro', - sessionId: KIRO_FIXTURE_SESSION_ID, - filePath: path, - messageCount: 2, - executionHostId: 'ssh:kiro-host' - }) - } - ) - - it('keeps the remote default when the peer supplies no override', async () => { - vi.stubEnv('KIRO_HOME', '/client/must-not-be-read') - const provider = new MemoryRemoteProvider() - const path = `/home/ada/.kiro/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json` - provider.addFile(path, JSON.stringify({ cwd: '/home/ada', title: 'Remote Kiro' }), 10) - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:kiro-host', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - expect(result.issues).toEqual([]) - expect(result.sessions[0]?.filePath).toBe(path) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-muse.test.ts b/src/main/ai-vault/remote-session-scanner-muse.test.ts deleted file mode 100644 index 3d94fc1eee4..00000000000 --- a/src/main/ai-vault/remote-session-scanner-muse.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' - -describe('scanRemoteAiVaultSessions muse', () => { - it('discovers Muse transcripts under the remote XDG sessions root', async () => { - const provider = new MemoryRemoteProvider() - const sessionDir = '/home/ada/.local/share/muse/sessions/2026/07/04/muse-remote' - provider.addFile( - `${sessionDir}/session.jsonl`, - jsonLines([ - { - record_type: 'event', - payload_type: 'runtime.session.metadata', - recorded_at: 1780000000000000, - payload: { kind: 'metadata', record: { workspace_root: '/home/ada/repo' } } - }, - { - record_type: 'event', - payload_type: 'runtime.user_intent.accepted', - recorded_at: 1780000001000000, - payload: { - intent_id: 'intent-remote', - refill_blocks: [{ kind: 'text', text: 'Remote muse title' }] - } - }, - { - record_type: 'event', - payload_type: 'runtime.session', - recorded_at: 1780000002000000, - payload: { - kind: 'run', - run_id: 'run-remote', - event: { - kind: 'model_completed', - model: 'muse-spark-remote', - usage: { input_tokens: 3, output_tokens: 4 } - } - } - } - ]), - 40 - ) - // Sidecars next to the transcript must not list as sessions. - provider.addFile(`${sessionDir}/cli-abc.log`, 'log output', 41) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'muse', - sessionId: 'muse-remote', - title: 'Remote muse title', - model: 'muse-spark-remote', - filePath: `${sessionDir}/session.jsonl` - }) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts deleted file mode 100644 index 8185765e4d2..00000000000 --- a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' - -describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => { - it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => { - const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' - const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0' - const provider = new MemoryRemoteProvider() - provider.addFile( - `${workspaceDir}/${stem}.jsonl`, - jsonLines([ - { - type: 'session', - version: 3, - id: 'cccccccc-dddd-4eee-8fff-000000000000', - cwd: '/home/ada/repo', - timestamp: '2026-05-01T10:00:00.000Z' - }, - { - type: 'message', - timestamp: '2026-05-01T10:00:01.000Z', - message: { role: 'user', content: 'Delegate some tasks' } - } - ]), - 70 - ) - for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) { - provider.addFile( - `${workspaceDir}/${stem}/${label}.jsonl`, - jsonLines([ - { - type: 'message', - timestamp: '2026-05-01T10:01:00.000Z', - message: { role: 'user', content: `${label} task prompt` } - } - ]), - 71 - ) - } - // A grandchild belongs to its own parent: neither a row nor a count here. - provider.addFile( - `${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`, - jsonLines([ - { - type: 'message', - timestamp: '2026-05-01T10:02:00.000Z', - message: { role: 'user', content: 'Nested task prompt' } - } - ]), - 72 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'omp', - sessionId: 'cccccccc-dddd-4eee-8fff-000000000000', - subagentTranscriptCount: 2, - filePath: `${workspaceDir}/${stem}.jsonl` - }) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-opencode-source.ts b/src/main/ai-vault/remote-session-scanner-opencode-source.ts deleted file mode 100644 index c42f4b6cb09..00000000000 --- a/src/main/ai-vault/remote-session-scanner-opencode-source.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { isMissingRemoteSessionPathError } from './remote-session-file-stat' -import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' -import { remotePathSegments } from './remote-session-scanner-source-parsers' -import type { - RemoteOpenCodeSessionReader, - RemoteScannerContext, - RemoteSessionSource -} from './remote-session-scanner-types' -import { recordSessionScanIssue } from './session-scan-issues' -import { restampAiVaultListResult } from './session-list-results' -import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' -import { errorMessage } from './session-scanner-values' - -export function remoteOpenCodeSources( - reader: RemoteOpenCodeSessionReader | undefined, - candidateLimit: number -): RemoteSessionSource[] { - if (!reader) { - return [] - } - let databasePaths: Promise | undefined - return (['opencode', 'opencode2'] as const).map((agent) => { - const source: RemoteSessionSource = { - agent, - rootDir: reader.dataDirectory, - extensions: ['.json'], - parse: async () => null, - parseCandidate: async (file, context) => { - throwIfAiVaultScanCancelled(context.signal) - const candidate = splitOpenCodeSqliteCandidate(file.path) - const session = candidate - ? await reader.parse({ - ...candidate, - platform: context.hostPlatform.os, - ...(agent === 'opencode2' ? { agent } : {}), - signal: context.signal - }) - : await reader.parseLegacy(file, context.hostPlatform.os) - throwIfAiVaultScanCancelled(context.signal) - if (!session) { - return null - } - const stamped = restampAiVaultListResult( - { sessions: [session], issues: [], scannedAt: '' }, - context.executionHostId - ).sessions[0] - return stamped ? { ...stamped, executionHostPlatform: context.hostPlatform.os } : null - }, - discover: async (context, issues) => { - databasePaths ??= discoverDatabasePaths(reader, context, issues) - const paths = await databasePaths - const ownPaths = - agent === 'opencode2' - ? paths - : paths.filter( - (path) => !isOpenCodeV2DatabaseName(remotePathSegments(path).at(-1) ?? '') - ) - const readIssues: AiVaultScanIssue[] = [] - const candidates = await reader.list({ - dbPaths: ownPaths, - limit: candidateLimit, - issues: readIssues, - ...(agent === 'opencode2' ? { agent } : {}), - signal: context.signal - }) - throwIfAiVaultScanCancelled(context.signal) - for (const issue of readIssues) { - recordSessionScanIssue(issues, { ...issue, executionHostId: context.executionHostId }) - } - const files = candidates.map((candidate) => candidate.file) - if (agent === 'opencode2') { - return files - } - const legacy = await discoverRemoteSourceCandidates({ - source: { - ...source, - discover: undefined, - rootDir: joinRemotePath( - context.hostPlatform, - reader.dataDirectory, - 'storage', - 'session' - ) - }, - context, - issues - }) - const ids = new Set(files.map((file) => splitOpenCodeSqliteCandidate(file.path)?.sessionId)) - return [ - ...files, - ...legacy - .filter( - ({ file }) => - !ids.has( - remotePathSegments(file.path) - .at(-1) - ?.replace(/\.json$/, '') - ) - ) - .map(({ file }) => file) - ] - } - } - return source - }) -} - -async function discoverDatabasePaths( - reader: RemoteOpenCodeSessionReader, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - throwIfAiVaultScanCancelled(context.signal) - if (reader.databasePath === null) { - return [] - } - try { - if (reader.databasePath !== undefined) { - const stat = await context.provider.stat(reader.databasePath) - return stat.type === 'file' || stat.type === 'symlink' ? [reader.databasePath] : [] - } - const entries = await context.provider.readDir(reader.dataDirectory) - return entries - .filter( - (entry) => - !entry.isDirectory && - !entry.isSymlink && - /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name) - ) - .map((entry) => joinRemotePath(context.hostPlatform, reader.dataDirectory, entry.name)) - .sort() - } catch (error) { - throwIfAiVaultScanCancelled(context.signal) - if (!isMissingRemoteSessionPathError(error)) { - recordSessionScanIssue(issues, { - executionHostId: context.executionHostId, - agent: 'opencode', - kind: 'scope', - path: reader.databasePath ?? reader.dataDirectory, - message: errorMessage(error) - }) - } - return [] - } -} diff --git a/src/main/ai-vault/remote-session-scanner-opencode.test.ts b/src/main/ai-vault/remote-session-scanner-opencode.test.ts deleted file mode 100644 index 573ee1939cf..00000000000 --- a/src/main/ai-vault/remote-session-scanner-opencode.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import type { RemoteOpenCodeSessionReader } from './remote-session-scanner-types' -import { createAccumulator, finalizeSession, updateTimeline } from './session-scanner-accumulator' -import { - buildOpenCodeSqliteCandidatePath, - splitOpenCodeSqliteCandidate -} from './session-scanner-opencode-sqlite-paths' -import type { FileWithMtime } from './session-scanner-types' - -const remoteHome = '/home/ada' -const dataDirectory = `${remoteHome}/.local/share/opencode` -const hostPlatform = getRemoteHostPlatform('linux-x64') -const scanOptions = { remoteHome, hostPlatform, executionHostId: 'ssh:one' as const } - -beforeEach(resetRemoteSessionParseCacheForTests) - -function fixture() { - const sessions = new Map() - const list: RemoteOpenCodeSessionReader['list'] = vi.fn(async (args) => - [...sessions.values()] - .filter( - (session) => - session.agent === (args.agent ?? 'opencode') && args.dbPaths.includes(session.filePath) - ) - .sort((left, right) => Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt)) - .slice(0, args.limit) - .map((session) => ({ - agent: session.agent, - codexHome: null, - file: file( - buildOpenCodeSqliteCandidatePath(session.filePath, session.sessionId), - Date.parse(session.modifiedAt) - ) - })) - ) - const parse: RemoteOpenCodeSessionReader['parse'] = vi.fn( - async (args) => - sessions.get( - `${args.agent ?? 'opencode'}:${buildOpenCodeSqliteCandidatePath(args.dbPath, args.sessionId)}` - ) ?? null - ) - const parseLegacy = vi.fn(async (entry: FileWithMtime) => - session('opencode', 'legacy', entry, '/home/ada/repo') - ) - const provider = Object.assign(new MemoryRemoteProvider(), { - openCode: { dataDirectory, list, parse, parseLegacy } - }) - const add = ( - id: string, - mtime: number, - options: { agent?: 'opencode' | 'opencode2'; cwd?: string; dbName?: string } = {} - ) => { - const agent = options.agent ?? 'opencode' - const dbPath = `${dataDirectory}/${options.dbName ?? 'opencode.db'}` - provider.addFile(dbPath, 'database', 1) - const path = buildOpenCodeSqliteCandidatePath(dbPath, id) - sessions.set( - `${agent}:${path}`, - session(agent, id, file(path, mtime), options.cwd ?? '/home/ada/other') - ) - } - return { provider, add, list, parse, parseLegacy } -} - -function file(path: string, mtimeMs: number): FileWithMtime { - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } -} - -function session( - agent: 'opencode' | 'opencode2', - sessionId: string, - entry: FileWithMtime, - cwd: string -): AiVaultSession { - const accumulator = createAccumulator({ - agent, - file: { ...entry, path: splitOpenCodeSqliteCandidate(entry.path)?.dbPath ?? entry.path }, - sessionId - }) - accumulator.cwd = cwd - updateTimeline(accumulator, entry.modifiedAt) - const parsed = finalizeSession(accumulator, 'linux') - if (!parsed) { - throw new Error('Invalid fixture session') - } - return parsed -} - -describe('remote OpenCode discovery', () => { - it('sorts database rows with file-backed agents before applying the global cap', async () => { - const { provider, add } = fixture() - add('older', 1000) - add('newest', 3000) - provider.addFile( - `${remoteHome}/.claude/projects/project/middle.jsonl`, - jsonLines([ - { - sessionId: 'middle', - timestamp: new Date(2000).toISOString(), - type: 'user', - message: { content: 'middle' } - } - ]), - 2000 - ) - const read = vi.spyOn(provider, 'readFile') - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 2 }) - expect(result.issues).toEqual([]) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['newest', 'middle']) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:one', - executionHostPlatform: 'linux', - id: `ssh:one:opencode:newest:${dataDirectory}/opencode.db` - }) - expect(read.mock.calls.every(([path]) => !path.includes('.db#'))).toBe(true) - }) - - it('retains older scoped rows beyond the recent parse budget', async () => { - const { provider, add, list } = fixture() - for (let index = 0; index < 10; index++) { - add(`other-${index}`, 10000 + index) - } - add('scoped', 1000, { cwd: '/home/ada/repo/app' }) - const result = await scanRemoteAiVaultSessions({ - ...scanOptions, - provider, - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['other-9', 'scoped']) - expect(list).toHaveBeenCalledWith(expect.objectContaining({ limit: 1003 })) - }) - - it('drops stale legacy duplicates before limiting while keeping legacy-only history', async () => { - const { provider, add, parseLegacy } = fixture() - add('migrated', 2000) - provider.addFile(`${dataDirectory}/storage/session/project/migrated.json`, '{}', 9000) - provider.addFile(`${dataDirectory}/storage/session/project/legacy.json`, '{}', 1000) - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 10 }) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['migrated', 'legacy']) - expect(parseLegacy).toHaveBeenCalledTimes(1) - expect(parseLegacy).toHaveBeenCalledWith( - expect.objectContaining({ path: expect.stringContaining('/legacy.json') }), - 'linux' - ) - }) - - it('keeps v1 and v2 identities separate in a shared database and parse cache', async () => { - const { provider, add, parse, list } = fixture() - add('same-id', 2000) - add('same-id', 2000, { agent: 'opencode2' }) - add('beta', 1000, { agent: 'opencode2', dbName: 'opencode-next.db' }) - const first = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - const second = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(first.sessions.map((entry) => entry.agent).sort()).toEqual([ - 'opencode', - 'opencode2', - 'opencode2' - ]) - expect(second.sessions).toEqual(first.sessions) - expect(parse).toHaveBeenCalledTimes(3) - expect(list).toHaveBeenCalledWith( - expect.objectContaining({ dbPaths: [`${dataDirectory}/opencode.db`] }) - ) - expect(list).toHaveBeenCalledWith( - expect.objectContaining({ - dbPaths: [`${dataDirectory}/opencode-next.db`, `${dataDirectory}/opencode.db`], - agent: 'opencode2' - }) - ) - }) - - it('invalidates cached database rows after updates and across hosts', async () => { - const { provider, add, parse } = fixture() - add('session', 1000) - await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - add('session', 2000) - const updated = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - const other = await scanRemoteAiVaultSessions({ - ...scanOptions, - executionHostId: 'ssh:two', - provider - }) - expect(parse).toHaveBeenCalledTimes(3) - expect(updated.sessions[0]?.modifiedAt).toBe(new Date(2000).toISOString()) - expect(other.sessions[0]?.executionHostId).toBe('ssh:two') - }) - - it('propagates cancellation into the reader and does not cache cancelled parses', async () => { - const { provider, add, parse } = fixture() - add('session', 1000) - const controller = new AbortController() - provider.openCode.parse = async (args) => { - expect(args.signal).toBe(controller.signal) - controller.abort() - return parse(args) - } - await expect( - scanRemoteAiVaultSessions({ ...scanOptions, provider, signal: controller.signal }) - ).rejects.toMatchObject({ name: 'AbortError' }) - provider.openCode.parse = parse - await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(parse).toHaveBeenCalledTimes(2) - }) - - it('leaves legacy filesystem providers on their existing fallback', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile(`${dataDirectory}/opencode.db`, 'database', 1000) - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([]) - expect(provider.readDirPaths).not.toContain(dataDirectory) - }) - - it('honors an explicitly configured database symlink without enumerating sibling databases', async () => { - const { provider, add, list } = fixture() - add('selected', 1000, { dbName: 'opencode-team.db' }) - add('other', 2000) - const selected = `${dataDirectory}/opencode-team.db` - Object.assign(provider.openCode, { databasePath: selected }) - const stat = provider.stat.bind(provider) - vi.spyOn(provider, 'stat').mockImplementation(async (path) => - path === selected ? { type: 'symlink', size: 20, mtime: 1000, mtimeMs: 1000 } : stat(path) - ) - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['selected']) - expect(list).toHaveBeenCalledWith(expect.objectContaining({ dbPaths: [selected] })) - expect(provider.readDirPaths).not.toContain(dataDirectory) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-source-parsers.ts b/src/main/ai-vault/remote-session-scanner-source-parsers.ts deleted file mode 100644 index c28b69319c2..00000000000 --- a/src/main/ai-vault/remote-session-scanner-source-parsers.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' -import { - parseMuseSessionContent, - parseMuseSessionRemoteContent -} from './session-scanner-muse-parser' -import type { FileWithMtime } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' -import type { RemoteSessionContent } from './remote-session-content-lines' -import type { RemoteParserOptions } from './remote-session-scanner-types' - -export function parseMuseRemoteContent( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - if (typeof content === 'string') { - return Promise.resolve(parseMuseSessionContent(file, content, platform, options)) - } - return parseMuseSessionRemoteContent(file, content, platform, options, signal) -} - -export function piParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) -} - -export function ompParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) -} - -export function primeAgentParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal) -} - -export function openClawParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) -} - -export function remotePathSegments(path: string): string[] { - return path.replace(/\\/g, '/').split('/').filter(Boolean) -} - -export function remotePiSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) -} - -export function remoteOmpSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) -} - -// Remote roots are POSIX regardless of the client platform. -export function remotePrimeAgentSessionsSegments(): string[] { - return ['.prime', 'agent', 'sessions'] -} diff --git a/src/main/ai-vault/remote-session-scanner-sources.test.ts b/src/main/ai-vault/remote-session-scanner-sources.test.ts deleted file mode 100644 index aa492565ee4..00000000000 --- a/src/main/ai-vault/remote-session-scanner-sources.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import type { RelayPlatform } from '../ssh/relay-protocol' -import { remoteSessionSources } from './remote-session-scanner-sources' - -function devinRootDirs(relayPlatform: RelayPlatform, remoteHome: string): string[] { - const hostPlatform: RemoteHostPlatform = getRemoteHostPlatform(relayPlatform) - return remoteSessionSources(remoteHome, hostPlatform) - .filter((source) => source.agent === 'devin') - .map((source) => source.rootDir) -} - -describe('remoteSessionSources devin transcripts root', () => { - it.each([ - { - relayPlatform: 'win32-x64' as const, - remoteHome: 'C:/Users/dev', - expected: 'C:/Users/dev/AppData/Roaming/devin/cli/transcripts' - }, - { - relayPlatform: 'linux-x64' as const, - remoteHome: '/home/dev', - expected: '/home/dev/.local/share/devin/cli/transcripts' - }, - { - relayPlatform: 'darwin-arm64' as const, - remoteHome: '/Users/dev', - expected: '/Users/dev/.local/share/devin/cli/transcripts' - } - ])('resolves $expected on $relayPlatform', ({ relayPlatform, remoteHome, expected }) => { - expect(devinRootDirs(relayPlatform, remoteHome)).toEqual([ - expected, - expected.replace(/transcripts$/, 'agent_logs') - ]) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts deleted file mode 100644 index 7503348c33e..00000000000 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { - ANTIGRAVITY_HISTORY_ROOTS, - type AntigravitySessionOrigin -} from '../../shared/antigravity-session-origin' -import { parseQoderSessionContent } from './session-scanner-qoder-parser' -import { remoteSessionDocumentParsers } from './remote-session-document-parsers' -import type { RemoteSessionContent } from './remote-session-content-lines' -import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' -import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' -import { parseDroidSessionContent } from './session-scanner-droid-parser' -import { parseClaudeSessionContent } from './session-scanner-primary-parsers' -import { parseCodebuddySessionContent } from './session-scanner-codebuddy-parser' -import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' -import { parseCopilotSessionContent } from './session-scanner-copilot-parser' -import { parseCursorSessionContent } from './session-scanner-cursor-parser' -import { parseHermesSessionContent } from './session-scanner-hermes-parser' -import { parseJcodeSessionContent } from './session-scanner-jcode-parser' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' -import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' -import { - ompParser, - openClawParser, - parseMuseRemoteContent, - piParser, - primeAgentParser, - remoteOmpSessionsSegments, - remotePathSegments, - remotePiSessionsSegments, - remotePrimeAgentSessionsSegments -} from './remote-session-scanner-source-parsers' -import type { FileWithMtime } from './session-scanner-types' -import { openClawSessionDirectoryPredicate } from './openclaw-session-layout' -import { remoteCodexSources } from './remote-session-scanner-codex-sources' -import { remoteClineSource } from './remote-session-scanner-cline-source' -import { remoteKiroSource } from './remote-session-scanner-kiro-source' -import { remoteDevinSource } from './remote-session-scanner-devin-source' -import type { - RemoteParserOptions, - RemoteScannerContext, - RemoteSessionSource -} from './remote-session-scanner-types' - -type RemoteContentParser = ( - file: FileWithMtime, - content: T, - platform: NodeJS.Platform, - options: RemoteParserOptions, - // Line-based parsers iterate cancellably; whole-document parsers ignore it. - signal?: AbortSignal -) => Promise | AiVaultSession | null - -export function remoteSessionSources( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - includeAntigravityIdeSessions = false, - kiroHomeDir?: string -): RemoteSessionSource[] { - return [ - ...remoteCodexSources(remoteHome, hostPlatform), - { - ...jsonlSource( - 'claude', - remoteHome, - hostPlatform, - ['.claude', 'projects'], - parseClaudeSessionContent - ), - // The remote host owns the transcript disk, so the local readdir in the - // Claude parser is skipped; the walked listing supplies the sibling - // subagent counts instead. Partitioning also prunes the subagent - // transcripts themselves, which would otherwise list as phantom - // top-level sessions carrying the parent's sessionId. - partitionSubagentTranscripts: partitionSubagentTranscriptPaths - }, - { - // The execution host owns CodeBuddy's transcripts, including subagent pruning. - ...jsonlSource( - 'codebuddy', - remoteHome, - hostPlatform, - ['.codebuddy', 'projects'], - parseCodebuddySessionContent - ), - partitionSubagentTranscripts: partitionSubagentTranscriptPaths - }, - { - ...jsonlSource( - 'qoder', - remoteHome, - hostPlatform, - ['.qoder', 'projects'], - parseQoderSessionContent - ), - partitionSubagentTranscripts: partitionSubagentTranscriptPaths - }, - ...(includeAntigravityIdeSessions - ? ANTIGRAVITY_HISTORY_ROOTS - : (['antigravity-cli'] as const) - ).map((origin) => remoteAntigravitySource(remoteHome, hostPlatform, origin)), - source( - 'gemini', - remoteHome, - hostPlatform, - ['.gemini', 'tmp'], - ['.json', '.jsonl'], - parseGeminiSessionContent - ), - jsonlSource( - 'copilot', - remoteHome, - hostPlatform, - ['.copilot', 'session-state'], - parseCopilotSessionContent - ), - jsonlSource( - 'cursor', - remoteHome, - hostPlatform, - ['.cursor', 'projects'], - parseCursorSessionContent, - (path) => remotePathSegments(path).includes('agent-transcripts') - ), - remoteClineSource(remoteHome, hostPlatform), - remoteKiroSource(remoteHome, hostPlatform, kiroHomeDir), - source( - 'hermes', - remoteHome, - hostPlatform, - ['.hermes', 'sessions'], - ['.json'], - parseHermesSessionContent - ), - remoteDevinSource(remoteHome, hostPlatform), - remoteDevinSource(remoteHome, hostPlatform, 'agent_logs'), - source( - 'jcode', - remoteHome, - hostPlatform, - ['.jcode', 'sessions'], - ['.json'], - parseJcodeSessionContent, - (path) => remotePathSegments(path).pop()?.startsWith('session_') === true - ), - jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), - { - ...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), - // Same posture as Claude above: OMP stores task-subagent transcripts in - // the session's same-named artifact dir; the walk supplies counts and the - // partition keeps the children out of the top-level list (#9330). - partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths - }, - jsonlSource( - 'prime-agent', - remoteHome, - hostPlatform, - remotePrimeAgentSessionsSegments(), - primeAgentParser - ), - jsonlSource( - 'muse', - remoteHome, - hostPlatform, - ['.local', 'share', 'muse', 'sessions'], - parseMuseRemoteContent, - // Why: each session dir holds session.jsonl plus .log/.sqlite3 sidecars; - // match only the transcript (same predicate as local discovery). - (path) => remotePathSegments(path).at(-1) === 'session.jsonl' - ), - jsonlSource( - 'droid', - remoteHome, - hostPlatform, - ['.factory', 'sessions'], - parseDroidSessionContent - ), - jsonlSource( - 'droid', - remoteHome, - hostPlatform, - ['.factory', 'projects'], - parseDroidSessionContent - ), - ...remoteOpenClawSources(remoteHome, hostPlatform) - ] -} - -function remoteAntigravitySource( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - origin: AntigravitySessionOrigin -): RemoteSessionSource { - const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', origin) - const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') - const parse = async ( - file: FileWithMtime, - content: RemoteSessionContent, - context: RemoteScannerContext - ) => { - const session = await parseAntigravitySessionContent( - file, - content, - context.hostPlatform.os, - parserOptions(context), - context.signal - ) - return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null - } - return { - agent: 'antigravity', - rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), - extensions: ['.jsonl'], - filePredicate: isAntigravityTranscriptPath, - fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], - additionalFixedChildFileSegments: [['.system_generated', 'logs', 'transcript_full.jsonl']], - parse, - parseLines: parse - } -} - -function source( - agent: AiVaultAgent, - remoteHome: string, - hostPlatform: RemoteHostPlatform, - segments: readonly string[], - extensions: readonly string[], - parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean, - directoryPredicate?: (name: string, depth: number) => boolean -): RemoteSessionSource { - return { - agent, - rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), - extensions, - filePredicate, - directoryPredicate, - ...remoteSessionDocumentParsers(agent), - parse: (file, content, context) => - Promise.resolve( - parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal) - ) - } -} - -function jsonlSource( - agent: AiVaultAgent, - remoteHome: string, - hostPlatform: RemoteHostPlatform, - segments: readonly string[], - parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean -): RemoteSessionSource { - return { - ...source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate), - parseLines: (file, lines, context) => - Promise.resolve( - parseContent(file, lines, context.hostPlatform.os, parserOptions(context), context.signal) - ) - } -} - -function remoteOpenClawSources( - remoteHome: string, - hostPlatform: RemoteHostPlatform -): RemoteSessionSource[] { - return ['.openclaw', '.clawdbot'].map((rootName) => ({ - ...jsonlSource( - 'openclaw', - remoteHome, - hostPlatform, - [rootName, 'agents'], - openClawParser, - (path) => remotePathSegments(path).includes('sessions') - ), - directoryPredicate: openClawSessionDirectoryPredicate - })) -} - -function parserOptions(context: RemoteScannerContext): RemoteParserOptions { - return { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - } -} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts deleted file mode 100644 index a36e0c20a84..00000000000 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ /dev/null @@ -1,823 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import { primeAgentFixture } from './session-scanner-prime-agent-fixtures' - -describe('scanRemoteAiVaultSessions', () => { - it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = '1786466194549_xrzrl' - const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - version: 1, - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - provider: 'deepseek', - model: 'deepseek-v4-flash', - cwd: '/home/ada/repo' - }), - 10 - ) - provider.addFile( - `${sessionDir}/${sessionId}.messages.json`, - JSON.stringify({ - version: 1, - updated_at: '2026-08-11T16:38:00.000Z', - sessionId, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'Fix remote Cline history' }] - } - ] - }), - 11 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'cline', - sessionId, - title: 'Fix remote Cline history', - cwd: '/home/ada/repo', - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - filePath: `${sessionDir}/${sessionId}.json` - }) - }) - - it('indexes and resumes Cline sessions from a Windows SSH host', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = '1786466194549_xrzrl' - const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}` - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - cwd: 'C:/repo/app' - }), - 10 - ) - provider.addFile( - `${sessionDir}/${sessionId}.messages.json`, - JSON.stringify({ - updated_at: '2026-08-11T16:38:00.000Z', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }] - }), - 11 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:win-box', - remoteHome: 'C:/Users/Ada', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'cline', - sessionId, - executionHostPlatform: 'win32', - filePath: `${sessionDir}/${sessionId}.json`, - resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""` - }) - }) - - it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/session_index.jsonl', - jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]), - 1 - ) - provider.addFile( - '/home/ada/.codex/sessions/2026/07/04/default.jsonl', - jsonLines([ - { - timestamp: '2026-07-04T01:00:00.000Z', - type: 'session_meta', - payload: { id: 'default-session', cwd: '/home/ada/repo' } - }, - { - timestamp: '2026-07-04T01:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Fallback default title' }] - } - } - ]), - 10 - ) - provider.addFile( - '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl', - jsonLines([ - { - timestamp: '2026-07-04T02:00:00.000Z', - type: 'session_meta', - payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' } - }, - { - timestamp: '2026-07-04T02:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Managed remote title' }] - } - } - ]), - 20 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - unlimited: true - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.title)).toEqual([ - 'Managed remote title', - 'Indexed remote title' - ]) - expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2) - expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true) - expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true) - expect( - result.sessions.find((session) => session.sessionId === 'default-session') - ).toMatchObject({ - codexHome: '/home/ada/.codex', - resumeCommand: - "cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'" - }) - expect( - result.sessions.find((session) => session.sessionId === 'runtime-session') - ).toMatchObject({ - codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home', - resumeCommand: - "cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'" - }) - }) - - it('collapses a bridged rollout present in both remote Codex homes to one row', async () => { - const provider = new MemoryRemoteProvider() - const rolloutName = 'rollout-2026-07-04T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' - const transcript = codexTranscript({ - sessionId: '019f0000-1111-7222-8333-444444444444', - title: 'Bridged both-homes session', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T10:00:00.000Z' - }) - // Same rollout name in both homes — the in-distro bridge/backfill hardlink. - provider.addFile(`/home/ada/.codex/sessions/2026/07/04/${rolloutName}`, transcript, 3_000) - provider.addFile( - `/home/ada/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${rolloutName}`, - transcript, - 3_000 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:build-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - // Remote lanes have not flipped to the real home: the managed runtime-home - // row stays canonical so resume keeps Orca-refreshed auth, as today. - expect(result.sessions[0]).toMatchObject({ - sessionId: '019f0000-1111-7222-8333-444444444444', - codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home' - }) - }) - - it('parses non-Codex transcripts through the same remote scanner', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/claude-session.jsonl', - jsonLines([ - { - sessionId: 'claude-session', - timestamp: '2026-07-04T04:00:00.000Z', - type: 'user', - message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] } - }, - { - sessionId: 'claude-session', - timestamp: '2026-07-04T04:00:01.000Z', - type: 'assistant', - message: { model: 'claude-opus-4', content: 'Sure.' } - } - ]), - 40 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'claude', - sessionId: 'claude-session', - title: 'Summarize the remote branch', - model: 'claude-opus-4', - filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl' - }) - }) - - it('discovers Prime Agent transcripts under the remote home sessions root', async () => { - const provider = new MemoryRemoteProvider() - const fixture = primeAgentFixture() - provider.addFile( - `/home/ada/.prime/agent/sessions/${fixture.fileName}`, - [...fixture.seedLines, ...fixture.appendLines].join('\n'), - 40 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'prime-agent', - title: 'prime-agent seed question', - model: 'inference/big-model', - filePath: `/home/ada/.prime/agent/sessions/${fixture.fileName}` - }) - }) - - it('parses only canonical Antigravity transcripts on SSH hosts', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' - const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs` - provider.addFile( - `${logsDir}/transcript.jsonl`, - jsonLines([ - { - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - created_at: '2026-07-15T11:39:10Z', - content: 'Fix remote Antigravity history' - }, - { - source: 'MODEL', - type: 'PLANNER_RESPONSE', - created_at: '2026-07-15T11:39:12Z', - content: 'Done' - } - ]), - 50 - ) - provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51) - provider.addFile( - `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`, - 'not a transcript', - 52 - ) - provider.addFile( - '/home/ada/.gemini/antigravity-cli/history.jsonl', - jsonLines([ - { - display: 'Fix remote Antigravity history', - timestamp: Date.parse('2026-07-15T11:39:10.100Z'), - workspace: '/home/ada/project' - } - ]), - 53 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - scopePaths: ['/home/ada/project'] - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'antigravity', - sessionId, - title: 'Fix remote Antigravity history', - cwd: '/home/ada/project', - messageCount: 2, - resumeCommand: `agy --conversation '${sessionId}'`, - filePath: `${logsDir}/transcript.jsonl` - }) - expect( - provider.readDirPaths.filter((path) => - path.startsWith('/home/ada/.gemini/antigravity-cli/brain') - ) - ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) - }) - - it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { - const provider = new MemoryRemoteProvider() - const brainDir = '/home/ada/.gemini/antigravity-cli/brain' - provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1) - const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl` - provider.addFile(deniedTranscript, 'unreadable', 2) - provider.failStat( - deniedTranscript, - new Error(`EACCES: permission denied, stat '${deniedTranscript}'`) - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ - agent: 'antigravity', - path: deniedTranscript, - message: expect.stringContaining('EACCES') - }) - ]) - }) - - it('reports non-missing fixed and recursive remote directory failures', async () => { - const provider = new MemoryRemoteProvider() - const brainDir = '/home/ada/.gemini/antigravity-cli/brain' - const claudeProjectDir = '/home/ada/.claude/projects/repo' - provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1) - provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`)) - provider.failReadDir( - claudeProjectDir, - new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`) - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - agent: 'antigravity', - kind: 'host', - path: brainDir, - message: expect.stringContaining('EACCES') - }), - expect.objectContaining({ - agent: 'claude', - kind: 'host', - path: claudeProjectDir, - message: expect.stringContaining('ECONNRESET') - }) - ]) - ) - expect(result.issues).toHaveLength(2) - }) - - it('keeps missing optional remote directories silent', async () => { - const provider = new MemoryRemoteProvider() - const brainDir = '/home/ada/.gemini/antigravity-cli/brain' - provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`)) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([]) - }) - - it('excludes Claude subagent transcripts from remote scans', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/claude-session.jsonl', - jsonLines([ - { - sessionId: 'claude-session', - timestamp: '2026-07-04T04:00:00.000Z', - type: 'user', - message: { content: [{ type: 'text', text: 'Spawn a Task' }] } - } - ]), - 40 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/claude-session/subagents/agent-abc123.jsonl', - jsonLines([ - { - sessionId: 'claude-session', - isSidechain: true, - agentId: 'abc123', - timestamp: '2026-07-04T04:01:00.000Z', - type: 'user', - message: { content: [{ type: 'text', text: 'Subagent task prompt' }] } - } - ]), - 41 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - // Only the parent session surfaces; the subagent transcript would carry - // the parent's sessionId and list as a phantom top-level row. - expect(result.sessions.map((session) => session.filePath)).toEqual([ - '/home/ada/.claude/projects/repo/claude-session.jsonl' - ]) - }) - - it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/lost-session.jsonl', - jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]), - 50 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl', - jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]), - 51 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl', - jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]), - 52 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - // Subagent transcripts must not surface as standalone sessions; they only - // contribute recoverable signal to their zero-turn parent. - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'claude', - sessionId: 'lost-session', - messageCount: 0, - subagentTranscriptCount: 2, - filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl' - }) - }) - - it('counts remote subagent siblings for Claude sessions with real turns', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/live-session.jsonl', - jsonLines([ - { - sessionId: 'live-session', - type: 'user', - message: { content: [{ type: 'text', text: 'Do the thing' }] } - } - ]), - 60 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl', - jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]), - 61 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - // The walk listing supplies the count for every session (the row badge), - // not only zero-turn recoverable ones. - expect(result.sessions[0]).toMatchObject({ - sessionId: 'live-session', - messageCount: 1, - subagentTranscriptCount: 1 - }) - }) - - it('builds resume commands with the remote host platform', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - 'C:/Users/Ada/.codex/sessions/win.jsonl', - jsonLines([ - { - timestamp: '2026-07-04T03:00:00.000Z', - type: 'session_meta', - payload: { id: 'win-session', cwd: 'C:/repo/app' } - }, - { - timestamp: '2026-07-04T03:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Windows remote title' }] - } - } - ]), - 30 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:win-box', - remoteHome: 'C:/Users/Ada', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions[0]?.executionHostPlatform).toBe('win32') - expect(result.sessions[0]?.resumeCommand).toBe( - 'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""' - ) - }) - - it('loads Antigravity workspace history with Windows remote paths', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb' - provider.addFile( - `C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`, - jsonLines([ - { - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - created_at: '2026-07-15T11:39:10.000Z', - content: 'Windows Antigravity title' - } - ]), - 30 - ) - provider.addFile( - 'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl', - jsonLines([ - { - display: 'Windows Antigravity title', - timestamp: Date.parse('2026-07-15T11:39:10.100Z'), - workspace: 'C:/repo/app' - } - ]), - 31 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:win-box', - remoteHome: 'C:/Users/Ada', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions[0]).toMatchObject({ - agent: 'antigravity', - sessionId, - cwd: 'C:/repo/app', - executionHostPlatform: 'win32' - }) - }) - - it('continues past skipped candidates to fill the remote scan limit', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/sessions/worker.jsonl', - codexTranscript({ - sessionId: 'worker-session', - title: 'Internal worker', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T04:00:00.000Z', - threadSource: 'agent' - }), - 40 - ) - provider.addFile( - '/home/ada/.codex/sessions/user.jsonl', - codexTranscript({ - sessionId: 'user-session', - title: 'Visible user session', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T03:00:00.000Z' - }), - 30 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1 - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session']) - }) - - it('keeps scoped remote sessions even when they are older than the recency cap', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/sessions/other.jsonl', - codexTranscript({ - sessionId: 'other-session', - title: 'Other workspace', - cwd: '/home/ada/other', - timestamp: '2026-07-04T05:00:00.000Z' - }), - 50 - ) - provider.addFile( - '/home/ada/.codex/sessions/scoped.jsonl', - codexTranscript({ - sessionId: 'scoped-session', - title: 'Scoped workspace', - cwd: '/home/ada/repo/app', - timestamp: '2026-07-04T01:00:00.000Z' - }), - 10 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - 'other-session', - 'scoped-session' - ]) - }) - - it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => { - const provider = new MemoryRemoteProvider() - for (const [sessionId, mtimeMs, hour] of [ - ['other-newest', 50, '05'], - ['other-newer', 40, '04'] - ] as const) { - provider.addFile( - `/home/ada/.codex/sessions/${sessionId}.jsonl`, - codexTranscript({ - sessionId, - title: sessionId, - cwd: '/home/ada/other', - timestamp: `2026-07-04T${hour}:00:00.000Z` - }), - mtimeMs - ) - } - provider.addFile( - '/home/ada/.codex/sessions/scoped.jsonl', - codexTranscript({ - sessionId: 'scoped-session', - title: 'Scoped workspace', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T01:00:00.000Z' - }), - 10 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - 'other-newest', - 'scoped-session' - ]) - }) - - it('caps scoped backfill at the requested limit', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/sessions/other.jsonl', - codexTranscript({ - sessionId: 'other-session', - title: 'Other workspace', - cwd: '/home/ada/other', - timestamp: '2026-07-04T05:00:00.000Z' - }), - 50 - ) - for (const [sessionId, mtimeMs] of [ - ['newer-scoped', 30], - ['older-scoped', 20] - ] as const) { - provider.addFile( - `/home/ada/.codex/sessions/${sessionId}.jsonl`, - codexTranscript({ - sessionId, - title: sessionId, - cwd: '/home/ada/repo', - timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z` - }), - mtimeMs - ) - } - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - 'other-session', - 'newer-scoped' - ]) - }) -}) - -function codexTranscript(args: { - sessionId: string - title: string - cwd: string - timestamp: string - threadSource?: string -}): string { - return jsonLines([ - { - timestamp: args.timestamp, - type: 'session_meta', - payload: { - id: args.sessionId, - cwd: args.cwd, - ...(args.threadSource ? { thread_source: args.threadSource } : {}) - } - }, - { - timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: args.title }] - } - } - ]) -} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts deleted file mode 100644 index d240ee40e0c..00000000000 --- a/src/main/ai-vault/remote-session-scanner.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { - candidateFileTime, - prioritizeAntigravityTranscriptCandidates -} from './antigravity-transcript-candidates' -import { readRemoteAntigravityIndex } from './antigravity-index-reader' -import { parseRemoteSessionTranscript } from './remote-session-transcript-read' -import { BinarySessionTranscriptError } from './remote-session-content-lines' -import type { - AiVaultListResult, - AiVaultScanIssue, - AiVaultSession -} from '../../shared/ai-vault-types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import type { ExecutionHostId } from '../../shared/execution-host' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { - codexRolloutHardlinkIdentity, - dedupeCodexRolloutFileAliases -} from './codex-session-root-dedup' -import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' -import { - parseRemoteSessionFileCached, - remoteSessionCandidateKey, - remoteSessionParseHostKey -} from './remote-session-parse-cache' -import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' -import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' -import { remoteSessionSources } from './remote-session-scanner-sources' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionFilesystemProvider -} from './remote-session-scanner-types' -import { sessionSortTime } from './session-scanner-accumulator' -import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' -import { errorMessage } from './session-scanner-values' -import { mapRemoteScanBatches } from './remote-session-scan-batching' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordSessionScanIssue } from './session-scan-issues' -import { canStopParsingSessions } from './session-scan-cutoff' -import { refreshCodexTitleFromIndex } from './session-scanner-codex-cached-title' -import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' -import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' -import { remoteOpenCodeSources } from './remote-session-scanner-opencode-source' - -const REMOTE_SCAN_CONCURRENCY = 8 -const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2 -// Remote scope membership is only known after a transcript is read, so the scope -// backfill carries its own ceiling on remote reads instead of borrowing the -// recency cap — under the cap, newer out-of-scope files ate the scope budget and -// silently dropped older in-scope sessions the scope contract guarantees. -const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000 - -export async function scanRemoteAiVaultSessions(args: { - provider: RemoteSessionFilesystemProvider - executionHostId: ExecutionHostId - remoteHome: string - kiroHomeDir?: string - hostPlatform: RemoteHostPlatform - includeAntigravityIdeSessions?: boolean - limit?: number - unlimited?: boolean - scopePaths?: readonly string[] - signal?: AbortSignal -}): Promise { - throwIfAiVaultScanCancelled(args.signal) - const limit = aiVaultScanLimit(args) - const issues: AiVaultScanIssue[] = [] - // One ceiling for the whole scan: discovery walks, stats and transcript reads - // all queue behind it instead of multiplying into a nested fan-out. - const provider = limitRemoteScanFilesystemConcurrency(args.provider) - const context: RemoteScannerContext = { - provider, - executionHostId: args.executionHostId, - hostPlatform: args.hostPlatform, - signal: args.signal, - titleCaches: new Map(), - antigravityWorkspaceResolver: createAntigravityWorkspaceResolver((path) => - readRemoteAntigravityIndex(provider, path, args.signal) - ) - } - const discoveredCandidates = dedupeCodexRolloutFileAliases( - ( - await mapRemoteScanBatches( - [ - ...remoteSessionSources( - args.remoteHome, - args.hostPlatform, - args.includeAntigravityIdeSessions, - args.kiroHomeDir - ), - ...remoteOpenCodeSources( - provider.openCode, - limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER + - (args.scopePaths?.length ? REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT + 1 : 0) - ) - ], - REMOTE_SCAN_CONCURRENCY, - (source) => discoverRemoteSourceCandidates({ source, context, issues }), - args.signal - ) - ) - .flat() - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), - { - isCodex: (candidate) => candidate.source.agent === 'codex', - getFilePath: (candidate) => candidate.file.path, - getCodexHome: (candidate) => candidate.source.codexHome ?? null, - getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) - } - ) - - const candidates = prioritizeAntigravityTranscriptCandidates( - discoveredCandidates, - (candidate) => candidate.source.agent === 'antigravity' - ) - const parsed = await parseRemoteSessionCandidates({ - candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER), - context, - issues, - limit - }) - const parsedSessions = dedupeScannedSessions(parsed.sessions) - const cappedSessions = parsedSessions - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) - const parsedScopeSessions = parsedSessions.filter((session) => - isRemoteSessionInScope(session, scopePaths) - ) - const extraScopeSessions = await scanRemoteInScopeSessions({ - candidates, - context, - issues, - scopePaths, - limit, - alreadyParsedCandidateKeys: parsed.parsedCandidateKeys - }) - const scopeSessions = dedupeScannedSessions([...parsedScopeSessions, ...extraScopeSessions]) - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - - return { - sessions: mergeRemoteSessions(cappedSessions, scopeSessions), - issues, - scannedAt: new Date().toISOString() - } -} - -async function parseRemoteSessionCandidates(args: { - candidates: readonly RemoteSessionCandidate[] - context: RemoteScannerContext - issues: AiVaultScanIssue[] - limit: number -}): Promise<{ sessions: AiVaultSession[]; parsedCandidateKeys: Set }> { - const sessions = new ScannedSessionCollection() - const parsedCandidateKeys = new Set() - let index = 0 - - while (index < args.candidates.length) { - if ( - canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file)) - ) { - break - } - - const remaining = args.candidates.length - index - const needed = Math.max(args.limit - sessions.size, 1) - const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining) - const batch = args.candidates.slice(index, index + batchSize) - for (const candidate of batch) { - parsedCandidateKeys.add(remoteSessionCandidateKey(candidate)) - } - throwIfAiVaultScanCancelled(args.context.signal) - const results = await Promise.all( - batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) - ) - for (const session of results) { - if (session) { - sessions.add(session) - } - } - index += batchSize - await yieldToEventLoop() - } - - // The loop can terminate on the yield after its final batch, so re-check - // rather than letting a cancelled scan return a partial parse as a success. - throwIfAiVaultScanCancelled(args.context.signal) - return { sessions: [...sessions.values()], parsedCandidateKeys } -} - -async function scanRemoteInScopeSessions(args: { - candidates: readonly RemoteSessionCandidate[] - context: RemoteScannerContext - issues: AiVaultScanIssue[] - scopePaths: readonly string[] - limit: number - alreadyParsedCandidateKeys: ReadonlySet -}): Promise { - if (args.scopePaths.length === 0) { - return [] - } - - const candidates = args.candidates.filter( - (candidate) => !args.alreadyParsedCandidateKeys.has(remoteSessionCandidateKey(candidate)) - ) - const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT) - const sessions: AiVaultSession[] = [] - let index = 0 - - // Keep reading newest-first until the scope has its requested number of - // sessions; out-of-scope candidates no longer end the search. - while (index < bound && sessions.length < args.limit) { - const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound) - const results = await mapRemoteScanBatches( - candidates.slice(index, batchEnd), - REMOTE_SCAN_CONCURRENCY, - (candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues), - args.context.signal - ) - sessions.push( - ...results.filter( - (session): session is AiVaultSession => - isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) - ) - ) - index = batchEnd - } - - if (index < candidates.length && sessions.length < args.limit) { - recordSessionScanIssue(args.issues, { - executionHostId: args.context.executionHostId, - agent: 'codex', - kind: 'scope', - path: 'Agent Session History scan', - message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.` - }) - } - - return sessions -} - -async function parseRemoteSessionCandidate( - candidate: RemoteSessionCandidate, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - try { - throwIfAiVaultScanCancelled(context.signal) - // The read is inside the cached parse: an unchanged transcript must not be - // pulled off the remote disk at all, which is the whole cost of #13753. - const session = await parseRemoteSessionFileCached({ - candidate, - hostKey: remoteSessionParseHostKey(context), - parse: () => parseRemoteSessionTranscript(candidate, context), - refreshReusedSession: reusedCodexTitleRefresh(candidate, context) - }) - throwIfAiVaultScanCancelled(context.signal) - // Mirror the local rule: every session carries its sibling subagent - // transcript count (row badge; recoverable signal at zero turns). The - // walk listing supplies it — the parser can't readdir a remote disk. - const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0 - if (session && subagentTranscriptCount > 0) { - return { ...session, subagentTranscriptCount } - } - return session - } catch (err) { - throwIfAiVaultScanCancelled(context.signal) - if (err instanceof BinarySessionTranscriptError) { - return null - } - recordSessionScanIssue(issues, { - executionHostId: context.executionHostId, - agent: candidate.source.agent, - path: candidate.file.path, - message: errorMessage(err) - }) - return null - } -} - -// Codex thread names live in `/session_index.jsonl`, not the -// rollout, and are written after it — so a transcript-keyed cache hit would -// pin the fallback title forever. Local counterpart: -// session-scanner-parse-cache.ts's reuse path. -function reusedCodexTitleRefresh( - candidate: RemoteSessionCandidate, - context: RemoteScannerContext -): ((session: AiVaultSession) => Promise) | undefined { - const codexHome = candidate.source.agent === 'codex' ? candidate.source.codexHome : undefined - if (!codexHome) { - return undefined - } - const readIndexedTitle = remoteCodexIndexedTitleReader(codexHome, context) - return (session) => refreshCodexTitleFromIndex(session, readIndexedTitle) -} - -function mergeRemoteSessions( - cappedSessions: AiVaultSession[], - scopeSessions: AiVaultSession[] -): AiVaultSession[] { - if (scopeSessions.length === 0) { - return cappedSessions - } - const byId = new Map() - for (const session of cappedSessions) { - byId.set(session.id, session) - } - for (const session of scopeSessions) { - byId.set(session.id, session) - } - return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) -} - -function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean { - const cwd = session.cwd - return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) -} - -function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { - return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) -} - -function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { - return Boolean(session) -} diff --git a/src/main/ai-vault/remote-session-sidecar-observation.test.ts b/src/main/ai-vault/remote-session-sidecar-observation.test.ts deleted file mode 100644 index 92ff41775ec..00000000000 --- a/src/main/ai-vault/remote-session-sidecar-observation.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' -import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache' - -describe('remote sidecar observations', () => { - const sessionId = '1786466194549_sidecar' - const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` - const messagesPath = `${sessionDir}/${sessionId}.messages.json` - - function addCline(provider: MemoryRemoteProvider, firstPrompt: string): void { - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - version: 1, - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - cwd: '/home/ada/repo' - }), - 10 - ) - provider.addFile( - messagesPath, - JSON.stringify({ - version: 1, - updated_at: '2026-08-11T16:38:00.000Z', - sessionId, - messages: [{ role: 'user', content: [{ type: 'text', text: firstPrompt }] }] - }), - 11 - ) - } - - const scan = (provider: MemoryRemoteProvider): ReturnType => - scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - it('re-parses when a messages-file stat fails rather than serving the cached row', async () => { - resetRemoteSessionParseCacheForTests() - const provider = new MemoryRemoteProvider() - addCline(provider, 'first prompt') - expect((await scan(provider)).sessions[0]).toMatchObject({ title: 'first prompt' }) - - // The sidecar changed underneath, and its stat now fails for a reason that - // is not "missing": nothing about it may be assumed, so the row is re-read. - addCline(provider, 'second prompt') - provider.failStat( - messagesPath, - Object.assign(new Error('permission denied'), { code: 'EACCES' }) - ) - - const refused = await scan(provider) - - expect(refused.sessions[0]).toMatchObject({ title: 'second prompt' }) - expect(refused.issues.map((issue) => issue.path)).toContain(messagesPath) - }) - - it('treats a genuinely missing messages file as no sidecar, not as unknown', async () => { - resetRemoteSessionParseCacheForTests() - const provider = new MemoryRemoteProvider() - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - version: 1, - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - cwd: '/home/ada/repo' - }), - 10 - ) - - const first = await scan(provider) - const second = await scan(provider) - - expect(first.issues).toEqual([]) - expect(second.issues).toEqual([]) - expect(second.sessions[0]?.sessionId).toBe(sessionId) - }) -}) diff --git a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts deleted file mode 100644 index 2b8e95ea2a4..00000000000 --- a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { streamedSessionContentLines } from './remote-session-content-lines' -import { readStreamedSessionDocument } from './session-document-stream' -import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' - -describe('stream lifetime and retained document work', () => { - it('aborts a newline-free record at the byte ceiling and closes the source', async () => { - let closed = false - let reads = 0 - async function* bytes() { - const chunk = Buffer.alloc(1024 * 1024, 'x') - try { - for (; reads < 100;) { - reads++ - yield chunk - } - } finally { - closed = true - } - } - const lines = streamedSessionContentLines(bytes()) - await expect(lines.next()).rejects.toThrow('record exceeds 10485760 byte limit') - expect(reads).toBe(11) - expect(closed).toBe(true) - }) - - it('releases the source when a line consumer finishes early', async () => { - let closed = false - async function* bytes() { - try { - yield Buffer.from('one\ntwo\n') - yield Buffer.from('three\n') - } finally { - closed = true - } - } - for await (const line of streamedSessionContentLines(bytes())) { - expect(line).toBe('one') - break - } - await vi.waitFor(() => expect(closed).toBe(true)) - }) - it('propagates disk failure and closes the source', async () => { - let closed = false - async function* bytes() { - try { - yield Buffer.from('one\n') - throw new Error('disk read failed') - } finally { - closed = true - } - } - await expect( - (async () => { - for await (const _ of streamedSessionContentLines(bytes())) { - /* consume */ - } - })() - ).rejects.toThrow('disk read failed') - expect(closed).toBe(true) - }) - it('cancellation discards a document fold and releases its source', async () => { - const controller = new AbortController() - let closed = false - async function* bytes() { - try { - yield Buffer.from('{"messages":[{"role":"user"}') - controller.abort() - yield Buffer.from(']}') - } finally { - closed = true - } - } - await expect( - readStreamedSessionDocument({ - bytes: bytes(), - arrayKey: 'messages', - fields: [], - create: () => ({ count: 0 }), - consume: (state) => { - state.count++ - }, - signal: controller.signal - }) - ).rejects.toThrow() - expect(closed).toBe(true) - }) - it('holds one filesystem slot for the stream lifetime and releases it on return', async () => { - let entered = 0 - async function* bytes() { - entered++ - yield Buffer.from('a') - yield Buffer.from('b') - } - const provider = limitRemoteScanFilesystemConcurrency( - { - readDir: async () => [], - readFile: async () => ({ content: '', isBinary: false }), - stat: async () => ({ size: 0, type: 'file', mtime: 0 }), - readTranscriptBytes: bytes - }, - 1 - ) - const first = provider.readTranscriptBytes!('/one')[Symbol.asyncIterator](), - second = provider.readTranscriptBytes!('/two')[Symbol.asyncIterator]() - await first.next() - const pending = second.next() - await Promise.resolve() - expect(entered).toBe(1) - await first.return!() - await pending - expect(entered).toBe(2) - await second.return!() - }) -}) diff --git a/src/main/ai-vault/remote-session-transcript-read.ts b/src/main/ai-vault/remote-session-transcript-read.ts deleted file mode 100644 index bc9d4a59532..00000000000 --- a/src/main/ai-vault/remote-session-transcript-read.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { streamedSessionContentLines } from './remote-session-content-lines' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import type { RemoteSessionCandidate, RemoteScannerContext } from './remote-session-scanner-types' -import type { AiVaultSession } from '../../shared/ai-vault-types' - -const LEGACY_SESSION_TEXT_LIMIT_BYTES = 10 * 1024 * 1024 - -export async function parseRemoteSessionTranscript( - candidate: RemoteSessionCandidate, - context: RemoteScannerContext -): Promise { - if (candidate.source.parseCandidate) { - return candidate.source.parseCandidate(candidate.file, context) - } - const sidecar = candidate.file.sidecar - const exceedsWholeReadLimit = - (candidate.file.sizeBytes ?? 0) > LEGACY_SESSION_TEXT_LIMIT_BYTES || - (typeof sidecar === 'object' && sidecar.sizeBytes > LEGACY_SESSION_TEXT_LIMIT_BYTES) - if ( - exceedsWholeReadLimit && - candidate.source.parseDocument && - !candidate.file.path.endsWith('.jsonl') && - context.provider.readTranscriptBytes - ) { - return candidate.source.parseDocument( - candidate.file, - context.provider.readTranscriptBytes(candidate.file.path, context.signal), - context - ) - } - if ( - exceedsWholeReadLimit && - candidate.file.path.endsWith('.jsonl') && - candidate.source.parseLines && - context.provider.readTranscriptBytes - ) { - return candidate.source.parseLines( - candidate.file, - streamedSessionContentLines( - context.provider.readTranscriptBytes(candidate.file.path, context.signal), - context.signal - ), - context - ) - } - const read = await context.provider.readFile(candidate.file.path) - throwIfAiVaultScanCancelled(context.signal) - if (read.isBinary) { - return null - } - return await candidate.source.parse(candidate.file, read.content, context) -} diff --git a/src/main/ai-vault/session-scanner-cutoff.test.ts b/src/main/ai-vault/session-scanner-cutoff.test.ts index 76488ddabbe..acf102c6c48 100644 --- a/src/main/ai-vault/session-scanner-cutoff.test.ts +++ b/src/main/ai-vault/session-scanner-cutoff.test.ts @@ -2,9 +2,6 @@ import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -15,63 +12,46 @@ afterEach(async () => { }) describe('session scanner cutoff', () => { - it.each(['native', 'remote'] as const)( - '%s does not sort timestamps at every post-limit candidate', - async (host) => { - const count = 128 - const limit = count / 2 - const provider = new MemoryRemoteProvider() - const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - await mkdir(roots.codexSessionsDir, { recursive: true }) - for (let index = 0; index < count; index++) { - const name = `rollout-session-${index}.jsonl` - const content = jsonLines([ - { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, - { - type: 'event_msg', - timestamp: new Date(index).toISOString(), - payload: { type: 'user_message', message: 'Check this session' } - } - ]) - const mtime = 10_000 - index - if (host === 'native') { - const filePath = join(roots.codexSessionsDir, name) - await writeFile(filePath, content) - await utimes(filePath, new Date(mtime), new Date(mtime)) - } else { - provider.addFile(`/home/ada/.codex/sessions/${name}`, content, mtime) + it('does not sort timestamps at every post-limit candidate', async () => { + const count = 128 + const limit = count / 2 + const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.codexSessionsDir, { recursive: true }) + for (let index = 0; index < count; index++) { + const name = `rollout-session-${index}.jsonl` + const content = jsonLines([ + { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, + { + type: 'event_msg', + timestamp: new Date(index).toISOString(), + payload: { type: 'user_message', message: 'Check this session' } } - } - let numericSorts = 0 - const originalSort = Array.prototype.sort - vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) { - if (typeof this[0] === 'number') { - numericSorts++ - } - return originalSort.call(this, compare) - }) - const scan = () => - host === 'native' - ? scanAiVaultSessions({ ...roots, limit }) - : scanRemoteAiVaultSessions({ - provider, - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - executionHostId: 'ssh:scan-cutoff', - limit - }) - - for (let pass = 0; pass < 2; pass++) { - numericSorts = 0 - const result = await scan() - expect(result.issues).toEqual([]) - expect(result.sessions.map((row) => row.sessionId)).toEqual( - Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`) - ) - expect(numericSorts).toBe(0) - } + ]) + const mtime = 10_000 - index + const filePath = join(roots.codexSessionsDir, name) + await writeFile(filePath, content) + await utimes(filePath, new Date(mtime), new Date(mtime)) } - ) + let numericSorts = 0 + const originalSort = Array.prototype.sort + vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) { + if (typeof this[0] === 'number') { + numericSorts++ + } + return originalSort.call(this, compare) + }) + const scan = () => scanAiVaultSessions({ ...roots, limit }) + + for (let pass = 0; pass < 2; pass++) { + numericSorts = 0 + const result = await scan() + expect(result.issues).toEqual([]) + expect(result.sessions.map((row) => row.sessionId)).toEqual( + Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`) + ) + expect(numericSorts).toBe(0) + } + }) }) diff --git a/src/main/ai-vault/session-scanner-dedup-batches.test.ts b/src/main/ai-vault/session-scanner-dedup-batches.test.ts index 7cf79e3ba25..facc28b9c59 100644 --- a/src/main/ai-vault/session-scanner-dedup-batches.test.ts +++ b/src/main/ai-vault/session-scanner-dedup-batches.test.ts @@ -2,9 +2,6 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -15,96 +12,37 @@ afterEach(async () => { }) describe('session scan batch deduplication', () => { - it.each(['native', 'remote'] as const)( - '%s does not rederive every retained rollout alias after each batch', - async (host) => { - const count = 128 - const provider = new MemoryRemoteProvider() - const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - await mkdir(roots.codexSessionsDir, { recursive: true }) - for (let index = 0; index < count; index++) { - const name = `rollout-session-${index}.jsonl` - const content = jsonLines([ - { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, - { type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } } - ]) - if (host === 'native') { - await writeFile(join(roots.codexSessionsDir, name), content) - } else { - provider.addFile(`/home/ada/.codex/sessions/${name}`, content, count - index) - } - } - let aliasChecks = 0 - const originalTest = RegExp.prototype.test - vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) { - if (this.source === '^rollout-.+\\.jsonl$') { - aliasChecks++ - } - return originalTest.call(this, value) - }) - const scan = () => - host === 'native' - ? scanAiVaultSessions({ ...roots, unlimited: true }) - : scanRemoteAiVaultSessions({ - provider, - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - executionHostId: 'ssh:dedup-batches', - unlimited: true - }) - - for (let pass = 0; pass < 2; pass++) { - aliasChecks = 0 - const result = await scan() - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(count) - expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count) - expect(aliasChecks).toBeLessThanOrEqual(count * 8) - } - } - ) - - it('replaces aliases across remote batches without consuming the unique-session budget', async () => { - const provider = new MemoryRemoteProvider() - const managedHome = '/home/ada/.local/share/orca/codex-runtime-home/home' - const content = (id: string) => - jsonLines([ - { type: 'session_meta', payload: { id, cwd: '/repo/folder' } }, - { type: 'event_msg', payload: { type: 'user_message', message: 'Session' } } + it('does not rederive every retained rollout alias after each batch', async () => { + const count = 128 + const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.codexSessionsDir, { recursive: true }) + for (let index = 0; index < count; index++) { + const name = `rollout-session-${index}.jsonl` + const content = jsonLines([ + { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, + { type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } } ]) - for (let index = 0; index < 8; index++) { - const name = `rollout-${index}.jsonl` - provider.addFile(`/home/ada/.codex/sessions/${name}`, content(`${index}`), 1000 - index) - provider.addFile(`${managedHome}/sessions/${name}`, content(`${index}`), 500 - index) + await writeFile(join(roots.codexSessionsDir, name), content) } - provider.addFile('/home/ada/.codex/sessions/rollout-unique.jsonl', content('unique'), 100) - const tooOld = '/home/ada/.codex/sessions/rollout-too-old.jsonl' - provider.addFile(tooOld, content('too-old'), 50) - const reads = vi.spyOn(provider, 'readFile') - const result = await scanRemoteAiVaultSessions({ - provider, - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - executionHostId: 'ssh:batch-replacements', - limit: 9 + let aliasChecks = 0 + const originalTest = RegExp.prototype.test + vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) { + if (this.source === '^rollout-.+\\.jsonl$') { + aliasChecks++ + } + return originalTest.call(this, value) }) - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - 'unique' - ]) - expect(result.sessions.slice(0, 8).every((session) => session.codexHome === managedHome)).toBe( - true - ) - expect(reads).not.toHaveBeenCalledWith(tooOld) + const scan = () => scanAiVaultSessions({ ...roots, unlimited: true }) + + for (let pass = 0; pass < 2; pass++) { + aliasChecks = 0 + const result = await scan() + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(count) + expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count) + expect(aliasChecks).toBeLessThanOrEqual(count * 8) + } }) }) diff --git a/src/main/ai-vault/session-scanner-devin-dedup.test.ts b/src/main/ai-vault/session-scanner-devin-dedup.test.ts index 0af43651a39..2546937411e 100644 --- a/src/main/ai-vault/session-scanner-devin-dedup.test.ts +++ b/src/main/ai-vault/session-scanner-devin-dedup.test.ts @@ -2,9 +2,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' import { isolatedScanRoots } from './session-scanner-test-fixtures' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { dedupeScannedSessions, ScannedSessionCollection } from './session-root-dedup' @@ -45,32 +42,6 @@ it('lists a session in both default directories once and still fills the scan li expect(unlimited.sessions.map((session) => session.sessionId)).toEqual(['same', 'other']) }) -it.each(['win32-x64', 'linux-x64'] as const)( - 'deduplicates both remote directories on %s', - async (platform) => { - const provider = new MemoryRemoteProvider() - const windows = platform === 'win32-x64' - const home = windows ? 'C:/Users/ada' : '/home/ada' - const cliDir = `${home}/${windows ? 'AppData/Roaming' : '.local/share'}/devin/cli` - provider.addFile(`${cliDir}/transcripts/same.json`, transcript('same'), 10) - provider.addFile(`${cliDir}/agent_logs/devin-same.json`, transcript('same'), 20) - provider.addFile( - `${cliDir}/transcripts/other.json`, - transcript('other', '2026-09-18T00:00:00Z'), - 5 - ) - const result = await scanRemoteAiVaultSessions({ - provider, - remoteHome: home, - executionHostId: 'ssh:devin', - hostPlatform: getRemoteHostPlatform(platform), - limit: 2 - }) - expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other']) - expect(result.sessions[0].filePath).toBe(`${cliDir}/agent_logs/devin-same.json`) - } -) - function session(path: string) { const parsed = parseDevinSessionContent( { path, mtimeMs: 100, sizeBytes: 1, modifiedAt: new Date(100).toISOString() }, diff --git a/src/main/ai-vault/session-scanner-qoder.test.ts b/src/main/ai-vault/session-scanner-qoder.test.ts index 78c99ddab88..356a05e01dd 100644 --- a/src/main/ai-vault/session-scanner-qoder.test.ts +++ b/src/main/ai-vault/session-scanner-qoder.test.ts @@ -1,16 +1,10 @@ -import { readFile, mkdtemp, rm, stat } from 'node:fs/promises' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { tmpdir } from 'node:os' +import { readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources' -import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' -import { scanAiVaultSessions } from './session-scanner' import { createQoderSessionResumeState, parseQoderSessionContent } from './session-scanner-qoder-parser' -import { remoteSessionSources } from './remote-session-scanner-sources' import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' import type { TranscriptMessage } from './session-transcript-consumers' @@ -67,39 +61,3 @@ it('decodes a real generated and resumed Qoder 1.1.64 session for preview and se cwd: '/tmp/qoder-proof' }) }) - -it('discovers Qoder folder history and prunes nested workers on local, WSL, and SSH hosts', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-qoder-vault-')) - roots.push(root) - const options = isolatedScanRoots(root) - const path = join(options.qoderProjectsDir, 'folder', 'qoder-session.jsonl') - const records = [ - { type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/folder'] }, - { - type: 'user', - sessionId: 'qoder-session', - message: { role: 'user', content: 'Qoder folder proof' } - } - ] - await writeJsonlFile(path, records) - await writeJsonlFile( - join(options.qoderProjectsDir, 'folder', 'qoder-session', 'subagents', 'worker.jsonl'), - records - ) - const result = await scanAiVaultSessions({ ...options, wslHomeDirs: [] }) - const sessions = result.sessions.filter((s) => s.agent === 'qoder') - expect(sessions).toHaveLength(1) - expect(sessions[0]).toMatchObject({ title: 'Qoder folder proof', cwd: '/tmp/folder' }) - expect(AI_VAULT_AGENT_SOURCES.qoder?.rootDirs(options, ['/home/test'])).toContain( - join('/home/test', '.qoder', 'projects') - ) - for (const platform of ['darwin-arm64', 'linux-x64', 'win32-x64'] as const) { - const source = remoteSessionSources( - platform === 'win32-x64' ? 'C:\\Users\\test' : '/home/test', - getRemoteHostPlatform(platform) - ).find((s) => s.agent === 'qoder') - expect(source).toBeDefined() - expect(source?.partitionSubagentTranscripts).toBeDefined() - } - expect((await stat(path)).size).toBeGreaterThan(0) -}) diff --git a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts index 8e912021e5f..a18b89646cc 100644 --- a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts +++ b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts @@ -1,6 +1,5 @@ import { beforeEach, expect, it, vi } from 'vitest' import type * as SessionDedup from './session-root-dedup' -import type * as RemoteSessionParseCache from './remote-session-parse-cache' import type { AiVaultSession } from '../../shared/ai-vault-types' const fixture = vi.hoisted((): { sessions: AiVaultSession[]; visits: number } => ({ @@ -28,16 +27,6 @@ vi.mock('./session-scanner-parse-cache', () => ({ }), parseAgentSessionFileCached: async (candidate: { session: AiVaultSession }) => candidate.session })) -vi.mock('./remote-session-scanner-sources', () => ({ remoteSessionSources: () => [{}] })) -vi.mock('./remote-session-scanner-discovery', () => ({ - discoverRemoteSourceCandidates: async () => candidates() -})) -vi.mock('./remote-session-parse-cache', async (original) => ({ - ...(await original()), - remoteSessionParseHostKey: () => 'fixture', - parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) => - candidate.session -})) vi.mock('./session-root-dedup', async (original) => { const actual = await original() return { @@ -50,7 +39,6 @@ vi.mock('./session-root-dedup', async (original) => { }) import { scanAiVaultSessions } from './session-scanner' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' function candidates() { @@ -93,62 +81,42 @@ beforeEach(() => { fixture.visits = 0 }) -for (const host of ['local', 'remote'] as const) { - const scan = (unlimited: boolean, limit?: number) => - host === 'local' - ? scanAiVaultSessions({ unlimited, limit }) - : scanRemoteAiVaultSessions({ - unlimited, - limit, - provider: { readDir: vi.fn(), readFile: vi.fn(), stat: vi.fn() }, - executionHostId: 'local', - remoteHome: '/fixture', - hostPlatform: { - relayPlatform: 'linux-x64', - os: 'linux', - arch: 'x64', - pathFlavor: 'posix', - commandDialect: 'posix', - pathSeparator: '/', - pathDelimiter: ':' - } - }) +const scan = (unlimited: boolean, limit?: number) => scanAiVaultSessions({ unlimited, limit }) - it(`${host}: load-all processes deduplication linearly and retains late canonical aliases`, async () => { - fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i)) - fixture.sessions[0] = { - ...fixture.sessions[0]!, - codexHome: '/custom', - filePath: '/custom/rollout-0.jsonl' - } - fixture.sessions.push(session(0)) - const expected = dedupeScannedSessions(fixture.sessions) - fixture.visits = 0 - const started = performance.now() - const result = await scan(true) - process.stdout.write( - `${JSON.stringify({ host, candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n` - ) - expect(result.issues).toEqual([]) - expect(result.sessions).toEqual(expected) - expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2) - }, 30000) +it('load-all processes deduplication linearly and retains late canonical aliases', async () => { + fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i)) + fixture.sessions[0] = { + ...fixture.sessions[0]!, + codexHome: '/custom', + filePath: '/custom/rollout-0.jsonl' + } + fixture.sessions.push(session(0)) + const expected = dedupeScannedSessions(fixture.sessions) + fixture.visits = 0 + const started = performance.now() + const result = await scan(true) + process.stdout.write( + `${JSON.stringify({ candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n` + ) + expect(result.issues).toEqual([]) + expect(result.sessions).toEqual(expected) + expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2) +}, 30000) - it(`${host}: capped scans still fill the unique-session budget`, async () => { - fixture.sessions = [ - session(0), - ...Array.from({ length: 8 }, () => ({ - ...session(0), - filePath: '/custom/rollout-0.jsonl', - codexHome: '/custom' - })), - ...Array.from({ length: 10 }, (_, i) => session(i + 1)) - ] - const result = await scan(false, 10) - expect(result.sessions).toHaveLength(10) - expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10) - }) -} +it('capped scans still fill the unique-session budget', async () => { + fixture.sessions = [ + session(0), + ...Array.from({ length: 8 }, () => ({ + ...session(0), + filePath: '/custom/rollout-0.jsonl', + codexHome: '/custom' + })), + ...Array.from({ length: 10 }, (_, i) => session(i + 1)) + ] + const result = await scan(false, 10) + expect(result.sessions).toHaveLength(10) + expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10) +}) it('incremental canonical selection preserves winner occurrence order, ties and repeated references', () => { const collection = new ScannedSessionCollection() diff --git a/src/main/git/git-network-safety-real-git.test.ts b/src/main/git/git-network-safety-real-git.test.ts index d81c9bf087f..db5dc805c60 100644 --- a/src/main/git/git-network-safety-real-git.test.ts +++ b/src/main/git/git-network-safety-real-git.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' import { quotePosixShell } from '../../shared/wsl-login-shell-command' -import { createGitHandlerRelay } from '../../relay/git-handler-test-harness' import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner' let root = '' @@ -73,25 +72,6 @@ async function expectIdentity(identity: string): Promise { } describe('network SSH configuration with real Git', () => { - it.each(['native', 'relay'] as const)( - '%s honors repository SSH wrappers on ordinary fetches', - async (host) => { - await git(['config', 'core.sshCommand', sshCommand('configured identity')]) - if (host === 'native') { - await expect(gitExecFileAsync(['fetch', 'origin'], { cwd: repo, env })).rejects.toThrow() - } else { - const { dispatcher, handler } = createGitHandlerRelay() - try { - await expect( - dispatcher.callRequest('git.fetch', { worktreePath: repo }) - ).rejects.toThrow() - } finally { - handler.dispose() - } - } - await expectIdentity('configured identity') - } - ) it('preserves command-line SSH configuration ahead of repository configuration', async () => { await git(['config', 'core.sshCommand', sshCommand('repository')]) @@ -104,28 +84,6 @@ describe('network SSH configuration with real Git', () => { await expectIdentity('command-line') }) - it.each(['native', 'relay'] as const)('%s preserves an explicit SSH command', async (host) => { - await git(['config', 'core.sshCommand', sshCommand('repository')]) - const command = sshCommand('environment') - if (host === 'native') { - await expect( - gitExecFileAsync(['fetch', 'origin'], { - cwd: repo, - env: { ...env, GIT_SSH_COMMAND: command } - }) - ).rejects.toThrow() - } else { - vi.stubEnv('GIT_SSH_COMMAND', command) - const { dispatcher, handler } = createGitHandlerRelay() - try { - await expect(dispatcher.callRequest('git.fetch', { worktreePath: repo })).rejects.toThrow() - } finally { - handler.dispose() - } - } - await expectIdentity('environment') - }) - it('honors configured global SSH wrappers for streaming clones from a folder', async () => { await git(['config', '--global', 'core.sshCommand', sshCommand('clone identity')]) const child = await gitSpawnAfterWindowsEnvironmentReady( diff --git a/src/main/git/source-control/blob-absence-real-git.test.ts b/src/main/git/source-control/blob-absence-real-git.test.ts index 5eb0a2bce67..0a03f574746 100644 --- a/src/main/git/source-control/blob-absence-real-git.test.ts +++ b/src/main/git/source-control/blob-absence-real-git.test.ts @@ -1,36 +1,39 @@ -import { readFile, unlink, utimes, writeFile } from 'node:fs/promises' +import { readFile, rm, unlink, utimes, writeFile } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' -import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from '../../../relay/git-handler-test-harness' -import type { GitHandler } from '../../../relay/git-handler' import { isMissingGitBlobPath } from '../../../shared/git-blob-absence' import { readGitBlobAtIndexPath } from './git-blob-read' import { getDiff } from './file-diff' import { invalidateGitReadCaches, settledDiffCache } from './git-read-cache-invalidation' import { readWorktreeDiffStamp } from './worktree-diff-stamp' +function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' }) +} + +function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) +} + describe('real Git missing paths and failed blob reads', () => { let repo: string - let dispatcher: MockDispatcher - let handler: GitHandler beforeEach(() => { - repo = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) + repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-')) gitInit(repo) invalidateGitReadCaches() }) afterEach(async () => { - handler.dispose() invalidateGitReadCaches() - await removeGitTempDir(repo) + await rm(repo, { recursive: true, force: true }) }) async function git(args: string[], input?: string): Promise { @@ -39,75 +42,60 @@ describe('real Git missing paths and failed blob reads', () => { return result.stdout } - function diff(host: 'native' | 'relay', staged = false): Promise { - return host === 'native' - ? getDiff(repo, 'file.txt', staged) - : dispatcher.callRequest('git.diff', { worktreePath: repo, filePath: 'file.txt', staged }) + function diff(staged = false): Promise { + return getDiff(repo, 'file.txt', staged) } - it.each(['native', 'relay'] as const)( - 'does not substitute HEAD for a corrupt index blob on %s', - async (host) => { - await writeFile(path.join(repo, 'file.txt'), 'committed\n') - gitCommit(repo, 'initial') - await writeFile(path.join(repo, 'file.txt'), 'staged\n') - await git(['add', 'file.txt']) - const oid = (await git(['rev-parse', ':file.txt'])).trim() - await writeFile(path.join(repo, 'file.txt'), 'working\n') - const old = new Date(Date.now() - 10_000) - await Promise.all( - ['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old)) - ) - const before = await readWorktreeDiffStamp(repo, 'file.txt', true) - await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2))) - expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({ - exists: false, - failed: true - }) + it('does not substitute HEAD for a corrupt index blob', async () => { + await writeFile(path.join(repo, 'file.txt'), 'committed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'file.txt'), 'staged\n') + await git(['add', 'file.txt']) + const oid = (await git(['rev-parse', ':file.txt'])).trim() + await writeFile(path.join(repo, 'file.txt'), 'working\n') + const old = new Date(Date.now() - 10_000) + await Promise.all( + ['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old)) + ) + const before = await readWorktreeDiffStamp(repo, 'file.txt', true) + await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2))) + expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({ + exists: false, + failed: true + }) - expect(await diff(host)).toMatchObject({ originalContent: '', modifiedContent: 'working\n' }) - if (host === 'native') { - expect(settledDiffCache.stats().entries).toBe(0) - } - await git(['hash-object', '-w', '--stdin'], 'staged\n') - const after = await readWorktreeDiffStamp(repo, 'file.txt', true) - expect(after?.value).toBe(before?.value) + expect(await diff()).toMatchObject({ originalContent: '', modifiedContent: 'working\n' }) + expect(settledDiffCache.stats().entries).toBe(0) + await git(['hash-object', '-w', '--stdin'], 'staged\n') + const after = await readWorktreeDiffStamp(repo, 'file.txt', true) + expect(after?.value).toBe(before?.value) - expect(await diff(host)).toMatchObject({ - originalContent: 'staged\n', - modifiedContent: 'working\n' - }) - if (host === 'native') { - expect(settledDiffCache.stats().entries).toBe(1) - } - } - ) + expect(await diff()).toMatchObject({ + originalContent: 'staged\n', + modifiedContent: 'working\n' + }) + expect(settledDiffCache.stats().entries).toBe(1) + }) - it.each(['native', 'relay'] as const)( - 'retains added paths on an unborn branch on %s', - async (host) => { - await writeFile(path.join(repo, 'file.txt'), 'new file\n') - await git(['add', 'file.txt']) - expect(await diff(host, true)).toMatchObject({ - originalContent: '', - modifiedContent: 'new file\n' - }) - } - ) + it('retains added paths on an unborn branch', async () => { + await writeFile(path.join(repo, 'file.txt'), 'new file\n') + await git(['add', 'file.txt']) + expect(await diff(true)).toMatchObject({ + originalContent: '', + modifiedContent: 'new file\n' + }) + }) - it.each(['native', 'relay'] as const)( - 'retains additions absent from a valid HEAD tree on %s', - async (host) => { - await writeFile(path.join(repo, 'seed.txt'), 'seed\n') - gitCommit(repo, 'initial') - await writeFile(path.join(repo, 'file.txt'), 'new file\n') - await git(['add', 'file.txt']) - expect(await diff(host, true)).toMatchObject({ - originalContent: '', - modifiedContent: 'new file\n' - }) - } - ) + it('retains additions absent from a valid HEAD tree', async () => { + await writeFile(path.join(repo, 'seed.txt'), 'seed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'file.txt'), 'new file\n') + await git(['add', 'file.txt']) + expect(await diff(true)).toMatchObject({ + originalContent: '', + modifiedContent: 'new file\n' + }) + }) it('recognizes actual index and tree absence without accepting corrupt objects', async () => { await writeFile(path.join(repo, 'seed.txt'), 'seed\n') diff --git a/src/main/git/source-control/bulk-pathspec-stdin.test.ts b/src/main/git/source-control/bulk-pathspec-stdin.test.ts deleted file mode 100644 index c45f82645f0..00000000000 --- a/src/main/git/source-control/bulk-pathspec-stdin.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from '../../../relay/git-handler-test-harness' -import type { GitHandler } from '../../../relay/git-handler' -import { bulkStageFiles, bulkUnstageFiles } from './staging' -import { bulkDiscardChanges } from './discard-changes' - -describe('bulk pathspec stdin on execution hosts', () => { - let repo: string - let dispatcher: MockDispatcher - let handler: GitHandler - - beforeEach(() => { - repo = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - gitInit(repo) - }) - - afterEach(async () => { - handler.dispose() - await removeGitTempDir(repo) - }) - - async function changedFiles(staged: boolean): Promise { - const result = await runProcess({ - program: 'git', - args: ['diff', ...(staged ? ['--cached'] : []), '--name-only', '-z'], - cwd: repo - }) - expect(result.code).toBe(0) - return result.stdout.split('\0').filter(Boolean).sort() - } - - function mutate( - host: 'native' | 'relay', - action: 'stage' | 'unstage' | 'discard', - filePaths: string[] - ): Promise { - if (host === 'relay') { - const methods = { - stage: 'git.bulkStage', - unstage: 'git.bulkUnstage', - discard: 'git.bulkDiscard' - } - return dispatcher.callRequest(methods[action], { worktreePath: repo, filePaths }) - } - const operations = { - stage: bulkStageFiles, - unstage: bulkUnstageFiles, - discard: bulkDiscardChanges - } - return operations[action](repo, filePaths) - } - - it.each(['native', 'relay'] as const)( - 'stages, unstages and discards over 100 literal paths on %s', - async (host) => { - const selected = Array.from({ length: 105 }, (_, index) => `file-${index}.txt`) - selected.push('[k]eep.log', 'space name.txt', '-option.txt') - if (process.platform !== 'win32') { - selected.push('line\nname.txt', ':(magic).txt', 'back\\slash.txt') - } - const allPaths = [...selected, 'keep.log'] - await Promise.all( - allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'original\n')) - ) - gitCommit(repo, 'initial') - await Promise.all( - allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'modified\n')) - ) - - await mutate(host, 'stage', []) - expect(await changedFiles(true)).toEqual([]) - await mutate(host, 'stage', selected) - expect(await changedFiles(true)).toEqual([...selected].sort()) - await mutate(host, 'unstage', []) - expect(await changedFiles(true)).toEqual([...selected].sort()) - await mutate(host, 'unstage', selected) - expect(await changedFiles(true)).toEqual([]) - await mutate(host, 'discard', []) - expect(await changedFiles(false)).toEqual([...allPaths].sort()) - await mutate(host, 'discard', selected) - expect(await changedFiles(false)).toEqual(['keep.log']) - expect(await readFile(path.join(repo, 'keep.log'), 'utf8')).toBe('modified\n') - } - ) - - it.each(['native', 'relay'] as const)('rejects NUL path injection on %s', async (host) => { - await writeFile(path.join(repo, 'one.txt'), 'original\n') - gitCommit(repo, 'initial') - await writeFile(path.join(repo, 'one.txt'), 'modified\n') - - await expect(mutate(host, 'stage', ['one.txt\0:(top)**'])).rejects.toThrow('NUL') - expect(await changedFiles(true)).toEqual([]) - }) -}) diff --git a/src/main/git/source-control/staging-discard-index-safety.test.ts b/src/main/git/source-control/staging-discard-index-safety.test.ts index aaff540d231..7dbda405cba 100644 --- a/src/main/git/source-control/staging-discard-index-safety.test.ts +++ b/src/main/git/source-control/staging-discard-index-safety.test.ts @@ -1,38 +1,36 @@ -import { readFile, writeFile } from 'node:fs/promises' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' -import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from '../../../relay/git-handler-test-harness' -import type { GitHandler } from '../../../relay/git-handler' import { bulkUnstageFiles, unstageFile } from './staging' import { bulkDiscardChanges, discardChanges } from './discard-changes' -const modes = [ - { host: 'native', bulk: false }, - { host: 'native', bulk: true }, - { host: 'relay', bulk: false }, - { host: 'relay', bulk: true } -] as const +function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' }) +} + +function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) +} + +const modes = [{ bulk: false }, { bulk: true }] as const describe('staging and discard preserve index authority', () => { let repo: string - let dispatcher: MockDispatcher - let handler: GitHandler beforeEach(() => { - repo = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) + repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-')) gitInit(repo) }) afterEach(async () => { - handler.dispose() - await removeGitTempDir(repo) + await rm(repo, { recursive: true, force: true }) }) async function git(args: string[], input?: string): Promise { @@ -46,19 +44,13 @@ describe('staging and discard preserve index authority', () => { action: 'unstage' | 'discard', filePaths: string[] ): Promise { - if (mode.host === 'relay') { - return dispatcher.callRequest( - `git.${mode.bulk ? (action === 'unstage' ? 'bulkUnstage' : 'bulkDiscard') : action}`, - { worktreePath: repo, ...(mode.bulk ? { filePaths } : { filePath: filePaths[0] }) } - ) - } if (mode.bulk) { return (action === 'unstage' ? bulkUnstageFiles : bulkDiscardChanges)(repo, filePaths) } return (action === 'unstage' ? unstageFile : discardChanges)(repo, filePaths[0]) } - it.each(modes)('preserves staged MM content during $host bulk=$bulk discard', async (mode) => { + it.each(modes)('preserves staged MM content during bulk=$bulk discard', async (mode) => { await writeFile(path.join(repo, 'file.txt'), 'committed\n') gitCommit(repo, 'initial') await writeFile(path.join(repo, 'file.txt'), 'staged\n') @@ -73,7 +65,7 @@ describe('staging and discard preserve index authority', () => { expect(await git(['diff', '--cached', '--name-only'])).toBe('file.txt\n') }) - it.each(modes)('restores staged additions during $host bulk=$bulk AM discard', async (mode) => { + it.each(modes)('restores staged additions during bulk=$bulk AM discard', async (mode) => { await writeFile(path.join(repo, 'initial.txt'), 'committed\n') gitCommit(repo, 'initial') await writeFile(path.join(repo, 'new.txt'), 'staged addition\n') @@ -87,7 +79,7 @@ describe('staging and discard preserve index authority', () => { expect(await git(['diff', '--name-only'])).toBe('') }) - it.each(modes)('unstages selected unborn paths on $host bulk=$bulk', async (mode) => { + it.each(modes)('unstages selected unborn paths on bulk=$bulk', async (mode) => { await writeFile(path.join(repo, 'selected.txt'), 'selected\n') await writeFile(path.join(repo, 'keep.txt'), 'keep\n') await git(['add', '.']) @@ -98,7 +90,7 @@ describe('staging and discard preserve index authority', () => { expect(await readFile(path.join(repo, 'selected.txt'), 'utf8')).toBe('selected\n') }) - it.each(['native', 'relay'] as const)('unstages both rename paths on %s', async (host) => { + it('unstages both rename paths', async () => { await writeFile(path.join(repo, 'old.txt'), 'rename content\n') await writeFile(path.join(repo, 'keep.txt'), 'keep\n') gitCommit(repo, 'initial') @@ -106,32 +98,29 @@ describe('staging and discard preserve index authority', () => { await writeFile(path.join(repo, 'keep.txt'), 'keep staged\n') await git(['add', 'keep.txt']) - await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt']) + await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt']) expect(await git(['diff', '--cached', '--name-only'])).toBe('keep.txt\n') expect(await git(['ls-files', '-z'])).toBe('keep.txt\0old.txt\0') expect(await readFile(path.join(repo, 'new.txt'), 'utf8')).toBe('rename content\n') }) - it.each(['native', 'relay'] as const)( - 'discards a staged rename after unstaging both paths on %s', - async (host) => { - await writeFile(path.join(repo, 'old.txt'), 'rename content\n') - gitCommit(repo, 'initial') - await git(['mv', 'old.txt', 'new.txt']) + it('discards a staged rename after unstaging both paths', async () => { + await writeFile(path.join(repo, 'old.txt'), 'rename content\n') + gitCommit(repo, 'initial') + await git(['mv', 'old.txt', 'new.txt']) - await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt']) - await mutate({ host, bulk: true }, 'discard', ['new.txt', 'old.txt']) + await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt']) + await mutate({ bulk: true }, 'discard', ['new.txt', 'old.txt']) - expect(await git(['status', '--porcelain'])).toBe('') - expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n') - await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({ - code: 'ENOENT' - }) - } - ) + expect(await git(['status', '--porcelain'])).toBe('') + expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n') + await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) - it.each(modes)('rejects an unresolved conflict on $host bulk=$bulk', async (mode) => { + it.each(modes)('rejects an unresolved conflict on bulk=$bulk', async (mode) => { await writeFile(path.join(repo, 'file.txt'), 'base\n') gitCommit(repo, 'initial') const blobs = await Promise.all( diff --git a/src/main/git/status-branch-compare-real-ref.test.ts b/src/main/git/status-branch-compare-real-ref.test.ts index 561fe5fab7a..3dc8e47dabf 100644 --- a/src/main/git/status-branch-compare-real-ref.test.ts +++ b/src/main/git/status-branch-compare-real-ref.test.ts @@ -4,8 +4,6 @@ import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getBranchCompare } from './status' -import { branchCompare } from '../../relay/git-handler-ops' -import { gitChangeListArgs, parseGitChangeList } from '../../shared/git-change-list' const tempRoots: string[] = [] @@ -17,22 +15,12 @@ function git(repo: string, args: string[]): string { }).trim() } -function relayCompare(repo: string, baseRef: string) { - return branchCompare( - async (args, cwd) => ({ stdout: git(cwd, args), stderr: '' }), - repo, - baseRef, - async (mergeBase, headOid) => - parseGitChangeList(git(repo, gitChangeListArgs(mergeBase, headOid))) - ) -} - afterEach(async () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) describe('getBranchCompare real refs', () => { - it('reports equal commits as empty on native and relay despite staged and unstaged changes', async () => { + it('reports equal commits as empty despite staged and unstaged changes', async () => { const repo = await mkdtemp(path.join(tmpdir(), 'orca-equal-branch-compare-')) tempRoots.push(repo) git(repo, ['init', '-q']) @@ -46,37 +34,18 @@ describe('getBranchCompare real refs', () => { git(repo, ['add', 'changes.txt']) await writeFile(path.join(repo, 'changes.txt'), 'unstaged\n') - for (const result of await Promise.all([ - getBranchCompare(repo, 'base'), - relayCompare(repo, 'base') - ])) { - expect(result).toEqual({ - summary: { - baseRef: 'base', - baseOid: oid, - compareRef: 'feature', - headOid: oid, - mergeBase: oid, - changedFiles: 0, - commitsAhead: 0, - commitsBehind: 0, - status: 'ready' - }, - entries: [] - }) - } - }) - - it('keeps the merge-base failure for identical blob tips on the relay', async () => { - const repo = await mkdtemp(path.join(tmpdir(), 'orca-blob-branch-compare-')) - tempRoots.push(repo) - git(repo, ['init', '-q']) - await writeFile(path.join(repo, 'blob.txt'), 'not a commit\n') - const oid = git(repo, ['hash-object', '-w', 'blob.txt']) - await writeFile(path.join(repo, '.git', 'HEAD'), `${oid}\n`) - - await expect(relayCompare(repo, oid)).resolves.toMatchObject({ - summary: { headOid: oid, baseOid: oid, status: 'no-merge-base', mergeBase: null }, + expect(await getBranchCompare(repo, 'base')).toEqual({ + summary: { + baseRef: 'base', + baseOid: oid, + compareRef: 'feature', + headOid: oid, + mergeBase: oid, + changedFiles: 0, + commitsAhead: 0, + commitsBehind: 0, + status: 'ready' + }, entries: [] }) }) @@ -109,19 +78,14 @@ describe('getBranchCompare real refs', () => { ]) expect(rawOid).not.toBe(peeledOid) - for (const result of await Promise.all([ - getBranchCompare(client, 'origin/tagbase'), - relayCompare(client, 'origin/tagbase') - ])) { - expect(result.summary).toMatchObject({ - baseOid: rawOid, - headOid: peeledOid, - mergeBase: peeledOid, - changedFiles: 0, - commitsAhead: 0, - commitsBehind: 0, - status: 'ready' - }) - } + expect((await getBranchCompare(client, 'origin/tagbase')).summary).toMatchObject({ + baseOid: rawOid, + headOid: peeledOid, + mergeBase: peeledOid, + changedFiles: 0, + commitsAhead: 0, + commitsBehind: 0, + status: 'ready' + }) }) }) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts deleted file mode 100644 index c62d419a25f..00000000000 --- a/src/main/git/status-branch-line-total-relay-parity.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Main and relay must publish the same `branchLineTotal` for the same fixture - * repo. Both call sites - * share `src/shared/git-branch-line-total.ts`; this is the test that catches one - * of them wiring it up differently — different flags, a different untracked - * source, a different completeness gate. - */ -import { execFile, execFileSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { promisify } from 'node:util' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getStatus } from './status' -import type { GitExec } from '../../relay/git-handler-ops' -import { getStatusOp } from '../../relay/git-handler-status-ops' -import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' -import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' -import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache' - -const execFileAsync = promisify(execFile) - -// Fork point → working tree for the fixture below: -// tracked.txt +2 (branch commit only) -// partial.txt +1 (staged +2 then one of those lines removed unstaged) -// flip.txt 0 (added in the branch commit, removed again in the worktree) -// moved.txt 0 (pure rename) -// fresh.txt +3 (untracked) -// No fixture path here looks like test or generated code, so it is all source. -const EXPECTED_TOTAL = { - added: 6, - removed: 0, - test: { added: 0, removed: 0 }, - generated: { added: 0, removed: 0 } -} -// Summing the per-area status rows instead would give this — the wrong answer -// the shared module exists to avoid. -const AREA_ROW_SUM = { added: 5, removed: 2 } - -const relayGit: GitExec = async (args, cwd, opts) => { - const { stdout, stderr } = await execFileAsync('git', args, { - cwd, - encoding: 'utf8', - ...(opts?.signal ? { signal: opts.signal } : {}), - ...(opts?.timeout ? { timeout: opts.timeout } : {}) - }) - return { stdout, stderr } -} - -const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => { - const { stdout } = await relayGit(args, cwd, { - disableOptionalLocks: options.disableOptionalLocks, - signal: options.signal - }) - return { stoppedEarly: options.onStdout(stdout) === true } -} - -function runFixtureGit(repo: string, args: string[]): string { - return execFileSync( - 'git', - [ - '-c', - 'user.email=test@test.com', - '-c', - 'user.name=Test', - '-c', - 'commit.gpgSign=false', - ...args - ], - { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } - ).trim() -} - -/** Returns the merge-base OID the chip is measured against. */ -async function seedParityFixture(repo: string): Promise { - execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) - await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n') - await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n') - await writeFile(path.join(repo, 'renamed.txt'), 'stable\n') - await writeFile(path.join(repo, 'flip.txt'), 'p\n') - runFixtureGit(repo, ['add', '.']) - runFixtureGit(repo, ['commit', '-m', 'base']) - const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) - - runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) - await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n') - await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n') - runFixtureGit(repo, ['add', '-A']) - runFixtureGit(repo, ['commit', '-m', 'branch commit']) - - runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt']) - // Staged and unstaged hunks land on the same added lines, so an area sum - // double-counts them. - await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n') - runFixtureGit(repo, ['add', 'partial.txt']) - await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n') - // The branch commit's line, taken back out in the worktree: net zero. - await writeFile(path.join(repo, 'flip.txt'), 'p\n') - await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n') - return mergeBase -} - -function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): { - added: number - removed: number -} { - let added = 0 - let removed = 0 - for (const entry of entries) { - added += entry.added ?? 0 - removed += entry.removed ?? 0 - } - return { added, removed } -} - -describe('branch line total parity between main and relay', () => { - let repo: string - - beforeEach(async () => { - clearGitStatusLineStatsCache() - invalidateGitBranchLineTotalInFlight() - repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-')) - }) - - afterEach(async () => { - clearGitStatusLineStatsCache() - invalidateGitBranchLineTotalInFlight() - await rm(repo, { recursive: true, force: true }) - }) - - it('produces identical totals for the same fixture repo', async () => { - const mergeBase = await seedParityFixture(repo) - - const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - clearGitStatusLineStatsCache() - const relayStatus = await getStatusOp(relayGit, relayStreamGit, { - worktreePath: repo, - branchLineTotalMergeBase: mergeBase - }) - - expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase }) - expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) - // Both sides model the same worktree, so a parity pass on a mismatched - // entry list would be meaningless. - expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual( - mainStatus.entries.map((entry) => entry.path).sort() - ) - }) - - it('agrees on a number no per-area row sum could produce', async () => { - const mergeBase = await seedParityFixture(repo) - - const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - clearGitStatusLineStatsCache() - const relayStatus = await getStatusOp(relayGit, relayStreamGit, { - worktreePath: repo, - branchLineTotalMergeBase: mergeBase - }) - - expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM) - expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual( - AREA_ROW_SUM - ) - expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM) - expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) - }) - - it('omits the total on both sides when no merge base is requested', async () => { - await seedParityFixture(repo) - - const mainStatus = await getStatus(repo) - clearGitStatusLineStatsCache() - const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo }) - - expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false) - expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false) - }) -}) diff --git a/src/main/git/status-conflict-overlap.bench.test.ts b/src/main/git/status-conflict-overlap.bench.test.ts deleted file mode 100644 index 5c92a23e10c..00000000000 --- a/src/main/git/status-conflict-overlap.bench.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { performance } from 'node:perf_hooks' -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFsPromises from 'node:fs/promises' -import type * as GitRunner from './runner' -import type { GitExec } from '../../relay/git-handler-ops' -import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' - -const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({ - gitStreamStdoutMock: vi.fn(), - readFileMock: vi.fn() -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, readFile: readFileMock } -}) - -vi.mock('./runner', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, gitStreamStdout: gitStreamStdoutMock } -}) - -import { getStatus } from './status' -import { getStatusOp } from '../../relay/git-handler-status-ops' - -const BENCH_DELAY_MS = 25 -const BENCH_SAMPLES = 31 -const BENCH_WARMUPS = 5 -const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip - -type Deferred = { - promise: Promise - resolve: (value: T) => void -} - -type BenchmarkResult = { - path: string - medianMs: number - p95Ms: number - minMs: number - maxMs: number -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve } -} - -function wait(delayMs: number): Promise { - return new Promise((resolve) => setTimeout(resolve, delayMs)) -} - -function percentile(samples: number[], fraction: number): number { - const sorted = [...samples].sort((a, b) => a - b) - return sorted[Math.ceil(sorted.length * fraction) - 1]! -} - -describe('git status conflict-read overlap', () => { - const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) - - beforeEach(() => { - readFileMock.mockReset() - gitStreamStdoutMock.mockReset() - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false }) - }) - - it('starts native status before conflict-marker I/O settles', async () => { - const markerRead = deferred() - const statusStarted = deferred() - readFileMock.mockReturnValue(markerRead.promise) - gitStreamStdoutMock.mockImplementation(async () => { - statusStarted.resolve() - return { stoppedEarly: false } - }) - - const resultPromise = getStatus('/repo') - await statusStarted.promise - expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') - - markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') - await expect(resultPromise).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it('starts relay status before conflict-marker I/O settles', async () => { - const markerRead = deferred() - const statusStarted = deferred() - readFileMock.mockReturnValue(markerRead.promise) - const relayStreamGit: RelayGitStreamExec = async () => { - statusStarted.resolve() - return { stoppedEarly: false } - } - - const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) - await statusStarted.promise - expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') - - markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') - await expect(resultPromise).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it('observes an early native status rejection while marker I/O remains pending', async () => { - const markerRead = deferred() - readFileMock.mockReturnValue(markerRead.promise) - gitStreamStdoutMock.mockRejectedValue(new Error('status failed first')) - let settled = false - - const resultPromise = getStatus('/repo').finally(() => { - settled = true - }) - await Promise.resolve() - await Promise.resolve() - expect(settled).toBe(false) - - markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') - await expect(resultPromise).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it('keeps a synchronous native status failure fail-soft', async () => { - const statusError = new Error('status threw') - gitStreamStdoutMock.mockImplementation(() => { - throw statusError - }) - - await expect(getStatus('/repo')).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => { - const statusError = new Error(`status ${mode}`) - const relayStreamGit = (() => { - if (mode === 'throw') { - throw statusError - } - return Promise.reject(statusError) - }) as RelayGitStreamExec - - await expect( - getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) - ).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' }) - }) - - it('rethrows the original relay status failure after cancellation', async () => { - const controller = new AbortController() - const statusError = new Error('cancelled status') - const relayStreamGit: RelayGitStreamExec = async () => { - controller.abort(statusError) - throw statusError - } - - await expect( - getStatusOp( - relayGit, - relayStreamGit, - { worktreePath: '/repo' }, - { signal: controller.signal } - ) - ).rejects.toBe(statusError) - }) - - it('surfaces detector errors without waiting for a hung status read', async () => { - const statusStarted = deferred() - const relayStreamGit: RelayGitStreamExec = () => { - statusStarted.resolve() - return new Promise(() => {}) - } - const invalidPath = { toString: () => '/repo' } - - const resultPromise = getStatusOp(relayGit, relayStreamGit, { - worktreePath: invalidPath - }) - const rejection = expect(resultPromise).rejects.toThrow(TypeError) - await statusStarted.promise - await rejection - }) - - it('keeps detector errors ahead of concurrent status failures', async () => { - const statusError = new Error('status failed too') - const relayStreamGit = (() => { - throw statusError - }) as RelayGitStreamExec - const invalidPath = { toString: () => '/repo' } - - const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath }) - await expect(result).rejects.toBeInstanceOf(TypeError) - }) -}) - -describeBench('git status conflict-read overlap benchmark', () => { - it('measures native and relay orchestration with matched independent latency', async () => { - readFileMock.mockImplementation(async () => { - await wait(BENCH_DELAY_MS) - return 'gitdir: /repo/.git/worktrees/feature\n' - }) - gitStreamStdoutMock.mockImplementation(async () => { - await wait(BENCH_DELAY_MS) - return { stoppedEarly: false } - }) - const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) - const relayStreamGit: RelayGitStreamExec = async () => { - await wait(BENCH_DELAY_MS) - return { stoppedEarly: false } - } - const cases = [ - { name: 'native', run: () => getStatus('/repo') }, - { - name: 'relay', - run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) - } - ] - - const results: BenchmarkResult[] = [] - for (const benchmarkCase of cases) { - for (let index = 0; index < BENCH_WARMUPS; index += 1) { - await benchmarkCase.run() - } - const samples: number[] = [] - for (let index = 0; index < BENCH_SAMPLES; index += 1) { - const startedAt = performance.now() - await benchmarkCase.run() - samples.push(performance.now() - startedAt) - } - results.push({ - path: benchmarkCase.name, - medianMs: Number(percentile(samples, 0.5).toFixed(3)), - p95Ms: Number(percentile(samples, 0.95).toFixed(3)), - minMs: Number(Math.min(...samples).toFixed(3)), - maxMs: Number(Math.max(...samples).toFixed(3)) - }) - } - - console.table(results) - expect(results).toHaveLength(2) - }, 10_000) -}) diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 2e4198eb665..5b23d7877b3 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -253,7 +253,6 @@ async function performAddWorktree( await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options) } - // SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep. // Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain // `git push` create+set origin/ (git >=2.37; older clients ignore it). `--local` on a // linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent, diff --git a/src/main/git/worktree-rebase-update-refs-real-git.test.ts b/src/main/git/worktree-rebase-update-refs-real-git.test.ts index 2795f3f1a7e..8da9da2388b 100644 --- a/src/main/git/worktree-rebase-update-refs-real-git.test.ts +++ b/src/main/git/worktree-rebase-update-refs-real-git.test.ts @@ -10,7 +10,6 @@ vi.mock('./local-repo-ref-maintenance', () => ({ })) import { forceDeleteLocalBranch } from './worktree-branch-removal' -import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup' const image = process.env.ORCA_GIT_COMPAT_IMAGE const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git' @@ -82,10 +81,10 @@ afterEach(async () => { }) describe('rebase branch reservations with real Git', () => { - it.for(['native', 'relay'] as const)( + it.for(['native'] as const)( '%s retains auxiliary branches reserved by a paused real rebase --update-refs', { timeout: 120_000 }, - async (host, context) => { + async (_host, context) => { await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) await writeFile(join(checkout, 'first'), 'first\n') await git(['add', 'first'], checkout) @@ -117,10 +116,7 @@ describe('rebase branch reservations with real Git', () => { /checked out|used by worktree/ ) const cleanupGit = vi.fn((args: string[], cwd: string) => git(args, cwd)) - const remove = () => - host === 'native' - ? forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit) - : forceDeletePreservedRelayBranch(cleanupGit, repo, 'auxiliary', expected) + const remove = () => forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit) await expect(remove()).rejects.toThrow('checked out in another worktree') expect(cleanupGit.mock.calls.map(([args]) => args)).toEqual([ ['worktree', 'list', '--porcelain'] diff --git a/src/main/git/worktree-safety-real-git.test.ts b/src/main/git/worktree-safety-real-git.test.ts index 7a31e2dfc5f..d6a8e2ce4ea 100644 --- a/src/main/git/worktree-safety-real-git.test.ts +++ b/src/main/git/worktree-safety-real-git.test.ts @@ -1,14 +1,9 @@ import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { GitCapabilityCache } from '../../shared/git-capability-cache' -import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation' -import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' -import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin' -import type { GitExec } from '../../relay/git-handler-ops' const { runner } = vi.hoisted(() => ({ runner: vi.fn() })) vi.mock('./runner', () => ({ @@ -18,14 +13,8 @@ vi.mock('./runner', () => ({ })) vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() })) -import { removeWorktree } from './worktree-removal' -import { forceDeleteLocalBranch } from './worktree-branch-removal' -import { readWorktreeList } from './worktree-list-reader' import { getBranchConflictKind, getBranchConflictKindViaExec } from './repo-branch-conflict' import { clearGitCapabilityStateForTests } from './git-capability-state' -import { removeWorktreeOp } from '../../relay/git-handler-worktree-remove' -import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup' -import { readRelayWorktreeList } from '../../relay/git-handler-worktree-list' const execFileAsync = promisify(execFile) const image = process.env.ORCA_GIT_COMPAT_IMAGE @@ -37,7 +26,6 @@ const dockerUser = : [] let root = '' let repo = '' -let checkout = '' async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> { return image @@ -66,8 +54,6 @@ async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr }) } -const relay: GitExec = (args, cwd) => git(args, cwd) - async function initializeRepo(cwd: string): Promise { await mkdir(cwd, { recursive: true }) await git(['init', '-q', '-b', 'main'], cwd).catch(() => git(['init', '-q'], cwd)) @@ -92,7 +78,6 @@ beforeAll(async () => { beforeEach(async () => { root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-'))) repo = join(root, 'repo') - checkout = join(root, 'checkout') await initializeRepo(repo) clearGitCapabilityStateForTests() runner.mockImplementation((args: string[], options: { cwd?: string }) => git(args, options.cwd)) @@ -103,99 +88,6 @@ afterEach(async () => { }) describe('worktree safety with the real Git binary', () => { - it.each(['native', 'relay'] as const)( - '%s preserves unpublished submodule objects without implicit force', - async (host) => { - const origin = join(root, 'sub-origin') - await initializeRepo(origin) - await git(['-c', 'protocol.file.allow=always', 'submodule', 'add', origin, 'sub']) - await git(['commit', '-qam', 'submodule']) - await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) - await git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], checkout) - const sub = join(checkout, 'sub') - await git(['checkout', '-qb', 'unpublished'], sub) - await writeFile(join(sub, 'local'), 'unpublished work\n') - await git(['add', 'local'], sub) - await git( - [ - '-c', - 'user.name=Safety', - '-c', - 'user.email=safety@example.invalid', - '-c', - 'commit.gpgSign=false', - 'commit', - '-qm', - 'local' - ], - sub - ) - const local = (await git(['rev-parse', 'HEAD'], sub)).stdout.trim() - await git(['checkout', '--detach', 'HEAD~1'], sub) - expect( - (await git(['status', '--porcelain', '--ignore-submodules=none'], checkout)).stdout - ).toBe('') - - const remove = (force: boolean) => - host === 'native' - ? removeWorktree(repo, checkout, force, { - deleteBranch: false, - knownRemovedWorktree: { branch: 'refs/heads/feature', head: '', locked: false } - }) - : removeWorktreeOp( - relay, - { worktreePath: checkout, force, deleteBranch: false }, - new GitCapabilityCache() - ) - await expect(remove(false)).rejects.toThrow(/submodules cannot be moved or removed/) - expect((await git(['cat-file', '-t', local], sub)).stdout.trim()).toBe('commit') - expect((await git(['worktree', 'list', '--porcelain'])).stdout).toContain(checkout) - await expect(remove(true)).resolves.toEqual({}) - await expect(readFile(join(checkout, '.git'))).rejects.toThrow() - }, - 120_000 - ) - - it.each(['native', 'relay'] as const)( - '%s reads preparation ownership from admin metadata on old porcelain', - async (host) => { - await git(['worktree', 'add', '-q', '--detach', checkout]) - const reason = 'orca-create-preparation:v1:12345:retained' - await git(['worktree', 'lock', '--reason', reason, checkout]) - const oldList = (await git(['worktree', 'list', '--porcelain'])).stdout - .split('\n') - .filter((line) => !line.startsWith('locked')) - .join('\n') - const capabilities = new GitCapabilityCache() - capabilities.rememberUnsupported('worktree-list-z') - runner.mockImplementation(async (args: string[], options: { cwd?: string }) => { - if (args[0] === 'worktree' && args[1] === 'list') { - if (args.includes('-z')) { - throw Object.assign(new Error("unknown switch `z'"), { - code: 129, - stderr: "error: unknown switch `z'" - }) - } - return { stdout: oldList, stderr: '' } - } - return git(args, options.cwd) - }) - const entries = - host === 'native' - ? await readWorktreeList(repo) - : await readRelayWorktreeList((args, cwd) => runner(args, { cwd }), repo, capabilities) - const prepared = entries.find((entry) => entry.path === checkout) - expect(prepared).toMatchObject({ locked: true, lockReason: reason }) - expect(prepared && isWorktreeCreatePreparation(prepared)).toBe(true) - await rm(checkout, { recursive: true }) - const annotated = await annotateWorktreeLocksFromAdmin(repo, parseWorktreeList(oldList)) - expect(annotated.find((entry) => entry.path === checkout)).toMatchObject({ - locked: true, - lockReason: reason - }) - }, - 90_000 - ) it.each([ ['feature', 'Feature'], @@ -226,59 +118,4 @@ describe('worktree safety with the real Git binary', () => { 90_000 ) - it.each(['native', 'relay'] as const)( - '%s retains detached branches reserved by rebase or bisect', - async (host) => { - await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) - const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim() - await git(['checkout', '--detach'], checkout) - const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim() - const remove = () => - host === 'native' - ? forceDeleteLocalBranch(repo, 'feature', expected, git) - : forceDeletePreservedRelayBranch(relay, repo, 'feature', expected) - for (const marker of ['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START']) { - const file = join(gitDir, ...marker.split('/')) - await mkdir(join(file, '..'), { recursive: true }) - await writeFile(file, marker === 'BISECT_START' ? 'feature\n' : 'refs/heads/feature\n') - if (marker === 'BISECT_START') { - await writeFile(join(gitDir, 'BISECT_LOG'), '') - } - await expect(git(['branch', '-D', 'feature'])).rejects.toThrow( - /checked out|used by worktree/ - ) - await expect(remove()).rejects.toThrow('checked out in another worktree') - expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected) - await rm(file) - if (marker === 'BISECT_START') { - await rm(join(gitDir, 'BISECT_LOG')) - } - } - await expect(remove()).resolves.toBeUndefined() - }, - 120_000 - ) - it.each(['native', 'relay'] as const)( - '%s restores a deleted ref when detached branch use starts during deletion', - async (host) => { - await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) - const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim() - await git(['checkout', '--detach'], checkout) - const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim() - const racingGit = async (args: string[], cwd: string) => { - const result = await git(args, cwd) - if (args[0] === 'update-ref' && args[1] === '-d') { - await writeFile(join(gitDir, 'BISECT_START'), 'feature\n') - } - return result - } - const remove = () => - host === 'native' - ? forceDeleteLocalBranch(repo, 'feature', expected, racingGit) - : forceDeletePreservedRelayBranch(racingGit, repo, 'feature', expected) - await expect(remove()).rejects.toThrow('checked out in another worktree') - expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected) - }, - 90_000 - ) }) diff --git a/src/main/hermes/hermes-config-comment-boundaries.test.ts b/src/main/hermes/hermes-config-comment-boundaries.test.ts index b28ad582ed9..e1bf9ab6f16 100644 --- a/src/main/hermes/hermes-config-comment-boundaries.test.ts +++ b/src/main/hermes/hermes-config-comment-boundaries.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { parseDocument } from 'yaml' -import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml' import { HermesHookService } from './hook-service' diff --git a/src/main/hermes/hermes-config-remote-adapter.test.ts b/src/main/hermes/hermes-config-remote-adapter.test.ts index 3005d9ef9dd..2c860b85798 100644 --- a/src/main/hermes/hermes-config-remote-adapter.test.ts +++ b/src/main/hermes/hermes-config-remote-adapter.test.ts @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' import { HermesHookService } from './hook-service' describe('Hermes remote installer through a local filesystem adapter (no SSH)', () => { diff --git a/src/main/ipc/filesystem-quick-open-options.integration.test.ts b/src/main/ipc/filesystem-quick-open-options.integration.test.ts index 904fe8bcb28..f908d2527f7 100644 --- a/src/main/ipc/filesystem-quick-open-options.integration.test.ts +++ b/src/main/ipc/filesystem-quick-open-options.integration.test.ts @@ -2,22 +2,17 @@ import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises' import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, expect, it } from 'vitest' import type { Store } from '../persistence' import { listQuickOpenFiles } from './filesystem-list-files' -import { listFilesWithRg } from '../../relay/fs-handler-list-files' import { searchQuickOpenFilePaths } from './filesystem-search-file-paths' -import { bundledRipgrepCommand } from '../ripgrep/bundled-ripgrep-path' -import { configureRelayBundledRipgrep } from '../../relay/relay-bundled-ripgrep' const fixtures: string[] = [] -beforeEach(() => configureRelayBundledRipgrep(bundledRipgrepCommand())) afterEach(async () => { - configureRelayBundledRipgrep(undefined) await Promise.all(fixtures.splice(0).map((path) => rm(path, { recursive: true, force: true }))) }) -it('honors inherited ignores, opt-in links, cycles, retargets and relay parity in real processes', async () => { +it('honors inherited ignores, opt-in links, cycles, and retargets in real processes', async () => { const parent = await mkdtemp(join(tmpdir(), 'orca-quick-open-options-')) fixtures.push(parent) await mkdir(join(parent, '.git')) @@ -53,8 +48,6 @@ it('honors inherited ignores, opt-in links, cycles, retargets and relay parity i undefined, options ) - const relay = await listFilesWithRg(root, [], options) - expect(local.sort()).toEqual(relay.sort()) expect(local.includes('ignored.txt')).toBe(options.includeIgnored) expect(local.includes('linked/linked.md')).toBe(options.followSymlinks) expect(local).not.toContain('cycle/apps/api/.env') @@ -122,13 +115,6 @@ it('validates recent membership independently of top32, ignores, exclusions and getSettings: () => ({}), getFolderWorkspaces: () => [] } as unknown as Store - const top = await listFilesWithRg(root, [], { - searchQuery: 'file', - maxResults: 32, - includeIgnored: false - }) - expect(top).toHaveLength(32) - expect(top).not.toContain('src/file059.ts') const candidates = [ 'src/file059.ts', 'deleted.ts', @@ -148,17 +134,17 @@ it('validates recent membership independently of top32, ignores, exclusions and undefined, options ) - const relay = await listFilesWithRg(root, ['excluded'], { - ...options, - maxResults: candidates.length - }) expect(local).toEqual(['src/file059.ts']) - expect(relay).toEqual(local) - const broad = await listFilesWithRg(root, ['excluded'], { - ...options, - includeIgnored: true, - maxResults: candidates.length - }) + const broad = await listQuickOpenFiles( + root, + store, + [join(root, 'excluded')], + undefined, + candidates.length, + undefined, + undefined, + { ...options, includeIgnored: true } + ) expect(broad.sort()).toEqual(['ignored.ts', 'src/file059.ts']) await mkdir(join(root, 'large')) for (let index = 0; index < 20_020; index += 1) { @@ -180,11 +166,4 @@ it('validates recent membership independently of top32, ignores, exclusions and includeIgnored: false }) ).toEqual([missing]) - expect( - await listFilesWithRg(root, [], { - candidatePaths: [missing], - includeIgnored: false, - maxResults: 1 - }) - ).toEqual([missing]) }, 60_000) diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index eacd2cd133f..84071ac3c2c 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi, type Mock } from 'vitest' -import { validateGitExecArgs } from '../../relay/git-exec-validator' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { @@ -84,20 +83,6 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) }) - it('sends only argv the relay accepts, so SSH cleanup is not silently skipped', async () => { - const exec = makeExec() - await cleanupUnusedWorktreePushTargetRemoteWithExec( - REPO_PATH, - 'repo-1::/wt/a', - forkTarget(), - storeOf({ 'repo-1::/wt/a': forkTarget() }), - exec - ) - for (const [args] of exec.mock.calls) { - expect(() => validateGitExecArgs(args)).not.toThrow() - } - }) - it('keeps a remote Orca did not create (remoteCreated falsy)', async () => { const exec = makeExec() await cleanupUnusedWorktreePushTargetRemoteWithExec( diff --git a/src/main/ipc/worktree-push-target-reconciliation.test.ts b/src/main/ipc/worktree-push-target-reconciliation.test.ts index ed29809a151..d1f37561c15 100644 --- a/src/main/ipc/worktree-push-target-reconciliation.test.ts +++ b/src/main/ipc/worktree-push-target-reconciliation.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi, type Mock } from 'vitest' -import { validateGitExecArgs } from '../../relay/git-exec-validator' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' @@ -234,25 +233,6 @@ describe('reconcileOrphanedPrRemotesWithExec', () => { expect(reclaimed).toEqual([]) }) - it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => { - // Exercise every branch (config probe, for-each-ref probe, and the reclaim itself). - const exec = makeExec({ - remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), - branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, - localBranches: 'main' - }) - await reconcileOrphanedPrRemotesWithExec( - REPO_PATH, - REPO_ID, - storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), - exec, - [] - ) - expect(exec.mock.calls.length).toBeGreaterThan(0) - for (const [args] of exec.mock.calls) { - expect(() => validateGitExecArgs(args)).not.toThrow() - } - }) }) describe('reconcileOrphanedPrRemotes rate limiting', () => { diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts deleted file mode 100644 index 19b1c903aad..00000000000 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { validateGitExecArgs } from '../../relay/git-exec-validator' -import { - getSshGitProviderMock, - getActiveMultiplexerMock, - removeWorktreeMock, - forceDeleteLocalBranchMock, - gitExecFileAsyncMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote' -import type { SshGitProvider } from '../providers/ssh-git-provider' -import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - // Was "adds the fork remote ... through git.exec": create used to mint the fork - // remote unconditionally. It now defers to first sync (#17828) -- split in two so - // each half stays true to a single claim: create stays a no-op, sync still mints. - it('defers minting the fork remote for an SSH fork-PR worktree until first sync', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - // Why: run every exec through the relay's own validator so a create shape the - // relay rejects fails here instead of on the user's SSH host. - const exec = vi.fn().mockImplementation(async (args: string[]) => { - validateGitExecArgs(args) - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }) - const provider = { - exec, - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-contributor-fix', - head: 'abc123', - branch: 'refs/heads/contributor/fix', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - - expect(exec).not.toHaveBeenCalledWith( - ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], - '/remote/repo' - ) - // fetchRemoteTrackingRef IS called once here, but for create's unrelated - // base-ref refresh (origin/main) -- not for the fork remote, which defers. - expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( - '/remote/repo', - 'pr-contributor-orca', - 'contributor/fix', - 'refs/remotes/pr-contributor-orca/contributor/fix' - ) - expect(store.setWorktreeMeta).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ) - }) - - // Companion to the deferral test above: `materializeWorktreePushTargetRemoteSsh` is - // exactly what `git:push`/`git:pull`'s SSH dispatch calls before syncing, so this is - // "first sync" without needing the sync IPC handlers registered in this harness. - it('mints the fork remote for an SSH fork-PR worktree on first sync', async () => { - const exec = vi.fn().mockImplementation(async (args: string[]) => { - validateGitExecArgs(args) - if (args[0] === 'remote' && args[1] === 'get-url') { - throw new Error('No such remote') - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - const fetchRemoteTrackingRef = vi.fn().mockResolvedValue(undefined) - const markRemoteOrcaCreated = vi.fn().mockResolvedValue(undefined) - const target = { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - - const result = await materializeWorktreePushTargetRemoteSsh( - { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, - '/remote/repo', - target - ) - - expect(result).toEqual({ ...target, remoteCreated: true }) - expect(exec).toHaveBeenCalledWith( - ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], - '/remote/repo' - ) - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/remote/repo', - 'pr-contributor-orca', - 'contributor/fix', - 'refs/remotes/pr-contributor-orca/contributor/fix' - ) - expect(markRemoteOrcaCreated).toHaveBeenCalledWith('/remote/repo', 'pr-contributor-orca') - }) - - // The relay-upgrade-messaging, fetch-failure rollback, and sibling-remote-preserved - // cases used to be exercised here because create minted the remote unconditionally. - // That code (prepareWorktreePushTargetSsh) is unchanged -- it just no longer runs at - // create time for a fork remote, only from materializeWorktreePushTargetRemoteSsh on - // first sync. Coverage for all three moved with it to - // worktree-remote-push-target-materialization.test.ts, which calls that function directly. -}) - -describe('worktree preserved branch fork remotes', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('preserves the branch on remove for worktrees created from an existing local branch', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true })) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(removeWorktreeMock).toHaveBeenCalledWith( - '/workspace/repo', - '/workspace/feature-wt', - false, - expect.objectContaining({ - deleteBranch: false, - knownRemovedWorktree: expect.objectContaining({ - branch: 'feature', - head: 'feature', - path: '/workspace/feature-wt' - }) - }) - ) - }) - - it('force-deletes a branch that was preserved by safe worktree removal', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'def456' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( - '/workspace/repo', - 'feature/test', - 'def456' - ) - }) - - it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const worktreeId = 'repo-ssh::/remote/feature-wt' - const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined } - const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'def456', - branch: 'feature/test', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepos.mockReturnValue([localRepo, repo]) - store.getRepo.mockReturnValue(localRepo) - store.getWorktreeMeta.mockReturnValue( - makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true }) - ) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() }) - - await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' }) - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId, - branchName: 'feature/test', - expectedHead: 'def456', - hostId: 'ssh:conn-1' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( - '/remote/repo', - 'feature/test', - 'def456' - ) - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('rejects stale preserved-branch cleanup actions with an old head', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'new456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - await expect( - handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'old123' - }) - ).rejects.toThrow('No preserved branch cleanup is pending') - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('removes an unused Orca-created fork remote after deleting its worktree', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) - - it('keeps an Orca-created fork remote while another worktree still uses it', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-1::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( - ['remote', 'remove', 'pr-contributor-orca'], - expect.any(Object) - ) - }) - - it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-2::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) -}) diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index f7b092813e5..abfe38dbacb 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' import { isPlainObject } from '../agent-hooks/installer-utils' import { KiroHookService } from './hook-service' import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings' diff --git a/src/main/plugins/plugin-host-conformance.test.ts b/src/main/plugins/plugin-host-conformance.test.ts deleted file mode 100644 index 43ec5c2f227..00000000000 --- a/src/main/plugins/plugin-host-conformance.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { PLUGIN_HOST_API_V0 } from '../../shared/plugins/plugin-host-api' -import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' -import { - admitPluginPanelCall, - createPluginPanelCallAdmission -} from '../../shared/plugins/plugin-panel-call-admission' -import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' -import type { MethodHandler } from '../../wsl-guest/dispatcher' -import { - RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, - RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, - registerRelayPluginHostCallHandlers -} from '../../relay/plugin-host-call-handler' -import { - executePluginHostCallRequest, - type PluginHostCallPolicy, - type ResolvePluginHostCallPolicy -} from './plugin-host-call-adapter' -import type { PluginHostServices } from './plugin-host-methods' - -const PLUGIN_KEY = 'orca-samples.demo' -const WORKTREE_ID = 'repo-id::/Users/private/orca' -const TERMINAL_ID = 'terminal:local:one' - -type HostCallAdapter = (request: unknown, viaPanel: boolean) => Promise - -function createServices(): PluginHostServices { - return { - resolveActiveWorktreeContext: vi.fn().mockResolvedValue({ - worktreeId: WORKTREE_ID, - branch: 'main', - displayName: 'Orca', - path: '/Users/private/orca' - }), - listWorktreeTerminals: vi - .fn() - .mockResolvedValue([{ id: TERMINAL_ID, title: '/home/private/orca' }]), - sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }), - dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }), - storage: { - get: vi.fn().mockReturnValue('stored'), - set: vi.fn().mockReturnValue({ ok: true }), - delete: vi.fn(), - keys: vi.fn().mockReturnValue(['alpha']) - }, - secrets: { - get: vi.fn().mockReturnValue({ ok: true, value: 'secret' }), - set: vi.fn().mockReturnValue({ ok: true }), - delete: vi.fn() - }, - settings: { - getAll: vi.fn().mockReturnValue({ theme: 'dark' }), - set: vi.fn().mockReturnValue({ ok: true }) - }, - subscribeEvents: vi.fn().mockImplementation((_pluginKey, events) => events) - } -} - -function createPolicy( - grantedCapabilities: readonly PluginCapabilityKind[] | null, - services: PluginHostServices = createServices(), - audit = { record: vi.fn().mockResolvedValue(undefined) } -): PluginHostCallPolicy { - return { grantedCapabilities, services, audit } -} - -function createAdapters( - resolvePolicy: ResolvePluginHostCallPolicy, - limits?: { maxBytes?: number; maxMessages?: number; perMs?: number } -): Record { - const relayHandlers = new Map() - registerRelayPluginHostCallHandlers( - { onRequest: (method, handler) => relayHandlers.set(method, handler) }, - (context) => (context.clientId === 1 ? PLUGIN_KEY : null), - resolvePolicy, - { panelAdmission: createPluginPanelCallAdmission({ limits, now: () => 0 }) } - ) - const desktopAdmission = createPluginPanelCallAdmission({ limits, now: () => 0 }) - return { - 'desktop-main': async (request, viaPanel) => { - if (viaPanel) { - const admissionRefusal = admitPluginPanelCall(desktopAdmission, PLUGIN_KEY, request) - if (admissionRefusal) { - return admissionRefusal - } - } - return executePluginHostCallRequest({ - pluginKey: PLUGIN_KEY, - request, - viaPanel, - resolvePolicy - }) - }, - relay: async (request, viaPanel) => { - const registeredMethod = viaPanel - ? RELAY_PLUGIN_PANEL_HOST_CALL_METHOD - : RELAY_PLUGIN_WORKER_HOST_CALL_METHOD - return (await relayHandlers.get(registeredMethod)!(request as Record, { - clientId: 1, - isStale: () => false - })) as PluginPanelActionOutcome - } - } -} - -const successParams: Record = { - 'workspace.readContext': {}, - 'terminal.sendText': { terminalId: TERMINAL_ID, text: 'echo hi', enter: true }, - 'notifications.show': { title: 'Hello' }, - 'storage.get': { key: 'alpha' }, - 'storage.set': { key: 'alpha', value: 1 }, - 'storage.delete': { key: 'alpha' }, - 'storage.keys': {}, - 'secrets.get': { key: 'token' }, - 'secrets.set': { key: 'token', value: 'secret' }, - 'secrets.delete': { key: 'token' }, - 'settings.get': {}, - 'settings.set': { key: 'theme', value: 'dark' }, - 'events.subscribe': { events: ['worktree.created'] } -} - -describe('plugin host main/relay conformance', () => { - it('runs a granted success through both transports for all 13 v0 methods', async () => { - expect(PLUGIN_HOST_API_V0).toHaveLength(13) - expect(Object.keys(successParams).sort()).toEqual( - PLUGIN_HOST_API_V0.map((entry) => entry.name).sort() - ) - expect(PLUGIN_HOST_API_V0.every((entry) => entry.stability === 'experimental')).toBe(true) - expect(PLUGIN_HOST_API_V0.every((entry) => entry.scope.length > 0)).toBe(true) - - for (const spec of PLUGIN_HOST_API_V0) { - const policy = createPolicy([spec.capability]) - const resolvePolicy = vi.fn().mockResolvedValue(policy) - const outcomes = await Promise.all( - Object.values(createAdapters(resolvePolicy)).map((adapter) => - adapter({ method: spec.name, params: successParams[spec.name] }, spec.panel) - ) - ) - expect(outcomes, spec.name).toHaveLength(2) - expect(outcomes[0], spec.name).toEqual(outcomes[1]) - expect(outcomes[0], spec.name).toMatchObject({ ok: true }) - } - }) - - it('projects workspace context without host paths on main and relay', async () => { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['workspace:read'])) - for (const adapter of Object.values(createAdapters(resolvePolicy))) { - const outcome = await adapter({ method: 'workspace.readContext', params: {} }, true) - expect(outcome).toEqual({ - ok: true, - value: { - branch: 'main', - displayName: 'Orca', - terminals: [{ id: TERMINAL_ID }] - } - }) - expect(outcome).not.toHaveProperty('value.path') - expect(outcome).not.toHaveProperty('value.worktreeId') - } - }) - - const deniedCases: { - name: string - request: unknown - viaPanel: boolean - policy: () => PluginHostCallPolicy - code: string - }[] = [ - { - name: 'missing or stale consent', - request: { method: 'workspace.readContext', params: {} }, - viaPanel: true, - policy: () => createPolicy(null), - code: 'consent_required' - }, - { - name: 'missing capability', - request: { method: 'workspace.readContext', params: {} }, - viaPanel: true, - policy: () => createPolicy([]), - code: 'capability_denied' - }, - { - name: 'unknown method', - request: { method: 'workspace.erase', params: {} }, - viaPanel: false, - policy: () => createPolicy(['workspace:read']), - code: 'unknown_method' - }, - { - name: 'malformed params', - request: { - method: 'terminal.sendText', - params: { terminalId: TERMINAL_ID, text: '' } - }, - viaPanel: true, - policy: () => createPolicy(['terminal:send']), - code: 'invalid_params' - }, - { - name: 'panel-forbidden method', - request: { method: 'storage.get', params: { key: 'alpha' } }, - viaPanel: true, - policy: () => createPolicy(['storage']), - code: 'panel_forbidden' - }, - { - name: 'malformed result', - request: { - method: 'notifications.show', - params: { title: 'Hello' } - }, - viaPanel: true, - policy: () => { - const services = createServices() - services.dispatchPluginNotification = vi - .fn() - .mockResolvedValue({ delivered: 'yes' } as unknown as { delivered: boolean }) - return createPolicy(['notifications:show'], services) - }, - code: 'action_failed' - }, - { - name: 'mutation audit failure', - request: { - method: 'storage.set', - params: { key: 'alpha', value: 1 } - }, - viaPanel: false, - policy: () => - createPolicy(['storage'], createServices(), { - record: vi.fn().mockRejectedValue(new Error('disk full')) - }), - code: 'action_failed' - } - ] - - it.each(deniedCases)('returns identical $code codes for $name', async (testCase) => { - const outcomes: PluginPanelActionOutcome[] = [] - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockImplementation(() => testCase.policy()) - const adapter = createAdapters(resolvePolicy)[adapterName]! - outcomes.push(await adapter(testCase.request, testCase.viaPanel)) - } - expect(outcomes[0]).toMatchObject({ ok: false, code: testCase.code }) - expect(outcomes[1]).toMatchObject({ ok: false, code: testCase.code }) - expect(outcomes[0]).toEqual(outcomes[1]) - }) - - it('enforces the same per-plugin panel budget on desktop main and relay', async () => { - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) - const adapter = createAdapters(resolvePolicy, { - maxMessages: 1, - perMs: 10_000 - })[adapterName]! - - await expect( - adapter({ method: 'notifications.show', params: { title: 'first' } }, true) - ).resolves.toMatchObject({ ok: true }) - await expect( - adapter({ method: 'notifications.show', params: { title: 'second' } }, true) - ).resolves.toEqual({ - ok: false, - code: 'rate_limited', - error: 'too many panel requests' - }) - } - }) - - it('charges malformed and oversized panel traffic before schema parsing', async () => { - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) - const adapter = createAdapters(resolvePolicy, { - maxBytes: 128, - maxMessages: 2, - perMs: 10_000 - })[adapterName]! - - await expect( - adapter({ method: 'notifications.show', unexpected: true }, true) - ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) - await expect( - adapter( - { - method: 'notifications.show', - params: { title: 'x'.repeat(256) } - }, - true - ) - ).resolves.toEqual({ - ok: false, - code: 'invalid_request', - error: 'panel message exceeds the size limit' - }) - await expect( - adapter({ method: 'notifications.show', params: { title: 'third' } }, true) - ).resolves.toEqual({ - ok: false, - code: 'rate_limited', - error: 'too many panel requests' - }) - expect(resolvePolicy).not.toHaveBeenCalled() - } - }) - - it('binds relay plugin identity to the requesting connection', async () => { - const relayHandlers = new Map() - const services = createServices() - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'], services)) - const resolveIdentity = vi - .fn() - .mockImplementation(({ clientId }: { clientId: number }) => - clientId === 7 ? PLUGIN_KEY : null - ) - registerRelayPluginHostCallHandlers( - { onRequest: (method, handler) => relayHandlers.set(method, handler) }, - resolveIdentity, - resolvePolicy - ) - const handler = relayHandlers.get(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD)! - - await expect( - handler( - { method: 'storage.get', params: { key: 'alpha' } }, - { clientId: 7, isStale: () => false } - ) - ).resolves.toMatchObject({ ok: true }) - expect(services.storage.get).toHaveBeenCalledWith(PLUGIN_KEY, 'alpha') - - await expect( - handler( - { method: 'storage.get', params: { key: 'alpha' } }, - { clientId: 8, isStale: () => false } - ) - ).resolves.toMatchObject({ ok: false, code: 'unavailable' }) - expect(resolvePolicy).toHaveBeenCalledTimes(1) - }) - - it('rejects malformed envelopes and client-supplied authority before policy resolution', async () => { - const requests = [ - { pluginKey: '../evil', method: 'storage.get', params: { key: 'alpha' } }, - { - pluginKey: PLUGIN_KEY, - method: 'storage.get', - params: { key: 'alpha' }, - grantedCapabilities: ['storage'] - }, - { - pluginKey: PLUGIN_KEY, - method: 'storage.get', - params: { key: 'alpha' }, - viaPanel: false - } - ] - for (const request of requests) { - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'])) - const outcome = await createAdapters(resolvePolicy)[adapterName]!(request, false) - expect(outcome).toMatchObject({ ok: false, code: 'invalid_request' }) - expect(resolvePolicy).not.toHaveBeenCalled() - } - } - }) -}) diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts index ed3f5e5619a..1f9ad453fd7 100644 --- a/src/main/providers/process-cwd.ts +++ b/src/main/providers/process-cwd.ts @@ -4,11 +4,6 @@ import { readlink } from 'node:fs/promises' /** * Resolve the current working directory of a local process by pid. * - * Why duplicated from `src/relay/pty-shell-utils.ts`: the relay and Electron - * main process have separate build graphs, and cross-importing across them - * is not a pattern used in this repo. The function is short and pure, and - * the duplication is cheaper than reshaping both bundle graphs. - * * Tries `/proc//cwd` on Linux, falls back to `lsof -d cwd` on macOS. * Returns `''` when neither works (including Windows, where `/proc` is * absent and `lsof` is not native). diff --git a/src/main/providers/ssh-review-draft-context.test.ts b/src/main/providers/ssh-review-draft-context.test.ts deleted file mode 100644 index de542c5c982..00000000000 --- a/src/main/providers/ssh-review-draft-context.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { ReviewDraftContextError } from '../../shared/review-draft-context-error' -import { RelayContext } from '../../relay/context' -import { RelayDispatcher } from '../../wsl-guest/dispatcher' -import { GitHandler } from '../../relay/git-handler' -import { createGitTempDir, removeGitTempDir } from '../../relay/git-handler-test-harness' -import { gitCommit, gitInit } from '../../relay/git-handler-test-setup' -import { - encodeJsonRpcFrame, - FrameDecoder, - MessageType, - parseJsonRpcMessage -} from '../../wsl-guest/protocol' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux } from './ssh-git-provider-test-harness' -import { execSshReviewDraft } from './ssh-review-draft-context' - -const input = { - base: 'main', - currentTitle: 'Existing review title', - currentBody: 'Existing review body', - currentDraft: true -} -const mergeBase = 'a'.repeat(40) -const disposals: (() => void)[] = [] -const directories: string[] = [] - -afterEach(async () => { - disposals.splice(0).forEach((dispose) => dispose()) - await Promise.all(directories.splice(0).map(removeGitTempDir)) - vi.restoreAllMocks() - vi.unstubAllEnvs() -}) - -function providerForMux(mux: ReturnType): SshGitProvider { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness implements every request, stream notification, disposal and notify method used by these provider calls. - return new SshGitProvider('review-host', mux as unknown as SshChannelMultiplexer) -} - -function createWireProvider() { - const mux = createMockMux() - const pending = new Map< - number, - { resolve: (value: unknown) => void; reject: (error: Error) => void } - >() - const notifications = new Map) => void>>() - const decoder = new FrameDecoder((frame) => { - if (frame.type !== MessageType.Regular) { - return - } - const message = parseJsonRpcMessage(frame.payload) - if ('method' in message) { - notifications.get(message.method)?.forEach((listener) => listener(message.params ?? {})) - return - } - const request = pending.get(message.id) - if (!request) { - return - } - pending.delete(message.id) - if (message.error) { - request.reject(Object.assign(new Error(message.error.message), { code: message.error.code })) - } else { - request.resolve(message.result) - } - }) - const dispatcher = new RelayDispatcher((frame) => decoder.feed(frame)) - const handler = new GitHandler(dispatcher, new RelayContext()) - let sequence = 0 - mux.request.mockImplementation((method: string, params: Record) => { - const id = ++sequence - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) - dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) - }) - }) - mux.notify.mockImplementation((method: string, params: Record) => { - dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, ++sequence, 0)) - }) - mux.onNotificationByMethod.mockImplementation( - (method: string, listener: (params: Record) => void) => { - const listeners = notifications.get(method) ?? new Set() - notifications.set(method, listeners) - listeners.add(listener) - return () => listeners.delete(listener) - } - ) - disposals.push(() => { - handler.dispose() - dispatcher.dispose() - pending.forEach((request) => request.reject(new Error('Test relay disposed'))) - pending.clear() - }) - return { provider: providerForMux(mux), mux, dispatcher, notifications } -} - -async function git(cwd: string, args: string[]) { - const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 10_000 }) - if (result.code !== 0) { - throw Object.assign(new Error(result.stderr), { code: result.code }) - } - return { stdout: result.stdout, stderr: result.stderr } -} - -async function createReviewFixture(large: boolean) { - const root = createGitTempDir() - directories.push(root) - const globalConfig = join(root, 'global.gitconfig') - await writeFile(globalConfig, '') - // Newer desktop Git preferences may be rejected while a baseline fixture loads config. - vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig) - vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') - const remote = join(root, 'remote repo') - const worktree = join(root, 'review worktree') - await mkdir(remote) - gitInit(remote) - await git(remote, ['checkout', '-b', 'main']) - await writeFile(join(remote, 'README.md'), 'Original content\n') - gitCommit(remote, 'Initial base') - await git(root, ['clone', remote, worktree]) - const originalBase = (await git(worktree, ['rev-parse', 'HEAD'])).stdout.trim() - await writeFile(join(remote, 'base-only.txt'), 'Advance the remote base after cloning\n') - gitCommit(remote, 'Advance base') - await git(worktree, ['checkout', '-b', 'feature/review']) - await writeFile(join(worktree, 'README.md'), 'Committed review evidence\n') - await writeFile( - join(worktree, 'new file.txt'), - large - ? 'Committed line with unicode café for a streamed review.\n'.repeat(36_000) - : 'New evidence\n' - ) - gitCommit(worktree, 'Add review evidence') - await writeFile(join(worktree, 'README.md'), 'Uncommitted content must stay out of the draft\n') - return { worktree, originalBase } -} - -describe('SSH review draft through the real relay dispatcher', () => { - it.each([false, true])('matches complete local context with streamed patch=%s', async (large) => { - const { worktree, originalBase } = await createReviewFixture(large) - const local = await getPullRequestDraftContext((args) => git(worktree, args), input) - expect(local).toMatchObject({ - branch: 'feature/review', - commitSummary: '- Add review evidence', - changeSummary: expect.stringContaining('new file.txt'), - patch: expect.stringContaining('+Committed review evidence') - }) - await git(worktree, ['update-ref', 'refs/remotes/origin/main', originalBase]) - const { provider, mux, notifications } = createWireProvider() - - const remote = await getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, worktree, options), - input - ) - - expect(remote).toEqual(local) - expect(remote?.patch).not.toContain('Uncommitted content') - expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', { - worktreePath: worktree, - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/main' - }) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.reviewDiff')).toHaveLength(2) - expect( - mux.request.mock.calls.some( - ([method, params]) => - method === 'git.exec' && (params.args[0] === 'fetch' || params.args[0] === 'diff') - ) - ).toBe(false) - expect([...notifications.values()].every((listeners) => listeners.size === 0)).toBe(true) - if (large) { - expect(mux.notify).toHaveBeenCalledWith('git.responseAck', expect.any(Object)) - } - }) -}) - -function createContextProvider() { - const mux = createMockMux() - const runRequest = async (method: string, params: Record) => { - if (method === 'git.fetchRemoteTrackingRef') { - return undefined - } - if (method === 'git.reviewDiff') { - return { stdout: 'remote evidence', stderr: '' } - } - const args = params.args - if (!Array.isArray(args)) { - throw new Error('Missing context Git arguments') - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'branch') { - return { stdout: 'feature/review\n', stderr: '' } - } - if (args[0] === 'merge-base') { - return { stdout: `${mergeBase}\n`, stderr: '' } - } - if (args[0] === 'log') { - return { stdout: '- committed evidence\n', stderr: '' } - } - throw new Error(`Unexpected method ${method}`) - } - mux.request.mockImplementation(runRequest) - return { mux, provider: providerForMux(mux), runRequest } -} - -describe('SSH review draft failures and routing', () => { - it('rejects an old-host missing diff method instead of returning commit-only context', async () => { - const { mux, provider, runRequest } = createContextProvider() - mux.request.mockImplementation((method, params) => { - if (method === 'git.reviewDiff') { - return Promise.reject(Object.assign(new Error('Method not found'), { code: -32601 })) - } - return runRequest(method, params) - }) - - await expect( - getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, '/repo', options), - input - ) - ).rejects.toThrow('Reconnect the SSH target') - }) - - it.each(['SSH connection closed', 'Git request timed out', 'git output exceeded maxBuffer.'])( - 'rejects incomplete context after %s', - async (message) => { - const { mux, provider, runRequest } = createContextProvider() - mux.request.mockImplementation((method, params) => { - if (method === 'git.reviewDiff') { - return Promise.reject(new Error(message)) - } - return runRequest(method, params) - }) - const draft = getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, '/repo', options), - input - ) - await expect(draft).rejects.toBeInstanceOf(ReviewDraftContextError) - await expect(draft).rejects.toThrow(message) - } - ) - - it('stops before reading evidence when an old host lacks the narrow fetch method', async () => { - const { mux, provider, runRequest } = createContextProvider() - mux.request.mockImplementation((method, params) => { - if (method === 'git.fetchRemoteTrackingRef') { - return Promise.reject( - Object.assign(new Error('Method not found: git.fetchRemoteTrackingRef'), { code: -32601 }) - ) - } - return runRequest(method, params) - }) - await expect( - getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, '/repo', options), - input - ) - ).rejects.toThrow('Fetch before generating PR details failed') - expect(mux.request.mock.calls.some(([method]) => method === 'git.reviewDiff')).toBe(false) - }) - - it.each([ - undefined, - null, - {}, - { stdout: 'patch' }, - { stdout: 3, stderr: '' }, - { stdout: '', stderr: null } - ])('refuses malformed remote evidence %j', async (reply) => { - const mux = createMockMux() - mux.request.mockResolvedValue(reply) - await expect(providerForMux(mux).readReviewDiff('/repo', mergeBase, 'patch')).rejects.toThrow( - ReviewDraftContextError - ) - }) - - it.each([{ timeout: 321 }, { timeoutMs: 654 }, { timeout: 321, timeoutMs: 654 }])( - 'forwards draft deadlines %j to the narrow diff request', - async (options) => { - const mux = createMockMux() - mux.request.mockResolvedValue({ stdout: 'patch', stderr: '' }) - await execSshReviewDraft( - providerForMux(mux), - ['diff', '--name-status', `${mergeBase}..HEAD`], - '/repo', - options - ) - expect(mux.request).toHaveBeenCalledWith( - 'git.reviewDiff', - { - worktreePath: '/repo', - mergeBase, - format: 'name-status', - __streamResponse: true - }, - { signal: undefined, timeoutMs: options.timeoutMs ?? options.timeout } - ) - } - ) - - it('keeps non-draft fetch and diff shapes on the restricted generic path', async () => { - const mux = createMockMux() - mux.request.mockResolvedValue({ stdout: '', stderr: '' }) - const provider = providerForMux(mux) - for (const args of [ - ['fetch', '--all'], - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/heads/overwrite'], - ['diff', '--name-status', '--no-ext-diff', `${mergeBase}..HEAD`], - ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', `${mergeBase}...HEAD`] - ]) { - await execSshReviewDraft(provider, args, '/repo', { timeoutMs: 456 }) - expect(mux.request).toHaveBeenLastCalledWith( - 'git.exec', - { - args, - cwd: '/repo', - __streamResponse: true - }, - { signal: undefined, timeoutMs: 456 } - ) - } - expect(mux.request.mock.calls.every(([method]) => method === 'git.exec')).toBe(true) - }) -}) diff --git a/src/main/runtime/runtime-file-path-existence.test.ts b/src/main/runtime/runtime-file-path-existence.test.ts deleted file mode 100644 index 24b12e7d5a6..00000000000 --- a/src/main/runtime/runtime-file-path-existence.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import './rpc/unused-default-rpc-methods.test-fixture' -import { afterEach, expect, it, vi } from 'vitest' -import { mkdtemp, writeFile, rm } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { RuntimeFileCommands } from './orca-runtime-files' -import { RpcDispatcher } from './rpc/dispatcher' -import { FILE_METHODS } from './rpc/methods/files' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { pathsExistOnRelay } from '../../relay/fs-path-existence' -import { statRelayPath } from '../../relay/fs-path-metadata-requests' -let root: string | undefined -const connection = 'batch-fixture-host' -afterEach(async () => { - unregisterSshFilesystemProvider(connection) - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = undefined -}) -async function setup(legacy = false) { - root = await mkdtemp(join(tmpdir(), 'orca-runtime-batch-')) - const names = Array.from({ length: 8 }, (_, i) => `file-${i}.ts`) - await Promise.all(names.map((name) => writeFile(join(root!, name), 'fixture'))) - const provider = { - pathsExist: legacy - ? undefined - : vi.fn((paths: string[]) => pathsExistOnRelay({ filePaths: paths })), - stat: vi.fn((filePath: string) => statRelayPath({ filePath })) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registered fixture implements the stat and optional batch operations exercised here. - registerSshFilesystemProvider(connection, provider as never) - const resolveTarget = vi.fn(async () => ({ - worktree: { id: 'folder-1', path: root, kind: 'folder', repoId: 'folder-repo' }, - executionHostId: `ssh:${connection}` - })) - const host = { - getRuntimeId: () => 'runtime-fixture', - requireStore: vi.fn(() => { - throw new Error('Local store should not be read') - }), - resolveRuntimeFileTarget: resolveTarget - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies runtime identity, target resolution and the guarded store accessor used by these reads. - const commands = new RuntimeFileCommands(host as never) - const runtime = { - getRuntimeId: host.getRuntimeId, - pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands), - statRuntimeFile: commands.statRuntimeFile.bind(commands) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatch is limited to the two file methods implemented by this fixture. - const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: FILE_METHODS }) - const dispatch = (relativePaths: string[]) => - dispatcher.dispatch({ - id: 'batch-1', - authToken: 'fixture', - method: 'files.pathsExist', - params: { worktree: 'id:folder-1', relativePaths } - }) - return { names, provider, resolveTarget, host, dispatch } -} -it('actual RPC dispatch resolves one folder owner and sends one provider batch for eight real files', async () => { - const f = await setup() - expect(await f.dispatch(f.names)).toMatchObject({ - ok: true, - result: f.names.map(() => ({ exists: true })) - }) - expect(f.resolveTarget).toHaveBeenCalledTimes(1) - expect(f.resolveTarget).toHaveBeenCalledWith('id:folder-1') - expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) - expect(f.provider.stat).not.toHaveBeenCalled() - expect(f.host.requireStore).not.toHaveBeenCalled() - expect(await f.dispatch(['../escape'])).toMatchObject({ ok: false }) - expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) -}) -it('legacy provider preserves all answers through scoped scalar stats', async () => { - const f = await setup(true) - expect(await f.dispatch([...f.names, 'missing'])).toMatchObject({ - ok: true, - result: [...f.names.map(() => ({ exists: true })), { exists: false }] - }) - expect(f.provider.stat).toHaveBeenCalledTimes(9) - expect(f.host.requireStore).not.toHaveBeenCalled() -}) -it('unavailable SSH never falls back to matching local files; oversized input never reaches provider', async () => { - const f = await setup() - unregisterSshFilesystemProvider(connection) - expect(await f.dispatch(f.names)).toMatchObject({ - ok: false, - error: { message: expect.stringContaining('Remote connection dropped') } - }) - expect(f.host.requireStore).not.toHaveBeenCalled() - expect(await f.dispatch(Array(129).fill('file-0.ts'))).toMatchObject({ ok: false }) - expect(f.provider.pathsExist).not.toHaveBeenCalled() -}) diff --git a/src/main/runtime/runtime-repository-ref-queries.test.ts b/src/main/runtime/runtime-repository-ref-queries.test.ts index 79369ec30cb..9b64b1c3c93 100644 --- a/src/main/runtime/runtime-repository-ref-queries.test.ts +++ b/src/main/runtime/runtime-repository-ref-queries.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' -import { validateGitExecArgs } from '../../relay/git-exec-validator' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { RuntimeRepositoryRefQueries } from './runtime-repository-ref-queries' @@ -17,42 +16,6 @@ const repo: Repo = { } describe('qualified refs in repository searches', () => { - it.each(['feature', 'origin/feature'])( - 'fills legacy pages before the limit for query %s', - async (query) => { - const exec = vi.fn(async (argv: string[]) => { - validateGitExecArgs(argv) - return { - stdout: - argv[0] === 'remote' - ? 'origin\n' - : [ - 'refs/heads/feature/加\0feature/�', - 'refs/remotes/origin/feature/加\0origin/feature/�', - 'refs/remotes/origin/feature/one\0origin/feature/one', - 'refs/remotes/origin/feature/two\0origin/feature/two', - 'refs/remotes/origin/feature/three\0origin/feature/three' - ].join('\n'), - stderr: '' - } - }) - getProvider.mockReturnValue({ exec }) - const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => repo }) - expect(await queries.search('id:remote-repo', query, 2, false)).toEqual({ - refs: ['origin/feature/one', 'origin/feature/two'], - refDetails: [ - { refName: 'origin/feature/one', localBranchName: 'feature/one' }, - { refName: 'origin/feature/two', localBranchName: 'feature/two' } - ], - truncated: true - }) - expect(await queries.search('id:remote-repo', query, 2)).toMatchObject({ - refs: ['refs/heads/feature/加', 'refs/remotes/origin/feature/加'], - truncated: true - }) - expect(exec).toHaveBeenCalledWith(expect.arrayContaining(['--count=12']), '/repo') - } - ) it('keeps folder workspaces outside Git searches', async () => { vi.mocked(getSshGitProvider).mockClear() diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 562ab23ee3e..ea7241c68eb 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -5,7 +5,6 @@ import type { OrcaRuntimeService } from './orca-runtime' import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness' import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' import { TERMINAL_LIFECYCLE_METHODS } from './rpc/methods/terminal/terminal-lifecycle-methods' -import { getForegroundProcessName } from '../../relay/pty-shell-utils' // #6011 end-to-end: a REAL pty running a REAL process that emits a REAL name-only // OSC title while streaming must not satisfy `orca terminal wait --for tui-idle`. @@ -41,11 +40,10 @@ async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: numbe }) running.push(child) - // Real foreground read against the real pty: the same helper the relay serves - // `pty.getForegroundProcess` with, so corroboration is host-produced here too. + // Real foreground read against the real pty, so corroboration is host-produced here too. const runtime = makeTuiIdleRuntime({ repoPath: '/tmp/tui-idle-real-pty', - getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null) + getForegroundProcess: async () => child.process || null }) runtime.attachWindow(1) const graph: RuntimeSyncWindowGraph = { diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index edc24c7a44a..b3f0757b762 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -17,7 +17,6 @@ import { getShellLaunchConfig as getDaemonShellLaunchConfig, getShellReadyWrapperRoot as getDaemonShellReadyWrapperRoot } from './daemon/shell-ready' -import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' import { getShellLaunchConfig as getLocalShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' @@ -142,11 +141,6 @@ describePosix('generated shell wrapper files', () => { await expectWrapperFiles('daemon', getDaemonShellReadyWrapperRoot()) }) - it('relay overlay wrappers', async () => { - ensureOverlayRestoreWrappers(root) - await expectWrapperFiles('relay', root) - }) - // Why a rule and not another fixture: `REPLY`, zsh's shared scratch global, // was the wrapper's resolver out-parameter. A user config that constrained it // (`typeset -r REPLY`) aborted the wrapper at its first executable line — on @@ -161,8 +155,7 @@ describePosix('generated shell wrapper files', () => { getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES) }, (): string => getDaemonShellReadyWrapperRoot() - ], - ['relay', (): void => void ensureOverlayRestoreWrappers(root), (): string => root] + ] ])('%s wrappers write no shell global outside Orca’s namespace', (_transport, generate, dir) => { generate() diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 5c5020c96bc..229063d058c 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -22,7 +22,6 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:f import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' @@ -378,44 +377,6 @@ describe.skipIf(process.platform === 'win32')( * why it drifted from the desktop template; now the only differences are the * spec flags, and this pins that the variant still works in a real shell. */ -describe.skipIf(process.platform === 'win32')('the relay variant of the hook', () => { - itWithZsh( - 'scopes history and restores the remote CLI path without emitting OSC 133', - withHome(USER_FILES, async (home) => { - const relayRoot = mkdtempSync(join(tmpdir(), 'orca-relay-wrapper-')) - try { - expect(ensureOverlayRestoreWrappers(relayRoot)).toBe(true) - const scoped = join(home, 'orca-history', 'zsh_history') - - const { output, values } = await runZshPty({ - env: { - PATH: '/usr/bin:/bin', - HOME: home, - ZDOTDIR: join(relayRoot, 'zsh'), - ORCA_ORIG_ZDOTDIR: home, - ORCA_SHELL_FEATURES: 'overlay,history,ready', - ORCA_HISTFILE: scoped, - ORCA_REMOTE_CLI_BIN_DIR: '/orca/remote-bin' - }, - commands: ['true'], - report: ['HISTFILE', 'ZDOTDIR', 'PATH', 'ORCA_HISTFILE'] - }) - - expect(values.HISTFILE).toBe(scoped) - expect(values.ZDOTDIR).toBe(home) - expect(values.ORCA_HISTFILE).toBe('UNSET') - expect(values.PATH.startsWith('/orca/remote-bin:')).toBe(true) - expect(output).toContain(MARKERS.ready) - // Remote panes get their command lifecycle from the bash rcfile, so the - // zsh variant must stay silent here. - expect(output).not.toContain(MARKERS.promptStart) - expect(output).not.toContain(MARKERS.commandStart) - } finally { - rmSync(relayRoot, { recursive: true, force: true }) - } - }) - ) -}) describe.skipIf(process.platform === 'win32')('the real-zsh harness', () => { itWithZsh( diff --git a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts deleted file mode 100644 index 796dc290b37..00000000000 --- a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Real-zsh proof that a wrapper dir holding files from two Orca builds still - * loads the user's own zsh config. - * - * A shared dir used to mean a shell could read one build's `.zshenv` and - * another's `.zprofile`/`.zshrc`/`.zlogin`, which is why every generated file - * redefined the helpers it called. #15285 removed the hazard for the desktop and - * daemon trees by naming each one after a hash of its contents, so two builds - * never write the same directory. - * - * The relay is the one writer left on a fixed path — `~/.orca-relay/shell-ready` - * — so this is where the scenario is still reachable, and it is now much smaller: - * Orca writes one file, and that file hands ZDOTDIR back before anything else - * runs. Both halves are pinned here: - * - * 1. Files an older build left beside the hook are inert — zsh reads .zprofile, - * .zshrc and .zlogin from the user's own directory and never from here. - * 2. Generation does not delete them. An older build checks for all four before - * calling its tree complete; deleting them makes it rewrite its own `.zshenv` - * over ours, and an older `.zshenv` points ZDOTDIR at a directory that would - * then hold no `.zshrc` at all — the user losing their entire config, which is - * the failure this file exists to prevent. - */ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' -import { hasZsh, makeZshHome, runZshPty } from './zsh-startup-hook-pty-harness' - -const itWithZsh = hasZsh ? it : it.skip - -/** The three files an older build wrote alongside its own `.zshenv`. */ -const OLDER_BUILD_FILES = { - '.zprofile': 'export ORCA_TEST_STALE_ZPROFILE=1\n', - '.zshrc': 'export ORCA_TEST_STALE_ZSHRC=1\n', - '.zlogin': 'export ORCA_TEST_STALE_ZLOGIN=1\n' -} - -describe.skipIf(process.platform === 'win32')('zsh wrapper dir written by mixed builds', () => { - itWithZsh('ignores an older build’s files and loads the user’s config instead', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-')) - const home = makeZshHome({ - '.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n', - '.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n', - '.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n' - }) - try { - expect(ensureOverlayRestoreWrappers(root)).toBe(true) - const zshDir = join(root, 'zsh') - for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { - writeFileSync(join(zshDir, name), content) - } - - const { values } = await runZshPty({ - env: { - PATH: '/usr/bin:/bin', - HOME: home, - ZDOTDIR: zshDir, - ORCA_ORIG_ZDOTDIR: home, - ORCA_SHELL_FEATURES: 'history', - ORCA_HISTFILE: join(home, 'scoped_history') - }, - report: [ - 'ORCA_TEST_USER_ZPROFILE', - 'ORCA_TEST_USER_ZSHRC', - 'ORCA_TEST_STALE_ZPROFILE', - 'ORCA_TEST_STALE_ZSHRC', - 'ORCA_TEST_STALE_ZLOGIN', - 'HISTFILE' - ] - }) - - // The user's own files loaded; the older build's leftovers did not. - expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') - expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') - expect(values.ORCA_TEST_STALE_ZPROFILE).toBe('UNSET') - expect(values.ORCA_TEST_STALE_ZSHRC).toBe('UNSET') - expect(values.ORCA_TEST_STALE_ZLOGIN).toBe('UNSET') - expect(values.HISTFILE).toBe(join(home, 'scoped_history')) - } finally { - rmSync(root, { recursive: true, force: true }) - rmSync(home, { recursive: true, force: true }) - } - }) - - it('leaves an older build’s files in place so that build can still use them', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-keep-')) - try { - expect(ensureOverlayRestoreWrappers(root)).toBe(true) - const zshDir = join(root, 'zsh') - for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { - writeFileSync(join(zshDir, name), content) - } - - // A second generation pass is what an older build's launch would race. - expect(ensureOverlayRestoreWrappers(root)).toBe(true) - - for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { - expect(existsSync(join(zshDir, name))).toBe(true) - expect(readFileSync(join(zshDir, name), 'utf8')).toBe(content) - } - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) -}) diff --git a/src/relay/agent-exec-disposal.test.ts b/src/relay/agent-exec-disposal.test.ts deleted file mode 100644 index 46a6ff2fca1..00000000000 --- a/src/relay/agent-exec-disposal.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { execFile } from 'node:child_process' -import type * as ChildProcess from 'node:child_process' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { AgentExecHandler } from './agent-exec-handler' -import { - RELAY_AGENT_CLOSE_DEADLINE_MS, - RelayAgentProcessLifetime -} from './relay-agent-process-lifetime' -import { createFakeChild, requestContext } from './agent-exec-handler-test-harness' -import type { MethodHandler, RelayDispatcher } from '../wsl-guest/dispatcher' - -// Why an untyped mock: the fake child stubs only what AgentExecHandler reads from a ChildProcess. -const { spawnMock, loginShellMock } = vi.hoisted(() => ({ - spawnMock: vi.fn(), - loginShellMock: vi.fn() -})) -vi.mock('../main/startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: () => loginShellMock() -})) -vi.mock('child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: (...args: unknown[]) => spawnMock(...args), - execFile: vi.fn() -})) - -function fixture() { - const methods = new Map() - const handler = new AgentExecHandler( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only registers request methods. - { - onRequest: (name: string, method: MethodHandler) => methods.set(name, method) - } as unknown as RelayDispatcher - ) - const exec = (params: Record = {}) => - methods.get('agent.execNonInteractive')!( - { binary: 'agent', timeoutMs: 1000, ...params }, - requestContext() - ) - return { handler, exec } -} - -beforeEach(() => { - vi.useFakeTimers() - spawnMock.mockReset() - vi.mocked(execFile).mockReset() -}) -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -it('waits for every child close, including commands without a cwd and replaced lanes', async () => { - const f = fixture() - const children = [createFakeChild(), createFakeChild(), createFakeChild()] - for (const child of children) { - spawnMock.mockReturnValueOnce(child) - } - const requests = [f.exec(), f.exec({ cwd: '/repo' }), f.exec({ cwd: '/repo' })] - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - if (process.platform === 'win32') { - expect(execFile).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - } else { - for (const child of children) { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - } - children[0].emit('close', null) - children[2].emit('close', null) - await Promise.resolve() - expect(finished).toBe(false) - children[1].emit('close', null) - await disposal - expect(finished).toBe(true) - await Promise.all(requests) -}) - -it('refuses execution once disposal begins and after it completes', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - const disposal = f.handler.dispose() - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) - child.emit('close', null) - await Promise.all([request, disposal]) - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) -}) - -it('does not spawn when shutdown fences during login-shell resolution, and admits after reopen', async () => { - const f = fixture() - let resolveShell!: (env: NodeJS.ProcessEnv) => void - loginShellMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveShell = resolve - }) - ) - const request = f.exec({ shell: true }) - await f.handler.dispose() - resolveShell({}) - await expect(request).rejects.toThrow('relay_agent_execution_shutdown_fenced') - expect(spawnMock).not.toHaveBeenCalled() - - f.handler.reopen() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const next = f.exec() - child.emit('close', 0) - await next - expect(spawnMock).toHaveBeenCalledOnce() -}) - -it('kills a child tracked after the fence instead of throwing', async () => { - const lifetime = new RelayAgentProcessLifetime() - void lifetime.dispose() - const child = createFakeChild() - expect(() => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: track reads only pid, kill and the close/error events the fake emits. - lifetime.track(child as unknown as Parameters[0]) - ).not.toThrow() - if (process.platform !== 'win32') { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - const disposal = lifetime.dispose() - child.emit('close', null) - await disposal -}) - -it('retains timed-out children until close rather than treating RPC settlement as exit', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ cwd: '/repo' }) - await vi.advanceTimersByTimeAsync(1000) - await expect(request).resolves.toMatchObject({ timedOut: true }) - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('error', new Error('late child error')) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('close', null) - await disposal - expect(finished).toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) -}) - -it('resolves disposal with no children or only already-closed children', async () => { - await fixture().handler.dispose() - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - child.emit('close', 0) - await request - await f.handler.dispose() - expect(child.kill).not.toHaveBeenCalled() -}) - -it('rejects within the close deadline and re-checks without re-killing on retry', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ timeoutMs: 60_000 }) - const signals = () => - process.platform === 'win32' - ? vi.mocked(execFile).mock.calls.length - : child.kill.mock.calls.length - const first = f.handler.dispose() - const firstOutcome = first.then( - () => 'resolved', - (error: Error) => error.message - ) - expect(signals()).toBe(1) - await vi.advanceTimersByTimeAsync(RELAY_AGENT_CLOSE_DEADLINE_MS) - await expect(firstOutcome).resolves.toBe('relay_agent_execution_shutdown_incomplete') - const retry = f.handler.dispose() - expect(signals()).toBe(1) - child.emit('close', null) - await expect(retry).resolves.toBeUndefined() - await request -}) diff --git a/src/relay/agent-exec-handler-test-harness.ts b/src/relay/agent-exec-handler-test-harness.ts deleted file mode 100644 index 1ee67a33015..00000000000 --- a/src/relay/agent-exec-handler-test-harness.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { EventEmitter } from 'node:events' -import { vi } from 'vitest' -import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' -import { AgentExecHandler } from './agent-exec-handler' - -export function withPlatform(platform: NodeJS.Platform, fn: () => T): T { - const original = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - try { - return fn() - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: original }) - } -} - -export type FakeChild = EventEmitter & { - pid: number - kill: ReturnType - stdout: EventEmitter - stderr: EventEmitter - stdin: { end: ReturnType } -} - -export function createFakeChild(): FakeChild { - return Object.assign(new EventEmitter(), { - pid: 12345, - kill: vi.fn(), - stdout: new EventEmitter(), - stderr: new EventEmitter(), - stdin: { end: vi.fn() } - }) -} - -export function createHandlers(): Map { - const handlers = new Map() - new AgentExecHandler({ - onRequest: (method: string, handler: MethodHandler): void => { - handlers.set(method, handler) - } - } as never) - return handlers -} - -export function requestContext(clientId = 1): RequestContext { - return { clientId, isStale: () => false } -} diff --git a/src/relay/agent-exec-handler-windows.test.ts b/src/relay/agent-exec-handler-windows.test.ts deleted file mode 100644 index 361b3e80244..00000000000 --- a/src/relay/agent-exec-handler-windows.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { exec, spawn } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ChildProcess from 'node:child_process' -import { - createFakeChild, - createHandlers, - requestContext, - withPlatform -} from './agent-exec-handler-test-harness' - -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - exec: vi.fn(), - spawn: vi.fn() - } -}) - -const spawnMock = vi.mocked(spawn) -const execMock = vi.mocked(exec) - -describe('AgentExecHandler Windows command spawning', () => { - beforeEach(() => { - spawnMock.mockReset() - execMock.mockReset() - }) - - it('resolves bare Windows agent commands to batch shims before spawning', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'orca-agent-exec-')) - const originalComSpec = process.env.ComSpec - process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' - try { - await withPlatform('win32', async () => { - const codexShim = join(tempDir, 'codex.cmd') - writeFileSync(codexShim, '@echo off\r\n') - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'codex', - args: ['exec', '-s', 'read-only'], - cwd: 'C:\\repo', - stdin: 'PROMPT', - timeoutMs: 5_000, - env: { PATH: tempDir } - }, - requestContext() - ) - - child.emit('close', 0) - - await expect(pending).resolves.toMatchObject({ - exitCode: 0, - timedOut: false - }) - expect(spawnMock).toHaveBeenCalledWith( - 'C:\\Windows\\System32\\cmd.exe', - ['/d', '/s', '/c', `"${codexShim}" "exec" "-s" "read-only"`], - { - cwd: 'C:\\repo', - env: expect.objectContaining({ PATH: tempDir }), - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - } - ) - }) - } finally { - rmSync(tempDir, { recursive: true, force: true }) - if (originalComSpec === undefined) { - delete process.env.ComSpec - } else { - process.env.ComSpec = originalComSpec - } - } - }) - - it('rejects unsafe args when routing Windows batch shims through cmd.exe', async () => { - await withPlatform('win32', async () => { - const handlers = createHandlers() - - const result = await handlers.get('agent.execNonInteractive')!( - { - binary: 'C:\\tools\\agent.cmd', - args: ['hello & goodbye'], - cwd: 'C:\\repo', - stdin: null, - timeoutMs: 5_000 - }, - requestContext() - ) - - expect(result).toEqual({ - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - }) - expect(spawnMock).not.toHaveBeenCalled() - }) - }) -}) diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts deleted file mode 100644 index 1cb2820b1ec..00000000000 --- a/src/relay/agent-exec-handler.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { execFile, spawn } from 'node:child_process' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ChildProcess from 'node:child_process' -import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' -import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' - -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - execFile: vi.fn(), - spawn: vi.fn() - } -}) - -const spawnMock = vi.mocked(spawn) -const execFileMock = vi.mocked(execFile) - -type AgentExecResult = { exitCode: number | null; timedOut: boolean } - -const GUARD_OWNED_ENV_RE = /^(?:GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)|WSLENV)$/ - -describe('AgentExecHandler', () => { - let ambientGuardEnv: Record = {} - - beforeEach(() => { - spawnMock.mockReset() - execFileMock.mockReset() - // Why: the guard rewrites these, so an already-guarded runner (Orca guards - // its own agent terminals) would not see its ambient values passed through. - ambientGuardEnv = {} - for (const key of Object.keys(process.env).filter((name) => GUARD_OWNED_ENV_RE.test(name))) { - ambientGuardEnv[key] = process.env[key] - delete process.env[key] - } - }) - - afterEach(() => { - vi.unstubAllEnvs() - for (const [key, value] of Object.entries(ambientGuardEnv)) { - if (value !== undefined) { - process.env[key] = value - } - } - }) - - it('executes a non-interactive command with captured output and stdin', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: ['--flag', 42], - cwd: '/repo', - stdin: 'PROMPT', - timeoutMs: 5_000 - }, - requestContext() - ) - - child.stdout.emit('data', Buffer.from('message')) - child.stderr.emit('data', Buffer.from('warning')) - child.emit('close', 0) - - await expect(pending).resolves.toEqual({ - stdout: 'message', - stderr: 'warning', - exitCode: 0, - timedOut: false, - canceled: false - }) - expect(spawnMock).toHaveBeenCalledWith('agent', ['--flag', '42'], { - cwd: '/repo', - env: expect.objectContaining({ - ...process.env, - GIT_TERMINAL_PROMPT: '0', - GCM_INTERACTIVE: 'never' - }), - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - expect(child.stdin.end).toHaveBeenCalledWith('PROMPT') - }) - - it('merges caller-supplied provider environment into the spawned command environment', async () => { - vi.stubEnv('ORCA_EXEC_INHERITED', 'inherited-value') - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'codex', - args: ['exec'], - cwd: '/repo', - stdin: 'PROMPT', - timeoutMs: 5_000, - env: { - CODEX_HOME: '/managed/codex-home', - PATH: '/managed/bin' - } - }, - requestContext() - ) - - child.emit('close', 0) - - await expect(pending).resolves.toMatchObject({ - exitCode: 0, - timedOut: false - }) - expect(spawnMock).toHaveBeenCalledWith('codex', ['exec'], { - cwd: '/repo', - env: expect.objectContaining({ - ORCA_EXEC_INHERITED: 'inherited-value', - CODEX_HOME: '/managed/codex-home', - PATH: '/managed/bin' - }), - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - }) - - it('honors Windows override casing while replacing indexed Git configuration', async () => { - const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every child event and stream used by the handler. - spawnMock.mockReturnValue(child as never) - const originalPlatform = process.platform - const originalPath = process.env.PATH - process.env.PATH = 'inherited-path' - process.env.GIT_CONFIG_COUNT = '2' - process.env.GIT_CONFIG_KEY_1 = 'stale.key' - process.env.GIT_CONFIG_VALUE_1 = 'stale-value' - try { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent.exe', - args: [], - env: { - Path: 'override-path', - token: 'override-token', - git_config_count: '1', - git_config_key_0: 'http.proxy', - git_config_value_0: 'proxy-value' - } - }, - requestContext() - ) - child.emit('close', 0) - await expect(pending).resolves.toMatchObject({ exitCode: 0 }) - const env = spawnMock.mock.calls[0][2]?.env - expect(env).toMatchObject({ - PATH: 'override-path', - TOKEN: 'override-token', - GIT_CONFIG_KEY_0: 'http.proxy', - GIT_CONFIG_VALUE_0: 'proxy-value' - }) - expect(env).not.toHaveProperty('Path') - expect(env).not.toHaveProperty('git_config_count') - expect(Object.values(env ?? {})).not.toContain('stale.key') - expect(Object.values(env ?? {})).not.toContain('stale-value') - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - if (originalPath === undefined) { - delete process.env.PATH - } else { - process.env.PATH = originalPath - } - delete process.env.GIT_CONFIG_COUNT - delete process.env.GIT_CONFIG_KEY_1 - delete process.env.GIT_CONFIG_VALUE_1 - } - }) - - it('consumes an unattended marker and applies the full Git guard on the relay host', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: '/bin/bash', - args: ['-lc', 'git fetch'], - cwd: '/repo', - timeoutMs: 5_000, - env: { [TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard' } - }, - requestContext() - ) - - child.emit('close', 0) - await expect(pending).resolves.toMatchObject({ exitCode: 0 }) - - const env = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBeUndefined() - expect(env.GIT_TERMINAL_PROMPT).toBe('0') - expect(env.GCM_INTERACTIVE).toBe('never') - expect(Object.values(env)).toContain('credential.interactive') - expect(Object.values(env)).toContain('credential.guiPrompt') - }) - - it('guards wrapped agents after atomically replacing inherited indexed config', async () => { - const keys = [ - 'GIT_CONFIG_COUNT', - 'GIT_CONFIG_KEY_0', - 'GIT_CONFIG_VALUE_0', - 'GIT_CONFIG_KEY_1', - 'GIT_CONFIG_VALUE_1' - ] as const - const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) - process.env.GIT_CONFIG_COUNT = '2' - process.env.GIT_CONFIG_KEY_0 = 'base.one' - process.env.GIT_CONFIG_VALUE_0 = 'one' - process.env.GIT_CONFIG_KEY_1 = 'base.two' - process.env.GIT_CONFIG_VALUE_1 = 'two' - - try { - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'npx', - args: ['codex', 'exec'], - cwd: '/repo', - timeoutMs: 5_000, - env: { - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'http.proxy', - GIT_CONFIG_VALUE_0: 'http://proxy.invalid' - } - }, - requestContext() - ) - - child.emit('close', 0) - await expect(pending).resolves.toMatchObject({ exitCode: 0 }) - const env = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(env.GIT_TERMINAL_PROMPT).toBe('0') - expect(env.GIT_CONFIG_COUNT).toBe('3') - expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy') - expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive') - expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt') - expect(Object.values(env)).not.toContain('base.two') - } finally { - for (const key of keys) { - if (saved[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = saved[key] - } - } - } - }) - - it('cancels the in-flight command for the requested cwd', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: [], - cwd: '/repo', - stdin: null, - timeoutMs: 5_000 - }, - requestContext() - ) - - await expect( - handlers.get('agent.cancelExec')!({ cwd: '/repo' }, requestContext()) - ).resolves.toEqual({ canceled: true }) - - if (process.platform === 'win32') { - expect(execFileMock).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - } else { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - - child.emit('close', null) - await expect(pending).resolves.toMatchObject({ - exitCode: null, - timedOut: false, - canceled: true - }) - }) - - it('cancels only the matching operation lane for a cwd', async () => { - const commitChild = createFakeChild() - const pullRequestChild = createFakeChild() - pullRequestChild.pid = 12346 - spawnMock - .mockReturnValueOnce(commitChild as never) - .mockReturnValueOnce(pullRequestChild as never) - const handlers = createHandlers() - - const commit = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: [], - cwd: '/repo', - stdin: null, - timeoutMs: 5_000, - operation: 'commit-message' - }, - requestContext() - ) - const pullRequest = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: [], - cwd: '/repo', - stdin: null, - timeoutMs: 5_000, - operation: 'pull-request-fields' - }, - requestContext() - ) - - await expect( - handlers.get('agent.cancelExec')!( - { cwd: '/repo', operation: 'commit-message' }, - requestContext() - ) - ).resolves.toEqual({ canceled: true }) - - if (process.platform === 'win32') { - expect(execFileMock).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - expect(execFileMock).not.toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12346', '/T', '/F'], - expect.any(Function) - ) - } else { - expect(commitChild.kill).toHaveBeenCalledWith('SIGKILL') - expect(pullRequestChild.kill).not.toHaveBeenCalled() - } - - commitChild.emit('close', null) - pullRequestChild.stdout.emit( - 'data', - Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}') - ) - pullRequestChild.emit('close', 0) - - await expect(commit).resolves.toMatchObject({ - exitCode: null, - timedOut: false, - canceled: true - }) - await expect(pullRequest).resolves.toMatchObject({ - exitCode: 0, - timedOut: false, - canceled: false - }) - }) - - it('kills the active command when the request aborts', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - const controller = new AbortController() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: [], - cwd: '/repo', - stdin: null, - timeoutMs: 5_000 - }, - { clientId: 1, isStale: () => controller.signal.aborted, signal: controller.signal } - ) - - controller.abort() - - if (process.platform === 'win32') { - expect(execFileMock).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - } else { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - - child.emit('close', null) - await expect(pending).resolves.toMatchObject({ - exitCode: null, - timedOut: false, - canceled: true - }) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - }) - - it('cancels a superseded command in the same operation lane', async () => { - const firstChild = createFakeChild() - const secondChild = createFakeChild() - secondChild.pid = 12346 - spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never) - const handlers = createHandlers() - - const first = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: ['first'], - cwd: '/repo', - stdin: null, - timeoutMs: 5_000, - operation: 'commit-message' - }, - requestContext() - ) - const second = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: ['second'], - cwd: '/repo', - stdin: null, - timeoutMs: 5_000, - operation: 'commit-message' - }, - requestContext() - ) - - if (process.platform === 'win32') { - expect(execFileMock).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - expect(execFileMock).not.toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12346', '/T', '/F'], - expect.any(Function) - ) - } else { - expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL') - expect(secondChild.kill).not.toHaveBeenCalled() - } - - await expect( - handlers.get('agent.cancelExec')!( - { cwd: '/repo', operation: 'commit-message' }, - requestContext() - ) - ).resolves.toEqual({ canceled: true }) - - if (process.platform === 'win32') { - expect(execFileMock).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12346', '/T', '/F'], - expect.any(Function) - ) - } else { - expect(secondChild.kill).toHaveBeenCalledWith('SIGKILL') - } - - firstChild.emit('close', null) - secondChild.emit('close', null) - await expect(first).resolves.toMatchObject({ canceled: true }) - await expect(second).resolves.toMatchObject({ canceled: true }) - }) - - it('reports when cancellation has no matching in-flight command', async () => { - const handlers = createHandlers() - - await expect( - handlers.get('agent.cancelExec')!({ cwd: '/repo' }, requestContext()) - ).resolves.toEqual({ canceled: false }) - }) - - it('settles timed-out commands even when the killed child does not close', async () => { - vi.useFakeTimers() - try { - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'agent', - args: [], - cwd: '/repo', - stdin: null, - timeoutMs: 5_000 - }, - requestContext() - ) as Promise - const outcomePromise = pending.then((result) => - result.timedOut ? `timed-out:${result.exitCode}` : 'not-timed-out' - ) - - await vi.advanceTimersByTimeAsync(5_000) - const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - - expect(outcome).toBe('timed-out:null') - if (process.platform === 'win32') { - expect(execFileMock).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - } else { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - // Shutdown keeps tracking the child until it physically closes. - expect(child.listenerCount('error')).toBe(1) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', null) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts deleted file mode 100644 index 009a2cd6ddd..00000000000 --- a/src/relay/agent-exec-handler.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { mergeCommandEnvironment } from '../shared/command-environment' -import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import { spawn, type ChildProcess } from 'node:child_process' -import { existsSync } from 'node:fs' -import { delimiter, join } from 'node:path' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' -import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' -import { terminateRelaySubprocessTree } from './subprocess-tree-termination' -import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime' -import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment' -import { errorMessage } from '../shared/error-message' - -const DEFAULT_TIMEOUT_MS = 60_000 -const MAX_TIMEOUT_MS = 5 * 60 * 1000 -const MAX_OUTPUT_BYTES = 4 * 1024 * 1024 -const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - -function getCmdExePath(): string { - return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe` -} - -function isWindowsBatchScript(commandPath: string): boolean { - return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) -} - -function hasUnsafeWindowsBatchSyntax(value: string): boolean { - return /[&|<>^"%!\r\n]/.test(value) -} - -function quoteWindowsBatchToken(value: string): string { - if (hasUnsafeWindowsBatchSyntax(value)) { - throw new Error(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) - } - return `"${value}"` -} - -function resolveWindowsCommand(binary: string, env: NodeJS.ProcessEnv): string { - if (process.platform !== 'win32') { - return binary - } - if (/[\\/]/.test(binary) || /\.[a-z0-9]+$/i.test(binary)) { - return binary - } - - const pathEnv = env.PATH ?? env.Path - if (!pathEnv) { - return binary - } - const names = [`${binary}.cmd`, `${binary}.exe`, `${binary}.bat`, binary] - for (const directory of pathEnv.split(delimiter).filter(Boolean)) { - for (const name of names) { - const candidate = join(directory, name) - if (existsSync(candidate)) { - return candidate - } - } - } - return binary -} - -function getWindowsSafeSpawn( - binary: string, - args: string[], - env: NodeJS.ProcessEnv -): { spawnCmd: string; spawnArgs: string[] } { - const resolvedBinary = resolveWindowsCommand(binary, env) - if (!isWindowsBatchScript(resolvedBinary)) { - return { spawnCmd: resolvedBinary, spawnArgs: args } - } - const commandLine = [resolvedBinary, ...args].map(quoteWindowsBatchToken).join(' ') - return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] } -} - -type ExecParams = { - binary: unknown - args: unknown - cwd: unknown - stdin: unknown - timeoutMs: unknown - env: unknown - operation: unknown - shell: unknown -} - -type CancelParams = { - cwd: unknown - operation: unknown -} - -function laneKeyFor(cwd: string, operation: unknown): string { - const op = typeof operation === 'string' && operation ? operation : 'default' - return JSON.stringify([op, cwd]) -} - -type InFlightExec = { child?: ChildProcess; cancel: () => void } - -type ExecResult = { - stdout: string - stderr: string - exitCode: number | null - timedOut: boolean - /** Set when the user canceled the exec via `agent.cancelExec`. */ - canceled?: boolean - /** Set when the binary could not be spawned (e.g. ENOENT). */ - spawnError?: string -} - -/** - * Non-interactive subprocess exec on the remote host. Used by the AI commit - * message generator to spawn agent CLIs (claude, codex, …) with the staged - * diff piped via stdin and the output captured to stdout. Distinct from - * `pty.spawn` because we want no terminal allocation, no escape sequences, - * and a clean exit code instead of an interactive session. - */ -export class AgentExecHandler { - private readonly processLifetime = new RelayAgentProcessLifetime() - // Why: commit-message and PR-field generation can run together for one cwd; - // operation lanes let cancel target only the user-visible job that stopped. - private inFlightByLane = new Map() - - constructor(dispatcher: RelayDispatcher) { - dispatcher.onRequest('agent.execNonInteractive', (p, context) => - this.exec(p as ExecParams, context) - ) - dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams)) - } - - dispose(): Promise { - return this.processLifetime.dispose() - } - - reopen = (): void => this.processLifetime.reopen() - - private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { - const cwd = typeof params.cwd === 'string' ? params.cwd : '' - const entry = this.inFlightByLane.get(laneKeyFor(cwd, params.operation)) - if (!entry) { - return { canceled: false } - } - entry.cancel() - return { canceled: true } - } - - private async exec(params: ExecParams, context?: RequestContext): Promise { - this.processLifetime.assertAdmission() - const binary = typeof params.binary === 'string' ? params.binary : '' - if (!binary) { - throw new Error('agent.execNonInteractive: binary is required') - } - const args = Array.isArray(params.args) ? params.args.map((a) => String(a)) : [] - const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : undefined - const stdinPayload = typeof params.stdin === 'string' ? params.stdin : null - const requestedTimeout = - typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_TIMEOUT_MS - const deadline = Date.now() + Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout)) - const extraEnv = - params.env && typeof params.env === 'object' && !Array.isArray(params.env) - ? (params.env as Record) - : null - let hostEnv = process.env - if (params.shell === true) { - const timeoutError = new Error('Profile resolution exceeded the request deadline') - const controller = new AbortController() - const key = laneKeyFor(cwd ?? '', params.operation) - const pending = { cancel: (): void => controller.abort() } - this.inFlightByLane.get(key)?.cancel() - this.inFlightByLane.set(key, pending) - context?.signal?.addEventListener('abort', pending.cancel, { once: true }) - if (context?.signal?.aborted) { - pending.cancel() - } - try { - hostEnv = await new PromiseSettlementWaiters( - resolveLoginShellEnvironment({ env: process.env }) - ).wait({ - signal: controller.signal, - timeoutMs: Math.max(1, deadline - Date.now()), - createTimeoutError: () => timeoutError - }) - } catch (error) { - if (error === timeoutError) { - return { stdout: '', stderr: '', exitCode: null, timedOut: true } - } - if (controller.signal.aborted) { - return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true } - } - throw error - } finally { - context?.signal?.removeEventListener('abort', pending.cancel) - if (this.inFlightByLane.get(key) === pending) { - this.inFlightByLane.delete(key) - } - } - if (controller.signal.aborted) { - return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true } - } - } - // Why again: shutdown may have fenced while the login shell resolved. - this.processLifetime.assertAdmission() - if (Date.now() >= deadline) { - return { stdout: '', stderr: '', exitCode: null, timedOut: true } - } - const baseEnv = mergeCommandEnvironment(hostEnv, extraEnv ? {} : undefined, process.platform) - const overrides = mergeCommandEnvironment({}, extraEnv ?? undefined, process.platform) - const spawnEnv = Object.fromEntries( - Object.entries(mergeGitConfigEnvProtocol(baseEnv ?? hostEnv, overrides)).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - ) - // Why: this RPC has no interactive terminal, regardless of which wrapper - // launches the agent or hook command. - applyTerminalGitCredentialPromptGuard(spawnEnv, { - isUnattended: true, - platform: process.platform - }) - - return new Promise((resolve) => { - let child - try { - const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv) - if (Date.now() >= deadline) { - resolve({ stdout: '', stderr: '', exitCode: null, timedOut: true }) - return - } - child = spawn(spawnCmd, spawnArgs, { - cwd, - env: spawnEnv, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - } catch (error) { - resolve({ - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: errorMessage(error) - }) - return - } - - this.processLifetime.track(child) - let stdout = '' - let stderr = '' - let stdoutBytes = 0 - let stderrBytes = 0 - let timedOut = false - let canceled = false - let settled = false - const laneKey = typeof cwd === 'string' ? laneKeyFor(cwd, params.operation) : '' - let entry: InFlightExec | null = null - let timer: ReturnType | null = null - let detachChildListeners = (): void => {} - let detachRequestAbortListener = (): void => {} - const finish = (result: ExecResult): void => { - if (settled) { - return - } - settled = true - if (timer) { - clearTimeout(timer) - timer = null - } - detachRequestAbortListener() - detachChildListeners() - if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) { - this.inFlightByLane.delete(laneKey) - } - resolve(result) - } - const cancelCurrent = (): void => { - canceled = true - terminateRelaySubprocessTree(child) - } - if (laneKey) { - // Why: the relay owns one visible non-interactive job per cwd+operation. - // Replacing the lane without canceling the prior child would orphan - // that process until timeout because future cancelExec calls reach only - // the newest map entry. - this.inFlightByLane.get(laneKey)?.cancel() - entry = { child, cancel: cancelCurrent } - this.inFlightByLane.set(laneKey, entry) - } - - const onStdoutData = (chunk: Buffer): void => { - stdoutBytes += chunk.byteLength - if (stdoutBytes > MAX_OUTPUT_BYTES) { - terminateRelaySubprocessTree(child) - return - } - stdout += chunk.toString('utf-8') - } - const onStderrData = (chunk: Buffer): void => { - stderrBytes += chunk.byteLength - if (stderrBytes > MAX_OUTPUT_BYTES) { - terminateRelaySubprocessTree(child) - return - } - stderr += chunk.toString('utf-8') - } - const onError = (error: Error): void => - finish({ stdout, stderr, exitCode: null, timedOut, spawnError: error.message }) - const onClose = (code: number | null): void => - finish({ stdout, stderr, exitCode: code, timedOut, canceled }) - child.stdout?.on('data', onStdoutData) - child.stderr?.on('data', onStderrData) - child.on('error', onError) - child.on('close', onClose) - detachChildListeners = () => { - child.stdout?.off('data', onStdoutData) - child.stderr?.off('data', onStderrData) - child.off('error', onError) - child.off('close', onClose) - } - - const expireCurrent = (): void => { - timedOut = true - // Why: wrappers and signal-trapping CLIs require terminating the whole tree. - terminateRelaySubprocessTree(child) - finish({ stdout, stderr, exitCode: null, timedOut, canceled }) - } - const remainingTimeoutMs = deadline - Date.now() - if (remainingTimeoutMs <= 0) { - expireCurrent() - return - } - timer = setTimeout(expireCurrent, remainingTimeoutMs) - - if (context?.signal) { - if (context.signal.aborted) { - cancelCurrent() - } else { - context.signal.addEventListener('abort', cancelCurrent, { once: true }) - detachRequestAbortListener = () => { - context.signal?.removeEventListener('abort', cancelCurrent) - } - } - } - - child.stdin?.end(stdinPayload ?? undefined) - }) - } -} diff --git a/src/relay/agent-exec-shell-environment.test.ts b/src/relay/agent-exec-shell-environment.test.ts deleted file mode 100644 index a369251635a..00000000000 --- a/src/relay/agent-exec-shell-environment.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { execFile, spawn } from 'node:child_process' -import { existsSync } from 'node:fs' -import type * as FileSystem from 'node:fs' -import type * as ChildProcess from 'node:child_process' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment' -import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' - -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: vi.fn(), - execFile: vi.fn() -})) -vi.mock('../main/startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: vi.fn() -})) - -vi.mock('node:fs', async (importOriginal) => { - const original = await importOriginal() - return { ...original, existsSync: vi.fn(original.existsSync) } -}) - -describe('relay headless generation shell environment', () => { - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - it('uses the execution host profile PATH and retains explicit command overrides', async () => { - vi.mocked(resolveLoginShellEnvironment).mockResolvedValue({ - PATH: '/profile/bin:/usr/bin', - MODEL: 'profile' - }) - const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake provides the streams and lifecycle used by the relay. - vi.mocked(spawn).mockReturnValue(child as never) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, env: { MODEL: 'override' } }, - requestContext() - ) - await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) - child.emit('close', 0) - await expect(pending).resolves.toMatchObject({ exitCode: 0 }) - expect(spawn).toHaveBeenLastCalledWith( - 'opencode', - ['run'], - expect.objectContaining({ - env: expect.objectContaining({ PATH: '/profile/bin:/usr/bin', MODEL: 'override' }) - }) - ) - }) - - it('does not start generation after cancellation during profile resolution', async () => { - vi.mocked(spawn).mockClear() - let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {} - vi.mocked(resolveLoginShellEnvironment).mockReturnValue( - new Promise((resolve) => { - resolveProfile = resolve - }) - ) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true }, - requestContext() - ) - await expect( - handlers.get('agent.cancelExec')?.( - { cwd: '/repo', operation: 'commit-message' }, - requestContext() - ) - ).resolves.toEqual({ canceled: true }) - resolveProfile({ PATH: '/profile/bin' }) - await expect(pending).resolves.toMatchObject({ canceled: true }) - expect(spawn).not.toHaveBeenCalled() - }) -}) - -describe('relay generation deadline includes profile resolution', () => { - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - it('counts both five-second primary and fallback profile probes in the generation budget', async () => { - vi.useFakeTimers() - vi.mocked(resolveLoginShellEnvironment).mockImplementation( - () => - new Promise((resolve) => - setTimeout(() => { - setTimeout(() => resolve({ PATH: '/profile/bin' }), 5_000) - }, 5_000) - ) - ) - const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle. - vi.mocked(spawn).mockReturnValue(child as never) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 12_000 }, - requestContext() - ) - let result: unknown - void pending?.then((value) => { - result = value - }) - await vi.advanceTimersByTimeAsync(10_000) - expect(spawn).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1_999) - expect(result).toBeUndefined() - await vi.advanceTimersByTimeAsync(1) - expect(result).toMatchObject({ timedOut: true, exitCode: null }) - child.emit('close', 0) - await pending - }) - - it('settles at the request deadline while the shared profile probe remains pending', async () => { - vi.useFakeTimers() - let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {} - vi.mocked(resolveLoginShellEnvironment).mockReturnValue( - new Promise((resolve) => { - resolveProfile = resolve - }) - ) - const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle. - vi.mocked(spawn).mockReturnValue(child as never) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 }, - requestContext() - ) - let result: unknown - void pending?.then((value) => { - result = value - }) - await vi.advanceTimersByTimeAsync(1_000) - try { - expect(result).toMatchObject({ timedOut: true, exitCode: null }) - expect(spawn).not.toHaveBeenCalled() - } finally { - resolveProfile({ PATH: '/profile/bin' }) - await vi.advanceTimersByTimeAsync(0) - child.emit('close', 0) - await pending - } - expect(spawn).not.toHaveBeenCalled() - }) - - it.each(['cancel', 'abort'] as const)( - 'settles %s immediately while the shared profile probe remains pending', - async (kind) => { - vi.useFakeTimers() - let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {} - vi.mocked(resolveLoginShellEnvironment).mockReturnValue( - new Promise((resolve) => { - resolveProfile = resolve - }) - ) - const handlers = createHandlers() - const controller = new AbortController() - const pending = handlers.get('agent.execNonInteractive')?.( - { - binary: 'opencode', - args: ['run'], - cwd: '/repo', - operation: 'commit-message', - shell: true - }, - { ...requestContext(), signal: controller.signal } - ) - let result: unknown - void pending?.then((value) => { - result = value - }) - if (kind === 'cancel') { - await handlers.get('agent.cancelExec')?.( - { cwd: '/repo', operation: 'commit-message' }, - requestContext() - ) - } else { - controller.abort() - } - await vi.advanceTimersByTimeAsync(0) - try { - expect(result).toMatchObject({ canceled: true, timedOut: false }) - expect(spawn).not.toHaveBeenCalled() - } finally { - resolveProfile({ PATH: '/profile/bin' }) - await vi.advanceTimersByTimeAsync(0) - await pending - } - } - ) -}) - -describe('relay deadline boundaries and profile failures', () => { - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - it('retains the final millisecond of the request budget without resetting the minimum timeout', async () => { - vi.useFakeTimers() - vi.mocked(resolveLoginShellEnvironment).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 999)) - ) - const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle. - vi.mocked(spawn).mockReturnValue(child as never) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 }, - requestContext() - ) - let result: unknown - void pending?.then((value) => { - result = value - }) - await vi.advanceTimersByTimeAsync(999) - expect(spawn).toHaveBeenCalledTimes(1) - expect(result).toBeUndefined() - await vi.advanceTimersByTimeAsync(1) - expect(result).toMatchObject({ timedOut: true }) - child.emit('close', 0) - await pending - }) - - it('does not spawn when profile settlement consumes the whole request budget', async () => { - vi.useFakeTimers() - vi.mocked(resolveLoginShellEnvironment).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 1_000)) - ) - const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle. - vi.mocked(spawn).mockReturnValue(child as never) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 }, - requestContext() - ) - let result: unknown - void pending?.then((value) => { - result = value - }) - await vi.advanceTimersByTimeAsync(1_000) - try { - expect(result).toMatchObject({ timedOut: true }) - expect(spawn).not.toHaveBeenCalled() - } finally { - child.emit('close', 0) - await pending - } - }) - - it('preserves a profile rejection and removes the pending cancellation lane', async () => { - const failure = new Error('profile resolution refused') - vi.mocked(resolveLoginShellEnvironment).mockRejectedValue(failure) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true }, - requestContext() - ) - await expect(pending).rejects.toBe(failure) - expect(spawn).not.toHaveBeenCalled() - await expect( - handlers.get('agent.cancelExec')?.( - { cwd: '/repo', operation: 'commit-message' }, - requestContext() - ) - ).resolves.toEqual({ canceled: false }) - }) -}) - -describe('relay deadline covers synchronous command startup', () => { - afterEach(() => { - vi.useRealTimers() - vi.mocked(existsSync).mockReset() - vi.clearAllMocks() - }) - - it('does not spawn after a Windows PATH lookup exhausts the request budget', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - vi.mocked(resolveLoginShellEnvironment).mockImplementation(() => { - vi.setSystemTime(999) - return Promise.resolve({ PATH: 'C:\\slow-network-bin' }) - }) - vi.mocked(existsSync).mockImplementation(() => { - vi.setSystemTime(1_001) - return false - }) - const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle. - vi.mocked(spawn).mockReturnValue(child as never) - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - const pending = createHandlers().get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: 'C:\\repo', shell: true, timeoutMs: 1_000 }, - requestContext() - ) - await vi.advanceTimersByTimeAsync(0) - try { - expect(existsSync).toHaveBeenCalled() - expect(spawn).not.toHaveBeenCalled() - await expect(pending).resolves.toMatchObject({ timedOut: true, exitCode: null }) - } finally { - child.emit('close', 0) - await pending - } - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } - }) - - it('times out immediately when spawning consumes the remaining request budget', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - vi.mocked(existsSync).mockReturnValue(false) - const child = createFakeChild() - vi.mocked(spawn).mockImplementation(() => { - vi.setSystemTime(1_001) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle. - return child as never - }) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')?.( - { binary: 'opencode', args: ['run'], cwd: '/repo', timeoutMs: 1_000 }, - requestContext() - ) - let result: unknown - void pending?.then((value) => { - result = value - }) - await vi.advanceTimersByTimeAsync(0) - try { - expect(spawn).toHaveBeenCalledTimes(1) - expect(result).toMatchObject({ timedOut: true, exitCode: null }) - if (process.platform === 'win32') { - expect(execFile).toHaveBeenCalledWith( - 'taskkill', - ['/pid', String(child.pid), '/T', '/F'], - expect.any(Function) - ) - } else { - expect(child.kill).toHaveBeenCalled() - } - await expect( - handlers.get('agent.cancelExec')?.({ cwd: '/repo' }, requestContext()) - ).resolves.toEqual({ canceled: false }) - } finally { - child.emit('close', 0) - await pending - } - }) -}) diff --git a/src/relay/agent-hook-retired-pane-suppression.test.ts b/src/relay/agent-hook-retired-pane-suppression.test.ts deleted file mode 100644 index f7e00d3281a..00000000000 --- a/src/relay/agent-hook-retired-pane-suppression.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 { RelayAgentHookServer } from '../wsl-guest/agent-hook-server' -import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' -import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' -import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' -import { makePaneKey } from '../shared/stable-pane-id' -import type { PtyHandler, PtySurfaceRetiredListener } from './pty-handler' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { ClaudeTerminalEvidence } from '../shared/claude-terminal-interrupt' - -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const PANE_KEY = makePaneKey('tab-1', LEAF_ID) - -function hookMeta(paneKey: string): string { - return Buffer.from([paneKey, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f')).toString('base64') -} - -async function postHook( - server: RelayAgentHookServer, - paneKey: string, - prompt = 'hi' -): Promise { - const { port, token } = server.getCoordinates() - const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': token, - 'X-Orca-Agent-Hook-Meta-Encoding': 'base64', - 'X-Orca-Agent-Hook-Meta': hookMeta(paneKey) - }, - body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }) - }) - return res.status -} - -type CachedPanes = { state: { lastStatusByPaneKey: Map } } - -function cachedPaneKeys(server: RelayAgentHookServer): string[] { - return [...(server as unknown as CachedPanes).state.lastStatusByPaneKey.keys()] -} - -describe('relay hook forwarding for a retired pane surface', () => { - let dir: string - let servers: RelayAgentHookServer[] - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-')) - servers = [] - }) - - afterEach(() => { - for (const server of servers) { - server.stop() - } - rmSync(dir, { recursive: true, force: true }) - }) - - async function startServer(options: { - forward: (envelope: AgentHookRelayEnvelope) => void - isPaneSurfaceRetired?: (paneKey: string) => boolean - }): Promise { - const server = new RelayAgentHookServer({ endpointDir: dir, ...options }) - servers.push(server) - await server.start({ publishEndpoint: false }) - return server - } - - it('forwards and caches a post from a pane that still has a tab', async () => { - const retired = new RetiredPaneSurfaceRegistry() - const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() - const server = await startServer({ - forward, - isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey) - }) - - expect(await postHook(server, PANE_KEY)).toBe(204) - - expect(forward).toHaveBeenCalledTimes(1) - expect(cachedPaneKeys(server)).toEqual([PANE_KEY]) - }) - - it('does not advertise an orphan agent whose tab was closed', async () => { - const retired = new RetiredPaneSurfaceRegistry() - const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() - const server = await startServer({ - forward, - isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey) - }) - - expect(await postHook(server, PANE_KEY, 'first')).toBe(204) - forward.mockClear() - - // The user closes the tab; the agent inside the pane shell survives and keeps posting. - retired.retire(PANE_KEY) - expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204) - - expect(forward).not.toHaveBeenCalled() - // The pre-close status must go too, or a reconnect replays it as a live agent pane. - expect(cachedPaneKeys(server)).toEqual([]) - }) - - it('does not replay a cached status for a pane retired after it was cached', async () => { - const retired = new RetiredPaneSurfaceRegistry() - const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() - const server = await startServer({ - forward, - isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey) - }) - - expect(await postHook(server, PANE_KEY)).toBe(204) - forward.mockClear() - retired.retire(PANE_KEY) - - expect(server.replayCachedPayloadsForPanes()).toBe(0) - expect(forward).not.toHaveBeenCalled() - expect(cachedPaneKeys(server)).toEqual([]) - }) - - it('forwards again once the pane key is bound by a new PTY', async () => { - const retired = new RetiredPaneSurfaceRegistry() - const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() - const server = await startServer({ - forward, - isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey) - }) - - retired.retire(PANE_KEY) - expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204) - expect(forward).not.toHaveBeenCalled() - - retired.restore(PANE_KEY) - expect(await postHook(server, PANE_KEY, 'reopened')).toBe(204) - - expect(forward).toHaveBeenCalledTimes(1) - }) - - it('an embedder that supplies no retirement source keeps forwarding everything', async () => { - const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() - const server = await startServer({ forward }) - - expect(await postHook(server, PANE_KEY)).toBe(204) - - expect(forward).toHaveBeenCalledTimes(1) - expect(cachedPaneKeys(server)).toEqual([PANE_KEY]) - }) -}) - -describe('RelayAgentHookRuntime wiring', () => { - let dir: string - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-runtime-')) - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - }) - - it('routes hook admission through the PTY handler and drops the cache on retirement', async () => { - const retired = new RetiredPaneSurfaceRegistry() - const surfaceRetiredListeners: PtySurfaceRetiredListener[] = [] - const setClaudeTerminalEvidenceListener = - vi.fn<(listener: (paneKey: string, evidence: ClaudeTerminalEvidence) => void) => void>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this stand-in implements every PtyHandler member the hook runtime registers or reads. - const ptyHandler = { - addEnvAugmenter: vi.fn(), - setAgentPresenceTrigger: vi.fn(), - setClaudeTerminalEvidenceListener, - setExitListener: vi.fn(), - getAgentLaunchToken: () => undefined, - setSurfaceRetiredListener: vi.fn((listener: PtySurfaceRetiredListener | null) => { - if (listener) { - surfaceRetiredListeners.push(listener) - } - }), - isPaneSurfaceRetired: (paneKey: string) => retired.isRetired(paneKey) - } as unknown as PtyHandler - // Zero attached clients: publishAgentHookEnvelope returns before touching any sink. - const dispatcher = { - onRequest: vi.fn(), - activeClientIds: () => [] as number[] - } as unknown as RelayDispatcher - - const runtime = new RelayAgentHookRuntime( - dispatcher, - ptyHandler, - join(dir, 'relay.sock'), - join(dir, 'hooks') - ) - await runtime.start() - try { - const server = (runtime as unknown as { hookServer: RelayAgentHookServer }).hookServer - expect(await postHook(server, PANE_KEY)).toBe(204) - expect(cachedPaneKeys(server)).toEqual([PANE_KEY]) - - expect(setClaudeTerminalEvidenceListener).toHaveBeenCalledOnce() - const observe = setClaudeTerminalEvidenceListener.mock.calls[0]?.[0] - if (!observe) { - throw new Error('Terminal evidence listener was not registered') - } - observe(PANE_KEY, { kind: 'title', title: '◐ Task' }) - observe(PANE_KEY, { kind: 'input', data: '\x1b[27u' }) - observe(PANE_KEY, { kind: 'title', title: '✳ Task' }) - expect( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server owns this pane-status map, as checked by cachedPaneKeys above. - (server as unknown as CachedPanes).state.lastStatusByPaneKey.get(PANE_KEY) - ).toMatchObject({ - payload: { state: 'done', mainAgent: { outcome: 'cancellation' } } - }) - - // The PTY handler retires the surface: the runtime must drop the cached status immediately, - // rather than waiting for a process exit a surviving shell never produces. - retired.retire(PANE_KEY) - expect(surfaceRetiredListeners).toHaveLength(1) - surfaceRetiredListeners[0]({ id: 'pty-1', paneKey: PANE_KEY }) - expect(cachedPaneKeys(server)).toEqual([]) - - expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204) - expect(cachedPaneKeys(server)).toEqual([]) - } finally { - runtime.stop() - } - }) -}) diff --git a/src/relay/agent-status-store-relay-context.test.ts b/src/relay/agent-status-store-relay-context.test.ts deleted file mode 100644 index da3cf5d2852..00000000000 --- a/src/relay/agent-status-store-relay-context.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createAgentChildWorkAdmission } from '../shared/agent-status-child-work-admission' -import { createAgentStatusStore } from '../shared/agent-status-store' -import { makeStructuredAgentStatusSubject } from '../shared/agent-status-subject' - -const trustedSubject = makeStructuredAgentStatusSubject( - { - executionHostId: 'ssh:relay-host-a', - wslDistro: null, - workspaceId: 'folder-workspace-a', - workspaceKind: 'folder' - }, - 'session_11111111-1111-4111-8111-111111111111' -) - -describe('agent status store relay context', () => { - it('instantiates the same shared core and completes an admission/snapshot round-trip', () => { - const authority = createAgentStatusStore({ epoch: 'relay-epoch-a', mode: 'authority' }) - expect( - authority.applyMutation({ parent: { subject: trustedSubject, firstObservedAt: 10 } }) - ).not.toBeNull() - const admission = createAgentChildWorkAdmission(authority, { - mintChildWorkId: () => 'relay-child-1' - }) - - expect( - admission.announce({ - parent: trustedSubject, - provider: 'claude', - aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], - fence: { invocationId: 'invocation-1', generation: 1 }, - lifetime: 'current', - kind: 'agent', - state: 'working', - membership: 'live', - observedAt: 20, - stoppable: true, - provenance: { source: 'transport', producerId: 'relay-fixture' } - }) - ).toMatchObject({ accepted: true, childWorkId: 'relay-child-1' }) - - const replica = createAgentStatusStore({ epoch: 'replica-placeholder', mode: 'replica' }) - expect(replica.applySnapshot(authority.getSnapshot())).toBe(true) - expect(replica.getParent(trustedSubject)?.firstObservedAt).toBe(10) - expect(replica.getChildren(trustedSubject)[0]?.childWorkId).toBe('relay-child-1') - }) -}) diff --git a/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts b/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts deleted file mode 100644 index d207da0e07b..00000000000 --- a/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../shared/home-or-filesystem-root', () => ({ - isTooBroadToPreTrust: () => { - throw new Error('homedir unavailable') - } -})) - -import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' - -describe('applyRelayAgentWorkspaceTrust when the breadth guard fails', () => { - it.each(['claude', 'copilot', 'qoder'] as const)( - 'skips %s trust and never fails the spawn', - async (agent) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect( - applyRelayAgentWorkspaceTrust({ workspacePath: '/srv/wt' }, agent, {}, { wslShell: false }) - ).resolves.toBeUndefined() - expect(warn).toHaveBeenCalled() - warn.mockRestore() - } - ) -}) diff --git a/src/relay/agent-workspace-trust-spawn.test.ts b/src/relay/agent-workspace-trust-spawn.test.ts deleted file mode 100644 index e17998e102e..00000000000 --- a/src/relay/agent-workspace-trust-spawn.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import type * as Os from 'node:os' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentTrustPreset } from '../main/agent-trust-presets' -import type { TuiAgent } from '../shared/tui-agent' -import { linkGitWorktree, workspaceTrustWritten } from '../main/workspace-trust-test-fixtures' - -const state = vi.hoisted(() => ({ home: '' })) - -// Why: a spawn env without HOME leaves the writers on this process's home, as on a real relay. -vi.mock('node:os', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, homedir: () => state.home } -}) - -import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' -import { buildSshPtySpawnRequest } from '../main/providers/ssh-pty-spawn-request' - -const HOST_SHELL = { wslShell: false } -const ORIGINAL_CLAUDE_CONFIG = '{"oauthAccount":{"x":1}}' -const AGENTS_WITH_A_RELAY_WRITER: [TuiAgent, AgentTrustPreset][] = [ - ['claude', 'claude'], - ['claude-agent-teams', 'claude'], - ['codex', 'codex'], - ['cursor', 'cursor'], - ['copilot', 'copilot'], - ['qoder', 'qoder'], - ['qoder-cn', 'qoder-cn'] -] -const AGENTS_THAT_INHERIT_TRUST = AGENTS_WITH_A_RELAY_WRITER.filter(([, preset]) => - ['claude', 'copilot', 'qoder', 'qoder-cn'].includes(preset) -) -const ALL_PRESETS: AgentTrustPreset[] = [ - 'claude', - 'codex', - 'cursor', - 'copilot', - 'qoder', - 'qoder-cn', - 'antigravity' -] - -let root: string -let home: string -let workspace: string - -beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-agent-trust-'))) - home = join(root, 'home', 'me') - mkdirSync(home, { recursive: true }) - state.home = home - writeFileSync(join(home, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 }) - workspace = join(root, 'wt') - mkdirSync(workspace) -}) - -afterEach(() => { - rmSync(root, { recursive: true, force: true }) -}) - -function nothingWritten(): boolean { - return ALL_PRESETS.every((preset) => !workspaceTrustWritten(home, preset)) -} - -describe('applyRelayAgentWorkspaceTrust', () => { - it.each(AGENTS_WITH_A_RELAY_WRITER)( - "writes %s trust on the relay host's own disk", - async (agent, preset) => { - await applyRelayAgentWorkspaceTrust( - { workspacePath: workspace }, - agent, - { HOME: home }, - HOST_SHELL - ) - expect(workspaceTrustWritten(home, preset)).toBe(true) - } - ) - - it("grants Claude in the remote host's own config, named by the merged spawn env", async () => { - const configDir = join(root, 'cfg') - mkdirSync(configDir) - writeFileSync(join(configDir, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 }) - await applyRelayAgentWorkspaceTrust( - { workspacePath: workspace }, - 'claude', - { CLAUDE_CONFIG_DIR: configDir, HOME: home }, - HOST_SHELL - ) - expect(JSON.parse(readFileSync(join(configDir, '.claude.json'), 'utf-8'))).toEqual({ - oauthAccount: { x: 1 }, - projects: { [workspace]: { hasTrustDialogAccepted: true } } - }) - expect(readFileSync(join(home, '.claude.json'), 'utf-8')).toBe(ORIGINAL_CLAUDE_CONFIG) - }) - - it('never creates a Claude config file Claude has not written', async () => { - const emptyHome = join(root, 'empty-home') - mkdirSync(emptyHome) - await applyRelayAgentWorkspaceTrust( - { workspacePath: workspace }, - 'claude', - { HOME: emptyHome }, - HOST_SHELL - ) - expect(existsSync(join(emptyHome, '.claude.json'))).toBe(false) - }) - - it('writes Codex trust into the CODEX_HOME the spawn env names', async () => { - const codexHome = join(root, 'codex-home') - await applyRelayAgentWorkspaceTrust( - { workspacePath: workspace }, - 'codex', - { HOME: home, CODEX_HOME: codexHome }, - HOST_SHELL - ) - expect(readFileSync(join(codexHome, 'config.toml'), 'utf-8')).toContain( - `[projects."${workspace}"]` - ) - expect(workspaceTrustWritten(home, 'codex')).toBe(false) - }) - - it("stores a Codex worktree's own path, not the relay home it hangs off, as a local launch does", async () => { - const worktree = join(root, 'worktrees', 'feature') - linkGitWorktree(home, worktree) - await applyRelayAgentWorkspaceTrust( - { workspacePath: worktree }, - 'codex', - { HOME: home }, - HOST_SHELL - ) - const written = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8') - expect(written).toContain(`[projects."${worktree}"]`) - expect(written).not.toContain(`[projects."${home}"]`) - }) - - it.each([ - ['codex', 'codex'], - ['cursor', 'cursor'] - ] as const)( - 'trusts the relay home for %s, whose trust there covers only the home', - async (agent, preset) => { - await applyRelayAgentWorkspaceTrust( - { workspacePath: home }, - agent, - { HOME: home }, - HOST_SHELL - ) - expect(workspaceTrustWritten(home, preset)).toBe(true) - } - ) - - const tooBroad: [string, () => string][] = [ - ['the relay home', () => home], - ['a folder containing the relay home', () => join(root, 'home')], - [ - 'a symlink to the relay home', - () => { - symlinkSync(home, join(workspace, 'home-link'), 'junction') - return join(workspace, 'home-link') - } - ] - ] - describe.each(tooBroad)('for %s', (_label, arrange) => { - it.each(AGENTS_THAT_INHERIT_TRUST)('writes no %s trust', async (agent) => { - await applyRelayAgentWorkspaceTrust( - { workspacePath: arrange() }, - agent, - { HOME: home }, - HOST_SHELL - ) - expect(nothingWritten()).toBe(true) - }) - }) - - it.each(AGENTS_WITH_A_RELAY_WRITER)( - "never writes this host's files for %s started in a WSL guest", - async (agent) => { - await applyRelayAgentWorkspaceTrust( - { workspacePath: workspace }, - agent, - { HOME: home }, - { wslShell: true } - ) - expect(nothingWritten()).toBe(true) - } - ) - - it('leaves Antigravity to ask, since its writer is unverified on SSH hosts', async () => { - await applyRelayAgentWorkspaceTrust( - { workspacePath: workspace }, - 'antigravity', - { HOME: home }, - HOST_SHELL - ) - expect(workspaceTrustWritten(home, 'antigravity')).toBe(false) - }) - - it.each([ - ['no request', () => undefined, 'claude'], - ['a malformed request', () => ({ workspacePath: 42 }), 'claude'], - ['an earlier-shaped request', () => ({ worktreeRoot: workspace, trusted: true }), 'codex'], - ['an agent with no trust preset', () => ({ workspacePath: workspace }), 'gemini'], - ['no declared agent', () => ({ workspacePath: workspace }), undefined] - ] as const)('writes nothing, so the agent asks, for %s', async (_label, request, agent) => { - await applyRelayAgentWorkspaceTrust(request(), agent, { HOME: home }, HOST_SHELL) - expect(nothingWritten()).toBe(true) - }) - - it('never fails the spawn when a writer throws', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const codexHome = join(root, 'codex-home') - // Why: a config.toml that cannot be read as a file makes the Codex writer throw. - mkdirSync(join(codexHome, 'config.toml'), { recursive: true }) - await expect( - applyRelayAgentWorkspaceTrust( - { workspacePath: workspace }, - 'codex', - { HOME: home, CODEX_HOME: codexHome }, - HOST_SHELL - ) - ).resolves.toBeUndefined() - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) -}) - -describe('buildSshPtySpawnRequest', () => { - it('forwards the workspace to trust as an optional field an old relay ignores', () => { - const request = buildSshPtySpawnRequest({ - options: { - cols: 80, - rows: 24, - launchAgent: 'codex', - agentWorkspaceTrust: { workspacePath: '/w' } - }, - supportsCreateOperation: false - }) - expect(request.agentWorkspaceTrust).toEqual({ workspacePath: '/w' }) - expect(request.launchAgent).toBe('codex') - expect( - buildSshPtySpawnRequest({ options: { cols: 80, rows: 24 }, supportsCreateOperation: false }) - ).not.toHaveProperty('agentWorkspaceTrust') - }) -}) diff --git a/src/relay/agent-workspace-trust-spawn.ts b/src/relay/agent-workspace-trust-spawn.ts deleted file mode 100644 index b8af0dc7fe7..00000000000 --- a/src/relay/agent-workspace-trust-spawn.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { existsSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { parseAgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request' -import { TUI_AGENT_CONFIG } from '../shared/tui-agent-config' -import type { TuiAgent } from '../shared/tui-agent' -import { resolveClaudeGlobalConfigFile } from '../main/claude/claude-folder-trust-file' -import { SHORT_AGENT_TRUST_WRITE_DEADLINE_MS } from '../main/agent-trust-write-deadline' -import { - applyWorkspaceTrustOnThisHost, - launchedAgentHome -} from '../main/execution-host-workspace-trust' - -/** - * Why here: this host owns the files the agent reads, so each writer's lock, re-read, atomic - * rename and refusal rules act on local disk, with no round trip across the SSH link. - */ -export async function applyRelayAgentWorkspaceTrust( - rawRequest: unknown, - launchAgent: TuiAgent | undefined, - spawnEnv: Record, - launch: { wslShell: boolean } -): Promise { - const request = parseAgentWorkspaceTrustSpawnRequest(rawRequest) - const preset = launchAgent ? TUI_AGENT_CONFIG[launchAgent].preflightTrust : undefined - // Why: an agent inside a WSL guest reads the guest's files, not this Windows host's, and - // Antigravity's writer is unverified on SSH hosts, so it still asks there. - if (!request || !preset || preset === 'antigravity' || launch.wslShell) { - return - } - await applyWorkspaceTrustOnThisHost(preset, request.workspacePath, () => { - const keyStyle = process.platform === 'win32' ? 'win32' : 'posix' - const homeDir = launchedAgentHome(spawnEnv) - return { - homes: [homeDir, homedir()], - agentHome: homeDir, - claudeConfig: () => ({ - configFile: resolveClaudeGlobalConfigFile({ - env: spawnEnv, - homeDir, - style: keyStyle, - exists: existsSync - }), - keyStyle - }), - codexConfigFiles: () => [join(spawnEnv.CODEX_HOME || join(homeDir, '.codex'), 'config.toml')], - // Why: every write here is on the relay's own disk, so each preset gets the local budget. - deadlineMs: SHORT_AGENT_TRUST_WRITE_DEADLINE_MS - } - }) -} diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts deleted file mode 100644 index 6e62369c45d..00000000000 --- a/src/relay/ai-vault-handler.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultListResult } from '../shared/ai-vault-types' -import { - SSH_AI_VAULT_LIST_SESSIONS_METHOD, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD -} from '../shared/ssh-ai-vault-relay' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' -import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { AiVaultHandler } from './ai-vault-handler' -import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' -import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' - -type RequestHandler = (params: Record, context: RequestContext) => Promise - -const temporaryHomes: string[] = [] - -afterEach(async () => { - await Promise.all( - temporaryHomes.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) -}) - -describe('AiVaultHandler', () => { - it('resolves an exact transcript title without invoking the broad scanner', async () => { - const remoteHome = await makeTemporaryHome() - const transcriptPath = join(remoteHome, 'session.jsonl') - await writeFile( - transcriptPath, - [ - JSON.stringify({ - timestamp: '2026-07-26T01:00:00.000Z', - type: 'session_meta', - payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') } - }), - JSON.stringify({ - timestamp: '2026-07-26T01:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Resolve only this transcript' }] - } - }) - ].join('\n') - ) - const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult()) - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome, - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'), scanRemoteSessions) - }) - - await expect( - dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { - requests: [ - { agent: 'codex', sessionId: 'ssh-session', transcriptPath }, - { agent: 'codex', sessionId: 'other', transcriptPath: '' } - ] - }) - ).resolves.toEqual({ - titles: [{ agent: 'codex', sessionId: 'ssh-session', title: 'Resolve only this transcript' }] - }) - expect(scanRemoteSessions).not.toHaveBeenCalled() - }) - - it('discovers and parses sessions entirely on the relay host', async () => { - const remoteHome = await makeTemporaryHome() - const transcriptPath = join( - remoteHome, - '.codex', - 'sessions', - '2026', - '07', - '26', - 'rollout-test.jsonl' - ) - await mkdir(dirname(transcriptPath), { recursive: true }) - await writeFile( - transcriptPath, - [ - JSON.stringify({ - timestamp: '2026-07-26T01:00:00.000Z', - type: 'session_meta', - payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') } - }), - JSON.stringify({ - timestamp: '2026-07-26T01:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Scan on the SSH target' }] - } - }) - ].join('\n') - ) - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome, - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64')) - }) - - const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { - limit: 20 - })) as AiVaultListResult - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'local', - executionHostPlatform: 'linux', - sessionId: 'ssh-session', - title: 'Scan on the SSH target', - filePath: transcriptPath - }) - }) - - it('bounds relay scan parameters before touching the target filesystem', async () => { - const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult()) - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: createTestService( - '/home/ada', - getRemoteHostPlatform('linux-x64'), - scanRemoteSessions - ) - }) - const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) - - await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { - limit: 50_000, - scopePaths - }) - - expect(scanRemoteSessions).toHaveBeenCalledWith( - expect.objectContaining({ - executionHostId: 'local', - limit: 1000, - scopePaths: scopePaths.slice(0, 64) - }) - ) - const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { - scopePaths - })) as AiVaultListResult - expect(result.issues).toContainEqual( - expect.objectContaining({ - kind: 'scope', - message: expect.stringContaining('first 64 project paths') - }) - ) - }) - - it('forwards Unlimited without the relay numeric cap', async () => { - const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult()) - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: createTestService( - '/home/ada', - getRemoteHostPlatform('linux-x64'), - scanRemoteSessions - ) - }) - - await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { - limit: 50_000, - unlimited: true - }) - - expect(scanRemoteSessions).toHaveBeenCalledWith( - expect.objectContaining({ limit: undefined, unlimited: true }) - ) - }) - - it('coalesces identical in-flight scans without coupling caller cancellation', async () => { - let resolveScan: ((result: AiVaultListResult) => void) | undefined - let sharedSignal: AbortSignal | undefined - const scanRemoteSessions = vi.fn( - (args: { signal?: AbortSignal }) => - new Promise((resolve) => { - sharedSignal = args.signal - resolveScan = resolve - }) - ) - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: createTestService( - '/home/ada', - getRemoteHostPlatform('linux-x64'), - scanRemoteSessions as never - ) - }) - const firstController = new AbortController() - const first = dispatcher.call( - SSH_AI_VAULT_LIST_SESSIONS_METHOD, - { limit: 20 }, - firstController.signal - ) - const second = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) - await vi.waitFor(() => expect(scanRemoteSessions).toHaveBeenCalledTimes(1)) - - firstController.abort() - await expect(first).rejects.toMatchObject({ name: 'AbortError' }) - expect(sharedSignal?.aborted).toBe(false) - - resolveScan?.(emptyResult()) - await expect(second).resolves.toEqual(emptyResult()) - }) - - it('re-joins a preempted relay caller onto the forced refresh', async () => { - const signals: AbortSignal[] = [] - let resolveForced: ((result: AiVaultListResult) => void) | undefined - const scanRemoteSessions = vi.fn((args: { signal: AbortSignal }) => { - signals.push(args.signal) - return new Promise((resolve) => { - if (signals.length === 1) { - args.signal.addEventListener('abort', () => resolve(emptyResult()), { once: true }) - } else { - resolveForced = resolve - } - }) - }) - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: createTestService( - '/home/ada', - getRemoteHostPlatform('linux-x64'), - scanRemoteSessions as never - ) - }) - const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) - await vi.waitFor(() => expect(signals).toHaveLength(1)) - - const forced = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { - limit: 20, - force: true - }) - await vi.waitFor(() => expect(signals).toHaveLength(2)) - - expect(signals[0]?.aborted).toBe(true) - resolveForced?.(emptyResult()) - // The desktop caller that did not ask for a refresh still gets sessions. - await expect(Promise.all([first, forced])).resolves.toEqual([emptyResult(), emptyResult()]) - }) - - it('soft-disables the method instead of aborting relay startup on an unsupported platform', () => { - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - const dispatcher = createMockDispatcher() - - expect(() => new AiVaultHandler(dispatcher.value)).not.toThrow() - - expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/) - } finally { - stderr.mockRestore() - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - } - }) - - it('soft-disables the method instead of aborting relay startup when the service is missing', () => { - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - const dispatcher = createMockDispatcher() - - expect( - () => - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - ).not.toThrow() - - expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/) - } finally { - stderr.mockRestore() - } - }) - - it('stops a relay-local scan when the owning request is cancelled', async () => { - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: createTestService('/home/ada', getRemoteHostPlatform('linux-x64')) - }) - const controller = new AbortController() - controller.abort() - - await expect( - dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('returns a host issue when the sidecar is unavailable', async () => { - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: { - listSessions: () => Promise.reject(new Error('sidecar crashed')), - resolveSessionTitles: () => Promise.resolve({ titles: [] }) - } - }) - - await expect(dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).resolves.toMatchObject({ - sessions: [], - issues: [ - expect.objectContaining({ kind: 'host', message: expect.stringContaining('sidecar') }) - ] - }) - }) - - it('returns no titles instead of an RPC error when the sidecar is unavailable', async () => { - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: { - listSessions: () => Promise.resolve(emptyResult()), - resolveSessionTitles: () => Promise.reject(new Error('sidecar crashed')) - } - }) - - await expect( - dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { - requests: [ - { agent: 'codex', sessionId: 'ssh-session', transcriptPath: '/home/ada/s.jsonl' } - ] - }) - ).resolves.toEqual({ titles: [] }) - } finally { - stderr.mockRestore() - } - }) - - it('propagates title cancellation instead of degrading it', async () => { - const dispatcher = createMockDispatcher() - new AiVaultHandler(dispatcher.value, { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - service: { - listSessions: () => Promise.resolve(emptyResult()), - resolveSessionTitles: () => { - const error = new Error('The operation was aborted.') - error.name = 'AbortError' - return Promise.reject(error) - } - } - }) - - await expect( - dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { requests: [] }) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) -}) - -async function makeTemporaryHome(): Promise { - const path = await mkdtemp(join(tmpdir(), 'orca-relay-ai-vault-')) - temporaryHomes.push(path) - return path -} - -function emptyResult(): AiVaultListResult { - return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' } -} - -function createTestService( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - scan: typeof scanRemoteAiVaultSessions = scanRemoteAiVaultSessions -): RelayAiVaultServiceApi { - return { - listSessions: (params, signal) => - scan({ - provider: createRelayAiVaultFilesystemProvider({ - homeDirectory: remoteHome, - environment: {} - }), - executionHostId: 'local', - remoteHome, - hostPlatform, - limit: params.limit, - unlimited: params.unlimited, - scopePaths: params.scopePaths, - signal - }), - resolveSessionTitles: (requests, signal) => - readAiVaultSessionTitlesFromFiles(requests, { signal }) - } -} - -function createMockDispatcher(): { - value: RelayDispatcher - call: (method: string, params: Record, signal?: AbortSignal) => Promise -} { - const handlers = new Map() - const value = { - onRequest(method: string, handler: RequestHandler) { - handlers.set(method, handler) - } - } as RelayDispatcher - return { - value, - call(method, params, signal) { - const handler = handlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params, { - clientId: 1, - isStale: () => signal?.aborted ?? false, - signal - }) - } - } -} diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts deleted file mode 100644 index 07271f4b268..00000000000 --- a/src/relay/ai-vault-handler.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { - searchSessionService, - sessionSearchServiceStatus -} from '../main/ai-vault-search/session-search-service-registry' -import { homedir } from 'node:os' -import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types' -import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' -import { - AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT, - type AiVaultSessionTitleRequest, - type AiVaultSessionTitlesResult -} from '../shared/ai-vault-session-title' -import { - SSH_AI_VAULT_LIST_LIMIT_MAX, - SSH_AI_VAULT_LIST_SESSIONS_METHOD, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, - SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH, - type SshAiVaultRelayListParams -} from '../shared/ssh-ai-vault-relay' -import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' -import { relayLogLine } from './relay-diagnostic-log' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator' -import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' - -type AiVaultHandlerOptions = { - remoteHome?: string - hostPlatform?: RemoteHostPlatform - service?: RelayAiVaultServiceApi -} - -export class AiVaultHandler { - private readonly remoteHome: string - private readonly scanCoordinator = new AiVaultScanCoordinator() - - constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) { - dispatcher.onRequest('aiVault.searchSessions', (params) => - searchSessionService(params, 'relay') - ) - dispatcher.onRequest('aiVault.searchStatus', (params) => - sessionSearchServiceStatus(params, 'relay') - ) - this.remoteHome = options.remoteHome ?? homedir() - const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform() - // Why: an OS/arch this build has no path flavor for must not abort relay - // startup — leaving the method unregistered soft-disables the feature and - // the host falls back to its own SSH filesystem scan. - if (!hostPlatform) { - relayLogLine( - `[relay] Agent Session History disabled: unsupported platform ${process.platform}-${process.arch}` - ) - return - } - // Why: same reasoning as an unsupported platform. Throwing here would take - // relay startup — and every PTY on the host — down over a Vault wiring bug. - const service = options.service - if (!service) { - relayLogLine('[relay] Agent Session History disabled: service unavailable') - return - } - dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) => - this.listSessions(service, params, context.signal) - ) - dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) => - this.resolveSessionTitles(service, params, context.signal) - ) - } - - private async resolveSessionTitles( - service: RelayAiVaultServiceApi, - rawParams: Record, - signal?: AbortSignal - ): Promise { - try { - return await service.resolveSessionTitles(normalizeTitleRequests(rawParams.requests), signal) - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw error - } - // Why: titles are decoration. Degrade like an unresolvable title instead of - // failing the RPC, which would surface a raw error on every list row. - relayLogLine( - `[relay-ai-vault-service] title resolution unavailable: ${error instanceof Error ? error.message : String(error)}` - ) - return { titles: [] } - } - } - - private async listSessions( - service: RelayAiVaultServiceApi, - rawParams: Record, - signal?: AbortSignal - ): Promise { - const params = normalizeSshAiVaultRelayListParams(rawParams) - let result: AiVaultListResult - try { - result = await this.scanCoordinator.run({ - key: JSON.stringify({ - includeAntigravityIdeSessions: params.includeAntigravityIdeSessions, - limit: params.limit, - unlimited: params.unlimited, - scopePaths: params.scopePaths, - scopePathsTruncated: params.scopePathsTruncated - }), - force: params.force, - signal, - start: (scanSignal) => service.listSessions(params, scanSignal) - }) - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw error - } - result = { - sessions: [], - scannedAt: new Date().toISOString(), - issues: [ - { - executionHostId: LOCAL_EXECUTION_HOST_ID, - agent: 'codex', - kind: 'host', - path: this.remoteHome, - message: `Agent Session History service unavailable: ${error instanceof Error ? error.message : String(error)}` - } - ] - } - } - if (!params.scopePathsTruncated) { - return result - } - return { - ...result, - issues: [ - ...result.issues, - { - executionHostId: LOCAL_EXECUTION_HOST_ID, - agent: 'codex', - kind: 'scope', - path: this.remoteHome, - message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` - } - ] - } - } -} - -function normalizeTitleRequests(raw: unknown): AiVaultSessionTitleRequest[] { - if (!Array.isArray(raw)) { - return [] - } - const requests: AiVaultSessionTitleRequest[] = [] - for (const value of raw.slice(0, AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)) { - if (!value || typeof value !== 'object') { - continue - } - const record = value as Record - const agent = record.agent - const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : '' - const transcriptPath = - typeof record.transcriptPath === 'string' ? record.transcriptPath.trim() : '' - if ( - (agent !== 'claude' && agent !== 'codex') || - !sessionId || - sessionId.length > 512 || - !transcriptPath || - transcriptPath.length > 32_768 - ) { - continue - } - requests.push({ agent, sessionId, transcriptPath }) - } - return requests -} - -export function normalizeSshAiVaultRelayListParams( - params: Record -): SshAiVaultRelayListParams { - const rawLimit = params.limit - const unlimited = params.unlimited === true - const limit = - !unlimited && typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0 - ? Math.min(Math.floor(rawLimit), SSH_AI_VAULT_LIST_LIMIT_MAX) - : undefined - const scopePaths = Array.isArray(params.scopePaths) - ? params.scopePaths - .slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT) - .filter( - (path): path is string => - typeof path === 'string' && - path.trim().length > 0 && - path.length <= SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH - ) - : undefined - const scopePathsTruncated = - params.scopePathsTruncated === true || - (Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT) - return { - ...(params.includeAntigravityIdeSessions === true - ? { includeAntigravityIdeSessions: true } - : {}), - ...(unlimited ? { unlimited: true } : {}), - ...(limit === undefined ? {} : { limit }), - ...(params.force === true ? { force: true } : {}), - ...(scopePaths === undefined ? {} : { scopePaths }), - ...(scopePathsTruncated ? { scopePathsTruncated: true } : {}) - } -} - -function currentRelayHostPlatform(): RemoteHostPlatform | null { - const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) - return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null -} diff --git a/src/relay/ai-vault-opencode-reader.test.ts b/src/relay/ai-vault-opencode-reader.test.ts deleted file mode 100644 index ae755246bf9..00000000000 --- a/src/relay/ai-vault-opencode-reader.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultScanIssue } from '../shared/ai-vault-types' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' -import { resetRemoteSessionParseCacheForTests } from '../main/ai-vault/remote-session-parse-cache' -import { listOpenCodeSqliteSessions } from '../main/ai-vault/session-scanner-opencode-sqlite-list' -import { listOpenCode2SqliteSessions } from '../main/ai-vault/session-scanner-opencode2-sqlite-list' -import { parseOpenCodeSqliteSession } from '../main/ai-vault/session-scanner-opencode-sqlite' -import { parseOpenCode2SqliteSession } from '../main/ai-vault/session-scanner-opencode2-sqlite' -import { writeOpenCodeSqliteDatabase } from '../main/ai-vault/session-scanner-opencode-sqlite-fixture' -import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' -import { - createRelayOpenCodeReader, - openCodeReaderUnavailableMessage, - type RelayOpenCodeReaderOptions -} from './ai-vault-opencode-reader' - -const directories: string[] = [] -const disposables: { dispose(): void }[] = [] - -afterEach(async () => { - for (const disposable of disposables.splice(0)) { - disposable.dispose() - } - await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) - resetRemoteSessionParseCacheForTests() -}) - -async function temporaryDirectory(): Promise { - const path = await mkdtemp(join(tmpdir(), 'orca-relay-opencode-')) - directories.push(path) - return path -} - -function factory() { - const reader = { list: vi.fn(async () => []), parse: vi.fn(async () => null), dispose: vi.fn() } - return { - reader, - create: vi.fn( - (_options: Parameters>[0]) => reader - ) - } -} - -describe('relay OpenCode reader', () => { - it('coalesces a persistent reader selected by the provisioned runtime reference', async () => { - const baseDir = await temporaryDirectory() - const executable = join(baseDir, 'runtime', 'bun') - await writeFile( - join(baseDir, 'opencode-sqlite-runtime.json'), - JSON.stringify({ protocol: 1, executable }) - ) - const { reader, create } = factory() - const probe = vi.fn(() => false) - const provider = createRelayAiVaultFilesystemProvider({ - baseDir, - readerFactory: create, - canReadSqlite: probe, - environment: { - HOME: baseDir, - NODE_OPTIONS: '--require=bad', - XDG_DATA_HOME: join(baseDir, 'data') - } - }) - disposables.push(provider) - const issues: AiVaultScanIssue[] = [] - const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 10, issues } - await Promise.all([provider.openCode?.list(args), provider.openCode?.list(args)]) - expect(issues).toEqual([]) - expect(create).toHaveBeenCalledTimes(1) - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ executable, args: [join(baseDir, 'opencode-sqlite-reader.cjs')] }) - ) - expect(create.mock.calls[0]?.[0]?.env?.NODE_OPTIONS).toBeUndefined() - expect(probe).not.toHaveBeenCalled() - provider.dispose() - expect(reader.dispose).toHaveBeenCalledTimes(1) - }) - - it('reports an unavailable reader once per source and retries after provisioning', async () => { - const baseDir = await temporaryDirectory() - const { create } = factory() - const reader = createRelayOpenCodeReader({ - baseDir, - readerFactory: create, - canReadSqlite: () => false - }) - disposables.push(reader) - const issues: AiVaultScanIssue[] = [] - const args = { - dbPaths: [join(baseDir, 'opencode.db'), join(baseDir, 'opencode-other.db')], - limit: 10, - issues - } - await reader.list(args) - expect(issues).toHaveLength(1) - expect(issues[0]).toMatchObject({ - agent: 'opencode', - kind: 'scope', - message: openCodeReaderUnavailableMessage(process.version) - }) - expect(issues[0]?.message).toContain('22.16 or newer') - expect(create).not.toHaveBeenCalled() - await writeFile( - join(baseDir, 'opencode-sqlite-runtime.json'), - JSON.stringify({ protocol: 1, executable: join(baseDir, 'bun') }) - ) - await reader.list({ ...args, issues: [] }) - expect(create).toHaveBeenCalledTimes(1) - }) - - it('uses the current runtime only after a successful SQLite read probe', async () => { - const baseDir = await temporaryDirectory() - const { create } = factory() - const reader = createRelayOpenCodeReader({ - baseDir, - readerFactory: create, - currentExecutable: process.execPath - }) - disposables.push(reader) - const issues: AiVaultScanIssue[] = [] - await reader.list({ dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues }) - expect(issues).toEqual([]) - expect(create).toHaveBeenCalledWith(expect.objectContaining({ executable: process.execPath })) - }) - - it('replaces the persistent reader after a repaired runtime reference changes', async () => { - const baseDir = await temporaryDirectory() - const referencePath = join(baseDir, 'opencode-sqlite-runtime.json') - await writeFile( - referencePath, - JSON.stringify({ protocol: 1, executable: join(baseDir, 'old-bun') }) - ) - const first = factory() - const second = factory() - const create = vi - .fn>() - .mockReturnValueOnce(first.reader) - .mockReturnValueOnce(second.reader) - const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create }) - disposables.push(reader) - const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues: [] } - await reader.list(args) - await writeFile( - referencePath, - JSON.stringify({ protocol: 1, executable: join(baseDir, 'repaired-bun') }) - ) - await reader.list(args) - expect(first.reader.dispose).toHaveBeenCalledOnce() - expect(second.reader.list).toHaveBeenCalledOnce() - expect(create).toHaveBeenLastCalledWith( - expect.objectContaining({ executable: join(baseDir, 'repaired-bun') }) - ) - }) - - it.each([ - { protocol: 2, executable: '/runtime/bun' }, - { protocol: 1, executable: 'bun' }, - { protocol: 1, executable: '/runtime/bun\0bad' } - ])('refuses an invalid runtime reference: %j', async (reference) => { - const baseDir = await temporaryDirectory() - await writeFile(join(baseDir, 'opencode-sqlite-runtime.json'), JSON.stringify(reference)) - const { create } = factory() - const reader = createRelayOpenCodeReader({ - baseDir, - readerFactory: create, - canReadSqlite: () => true - }) - disposables.push(reader) - const issues: AiVaultScanIssue[] = [] - await reader.list({ dbPaths: ['opencode.db'], limit: 1, issues }) - expect(issues).toHaveLength(1) - expect(create).not.toHaveBeenCalled() - }) - - it('retains the host database overrides including relative and in-memory paths', async () => { - const baseDir = await temporaryDirectory() - const data = join(baseDir, 'data') - const reader = createRelayOpenCodeReader({ - baseDir, - environment: { XDG_DATA_HOME: data, OPENCODE_DB: 'opencode-team.db' } - }) - expect(reader.dataDirectory).toBe(join(data, 'opencode')) - expect(reader.databasePath).toBe(join(data, 'opencode', 'opencode-team.db')) - expect( - createRelayOpenCodeReader({ environment: { OPENCODE_DB: ':memory:' } }).databasePath - ).toBeNull() - }) - - it('does not start a child for empty or cancelled scans', async () => { - const baseDir = await temporaryDirectory() - const { create } = factory() - const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create }) - disposables.push(reader) - await reader.list({ dbPaths: [], limit: 1, issues: [] }) - const controller = new AbortController() - controller.abort() - await expect( - reader.list({ dbPaths: ['opencode.db'], limit: 1, issues: [], signal: controller.signal }) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(create).not.toHaveBeenCalled() - }) - - it('scans the host database and legacy JSON through the existing readers', async () => { - const baseDir = await temporaryDirectory() - const xdg = join(baseDir, 'data') - const dbPath = join(xdg, 'opencode', 'opencode-team.db') - writeOpenCodeSqliteDatabase(dbPath, [ - { id: 'migrated', turns: [{ role: 'user', parts: ['Database prompt'] }] } - ]) - const sessionDir = join(xdg, 'opencode', 'storage', 'session', 'project') - await mkdir(sessionDir, { recursive: true }) - await writeFile( - join(sessionDir, 'migrated.json'), - JSON.stringify({ id: 'migrated', title: 'Stale title' }) - ) - await writeFile( - join(sessionDir, 'legacy.json'), - JSON.stringify({ id: 'legacy', title: 'Legacy title', directory: '/project' }) - ) - const readerFactory: NonNullable = () => ({ - list: (args) => - args.agent === 'opencode2' - ? listOpenCode2SqliteSessions(args) - : listOpenCodeSqliteSessions({ ...args, agent: 'opencode' }), - parse: async (args) => - args.agent === 'opencode2' - ? parseOpenCode2SqliteSession(args) - : parseOpenCodeSqliteSession({ ...args, agent: 'opencode' }), - dispose() {} - }) - const provider = createRelayAiVaultFilesystemProvider({ - baseDir, - environment: { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' }, - readerFactory - }) - disposables.push(provider) - const result = await scanRemoteAiVaultSessions({ - provider, - remoteHome: baseDir, - executionHostId: 'ssh:actual', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - expect(result.issues).toEqual([]) - expect(result.sessions.map((entry) => entry.sessionId).sort()).toEqual(['legacy', 'migrated']) - expect(result.sessions.find((entry) => entry.sessionId === 'migrated')).toMatchObject({ - filePath: dbPath, - executionHostId: 'ssh:actual', - previewMessages: [expect.objectContaining({ text: 'Database prompt' })] - }) - }) -}) diff --git a/src/relay/ai-vault-opencode-reader.ts b/src/relay/ai-vault-opencode-reader.ts deleted file mode 100644 index 2da5b6d62c2..00000000000 --- a/src/relay/ai-vault-opencode-reader.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { open } from 'node:fs/promises' -import { homedir } from 'node:os' -import { isAbsolute, join } from 'node:path' -import type { RemoteOpenCodeSessionReader } from '../main/ai-vault/remote-session-scanner-types' -import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation' -import { isMissingRemoteSessionPathError } from '../main/ai-vault/remote-session-file-stat' -import { parseOpenCodeSessionFile } from '../main/ai-vault/session-scanner-opencode-parser' -import { createOpenCodeSqliteProcessClient } from '../main/ai-vault/session-scanner-opencode-sqlite-process-client' -import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env' -import { resolveOpenCodeDataDirectory } from '../main/opencode/opencode-data-directory' -import SyncDatabase, { isSqliteAvailable } from '../main/sqlite/sync-database' - -type Reader = Pick & { dispose(): void } - -export type RelayOpenCodeReaderOptions = { - baseDir?: string - environment?: NodeJS.ProcessEnv - homeDirectory?: string - currentExecutable?: string - readerFactory?: (options: Parameters[0]) => Reader - canReadSqlite?: () => boolean -} - -export function createRelayOpenCodeReader( - options: RelayOpenCodeReaderOptions = {} -): RemoteOpenCodeSessionReader & { dispose(): void } { - const environment = options.environment ?? process.env - const dataDirectory = resolveOpenCodeDataDirectory( - environment, - options.homeDirectory ?? homedir() - ) - const override = environment.OPENCODE_DB?.trim() - const baseDir = options.baseDir ?? __dirname - let reader: Reader | undefined - let executable: string | undefined - let pending: Promise | undefined - let disposed = false - const getReader = (refreshRuntime = false): Promise => { - if (disposed) { - return Promise.reject(new Error('OpenCode database reader was disposed.')) - } - if (reader && !refreshRuntime) { - return Promise.resolve(reader) - } - pending ??= (async () => { - const configured = await readRuntimeExecutable(join(baseDir, 'opencode-sqlite-runtime.json')) - if (!configured && !(options.canReadSqlite ?? canCurrentRuntimeReadSqlite)()) { - throw new Error(openCodeReaderUnavailableMessage(process.version)) - } - if (disposed) { - throw new Error('OpenCode database reader was disposed.') - } - const nextExecutable = configured ?? options.currentExecutable ?? process.execPath - if (!reader || executable !== nextExecutable) { - reader?.dispose() - reader = (options.readerFactory ?? createOpenCodeSqliteProcessClient)({ - executable: nextExecutable, - args: [join(baseDir, 'opencode-sqlite-reader.cjs')], - env: buildRelayAiVaultServiceEnv(environment) - }) - executable = nextExecutable - } - return reader - })().finally(() => { - pending = undefined - }) - return pending - } - return { - dataDirectory, - ...(override - ? { - databasePath: - override === ':memory:' - ? null - : isAbsolute(override) - ? override - : join(dataDirectory, override) - } - : {}), - async list(args) { - throwIfAiVaultScanCancelled(args.signal) - if (args.dbPaths.length === 0) { - return [] - } - try { - return await (await getReader(true)).list(args) - } catch (error) { - throwIfAiVaultScanCancelled(args.signal) - args.issues.push({ - agent: args.agent ?? 'opencode', - kind: 'scope', - path: args.dbPaths[0] ?? dataDirectory, - message: error instanceof Error ? error.message : String(error) - }) - return [] - } - }, - async parse(args) { - throwIfAiVaultScanCancelled(args.signal) - return (await getReader()).parse(args) - }, - parseLegacy: parseOpenCodeSessionFile, - dispose() { - if (disposed) { - return - } - disposed = true - reader?.dispose() - } - } -} - -async function readRuntimeExecutable(path: string): Promise { - let file - try { - file = await open(path, 'r') - } catch (error) { - if (isMissingRemoteSessionPathError(error)) { - return undefined - } - throw error - } - try { - const bytes = Buffer.alloc(16_385) - const { bytesRead } = await file.read(bytes, 0, bytes.length, 0) - if (bytesRead > 16_384) { - throw new Error('OpenCode database runtime reference exceeds its size limit.') - } - const reference: unknown = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8')) - if ( - typeof reference !== 'object' || - reference === null || - !('protocol' in reference) || - reference.protocol !== 1 || - !('executable' in reference) || - typeof reference.executable !== 'string' || - !isAbsolute(reference.executable) || - reference.executable.includes('\0') - ) { - throw new Error('OpenCode database runtime reference is invalid.') - } - return reference.executable - } finally { - await file.close() - } -} - -/** Rung C runs the relay on the host's Node, which may predate the full node:sqlite surface. */ -export function openCodeReaderUnavailableMessage(nodeVersion: string): string { - return ( - `OpenCode history is unavailable on this host for now: its Node.js ${nodeVersion} has no ` + - 'complete node:sqlite (22.16 or newer), and Orca has not installed its own Node reader here yet.' - ) -} - -function canCurrentRuntimeReadSqlite(): boolean { - // Why: same admission as the host probes, so Node 22.13–22.15 waits for the pinned runtime. - if (!isSqliteAvailable()) { - return false - } - let db: SyncDatabase | undefined - try { - db = new SyncDatabase(':memory:') - return db.prepare('SELECT 1 AS ready').get()?.ready === 1 - } catch { - return false - } finally { - db?.close() - } -} diff --git a/src/relay/ai-vault-service-cancellation.test.ts b/src/relay/ai-vault-service-cancellation.test.ts deleted file mode 100644 index 8354eaabaae..00000000000 --- a/src/relay/ai-vault-service-cancellation.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { setImmediate } from 'node:timers/promises' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { RelayAiVaultServiceClient } from './ai-vault-service-client' -import { AiVaultServiceTestChild } from '../main/ai-vault/session-scanner-service-test-child' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import type { RelayAiVaultServiceChildMessage } from './ai-vault-service-protocol' -import type { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' - -const scan = vi.fn() -const disposeProvider = vi.fn<() => void>() - -const result = { sessions: [], issues: [], scannedAt: '2026-10-02T13:00:00.000Z' } -let outbound: RelayAiVaultServiceChildMessage[] = [] -let frames: RelayAiVaultServiceChildMessage[] = [] -let restoreProcess = (): void => undefined -let client: RelayAiVaultServiceClient | undefined - -class BridgeChild extends AiVaultServiceTestChild { - override send(message: unknown, callback?: (error: Error | null) => void): boolean { - super.send(message, callback) - process.emit('message', message) - return true - } -} - -function deliver(child: BridgeChild): void { - const message = outbound.shift() - if (!message) { - throw new Error('Expected one complete service reply in transit') - } - child.emit('message', message) -} - -async function turn(): Promise { - await setImmediate() -} - -function createClient(): BridgeChild { - const child = new BridgeChild() - client = new RelayAiVaultServiceClient({ - processFactory: () => child.asChildProcess(), - init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } - }) - return child -} - -function currentClient(): RelayAiVaultServiceClient { - if (!client) { - throw new Error('Expected the owned service client') - } - return client -} - -async function close(child: BridgeChild): Promise { - const disposed = client?.dispose() - await turn() - child.emit('exit', 0) - await disposed - expect(disposeProvider).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - client = undefined -} - -beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - scan.mockReset() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - outbound = [] - frames = [] - scan.mockResolvedValue(result) - const send = Object.getOwnPropertyDescriptor(process, 'send') - const disconnect = Object.getOwnPropertyDescriptor(process, 'disconnect') - const messages = new Set(process.listeners('message')) - const disconnects = new Set(process.listeners('disconnect')) - Object.defineProperty(process, 'send', { - configurable: true, - value: (message: RelayAiVaultServiceChildMessage) => { - outbound.push(message) - frames.push(message) - return true - } - }) - Object.defineProperty(process, 'disconnect', { configurable: true, value: vi.fn() }) - restoreProcess = () => { - for (const listener of process.listeners('message')) { - if (!messages.has(listener)) { - process.removeListener('message', listener) - } - } - for (const listener of process.listeners('disconnect')) { - if (!disconnects.has(listener)) { - process.removeListener('disconnect', listener) - } - } - if (send) { - Object.defineProperty(process, 'send', send) - } else { - delete process.send - } - if (disconnect) { - Object.defineProperty(process, 'disconnect', disconnect) - } else { - delete process.disconnect - } - } - const scanner = await import('../main/ai-vault/remote-session-scanner') - vi.spyOn(scanner, 'scanRemoteAiVaultSessions').mockImplementation(scan) - const titles = await import('../main/ai-vault/session-title-file-reader') - vi.spyOn(titles, 'readAiVaultSessionTitlesFromFiles').mockResolvedValue({ titles: [] }) - const filesystem = await import('./ai-vault-service-filesystem') - const createProvider = filesystem.createRelayAiVaultFilesystemProvider - vi.spyOn(filesystem, 'createRelayAiVaultFilesystemProvider').mockImplementation((options) => { - const provider = createProvider(options) - const dispose = provider.dispose - vi.spyOn(provider, 'dispose').mockImplementation(() => { - disposeProvider() - dispose() - }) - return provider - }) - await import('./ai-vault-service-entry') -}) - -afterEach(async () => { - process.emit('message', { type: 'shutdown' }) - await turn() - restoreProcess() - vi.clearAllTimers() - vi.restoreAllMocks() - vi.useRealTimers() -}) - -it('actual parent cancellation during completed-response transit retains zero completed IDs', async () => { - const child = createClient() - const errors: unknown[] = [] - const retained: Set[] = [] - const counts: number[] = [] - const warm = Promise.withResolvers() - scan.mockImplementationOnce(() => warm.promise) - const warmController = new AbortController() - const warmResponse = currentClient() - .listSessions({}, warmController.signal) - .catch((error: unknown) => error) - deliver(child) - await turn() - const warmAdd = vi.spyOn(Set.prototype, 'add') - try { - warmController.abort() - const callIndex = warmAdd.mock.calls.findIndex(([value]) => value === 1) - const owned = warmAdd.mock.contexts[callIndex] - if (!(owned instanceof Set)) { - throw new Error('Expected the actual native cancellation set for the admitted warm request') - } - retained.push(owned) - } finally { - warmAdd.mockRestore() - } - errors.push(await warmResponse) - warm.resolve(result) - await turn() - deliver(child) - await turn() - expect(retained[0]?.size).toBe(0) - for (let index = 0; index < 64; index++) { - const id = index + 2 - if (index % 3 !== 2) { - scan.mockImplementationOnce(async () => { - if (index % 3 === 1) { - throw new Error(`scan failure ${id}`) - } - return result - }) - } - const controller = new AbortController() - const response = ( - index % 3 === 2 - ? currentClient().resolveSessionTitles([], controller.signal) - : currentClient().listSessions({}, controller.signal) - ).catch((error: unknown) => error) - await turn() - expect(outbound).toHaveLength(1) - expect(outbound[0]).toEqual( - index % 3 === 2 - ? { type: 'result', id, operation: 'titles', value: { titles: [] } } - : index % 3 === 1 - ? { type: 'error', id, message: `scan failure ${id}` } - : { type: 'result', id, operation: 'list', value: result } - ) - const add = vi.spyOn(Set.prototype, 'add') - try { - controller.abort() - const matching = add.mock.calls.flatMap(([value], callIndex) => - value === id ? [callIndex] : [] - ) - counts.push(matching.length) - for (const callIndex of matching) { - const owned = add.mock.contexts[callIndex] - if (!(owned instanceof Set)) { - throw new Error('Expected the actual native cancellation set') - } - retained.push(owned) - } - } finally { - add.mockRestore() - } - errors.push(await response) - deliver(child) - await turn() - } - expect( - errors.every( - (error) => - error instanceof Error && - error.name === 'AbortError' && - error.message === 'The operation was aborted.' - ) - ).toBe(true) - expect(scan).toHaveBeenCalledTimes(44) - expect( - child.sent.filter( - (message) => - typeof message === 'object' && - message !== null && - 'type' in message && - message.type === 'cancel' - ) - ).toEqual(Array.from({ length: 65 }, (_, index) => ({ type: 'cancel', id: index + 1 }))) - const sizes = [...new Set(retained)].map((owned) => owned.size) - await close(child) - expect(frames).toEqual([ - { type: 'ready', protocol: 1, pid: process.pid }, - { type: 'result', id: 1, operation: 'list', value: result }, - ...Array.from({ length: 64 }, (_, index) => { - const id = index + 2 - return index % 3 === 2 - ? { type: 'result', id, operation: 'titles', value: { titles: [] } } - : index % 3 === 1 - ? { type: 'error', id, message: `scan failure ${id}` } - : { type: 'result', id, operation: 'list', value: result } - }) - ]) - expect(counts).toEqual(Array(64).fill(0)) - expect(sizes).toEqual([0]) -}) - -it('an admitted active cancellation still aborts its exact scanner signal', async () => { - const child = createClient() - const pending = Promise.withResolvers() - let signal: AbortSignal | undefined - scan.mockImplementationOnce((options) => { - signal = options.signal - return pending.promise - }) - const controller = new AbortController() - const response = currentClient() - .listSessions({}, controller.signal) - .catch((error: unknown) => error) - deliver(child) - await turn() - expect(signal?.aborted).toBe(false) - controller.abort() - await expect(response).resolves.toMatchObject({ name: 'AbortError' }) - expect(signal?.aborted).toBe(true) - pending.resolve(result) - await turn() - expect(outbound).toEqual([{ type: 'result', id: 1, operation: 'list', value: result }]) - deliver(child) - await close(child) -}) - -it('a pending queued cancellation still aborts before the queued scanner begins', async () => { - const child = createClient() - const pending = Promise.withResolvers() - const signals: AbortSignal[] = [] - scan.mockImplementation((options) => { - if (!options.signal) { - throw new Error('Expected the owned scanner signal') - } - signals.push(options.signal) - return signals.length === 1 ? pending.promise : Promise.resolve(result) - }) - const response = currentClient().listSessions({}) - deliver(child) - await turn() - process.emit('message', { type: 'request', id: 999, operation: 'list', params: {} }) - process.emit('message', { type: 'cancel', id: 999 }) - expect(signals).toHaveLength(1) - pending.resolve(result) - await turn() - expect(signals).toHaveLength(2) - expect(signals[1]?.aborted).toBe(true) - expect(outbound).toEqual([ - { type: 'result', id: 1, operation: 'list', value: result }, - { type: 'result', id: 999, operation: 'list', value: result } - ]) - deliver(child) - await expect(response).resolves.toEqual(result) - deliver(child) - await close(child) -}) - -it('unknown, pre-init and duplicate completed cancels preserve the next live request', async () => { - process.emit('message', { type: 'cancel', id: 9000 }) - const child = createClient() - const first = currentClient().listSessions({}) - deliver(child) - await turn() - deliver(child) - await expect(first).resolves.toEqual(result) - for (const id of [1, 1, 9000, 9000]) { - process.emit('message', { type: 'cancel', id }) - } - expect(outbound).toEqual([]) - const live = currentClient().listSessions({}) - await turn() - expect(scan.mock.calls[1]?.[0].signal?.aborted).toBe(false) - expect(outbound).toEqual([{ type: 'result', id: 2, operation: 'list', value: result }]) - deliver(child) - await expect(live).resolves.toEqual(result) - await close(child) -}) - -it('shutdown aborts active work and ignores later cancellations and admission', async () => { - const child = createClient() - const pending = Promise.withResolvers() - let signal: AbortSignal | undefined - scan.mockImplementationOnce((options) => { - signal = options.signal - return pending.promise - }) - const response = currentClient().listSessions({}) - const rejected = expect(response).rejects.toThrow('scan stopped') - deliver(child) - await turn() - process.emit('message', { type: 'shutdown' }) - expect(signal?.aborted).toBe(true) - process.emit('message', { type: 'cancel', id: 1 }) - process.emit('message', { type: 'cancel', id: 9000 }) - process.emit('message', { type: 'request', id: 9000, operation: 'list', params: {} }) - pending.reject(new Error('scan stopped')) - await turn() - expect(scan).toHaveBeenCalledOnce() - expect(outbound).toEqual([{ type: 'error', id: 1, message: 'scan stopped' }]) - deliver(child) - await rejected - await close(child) -}) diff --git a/src/relay/ai-vault-service-client-state.ts b/src/relay/ai-vault-service-client-state.ts deleted file mode 100644 index 11cab3763d7..00000000000 --- a/src/relay/ai-vault-service-client-state.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { ChildProcess } from 'node:child_process' -import type { AiVaultListResult } from '../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../shared/ai-vault-session-title' -import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' -import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { - relayAiVaultServiceLane, - type RelayAiVaultServiceLane, - type RelayAiVaultServiceRequest -} from './ai-vault-service-protocol' - -export const RELAY_AI_VAULT_READY_TIMEOUT_MS = 5_000 -export const RELAY_AI_VAULT_SCAN_TIMEOUT_MS = 130_000 -export const RELAY_AI_VAULT_TITLE_TIMEOUT_MS = 15_000 -export const RELAY_AI_VAULT_MAX_CALLS = 16 -export const RELAY_AI_VAULT_IDLE_TIMEOUT_MS = 10 * 60_000 - -export function relayAiVaultAbortError(): Error { - const error = new Error('The operation was aborted.') - error.name = 'AbortError' - return error -} - -export function relayAiVaultError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)) -} - -export function armRelayAiVaultCallTimeout( - call: RelayAiVaultServiceCall, - onExpired: (timeout: number) => void -): void { - const timeout = - call.request.operation === 'list' - ? RELAY_AI_VAULT_SCAN_TIMEOUT_MS - : RELAY_AI_VAULT_TITLE_TIMEOUT_MS - call.timer = setTimeout(() => onExpired(timeout), timeout) - call.timer.unref?.() -} - -export function armRelayAiVaultCancellationTimeout( - call: RelayAiVaultServiceCall, - onExpired: () => void -): void { - call.timer = setTimeout(onExpired, 2_000) - call.timer.unref?.() -} - -export function createRelayAiVaultServiceCall(args: { - request: RelayAiVaultServiceRequest - signal?: AbortSignal - resolve: RelayAiVaultServiceCall['resolve'] - reject: RelayAiVaultServiceCall['reject'] -}): RelayAiVaultServiceCall { - return { - request: args.request, - lane: relayAiVaultServiceLane(args.request.operation), - signal: args.signal, - forceStart: args.request.operation === 'list' && args.request.params.force === true, - resolve: args.resolve, - reject: args.reject, - timer: null, - onAbort: null, - settled: false, - sent: false, - startRetried: false, - startAttempt: 0 - } -} - -/** - * A cold start that faults before the request reached the sidecar self-heals on - * the scheduled respawn. Requeue once; the caller settles when this returns false. - */ -export function requeueRelayAiVaultServiceStart( - call: RelayAiVaultServiceCall, - queue: RelayAiVaultServiceCall[] -): boolean { - if (call.sent || call.settled || call.startRetried) { - return false - } - call.startRetried = true - queue.unshift(call) - return true -} - -export function settleRelayAiVaultServiceCall( - call: RelayAiVaultServiceCall, - value: Error | AiVaultListResult | AiVaultSessionTitlesResult -): void { - // A cancelled call is settled before its cancel watchdog is armed, so the - // timer has to be cleared even when the reject/resolve is already done. - if (call.timer) { - clearTimeout(call.timer) - call.timer = null - } - if (call.settled) { - return - } - call.settled = true - if (call.signal && call.onAbort) { - call.signal.removeEventListener('abort', call.onAbort) - } - if (value instanceof Error) { - call.reject(value) - } else { - call.resolve(value) - } -} - -export type RelayAiVaultServiceCall = { - request: RelayAiVaultServiceRequest - lane: RelayAiVaultServiceLane - signal?: AbortSignal - forceStart: boolean - resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void - reject: (error: Error) => void - timer: NodeJS.Timeout | null - onAbort: (() => void) | null - settled: boolean - /** Whether the sidecar received the request; an unsent call gets no reply. */ - sent: boolean - startRetried: boolean - startAttempt: number -} - -export type RelayAiVaultServiceApi = { - listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise - resolveSessionTitles( - requests: AiVaultSessionTitleRequest[], - signal?: AbortSignal - ): Promise -} - -export type RelayAiVaultServiceClientOptions = { - processFactory: () => ChildProcess - init: { - remoteHome: string - hostPlatform: RemoteHostPlatform - } - now?: () => number - idleTimeoutMs?: number -} - -export class RelayAiVaultIdleRetirement { - private timer: NodeJS.Timeout | null = null - - clear(): void { - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - } - - schedule(busy: boolean, timeoutMs: number, retire: () => void): void { - if (busy || this.timer) { - return - } - this.timer = setTimeout(() => { - this.timer = null - retire() - }, timeoutMs) - this.timer.unref?.() - } -} - -export function retireRelayAiVaultServiceChild(child: ChildProcess): void { - const timer = setTimeout(() => child.kill(), 2_000) - timer.unref?.() - child.once('exit', () => clearTimeout(timer)) - child.send({ type: 'shutdown' }, () => undefined) -} - -/** Orderly shutdown that never holds relay teardown past the kill deadline. */ -export function shutdownRelayAiVaultServiceChild(child: ChildProcess): Promise { - child.send({ type: 'shutdown' }, () => undefined) - return new Promise((resolve) => { - const timer = setTimeout(() => { - child.kill() - resolve() - }, 2_000) - timer.unref?.() - child.once('exit', () => { - clearTimeout(timer) - resolve() - }) - }) -} diff --git a/src/relay/ai-vault-service-client.test.ts b/src/relay/ai-vault-service-client.test.ts deleted file mode 100644 index 10eaeb1a660..00000000000 --- a/src/relay/ai-vault-service-client.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { - AiVaultServiceTestChild, - readyAiVaultServiceChild -} from '../main/ai-vault/session-scanner-service-test-child' -import { RelayAiVaultServiceClient } from './ai-vault-service-client' -import { RELAY_AI_VAULT_READY_TIMEOUT_MS } from './ai-vault-service-client-state' -import { relayAiVaultServiceEntryPath } from './ai-vault-service-spawn' - -function createClient( - children: AiVaultServiceTestChild[], - idleTimeoutMs?: number -): RelayAiVaultServiceClient { - return new RelayAiVaultServiceClient({ - init: { - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }, - processFactory: () => { - const child = new AiVaultServiceTestChild(20_000 + children.length) - children.push(child) - return child.asChildProcess() - }, - idleTimeoutMs - }) -} - -function relayRequests(child: AiVaultServiceTestChild, operation: string): { id: number }[] { - return child.sent.filter( - (message) => (message as { operation?: string }).operation === operation - ) as { id: number }[] -} - -function relayRequestCount(child: AiVaultServiceTestChild, operation: string): number { - return relayRequests(child, operation).length -} - -function relayRequestId(child: AiVaultServiceTestChild, operation: string): number { - const request = relayRequests(child, operation).at(-1) - if (!request) { - throw new Error(`No ${operation} request was sent.`) - } - return request.id -} - -afterEach(() => vi.useRealTimers()) - -describe('RelayAiVaultServiceClient', () => { - it('holds list and title calls behind the ready handshake', async () => { - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children) - const list = client.listSessions({ limit: 20 }) - const titles = client.resolveSessionTitles([]) - const child = children[0]! - - expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })]) - readyAiVaultServiceChild(child) - await Promise.resolve() - const listRequest = child.sent.find( - (message) => (message as { operation?: string }).operation === 'list' - ) as { id: number } - child.emit('message', { - type: 'result', - id: listRequest.id, - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } - }) - await expect(list).resolves.toMatchObject({ sessions: [] }) - const titleRequest = child.sent.find( - (message) => (message as { operation?: string }).operation === 'titles' - ) as { id: number } - child.emit('message', { - type: 'result', - id: titleRequest.id, - operation: 'titles', - value: { titles: [] } - }) - await expect(titles).resolves.toEqual({ titles: [] }) - const disposing = client.dispose() - child.emit('exit', 0) - await disposing - }) - - it('resolves titles while a scan still occupies the cache lane', async () => { - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children) - const list = client.listSessions({}) - const child = children[0]! - readyAiVaultServiceChild(child) - await Promise.resolve() - - const titles = client.resolveSessionTitles([ - { agent: 'claude', sessionId: 'session-1', transcriptPath: '/home/ada/session-1.jsonl' } - ]) - await Promise.resolve() - child.emit('message', { - type: 'result', - id: relayRequestId(child, 'titles'), - operation: 'titles', - value: { titles: [] } - }) - - await expect(titles).resolves.toEqual({ titles: [] }) - child.emit('message', { - type: 'result', - id: relayRequestId(child, 'list'), - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } - }) - await expect(list).resolves.toMatchObject({ sessions: [] }) - const disposing = client.dispose() - child.emit('exit', 0) - await disposing - }) - - it('does not start queued cache work until cancelled work acknowledges', async () => { - vi.useFakeTimers() - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children) - const controller = new AbortController() - const first = client.listSessions({}, controller.signal) - const second = client.listSessions({ limit: 5 }) - const child = children[0]! - readyAiVaultServiceChild(child) - await Promise.resolve() - const firstRequest = relayRequestId(child, 'list') - - controller.abort() - await expect(first).rejects.toMatchObject({ name: 'AbortError' }) - expect(relayRequestCount(child, 'list')).toBe(1) - child.emit('message', { - type: 'error', - id: firstRequest, - message: 'aborted' - }) - await Promise.resolve() - expect(relayRequestCount(child, 'list')).toBe(2) - void second.catch(() => undefined) - const disposing = client.dispose() - child.emit('exit', 0) - await disposing - }) - - it('drops a call cancelled before the sidecar received it', async () => { - vi.useFakeTimers() - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children) - const controller = new AbortController() - const cancelled = client.listSessions({}, controller.signal) - const child = children[0]! - - controller.abort() - await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) - expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' })) - - readyAiVaultServiceChild(child) - const next = client.listSessions({ limit: 5 }) - await Promise.resolve() - child.emit('message', { - type: 'result', - id: relayRequestId(child, 'list'), - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } - }) - await expect(next).resolves.toMatchObject({ sessions: [] }) - vi.advanceTimersByTime(2_000) - - expect(child.killed).toBe(false) - const disposing = client.dispose() - child.emit('exit', 0) - await disposing - }) - - it('retries a cold-start crash once without faulting the replacement sidecar', async () => { - vi.useFakeTimers() - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children) - const list = client.listSessions({}) - - children[0]!.emit('exit', 1) - await Promise.resolve() - vi.advanceTimersByTime(250) - expect(children).toHaveLength(2) - readyAiVaultServiceChild(children[1]!) - await Promise.resolve() - vi.advanceTimersByTime(RELAY_AI_VAULT_READY_TIMEOUT_MS) - - expect(children[1]!.killed).toBe(false) - children[1]!.emit('message', { - type: 'result', - id: relayRequestId(children[1]!, 'list'), - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } - }) - await expect(list).resolves.toMatchObject({ sessions: [] }) - const disposing = client.dispose() - children[1]!.emit('exit', 0) - await disposing - }) - - it('keeps the sidecar alive once a cancelled call acknowledges', async () => { - vi.useFakeTimers() - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children) - const controller = new AbortController() - const first = client.listSessions({}, controller.signal) - const second = client.resolveSessionTitles([]) - const child = children[0]! - readyAiVaultServiceChild(child) - await Promise.resolve() - const firstRequest = child.sent.find( - (message) => (message as { operation?: string }).operation === 'list' - ) as { id: number } - - controller.abort() - await expect(first).rejects.toMatchObject({ name: 'AbortError' }) - child.emit('message', { type: 'error', id: firstRequest.id, message: 'aborted' }) - await Promise.resolve() - vi.advanceTimersByTime(2_000) - - expect(child.killed).toBe(false) - const titleRequest = child.sent.find( - (message) => (message as { operation?: string }).operation === 'titles' - ) as { id: number } - child.emit('message', { - type: 'result', - id: titleRequest.id, - operation: 'titles', - value: { titles: [] } - }) - await expect(second).resolves.toEqual({ titles: [] }) - const disposing = client.dispose() - child.emit('exit', 0) - await disposing - }) - - it('restarts queued work after a sidecar crash with bounded backoff', async () => { - vi.useFakeTimers() - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children) - const first = client.listSessions({}) - const second = client.listSessions({ limit: 5 }) - readyAiVaultServiceChild(children[0]!) - await Promise.resolve() - - children[0]!.emit('exit', 1) - await expect(first).rejects.toThrow('exited') - expect(children).toHaveLength(1) - vi.advanceTimersByTime(250) - expect(children).toHaveLength(2) - readyAiVaultServiceChild(children[1]!) - await Promise.resolve() - children[1]!.emit('message', { - type: 'result', - id: relayRequestId(children[1]!, 'list'), - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } - }) - await expect(second).resolves.toMatchObject({ sessions: [] }) - const disposing = client.dispose() - children[1]!.emit('exit', 0) - await disposing - }) - - it('retires the sidecar after the idle bound', async () => { - vi.useFakeTimers() - const children: AiVaultServiceTestChild[] = [] - const client = createClient(children, 100) - const list = client.listSessions({}) - const child = children[0]! - readyAiVaultServiceChild(child) - await Promise.resolve() - const request = child.sent.find( - (message) => (message as { operation?: string }).operation === 'list' - ) as { id: number } - child.emit('message', { - type: 'result', - id: request.id, - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } - }) - await list - - vi.advanceTimersByTime(99) - expect(child.sent).not.toContainEqual({ type: 'shutdown' }) - vi.advanceTimersByTime(1) - expect(child.sent).toContainEqual({ type: 'shutdown' }) - vi.advanceTimersByTime(2_000) - expect(child.killed).toBe(true) - await client.dispose() - }) - - it('resolves the sidecar beside each bundled relay', () => { - expect(relayAiVaultServiceEntryPath('/opt/orca/relay')).toBe( - '/opt/orca/relay/relay-ai-vault-service.js' - ) - }) -}) diff --git a/src/relay/ai-vault-service-client.ts b/src/relay/ai-vault-service-client.ts deleted file mode 100644 index 9197bce1080..00000000000 --- a/src/relay/ai-vault-service-client.ts +++ /dev/null @@ -1,318 +0,0 @@ -import type { ChildProcess } from 'node:child_process' -import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { AiVaultListResult } from '../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../shared/ai-vault-session-title' -import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' -import { - RELAY_AI_VAULT_MAX_CALLS, - RELAY_AI_VAULT_IDLE_TIMEOUT_MS, - RELAY_AI_VAULT_READY_TIMEOUT_MS, - armRelayAiVaultCallTimeout, - armRelayAiVaultCancellationTimeout, - createRelayAiVaultServiceCall, - relayAiVaultAbortError, - relayAiVaultError, - RelayAiVaultIdleRetirement, - requeueRelayAiVaultServiceStart, - retireRelayAiVaultServiceChild, - settleRelayAiVaultServiceCall, - shutdownRelayAiVaultServiceChild, - type RelayAiVaultServiceApi, - type RelayAiVaultServiceCall, - type RelayAiVaultServiceClientOptions -} from './ai-vault-service-client-state' -import { - RELAY_AI_VAULT_SERVICE_PROTOCOL, - isRelayAiVaultServiceChildMessage, - type RelayAiVaultServiceChildMessage, - type RelayAiVaultServiceInit, - type RelayAiVaultServiceLane, - type RelayAiVaultServiceRequest -} from './ai-vault-service-protocol' -import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy' -import { relayLogLine } from './relay-diagnostic-log' - -export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { - private child: ChildProcess | null = null - private ready: PromiseSettlementWaiters | null = null - private readyReject: ((error: Error) => void) | null = null - private readyTimer: NodeJS.Timeout | null = null - private readonly active = new Map() - private readonly queue: RelayAiVaultServiceCall[] = [] - private nextId = 1 - private readonly restartPolicy: RelayAiVaultRestartPolicy - private readonly idleRetirement = new RelayAiVaultIdleRetirement() - private disposed = false - - constructor(private readonly options: RelayAiVaultServiceClientOptions) { - this.restartPolicy = new RelayAiVaultRestartPolicy(options.now) - } - - listSessions( - params: SshAiVaultRelayListParams, - signal?: AbortSignal - ): Promise { - return this.request({ type: 'request', id: this.nextId++, operation: 'list', params }, signal) - } - - resolveSessionTitles( - requests: AiVaultSessionTitleRequest[], - signal?: AbortSignal - ): Promise { - return this.request( - { type: 'request', id: this.nextId++, operation: 'titles', requests }, - signal - ) - } - - async dispose(): Promise { - this.disposed = true - this.idleRetirement.clear() - this.clearReadyTimer() - this.restartPolicy.dispose() - const error = new Error('Relay AI Vault service was disposed.') - this.readyReject?.(error) - this.readyReject = null - this.ready = null - for (const call of [...this.active.values(), ...this.queue.splice(0)]) { - settleRelayAiVaultServiceCall(call, error) - } - this.active.clear() - const child = this.detachChild() - if (child) { - await shutdownRelayAiVaultServiceChild(child) - } - } - - private request( - request: RelayAiVaultServiceRequest, - signal?: AbortSignal - ): Promise { - if (this.disposed) { - return Promise.reject(new Error('Relay AI Vault service was disposed.')) - } - if (signal?.aborted) { - return Promise.reject(relayAiVaultAbortError()) - } - if (this.queue.length + this.active.size >= RELAY_AI_VAULT_MAX_CALLS) { - return Promise.reject(new Error('Relay AI Vault service queue is full.')) - } - return new Promise((resolve, reject) => { - const call = createRelayAiVaultServiceCall({ - request, - signal, - resolve: resolve as RelayAiVaultServiceCall['resolve'], - reject - }) - if (signal) { - call.onAbort = () => this.cancel(call) - signal.addEventListener('abort', call.onAbort, { once: true }) - } - this.queue.push(call) - this.idleRetirement.clear() - this.pump() - }) - } - - private pump(): void { - if (this.disposed || this.restartPolicy.restartScheduled) { - return - } - for (const lane of ['cache', 'interactive'] as const) { - if (this.active.has(lane)) { - continue - } - const index = this.queue.findIndex((call) => call.lane === lane) - if (index === -1) { - continue - } - const call = this.queue.splice(index, 1)[0]! - this.active.set(lane, call) - const attempt = ++call.startAttempt - const readiness = this.ensureChild(call.forceStart) - void readiness - .wait({ - signal: call.signal, - createAbortError: relayAiVaultAbortError, - onFulfilled: (child) => this.sendCall(child, call) - }) - .catch((error: Error) => { - if (this.active.get(lane) !== call || call.startAttempt !== attempt) { - return - } - this.active.delete(lane) - this.retryStartOrSettle(call, error) - this.pump() - }) - } - this.scheduleIdleIfNeeded() - } - - private sendCall(child: ChildProcess, call: RelayAiVaultServiceCall): void { - if (this.active.get(call.lane) !== call || call.settled) { - return - } - armRelayAiVaultCallTimeout(call, (timeout) => - this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`)) - ) - call.sent = true - child.send(call.request) - } - - private retryStartOrSettle(call: RelayAiVaultServiceCall, error: Error): void { - if (this.disposed || !requeueRelayAiVaultServiceStart(call, this.queue)) { - settleRelayAiVaultServiceCall(call, error) - } - } - - private ensureChild(forceStart: boolean): PromiseSettlementWaiters { - if (this.child && !this.ready) { - return new PromiseSettlementWaiters(Promise.resolve(this.child)) - } - if (this.ready) { - return this.ready - } - const startError = this.restartPolicy.startError(forceStart) - if (startError) { - return new PromiseSettlementWaiters(Promise.reject(startError)) - } - let child: ChildProcess - try { - child = this.options.processFactory() - } catch (error) { - return new PromiseSettlementWaiters(Promise.reject(relayAiVaultError(error))) - } - this.child = child - this.ready = new PromiseSettlementWaiters( - new Promise((resolve, reject) => { - this.readyReject = reject - // Why: held on the instance so a crash before ready cannot leave the deadline - // armed, where it would later fault the healthy replacement sidecar. - this.readyTimer = setTimeout( - () => this.onFault(new Error('Relay AI Vault service did not become ready.')), - RELAY_AI_VAULT_READY_TIMEOUT_MS - ) - this.readyTimer.unref?.() - child.on('message', (message) => { - if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') { - this.clearReadyTimer() - this.ready = null - this.readyReject = null - resolve(child) - return - } - this.onMessage(message) - }) - }) - ) - child.on('error', (error) => this.onFault(error)) - child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.'))) - child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`))) - child.stderr?.on('data', (chunk: Buffer) => - relayLogLine(`[relay-ai-vault-service] ${String(chunk).trimEnd()}`) - ) - child.send({ - type: 'init', - protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, - ...this.options.init - } satisfies RelayAiVaultServiceInit) - return this.ready - } - - private onMessage(raw: unknown): void { - if (!isRelayAiVaultServiceChildMessage(raw)) { - this.onFault(new Error('Relay AI Vault service sent a malformed message.')) - return - } - const message = raw as RelayAiVaultServiceChildMessage - if (message.type === 'ready') { - return - } - const call = [...this.active.values()].find((entry) => entry.request.id === message.id) - if (!call) { - return - } - this.active.delete(call.lane) - settleRelayAiVaultServiceCall( - call, - message.type === 'error' ? new Error(message.message) : message.value - ) - this.pump() - } - - private cancel(call: RelayAiVaultServiceCall): void { - const index = this.queue.indexOf(call) - if (index !== -1) { - this.queue.splice(index, 1) - settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) - this.pump() - return - } - if (this.active.get(call.lane) === call) { - // Why: a call cancelled before it reached the sidecar gets no acknowledgement, - // so waiting on one would kill a healthy sidecar and stall the lane. - if (!call.sent) { - this.active.delete(call.lane) - settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) - this.pump() - return - } - this.child?.send({ type: 'cancel', id: call.request.id }) - settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) - armRelayAiVaultCancellationTimeout(call, () => - this.onFault(new Error('Relay AI Vault service did not cancel within 2000ms.')) - ) - } - } - - private onFault(error: Error): void { - if (!this.child) { - return - } - this.idleRetirement.clear() - this.detachChild()?.kill() - this.clearReadyTimer() - this.readyReject?.(error) - this.readyReject = null - this.ready = null - const active = [...this.active.values()] - this.active.clear() - for (const call of active) { - this.retryStartOrSettle(call, error) - } - this.restartPolicy.recordFault() - if (this.queue.length > 0 && !this.disposed) { - this.restartPolicy.scheduleRestart(() => this.pump()) - } - } - - private clearReadyTimer(): void { - if (this.readyTimer) { - clearTimeout(this.readyTimer) - this.readyTimer = null - } - } - - private detachChild(): ChildProcess | null { - const child = this.child - this.child = null - child?.removeAllListeners() - return child - } - - private scheduleIdleIfNeeded(): void { - this.idleRetirement.schedule( - this.active.size > 0 || this.queue.length > 0 || !this.child, - this.options.idleTimeoutMs ?? RELAY_AI_VAULT_IDLE_TIMEOUT_MS, - () => { - const child = this.detachChild() - if (child) { - retireRelayAiVaultServiceChild(child) - } - } - ) - } -} diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts deleted file mode 100644 index 5d2f66fe3d9..00000000000 --- a/src/relay/ai-vault-service-entry.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' -import { resolveAbsoluteDirOverride } from '../shared/absolute-dir-override' -import { joinRemotePath } from '../main/ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' -import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' -import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' -import { - RELAY_AI_VAULT_SERVICE_PROTOCOL, - isRelayAiVaultServiceRequest, - relayAiVaultServiceLane, - type RelayAiVaultServiceChildMessage, - type RelayAiVaultServiceInit, - type RelayAiVaultServiceParentMessage, - type RelayAiVaultServiceRequest -} from './ai-vault-service-protocol' - -if (!process.send) { - throw new Error('Relay AI Vault service requires a parent IPC channel.') -} - -const controllers = new Map() -const cancelled = new Set() -const pending = new Set() -let provider: ReturnType | null = null -let init: RelayAiVaultServiceInit | null = null -let cacheLane = Promise.resolve() -let interactiveLane = Promise.resolve() -let shuttingDown = false - -function send(message: RelayAiVaultServiceChildMessage): void { - process.send?.(message) -} - -async function execute(request: RelayAiVaultServiceRequest): Promise { - const controller = new AbortController() - controllers.set(request.id, controller) - if (cancelled.delete(request.id)) { - controller.abort() - } - try { - if (!init || !provider) { - throw new Error('Relay AI Vault service is not initialized.') - } - if (request.operation === 'titles') { - const value = await readAiVaultSessionTitlesFromFiles(request.requests, { - signal: controller.signal - }) - send({ type: 'result', id: request.id, operation: 'titles', value }) - return - } - const value = await scanRemoteAiVaultSessions({ - provider, - executionHostId: LOCAL_EXECUTION_HOST_ID, - remoteHome: init.remoteHome, - kiroHomeDir: resolveAbsoluteDirOverride( - process.env.KIRO_HOME, - joinRemotePath(init.hostPlatform, init.remoteHome, '.kiro') - ), - hostPlatform: init.hostPlatform, - limit: request.params.limit, - unlimited: request.params.unlimited, - includeAntigravityIdeSessions: request.params.includeAntigravityIdeSessions, - scopePaths: request.params.scopePaths, - signal: controller.signal - }) - send({ type: 'result', id: request.id, operation: 'list', value }) - } catch (error) { - send({ - type: 'error', - id: request.id, - message: error instanceof Error ? error.message : String(error) - }) - } finally { - controllers.delete(request.id) - cancelled.delete(request.id) - pending.delete(request.id) - } -} - -async function shutdown(): Promise { - if (shuttingDown) { - return - } - shuttingDown = true - for (const controller of controllers.values()) { - controller.abort() - } - await Promise.allSettled([cacheLane, interactiveLane]) - provider?.dispose() - process.disconnect?.() -} - -process.on('message', (raw: RelayAiVaultServiceParentMessage) => { - if (raw?.type === 'init') { - if (init || raw.protocol !== RELAY_AI_VAULT_SERVICE_PROTOCOL) { - void shutdown() - return - } - init = raw - provider = createRelayAiVaultFilesystemProvider({ homeDirectory: raw.remoteHome }) - send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid }) - return - } - if (!init || shuttingDown) { - return - } - if (raw?.type === 'cancel') { - if (!pending.has(raw.id)) { - return - } - cancelled.add(raw.id) - controllers.get(raw.id)?.abort() - return - } - if (raw?.type === 'shutdown') { - void shutdown() - return - } - if (!isRelayAiVaultServiceRequest(raw)) { - return - } - if (pending.size >= 16) { - send({ type: 'error', id: raw.id, message: 'Relay AI Vault service queue is full.' }) - return - } - pending.add(raw.id) - if (relayAiVaultServiceLane(raw.operation) === 'interactive') { - interactiveLane = interactiveLane.then(() => execute(raw)) - return - } - cacheLane = cacheLane.then(() => execute(raw)) -}) - -process.on('disconnect', () => void shutdown()) diff --git a/src/relay/ai-vault-service-factory.ts b/src/relay/ai-vault-service-factory.ts deleted file mode 100644 index c6a20640662..00000000000 --- a/src/relay/ai-vault-service-factory.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { RelayAiVaultServiceClient } from './ai-vault-service-client' -import { spawnRelayAiVaultService } from './ai-vault-service-spawn' - -export function createRelayAiVaultService( - remoteHome: string, - hostPlatform: RemoteHostPlatform -): RelayAiVaultServiceClient { - return new RelayAiVaultServiceClient({ - init: { remoteHome, hostPlatform }, - processFactory: spawnRelayAiVaultService - }) -} diff --git a/src/relay/ai-vault-service-filesystem.ts b/src/relay/ai-vault-service-filesystem.ts deleted file mode 100644 index 58b1b1f2d6e..00000000000 --- a/src/relay/ai-vault-service-filesystem.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { readRelayTranscriptBytes } from './ai-vault-transcript-stream' -import { lstat, readdir } from 'node:fs/promises' -import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' -import { readRelayFileContent } from './fs-handler-file-read' -import { - createRelayOpenCodeReader, - type RelayOpenCodeReaderOptions -} from './ai-vault-opencode-reader' - -export function createRelayAiVaultFilesystemProvider( - options: RelayOpenCodeReaderOptions = {} -): RemoteSessionFilesystemProvider & { dispose(): void } { - const openCode = createRelayOpenCodeReader(options) - return { - openCode, - dispose: () => openCode.dispose(), - async readDir(dirPath) { - const entries = await readdir(dirPath, { withFileTypes: true }) - return entries.map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - }, - readFile: readRelayFileContent, - readTranscriptBytes: readRelayTranscriptBytes, - async stat(filePath) { - const stats = await lstat(filePath) - return { - size: stats.size, - type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', - mtime: stats.mtimeMs, - mtimeMs: stats.mtimeMs, - dev: stats.dev, - ino: stats.ino, - nlink: stats.nlink - } - } - } -} diff --git a/src/relay/ai-vault-service-protocol.ts b/src/relay/ai-vault-service-protocol.ts deleted file mode 100644 index dbbef94f2f9..00000000000 --- a/src/relay/ai-vault-service-protocol.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { AiVaultListResult } from '../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../shared/ai-vault-session-title' -import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' -import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' - -export const RELAY_AI_VAULT_SERVICE_PROTOCOL = 1 - -export type RelayAiVaultServiceInit = { - type: 'init' - protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL - remoteHome: string - hostPlatform: RemoteHostPlatform -} - -export type RelayAiVaultServiceRequest = - | { - type: 'request' - id: number - operation: 'list' - params: SshAiVaultRelayListParams - } - | { - type: 'request' - id: number - operation: 'titles' - requests: AiVaultSessionTitleRequest[] - } - -export type RelayAiVaultServiceLane = 'cache' | 'interactive' -export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation'] - -/** Title reads must not queue behind a full scan; they back interactive UI. */ -export function relayAiVaultServiceLane( - operation: RelayAiVaultServiceOperation -): RelayAiVaultServiceLane { - return operation === 'titles' ? 'interactive' : 'cache' -} - -export type RelayAiVaultServiceParentMessage = - | RelayAiVaultServiceInit - | RelayAiVaultServiceRequest - | { type: 'cancel'; id: number } - | { type: 'shutdown' } - -export type RelayAiVaultServiceChildMessage = - | { - type: 'ready' - protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL - pid: number - } - | { type: 'result'; id: number; operation: 'list'; value: AiVaultListResult } - | { - type: 'result' - id: number - operation: 'titles' - value: AiVaultSessionTitlesResult - } - | { type: 'error'; id: number; message: string } - -export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVaultServiceRequest { - if (!value || typeof value !== 'object') { - return false - } - const message = value as Record - return ( - message.type === 'request' && - Number.isSafeInteger(message.id) && - (message.operation === 'list' || message.operation === 'titles') - ) -} - -export function isRelayAiVaultServiceChildMessage( - value: unknown -): value is RelayAiVaultServiceChildMessage { - if (!value || typeof value !== 'object') { - return false - } - const message = value as Record - if (message.type === 'ready') { - return message.protocol === RELAY_AI_VAULT_SERVICE_PROTOCOL && Number.isSafeInteger(message.pid) - } - return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id) -} diff --git a/src/relay/ai-vault-service-ready-retention.test.ts b/src/relay/ai-vault-service-ready-retention.test.ts deleted file mode 100644 index 53f845a4e90..00000000000 --- a/src/relay/ai-vault-service-ready-retention.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { setImmediate } from 'node:timers/promises' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { - AiVaultServiceTestChild, - readyAiVaultServiceChild -} from '../main/ai-vault/session-scanner-service-test-child' -import type { AiVaultSessionTitleRequest } from '../shared/ai-vault-session-title' -import { RelayAiVaultServiceClient } from './ai-vault-service-client' - -function fixture() { - const child = new AiVaultServiceTestChild() - const processFactory = vi.fn(() => child.asChildProcess()) - const client = new RelayAiVaultServiceClient({ - processFactory, - init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } - }) - return { client, child, processFactory } -} - -async function collect(): Promise { - if (!global.gc) { - throw new Error('This regression requires --expose-gc') - } - for (let turn = 0; turn < 3; turn++) { - await setImmediate() - global.gc() - } -} - -function titleRequests(): AiVaultSessionTitleRequest[] { - return [{ agent: 'claude', sessionId: 'session', transcriptPath: '/home/ada/session.jsonl' }] -} - -beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })) -afterEach(() => { - vi.clearAllTimers() - vi.useRealTimers() -}) - -describe('AI Vault readiness ownership', () => { - it('releases canceled request payloads while readiness remains pending', async () => { - const { client, child, processFactory } = fixture() - const canceled = await (async () => { - const refs: WeakRef[] = [] - for (let index = 0; index < 1000; index++) { - const requests = titleRequests() - refs.push(new WeakRef(requests)) - const controller = new AbortController() - const result = client.resolveSessionTitles(requests, controller.signal) - controller.abort() - const error = await result.catch((reason: unknown) => reason) - if (!(error instanceof Error) || error.name !== 'AbortError') { - throw new Error('Canceled title request did not reject with AbortError') - } - } - return refs - })() - await collect() - expect(canceled.filter((ref) => ref.deref() !== undefined)).toHaveLength(0) - expect(child.sent).toHaveLength(1) - expect(processFactory).toHaveBeenCalledOnce() - - const live = client.resolveSessionTitles(titleRequests()) - readyAiVaultServiceChild(child) - await Promise.resolve() - expect(child.sent.at(-1)).toMatchObject({ type: 'request', id: 1001, operation: 'titles' }) - child.emit('message', { type: 'result', id: 1001, operation: 'titles', value: { titles: [] } }) - await expect(live).resolves.toEqual({ titles: [] }) - const disposed = client.dispose() - child.emit('exit', 0) - await disposed - expect(vi.getTimerCount()).toBe(0) - }) - - it('releases uncanceled readiness payloads when the client is disposed', async () => { - const { client, child } = fixture() - const discarded = await (async () => { - const requests = titleRequests() - const ref = new WeakRef(requests) - const result = client.resolveSessionTitles(requests) - const rejection = expect(result).rejects.toThrow('disposed') - const disposed = client.dispose() - child.emit('exit', 0) - await disposed - await rejection - return ref - })() - await collect() - expect(discarded.deref() === undefined).toBe(true) - expect(child.sent).toEqual([expect.objectContaining({ type: 'init' }), { type: 'shutdown' }]) - expect(vi.getTimerCount()).toBe(0) - await expect(client.resolveSessionTitles([])).rejects.toThrow('disposed') - }) - - it('does not send a call canceled in the turn that readiness arrives', async () => { - const { client, child } = fixture() - const controller = new AbortController() - const canceled = client.resolveSessionTitles(titleRequests(), controller.signal) - readyAiVaultServiceChild(child) - controller.abort() - const live = client.resolveSessionTitles([]) - await expect(canceled).rejects.toMatchObject({ name: 'AbortError' }) - expect(child.sent).toEqual([ - expect.objectContaining({ type: 'init' }), - { type: 'request', id: 2, operation: 'titles', requests: [] } - ]) - child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } }) - await expect(live).resolves.toEqual({ titles: [] }) - const disposed = client.dispose() - child.emit('exit', 0) - await disposed - }) -}) - -it('retries an unsent lane after spawn fails while the other lane starts a child', async () => { - const child = new AiVaultServiceTestChild() - const processFactory = vi.fn(() => child.asChildProcess()) - processFactory.mockImplementationOnce(() => { - throw new Error('temporary spawn failure') - }) - const client = new RelayAiVaultServiceClient({ - processFactory, - init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } - }) - const list = client.listSessions({}) - const titles = client.resolveSessionTitles([]) - void list.catch(() => undefined) - void titles.catch(() => undefined) - try { - readyAiVaultServiceChild(child) - await vi.waitFor(() => { - expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} }) - }) - expect(processFactory).toHaveBeenCalledTimes(2) - expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} }) - expect(child.sent).toContainEqual({ type: 'request', id: 2, operation: 'titles', requests: [] }) - child.emit('message', { - type: 'result', - id: 1, - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' } - }) - child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } }) - await expect(list).resolves.toMatchObject({ sessions: [] }) - await expect(titles).resolves.toEqual({ titles: [] }) - } finally { - const disposed = client.dispose() - child.emit('exit', 0) - await disposed - } -}) - -it('ignores an earlier readiness rejection after a replacement startup begins', async () => { - const first = new AiVaultServiceTestChild() - const replacement = new AiVaultServiceTestChild() - const processFactory = vi.fn(() => replacement.asChildProcess()) - processFactory.mockImplementationOnce(() => first.asChildProcess()) - const client = new RelayAiVaultServiceClient({ - processFactory, - init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } - }) - const list = client.listSessions({}) - void list.catch(() => undefined) - try { - first.emit('exit', 1) - // Start the replacement before the old readiness rejection's microtasks run. - vi.advanceTimersByTime(250) - const titles = client.resolveSessionTitles([]) - void titles.catch(() => undefined) - readyAiVaultServiceChild(replacement) - await setImmediate() - - expect(processFactory).toHaveBeenCalledTimes(2) - expect(first.sent).toEqual([expect.objectContaining({ type: 'init' })]) - expect(replacement.sent).toEqual([ - expect.objectContaining({ type: 'init' }), - { type: 'request', id: 1, operation: 'list', params: {} }, - { type: 'request', id: 2, operation: 'titles', requests: [] } - ]) - replacement.emit('message', { - type: 'result', - id: 1, - operation: 'list', - value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' } - }) - replacement.emit('message', { - type: 'result', - id: 2, - operation: 'titles', - value: { titles: [] } - }) - await expect(list).resolves.toMatchObject({ sessions: [] }) - await expect(titles).resolves.toEqual({ titles: [] }) - expect(processFactory).toHaveBeenCalledTimes(2) - } finally { - const disposed = client.dispose() - replacement.emit('exit', 0) - await disposed - } - expect(vi.getTimerCount()).toBe(0) -}) diff --git a/src/relay/ai-vault-service-restart-policy.test.ts b/src/relay/ai-vault-service-restart-policy.test.ts deleted file mode 100644 index 8b9608be216..00000000000 --- a/src/relay/ai-vault-service-restart-policy.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy' - -afterEach(() => vi.useRealTimers()) - -describe('RelayAiVaultRestartPolicy', () => { - it('opens the circuit after three faults inside the window and closes it after', () => { - let now = 0 - const policy = new RelayAiVaultRestartPolicy(() => now) - - expect(policy.startError(false)).toBeNull() - for (let index = 0; index < 3; index += 1) { - policy.recordFault() - now += 1_000 - } - - expect(policy.startError(false)?.message).toContain('circuit is open') - now += 60_000 - expect(policy.startError(false)).toBeNull() - }) - - it('keeps the circuit closed when the faults age out of the window', () => { - let now = 0 - const policy = new RelayAiVaultRestartPolicy(() => now) - - for (let index = 0; index < 3; index += 1) { - policy.recordFault() - now += 30_000 - } - - expect(policy.startError(false)).toBeNull() - }) - - it('lets a forced refresh reopen an open circuit', () => { - let now = 0 - const policy = new RelayAiVaultRestartPolicy(() => now) - - for (let index = 0; index < 3; index += 1) { - policy.recordFault() - } - expect(policy.startError(false)?.message).toContain('circuit is open') - - expect(policy.startError(true)).toBeNull() - // The forced start clears the circuit outright, so the next background - // start is no longer refused either. - expect(policy.startError(false)).toBeNull() - }) - - it('backs off further with each fault and keeps one pending restart', () => { - vi.useFakeTimers() - const policy = new RelayAiVaultRestartPolicy() - const restart = vi.fn() - - policy.recordFault() - policy.scheduleRestart(restart) - vi.advanceTimersByTime(250) - expect(restart).toHaveBeenCalledTimes(1) - - policy.recordFault() - policy.scheduleRestart(restart) - policy.recordFault() - policy.scheduleRestart(restart) - vi.advanceTimersByTime(250) - expect(restart).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(5_000) - expect(restart).toHaveBeenCalledTimes(2) - }) - - it('cancels the pending restart on dispose', () => { - vi.useFakeTimers() - const policy = new RelayAiVaultRestartPolicy() - const restart = vi.fn() - - policy.recordFault() - policy.scheduleRestart(restart) - policy.dispose() - vi.advanceTimersByTime(10_000) - - expect(restart).not.toHaveBeenCalled() - expect(policy.restartScheduled).toBe(false) - }) -}) diff --git a/src/relay/ai-vault-service-restart-policy.ts b/src/relay/ai-vault-service-restart-policy.ts deleted file mode 100644 index e764ea66f6e..00000000000 --- a/src/relay/ai-vault-service-restart-policy.ts +++ /dev/null @@ -1,55 +0,0 @@ -const RELAY_AI_VAULT_FAULT_WINDOW_MS = 60_000 -const RELAY_AI_VAULT_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const - -export class RelayAiVaultRestartPolicy { - private faults: number[] = [] - private circuitUntil = 0 - private timer: NodeJS.Timeout | null = null - - constructor(private readonly now: () => number = Date.now) {} - - get restartScheduled(): boolean { - return this.timer !== null - } - - /** A forced refresh is a deliberate user action, so it reopens the circuit. */ - startError(forceStart: boolean): Error | null { - if (forceStart) { - this.circuitUntil = 0 - return null - } - return this.now() < this.circuitUntil - ? new Error('Relay AI Vault service restart circuit is open.') - : null - } - - recordFault(): void { - const now = this.now() - this.faults = [ - ...this.faults.filter((time) => now - time < RELAY_AI_VAULT_FAULT_WINDOW_MS), - now - ] - if (this.faults.length >= 3) { - this.circuitUntil = now + RELAY_AI_VAULT_FAULT_WINDOW_MS - } - } - - scheduleRestart(restart: () => void): void { - if (this.timer) { - clearTimeout(this.timer) - } - const delay = RELAY_AI_VAULT_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)] - this.timer = setTimeout(() => { - this.timer = null - restart() - }, delay) - this.timer.unref?.() - } - - dispose(): void { - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - } -} diff --git a/src/relay/ai-vault-service-spawn.test.ts b/src/relay/ai-vault-service-spawn.test.ts deleted file mode 100644 index 596c25afc2e..00000000000 --- a/src/relay/ai-vault-service-spawn.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ChildProcess } from 'node:child_process' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const forkMock = vi.hoisted(() => vi.fn()) - -vi.mock('node:child_process', () => ({ fork: forkMock })) - -const { spawnRelayAiVaultService } = await import('./ai-vault-service-spawn') - -function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } { - return forkMock.mock.calls.at(-1)?.[2] ?? {} -} - -describe('spawnRelayAiVaultService', () => { - beforeEach(() => { - forkMock.mockReset() - forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess) - }) - - it('keeps NODE_OPTIONS out of the sidecar so the heap cap and loader stand', () => { - vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js') - spawnRelayAiVaultService() - const options = forkOptions() - - // Asserted first: omitting `env` entirely inherits everything, and would - // leave the NODE_OPTIONS assertion below passing for the wrong reason. - expect(options.env).toBeDefined() - expect(options.env?.NODE_OPTIONS).toBeUndefined() - expect(options.execArgv).toEqual(['--max-old-space-size=384']) - vi.unstubAllEnvs() - }) - - it('does not hand the sidecar the rest of the remote login environment', () => { - vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'shhh') - spawnRelayAiVaultService() - const options = forkOptions() - - expect(options.env).toBeDefined() - expect(options.env?.AWS_SECRET_ACCESS_KEY).toBeUndefined() - vi.unstubAllEnvs() - }) -}) diff --git a/src/relay/ai-vault-service-spawn.ts b/src/relay/ai-vault-service-spawn.ts deleted file mode 100644 index 90c5dbae67a..00000000000 --- a/src/relay/ai-vault-service-spawn.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { fork, type ChildProcess } from 'node:child_process' -import { join } from 'node:path' -import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env' -import { lowerAiVaultServicePriority } from '../main/ai-vault/session-scanner-service-priority' - -export function relayAiVaultServiceEntryPath(baseDir = __dirname): string { - return join(baseDir, 'relay-ai-vault-service.js') -} - -export function spawnRelayAiVaultService(): ChildProcess { - const child = fork(relayAiVaultServiceEntryPath(), [], { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - execArgv: ['--max-old-space-size=384'], - env: buildRelayAiVaultServiceEnv(), - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - }) - lowerAiVaultServicePriority(child.pid) - child.unref() - return child -} diff --git a/src/relay/ai-vault-transcript-stream.ts b/src/relay/ai-vault-transcript-stream.ts deleted file mode 100644 index 7f88a6e4dbe..00000000000 --- a/src/relay/ai-vault-transcript-stream.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { open } from 'node:fs/promises' -import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation' -import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines' -import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' - -/** The same open handle supplies the probe and stream, including across renames. */ -export async function* readRelayTranscriptBytes( - path: string, - signal?: AbortSignal, - options?: { regularFileOnly: true; maxBytes: number } -): AsyncGenerator { - throwIfAiVaultScanCancelled(signal) - if (options?.regularFileOnly) { - const read = await readNodeFileWithinLimit(path, options.maxBytes, { - regularFileOnly: true, - signal - }) - if (isBinaryBuffer(read.buffer.subarray(0, BINARY_PROBE_BYTES))) { - throw new BinarySessionTranscriptError() - } - throwIfAiVaultScanCancelled(signal) - yield read.buffer - return - } - const handle = await open(path, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - if (isBinaryBuffer(probe.subarray(0, bytesRead))) { - throw new BinarySessionTranscriptError() - } - const input = handle.createReadStream({ start: 0, autoClose: false, signal }) - try { - for await (const chunk of input) { - throwIfAiVaultScanCancelled(signal) - if (!Buffer.isBuffer(chunk)) { - throw new TypeError('Expected transcript byte buffer') - } - yield chunk - } - } finally { - input.destroy() - } - } finally { - await handle.close() - } -} diff --git a/src/relay/context.test.ts b/src/relay/context.test.ts deleted file mode 100644 index 7de8bf9cd0e..00000000000 --- a/src/relay/context.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { homedir } from 'node:os' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { expandTilde } from './context' - -describe('expandTilde', () => { - it('expands POSIX-style home paths', () => { - expect(expandTilde('~/projects')).toBe(resolve(homedir(), 'projects')) - }) - - it('expands Windows-style home paths without forcing POSIX separators', () => { - expect(expandTilde('~\\projects')).toBe(`${homedir()}\\projects`) - }) -}) diff --git a/src/relay/context.ts b/src/relay/context.ts deleted file mode 100644 index b07f484364d..00000000000 --- a/src/relay/context.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { resolve } from 'node:path' -import { homedir } from 'node:os' - -// Why: Node's fs APIs don't understand shell tilde expansion. Old repos may -// have been stored with `~` or `~/…` paths before the client-side fix, so the -// relay must expand them to absolute paths as a safety net. -export function expandTilde(p: string): string { - if (p === '~' || p === '~/' || p === '~\\') { - return homedir() - } - if (p.startsWith('~/')) { - return resolve(homedir(), p.slice(2)) - } - if (p.startsWith('~\\')) { - return `${homedir()}\\${p.slice(2)}` - } - return p -} - -// Why: the relay runs as the SSH user and trusts the renderer process. A -// compromised renderer can already weaponize pty.spawn and git.exec to reach -// any path the SSH user can reach, so the FS-side allowlist provided friction -// without meaningfully narrowing the blast radius. See -// docs/relay-fs-allowlist-removal.md. -// -// registerRoot is retained as a no-op so existing session.registerRoot RPC -// calls (notification + request) remain valid during the relay-deploy upgrade -// window where an old main may still call into a new relay (and vice versa). -// Tracked for deletion once the relay-version floor moves past the cutover. -export class RelayContext { - registerRoot(_rootPath: string): void { - // intentionally empty - } -} diff --git a/src/relay/external-automation-command-executor.ts b/src/relay/external-automation-command-executor.ts deleted file mode 100644 index 83f899241e9..00000000000 --- a/src/relay/external-automation-command-executor.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - EXTERNAL_AUTOMATION_JOB_ID_PATTERN, - externalAutomationProvider, - isExternalAutomationAction, - type ExternalAutomationAction -} from './external-automation-provider' - -export type ExternalAutomationCommandRunner = ( - command: string, - args: string[], - options: { encoding: 'utf-8'; timeout: number } -) => Promise - -type HermesJobMutation = { - name: string - prompt: string - schedule: string - workdir: string -} - -function hermesCommand(action: ExternalAutomationAction): string { - switch (action) { - case 'pause': - return 'pause' - case 'resume': - return 'resume' - case 'run': - return 'run' - case 'delete': - return 'remove' - } -} - -function openClawCommand(action: ExternalAutomationAction): string { - switch (action) { - case 'pause': - return 'disable' - case 'resume': - return 'enable' - case 'run': - return 'run' - case 'delete': - return 'rm' - } -} - -function normalizeHermesJobMutation(params: Record): HermesJobMutation { - const provider = externalAutomationProvider(params.provider) - if (provider !== 'hermes') { - throw new Error('Only Hermes cron creation and editing are supported.') - } - const name = typeof params.name === 'string' ? params.name.trim() : '' - const prompt = typeof params.prompt === 'string' ? params.prompt.trim() : '' - const schedule = typeof params.schedule === 'string' ? params.schedule.trim() : '' - const workdir = typeof params.workdir === 'string' ? params.workdir.trim() : '' - if (!prompt) { - throw new Error('Hermes cron requires a prompt.') - } - if (!schedule) { - throw new Error('Hermes cron requires a schedule.') - } - return { - name: name || prompt.slice(0, 50).trim(), - prompt, - schedule, - workdir - } -} - -export class ExternalAutomationCommandExecutor { - constructor( - private readonly runCommand: ExternalAutomationCommandRunner, - private readonly clearHermesRunCount: (jobId?: string) => void - ) {} - - async createJob(params: Record = {}): Promise<{ ok: true }> { - const input = normalizeHermesJobMutation(params) - const args = [ - 'cron', - 'create', - input.schedule, - input.prompt, - '--name', - input.name, - '--deliver', - 'local' - ] - if (input.workdir) { - args.push('--workdir', input.workdir) - } - await this.runHermesCronCommand(args) - this.clearHermesRunCount() - return { ok: true } - } - - async updateJob(params: Record = {}): Promise<{ ok: true }> { - const input = normalizeHermesJobMutation(params) - const jobId = params.jobId - if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - const args = [ - 'cron', - 'edit', - jobId, - '--schedule', - input.schedule, - '--prompt', - input.prompt, - '--name', - input.name - ] - if (input.workdir) { - args.push('--workdir', input.workdir) - } - await this.runHermesCronCommand(args) - this.clearHermesRunCount(jobId) - return { ok: true } - } - - async runAction(params: Record = {}): Promise<{ ok: true }> { - const provider = externalAutomationProvider(params.provider) - const action = params.action - const jobId = params.jobId - if (!isExternalAutomationAction(action)) { - throw new Error('Unsupported external automation action.') - } - if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - const command = provider === 'hermes' ? hermesCommand(action) : openClawCommand(action) - await this.runCommand(provider, ['cron', command, jobId], { - encoding: 'utf-8', - timeout: 30_000 - }) - if (provider === 'hermes') { - this.clearHermesRunCount(jobId) - } - return { ok: true } - } - - private async runHermesCronCommand(args: string[]): Promise { - await this.runCommand('hermes', args, { - encoding: 'utf-8', - timeout: 30_000 - }) - } -} diff --git a/src/relay/external-automation-provider-catalog.test.ts b/src/relay/external-automation-provider-catalog.test.ts deleted file mode 100644 index d2a600b073e..00000000000 --- a/src/relay/external-automation-provider-catalog.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -describe('ExternalAutomationProviderCatalog', () => { - let hermesHome: string - let previousHermesHome: string | undefined - - beforeEach(async () => { - previousHermesHome = process.env.HERMES_HOME - hermesHome = await mkdtemp(join(tmpdir(), 'relay-hermes-catalog-')) - process.env.HERMES_HOME = hermesHome - await mkdir(join(hermesHome, 'cron'), { recursive: true }) - vi.resetModules() - }) - - afterEach(async () => { - if (previousHermesHome === undefined) { - delete process.env.HERMES_HOME - } else { - process.env.HERMES_HOME = previousHermesHome - } - await rm(hermesHome, { recursive: true, force: true }) - vi.resetModules() - }) - - it('returns readable jobs when the remote CLI is unavailable', async () => { - await writeFile( - join(hermesHome, 'cron', 'jobs.json'), - JSON.stringify({ jobs: [{ id: 'job-1', name: 'Monitor' }] }), - 'utf-8' - ) - const { ExternalAutomationProviderCatalog } = - await import('./external-automation-provider-catalog') - const catalog = new ExternalAutomationProviderCatalog( - vi.fn().mockRejectedValue(new Error('not found')), - vi.fn().mockResolvedValue({ total: 3, runs: [] }) - ) - - await expect(catalog.listJobs({ provider: 'hermes' })).resolves.toEqual({ - jobs: [{ id: 'job-1', name: 'Monitor', run_count: 3, runs: [] }], - hermesAvailable: false, - openclawAvailable: false, - error: null - }) - }) - - it('projects jobs-file parse failures without hiding command availability', async () => { - await writeFile(join(hermesHome, 'cron', 'jobs.json'), '{not-json', 'utf-8') - const { ExternalAutomationProviderCatalog } = - await import('./external-automation-provider-catalog') - const catalog = new ExternalAutomationProviderCatalog( - vi.fn().mockResolvedValue(undefined), - vi.fn() - ) - - const result = await catalog.listJobs({ provider: 'hermes' }) - - expect(result.jobs).toEqual([]) - expect(result.hermesAvailable).toBe(true) - expect(result.openclawAvailable).toBe(false) - expect(result.error).toMatch(/^SyntaxError:/) - }) - - it('preserves all-or-nothing Hermes count enrichment failures', async () => { - await writeFile( - join(hermesHome, 'cron', 'jobs.json'), - JSON.stringify([{ id: 'job-1' }, { id: '--invalid' }]), - 'utf-8' - ) - const { ExternalAutomationProviderCatalog } = - await import('./external-automation-provider-catalog') - const catalog = new ExternalAutomationProviderCatalog( - vi.fn().mockResolvedValue(undefined), - vi - .fn() - .mockResolvedValueOnce({ total: 1, runs: [] }) - .mockRejectedValueOnce(new Error('Invalid external automation job ID.')) - ) - - const result = await catalog.listJobs({ provider: 'hermes' }) - - expect(result.jobs).toEqual([]) - expect(result.error).toBe('Error: Invalid external automation job ID.') - }) -}) diff --git a/src/relay/external-automation-provider-catalog.ts b/src/relay/external-automation-provider-catalog.ts deleted file mode 100644 index 06408160e98..00000000000 --- a/src/relay/external-automation-provider-catalog.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { existsSync } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { HERMES_JOBS_FILE, OPENCLAW_JOBS_FILE } from './external-automation-storage-paths' -import { - externalAutomationProvider, - type ExternalAutomationProvider -} from './external-automation-provider' -import type { ExternalAutomationCommandRunner } from './external-automation-command-executor' - -type HermesRunLister = (params: Record) => Promise<{ - total: number - runs: unknown[] -}> - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export class ExternalAutomationProviderCatalog { - constructor( - private readonly runCommand: ExternalAutomationCommandRunner, - private readonly listRuns: HermesRunLister - ) {} - - async listJobs(params?: Record): Promise<{ - jobs: unknown[] - hermesAvailable: boolean - openclawAvailable: boolean - error: string | null - }> { - const provider = externalAutomationProvider(params?.provider) - const [commandAvailable, jobsResult] = await Promise.allSettled([ - this.isCommandAvailable(provider), - this.readJobs(provider) - ]) - const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] - const available = commandAvailable.status === 'fulfilled' && commandAvailable.value - return { - jobs, - hermesAvailable: provider === 'hermes' && available, - openclawAvailable: provider === 'openclaw' && available, - error: jobsResult.status === 'rejected' ? String(jobsResult.reason) : null - } - } - - private async isCommandAvailable(command: string): Promise { - const finder = process.platform === 'win32' ? 'where' : 'which' - try { - await this.runCommand(finder, [command], { - encoding: 'utf-8', - timeout: 5000 - }) - return true - } catch { - return false - } - } - - private async readJobs(provider: ExternalAutomationProvider): Promise { - const jobsFile = provider === 'hermes' ? HERMES_JOBS_FILE : OPENCLAW_JOBS_FILE - if (!existsSync(jobsFile)) { - return [] - } - const content = await readFile(jobsFile, 'utf-8') - const parsed = JSON.parse(content) as unknown - const jobs = Array.isArray(parsed) - ? parsed - : isRecord(parsed) && Array.isArray(parsed.jobs) - ? parsed.jobs - : [] - if (provider !== 'hermes') { - return jobs - } - return Promise.all( - jobs.map(async (job) => { - if (!isRecord(job) || typeof job.id !== 'string') { - return job - } - const runsPage = await this.listRuns({ - provider: 'hermes', - jobId: job.id, - page: 1, - pageSize: 0 - }) - return { - ...job, - run_count: runsPage.total, - runs: runsPage.runs - } - }) - ) - } -} diff --git a/src/relay/external-automation-provider.ts b/src/relay/external-automation-provider.ts deleted file mode 100644 index 08028ab5439..00000000000 --- a/src/relay/external-automation-provider.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type ExternalAutomationProvider = 'hermes' | 'openclaw' - -export type ExternalAutomationAction = 'pause' | 'resume' | 'run' | 'delete' - -export const EXTERNAL_AUTOMATION_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ - -export function externalAutomationProvider(value: unknown): ExternalAutomationProvider { - return value === 'openclaw' ? 'openclaw' : 'hermes' -} - -export function isExternalAutomationAction(value: unknown): value is ExternalAutomationAction { - return value === 'pause' || value === 'resume' || value === 'run' || value === 'delete' -} diff --git a/src/relay/external-automation-storage-paths.ts b/src/relay/external-automation-storage-paths.ts deleted file mode 100644 index 5079c8bf960..00000000000 --- a/src/relay/external-automation-storage-paths.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' - -export const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') -export const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') -export const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') -export const HERMES_OUTPUT_DIR = join(HERMES_CRON_DIR, 'output') -export const HERMES_STATE_DB = join(HERMES_HOME, 'state.db') -export const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') diff --git a/src/relay/external-automations-handler-log-path.test.ts b/src/relay/external-automations-handler-log-path.test.ts deleted file mode 100644 index ca865c86540..00000000000 --- a/src/relay/external-automations-handler-log-path.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -describe('ExternalAutomationsHandler referenced log paths', () => { - let hermesHome: string - let previousHermesHome: string | undefined - - beforeEach(async () => { - previousHermesHome = process.env.HERMES_HOME - hermesHome = await mkdtemp(join(tmpdir(), 'relay-hermes-output-')) - process.env.HERMES_HOME = hermesHome - vi.resetModules() - }) - - afterEach(async () => { - if (previousHermesHome === undefined) { - delete process.env.HERMES_HOME - } else { - process.env.HERMES_HOME = previousHermesHome - } - await rm(hermesHome, { recursive: true, force: true }) - vi.resetModules() - }) - - it('hydrates referenced logs in valid dot-dot-prefixed Hermes subdirectories', async () => { - const logPath = join(hermesHome, '..logs', 'x-monitor.log') - await mkdir(dirname(logPath), { recursive: true }) - await writeFile(logPath, 'remote dot-dot-prefixed log line\n', 'utf-8') - - const { readHermesReferencedLogFile } = await import('./hermes-output-run-files') - - const result = await readHermesReferencedLogFile(`Latest log path: ${logPath} -Run summary: monitor automation completed successfully.`) - - expect(result?.content).toBe('remote dot-dot-prefixed log line\n') - }) -}) diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts deleted file mode 100644 index 887d902cc9a..00000000000 --- a/src/relay/external-automations-handler.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ExternalAutomationCommandExecutor } from './external-automation-command-executor' -import { ExternalAutomationsHandler } from './external-automations-handler' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -const execFileMock = vi.hoisted(() => - vi.fn((...args: unknown[]) => { - const callback = args.at(-1) - if (typeof callback === 'function') { - const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void - execCallback(null, '', '') - } - }) -) - -vi.mock('child_process', () => ({ execFile: execFileMock })) - -type CapturedHandler = (params?: Record) => Promise - -function createHandlerHarness(): Map { - const requestHandlers = new Map() - const dispatcher = { - onRequest(method: string, handler: CapturedHandler): void { - requestHandlers.set(method, handler) - } - } - new ExternalAutomationsHandler(dispatcher as unknown as RelayDispatcher) - return requestHandlers -} - -beforeEach(() => { - execFileMock.mockClear() -}) - -describe('ExternalAutomationsHandler', () => { - it('preserves the five external automation routes', () => { - expect([...createHandlerHarness().keys()]).toEqual([ - 'externalAutomations.list', - 'externalAutomations.runs', - 'externalAutomations.create', - 'externalAutomations.update', - 'externalAutomations.act' - ]) - }) - - it('runs lifecycle actions without shell wrapping', async () => { - const requestHandlers = createHandlerHarness() - - await requestHandlers.get('externalAutomations.act')?.({ - provider: 'hermes', - action: 'run', - jobId: 'job-1' - }) - - expect(execFileMock).toHaveBeenCalledWith( - 'hermes', - ['cron', 'run', 'job-1'], - { encoding: 'utf-8', timeout: 30_000 }, - expect.any(Function) - ) - }) - - it('propagates command failures through the registered route', async () => { - const commandError = new Error('remote Hermes failed') - execFileMock.mockImplementationOnce((...args: unknown[]) => { - const callback = args.at(-1) as (error: Error) => void - callback(commandError) - }) - const requestHandlers = createHandlerHarness() - - await expect( - requestHandlers.get('externalAutomations.act')?.({ - provider: 'hermes', - action: 'run', - jobId: 'job-1' - }) - ).rejects.toBe(commandError) - }) -}) - -describe('ExternalAutomationCommandExecutor', () => { - it.each([ - ['hermes', 'pause', 'pause'], - ['hermes', 'resume', 'resume'], - ['hermes', 'run', 'run'], - ['hermes', 'delete', 'remove'], - ['openclaw', 'pause', 'disable'], - ['openclaw', 'resume', 'enable'], - ['openclaw', 'run', 'run'], - ['openclaw', 'delete', 'rm'] - ])('maps %s %s to its provider command', async (provider, action, command) => { - const runCommand = vi.fn().mockResolvedValue(undefined) - const executor = new ExternalAutomationCommandExecutor(runCommand, vi.fn()) - - await executor.runAction({ provider, action, jobId: 'job-1' }) - - expect(runCommand).toHaveBeenCalledWith(provider, ['cron', command, 'job-1'], { - encoding: 'utf-8', - timeout: 30_000 - }) - }) - - it('does not clear count state when a command fails', async () => { - const commandError = new Error('command failed') - const clearRunCount = vi.fn() - const executor = new ExternalAutomationCommandExecutor( - vi.fn().mockRejectedValue(commandError), - clearRunCount - ) - - await expect( - executor.runAction({ provider: 'hermes', action: 'run', jobId: 'job-1' }) - ).rejects.toBe(commandError) - expect(clearRunCount).not.toHaveBeenCalled() - }) - - it('preserves Hermes create arguments and clears all cached counts after success', async () => { - const runCommand = vi.fn().mockResolvedValue(undefined) - const clearRunCount = vi.fn() - const executor = new ExternalAutomationCommandExecutor(runCommand, clearRunCount) - - await executor.createJob({ - provider: 'hermes', - name: 'Nightly review', - prompt: 'Review the workspace', - schedule: '0 2 * * *', - workdir: '/remote/workspace' - }) - - expect(runCommand).toHaveBeenCalledWith( - 'hermes', - [ - 'cron', - 'create', - '0 2 * * *', - 'Review the workspace', - '--name', - 'Nightly review', - '--deliver', - 'local', - '--workdir', - '/remote/workspace' - ], - { encoding: 'utf-8', timeout: 30_000 } - ) - expect(clearRunCount).toHaveBeenCalledWith() - }) - - it('preserves Hermes update arguments and clears only the edited job count', async () => { - const runCommand = vi.fn().mockResolvedValue(undefined) - const clearRunCount = vi.fn() - const executor = new ExternalAutomationCommandExecutor(runCommand, clearRunCount) - - await executor.updateJob({ - provider: 'hermes', - jobId: 'job-1', - name: 'Updated review', - prompt: 'Review changes', - schedule: '15 3 * * *' - }) - - expect(runCommand).toHaveBeenCalledWith( - 'hermes', - [ - 'cron', - 'edit', - 'job-1', - '--schedule', - '15 3 * * *', - '--prompt', - 'Review changes', - '--name', - 'Updated review' - ], - { encoding: 'utf-8', timeout: 30_000 } - ) - expect(clearRunCount).toHaveBeenCalledWith('job-1') - }) - - it('preserves update validation order', async () => { - const executor = new ExternalAutomationCommandExecutor(vi.fn(), vi.fn()) - - await expect( - executor.updateJob({ provider: 'hermes', jobId: '--help', prompt: '', schedule: '' }) - ).rejects.toThrow('Hermes cron requires a prompt.') - }) -}) diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts deleted file mode 100644 index c9805376d95..00000000000 --- a/src/relay/external-automations-handler.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - ExternalAutomationCommandExecutor, - type ExternalAutomationCommandRunner -} from './external-automation-command-executor' -import { ExternalAutomationProviderCatalog } from './external-automation-provider-catalog' -import { HermesRunHistory } from './hermes-run-history' - -const execFileAsync = promisify(execFile) - -const runExternalAutomationCommand: ExternalAutomationCommandRunner = async ( - command, - args, - options -) => { - await execFileAsync(command, args, options) -} - -export class ExternalAutomationsHandler { - private readonly runHistory = new HermesRunHistory() - private readonly commands = new ExternalAutomationCommandExecutor( - runExternalAutomationCommand, - (jobId) => this.runHistory.clearRunCount(jobId) - ) - private readonly providers = new ExternalAutomationProviderCatalog( - runExternalAutomationCommand, - (params) => this.runHistory.listRuns(params) - ) - - constructor(dispatcher: RelayDispatcher) { - dispatcher.onRequest('externalAutomations.list', (params) => this.providers.listJobs(params)) - dispatcher.onRequest('externalAutomations.runs', (params) => this.runHistory.listRuns(params)) - dispatcher.onRequest('externalAutomations.create', (params) => this.commands.createJob(params)) - dispatcher.onRequest('externalAutomations.update', (params) => this.commands.updateJob(params)) - dispatcher.onRequest('externalAutomations.act', (params) => this.commands.runAction(params)) - } -} diff --git a/src/relay/freebuff-status-projection.test.ts b/src/relay/freebuff-status-projection.test.ts deleted file mode 100644 index b10214ef801..00000000000 --- a/src/relay/freebuff-status-projection.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { FreebuffStatusProjection } from './freebuff-status-projection' -import { createAgentStatusOscProcessor } from '../shared/agent-status-osc' - -const transcript = readFileSync( - join(import.meta.dirname, '../main/runtime/__fixtures__/freebuff-lifecycle.txt'), - 'utf8' -) - -describe('Freebuff relay status projection', () => { - it.each([1, 137, 4096, transcript.length])( - 'publishes host-observed states with %i-character PTY chunks', - (size) => { - const projection = new FreebuffStatusProjection(120, 40) - const parse = createAgentStatusOscProcessor() - const states: string[] = [] - try { - for (let index = 0; index < transcript.length; index += size) { - const raw = transcript.slice(index, index + size) - const projected = projection.project(raw) - // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. - expect(projected.replace(/\x1b\]9999;[^\x07]*\x07/g, '')).toBe(raw) - for (const payload of parse(projected).payloads) { - expect(payload.agentType).toBe('freebuff') - if (states.at(-1) !== payload.state) { - states.push(payload.state) - } - } - } - expect(states).toContain('working') - expect(states).toContain('waiting') - expect(states.at(-1)).toBe('done') - projection.resize(90, 30) - projection.dispose() - expect(projection.project('later output')).toBe('later output') - } finally { - projection.dispose() - } - } - ) -}) diff --git a/src/relay/freebuff-status-projection.ts b/src/relay/freebuff-status-projection.ts deleted file mode 100644 index 6e8ea7d9de3..00000000000 --- a/src/relay/freebuff-status-projection.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { HeadlessEmulator } from '../main/daemon/headless-emulator' -import { - FreebuffScreenStatusTracker, - splitFreebuffScreenUpdates -} from '../shared/freebuff-screen-status' - -/** Emits the existing status OSC on the execution host, preserving the PTY's raw byte credit. */ -export class FreebuffStatusProjection { - private readonly emulator: HeadlessEmulator - private readonly tracker = new FreebuffScreenStatusTracker(true) - private disposed = false - - constructor(cols: number, rows: number) { - this.emulator = new HeadlessEmulator({ cols, rows, scrollback: 0 }) - } - - project(data: string): string { - if (this.disposed) { - return data - } - return splitFreebuffScreenUpdates(data, this.emulator.partialEscapeTailAnsi) - .map((chunk) => { - if (!this.emulator.writeSync(chunk)) { - return chunk - } - const status = this.tracker.observe(chunk, () => ({ - lines: this.emulator.getVisibleLines(), - alternate: this.emulator.isAlternateScreen - })) - return status ? `${chunk}\x1b]9999;${JSON.stringify(status)}\x07` : chunk - }) - .join('') - } - - resize(cols: number, rows: number): void { - if (!this.disposed) { - this.emulator.resize(cols, rows) - } - } - - dispose(): void { - this.disposed = true - this.emulator.dispose() - } -} diff --git a/src/relay/fs-directory-listing.ts b/src/relay/fs-directory-listing.ts deleted file mode 100644 index 10aac1be4ff..00000000000 --- a/src/relay/fs-directory-listing.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { opendir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { DirectoryListingBudget } from '../shared/directory-listing-budget' -import type { DirEntry } from '../shared/filesystem-entry-types' -import { sortDirEntries } from '../shared/file-name-sort' -import { expandTilde } from './context' - -export async function readRelayDirectoryBounded( - dirPath: string, - signal?: AbortSignal, - options?: { followSymlinks?: boolean } -): Promise { - signal?.throwIfAborted() - const root = expandTilde(dirPath) - const budget = new DirectoryListingBudget() - const entries: DirEntry[] = [] - for await (const entry of await opendir(root)) { - signal?.throwIfAborted() - budget.record(entry.name) - const mapped = { - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - } - if (mapped.isSymlink && !mapped.isDirectory && options?.followSymlinks !== false) { - try { - mapped.isDirectory = (await stat(join(root, entry.name))).isDirectory() - } catch { - // Broken links remain visible as links. - } - } - entries.push(mapped) - } - return sortDirEntries(entries) -} diff --git a/src/relay/fs-file-listing-paths.ts b/src/relay/fs-file-listing-paths.ts deleted file mode 100644 index cf86945f20b..00000000000 --- a/src/relay/fs-file-listing-paths.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { normalizeQuickOpenRgLine } from '../shared/quick-open-filter' -import type { FileInventoryBudget } from '../shared/file-inventory-budget' -import type { QuickOpenPathRanker } from '../shared/quick-open-path-search' - -export function retainRelayFileListingPath( - rawLine: string, - ranker: QuickOpenPathRanker | null, - retention: { - files: Set - budget: FileInventoryBudget | null - includePath: (path: string) => boolean - } -): boolean { - const { files, budget, includePath } = retention - const relativePath = normalizeQuickOpenRgLine(rawLine, { kind: 'cwd-relative' }) - if (relativePath === null) { - return false - } - if (!includePath(relativePath)) { - return true - } - if (ranker) { - ranker.consider(relativePath) - } else if (!files.has(relativePath)) { - budget?.record(relativePath) - files.add(relativePath) - } - return true -} diff --git a/src/relay/fs-file-stream-shutdown.test.ts b/src/relay/fs-file-stream-shutdown.test.ts deleted file mode 100644 index 21c839bf828..00000000000 --- a/src/relay/fs-file-stream-shutdown.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { readRelayFileStreamMetadata } from './fs-handler-file-read' -import { RelayStreamRegistry } from './fs-stream-registry' -import { STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' - -let directory: string -let filePath: string -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-file-stream-shutdown-')) - filePath = join(directory, 'sample.png') - await writeFile(filePath, Buffer.alloc(12, 42)) -}) -afterEach(async () => { - vi.restoreAllMocks() - await rm(directory, { recursive: true, force: true }) -}) - -function fixture() { - const registry = new RelayStreamRegistry() - const bulk = vi.fn(async () => {}) - const terminal = vi.fn((_id, _method, _params, settled) => { - settled({ ok: true }) - return true - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the file stream reads only these two dispatcher methods. - const dispatcher = { notifyBulk: bulk, tryNotifyClient: terminal } as unknown as RelayDispatcher - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream reads only clientId and isStale from its context. - const context = { clientId: 1, isStale: () => false } as RequestContext - const start = (paceWithAcks = false) => - readRelayFileStreamMetadata(filePath, dispatcher, registry, context, { - clientId: 1, - paceWithAcks - }) - return { registry, bulk, terminal, start } -} - -it('waits for a scheduled pump and permanently fences later metadata requests', async () => { - const f = fixture() - const scheduled: (() => void)[] = [] - // A real, already-cleared handle satisfies the return type without scheduling anything. - const handle = setImmediate(() => {}) - clearImmediate(handle) - const schedule = vi.spyOn(globalThis, 'setImmediate').mockImplementation((callback) => { - scheduled.push(callback) - return handle - }) - await f.start() - schedule.mockRestore() - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(f.start()).rejects.toThrow('relay_file_stream_shutdown_fenced') - scheduled.forEach((run) => run()) - await drain - expect(f.bulk).not.toHaveBeenCalled() - expect(f.terminal).not.toHaveBeenCalled() -}) - -it.each(['resolve', 'reject'] as const)( - 'waits for an in-flight final chunk to %s without a terminal frame', - async (outcome) => { - const f = fixture() - const pending = Promise.withResolvers() - f.bulk.mockReturnValue(pending.promise) - await f.start() - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledOnce()) - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - if (outcome === 'resolve') { - pending.resolve() - } else { - pending.reject(new Error('transport closed')) - } - await drain - expect(f.terminal).not.toHaveBeenCalled() - } -) - -it('wakes an ACK-parked producer during shutdown', async () => { - const f = fixture() - await writeFile(filePath, Buffer.alloc(STREAM_CHUNK_SIZE * 6, 42)) - await f.start(true) - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledTimes(4)) - await f.registry.disposeAll() - expect(f.bulk).toHaveBeenCalledTimes(4) - expect(f.terminal).not.toHaveBeenCalled() -}) - -it('retains a binary-probe handle when its close fails and retries shutdown cleanup', async () => { - const f = fixture() - filePath = join(directory, 'sample.txt') - await writeFile(filePath, 'text content') - const failure = new Error('probe close failed') - const release = f.registry.releaseUnregisteredHandle.bind(f.registry) - vi.spyOn(f.registry, 'releaseUnregisteredHandle').mockImplementationOnce((handle) => { - vi.spyOn(handle, 'close').mockRejectedValueOnce(failure) - return release(handle) - }) - try { - await expect(f.start()).rejects.toBe(failure) - expect(f.registry.size()).toBe(1) - await f.registry.disposeAll() - expect(f.registry.size()).toBe(0) - } finally { - await f.registry.disposeAll() - } -}) diff --git a/src/relay/fs-handler-doc-preview.test.ts b/src/relay/fs-handler-doc-preview.test.ts deleted file mode 100644 index b3be04e233c..00000000000 --- a/src/relay/fs-handler-doc-preview.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { FsHandler } from './fs-handler' - -const fixtureRoots: string[] = [] - -afterEach(async () => { - await Promise.all( - fixtureRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) - ) -}) - -describe('FsHandler document previews', () => { - it('registers an execution-host read that rejects canonical symlink escapes', async () => { - const requestHandlers = new Map) => Promise>() - const dispatcher = { - onRequest: vi.fn( - (method: string, handler: (params: Record) => Promise) => { - requestHandlers.set(method, handler) - } - ), - onNotification: vi.fn(), - onClientDetached: vi.fn(), - notify: vi.fn(), - notifyClient: vi.fn() - } - new FsHandler(dispatcher as never, {} as never) - const readDocPreview = requestHandlers.get('fs.readDocPreview') - expect(readDocPreview).toBeTypeOf('function') - - const fixture = await mkdtemp(join(tmpdir(), 'orca-relay-doc-preview-')) - fixtureRoots.push(fixture) - const workspace = join(fixture, 'workspace') - const docs = join(workspace, 'docs') - const outside = join(fixture, 'outside') - await Promise.all([mkdir(docs, { recursive: true }), mkdir(outside)]) - const entryPath = join(docs, 'index.html') - await writeFile(entryPath, '

entry

') - await writeFile(join(outside, 'secret.txt'), 'secret') - const linkedOutside = join(docs, 'linked') - await symlink(outside, linkedOutside, process.platform === 'win32' ? 'junction' : 'dir') - const request = { - boundaryPath: workspace, - entryPath, - implicitRootPath: docs, - authorizedRootPaths: [], - maxTextBytes: 1024, - maxBinaryBytes: 1024 - } - - await expect(readDocPreview!({ ...request, targetPath: entryPath })).resolves.toEqual({ - content: '

entry

', - isBinary: false - }) - await expect( - readDocPreview!({ ...request, targetPath: join(linkedOutside, 'secret.txt') }) - ).rejects.toThrow('doc_preview_path_unauthorized') - }) -}) diff --git a/src/relay/fs-handler-file-range-dispatch.test.ts b/src/relay/fs-handler-file-range-dispatch.test.ts deleted file mode 100644 index 3e0ad07ccac..00000000000 --- a/src/relay/fs-handler-file-range-dispatch.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { FsHandler } from './fs-handler' -import { RelayContext } from './context' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, RelayErrorCode } from '../wsl-guest/protocol' -import { FileRangeReadRequestError, MAX_FILE_RANGE_READ_BYTES } from '../shared/file-range-read' - -/** Minimal dispatcher: this suite only needs the registered request handlers. - * The full harness lives in fs-handler.test.ts, which is at its line budget. */ -function createHandlerUnderTest() { - const requests = new Map) => Promise>() - const dispatcher = { - onRequest: (method: string, handler: (params: Record) => Promise) => { - requests.set(method, handler) - }, - onNotification: vi.fn(), - notify: vi.fn(), - notifyClient: vi.fn(), - onClientDetached: vi.fn(() => () => {}) - } - const handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - const call = (method: string, params: Record): Promise => { - const registered = requests.get(method) - if (!registered) { - throw new Error(`No handler for ${method}`) - } - return registered(params) - } - return { handler, call } -} - -let root: string -let filePath: string -let underTest: ReturnType - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-relay-range-dispatch-')) - filePath = join(root, 'data.jsonl') - await writeFile(filePath, '0123456789') - underTest = createHandlerUnderTest() -}) - -afterEach(async () => { - underTest.handler.dispose() - await rm(root, { recursive: true, force: true }) -}) - -describe('fs.readFileRange dispatch', () => { - it('serves the requested window', async () => { - const result = (await underTest.call('fs.readFileRange', { - filePath, - position: 2, - length: 3 - })) as { base64: string; bytesRead: number } - expect(Buffer.from(result.base64, 'base64').toString('utf8')).toBe('234') - expect(result.bytesRead).toBe(3) - }) - - // Params reach the handler as an unschema-d bag, so a missing or wrong-typed - // path must be a stated refusal rather than a TypeError from expandTilde. - it.each([ - ['a missing filePath', {}], - ['a non-string filePath', { filePath: 42 }], - ['an empty filePath', { filePath: '' }] - ])('rejects %s', async (_label, params) => { - await expect( - underTest.call('fs.readFileRange', { position: 0, length: 4, ...params }) - ).rejects.toBeInstanceOf(FileRangeReadRequestError) - }) - - it('rejects an out-of-contract offset before touching the file', async () => { - await expect( - underTest.call('fs.readFileRange', { filePath, position: -1, length: 4 }) - ).rejects.toBeInstanceOf(FileRangeReadRequestError) - }) -}) - -// A direct call to readRelayFileRange never meets the writer, which is where an -// over-wide window actually dies: a response frame past the control lane's -// budget is demoted to `legacy-response` and refused as ResponseOverCapacity. -// This drives a full-cap read through the real dispatcher instead. -describe('fs.readFileRange over the real dispatcher', () => { - function decodePayload(frame: Buffer): Record { - const length = frame.readUInt32BE(9) - return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) - } - - it('delivers a full-cap window as a result, not a capacity error', async () => { - const contents = Buffer.allocUnsafe(MAX_FILE_RANGE_READ_BYTES) - for (let i = 0; i < contents.length; i++) { - contents[i] = (i * 37) % 256 - } - const capPath = join(root, 'full-cap.bin') - await writeFile(capPath, contents) - - const frames: Buffer[] = [] - let closes = 0 - const dispatcher = new RelayDispatcher( - (data: Buffer) => { - frames.push(Buffer.from(data)) - return true - }, - { - writableHighWaterMark: () => 64 * 1024, - writableLength: () => 0, - close: () => { - closes++ - } - } - ) - const handler = new FsHandler(dispatcher, new RelayContext()) - try { - dispatcher.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: 91, - method: 'fs.readFileRange', - params: { filePath: capPath, position: 0, length: MAX_FILE_RANGE_READ_BYTES } - }, - 1, - 0 - ) - ) - await vi.waitFor(() => expect(frames).toHaveLength(1), { timeout: 4_000 }) - const response = decodePayload(frames[0]) as { - id: number - error?: { code: number } - result?: { base64: string; bytesRead: number } - } - expect(response.id).toBe(91) - expect(response.error?.code).not.toBe(RelayErrorCode.ResponseOverCapacity) - expect(response.error).toBeUndefined() - expect(response.result?.bytesRead).toBe(MAX_FILE_RANGE_READ_BYTES) - expect(Buffer.from(response.result?.base64 ?? '', 'base64').equals(contents)).toBe(true) - expect(closes).toBe(0) - } finally { - handler.dispose() - dispatcher.dispose() - } - }) -}) - -describe('fs.getCapabilities', () => { - // Rule 1 of docs/reference/remote-wire-compatibility.md: the ranged-read flag - // is additive. Dropping the pre-existing key would strand an older desktop's - // quick-open probe on a host that still serves it. - it('advertises ranged reads without dropping the existing capability', async () => { - await expect(underTest.call('fs.getCapabilities', {})).resolves.toMatchObject({ - quickOpenSearchVersion: 3, - rangedReadVersion: 1 - }) - }) -}) diff --git a/src/relay/fs-handler-file-range.test.ts b/src/relay/fs-handler-file-range.test.ts deleted file mode 100644 index 1d1d55f38b9..00000000000 --- a/src/relay/fs-handler-file-range.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { readRelayFileRange } from './fs-handler-file-range' -import { readFullStreamChunk } from './fs-handler-file-read' -import { - FileRangeReadRequestError, - MAX_FILE_RANGE_READ_BYTES, - validateFileRangeRequest -} from '../shared/file-range-read' -import { HEADER_LENGTH, prepareJsonRpcPayload } from '../wsl-guest/protocol' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' - -let roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))) - roots = [] -}) - -async function fileWith(contents: Buffer | string): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-range-')) - roots.push(root) - const path = join(root, 'data.bin') - await writeFile(path, contents) - return path -} - -function decode(base64: string): Buffer { - return Buffer.from(base64, 'base64') -} - -describe('readRelayFileRange', () => { - it('returns exactly the requested window', async () => { - const path = await fileWith('0123456789') - const result = await readRelayFileRange(path, 3, 4) - expect(decode(result.base64).toString('utf8')).toBe('3456') - expect(result.bytesRead).toBe(4) - }) - - it('reads from a non-zero position to the end', async () => { - const path = await fileWith('abcdef') - const result = await readRelayFileRange(path, 4, 2) - expect(decode(result.base64).toString('utf8')).toBe('ef') - }) - - // A short result must mean EOF and nothing else, or a caller advancing a - // cursor by bytesRead would silently skip data. - it('short-reads only at end of file', async () => { - const path = await fileWith('abc') - const result = await readRelayFileRange(path, 1, 100) - expect(result.bytesRead).toBe(2) - expect(decode(result.base64).toString('utf8')).toBe('bc') - }) - - it('returns zero bytes when the position is past the end', async () => { - const path = await fileWith('abc') - const result = await readRelayFileRange(path, 99, 10) - expect(result.bytesRead).toBe(0) - expect(decode(result.base64)).toHaveLength(0) - }) - - it('returns zero bytes when the position is exactly at the end', async () => { - const path = await fileWith('abc') - const result = await readRelayFileRange(path, 3, 10) - expect(result.bytesRead).toBe(0) - }) - - // A regular file answers a full-cap read in one syscall, so this pins offset - // arithmetic over a many-page window, not the fill loop -- that is covered - // against a stub reader in `readFullStreamChunk` below. - it('assembles a many-page window without misplacing any byte', async () => { - const size = MAX_FILE_RANGE_READ_BYTES - const contents = Buffer.allocUnsafe(size) - for (let i = 0; i < size; i++) { - contents[i] = i % 251 - } - const path = await fileWith(contents) - const result = await readRelayFileRange(path, 1024, size - 1024) - expect(result.bytesRead).toBe(size - 1024) - // .equals(), not toEqual(): a byte-wise diff of a 256 KiB buffer costs - // minutes when it passes. - expect(decode(result.base64).equals(contents.subarray(1024))).toBe(true) - }) - - // Base64 rather than utf-8: a range boundary can split a multi-byte sequence, - // and a utf-8 round trip would substitute U+FFFD and shift every later offset. - it('preserves bytes that split a multi-byte character at both edges', async () => { - const text = Buffer.from('aé漢b', 'utf8') - const path = await fileWith(text) - const middle = await readRelayFileRange(path, 1, 3) - expect(decode(middle.base64)).toEqual(text.subarray(1, 4)) - }) - - it('round-trips arbitrary binary bytes', async () => { - const bytes = Buffer.from([0x00, 0xff, 0x0a, 0x80, 0x7f]) - const path = await fileWith(bytes) - const result = await readRelayFileRange(path, 0, bytes.length) - expect(decode(result.base64)).toEqual(bytes) - }) - - // The cap is a TRANSPORT budget, not just a memory one. A response frame over - // DISPATCHER_CONTROL_QUEUE_MAX_BYTES is demoted to the `legacy-response` lane, - // which the writer refuses once the producer queue is busy -- so an over-wide - // cap fails with ResponseOverCapacity depending on unrelated load. - // - // Fitting the lane once is not enough: the control queue is a SHARED budget, - // so a full-cap frame has to leave room for a second one. Anything wider lets - // two pipelined tail reads -- or one read racing an unrelated response -- - // fail as ResponseOverCapacity on load that has nothing to do with them. - it('leaves control-queue headroom for a second full-cap window', async () => { - const contents = Buffer.allocUnsafe(MAX_FILE_RANGE_READ_BYTES) - for (let i = 0; i < contents.length; i++) { - contents[i] = (i * 37) % 256 - } - const path = await fileWith(contents) - const result = await readRelayFileRange(path, 0, MAX_FILE_RANGE_READ_BYTES) - expect(result.bytesRead).toBe(MAX_FILE_RANGE_READ_BYTES) - const frameBytes = - HEADER_LENGTH + prepareJsonRpcPayload({ jsonrpc: '2.0', id: 4294967295, result }).byteLength - expect(frameBytes).toBeLessThanOrEqual(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) - expect(frameBytes * 2).toBeLessThanOrEqual(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) - }) - - it('rejects a missing file', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-range-missing-')) - roots.push(root) - await expect(readRelayFileRange(join(root, 'nope.bin'), 0, 4)).rejects.toThrow() - }) - - // Every rejection below happens before the file is opened: these offsets go - // straight into a read syscall and the relay has no request schema. - describe('request validation', () => { - // Rejecting beats clamping: a clamped read is indistinguishable from EOF. - it('rejects an over-cap length instead of silently clamping', async () => { - const path = await fileWith('abc') - await expect( - readRelayFileRange(path, 0, MAX_FILE_RANGE_READ_BYTES + 1) - ).rejects.toBeInstanceOf(FileRangeReadRequestError) - }) - - it('accepts a length exactly at the cap', async () => { - const path = await fileWith('abc') - const result = await readRelayFileRange(path, 0, MAX_FILE_RANGE_READ_BYTES) - expect(result.bytesRead).toBe(3) - }) - - it.each([ - ['a negative position', -1, 4], - ['a fractional position', 1.5, 4], - ['a NaN position', Number.NaN, 4], - ['an infinite position', Number.POSITIVE_INFINITY, 4], - ['a position past the safe-integer range', Number.MAX_SAFE_INTEGER + 2, 4], - ['a window past the safe-integer range', Number.MAX_SAFE_INTEGER - 1, 3], - ['a zero length', 0, 0], - ['a negative length', 0, -4], - ['a fractional length', 0, 4.5], - ['a NaN length', 0, Number.NaN] - ])('rejects %s', async (_label, position, length) => { - const path = await fileWith('abc') - await expect(readRelayFileRange(path, position, length)).rejects.toBeInstanceOf( - FileRangeReadRequestError - ) - }) - - it.each([ - ['a missing position', undefined, 4], - ['a string position', '0', 4], - ['a missing length', 0, undefined], - ['a string length', 0, '4'] - ])('rejects %s from an unschema-d param bag', async (_label, position, length) => { - const path = await fileWith('abc') - await expect(readRelayFileRange(path, position, length)).rejects.toBeInstanceOf( - FileRangeReadRequestError - ) - }) - }) -}) - -// The fill loop is what makes "short result == EOF" true. A regular file hands -// back a whole window in one syscall, so the loop only shows up against a -// reader that answers partially -- a pipe, a network filesystem, a large read -// interrupted by a signal. -describe('readFullStreamChunk', () => { - function partialReader(source: Buffer, perCall: number) { - const calls: { offset: number; length: number; position: number }[] = [] - return { - calls, - read(buffer: Buffer, offset: number, length: number, position: number) { - calls.push({ offset, length, position }) - const slice = source.subarray(position, position + Math.min(length, perCall)) - slice.copy(buffer, offset) - return Promise.resolve({ bytesRead: slice.length }) - } - } - } - - it('fills the window across partial reads, each landing at its own offset', async () => { - const source = Buffer.from('abcdefghij') - const reader = partialReader(source, 3) - const buffer = Buffer.alloc(6) - const bytesRead = await readFullStreamChunk(reader, buffer, 6, 2) - expect(bytesRead).toBe(6) - expect(buffer.toString('utf8')).toBe('cdefgh') - expect(reader.calls).toEqual([ - { offset: 0, length: 6, position: 2 }, - { offset: 3, length: 3, position: 5 } - ]) - }) - - it('keeps every partial-read offset safe at the upper boundary', async () => { - const { position: start, length } = validateFileRangeRequest(Number.MAX_SAFE_INTEGER - 2, 3) - const calls: number[] = [] - const reader = { - read(buffer: Buffer, offset: number, _length: number, position: number) { - calls.push(position) - buffer[offset] = 1 - return Promise.resolve({ bytesRead: 1 }) - } - } - const bytesRead = await readFullStreamChunk(reader, Buffer.alloc(length), length, start) - expect(bytesRead).toBe(length) - expect(calls).toEqual([start, start + 1, Number.MAX_SAFE_INTEGER]) - expect(calls.every(Number.isSafeInteger)).toBe(true) - }) - - // Without the zero check the loop would spin forever on a reader at EOF. - it('stops at the first empty read and reports only what it filled', async () => { - const source = Buffer.from('abcd') - const reader = partialReader(source, 3) - const buffer = Buffer.alloc(16) - const bytesRead = await readFullStreamChunk(reader, buffer, 16, 0) - expect(bytesRead).toBe(4) - expect(buffer.subarray(0, bytesRead).toString('utf8')).toBe('abcd') - }) -}) diff --git a/src/relay/fs-handler-file-range.ts b/src/relay/fs-handler-file-range.ts deleted file mode 100644 index cba1ed9f594..00000000000 --- a/src/relay/fs-handler-file-range.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { open } from 'node:fs/promises' -import { validateFileRangeRequest } from '../shared/file-range-read' -import { readFullStreamChunk } from './fs-handler-file-read' - -/** Positional read for tailing an append-only file. Fills the window until - * `length` is satisfied or the file genuinely ends, so a short result always - * means EOF and never a partial syscall. Base64 because the payload is - * arbitrary bytes and may split a UTF-8 sequence at either edge. - * - * Validates here rather than at the dispatcher: this is the function that puts - * a caller-supplied offset into a read syscall, so it is the boundary that has - * to hold even if a future call site forgets. */ -export async function readRelayFileRange( - filePath: string, - rawPosition: unknown, - rawLength: unknown -): Promise<{ base64: string; bytesRead: number }> { - const { position, length } = validateFileRangeRequest(rawPosition, rawLength) - const handle = await open(filePath, 'r') - try { - // allocUnsafe over alloc: only `subarray(0, bytesRead)` is ever read back, so - // uninitialised bytes cannot escape, and a tailing poll with a generous - // `length` should not pay a memset over the whole window per call. - const buffer = Buffer.allocUnsafe(length) - const bytesRead = await readFullStreamChunk(handle, buffer, length, position) - return { base64: buffer.subarray(0, bytesRead).toString('base64'), bytesRead } - } finally { - await handle.close() - } -} diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts deleted file mode 100644 index ed666328b16..00000000000 --- a/src/relay/fs-handler-file-read.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { open, readFile, stat } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { extname } from 'node:path' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' -import type { RelayStreamRegistry } from './fs-stream-registry' -import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer' -import { - IMAGE_MIME_TYPES, - MAX_PREVIEWABLE_BINARY_SIZE, - MAX_TEXT_FILE_SIZE, - isBinaryFilePrefix -} from './fs-handler-utils' -import { errorMessage as describeError } from '../shared/error-message' - -export async function readRelayFileContent(filePath: string) { - const stats = await stat(filePath) - const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (mimeType) { - const buffer = await readFile(filePath) - return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } - } - - if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { - return { content: '', isBinary: true } - } - - const buffer = await readFile(filePath) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } -} - -export type StreamMetadata = { - streamId?: number - totalSize: number - isBinary: boolean - isImage?: boolean - mimeType?: string - /** On-the-wire encoding of each chunk's `data` field. Always 'base64'. */ - chunkEncoding?: 'base64' - /** Encoding of the assembled FileReadResult.content. */ - resultEncoding?: 'base64' | 'utf-8' - /** True for empty files and binary archives that short-circuit without pumping. */ - empty?: boolean -} - -type StreamChunkReader = { - read( - buffer: Buffer, - offset: number, - length: number, - position: number - ): Promise<{ bytesRead: number }> -} - -export type StreamPumpOptions = { - /** Client that requested the stream. Chunks go only to it — broadcasting - * bulk frames would let one slow secondary client stall the requester. */ - clientId?: number - /** True when the client declared `flowControl: 'ack'` — it sends - * fs.streamAck per processed chunk and the pump caps unacked chunks. */ - paceWithAcks: boolean -} - -export async function readRelayFileStreamMetadata( - filePath: string, - dispatcher: RelayDispatcher, - registry: RelayStreamRegistry, - context: RequestContext, - pumpOptions?: StreamPumpOptions -): Promise { - const finish = registry.beginOperation() - try { - return await prepareRelayFileStream(filePath, dispatcher, registry, context, pumpOptions) - } finally { - finish() - } -} - -async function prepareRelayFileStream( - filePath: string, - dispatcher: RelayDispatcher, - registry: RelayStreamRegistry, - context: RequestContext, - pumpOptions?: StreamPumpOptions -): Promise { - const stats = await stat(filePath) - const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (stats.size === 0) { - return { - totalSize: 0, - isBinary: !!mimeType, - mimeType, - isImage: mimeType ? true : undefined, - empty: true - } - } - // Why: unlike the legacy single-shot path, streaming does not read the full - // buffer before classifying content. Probe every unknown file so small binary - // files do not get decoded as UTF-8 text over SSH. - if ( - !mimeType && - (await isBinaryFilePrefix(filePath, (handle) => registry.releaseUnregisteredHandle(handle))) - ) { - return { totalSize: 0, isBinary: true, empty: true } - } - - // Why: reserved before the fd opens so a refusal costs nothing, and released only - // once the terminal frame settles — see reserveTerminalFrameSlot. - const releaseTerminalFrameSlot = registry.reserveTerminalFrameSlot(context.clientId) - let handle: FileHandle | undefined - let streamId: number - try { - handle = await open(filePath, 'r') - streamId = registry.register(handle) - } catch (err) { - try { - if (handle) { - await registry.releaseUnregisteredHandle(handle) - } - } finally { - releaseTerminalFrameSlot() - } - throw err - } - - process.stderr.write(`[relay] stream start id=${streamId} size=${stats.size}\n`) - - // Why: pumpChunks owns its own try/finally for handle release; the outer - // setImmediate kicks the pump off the metadata-response task so the client - // sees the response before the first chunk frame. - const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } - const finishPump = registry.beginOperation() - setImmediate(() => { - void pumpChunks( - streamId, - stats.size, - dispatcher, - registry, - context, - resolvedPumpOptions, - releaseTerminalFrameSlot - ) - .catch((error: unknown) => { - process.stderr.write(`[relay] stream cleanup failed id=${streamId}: ${String(error)}\n`) - }) - .finally(finishPump) - }) - - return { - streamId, - totalSize: stats.size, - isBinary: !!mimeType, - isImage: mimeType ? true : undefined, - mimeType, - chunkEncoding: 'base64', - resultEncoding: mimeType ? 'base64' : 'utf-8' - } -} - -async function pumpChunks( - streamId: number, - totalSize: number, - dispatcher: RelayDispatcher, - registry: RelayStreamRegistry, - context: RequestContext, - pumpOptions: StreamPumpOptions, - releaseTerminalFrameSlot: () => void -): Promise { - const entry = registry.get(streamId) - if (!entry) { - releaseTerminalFrameSlot() - return - } - const buffer = Buffer.allocUnsafe(STREAM_CHUNK_SIZE) - let offset = 0 - let seq = 0 - let endReason: 'end' | 'aborted' | 'stale' | 'error' = 'end' - let errorCode: string | null = null - let errorMessage: string | null = null - let slotReleaseDeferred = false - - try { - try { - while (offset < totalSize) { - if (context.isStale()) { - endReason = 'stale' - break - } - if (registry.isAborted(streamId)) { - endReason = 'aborted' - break - } - // Why: credit window — bulk chunks share one ordered SSH channel with - // interactive pty.data frames. Waiting for client acks bounds how many - // stream bytes a keystroke echo can queue behind, and yields the relay - // event loop so incoming keystrokes are handled between chunks. - if (pumpOptions.paceWithAcks) { - while ( - seq - registry.ackedThroughSeq(streamId) > STREAM_ACK_WINDOW_CHUNKS && - !context.isStale() && - !registry.isAborted(streamId) - ) { - await registry.waitForAck(streamId) - } - if (context.isStale()) { - endReason = 'stale' - break - } - if (registry.isAborted(streamId)) { - endReason = 'aborted' - break - } - } - const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) - const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset) - if (bytesRead !== want) { - endReason = 'error' - errorCode = 'ESTREAMTRUNCATED' - errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset + bytesRead}` - break - } - if (context.isStale()) { - endReason = 'stale' - break - } - if (registry.isAborted(streamId)) { - endReason = 'aborted' - break - } - const data = buffer.subarray(0, bytesRead).toString('base64') - // Why: the bulk lane waits out sink saturation, so a flood of chunk - // frames cannot pile up in the outbound pipe ahead of interactive - // pty.data frames written via plain notify(). - await dispatcher.notifyBulk( - 'fs.streamChunk', - { streamId, seq, data }, - pumpOptions.clientId !== undefined ? { clientId: pumpOptions.clientId } : undefined - ) - offset += bytesRead - seq += 1 - } - } catch (err) { - // Why: a read() rejection that races with disposeAll surfaces as EBADF; - // treat as aborted so we don't emit a spurious streamError to a client - // that is already gone. - const code = (err as { code?: string }).code - if (code === 'EBADF' && registry.isAborted(streamId)) { - endReason = 'aborted' - } else { - endReason = 'error' - errorCode = code ?? 'ESTREAMREAD' - errorMessage = describeError(err) - } - } - - try { - // Why: a dropped terminal frame hangs the reader forever, so it takes the control lane, which - // never drops — but that lane KILLS the link when it overflows, hence the reserved slot held - // until this frame settles. The per-chunk await already settled every chunk, so the control - // frame cannot overtake stream data. - const publishTerminal = (method: string, params: Record): void => { - if (pumpOptions.clientId === undefined) { - // Legacy broadcast path (direct calls/tests): no per-frame settlement to hold the slot on. - dispatcher.notifyControl(method, params) - return - } - slotReleaseDeferred = dispatcher.tryNotifyClient( - pumpOptions.clientId, - method, - params, - releaseTerminalFrameSlot - ) - } - if (registry.isAborted(streamId)) { - endReason = 'aborted' - } else if (context.isStale()) { - endReason = 'stale' - } - if (endReason === 'end') { - publishTerminal('fs.streamEnd', { streamId }) - process.stderr.write(`[relay] stream end id=${streamId}\n`) - } else if (endReason === 'error') { - publishTerminal('fs.streamError', { - streamId, - code: errorCode ?? 'ESTREAMERROR', - message: errorMessage ?? 'stream error' - }) - process.stderr.write(`[relay] stream error id=${streamId} code=${errorCode}\n`) - } else if (endReason === 'aborted') { - process.stderr.write(`[relay] stream cancel id=${streamId}\n`) - } else { - process.stderr.write(`[relay] stream stale id=${streamId}\n`) - } - } catch (err) { - process.stderr.write(`[relay] stream notify failed id=${streamId}: ${describeError(err)}\n`) - } - } finally { - // Why: the fd goes back first — a terminal frame that can never be delivered must not - // strand it. Cancelled/stale streams publish nothing, so nothing else frees their slot. - try { - await registry.release(streamId) - } finally { - if (!slotReleaseDeferred) { - releaseTerminalFrameSlot() - } - } - } -} - -// Why: fs.read() may return fewer bytes than requested before EOF. Fill each -// protocol chunk so strict clients reject corruption, not valid short reads. -// Shared with fs.readFileRange, where the same rule makes a short result mean -// EOF and nothing else. -export async function readFullStreamChunk( - handle: StreamChunkReader, - buffer: Buffer, - length: number, - offset: number -): Promise { - let totalRead = 0 - while (totalRead < length) { - const { bytesRead } = await handle.read( - buffer, - totalRead, - length - totalRead, - offset + totalRead - ) - if (bytesRead === 0) { - break - } - totalRead += bytesRead - } - return totalRead -} diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts deleted file mode 100644 index d666a059900..00000000000 --- a/src/relay/fs-handler-git-fallback.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { killSpawnedRipgrepProcess } from '../shared/ripgrep-process-availability' -import { FileInventoryBudget, FileInventoryCapacityError } from '../shared/file-inventory-budget' -/** - * Git-based fallbacks for file listing and text search. - * - * Why: the relay depends on ripgrep (rg) for fs.listFiles and fs.search, but - * rg is not installed on many remote machines. These functions use git ls-files - * and git grep as universal fallbacks — git is always available since this is - * a git-focused app. - */ -import { spawn } from 'node:child_process' -import { fileListingCancellationError } from '../shared/file-listing-cancellation' -import { - buildGitLsFilesArgsForQuickOpen, - shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath -} from '../shared/quick-open-filter' -import { - expandQuickOpenGitFileListing, - parseQuickOpenGitLsFilesEntry -} from '../shared/quick-open-readdir-walk' -import { buildRelayGitEnv } from '../wsl-guest/relay-command-env' - -/** - * List files using `git ls-files`. Fallback when rg is not installed. - * - * Why both passes: primary surfaces tracked + untracked-non-ignored; - * ignoredPass surfaces gitignored files that users frequently Quick Open. - * Exclude pathspecs are prepended by the shared builder so nested linked - * worktrees are pruned by git directly; post-filtering remains as a - * correctness backstop. - */ -export function listFilesWithGit( - rootPath: string, - excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal; maxResults?: number } = {} -): Promise { - const { signal, maxResults } = options - if (signal?.aborted) { - return Promise.reject(fileListingCancellationError(signal)) - } - const inventoryBudget = new FileInventoryBudget() - const gitPaths = new Set() - const directoryPaths = new Set() - const directFileCandidates = new Set() - const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) - const children: { - child: ReturnType - isDone: () => boolean - reject: (error: Error) => void - resolve: () => void - }[] = [] - - const runGitLsFiles = (args: string[]): Promise => { - return new Promise((resolve, reject) => { - let buf = '' - let done = false - - const processPath = (path: string): boolean => { - if (!path) { - return false - } - if (!gitPaths.has(path) && !directoryPaths.has(path)) { - inventoryBudget.record(path) - } - if (path.endsWith('/')) { - directoryPaths.add(path) - } else { - gitPaths.add(path) - if (maxResults !== undefined) { - // Why: this duplicate classification exists only to stop bounded - // scans; unbounded SSH scans must not retain another full listing. - const parsed = parseQuickOpenGitLsFilesEntry(path) - const relPath = parsed.path.replace(/\/+$/, '') - if ( - !parsed.isGitlink && - !parsed.isUntrackedDir && - shouldIncludeQuickOpenPath(relPath) && - !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) - ) { - directFileCandidates.add(relPath) - } - } - } - // Why: placeholders need IO classification and can disappear; only - // guaranteed final files are allowed to stop the remote Git processes. - return maxResults !== undefined && directFileCandidates.size >= maxResults - } - - const child = spawn('git', ['ls-files', ...args], { - cwd: rootPath, - env: buildRelayGitEnv(), - stdio: ['ignore', 'pipe', 'pipe'] - }) - let timer: ReturnType | null = null - const cleanup = (): void => { - if (timer) { - clearTimeout(timer) - timer = null - } - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) - child.off('error', handleError) - child.off('close', handleClose) - } - const rejectPass = (error: Error): void => { - if (done) { - return - } - done = true - buf = '' - cleanup() - reject(error) - } - const resolvePass = (): void => { - if (done) { - return - } - done = true - cleanup() - resolve() - } - children.push({ - child, - isDone: () => done, - reject: rejectPass, - resolve: resolvePass - }) - - function handleStdoutData(chunk: string): void { - buf += chunk - let start = 0 - let idx = buf.indexOf('\0', start) - while (idx !== -1) { - let atLimit: boolean - try { - atLimit = processPath(buf.substring(start, idx)) - } catch (error) { - killSpawnedRipgrepProcess(child) - rejectPass(error instanceof Error ? error : new FileInventoryCapacityError()) - killSurvivors('git file inventory capacity exceeded') - gitPaths.clear() - directoryPaths.clear() - return - } - if (atLimit) { - buf = '' - finishAtLimit() - return - } - start = idx + 1 - idx = buf.indexOf('\0', start) - } - buf = start < buf.length ? buf.substring(start) : '' - } - function handleStderrData(): void { - /* drain */ - } - function handleError(err: Error): void { - rejectPass(err) - } - function handleClose(code: number | null, signal: NodeJS.Signals | null): void { - if (done) { - return - } - if (signal) { - // Why: a signal exit means the child was killed (timeout or - // external). Treat that as a load failure rather than silently - // resolving with whatever git had managed to print. - rejectPass(new Error(`git ls-files killed by ${signal}`)) - return - } - if (buf && processPath(buf)) { - buf = '' - finishAtLimit() - return - } - if (code === 0) { - resolvePass() - return - } - // Why: a non-zero exit (e.g. not a git repo) means the listing is - // incomplete; reject so the caller surfaces the failure instead of - // expanding a partial result set. Matches the main-process fallback. - rejectPass(new Error(`git ls-files exited with code ${code}`)) - } - - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) - child.once('error', handleError) - child.once('close', handleClose) - timer = setTimeout(() => { - child.kill() - rejectPass(new Error('git ls-files timed out')) - }, 10_000) - }) - } - - const killSurvivors = (reason: string): void => { - // Why: Promise.all returns after the first failed pass, but the sibling - // git process can keep streaming on SSH unless we cancel it explicitly. - for (const entry of children) { - if (entry.isDone()) { - continue - } - if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() - } - entry.reject(new Error(reason)) - } - } - - function finishAtLimit(): void { - for (const entry of children) { - if (entry.isDone()) { - continue - } - entry.resolve() - if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() - } - } - } - - // Why: a cancelled scan (workspace switch, superseded request) must stop - // its git children right away instead of streaming a huge tree the caller - // has already abandoned over the shared SSH channel. - const onAbort = (): void => killSurvivors('git ls-files cancelled') - signal?.addEventListener('abort', onAbort, { once: true }) - - const runIgnoredPass = () => - // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs. - runGitLsFiles(ignoredPass).catch((err: Error) => { - if (err instanceof FileInventoryCapacityError) { - throw err - } - if (!signal?.aborted) { - console.warn( - '[relay quick-open] git ignored-file pass failed; keeping primary results:', - err - ) - } - }) - const passes = - maxResults === undefined - ? Promise.all([runGitLsFiles(primary), runIgnoredPass()]) - : runGitLsFiles(primary).then(() => - directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve() - ) - - return passes - .then(async () => { - const files = await expandQuickOpenGitFileListing({ - rootPath, - gitPaths, - directoryPaths, - excludePathPrefixes, - signal, - maxResults - }) - // Why: directory placeholders are expanded after Git exits; restore - // Git's path order for empty queries and fuzzy-score ties over SSH. - if (maxResults === undefined) { - const outputBudget = new FileInventoryBudget() - for (const path of files) { - outputBudget.record(path) - } - } - return files.sort().slice(0, maxResults) - }) - .catch((err) => { - killSurvivors('git ls-files canceled after sibling failure') - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - throw err - }) - .finally(() => { - signal?.removeEventListener('abort', onAbort) - }) -} - -export { searchWithGitGrep } from './fs-handler-git-search' diff --git a/src/relay/fs-handler-git-search-capacity.test.ts b/src/relay/fs-handler-git-search-capacity.test.ts deleted file mode 100644 index 6ac532ccfb4..00000000000 --- a/src/relay/fs-handler-git-search-capacity.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('@orca/process-host', () => ({ spawnProcess: spawnMock })) -import { searchWithGitGrep } from './fs-handler-git-search' -import { GitGrepRecordCapacityError } from '../shared/git-grep-record-limit' - -class SearchProcess extends EventEmitter { - stdout = new PassThrough() - stderr = new PassThrough() - kill = vi.fn(() => true) -} - -function start() { - const child = new SearchProcess() - spawnMock.mockReturnValue(child) - return { child, result: searchWithGitGrep('/repo', 'ok', { maxResults: 100 }) } -} - -describe('git search record capacity', () => { - it('rejects an unterminated record past 8 MiB and detaches even when kill fails', async () => { - const { child, result } = start() - child.kill.mockImplementation(() => { - throw new Error('kill refused') - }) - const outcome = expect(result).rejects.toThrow(GitGrepRecordCapacityError) - for (let chunk = 0; chunk < 129; chunk++) { - child.stdout.write(Buffer.alloc(64 * 1024, 'x')) - } - await outcome - expect(child.kill).toHaveBeenCalled() - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - child.emit('close', 0) - child.stdout.write('later.ts\x001\x00ok\n') - }) - - it('accepts exactly 8 MiB followed by newline and recovers on the next request', async () => { - const { child, result } = start() - child.stdout.write('x'.repeat(8 * 1024 * 1024)) - child.stdout.write('\nvalid.ts\x001\x00ok\n') - child.emit('close', 0) - expect((await result).files).toHaveLength(1) - const next = start() - next.child.stdout.write('next.ts\x001\x00ok\n') - next.child.emit('close', 0) - expect((await next.result).files[0].relativePath).toBe('next.ts') - }) - - it('releases the carry on cancellation before reaching the cap', async () => { - const child = new SearchProcess() - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const result = searchWithGitGrep('/repo', 'ok', { maxResults: 100, signal: controller.signal }) - child.stdout.write('x'.repeat(1024 * 1024)) - controller.abort(new Error('workspace switched')) - await expect(result).rejects.toThrow('workspace switched') - expect(child.stdout.listenerCount('data')).toBe(0) - }) -}) - -it('charges raw bytes before replacement decoding invalid UTF-8', async () => { - const { child, result } = start() - child.stdout.write(Buffer.alloc(3 * 1024 * 1024, 0xff)) - child.stdout.write('\nvalid.ts\x001\x00ok\n') - child.emit('close', 0) - expect((await result).files[0].relativePath).toBe('valid.ts') - expect(child.kill).not.toHaveBeenCalled() -}) diff --git a/src/relay/fs-handler-git-search-real-capacity.test.ts b/src/relay/fs-handler-git-search-real-capacity.test.ts deleted file mode 100644 index 48292adccb4..00000000000 --- a/src/relay/fs-handler-git-search-real-capacity.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { searchWithGitGrep } from './fs-handler-git-search' -import { GitGrepRecordCapacityError } from '../shared/git-grep-record-limit' - -describe('real git search capacity and recovery', () => { - it('rejects a matching newline-free file and can search again in the same folder', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-git-record-')) - try { - await runProcess({ program: 'git', args: ['init', '--quiet'], cwd: root }) - await writeFile(join(root, 'record.txt'), `needle${'x'.repeat(9 * 1024 * 1024)}`) - await expect(searchWithGitGrep(root, 'needle', { maxResults: 10 })).rejects.toThrow( - GitGrepRecordCapacityError - ) - await writeFile(join(root, 'record.txt'), 'needle\n') - expect((await searchWithGitGrep(root, 'needle', { maxResults: 10 })).totalMatches).toBe(1) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) -}) diff --git a/src/relay/fs-handler-git-search.test.ts b/src/relay/fs-handler-git-search.test.ts deleted file mode 100644 index 23f1871b691..00000000000 --- a/src/relay/fs-handler-git-search.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' - -const { spawnMock } = vi.hoisted(() => ({ - spawnMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - spawn: spawnMock -})) - -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { searchWithGitGrep } from './fs-handler-git-fallback' - -function createMockProcess(): ChildProcess { - const p = new EventEmitter() as unknown as ChildProcess - ;(p as unknown as Record).stdout = new EventEmitter() - ;( - (p as unknown as Record).stdout as EventEmitter & { - setEncoding: () => void - } - ).setEncoding = vi.fn() - ;(p as unknown as Record).stderr = new EventEmitter() - ;(p as unknown as Record).kill = vi.fn() - return p -} - -describe('relay git grep fallback', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('settles and detaches when git grep ignores the timeout kill', async () => { - vi.useFakeTimers() - - try { - const proc = createMockProcess() - spawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep('/remote/root', 'ok', { maxResults: 100 }) - - ;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x001\x00ok\npartial') - - await vi.runOnlyPendingTimersAsync() - - const result = await promise - expect(result.truncated).toBe(true) - expect(result.files).toHaveLength(1) - expect(result.files[0].matchCount).toBe(1) - expect(proc.kill).toHaveBeenCalled() - expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((proc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(proc.listenerCount('error')).toBe(0) - expect(proc.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/relay/fs-handler-git-search.ts b/src/relay/fs-handler-git-search.ts deleted file mode 100644 index 07d3d5284d0..00000000000 --- a/src/relay/fs-handler-git-search.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - GitGrepRecordCapacityError, - GIT_GREP_MAX_RECORD_BYTES -} from '../shared/git-grep-record-limit' -import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' -import { spawnProcess } from '@orca/process-host' -import { abortSignalReason } from '../shared/abort-signal-reason' -import type { SearchOptions, SearchResult } from './fs-handler-utils' -import { - buildGitGrepArgs, - buildSubmatchRegex, - createAccumulator, - finalize, - ingestGitGrepLine, - SEARCH_TIMEOUT_MS -} from '../shared/text-search' -import { - absorbPendingRipgrepSpawnError, - killSpawnedRipgrepProcess -} from '../shared/ripgrep-process-availability' -import { buildRelayGitEnv } from '../wsl-guest/relay-command-env' - -/** - * Text search using `git grep`. Fallback when rg is not installed. - */ -export function searchWithGitGrep( - rootPath: string, - query: string, - opts: SearchOptions -): Promise { - const { signal } = opts - if (signal?.aborted) { - return Promise.reject(abortSignalReason(signal)) - } - return new Promise((resolve, reject) => { - const gitArgs = buildGitGrepArgs(query, opts) - const matchRegex = buildSubmatchRegex(query, opts) - const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(GIT_GREP_MAX_RECORD_BYTES) - let done = false - let processErrorObserved = false - - const child = spawnProcess({ - program: 'git', - args: gitArgs, - cwd: rootPath, - env: buildRelayGitEnv(), - stdio: ['ignore', 'pipe', 'pipe'] - }) - let killTimeout: ReturnType - - function settle(): boolean { - if (done) { - return false - } - done = true - signal?.removeEventListener('abort', onAbort) - lines.clear() - clearTimeout(killTimeout) - // Why: child.kill() is advisory. If git ignores it, detach our - // closures so repeated relay searches do not retain old scans. - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) - child.off('error', handleError) - child.off('close', handleClose) - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved: false - }) - return true - } - - function resolveOnce(): void { - if (settle()) { - resolve(finalize(acc)) - } - } - - function onAbort(): void { - if (signal && settle()) { - try { - killSpawnedRipgrepProcess(child) - } catch { - // A refused kill must still release the canceled request. - } - reject(abortSignalReason(signal)) - } - } - - function processLine(line: string): void { - const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, opts.maxResults) - if (verdict === 'stop') { - child.kill() - } - } - - function handleStdoutData(chunk: Buffer | string): void { - if (!lines.push(chunk, processLine) && settle()) { - try { - killSpawnedRipgrepProcess(child) - } catch { - // Release the request even when the host refuses the kill. - } - reject(new GitGrepRecordCapacityError()) - } - } - - function handleStderrData(): void { - /* drain */ - } - - function handleError(): void { - processErrorObserved = true - resolveOnce() - } - - function handleClose(): void { - const tail = lines.finish() - if (tail !== null) { - processLine(tail) - } - resolveOnce() - } - - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) - child.once('error', handleError) - child.once('close', handleClose) - - killTimeout = setTimeout(() => { - acc.truncated = true - child.kill() - resolveOnce() - }, SEARCH_TIMEOUT_MS) - signal?.addEventListener('abort', onAbort, { once: true }) - if (signal?.aborted) { - onAbort() - } - }) -} diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts deleted file mode 100644 index ca3bfed18e9..00000000000 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Cancellation behavior of the relay file-list scanners (#7721): an aborted - * scan must kill its child processes immediately and reject with a - * cancellation error instead of streaming an abandoned tree to completion. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { spawnMock } = vi.hoisted(() => ({ - spawnMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - spawn: spawnMock -})) - -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { listFilesWithRg } from './fs-handler-list-files' -import { listFilesWithGit } from './fs-handler-git-fallback' -import { isFileListingCancellation } from '../shared/file-listing-cancellation' - -function createMockProcess(): ChildProcess { - const p = new EventEmitter() as unknown as ChildProcess - ;(p as unknown as Record).stdout = new EventEmitter() - ;( - (p as unknown as Record).stdout as EventEmitter & { - setEncoding: () => void - } - ).setEncoding = vi.fn() - ;(p as unknown as Record).stderr = new EventEmitter() - ;(p as unknown as Record).kill = vi.fn() - ;(p as unknown as Record).exitCode = null - ;(p as unknown as Record).signalCode = null - Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) - return p -} - -describe('relay list-files cancellation', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('listFilesWithRg kills the broad rg pass and rejects when aborted mid-flight', async () => { - const ignoredProc = createMockProcess() - spawnMock.mockReturnValue(ignoredProc) - - const controller = new AbortController() - const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) - - // Partial output before the abort — must be discarded, not resolved. - ignoredProc.stdout?.emit('data', 'src/index.ts\0') - controller.abort() - - await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(ignoredProc.kill).toHaveBeenCalled() - - // Late close events after cancellation must not fire anything. - ignoredProc.emit('close', null, 'SIGTERM') - }) - - it('listFilesWithRg rejects without spawning when the signal is already aborted', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - listFilesWithRg('/remote/root', [], { signal: controller.signal }) - ).rejects.toSatisfy(isFileListingCancellation) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('listFilesWithRg does not start the ignored pass after cancellation between passes', async () => { - vi.useFakeTimers() - const primaryProc = createMockProcess() - spawnMock.mockReturnValue(primaryProc) - const controller = new AbortController() - const promise = listFilesWithRg('/remote/root', [], { - signal: controller.signal, - maxResults: 10 - }) - const rejected = expect(promise).rejects.toSatisfy(isFileListingCancellation) - - primaryProc.stdout?.emit('data', 'src/index.ts\0') - primaryProc.emit('close', 0, null) - // Abort before the completed pass's promise continuation admits the broader scan. - controller.abort() - - await rejected - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(primaryProc.kill).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - - it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => { - const ignoredProc = createMockProcess() - spawnMock.mockReturnValue(ignoredProc) - - const controller = new AbortController() - const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) - - setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\0') - ignoredProc.stdout?.emit('data', 'dist/out.js\0') - ignoredProc.emit('close', 0, null) - }, 5) - - await expect(promise).resolves.toEqual(['src/index.ts', 'dist/out.js']) - }) - - it('listFilesWithGit kills both git passes and rejects when aborted mid-flight', async () => { - const procs: ChildProcess[] = [] - spawnMock.mockImplementation(() => { - const proc = createMockProcess() - procs.push(proc) - return proc - }) - - const controller = new AbortController() - const promise = listFilesWithGit('/remote/root', [], { signal: controller.signal }) - - expect(procs).toHaveLength(2) - controller.abort() - - await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(procs[0].kill).toHaveBeenCalled() - expect(procs[1].kill).toHaveBeenCalled() - }) - - it('listFilesWithGit rejects without spawning when the signal is already aborted', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - listFilesWithGit('/remote/root', [], { signal: controller.signal }) - ).rejects.toSatisfy(isFileListingCancellation) - expect(spawnMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/fs-handler-list-files-candidates.integration.test.ts b/src/relay/fs-handler-list-files-candidates.integration.test.ts deleted file mode 100644 index 31903ab2815..00000000000 --- a/src/relay/fs-handler-list-files-candidates.integration.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' -import { writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, it, vi } from 'vitest' -vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -import { createRelayFileListingRequestHarness } from './fs-list-files-dispatch-test-harness' -import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' -import { bundledRipgrepCommand } from '../main/ripgrep/bundled-ripgrep-path' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' -it('dispatches a real late candidate beyond the capped inventory and preserves ignore policy', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-dispatched-candidates-')) - const harness = createRelayFileListingRequestHarness() - configureRelayBundledRipgrep(bundledRipgrepCommand()) - try { - await mkdir(join(root, '.git')) - await writeFile(join(root, '.gitignore'), 'ignored.ts\n') - await writeFile(join(root, 'ignored.ts'), '') - const paths = Array.from( - { length: QUICK_OPEN_LISTING_MAX_RESULTS + 100 }, - (_, i) => `file-${i}.ts` - ) - for (const path of paths) { - writeFileSync(join(root, path), '') - } - const inventory = await harness.request({ - rootPath: root, - includeIgnored: false, - maxResults: QUICK_OPEN_LISTING_MAX_RESULTS - }) - const retained = new Set(inventory) - const omitted = paths.find((path) => !retained.has(path)) - expect(omitted).toBeDefined() - if (!omitted) { - throw new Error('Fixture has no omitted candidate') - } - await expect( - harness.request({ - rootPath: root, - includeIgnored: false, - candidatePaths: [omitted, 'ignored.ts', 'deleted.ts'], - maxResults: 3 - }) - ).resolves.toEqual([omitted]) - await expect( - harness.request({ - rootPath: root, - includeIgnored: true, - candidatePaths: ['ignored.ts'], - maxResults: 1 - }) - ).resolves.toEqual(['ignored.ts']) - expect(inventory).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) - } finally { - harness.dispose() - configureRelayBundledRipgrep(undefined) - await rm(root, { recursive: true, force: true }) - } -}, 30_000) diff --git a/src/relay/fs-handler-list-files-candidates.test.ts b/src/relay/fs-handler-list-files-candidates.test.ts deleted file mode 100644 index 805cf8467be..00000000000 --- a/src/relay/fs-handler-list-files-candidates.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -const { scan } = vi.hoisted(() => ({ scan: vi.fn() })) -vi.mock('./fs-list-files-fallback-chain', () => ({ runListFilesScan: scan })) -vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -import { createRelayFileListingRequestHarness } from './fs-list-files-dispatch-test-harness' -let harness: ReturnType -beforeEach(() => { - scan.mockReset().mockResolvedValue([]) - harness = createRelayFileListingRequestHarness() -}) -afterEach(() => harness.dispose()) -it('routes validated candidates and both discovery options from wire dispatch into the scan', async () => { - await harness.request({ - rootPath: '/root', - candidatePaths: ['late.ts', 'late.ts', '../escape'], - includeIgnored: false, - followSymlinks: true, - maxResults: 1 - }) - expect(scan).toHaveBeenCalledWith('/root', [], expect.any(AbortSignal), 1, undefined, { - candidatePaths: ['late.ts'], - includeIgnored: false, - followSymlinks: true - }) -}) -it.each([null, 42, ['valid.ts', 1]])( - 'rejects malformed candidates %j before scanning', - async (candidatePaths) => { - await expect(harness.request({ rootPath: '/root', candidatePaths })).rejects.toThrow( - 'Invalid Quick Open' - ) - expect(scan).not.toHaveBeenCalled() - } -) -it('rejects an over-count candidate set before scanning', async () => { - await expect( - harness.request({ - rootPath: '/root', - candidatePaths: Array.from({ length: 101 }, (_, i) => `${i}.ts`) - }) - ).rejects.toThrow('Too many') - expect(scan).not.toHaveBeenCalled() -}) -it('rejects an over-byte candidate before scanning', async () => { - await expect( - harness.request({ rootPath: '/root', candidatePaths: ['x'.repeat(65_537)] }) - ).rejects.toThrow('too large') - expect(scan).not.toHaveBeenCalled() -}) -it('keeps distinct candidate sets from coalescing and stops the superseded scan', async () => { - scan.mockImplementation( - ( - _root, - _excluded, - signal: AbortSignal, - _limit, - _query, - options: { candidatePaths: string[] } - ) => - options.candidatePaths[0] === 'a.ts' - ? new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - : Promise.resolve(options.candidatePaths) - ) - const first = harness.request({ rootPath: '/root', candidatePaths: ['a.ts'], maxResults: 1 }) - const rejected = expect(first).rejects.toThrow('superseded') - await vi.waitFor(() => expect(scan).toHaveBeenCalledOnce()) - await expect( - harness.request({ rootPath: '/root', candidatePaths: ['b.ts'], maxResults: 1 }) - ).resolves.toEqual(['b.ts']) - await rejected - expect(scan).toHaveBeenCalledTimes(2) - expect(scan.mock.calls[0][2].aborted).toBe(true) -}) -it('coalesces matching candidate sets and preserves omitted candidates for old callers', async () => { - let release = (): void => { - throw new Error('Scan not started') - } - scan.mockReturnValue( - new Promise((resolve) => { - release = () => resolve(['a.ts']) - }) - ) - const params = { rootPath: '/root', candidatePaths: ['a.ts'], maxResults: 1 } - const first = harness.request(params) - const second = harness.request(params) - await vi.waitFor(() => expect(scan).toHaveBeenCalledOnce()) - release() - expect(await first).toEqual(['a.ts']) - expect(await second).toEqual(['a.ts']) - expect(scan).toHaveBeenCalledOnce() - scan.mockResolvedValueOnce([]) - await harness.request({ rootPath: '/root' }) - expect(scan.mock.calls[1][5]).toEqual({}) -}) diff --git a/src/relay/fs-handler-list-files-diagnosis-cancel.test.ts b/src/relay/fs-handler-list-files-diagnosis-cancel.test.ts deleted file mode 100644 index 57590a1c898..00000000000 --- a/src/relay/fs-handler-list-files-diagnosis-cancel.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { ChildProcess } from 'node:child_process' -import type * as ChildProcessModule from 'node:child_process' -import type * as FsPromises from 'node:fs/promises' -import { getEventListeners } from 'node:events' -import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -const { spawnMock, statMock, retryMock } = vi.hoisted(() => ({ - spawnMock: vi.fn(), - statMock: vi.fn(), - retryMock: vi.fn() -})) -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: spawnMock -})) -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), - stat: statMock, - access: vi.fn(async () => undefined) -})) -vi.mock('./relay-bundled-ripgrep', () => ({ - resolveRelayRipgrepCommand: () => '/tools/rg', - pathRipgrepCommand: () => '/fallback/rg', - retryRipgrepOnPathAfterLaunchFailure: retryMock -})) -import { listFilesWithRg } from './fs-handler-list-files' - -function child(spawned: boolean) { - const result = new ChildProcess() - Object.defineProperties(result, { - stdout: { value: new PassThrough() }, - stderr: { value: new PassThrough() } - }) - Object.defineProperty(result, 'pid', { value: spawned ? 4321 : undefined }) - result.kill = vi.fn(() => true) - return result -} - -async function drainDiagnosis(): Promise { - for (let tick = 0; tick < 20; tick += 1) { - await Promise.resolve() - } -} - -beforeEach(() => { - vi.useFakeTimers() - spawnMock.mockReset() - statMock.mockReset().mockResolvedValue({ isDirectory: () => false }) - retryMock.mockReset().mockResolvedValue(false) -}) -afterEach(() => vi.useRealTimers()) - -it('settles an unexpected diagnosis rejection without leaving a listing pending', async () => { - retryMock.mockRejectedValue('diagnosis failed') - const failed = child(false) - spawnMock.mockReturnValue(failed) - const controller = new AbortController() - const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) - const rejected = expect(result).rejects.toThrow('diagnosis failed') - failed.emit('error', new Error('spawn ENOENT')) - await rejected - expect(spawnMock).toHaveBeenCalledOnce() - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) -}) - -it('skips diagnosis when a canceled listing resumes its retry check', async () => { - let completeRetry: (value: boolean) => void = () => undefined - retryMock.mockReturnValue( - new Promise((resolve) => { - completeRetry = resolve - }) - ) - const failed = child(false) - const probe = child(true) - spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) - const controller = new AbortController() - const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) - const canceled = expect(result).rejects.toMatchObject({ name: 'FileListingCancelledError' }) - failed.emit('error', new Error('spawn ENOENT')) - expect(retryMock).toHaveBeenCalledOnce() - controller.abort() - await canceled - completeRetry(false) - await drainDiagnosis() - const statCalls = statMock.mock.calls.length - probe.emit('close', 0) - await drainDiagnosis() - expect(statCalls).toBe(0) - expect(spawnMock).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) -}) - -it('skips version probes when cancellation wins a pending cwd check', async () => { - let completeStat: (value: { isDirectory: () => boolean }) => void = () => undefined - statMock.mockReturnValue( - new Promise((resolve) => { - completeStat = resolve - }) - ) - const failed = child(false) - const probe = child(true) - spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) - const controller = new AbortController() - const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) - const canceled = expect(result).rejects.toMatchObject({ name: 'FileListingCancelledError' }) - failed.emit('error', new Error('spawn ENOENT')) - await drainDiagnosis() - expect(statMock).toHaveBeenCalledOnce() - controller.abort() - await canceled - completeStat({ isDirectory: () => false }) - await drainDiagnosis() - const spawnCalls = spawnMock.mock.calls.length - probe.emit('close', 0) - await drainDiagnosis() - expect(spawnCalls).toBe(1) - expect(vi.getTimerCount()).toBe(0) -}) - -it('terminates a running diagnosis probe when its listing is canceled', async () => { - const failed = child(false) - const probe = child(true) - spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) - const controller = new AbortController() - const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) - const canceled = expect(result).rejects.toMatchObject({ name: 'FileListingCancelledError' }) - failed.emit('error', new Error('spawn ENOENT')) - await drainDiagnosis() - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock.mock.calls[1][1]).toEqual(['--version']) - controller.abort() - await canceled - const killCalls = vi.mocked(probe.kill).mock.calls.length - const closeListeners = probe.listenerCount('close') - probe.emit('close', 0) - await drainDiagnosis() - expect(killCalls).toBe(1) - expect(closeListeners).toBe(0) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) -}) diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts deleted file mode 100644 index 6216fe61c88..00000000000 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ /dev/null @@ -1,795 +0,0 @@ -import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest' - -const { spawnMock } = vi.hoisted(() => ({ - spawnMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - spawn: spawnMock -})) - -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { tmpdir } from 'node:os' -import { listFilesWithGit } from './fs-handler-git-fallback' -import { listFilesWithRg } from './fs-handler-list-files' -import { searchWithRg } from './fs-handler-utils' -import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' -import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' -import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' -import { - ListFilesScanCoordinator, - LIST_FILES_SUPERSEDED_MESSAGE -} from './fs-list-files-scan-coordinator' - -const tempDirs: string[] = [] -const SHA1 = '0123456789abcdef0123456789abcdef01234567' - -function staged(mode: string, path: string): string { - return `${mode} ${SHA1} 0\t${path}` -} - -function createMockProcess(): ChildProcess { - const p = new EventEmitter() as unknown as ChildProcess - ;(p as unknown as Record).stdout = new EventEmitter() - ;( - (p as unknown as Record).stdout as EventEmitter & { - setEncoding: () => void - } - ).setEncoding = vi.fn() - ;(p as unknown as Record).stderr = new EventEmitter() - ;(p as unknown as Record).kill = vi.fn() - ;(p as unknown as Record).exitCode = null - ;(p as unknown as Record).signalCode = null - Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) - return p -} - -async function makeTempRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-git-list-files-')) - tempDirs.push(root) - return root -} - -async function writeRel(root: string, relPath: string, content = 'x'): Promise { - const absPath = join(root, ...relPath.split('/')) - await mkdir(dirname(absPath), { recursive: true }) - await writeFile(absPath, content) -} - -describe('relay quick open ignored file listing', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(async () => { - // Why reset: the bundled path is module state, and leaking it changes which binary the next - // test's launch-failure classifier probes. - configureRelayBundledRipgrep(undefined) - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) - }) - - it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { - const child = createMockProcess() - spawnMock.mockReturnValue(child) - const promise = listFilesWithRg('/remote/root') - - child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) - if (kind === 'incomplete') { - child.emit('close', 0, null) - } - await expect(promise).rejects.toThrow('not valid UTF-8') - if (kind === 'invalid') { - expect(child.kill).toHaveBeenCalled() - } - }) - - it('rejects a full git inventory capacity failure and stops both passes', async () => { - const primary = createMockProcess() - const ignored = createMockProcess() - spawnMock.mockReturnValueOnce(primary).mockReturnValueOnce(ignored) - const result = listFilesWithGit('/remote/root') - const rejected = expect(result).rejects.toThrow('inventory is too large') - let produced = 0 - while (primary.stdout?.listenerCount('data') && produced < 100000) { - primary.stdout.emit( - 'data', - Array.from({ length: 100 }, () => `src/${'x'.repeat(1000)}-${produced++}.ts\0`).join('') - ) - } - await rejected - expect(produced).toBeLessThan(40000) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).toHaveBeenCalled() - expect(primary.stdout?.listenerCount('data')).toBe(0) - expect(ignored.stdout?.listenerCount('data')).toBe(0) - }) - - it('retains a late 25,002nd file in a complete inventory', async () => { - const child = createMockProcess() - spawnMock.mockReturnValue(child) - const result = listFilesWithRg('/remote/root') - child.stdout?.emit( - 'data', - Array.from({ length: 25002 }, (_, i) => `src/file-${i}.ts\0`).join('') - ) - child.emit('close', 0, null) - const paths = await result - expect(paths).toHaveLength(25002) - expect(paths.at(-1)).toBe('src/file-25001.ts') - }) - - it('stops a full-inventory producer at its aggregate retained-byte ceiling', async () => { - const child = createMockProcess() - spawnMock.mockReturnValue(child) - const result = listFilesWithRg('/remote/root') - const rejected = expect(result).rejects.toThrow('inventory is too large') - let produced = 0 - while (child.stdout?.listenerCount('data') && produced < 100000) { - child.stdout.emit( - 'data', - Array.from({ length: 100 }, () => `src/${'x'.repeat(1000)}-${produced++}.ts\0`).join('') - ) - } - await rejected - expect(produced).toBeLessThan(40000) - expect(child.kill).toHaveBeenCalled() - expect(child.stdout?.listenerCount('data')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - }) - - it('uses one broad rg pass for unbounded listings and keeps blocklists/excludes', async () => { - const ignoredProc = createMockProcess() - - spawnMock.mockReturnValue(ignoredProc) - - const promise = listFilesWithRg('/remote/root', ['packages/other']) - expect(spawnMock).toHaveBeenCalledTimes(1) - - setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\0') - ignoredProc.stdout?.emit('data', 'dist/generated.js\0') - ignoredProc.stdout?.emit('data', 'node_modules/pkg/index.js\0') - ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') - ignoredProc.emit('close', 0, null) - }, 10) - - await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) - - const ignoredArgs = spawnMock.mock.calls[0][1] - expect(ignoredArgs).toBeDefined() - expect(ignoredArgs).toContain('--no-ignore-vcs') - expect(ignoredArgs).not.toContain('.env*') - expect(ignoredArgs).not.toContain('**/.env*') - expect(ignoredArgs).toContain('!**/node_modules') - expect(ignoredArgs).toContain('!packages/other') - expect(ignoredArgs).toContain('!packages/other/**') - }) - - it('stops after the primary relay rg pass fills the result budget', async () => { - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) - - const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) - primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts\0src/three.ts\0') - - await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) - expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) - }) - - it('searches the complete stream and retains only ranked fuzzy matches', async () => { - const ignoredProc = createMockProcess() - spawnMock.mockReturnValue(ignoredProc) - const promise = listFilesWithRg('/remote/root', [], { - maxResults: 2, - searchQuery: 'sct' - }) - - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ignoredProc.stdout?.emit( - 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` - ) - ignoredProc.stdout?.emit('data', 'src/components/target.ts\0scripts/check-target.ts\0') - ignoredProc.emit('close', 0, null) - - await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts']) - }) - - it('fills a bounded listing from primary files before admitting ignored files', async () => { - const primary = createMockProcess() - const broad = createMockProcess() - spawnMock.mockReturnValueOnce(primary).mockReturnValueOnce(broad) - - const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(spawnMock.mock.calls[0][1]).not.toContain('--no-ignore-vcs') - primary.stdout?.emit('data', 'src/index.ts\0') - primary.emit('close', 0, null) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') - broad.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0dist/extra.js\0') - - await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) - expect(broad.kill).toHaveBeenCalled() - }) - - it('retries a transient remote rg spawn failure without reporting ripgrep as missing', async () => { - const failed = createMockProcess() - const succeeded = createMockProcess() - Object.defineProperty(failed, 'pid', { value: undefined }) - spawnMock.mockReturnValueOnce(failed).mockReturnValueOnce(succeeded) - - const promise = listFilesWithRg('/remote/root', [], { - maxResults: 32, - searchQuery: 'target' - }) - failed.stdout?.emit('data', 'src/target.ts\0') - failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) - - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - succeeded.stdout?.emit('data', 'src/target.ts\0src/another-target.ts\0') - succeeded.emit('close', 0, null) - - await expect(promise).resolves.toEqual(['src/target.ts', 'src/another-target.ts']) - }) - - it('retries a synchronous transient remote rg spawn failure', async () => { - const succeeded = createMockProcess() - spawnMock.mockImplementationOnce(() => { - throw Object.assign(new Error('spawn rg ETXTBSY'), { code: 'ETXTBSY' }) - }) - spawnMock.mockReturnValueOnce(succeeded) - - const promise = listFilesWithRg('/remote/root', [], { - maxResults: 32, - searchQuery: 'target' - }) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - succeeded.stdout?.emit('data', 'src/target.ts\0') - succeeded.emit('close', 0, null) - - await expect(promise).resolves.toEqual(['src/target.ts']) - }) - - it('retries an unbounded broad listing once after a transient spawn failure', async () => { - const failed = createMockProcess() - const succeeded = createMockProcess() - Object.defineProperty(failed, 'pid', { value: undefined }) - spawnMock.mockReturnValueOnce(failed).mockReturnValueOnce(succeeded) - - const promise = listFilesWithRg('/remote/root') - failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - - succeeded.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0') - succeeded.emit('close', 0, null) - - await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') - }) - - it.each(['error-first', 'close-first'] as const)( - 'tags a %s pre-spawn listing failure without starting another pass', - async (order) => { - const root = await makeTempRoot() - const missing = createMockProcess() - Object.defineProperty(missing, 'pid', { value: undefined }) - spawnMock.mockReturnValue(missing) - const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) - - const promise = listFilesWithRg(root) - if (order === 'error-first') { - expect(() => missing.emit('error', error)).not.toThrow() - } else { - missing.emit('close', -2, null) - } - - await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) - if (order === 'error-first') { - missing.emit('close', -2, null) - } else { - expect(() => missing.emit('error', error)).not.toThrow() - } - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(missing.listenerCount('error')).toBe(0) - expect(missing.listenerCount('close')).toBe(0) - } - ) - - it('does not signal the unbounded broad pass when it fails before spawn', async () => { - const root = await makeTempRoot() - const missingIgnored = createMockProcess() - Object.defineProperty(missingIgnored, 'pid', { value: undefined }) - spawnMock.mockReturnValue(missingIgnored) - - const promise = listFilesWithRg(root) - expect(spawnMock).toHaveBeenCalledTimes(1) - missingIgnored.emit('close', -2, null) - - await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) - expect(missingIgnored.kill).not.toHaveBeenCalled() - const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) - expect(() => missingIgnored.emit('error', error)).not.toThrow() - expect(missingIgnored.listenerCount('error')).toBe(0) - }) - - it('does not signal failed-spawn passes when a same-client scan is superseded', async () => { - const root = await makeTempRoot() - const firstChild = createMockProcess() - Object.defineProperty(firstChild, 'pid', { value: undefined }) - spawnMock.mockReturnValue(firstChild) - const coordinator = new ListFilesScanCoordinator() - const first = coordinator.run({ - clientId: 1, - key: 'first', - start: (signal) => listFilesWithRg(root, [], { signal }) - }) - const firstOutcome = first.catch((error: unknown) => error) - - const second = coordinator.run({ - clientId: 1, - key: 'second', - start: async () => ['second.ts'] - }) - - await expect(firstOutcome).resolves.toMatchObject({ message: LIST_FILES_SUPERSEDED_MESSAGE }) - await expect(second).resolves.toEqual(['second.ts']) - expect(firstChild.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledTimes(1) - const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) - expect(() => firstChild.emit('error', error)).not.toThrow() - }) - - it('git fallback ignored pass includes ignored non-env files', async () => { - const root = await makeTempRoot() - await writeRel(root, 'dist/generated.js') - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) - - const promise = listFilesWithGit(root, ['packages/other']) - - setTimeout(() => { - primaryProc.stdout?.emit('data', `${staged('100644', 'src/index.ts')}\0`) - primaryProc.stdout?.emit('data', `${staged('100644', 'tab\tfile.txt')}\0`) - primaryProc.emit('close', 0, null) - - ignoredProc.stdout?.emit('data', 'dist/\0') - ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') - ignoredProc.emit('close', 0, null) - }, 10) - - await expect(promise).resolves.toEqual(['dist/generated.js', 'src/index.ts', 'tab\tfile.txt']) - - const ignoredArgs = spawnMock.mock.calls[1][1] as string[] - expect(ignoredArgs.slice(0, 6)).toEqual([ - 'ls-files', - '-z', - '-s', - '--others', - '--ignored', - '--exclude-standard' - ]) - expect(ignoredArgs).toContain('--') - expect(ignoredArgs).toContain('.') - expect(ignoredArgs).toContain('--directory') - expect(ignoredArgs).toContain('--no-empty-directory') - expect(ignoredArgs).toContain(':(exclude,glob)packages/other') - expect(ignoredArgs).toContain(':(exclude,glob)packages/other/**') - }) - - it('stops after primary relay Git files fill the result budget', async () => { - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) - - const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) - primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts') - primaryProc.emit('close', 0, null) - await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) - expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) - }) - - it('does not let a discarded relay Git placeholder consume the result budget', async () => { - const primaryProc = createMockProcess() - spawnMock.mockReturnValue(primaryProc) - - const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) - primaryProc.stdout?.emit('data', `discarded/\0${staged('100644', 'src/kept.ts')}\0`) - - await expect(promise).resolves.toEqual(['src/kept.ts']) - expect(primaryProc.kill).toHaveBeenCalled() - }) - - it('git fallback fills nested git repos returned as root-relative placeholders', async () => { - const root = await makeTempRoot() - await writeRel(root, 'README.md') - await mkdir(join(root, 'packages', 'app', '.git'), { recursive: true }) - await writeRel(root, 'packages/app/src/main.ts') - await mkdir(join(root, 'packages', 'lib'), { recursive: true }) - await writeFile(join(root, 'packages', 'lib', '.git'), 'gitdir: ../.git/worktrees/lib') - await writeRel(root, 'packages/lib/src/lib.ts') - - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) - - const promise = listFilesWithGit(root) - - setTimeout(() => { - primaryProc.stdout?.emit( - 'data', - `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` - ) - primaryProc.emit('close', 0, null) - ignoredProc.emit('close', 0, null) - }, 10) - - await expect(promise).resolves.toEqual([ - 'README.md', - 'packages/app/src/main.ts', - 'packages/lib/src/lib.ts' - ]) - }) - - it('git fallback keeps primary results when the ignored pass is killed', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) - - const promise = listFilesWithGit('/remote/root') - - setTimeout(() => { - primaryProc.stdout?.emit('data', 'src/index.ts\0') - primaryProc.emit('close', 0, null) - - // Entries streamed before the kill are kept alongside the primary pass. - ignoredProc.stdout?.emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', null, 'SIGTERM') - }, 10) - - await expect(promise).resolves.toEqual(['dist/generated.js', 'src/index.ts']) - expect(warnSpy).toHaveBeenCalled() - } finally { - warnSpy.mockRestore() - } - }) - - it('git fallback keeps primary results when the ignored pass exits non-zero', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) - - const promise = listFilesWithGit('/remote/root') - - setTimeout(() => { - primaryProc.stdout?.emit('data', 'src/index.ts\0') - primaryProc.emit('close', 0, null) - - ignoredProc.emit('close', 128, null) - }, 10) - - await expect(promise).resolves.toEqual(['src/index.ts']) - expect(warnSpy).toHaveBeenCalled() - } finally { - warnSpy.mockRestore() - } - }) - - it('git fallback rejects when the primary pass is killed', async () => { - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) - - const promise = listFilesWithGit('/remote/root') - - setTimeout(() => { - primaryProc.stdout?.emit('data', 'src/index.ts\0') - primaryProc.emit('close', null, 'SIGTERM') - - ignoredProc.stdout?.emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', 0, null) - }, 10) - - await expect(promise).rejects.toThrow('git ls-files killed by SIGTERM') - }) - - it('git fallback rejects when the primary pass exits non-zero', async () => { - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) - - const promise = listFilesWithGit('/remote/root') - - setTimeout(() => { - primaryProc.emit('close', 128, null) - - ignoredProc.stdout?.emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', 0, null) - }, 10) - - await expect(promise).rejects.toThrow('git ls-files exited with code 128') - }) - - it('git fallback rejects when a timed-out child does not emit close', async () => { - vi.useFakeTimers() - try { - const primaryProc = createMockProcess() - const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) - - const promise = listFilesWithGit('/remote/root') - const outcomePromise = promise.then( - () => 'resolved', - (err: Error) => `rejected:${err.message}` - ) - - await vi.advanceTimersByTimeAsync(10_000) - const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - - expect(outcome).toContain('git ls-files timed out') - expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).toHaveBeenCalled() - expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(primaryProc.listenerCount('error')).toBe(0) - expect(primaryProc.listenerCount('close')).toBe(0) - expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(ignoredProc.listenerCount('error')).toBe(0) - expect(ignoredProc.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('rg file listing rejects and detaches when a timed-out child does not emit close', async () => { - vi.useFakeTimers() - try { - const ignoredProc = createMockProcess() - spawnMock.mockReturnValue(ignoredProc) - - const promise = listFilesWithRg('/remote/root') - const outcomePromise = promise.then( - () => 'resolved', - (err: Error) => `rejected:${err.message}` - ) - - await vi.advanceTimersByTimeAsync(25_000) - const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - - expect(outcome).toBe('rejected:rg list timed out') - expect(spawnMock).toHaveBeenCalledTimes(1) - expect(ignoredProc.kill).toHaveBeenCalled() - expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(ignoredProc.listenerCount('error')).toBe(0) - expect(ignoredProc.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('rg search settles and detaches when a timed-out child does not emit close', async () => { - vi.useFakeTimers() - try { - const proc = createMockProcess() - spawnMock.mockReturnValue(proc) - - const promise = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) - const outcomePromise = promise.then((result) => - result.truncated ? `truncated:${result.totalMatches}` : 'not-truncated' - ) - - await vi.runOnlyPendingTimersAsync() - const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - - expect(outcome).toBe('truncated:0') - expect(proc.kill).toHaveBeenCalled() - expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((proc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(proc.listenerCount('error')).toBe(0) - expect(proc.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it.each(['error-first', 'close-first'] as const)( - 'tags only a %s pre-spawn rg search failure as unavailable', - async (order) => { - const root = await makeTempRoot() - const missing = createMockProcess() - Object.defineProperty(missing, 'pid', { value: undefined }) - spawnMock.mockReturnValueOnce(missing) - const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) - const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) - if (order === 'error-first') { - expect(() => missing.emit('error', error)).not.toThrow() - missing.emit('close', -2, null) - } else { - missing.emit('close', -2, null) - expect(() => missing.emit('error', error)).not.toThrow() - } - - await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) - expect(missing.listenerCount('error')).toBe(0) - expect(missing.listenerCount('close')).toBe(0) - } - ) - - it('tags unsupported native launcher exits as unavailable', async () => { - const root = await makeTempRoot() - const child = createMockProcess() - Object.defineProperty(child, 'pid', { value: 1 }) - spawnMock.mockReturnValueOnce(child) - const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) - - child.emit('close', 127, null) - - await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) - }) - - // Why this changed: both paths still refuse the git/readdir fallback, which is what "non-fallback" - // pinned -- a chain that walks the root cannot help when the root is gone. What changed is that - // search no longer reports an empty, successful-looking scan for a workspace that moved. - it('names the unreachable root on both missing-root launch paths', async () => { - const missingRoot = await makeTempRoot() - await rm(missingRoot, { recursive: true, force: true }) - const listFirst = createMockProcess() - const listProbe = createMockProcess() - Object.defineProperty(listFirst, 'pid', { value: undefined }) - Object.defineProperties(listFirst, { - stdout: { value: undefined }, - stderr: { value: undefined } - }) - Object.defineProperty(listProbe, 'pid', { value: 1 }) - let callIndex = 0 - spawnMock.mockImplementation(() => [listFirst, listProbe][callIndex++]) - const listError = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) - const listing = listFilesWithRg(missingRoot) - listFirst.emit('error', listError) - - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - // Why assert the relay env and not just its presence: the probe decides whether a launch - // failure was the binary or the root, so it has to resolve the same `rg` the failed spawn - // would have. Under process.env it could find a different one, or none. - expect(spawnMock.mock.calls[1]).toEqual([ - 'rg', - ['--version'], - // windowsHide: the probe must never flash a console window on Windows. - expect.objectContaining({ - env: buildRelayCommandEnv(), - stdio: 'ignore', - windowsHide: true, - shell: false - }) - ]) - listProbe.emit('close', 0, null) - await expect(listing).rejects.toThrow(`Search root is not reachable: ${missingRoot}`) - - spawnMock.mockReset() - const searchChild = createMockProcess() - const searchProbe = createMockProcess() - Object.defineProperty(searchChild, 'pid', { value: undefined }) - Object.defineProperties(searchChild, { - stdout: { value: undefined }, - stderr: { value: undefined } - }) - Object.defineProperty(searchProbe, 'pid', { value: 1 }) - spawnMock.mockReturnValueOnce(searchChild).mockReturnValueOnce(searchProbe) - const search = searchWithRg(missingRoot, 'ok', { maxResults: 100 }) - searchChild.emit('error', listError) - - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - searchProbe.emit('close', 0, null) - await expect(search).rejects.toThrow(`Search root is not reachable: ${missingRoot}`) - }) - - // Why this is the case that matters: it is the NORMAL remote setup. Orca uploads a bundled rg - // precisely because the host has no `rg` of its own, so probing PATH alone would fail and report - // a moved workspace as a missing ripgrep -- telling the user to install what Orca already ships. - it('names the unreachable root when only the bundled rg exists, not PATH rg', async () => { - const missingRoot = await makeTempRoot() - await rm(missingRoot, { recursive: true, force: true }) - const bundled = process.execPath - configureRelayBundledRipgrep(bundled) - const first = createMockProcess() - Object.defineProperty(first, 'pid', { value: undefined }) - const probe = createMockProcess() - Object.defineProperty(probe, 'pid', { value: 1 }) - let callIndex = 0 - spawnMock.mockImplementation(() => [first, probe][callIndex++]) - const listing = listFilesWithRg(missingRoot) - first.emit('error', Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })) - - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - // The probe must ask about the binary that actually failed, not a PATH rg this host lacks. - expect(spawnMock.mock.calls[1]?.[0]).toBe(bundled) - probe.emit('close', 0, null) - - await expect(listing).rejects.toThrow(`Search root is not reachable: ${missingRoot}`) - }) - - it('keeps missing-rg precedence when the root also disappeared', async () => { - const missingRoot = await makeTempRoot() - await rm(missingRoot, { recursive: true, force: true }) - const first = createMockProcess() - const probe = createMockProcess() - for (const child of [first, probe]) { - Object.defineProperty(child, 'pid', { value: undefined }) - } - let callIndex = 0 - spawnMock.mockImplementation(() => [first, probe][callIndex++]) - const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) - const listing = listFilesWithRg(missingRoot) - first.emit('error', error) - - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - probe.emit('close', -2, null) - - await expect(listing).rejects.toBeInstanceOf(RipgrepUnavailableError) - expect(() => probe.emit('error', error)).not.toThrow() - }) - - it('rejects post-spawn rg search errors instead of returning an empty result', async () => { - const started = createMockProcess() - Object.defineProperty(started, 'pid', { value: 1 }) - spawnMock.mockReturnValueOnce(started) - const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) - started.emit('error', new Error('post-spawn failure')) - await expect(ordinaryFailure).rejects.toThrow('post-spawn failure') - }) -}) diff --git a/src/relay/fs-handler-list-files-path-framing.test.ts b/src/relay/fs-handler-list-files-path-framing.test.ts deleted file mode 100644 index 037bbc97a41..00000000000 --- a/src/relay/fs-handler-list-files-path-framing.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { rgPath } from '@vscode/ripgrep-universal' -import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' -import { listFilesWithRg } from './fs-handler-list-files' - -let root: string | undefined - -afterEach(async () => { - configureRelayBundledRipgrep(undefined) - vi.unstubAllEnvs() - if (root) { - await rm(root, { recursive: true, force: true }) - } -}) - -it('ignores user rg configuration when listing files', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-rg-config-')) - configureRelayBundledRipgrep(rgPath) - const config = join(root, 'config') - await writeFile(config, '--glob\n!*.ts\n') - await writeFile(join(root, 'visible.ts'), '') - vi.stubEnv('RIPGREP_CONFIG_PATH', config) - - expect(await listFilesWithRg(root)).toContain('visible.ts') - expect(await listFilesWithRg(root, [], { maxResults: 10 })).toContain('visible.ts') -}) - -it.skipIf(process.platform === 'win32')( - 'preserves newline, carriage return and Unicode filenames', - async () => { - root = await mkdtemp(join(tmpdir(), 'orca-rg-filenames-')) - configureRelayBundledRipgrep(rgPath) - const names = ['first\nsecond.ts', 'trailing\r', 'résumé-😀.ts', 'spaces .ts '] - const fixtureRoot = root - await Promise.all(names.map((name) => writeFile(join(fixtureRoot, name), ''))) - - expect((await listFilesWithRg(root)).sort()).toEqual([...names].sort()) - expect((await listFilesWithRg(root, [], { maxResults: 10 })).sort()).toEqual([...names].sort()) - expect(await listFilesWithRg(root, [], { searchQuery: 'second', maxResults: 1 })).toEqual([ - 'first\nsecond.ts' - ]) - } -) - -it.skipIf(process.platform !== 'linux')( - 'rejects real non-UTF8 filenames instead of fabricating paths', - async () => { - root = await mkdtemp(join(tmpdir(), 'orca-rg-invalid-name-')) - configureRelayBundledRipgrep(rgPath) - const invalidPath = Buffer.concat([Buffer.from(join(root, 'bad-')), Buffer.from([0xff])]) - await writeFile(invalidPath, '') - await expect(listFilesWithRg(root)).rejects.toThrow('not valid UTF-8') - } -) diff --git a/src/relay/fs-handler-list-files-result-limit.test.ts b/src/relay/fs-handler-list-files-result-limit.test.ts deleted file mode 100644 index 44320c7aaa5..00000000000 --- a/src/relay/fs-handler-list-files-result-limit.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * #12547: the relay used to serialize an unbounded `fs.listFiles` reply into one response frame, - * which died as "Message too large" or over-capacity. #17954 fixed that by streaming the reply, so - * the size of a listing is no longer a correctness question and the host must NOT quietly impose a - * cap of its own — a caller that named no limit reads the array as the whole listing, and clients - * that predate `maxResults` on this call hardcode `truncated: false`, so a prefix would reach them - * as a complete tree with nothing on the wire to notice. The cap belongs to the caller; the host - * only clamps it to the ceiling the scan's retention budget assumes. - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { runListFilesScanMock } = vi.hoisted(() => ({ - runListFilesScanMock: vi.fn() -})) - -vi.mock('./fs-list-files-fallback-chain', () => ({ - runListFilesScan: runListFilesScanMock -})) - -vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) - -import { FsHandler } from './fs-handler' -import { RelayContext } from './context' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' - -type ListFilesHandler = ( - params: Record, - context?: { clientId: number } -) => Promise - -function createHandler(): { listFiles: ListFilesHandler; dispose: () => void } { - const requestHandlers = new Map() - const dispatcher = { - onRequest: (method: string, handler: ListFilesHandler) => requestHandlers.set(method, handler), - onNotification: vi.fn(), - onClientDetached: vi.fn(), - notify: vi.fn(), - notifyBulk: vi.fn(), - publishProducerNotification: vi.fn(() => true), - activeClientIds: () => [], - producerEnvelopeBudget: () => Number.MAX_SAFE_INTEGER - } as unknown as RelayDispatcher - const handler = new FsHandler(dispatcher, new RelayContext(), { - dispose: vi.fn(), - forgetRoot: vi.fn(), - disposeAndWait: vi.fn(async () => {}), - reopen: vi.fn(), - subscribe: vi.fn() - }) - return { listFiles: requestHandlers.get('fs.listFiles')!, dispose: () => handler.dispose() } -} - -describe('fs.listFiles result limit', () => { - let listFiles: ListFilesHandler - let dispose: () => void - - beforeEach(() => { - runListFilesScanMock.mockReset() - runListFilesScanMock.mockResolvedValue([]) - const created = createHandler() - listFiles = created.listFiles - dispose = created.dispose - return () => dispose() - }) - - function scanMaxResults(): unknown { - // runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery) - return runListFilesScanMock.mock.calls[0][3] - } - - it('leaves a request that omitted maxResults unbounded rather than silently prefixing it', async () => { - await listFiles({ rootPath: '/remote/root' }, { clientId: 1 }) - - expect(scanMaxResults()).toBeUndefined() - }) - - it('ignores a malformed maxResults rather than treating it as a cap', async () => { - await listFiles({ rootPath: '/remote/root', maxResults: 'all' }, { clientId: 1 }) - - expect(scanMaxResults()).toBeUndefined() - }) - - it('answers an uncapped request in full, however large the tree is', async () => { - const files = Array.from( - { length: QUICK_OPEN_LISTING_MAX_RESULTS + 500 }, - (_, index) => `f${index}` - ) - runListFilesScanMock.mockResolvedValue(files) - - await expect(listFiles({ rootPath: '/remote/root' }, { clientId: 1 })).resolves.toEqual(files) - }) - - it('hands a client that named a cap the prefix it asked for', async () => { - runListFilesScanMock.mockResolvedValue( - Array.from({ length: QUICK_OPEN_LISTING_MAX_RESULTS }, (_, index) => `f${index}`) - ) - - const files = await listFiles( - { rootPath: '/remote/root', maxResults: QUICK_OPEN_LISTING_MAX_RESULTS }, - { clientId: 1 } - ) - - expect(files).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) - }) - - it('keeps a smaller client limit and clamps a larger one', async () => { - await listFiles({ rootPath: '/remote/root', maxResults: 33 }, { clientId: 1 }) - expect(scanMaxResults()).toBe(33) - - runListFilesScanMock.mockClear() - await listFiles( - { rootPath: '/remote/root', maxResults: QUICK_OPEN_LISTING_MAX_RESULTS * 10 }, - { clientId: 2 } - ) - expect(scanMaxResults()).toBe(QUICK_OPEN_LISTING_MAX_RESULTS) - }) -}) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts deleted file mode 100644 index cafa549a290..00000000000 --- a/src/relay/fs-handler-list-files.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { quickOpenListingPathFilter } from '../shared/quick-open-listing-path-filter' -import { retainRelayFileListingPath } from './fs-file-listing-paths' -import { FileInventoryBudget } from '../shared/file-inventory-budget' -import { runRelayFileListingPasses, retryRelayFileListingPass } from './fs-list-files-passes' -import { RipgrepFilenameDecoder } from '../shared/ripgrep-filename-decoder' -/** - * Ripgrep-based file listing for Quick Open. - * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir - * worktree over SSH, rg descended into every dotfile cache, hit the timeout, - * and silently resolved with a partial list — Quick Open then showed "No - * matching files" even though the file existed on disk. This implementation: - * - streams via spawn (no maxBuffer failure mode) - * - prunes traversal at rg level using the shared blocklist globs - * - includes gitignored files, preserving primary-first order for bounded listings - * - honors excludePathPrefixes for nested linked worktrees - * - rejects (not resolves) on timeout / spawn error / signal exit so - * the UI shows a load error instead of a false-empty list - * - treats rg exit code 2 with parseable stdout as success (permission - * denied on a single subdir is expected on home-dir roots) - */ -import { spawn, type ChildProcess } from 'node:child_process' -import { fileListingCancellationError } from '../shared/file-listing-cancellation' -import { buildRgArgsForQuickOpen } from '../shared/quick-open-filter' -import { - absorbPendingRipgrepSpawnError, - classifyRipgrepLaunchFailure, - isRipgrepUnavailableExit, - isTransientRipgrepSpawnError, - killSpawnedRipgrepProcess, - RipgrepLaunchFailureError, - ripgrepMissingCwdError, - RipgrepUnavailableError -} from '../shared/ripgrep-process-availability' -import { QuickOpenPathRanker } from '../shared/quick-open-path-search' -import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' -import { - pathRipgrepCommand, - resolveRelayRipgrepCommand, - retryRipgrepOnPathAfterLaunchFailure -} from './relay-bundled-ripgrep' - -export const LIST_FILES_TIMEOUT_MS = 25_000 - -export function listFilesWithRg( - rootPath: string, - excludePathPrefixes: readonly string[] = [], - options: { - signal?: AbortSignal - maxResults?: number - candidatePaths?: string[] - searchQuery?: string - includeIgnored?: boolean - followSymlinks?: boolean - } = {} -): Promise { - const { signal, maxResults, searchQuery } = options - const includePath = quickOpenListingPathFilter(excludePathPrefixes, options.candidatePaths) - if (signal?.aborted) { - return Promise.reject(fileListingCancellationError(signal)) - } - return new Promise((resolve, reject) => { - const inventoryBudget = - maxResults === undefined && searchQuery === undefined ? new FileInventoryBudget() : null - const files = new Set() - const retention = { files, budget: inventoryBudget, includePath } - let rankedPaths: string[] | null = null - let done = false - const children: { - child: ChildProcess - isDone: () => boolean - reject: (error: Error) => void - }[] = [] - - const { primary, ignoredPass } = buildRgArgsForQuickOpen({ - // Why: rg only applies root-relative exclude globs as traversal pruning - // when the search target is relative to cwd. Absolute targets still - // emit root-relative-looking paths for filters, but they do not prune. - searchRoot: '.', - followSymlinks: options.followSymlinks, - excludePathPrefixes, - forceSlashSeparator: true - }) - - const processLine = (rawLine: string, attemptRanker: QuickOpenPathRanker | null): boolean => { - try { - const included = retainRelayFileListingPath(rawLine, attemptRanker, retention) - if (maxResults !== undefined && files.size >= maxResults) { - finishAtLimit() - } - return included - } catch (error) { - done = true - signal?.removeEventListener('abort', onAbort) - killSurvivors('File inventory capacity exceeded') - files.clear() - reject(error) - return true - } - } - - const runPassOnce = (args: string[]): Promise => - new Promise((passResolve, passReject) => { - // A completed pass may queue its continuation before cancellation settles the request. - if (done || signal?.aborted) { - return passResolve() - } - const attemptRanker = - searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16) - const filenameDecoder = new RipgrepFilenameDecoder((error) => { - killSpawnedRipgrepProcess(child) - rejectPass(error) - }) - let passBuf = '' - let passDone = false - let passFileCount = 0 - let processErrorObserved = false - let unavailableExitObserved = false - let launchFailureCheck: Promise | null = null - // Suppress permission noise; cwd anchors root-relative exclusion globs. - const command = resolveRelayRipgrepCommand() - // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the - // spawn cwd -- the user's repo -- before PATH. "No rg here" is what the chain handles. - if (command === null) { - throw new RipgrepUnavailableError() - } - const env = buildRelayCommandEnv() - let child: ChildProcess - try { - child = spawn(command, ['--no-messages', ...args], { - cwd: rootPath, - env, - stdio: ['ignore', 'pipe', 'ignore'], - windowsHide: true - }) - } catch (error) { - throw isTransientRipgrepSpawnError(error) - ? new RipgrepLaunchFailureError( - `rg failed to start (${(error as NodeJS.ErrnoException).code})` - ) - : error - } - const cleanup = (): void => { - clearTimeout(timer) - child.stdout?.off('data', handleStdoutData) - child.off('error', handleError) - child.off('close', handleClose) - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - } - const rejectPass = (error: unknown): void => { - if (passDone) { - return - } - passDone = true - passBuf = '' - cleanup() - passReject(error instanceof Error ? error : new Error(String(error))) - } - const resolvePass = (): void => { - if (passDone) { - return - } - passDone = true - cleanup() - if (attemptRanker) { - rankedPaths = attemptRanker.result().paths - } - passResolve() - } - const rejectLaunchFailure = (error: Error): void => { - if (launchFailureCheck) { - return - } - launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error) - .then(async (retryOnPath) => { - if (passDone || done) { - return - } - if (retryOnPath) { - // Why: runPass retries a launch failure once, and the retry resolves to PATH rg. - rejectPass(new RipgrepLaunchFailureError('bundled rg failed to start')) - return - } - // Why distinguish: RipgrepUnavailableError is what engages the git/readdir chain, - // and that chain cannot help when the root itself is gone. - rejectPass( - (await classifyRipgrepLaunchFailure( - rootPath, - [command, pathRipgrepCommand()], - env, - signal - )) === 'cwd-unreachable' - ? ripgrepMissingCwdError(rootPath) - : new RipgrepUnavailableError() - ) - }) - .catch(rejectPass) - } - children.push({ child, isDone: () => passDone, reject: rejectPass }) - - const timer = setTimeout(() => { - // Discard residual buffer on abnormal exit — a truncated byte - // sequence could look like a valid path. - killSpawnedRipgrepProcess(child) - rejectPass(new Error('rg list timed out')) - }, LIST_FILES_TIMEOUT_MS) - - function handleStdoutData(chunk: Buffer | string): void { - const decoded = filenameDecoder.decode(chunk) - if (decoded === null) { - return - } - passBuf += decoded - let start = 0 - let idx = passBuf.indexOf('\0', start) - while (idx !== -1) { - if (processLine(passBuf.substring(start, idx), attemptRanker)) { - passFileCount++ - } - if (done) { - return - } - start = idx + 1 - idx = passBuf.indexOf('\0', start) - } - passBuf = start < passBuf.length ? passBuf.substring(start) : '' - } - function handleError(err: NodeJS.ErrnoException): void { - processErrorObserved = true - if (isTransientRipgrepSpawnError(err)) { - rejectPass(new RipgrepLaunchFailureError(`rg failed to start (${err.code})`)) - return - } - if (isRipgrepUnavailableExit(child, null, null)) { - passBuf = '' - rejectLaunchFailure(err) - return - } - rejectPass(err) - } - function handleClose(code: number | null, signal: NodeJS.Signals | null): void { - if (passDone) { - return - } - if ( - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: true - }) - ) { - unavailableExitObserved = true - passBuf = '' - rejectLaunchFailure(new Error(`rg exited with code ${code}`)) - return - } - // Why signal != null is a failure: the only way spawn gets a signal - // is if the process was killed (timeout, OOM, external SIGKILL). - // Trusting its stdout could surface a truncated list as a success. - if (signal) { - rejectPass(new Error(`rg killed by ${signal}`)) - return - } - if (!filenameDecoder.finish()) { - return - } - // Flush residual line only on clean exit. - if (passBuf) { - if (processLine(passBuf, attemptRanker)) { - passFileCount++ - } - } - // exit 0 = matches found, 1 = no files (still success for --files). - // exit 2 is documented as "a subdirectory could not be searched" - // (e.g. EACCES on .ssh), but rg also returns 2 for fatal errors - // (bad flag, invalid glob). Only trust exit 2 when rg emitted at - // least one parseable path — otherwise treat it as a real failure. - if (code === 0 || code === 1 || (code === 2 && passFileCount > 0)) { - resolvePass() - } else { - rejectPass(new Error(`rg exited with code ${code}`)) - } - } - - child.stdout?.on('data', handleStdoutData) - child.once('error', handleError) - child.once('close', handleClose) - }) - - const runPass = (args: string[]): Promise => - retryRelayFileListingPass( - () => runPassOnce(args), - () => Boolean(signal?.aborted || done) - ) - - const killSurvivors = (reason: string): void => { - // Cancellation or a reached budget must stop any admitted scan or retry. - for (const entry of children) { - if (entry.isDone()) { - continue - } - if (entry.child.exitCode === null && entry.child.signalCode === null) { - killSpawnedRipgrepProcess(entry.child) - } - entry.reject(new Error(reason)) - } - } - - function finishAtLimit(): void { - if (done) { - return - } - done = true - signal?.removeEventListener('abort', onAbort) - killSurvivors('rg list reached bounded result limit') - resolve(Array.from(files).slice(0, maxResults)) - } - - // Why: a cancelled scan (workspace switch, superseded request) must stop - // its rg children immediately instead of letting them walk the tree to - // completion and flood the relay with stdout it will only discard. - const onAbort = (): void => { - if (done) { - return - } - done = true - killSurvivors('rg list cancelled') - reject(fileListingCancellationError(signal)) - } - signal?.addEventListener('abort', onAbort, { once: true }) - - runRelayFileListingPasses(options, primary, ignoredPass, runPass, () => files.size) - .then(() => { - if (done) { - return - } - done = true - signal?.removeEventListener('abort', onAbort) - resolve(rankedPaths ?? Array.from(files)) - }) - .catch((err) => { - if (done) { - return - } - done = true - signal?.removeEventListener('abort', onAbort) - killSurvivors('rg list canceled after failure') - reject(err instanceof Error ? err : new Error(String(err))) - }) - }) -} diff --git a/src/relay/fs-handler-readdir-fallback.test.ts b/src/relay/fs-handler-readdir-fallback.test.ts deleted file mode 100644 index d3e5591ab32..00000000000 --- a/src/relay/fs-handler-readdir-fallback.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { listFilesWithReaddir } from './fs-handler-readdir-fallback' - -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('relay readdir file-list fallback', () => { - it('returns a bounded prefix instead of rejecting at the requested result limit', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-readdir-')) - roots.push(root) - await mkdir(join(root, 'src')) - await writeFile(join(root, 'src', 'a.ts'), 'a') - await writeFile(join(root, 'src', 'b.ts'), 'b') - - const files = await listFilesWithReaddir(root, [], { maxResults: 1 }) - - expect(files).toHaveLength(1) - expect(['src/a.ts', 'src/b.ts']).toContain(files[0]) - }) -}) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts deleted file mode 100644 index 25006788f1b..00000000000 --- a/src/relay/fs-handler-readdir-fallback.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Plain readdir-based file listing fallback. - * - * Why: when neither ripgrep nor git is available (e.g. a non-git folder on a - * remote machine without rg), we still need to list files for quick-open. - * This walks the directory tree using Node's fs.readdir, applying the shared - * Quick Open filter policy (blocklist + nested-worktree excludes). - * - * Partial results: the cap and deadline remain as containment, but a capped - * or timed-out traversal now rejects instead of returning a partial list — - * otherwise Quick Open would display "No matching files" for what was - * actually an incomplete scan. - */ -import { - createQuickOpenReaddirBudget, - listQuickOpenFilesWithReaddir -} from '../shared/quick-open-readdir-walk' - -/** - * Recursively list files under `rootPath` using fs.readdir. - * Returns relative POSIX paths. Rejects on cap/deadline so the UI cannot - * mistake a partial list for a complete empty result. - */ -export async function listFilesWithReaddir( - rootPath: string, - excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal; maxResults?: number } = {} -): Promise { - return listQuickOpenFilesWithReaddir(rootPath, { - excludePathPrefixes, - budget: createQuickOpenReaddirBudget(), - maxResults: options.maxResults, - signal: options.signal - }) -} diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts deleted file mode 100644 index 0487a237c4d..00000000000 --- a/src/relay/fs-handler-ripgrep-fallback.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { ProcessResult } from '@orca/process-host/process-spec' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RunProcessModule from '@orca/process-host' -import type * as FsHandlerGitFallback from './fs-handler-git-fallback' -import type * as FsHandlerUtils from './fs-handler-utils' - -const { - runProcessMock, - listFilesWithGitMock, - listFilesWithReaddirMock, - listFilesWithRgMock, - searchWithGitGrepMock, - searchWithRgMock -} = vi.hoisted(() => ({ - runProcessMock: vi.fn(), - listFilesWithGitMock: vi.fn(), - listFilesWithReaddirMock: vi.fn(), - listFilesWithRgMock: vi.fn(), - searchWithGitGrepMock: vi.fn(), - searchWithRgMock: vi.fn() -})) - -vi.mock('@orca/process-host', async (importOriginal) => ({ - ...(await importOriginal()), - runProcess: runProcessMock -})) - -vi.mock('./fs-handler-utils', async (importOriginal) => ({ - ...(await importOriginal()), - listFilesWithRg: listFilesWithRgMock, - searchWithRg: searchWithRgMock -})) - -vi.mock('./fs-handler-git-fallback', async (importOriginal) => ({ - ...(await importOriginal()), - listFilesWithGit: listFilesWithGitMock, - searchWithGitGrep: searchWithGitGrepMock -})) - -vi.mock('./fs-handler-readdir-fallback', () => ({ - listFilesWithReaddir: listFilesWithReaddirMock -})) - -import { FileListingCancelledError } from '../shared/file-listing-cancellation' -import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' -import { RelayContext } from './context' -import { FsHandler } from './fs-handler' -import { runListFilesScan } from './fs-list-files-fallback-chain' -import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' - -type FsHandlerInternals = { - search(params: Record): Promise -} - -function createHandler(): FsHandlerInternals { - const dispatcher = { - onRequest: vi.fn(), - onNotification: vi.fn(), - onClientDetached: vi.fn(() => () => undefined) - } - const watcherPool = { - dispose: vi.fn(), - forgetRoot: vi.fn(), - subscribe: vi.fn() - } - return new FsHandler(dispatcher as never, new RelayContext(), watcherPool as never) as never -} - -describe('relay direct ripgrep admission', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('falls back only for a tagged search launch failure', async () => { - const handler = createHandler() - const fallback = { files: [], totalMatches: 0, truncated: false } - searchWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) - searchWithGitGrepMock.mockResolvedValueOnce(fallback) - - await expect(handler.search({ rootPath: '/repo', query: 'needle' })).resolves.toBe(fallback) - expect(searchWithRgMock).toHaveBeenCalledTimes(1) - expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) - - const ordinaryFailure = new Error('rg failed after spawn') - searchWithRgMock.mockRejectedValueOnce(ordinaryFailure) - await expect(handler.search({ rootPath: '/repo', query: 'needle' })).rejects.toBe( - ordinaryFailure - ) - expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) - }) - - it.each(['true\n', 'false\n'])( - 'uses the Git listing after a successful probe (%s)', - async (stdout) => { - const controller = new AbortController() - listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) - runProcessMock.mockResolvedValueOnce({ - code: 0, - signal: null, - stdout, - stderr: '', - timedOut: false - }) - listFilesWithGitMock.mockResolvedValueOnce(['src/index.ts']) - - await expect(runListFilesScan('/repo', [], controller.signal)).resolves.toEqual([ - 'src/index.ts' - ]) - expect(listFilesWithRgMock).toHaveBeenCalledTimes(1) - expect(runProcessMock).toHaveBeenCalledWith({ - program: 'git', - args: ['rev-parse', '--is-inside-work-tree'], - cwd: '/repo', - env: buildRelayCommandEnv(), - timeoutMs: 5_000, - signal: controller.signal - }) - expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) - expect(listFilesWithReaddirMock).not.toHaveBeenCalled() - } - ) - - it.each([ - { code: 128, signal: null, timedOut: false }, - { code: null, signal: 'SIGTERM', timedOut: false }, - { code: 0, signal: null, timedOut: true } - ] as const)('uses the directory walk when the Git probe fails (%j)', async (result) => { - const controller = new AbortController() - listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) - runProcessMock.mockResolvedValueOnce({ ...result, stdout: '', stderr: '' }) - listFilesWithReaddirMock.mockResolvedValueOnce(['src/index.ts']) - - await expect(runListFilesScan('/folder', [], controller.signal)).resolves.toEqual([ - 'src/index.ts' - ]) - expect(listFilesWithGitMock).not.toHaveBeenCalled() - expect(listFilesWithReaddirMock).toHaveBeenCalledWith('/folder', [], { - signal: controller.signal, - maxResults: undefined - }) - }) - - it('uses the directory walk when Git cannot start', async () => { - const controller = new AbortController() - listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) - runProcessMock.mockRejectedValueOnce(new Error('spawn git ENOENT')) - listFilesWithReaddirMock.mockResolvedValueOnce(['src/index.ts']) - - await expect(runListFilesScan('/folder', [], controller.signal)).resolves.toEqual([ - 'src/index.ts' - ]) - expect(listFilesWithGitMock).not.toHaveBeenCalled() - expect(listFilesWithReaddirMock).toHaveBeenCalledTimes(1) - }) - - it.each([0, 128])('lets cancellation during the Git probe win its exit (%s)', async (code) => { - const controller = new AbortController() - const cancellation = new FileListingCancelledError('superseded') - const probe = Promise.withResolvers() - listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) - runProcessMock.mockReturnValueOnce(probe.promise) - - const scan = runListFilesScan('/repo', [], controller.signal) - await vi.waitFor(() => expect(runProcessMock).toHaveBeenCalledTimes(1)) - expect(runProcessMock.mock.calls[0][0].signal).toBe(controller.signal) - controller.abort(cancellation) - probe.resolve({ code, signal: null, stdout: '', stderr: '', timedOut: false }) - - await expect(scan).rejects.toBe(cancellation) - expect(listFilesWithGitMock).not.toHaveBeenCalled() - expect(listFilesWithReaddirMock).not.toHaveBeenCalled() - }) - - it('lets cancellation win an unavailable-listing race before Git starts', async () => { - const controller = new AbortController() - const cancellation = new FileListingCancelledError('superseded') - listFilesWithRgMock.mockImplementationOnce(async () => { - controller.abort(cancellation) - throw new RipgrepUnavailableError() - }) - - await expect(runListFilesScan('/repo', [], controller.signal)).rejects.toBe(cancellation) - expect(runProcessMock).not.toHaveBeenCalled() - expect(listFilesWithGitMock).not.toHaveBeenCalled() - expect(listFilesWithReaddirMock).not.toHaveBeenCalled() - }) - - it('requires ripgrep for bounded query ranking instead of retaining a full Git inventory', async () => { - const controller = new AbortController() - listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) - - await expect(runListFilesScan('/repo', [], controller.signal, 33, 'target')).rejects.toThrow( - 'Quick Open search requires ripgrep' - ) - expect(runProcessMock).not.toHaveBeenCalled() - expect(listFilesWithGitMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts deleted file mode 100644 index 480bb36485e..00000000000 --- a/src/relay/fs-handler-stream.test.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { FsHandler } from './fs-handler' -import { RelayContext } from './context' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { MAX_CONCURRENT_STREAMS, STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' - -vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) - -type NotificationLane = 'producer' | 'bulk' | 'control' -type Notification = { - method: string - params?: Record - lane: NotificationLane - clientId?: number -} -type MockRequestContext = { clientId?: number; isStale: () => boolean } - -function createMockDispatcher() { - const requestHandlers = new Map< - string, - (params: Record, context?: MockRequestContext) => Promise - >() - const notificationHandlers = new Map) => void>() - const notifications: Notification[] = [] - const droppedProducerFrames: Notification[] = [] - // Mirrors the real lane split: a saturated producer queue drops frames, control frames still land. - let producerSaturated = false - let holdControlSettlement = false - const heldControlSettlements: (() => void)[] = [] - return { - onRequest: vi.fn( - ( - method: string, - handler: typeof requestHandlers extends Map ? H : never - ) => { - requestHandlers.set(method, handler as never) - } - ), - onNotification: vi.fn((method: string, handler: (params: Record) => void) => { - notificationHandlers.set(method, handler) - }), - notify: vi.fn((method: string, params?: Record) => { - const frame: Notification = { method, params, lane: 'producer' } - if (producerSaturated) { - droppedProducerFrames.push(frame) - return - } - notifications.push(frame) - }), - notifyBulk: vi.fn( - async ( - method: string, - params?: Record, - opts?: { clientId?: number } - ): Promise => { - notifications.push({ method, params, lane: 'bulk', clientId: opts?.clientId }) - } - ), - notifyClient: vi.fn((clientId: number, method: string, params?: Record) => { - notifications.push({ method, params, lane: 'control', clientId }) - }), - // Mirrors the real control lane: the frame is queued on enqueue, but settles only once the - // sink actually wrote it — holding settlement models a socket that is not draining. - tryNotifyClient: vi.fn( - ( - clientId: number, - method: string, - params?: Record, - onSettled?: (result: { ok: boolean }) => void - ): boolean => { - notifications.push({ method, params, lane: 'control', clientId }) - const settle = () => onSettled?.({ ok: true }) - if (holdControlSettlement) { - heldControlSettlements.push(settle) - } else { - settle() - } - return true - } - ), - notifyControl: vi.fn((method: string, params?: Record) => { - notifications.push({ method, params, lane: 'control' }) - }), - _notifications: notifications, - _droppedProducerFrames: droppedProducerFrames, - saturateProducerLane() { - producerSaturated = true - }, - holdControlSettlements() { - holdControlSettlement = true - }, - settleHeldControlFrames() { - holdControlSettlement = false - for (const settle of heldControlSettlements.splice(0)) { - settle() - } - }, - callRequest( - method: string, - params: Record = {}, - context?: MockRequestContext - ) { - const handler = requestHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params, context) - }, - callNotification(method: string, params: Record = {}) { - const handler = notificationHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - handler(params) - } - } -} - -type StreamOutcome = { - chunks: { seq: number; data: string }[] - end: { streamId: number } | null - err: { code: string; message: string } | null -} - -function collectStream(d: ReturnType): StreamOutcome { - const chunks: { seq: number; data: string }[] = [] - let end: { streamId: number } | null = null - let err: { code: string; message: string } | null = null - for (const n of d._notifications) { - if (n.method === 'fs.streamChunk') { - chunks.push({ seq: n.params!.seq as number, data: n.params!.data as string }) - } else if (n.method === 'fs.streamEnd') { - end = { streamId: n.params!.streamId as number } - } else if (n.method === 'fs.streamError') { - err = { - code: n.params!.code as string, - message: n.params!.message as string - } - } - } - return { chunks, end, err } -} - -async function flush(times = 5): Promise { - for (let i = 0; i < times; i++) { - await new Promise((r) => setImmediate(r)) - } -} - -async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { - const deadline = Date.now() + timeoutMs - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error('waitFor: predicate did not become true in time') - } - await new Promise((r) => setImmediate(r)) - } -} - -describe('FsHandler readFileStream', () => { - let dispatcher: ReturnType - let handler: FsHandler - let tmpDir: string - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-')) - dispatcher = createMockDispatcher() - handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - }) - - afterEach(async () => { - handler.dispose() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - it('streams a binary file in chunked notifications', async () => { - const filePath = path.join(tmpDir, 'image.png') - const content = Buffer.alloc(300 * 1024, 0x42) - writeFileSync(filePath, content) - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { isStale: () => false } - )) as { streamId: number; totalSize: number; resultEncoding: string } - expect(meta.streamId).toBeDefined() - expect(meta.totalSize).toBe(content.length) - expect(meta.resultEncoding).toBe('base64') - - await waitFor(() => collectStream(dispatcher).end !== null) - const { chunks, end, err } = collectStream(dispatcher) - expect(err).toBeNull() - expect(end).toEqual({ streamId: meta.streamId }) - const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) - expect(reassembled.equals(content)).toBe(true) - }) - - it('publishes fs.streamEnd after the final fs.streamChunk and targets the same client', async () => { - const filePath = path.join(tmpDir, 'targeted.png') - writeFileSync(filePath, Buffer.alloc(300 * 1024, 0x42)) - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { clientId: 7, isStale: () => false } - )) as { streamId: number } - - await waitFor(() => collectStream(dispatcher).end !== null) - const frames = dispatcher._notifications - const endIndex = frames.findIndex((n) => n.method === 'fs.streamEnd') - const lastChunkIndex = frames.map((n) => n.method).lastIndexOf('fs.streamChunk') - expect(lastChunkIndex).toBeGreaterThanOrEqual(0) - expect(endIndex).toBe(lastChunkIndex + 1) - expect(frames[endIndex]).toEqual({ - method: 'fs.streamEnd', - params: { streamId: meta.streamId }, - lane: 'control', - clientId: 7 - }) - expect(frames[lastChunkIndex].clientId).toBe(7) - }) - - // Real saturation is not constructible: the fixed-bulk lane admits a chunk only while producerBytes is 0, - // so a real backlog parks the chunk pump long before the terminal frame — hence the lane-tagging mock. - it('orders fs.streamEnd after the final chunk on the control lane and releases the stream', async () => { - const filePath = path.join(tmpDir, 'stream-end-release.png') - writeFileSync(filePath, Buffer.alloc(300 * 1024, 0x42)) - // Producer-lane frames now divert to the drop log, so an empty log proves no stream frame took that lane. - dispatcher.saturateProducerLane() - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { clientId: 3, isStale: () => false } - )) as { streamId: number } - - await waitFor(() => collectStream(dispatcher).end !== null) - expect(dispatcher._droppedProducerFrames).toHaveLength(0) - expect(collectStream(dispatcher).end).toEqual({ streamId: meta.streamId }) - - const frames = dispatcher._notifications - const endIndex = frames.findIndex((n) => n.method === 'fs.streamEnd') - const lastChunkIndex = frames.map((n) => n.method).lastIndexOf('fs.streamChunk') - expect(lastChunkIndex).toBeGreaterThanOrEqual(0) - expect(endIndex).toBe(lastChunkIndex + 1) - expect(frames[endIndex].lane).toBe('control') - - const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry - await waitFor(() => registry.size() === 0) - }) - - it('fills protocol chunks when fs.read returns short before EOF', async () => { - const filePath = path.join(tmpDir, 'short-read.png') - const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42) - writeFileSync(filePath, content) - - const sampleHandle = await fs.open(filePath, 'r') - const fileHandlePrototype = Object.getPrototypeOf(sampleHandle) as { - read: typeof sampleHandle.read - } - await sampleHandle.close() - - type PositionedRead = ( - this: typeof sampleHandle, - buffer: Buffer, - offset: number | null, - length: number | null, - position: number | null - ) => Promise<{ bytesRead: number; buffer: Buffer }> - const originalRead = fileHandlePrototype.read as unknown as PositionedRead - const readSpy = vi.spyOn(fileHandlePrototype, 'read').mockImplementation(async function ( - this: typeof sampleHandle, - buffer: Buffer, - offset: number | null = 0, - length: number | null = buffer.byteLength, - position: number | null = null - ) { - const readLength = position === 0 && length !== null ? Math.min(length, 5) : length - return originalRead.call(this, buffer, offset, readLength, position) - } as typeof sampleHandle.read) - try { - await dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false }) - await waitFor(() => collectStream(dispatcher).end !== null) - } finally { - readSpy.mockRestore() - } - - const { chunks, end, err } = collectStream(dispatcher) - expect(err).toBeNull() - expect(end).not.toBeNull() - expect(chunks.map((c) => Buffer.from(c.data, 'base64').length)).toEqual([STREAM_CHUNK_SIZE, 17]) - const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) - expect(reassembled.equals(content)).toBe(true) - }) - - it('returns empty:true for 0-byte files without opening a handle', async () => { - const filePath = path.join(tmpDir, 'empty.txt') - writeFileSync(filePath, '') - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { isStale: () => false } - )) as { totalSize: number; empty: boolean; streamId?: number } - expect(meta.empty).toBe(true) - expect(meta.totalSize).toBe(0) - expect(meta.streamId).toBeUndefined() - - await flush() - const { chunks } = collectStream(dispatcher) - expect(chunks).toHaveLength(0) - }) - - it('returns empty:true for binary archives over the probe threshold', async () => { - const filePath = path.join(tmpDir, 'archive.bin') - const content = Buffer.alloc(20 * 1024, 0x61) - content[0] = 0x00 - writeFileSync(filePath, content) - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { isStale: () => false } - )) as { totalSize: number; empty: boolean; isBinary: boolean } - expect(meta.empty).toBe(true) - expect(meta.isBinary).toBe(true) - }) - - it('returns empty:true for small binary files under the probe threshold', async () => { - const filePath = path.join(tmpDir, 'small.bin') - const content = Buffer.from([0x41, 0x00, 0x42]) - writeFileSync(filePath, content) - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { isStale: () => false } - )) as { totalSize: number; empty: boolean; isBinary: boolean; streamId?: number } - expect(meta.empty).toBe(true) - expect(meta.isBinary).toBe(true) - expect(meta.totalSize).toBe(0) - expect(meta.streamId).toBeUndefined() - }) - - it('rejects when totalSize exceeds the binary cap', async () => { - const filePath = path.join(tmpDir, 'huge.png') - writeFileSync(filePath, Buffer.alloc(51 * 1024 * 1024)) - - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false }) - ).rejects.toThrow(/File too large/) - }) - - it('exits the pump and emits no further chunks when isStale flips', async () => { - const filePath = path.join(tmpDir, 'big.png') - const content = Buffer.alloc(800 * 1024, 0x42) - writeFileSync(filePath, content) - - let stale = false - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { isStale: () => stale } - )) as { streamId: number } - - await new Promise((r) => setImmediate(r)) - stale = true - await flush(10) - - const { end, err } = collectStream(dispatcher) - expect(end).toBeNull() - expect(err).toBeNull() - expect(meta.streamId).toBeGreaterThan(0) - }) - - it('honors fs.cancelStream by stopping the pump and emitting no end frame', async () => { - const filePath = path.join(tmpDir, 'cancel.png') - writeFileSync(filePath, Buffer.alloc(2 * 1024 * 1024, 0x42)) - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { isStale: () => false } - )) as { streamId: number } - - await new Promise((r) => setImmediate(r)) - dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }) - await flush(10) - - const { end, err } = collectStream(dispatcher) - expect(end).toBeNull() - expect(err).toBeNull() - }) - - it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => { - const filePath = path.join(tmpDir, 'paced.png') - const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks - writeFileSync(filePath, content) - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath, flowControl: 'ack' }, - { isStale: () => false } - )) as { streamId: number } - - // Window of 4 admits seqs 0..3; seq 4 must wait for an ack. - await waitFor(() => collectStream(dispatcher).chunks.length === 4) - await flush(10) - expect(collectStream(dispatcher).chunks.length).toBe(4) - expect(collectStream(dispatcher).end).toBeNull() - - // Ack one chunk → exactly one more is admitted. - dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }) - await waitFor(() => collectStream(dispatcher).chunks.length === 5) - await flush(10) - expect(collectStream(dispatcher).chunks.length).toBe(5) - - for (let seq = 1; seq < 6; seq += 1) { - dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq }) - } - await waitFor(() => collectStream(dispatcher).end !== null) - - const { chunks, err } = collectStream(dispatcher) - expect(err).toBeNull() - const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) - expect(reassembled.equals(content)).toBe(true) - }) - - it('releases a pump parked on the ack window when the stream is cancelled', async () => { - const filePath = path.join(tmpDir, 'parked-cancel.png') - writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath, flowControl: 'ack' }, - { isStale: () => false } - )) as { streamId: number } - - await waitFor(() => collectStream(dispatcher).chunks.length === 4) - dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }) - - const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry - await waitFor(() => registry.size() === 0) - - const { end, err } = collectStream(dispatcher) - expect(end).toBeNull() - expect(err).toBeNull() - }) - - it('caps undelivered terminal frames so they cannot overflow the link-killing control lane', async () => { - const filePath = path.join(tmpDir, 'terminal-budget.png') - writeFileSync(filePath, Buffer.alloc(STREAM_CHUNK_SIZE, 0x42)) // one chunk per stream - // A socket that accepts nothing: control frames queue, and the real writer destroys the - // client once 256 of them pile up. - dispatcher.holdControlSettlements() - - const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry - const context = { clientId: 5, isStale: () => false } - const terminalFrames = () => - dispatcher._notifications.filter( - (n) => n.method === 'fs.streamEnd' || n.method === 'fs.streamError' - ) - - for (let i = 0; i < MAX_CONCURRENT_STREAMS; i++) { - await dispatcher.callRequest('fs.readFileStream', { filePath }, context) - await waitFor(() => terminalFrames().length === i + 1) - } - // The fd must go back even though its terminal frame is still undelivered. - await waitFor(() => registry.size() === 0) - - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath }, context) - ).rejects.toThrow(/Too many concurrent streams/) - await flush(10) - expect(terminalFrames()).toHaveLength(MAX_CONCURRENT_STREAMS) - - // The budget is per client, so a second peer on the same relay still reads. - await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { clientId: 9, isStale: () => false } - ) - await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 1) - - // Draining the sink settles the queued frames and hands the budget back. - dispatcher.settleHeldControlFrames() - await dispatcher.callRequest('fs.readFileStream', { filePath }, context) - await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 2) - }) - - it('holds file stream shutdown until an undelivered terminal frame settles', async () => { - const filePath = path.join(tmpDir, 'shutdown-terminal.png') - writeFileSync(filePath, Buffer.alloc(STREAM_CHUNK_SIZE, 0x42)) - dispatcher.holdControlSettlements() - const context = { clientId: 4, isStale: () => false } - await dispatcher.callRequest('fs.readFileStream', { filePath }, context) - await waitFor(() => collectStream(dispatcher).end !== null) - - let drained = false - const drain = handler.disposeFileStreams().then(() => { - drained = true - }) - await flush(10) - expect(drained).toBe(false) - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath }, context) - ).rejects.toThrow('relay_file_stream_shutdown_fenced') - - dispatcher.settleHeldControlFrames() - await drain - expect(drained).toBe(true) - }) - - it('rejects the 17th concurrent stream with TooManyStreams', async () => { - const paths: string[] = [] - for (let i = 0; i < 17; i++) { - const p = path.join(tmpDir, `s${i}.png`) - writeFileSync(p, Buffer.alloc(8 * 1024 * 1024, 0x42)) - paths.push(p) - } - - const isStale = () => false - const queuedPumps: (() => void)[] = [] - // Why: the concurrency cap is about registered active streams. Hold the - // scheduled pumps so fast CI machines cannot finish early streams before - // the 17th request checks the registry size. - const setImmediateSpy = vi - .spyOn(globalThis, 'setImmediate') - .mockImplementation((callback: (...args: unknown[]) => void, ...args: unknown[]) => { - queuedPumps.push(() => callback(...args)) - return {} as NodeJS.Immediate - }) - try { - for (let i = 0; i < 16; i++) { - await dispatcher.callRequest('fs.readFileStream', { filePath: paths[i] }, { isStale }) - } - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath: paths[16] }, { isStale }) - ).rejects.toThrow(/Too many concurrent streams/) - } finally { - setImmediateSpy.mockRestore() - } - for (const runPump of queuedPumps) { - runPump() - } - await flush(50) - }, 20_000) -}) diff --git a/src/relay/fs-handler-terminal-artifact.ts b/src/relay/fs-handler-terminal-artifact.ts deleted file mode 100644 index 91f17d3174c..00000000000 --- a/src/relay/fs-handler-terminal-artifact.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { constants } from 'node:fs' -import type { Stats } from 'node:fs' -import { chmod, open, realpath, rename, rm, writeFile } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { basename, dirname, extname, join } from 'node:path' -import { randomUUID } from 'node:crypto' -import { isBinaryBuffer } from '../shared/binary-buffer' -import { - IMAGE_MIME_TYPES, - MAX_PREVIEWABLE_BINARY_SIZE, - MAX_TEXT_FILE_SIZE -} from './fs-handler-utils' - -type TerminalArtifactStat = { - size: number - type: 'file' | 'directory' | 'symlink' - mtime: number - mtimeMs: number - mode?: number - dev?: number - ino?: number - nlink?: number -} - -type VerifiedTerminalArtifactOptions = { - expectedRealPath: string - expectedStatIdentity?: string | null - maxBytes?: number -} - -const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 - -export async function readVerifiedTerminalArtifact(params: Record) { - const filePath = stringParam(params.filePath) - const options = verifiedTerminalArtifactOptions(params) - const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) - try { - await verifiedHandleStat(handle, options) - const mimeType = terminalArtifactImageMimeType(filePath) - const sizeLimit = Math.min( - options.maxBytes ?? (mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE), - mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - ) - const buffer = await readBoundedFileFromHandle(handle, sizeLimit) - if (mimeType) { - return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } - } finally { - await handle.close() - } -} - -export async function writeVerifiedTerminalArtifact( - params: Record -): Promise<{ ok: true; stat: TerminalArtifactStat }> { - const filePath = stringParam(params.filePath) - const content = stringParam(params.content) - const options = verifiedTerminalArtifactOptions(params) - // Why: maxBytes is client-supplied; clamp before it sizes buffer allocations. - const writeLimit = Math.min(options.maxBytes ?? MAX_TEXT_FILE_SIZE, MAX_TEXT_FILE_SIZE) - if (Buffer.byteLength(content, 'utf8') > writeLimit) { - throw new Error('file_too_large') - } - const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) - let originalMode: number | null = null - try { - originalMode = (await verifiedHandleStat(handle, options)).mode ?? null - const existing = await readBoundedFileFromHandle(handle, writeLimit) - if (isBinaryBuffer(existing)) { - throw new Error('binary_file') - } - } finally { - await handle.close() - } - const tempPath = join(dirname(filePath), `.${basename(filePath)}.${randomUUID()}.tmp`) - try { - await writeFile(tempPath, content, { encoding: 'utf8', flag: 'wx' }) - if (typeof originalMode === 'number') { - await chmod(tempPath, originalMode & 0o7777) - } - const freshHandle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) - try { - await verifiedHandleStat(freshHandle, options) - } finally { - await freshHandle.close() - } - await rename(tempPath, filePath) - return { ok: true, stat: fileStatFromHandleStats(await openStatClose(filePath)) } - } finally { - await rm(tempPath, { force: true }).catch(() => {}) - } -} - -async function openStatClose(filePath: string): Promise { - const handle = await open(filePath, constants.O_RDONLY) - try { - return await handle.stat() - } finally { - await handle.close() - } -} - -function terminalArtifactImageMimeType(filePath: string): string | undefined { - const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - // Why: mobile renders SVG terminal artifacts as source text; returning image - // data from the relay would make SSH disagree with local artifact previews. - return mimeType === 'image/svg+xml' ? undefined : mimeType -} - -async function readBoundedFileFromHandle(handle: FileHandle, maxBytes: number): Promise { - const safeLimit = Math.max(0, Math.floor(maxBytes)) - const buffer = Buffer.alloc(safeLimit + 1) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) - if (bytesRead > safeLimit) { - throw new Error('file_too_large') - } - return buffer.subarray(0, bytesRead) -} - -async function openVerifiedTerminalArtifact( - filePath: string, - options: VerifiedTerminalArtifactOptions, - flags: number -): Promise { - await assertRealPathStillGranted(filePath, options.expectedRealPath) - try { - return await open(filePath, flags | OPEN_NOFOLLOW) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ELOOP') { - throw new Error('terminal_file_grant_stale') - } - throw error - } -} - -async function verifiedHandleStat( - handle: FileHandle, - options: VerifiedTerminalArtifactOptions -): Promise { - const stats = fileStatFromHandleStats(await handle.stat()) - if (stats.type !== 'file') { - throw new Error( - stats.type === 'directory' ? 'Cannot write to a directory' : 'terminal_file_grant_stale' - ) - } - assertTerminalArtifactNotHardLinked(stats) - assertTerminalArtifactStatIdentity(options.expectedStatIdentity ?? null, stats) - return stats -} - -async function assertRealPathStillGranted( - filePath: string, - expectedRealPath: string -): Promise { - // Why: terminal artifacts are user-writable temp paths; the relay must check - // canonicality in the same remote operation that opens the file handle. - if ((await realpath(filePath)) !== expectedRealPath) { - throw new Error('terminal_file_grant_stale') - } -} - -function fileStatFromHandleStats(stats: Stats): TerminalArtifactStat { - let type: TerminalArtifactStat['type'] = 'file' - if (stats.isDirectory()) { - type = 'directory' - } else if (stats.isSymbolicLink()) { - type = 'symlink' - } - return { - size: stats.size, - type, - mtime: stats.mtimeMs, - mtimeMs: stats.mtimeMs, - mode: stats.mode, - dev: stats.dev, - ino: stats.ino, - nlink: stats.nlink - } -} - -function terminalArtifactStatIdentity(stats: { - size?: number - dev?: number - ino?: number - nlink?: number - mtime?: number | Date - mtimeMs?: number -}): string | null { - const dev = typeof stats.dev === 'number' ? stats.dev : null - const ino = typeof stats.ino === 'number' ? stats.ino : null - const nlink = typeof stats.nlink === 'number' ? stats.nlink : null - const size = typeof stats.size === 'number' ? stats.size : null - const mtimeMs = - typeof stats.mtimeMs === 'number' - ? stats.mtimeMs - : typeof stats.mtime === 'number' - ? stats.mtime - : null - if (dev !== null && ino !== null && size !== null && mtimeMs !== null) { - return `${dev}:${ino}:${nlink ?? 'unknown'}:${size}:${mtimeMs}` - } - if (size !== null && mtimeMs !== null) { - return `${size}:${mtimeMs}` - } - return null -} - -function assertTerminalArtifactNotHardLinked(stats: TerminalArtifactStat): void { - if (typeof stats.nlink === 'number' && stats.nlink > 1) { - throw new Error('terminal_file_grant_stale') - } -} - -function assertTerminalArtifactStatIdentity( - expectedStatIdentity: string | null, - stats: TerminalArtifactStat -): void { - const nextIdentity = terminalArtifactStatIdentity(stats) - if ( - expectedStatIdentity !== null && - nextIdentity !== null && - expectedStatIdentity !== nextIdentity - ) { - throw new Error('terminal_file_grant_stale') - } -} - -function verifiedTerminalArtifactOptions( - params: Record -): VerifiedTerminalArtifactOptions { - return { - expectedRealPath: stringParam(params.expectedRealPath), - expectedStatIdentity: - typeof params.expectedStatIdentity === 'string' ? params.expectedStatIdentity : null, - maxBytes: - typeof params.maxBytes === 'number' && Number.isFinite(params.maxBytes) - ? Math.max(0, Math.floor(params.maxBytes)) - : undefined - } -} - -function stringParam(value: unknown): string { - if (typeof value !== 'string') { - throw new Error('invalid_terminal_artifact_request') - } - return value -} diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts deleted file mode 100644 index 3385160b56b..00000000000 --- a/src/relay/fs-handler-utils.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { RipgrepSearchDiagnostics } from '../shared/ripgrep-search-diagnostics' -/** - * Pure helpers and child-process search utilities extracted from fs-handler.ts. - * - * Why: oxlint max-lines requires .ts files to stay under 300 lines. - * These functions depend only on their arguments (plus a launchable `rg`), - * so they are straightforward to test independently. - */ -import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' -import { spawnProcess } from '@orca/process-host' -import { abortSignalReason } from '../shared/abort-signal-reason' -import { open } from 'node:fs/promises' -import { - buildRgArgs, - createAccumulator, - finalize, - ingestRgJsonLine, - SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS -} from '../shared/text-search' -import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' -import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer' -import type { SearchResult as SharedSearchResult } from '../shared/code-search-types' -import { - absorbPendingRipgrepSpawnError, - classifyRipgrepLaunchFailure, - isRipgrepUnavailableExit, - killSpawnedRipgrepProcess, - ripgrepMissingCwdError, - RipgrepUnavailableError -} from '../shared/ripgrep-process-availability' -import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' -import { - pathRipgrepCommand, - resolveRelayRipgrepCommand, - retryRipgrepOnPathAfterLaunchFailure -} from './relay-bundled-ripgrep' - -// ─── Constants ─────────────────────────────────────────────────────── - -// Why: remote reads still travel through bounded JSON-RPC frames, but matching -// the old 5MB search cap would block common JSON/log files before Monaco's -// large-file optimizations can handle them. -export const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 -// Why: matches the local cap (src/main/ipc/filesystem.ts MAX_PREVIEWABLE_BINARY_SIZE). -// Reads above the legacy 16MB single-frame budget go through fs.readFileStream, -// which chunks at STREAM_CHUNK_SIZE; see docs/relay-file-stream-design.md. -export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS -export const DEFAULT_MAX_RESULTS = 2000 - -export const IMAGE_MIME_TYPES: Record = { - ...IMAGE_FILE_MIME_TYPES, - '.pdf': 'application/pdf' -} - -export async function isBinaryFilePrefix( - filePath: string, - releaseHandle: (handle: Awaited>) => Promise = (handle) => - handle.close() -): Promise { - const handle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await releaseHandle(handle) - } -} - -// ─── Search types ──────────────────────────────────────────────────── - -export type SearchOptions = { - caseSensitive?: boolean - wholeWord?: boolean - useRegex?: boolean - includePattern?: string - excludePattern?: string - maxResults: number - signal?: AbortSignal -} - -export type SearchResult = SharedSearchResult - -// ─── rg-based search ───────────────────────────────────────────────── - -/** - * Run ripgrep (`rg`) with JSON output to collect text matches. - * - * Why `spawn` and not `execFile`: `execFile` buffers stdout internally and - * kills the child when `maxBuffer` is exceeded, even when 'data' listeners - * are attached. Under rg's verbose `--json` output, a 50MB buffer fills - * well before the match cap in large folders, and `execFile`'s silent - * buffer-exceeded error resolves the result as `truncated: false` despite - * dropping matches. See docs/design/share-text-search.md. - */ -export function searchWithRg( - rootPath: string, - query: string, - opts: SearchOptions -): Promise { - const { signal } = opts - if (signal?.aborted) { - return Promise.reject(abortSignalReason(signal)) - } - return new Promise((resolve, reject) => { - const rgArgs = buildRgArgs(query, '.', opts) - const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator() - const diagnostics = new RipgrepSearchDiagnostics() - let resolved = false - let processErrorObserved = false - let unavailableExitObserved = false - let launchFailureCheck: Promise | null = null - - const resolvedRgCommand = resolveRelayRipgrepCommand() - // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the spawn - // cwd -- the user's repo -- before PATH, so a planted rg.exe would run instead. - if (resolvedRgCommand === null) { - reject(new RipgrepUnavailableError()) - return - } - // Why a second binding: the closures below capture it, and narrowing does not reach them. - const command: string = resolvedRgCommand - const env = buildRelayCommandEnv() - let child: ReturnType - try { - child = spawnProcess({ - program: command, - args: rgArgs, - cwd: rootPath, - env, - stdio: ['ignore', 'pipe', 'pipe'] - }) - } catch (error) { - reject(error) - return - } - - let killTimeout: ReturnType - - function settle(): boolean { - if (resolved) { - return false - } - resolved = true - signal?.removeEventListener('abort', onAbort) - lines.clear() - clearTimeout(killTimeout) - // Why: child.kill() is advisory over SSH; detach listeners if the - // process ignores timeout kill so old searches cannot retain closures. - child.stdout?.off('data', handleStdoutData) - child.stderr?.off('data', handleStderrData) - child.off('error', handleError) - child.off('close', handleClose) - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - return true - } - - function onAbort(): void { - if (signal && settle()) { - try { - killSpawnedRipgrepProcess(child) - } catch { - // A refused kill must still release the canceled request. - } - reject(abortSignalReason(signal)) - } - } - - function resolveOnce(code = 0, signal: NodeJS.Signals | null = null): void { - if (settle()) { - const error = diagnostics.failure(code, signal, acc) - if (error) { - reject(error) - } else { - resolve(finalize(acc)) - } - } - } - - function settleLaunchFailure(error?: unknown): void { - if (launchFailureCheck) { - return - } - launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error) - .then(async (retryOnPath) => { - if (resolved) { - return - } - if (retryOnPath) { - // Why: a launch failure produced no output, so rerunning on PATH rg loses nothing. - if (settle()) { - searchWithRg(rootPath, query, opts).then(resolve, reject) - } - return - } - // Why not resolveOnce() on an unreachable root: an empty result reads as "no matches" - // and the git/readdir chain never engages, because it only triggers on an unavailable - // ripgrep. The workspace moving would otherwise look like a successful empty scan. - const failure = await classifyRipgrepLaunchFailure( - rootPath, - [command, pathRipgrepCommand()], - env, - signal - ) - if (settle()) { - reject( - failure === 'cwd-unreachable' - ? ripgrepMissingCwdError(rootPath) - : new RipgrepUnavailableError() - ) - } - }) - .catch((error: unknown) => { - if (settle()) { - reject(error) - } - }) - } - - function processLine(line: string): void { - const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults) - if (verdict === 'stop') { - killSpawnedRipgrepProcess(child) - } - } - - function handleStdoutData(chunk: string): void { - if (!lines.push(chunk, processLine)) { - acc.truncated = true - killSpawnedRipgrepProcess(child) - resolveOnce() - } - } - - function handleStderrData(chunk: Buffer): void { - diagnostics.append(chunk) - } - - function handleError(error: Error): void { - processErrorObserved = true - if (isRipgrepUnavailableExit(child, null, null)) { - settleLaunchFailure(error) - return - } - if (settle()) { - reject(error) - } - killSpawnedRipgrepProcess(child) - } - - function handleClose(code: number | null, signal: NodeJS.Signals | null): void { - if ( - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: true - }) - ) { - unavailableExitObserved = true - settleLaunchFailure() - return - } - const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null - if (tail !== null) { - processLine(tail) - } - resolveOnce(code ?? -1, signal) - } - - child.stdout?.setEncoding('utf-8') - child.stdout?.on('data', handleStdoutData) - child.stderr?.on('data', handleStderrData) - child.once('error', handleError) - child.once('close', handleClose) - - killTimeout = setTimeout(() => { - acc.truncated = true - killSpawnedRipgrepProcess(child) - resolveOnce() - }, SEARCH_TIMEOUT_MS) - signal?.addEventListener('abort', onAbort, { once: true }) - if (signal?.aborted) { - onAbort() - } - }) -} - -// Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint) -export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files' diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts deleted file mode 100644 index d86173f4e01..00000000000 --- a/src/relay/fs-handler.test.ts +++ /dev/null @@ -1,824 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { FsHandler } from './fs-handler' -import { MAX_TEXT_FILE_SIZE } from './fs-handler-utils' -import { RelayContext } from './context' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback' -import { createMockDispatcher } from './relay-fs-test-dispatcher' - -const { mockSubscribe } = vi.hoisted(() => ({ - mockSubscribe: vi.fn() -})) - -vi.mock('@parcel/watcher', () => ({ - subscribe: mockSubscribe -})) - -function statIdentity(stats: { - dev?: number - ino?: number - nlink?: number - size?: number - mtimeMs?: number -}) { - return `${stats.dev}:${stats.ino}:${stats.nlink ?? 'unknown'}:${stats.size}:${stats.mtimeMs}` -} - -describe('FsHandler', () => { - let dispatcher: ReturnType - let handler: FsHandler - let tmpDir: string - - beforeEach(() => { - mockSubscribe.mockReset() - mockSubscribe.mockResolvedValue({ unsubscribe: vi.fn() }) - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-')) - dispatcher = createMockDispatcher() - const ctx = new RelayContext() - handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx, { - dispose: vi.fn(), - forgetRoot: vi.fn(), - disposeAndWait: vi.fn(async () => {}), - reopen: vi.fn(), - subscribe: subscribeWithInProcessWatcher - }) - }) - - afterEach(async () => { - handler.dispose() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - it('registers all expected handlers', () => { - const methods = Array.from(dispatcher._requestHandlers.keys()) - expect(methods).toContain('fs.readDir') - expect(methods).toContain('fs.readFile') - expect(methods).toContain('fs.tempDir') - expect(methods).toContain('fs.writeFile') - expect(methods).toContain('fs.stat') - expect(methods).toContain('fs.deletePath') - expect(methods).toContain('fs.createFile') - expect(methods).toContain('fs.createDir') - expect(methods).toContain('fs.createDirNoClobber') - expect(methods).toContain('fs.rename') - expect(methods).toContain('fs.renameNoClobber') - expect(methods).toContain('fs.copy') - expect(methods).toContain('fs.realpath') - expect(methods).toContain('fs.search') - expect(methods).toContain('fs.listFiles') - expect(methods).toContain('fs.workspaceSpaceScan') - expect(methods).toContain('fs.watch') - expect(methods).toContain('fs.unwatchAndWait') - - const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) - expect(notifMethods).toContain('fs.unwatch') - }) - - it('tempDir returns the relay host temp directory', async () => { - await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir()) - }) - - it('readDir returns entries directories-first in natural name order', async () => { - mkdirSync(path.join(tmpDir, 'subdir')) - for (const name of ['file.txt', '100 - b.txt', '99 - a.txt', '9 - c.txt']) { - writeFileSync(path.join(tmpDir, name), 'x') - } - - const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { - name: string - isDirectory: boolean - }[] - expect(result[0]).toMatchObject({ name: 'subdir', isDirectory: true }) - expect(result.slice(1).map((e) => e.name)).toEqual([ - '9 - c.txt', - '99 - a.txt', - '100 - b.txt', - 'file.txt' - ]) - }) - - it('readDir reports symlinked directories as directories', async () => { - const targetDir = path.join(tmpDir, 'external-models') - const linkPath = path.join(tmpDir, 'Model') - mkdirSync(targetDir) - symlinkSync(targetDir, linkPath, process.platform === 'win32' ? 'junction' : 'dir') - - const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { - name: string - isDirectory: boolean - isSymlink: boolean - }[] - - expect(result.find((e) => e.name === 'Model')).toEqual({ - name: 'Model', - isDirectory: true, - isSymlink: true - }) - }) - - it('readFile returns text content for text files', async () => { - const filePath = path.join(tmpDir, 'test.txt') - writeFileSync(filePath, 'hello world') - - const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { - content: string - isBinary: boolean - } - expect(result.content).toBe('hello world') - expect(result.isBinary).toBe(false) - }) - - it('readFile returns text files larger than the old 5MB guard', async () => { - const filePath = path.join(tmpDir, 'large.json') - const content = 'a'.repeat(6 * 1024 * 1024) - writeFileSync(filePath, content) - - const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { - content: string - isBinary: boolean - } - expect(result.content).toBe(content) - expect(result.isBinary).toBe(false) - }) - - it('readFile returns binary marker for large unknown binary files', async () => { - const filePath = path.join(tmpDir, 'archive.bin') - const content = Buffer.alloc(6 * 1024 * 1024, 0x61) - content[0] = 0x00 - writeFileSync(filePath, content) - - const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { - content: string - isBinary: boolean - } - expect(result.content).toBe('') - expect(result.isBinary).toBe(true) - }) - - it('readFile returns base64 for image files', async () => { - const filePath = path.join(tmpDir, 'test.png') - writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) - - const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { - content: string - isBinary: boolean - isImage: boolean - mimeType: string - } - expect(result.isBinary).toBe(true) - expect(result.isImage).toBe(true) - expect(result.mimeType).toBe('image/png') - expect(result.content).toBeTruthy() - }) - - it('readFile throws for files exceeding size limit', async () => { - const filePath = path.join(tmpDir, 'huge.txt') - writeFileSync(filePath, Buffer.alloc(11 * 1024 * 1024, 'a')) - - await expect(dispatcher.callRequest('fs.readFile', { filePath })).rejects.toThrow( - 'File too large' - ) - }) - - it('writeFile creates/overwrites file content', async () => { - const filePath = path.join(tmpDir, 'write-test.txt') - await dispatcher.callRequest('fs.writeFile', { filePath, content: 'new content' }) - - const content = await fs.readFile(filePath, 'utf-8') - expect(content).toBe('new content') - }) - - it('readTerminalArtifact reads through a verified artifact handle', async () => { - const filePath = path.join(tmpDir, 'artifact-read.json') - writeFileSync(filePath, '{"ok":true}') - const stats = await fs.stat(filePath) - - const result = (await dispatcher.callRequest('fs.readTerminalArtifact', { - filePath, - expectedRealPath: await fs.realpath(filePath), - expectedStatIdentity: statIdentity(stats), - maxBytes: 512 * 1024 - })) as { content: string; isBinary: boolean } - - expect(result).toEqual({ content: '{"ok":true}', isBinary: false }) - }) - - it('readTerminalArtifact treats SVG artifacts as editable text', async () => { - const filePath = path.join(tmpDir, 'artifact.svg') - writeFileSync(filePath, 'ok') - const stats = await fs.stat(filePath) - - const result = (await dispatcher.callRequest('fs.readTerminalArtifact', { - filePath, - expectedRealPath: await fs.realpath(filePath), - expectedStatIdentity: statIdentity(stats), - maxBytes: 512 * 1024 - })) as { content: string; isBinary: boolean; isImage?: boolean } - - expect(result).toEqual({ content: 'ok', isBinary: false }) - }) - - it('readTerminalArtifact rejects content beyond the requested byte limit', async () => { - const filePath = path.join(tmpDir, 'artifact-read-too-large.txt') - writeFileSync(filePath, 'abcdef') - - await expect( - dispatcher.callRequest('fs.readTerminalArtifact', { - filePath, - expectedRealPath: await fs.realpath(filePath), - maxBytes: 5 - }) - ).rejects.toThrow('file_too_large') - }) - - it('writeTerminalArtifact writes through a verified artifact handle', async () => { - const filePath = path.join(tmpDir, 'artifact-write.json') - writeFileSync(filePath, '{"ok":true}') - const stats = await fs.stat(filePath) - - const result = (await dispatcher.callRequest('fs.writeTerminalArtifact', { - filePath, - content: '{"ok":false}', - expectedRealPath: await fs.realpath(filePath), - expectedStatIdentity: statIdentity(stats), - maxBytes: 512 * 1024 - })) as { stat: { type: string; size: number } } - - await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('{"ok":false}') - expect(result.stat).toMatchObject({ type: 'file', size: 12 }) - }) - - it.skipIf(process.platform === 'win32')( - 'writeTerminalArtifact preserves executable mode across the atomic rename', - async () => { - const filePath = path.join(tmpDir, 'artifact-executable.sh') - writeFileSync(filePath, '#!/bin/sh\necho ok\n') - await fs.chmod(filePath, 0o755) - const stats = await fs.stat(filePath) - - await dispatcher.callRequest('fs.writeTerminalArtifact', { - filePath, - content: '#!/bin/sh\necho changed\n', - expectedRealPath: await fs.realpath(filePath), - expectedStatIdentity: statIdentity(stats), - maxBytes: 512 * 1024 - }) - - expect((await fs.stat(filePath)).mode & 0o777).toBe(0o755) - } - ) - - it('writeTerminalArtifact rejects oversized existing content before writing', async () => { - const filePath = path.join(tmpDir, 'artifact-write-too-large.txt') - writeFileSync(filePath, 'abcdef') - - await expect( - dispatcher.callRequest('fs.writeTerminalArtifact', { - filePath, - content: 'ok', - expectedRealPath: await fs.realpath(filePath), - maxBytes: 5 - }) - ).rejects.toThrow('file_too_large') - await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('abcdef') - }) - - it('writeTerminalArtifact clamps client-supplied maxBytes to the text-file cap', async () => { - const filePath = path.join(tmpDir, 'artifact-write-clamp.txt') - writeFileSync(filePath, 'abcdef') - - await expect( - dispatcher.callRequest('fs.writeTerminalArtifact', { - filePath, - content: 'a'.repeat(MAX_TEXT_FILE_SIZE + 1), - expectedRealPath: await fs.realpath(filePath), - maxBytes: Number.MAX_SAFE_INTEGER - }) - ).rejects.toThrow('file_too_large') - await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('abcdef') - }) - - it('writeTerminalArtifact rejects a retargeted symlink before writing outside temp', async () => { - const filePath = path.join(tmpDir, 'artifact-link.json') - const outsidePath = path.join(tmpDir, 'outside.json') - writeFileSync(filePath, '{"ok":true}') - writeFileSync(outsidePath, '{"secret":true}') - const stats = await fs.stat(filePath) - const expectedRealPath = await fs.realpath(filePath) - await fs.rm(filePath) - symlinkSync(outsidePath, filePath) - - await expect( - dispatcher.callRequest('fs.writeTerminalArtifact', { - filePath, - content: '{"ok":false}', - expectedRealPath, - expectedStatIdentity: statIdentity(stats), - maxBytes: 512 * 1024 - }) - ).rejects.toThrow('terminal_file_grant_stale') - await expect(fs.readFile(outsidePath, 'utf-8')).resolves.toBe('{"secret":true}') - }) - - it('writeTerminalArtifact rejects hard-linked files before writing', async () => { - const outsidePath = path.join(tmpDir, 'outside-hardlink.json') - const filePath = path.join(tmpDir, 'artifact-hardlink.json') - writeFileSync(outsidePath, '{"secret":true}') - await fs.link(outsidePath, filePath) - const stats = await fs.stat(filePath) - - await expect( - dispatcher.callRequest('fs.writeTerminalArtifact', { - filePath, - content: '{"ok":false}', - expectedRealPath: await fs.realpath(filePath), - expectedStatIdentity: statIdentity(stats), - maxBytes: 512 * 1024 - }) - ).rejects.toThrow('terminal_file_grant_stale') - await expect(fs.readFile(outsidePath, 'utf-8')).resolves.toBe('{"secret":true}') - }) - - it('stat returns file metadata', async () => { - const filePath = path.join(tmpDir, 'stat-test.txt') - writeFileSync(filePath, 'test') - - const result = (await dispatcher.callRequest('fs.stat', { filePath })) as { - size: number - type: string - mtime: number - } - expect(result.type).toBe('file') - expect(result.size).toBe(4) - expect(typeof result.mtime).toBe('number') - }) - - it('stat returns directory type for directories', async () => { - const result = (await dispatcher.callRequest('fs.stat', { filePath: tmpDir })) as { - type: string - } - expect(result.type).toBe('directory') - }) - - it('stat returns directory type for symlinked directories', async () => { - const targetDir = path.join(tmpDir, 'external-models') - const linkPath = path.join(tmpDir, 'Model') - mkdirSync(targetDir) - symlinkSync(targetDir, linkPath, process.platform === 'win32' ? 'junction' : 'dir') - - const result = (await dispatcher.callRequest('fs.stat', { filePath: linkPath })) as { - type: string - } - - expect(result.type).toBe('directory') - }) - - it('lstat returns symlink type without following links', async () => { - const targetFile = path.join(tmpDir, 'target.txt') - const linkPath = path.join(tmpDir, 'link.txt') - writeFileSync(targetFile, 'target') - symlinkSync(targetFile, linkPath) - - const result = (await dispatcher.callRequest('fs.lstat', { filePath: linkPath })) as { - type: string - } - - expect(result.type).toBe('symlink') - }) - - it('workspaceSpaceScan returns bounded top-level size details', async () => { - mkdirSync(path.join(tmpDir, 'node_modules')) - writeFileSync(path.join(tmpDir, 'node_modules', 'pkg.js'), Buffer.alloc(512)) - writeFileSync(path.join(tmpDir, 'file.log'), Buffer.alloc(128)) - - const result = (await dispatcher.callRequest( - 'fs.workspaceSpaceScan', - { rootPath: tmpDir }, - { isStale: () => false } - )) as { - sizeBytes: number - topLevelItems: { name: string; sizeBytes: number }[] - } - - expect(result.sizeBytes).toBeGreaterThanOrEqual(640) - expect(result.topLevelItems.map((item) => item.name)).toContain('node_modules') - expect(result.topLevelItems.map((item) => item.name)).toContain('file.log') - }) - - it('deletePath removes files', async () => { - const filePath = path.join(tmpDir, 'to-delete.txt') - writeFileSync(filePath, 'bye') - - await dispatcher.callRequest('fs.deletePath', { targetPath: filePath }) - await expect(fs.access(filePath)).rejects.toThrow() - }) - - it('holds the relay watcher fence through recursive directory deletion', async () => { - const directoryPath = path.join(tmpDir, 'watched-orphan') - mkdirSync(directoryPath) - const unsubscribe = vi.fn() - mockSubscribe.mockResolvedValue({ unsubscribe }) - await dispatcher.callRequest( - 'fs.watch', - { rootPath: directoryPath, watchId: 77 }, - { clientId: 3, isStale: () => false } - ) - - await dispatcher.callRequest('fs.deletePath', { targetPath: directoryPath, recursive: true }) - - expect(unsubscribe).toHaveBeenCalledTimes(1) - expect(dispatcher.notifyClient).toHaveBeenCalledWith(3, 'fs.watchFailed', { - rootPath: directoryPath, - watchId: 77, - message: 'Remote worktree is being removed' - }) - await expect(fs.access(directoryPath)).rejects.toThrow() - }) - - it('createFile creates an empty file with parent dirs', async () => { - const filePath = path.join(tmpDir, 'deep', 'nested', 'file.txt') - await dispatcher.callRequest('fs.createFile', { filePath }) - - const content = await fs.readFile(filePath, 'utf-8') - expect(content).toBe('') - }) - - it('createDir creates directories recursively', async () => { - const dirPath = path.join(tmpDir, 'a', 'b', 'c') - await dispatcher.callRequest('fs.createDir', { dirPath }) - - const stats = await fs.stat(dirPath) - expect(stats.isDirectory()).toBe(true) - }) - - it('createDirNoClobber fails when the directory already exists', async () => { - const dirPath = path.join(tmpDir, 'existing') - mkdirSync(dirPath) - - await expect(dispatcher.callRequest('fs.createDirNoClobber', { dirPath })).rejects.toThrow() - }) - - it('rename moves files', async () => { - const oldPath = path.join(tmpDir, 'old.txt') - const newPath = path.join(tmpDir, 'new.txt') - writeFileSync(oldPath, 'content') - - await dispatcher.callRequest('fs.rename', { oldPath, newPath }) - - await expect(fs.access(oldPath)).rejects.toThrow() - const content = await fs.readFile(newPath, 'utf-8') - expect(content).toBe('content') - }) - - it('rename preserves raw fs.rename overwrite semantics', async () => { - const oldPath = path.join(tmpDir, 'old.txt') - const newPath = path.join(tmpDir, 'existing.txt') - writeFileSync(oldPath, 'new') - writeFileSync(newPath, 'keep') - - await dispatcher.callRequest('fs.rename', { oldPath, newPath }) - - expect(await fs.readFile(newPath, 'utf-8')).toBe('new') - await expect(fs.access(oldPath)).rejects.toThrow() - }) - - it('renameNoClobber moves files when destination is available', async () => { - const oldPath = path.join(tmpDir, 'old.txt') - const newPath = path.join(tmpDir, 'new.txt') - writeFileSync(oldPath, 'content') - - await dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath }) - - await expect(fs.access(oldPath)).rejects.toThrow() - expect(await fs.readFile(newPath, 'utf-8')).toBe('content') - }) - - it('renameNoClobber does not overwrite an existing destination', async () => { - const oldPath = path.join(tmpDir, 'old.txt') - const newPath = path.join(tmpDir, 'existing.txt') - writeFileSync(oldPath, 'new') - writeFileSync(newPath, 'keep') - - await expect( - dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath }) - ).rejects.toThrow() - - expect(await fs.readFile(newPath, 'utf-8')).toBe('keep') - expect(await fs.readFile(oldPath, 'utf-8')).toBe('new') - }) - - it('copy duplicates files', async () => { - const src = path.join(tmpDir, 'src.txt') - const dst = path.join(tmpDir, 'dst.txt') - writeFileSync(src, 'original') - - await dispatcher.callRequest('fs.copy', { source: src, destination: dst }) - - const content = await fs.readFile(dst, 'utf-8') - expect(content).toBe('original') - }) - - it('copy does not overwrite an existing destination', async () => { - const src = path.join(tmpDir, 'src.txt') - const dst = path.join(tmpDir, 'dst.txt') - writeFileSync(src, 'original') - writeFileSync(dst, 'existing') - - await expect( - dispatcher.callRequest('fs.copy', { source: src, destination: dst }) - ).rejects.toThrow('EEXIST') - - const content = await fs.readFile(dst, 'utf-8') - expect(content).toBe('existing') - }) - - it('realpath resolves symlinks', async () => { - const realFile = path.join(tmpDir, 'real.txt') - const linkPath = path.join(tmpDir, 'link.txt') - writeFileSync(realFile, 'real') - symlinkSync(realFile, linkPath) - - const result = (await dispatcher.callRequest('fs.realpath', { filePath: linkPath })) as string - // On macOS, /var is a symlink to /private/var, so resolve both to compare - expect(result).toBe(await fs.realpath(realFile)) - }) - - it('does not let stale pending watch remove newer replacement watch', async () => { - const firstUnsubscribe = vi.fn() - const secondUnsubscribe = vi.fn() - let resolveFirst!: () => void - mockSubscribe - .mockReturnValueOnce( - new Promise((resolve) => { - resolveFirst = () => resolve({ unsubscribe: firstUnsubscribe }) - }) - ) - .mockResolvedValueOnce({ unsubscribe: secondUnsubscribe }) - - const firstWatch = dispatcher.callRequest( - 'fs.watch', - { rootPath: tmpDir }, - { isStale: () => true } - ) - while (mockSubscribe.mock.calls.length === 0) { - await Promise.resolve() - } - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) - resolveFirst() - await firstWatch - - expect(firstUnsubscribe).toHaveBeenCalled() - expect(secondUnsubscribe).not.toHaveBeenCalled() - dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) - expect(secondUnsubscribe).toHaveBeenCalled() - }) - - it('unsubscribes an active stale watch before replacing it', async () => { - const firstUnsubscribe = vi.fn() - const secondUnsubscribe = vi.fn() - mockSubscribe - .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe }) - .mockResolvedValueOnce({ unsubscribe: secondUnsubscribe }) - - let stale = false - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale }) - stale = true - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) - - expect(firstUnsubscribe).toHaveBeenCalled() - expect(secondUnsubscribe).not.toHaveBeenCalled() - dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) - expect(secondUnsubscribe).toHaveBeenCalled() - }) - - it('replaces a stale watch for the same root before enforcing the watch cap', async () => { - const firstUnsubscribe = vi.fn() - const replacementUnsubscribe = vi.fn() - mockSubscribe - .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe }) - .mockResolvedValueOnce({ unsubscribe: replacementUnsubscribe }) - - let stale = false - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale }) - for (let index = 0; index < 19; index++) { - await dispatcher.callRequest('fs.watch', { - rootPath: path.join(tmpDir, `watched-${index}`) - }) - } - - stale = true - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) - - expect(firstUnsubscribe).toHaveBeenCalled() - expect(replacementUnsubscribe).not.toHaveBeenCalled() - }) - - it('removes stale watches for any root before enforcing the watch cap', async () => { - const staleUnsubscribe = vi.fn() - mockSubscribe - .mockResolvedValueOnce({ unsubscribe: staleUnsubscribe }) - .mockResolvedValue({ unsubscribe: vi.fn() }) - - let stale = false - await dispatcher.callRequest( - 'fs.watch', - { rootPath: path.join(tmpDir, 'stale-root') }, - { - isStale: () => stale - } - ) - for (let index = 0; index < 19; index += 1) { - await dispatcher.callRequest('fs.watch', { - rootPath: path.join(tmpDir, `watched-${index}`) - }) - } - - stale = true - - await expect( - dispatcher.callRequest('fs.watch', { rootPath: path.join(tmpDir, 'new-root') }) - ).resolves.toBeUndefined() - expect(staleUnsubscribe).toHaveBeenCalledTimes(1) - }) - - it('keeps a shared watch alive until every client unwatches it', async () => { - const unsubscribe = vi.fn() - mockSubscribe.mockResolvedValue({ unsubscribe }) - - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false }) - await dispatcher.callRequest( - 'fs.watch', - { rootPath: tmpDir }, - { - clientId: 2, - isStale: () => false - } - ) - - dispatcher.callNotification( - 'fs.unwatch', - { rootPath: tmpDir }, - { - clientId: 1, - isStale: () => false - } - ) - expect(unsubscribe).not.toHaveBeenCalled() - - dispatcher.callNotification( - 'fs.unwatch', - { rootPath: tmpDir }, - { - clientId: 2, - isStale: () => false - } - ) - expect(unsubscribe).toHaveBeenCalledTimes(1) - }) - - it('settles acknowledged unwatch only after native unsubscribe completes', async () => { - let resolveUnsubscribe: () => void = () => {} - const unsubscribe = vi.fn( - () => - new Promise((resolve) => { - resolveUnsubscribe = resolve - }) - ) - mockSubscribe.mockResolvedValue({ unsubscribe }) - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) - - let settled = false - const unwatch = dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }).then(() => { - settled = true - }) - await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) - expect(settled).toBe(false) - - resolveUnsubscribe() - await unwatch - expect(settled).toBe(true) - }) - - it('waits for in-flight native setup before acknowledging teardown', async () => { - handler.dispose() - let resolveSubscribe: (value: { unsubscribe: () => Promise }) => void = () => {} - const unsubscribe = vi.fn(async () => undefined) - const subscribe = vi.fn( - () => - new Promise<{ unsubscribe: () => Promise }>((resolve) => { - resolveSubscribe = resolve - }) - ) - handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext(), { - dispose: vi.fn(), - forgetRoot: vi.fn(), - disposeAndWait: vi.fn(async () => {}), - reopen: vi.fn(), - subscribe - }) - - const watch = dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) - let unwatchSettled = false - const unwatch = dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }).then(() => { - unwatchSettled = true - }) - await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) - expect(unwatchSettled).toBe(false) - - resolveSubscribe({ unsubscribe }) - await Promise.all([watch, unwatch]) - expect(unsubscribe).toHaveBeenCalledTimes(1) - }) - - it('joins a physical unsubscribe already started by the notification path', async () => { - let resolveUnsubscribe: () => void = () => {} - const unsubscribe = vi.fn( - () => - new Promise((resolve) => { - resolveUnsubscribe = resolve - }) - ) - mockSubscribe.mockResolvedValue({ unsubscribe }) - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) - dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) - - let settled = false - const joined = dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }).then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - - resolveUnsubscribe() - await joined - }) - - it('retains a failed native unsubscribe slot until acknowledged retry succeeds', async () => { - const unsubscribe = vi - .fn() - .mockRejectedValueOnce(new Error('native handle still active')) - .mockResolvedValueOnce(undefined) - mockSubscribe.mockResolvedValue({ unsubscribe }) - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) - - await expect(dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir })).rejects.toThrow( - 'native handle still active' - ) - await expect( - dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }) - ).resolves.toBeUndefined() - expect(unsubscribe).toHaveBeenCalledTimes(2) - - await expect(dispatcher.callRequest('fs.watch', { rootPath: tmpDir })).resolves.toBeUndefined() - expect(mockSubscribe).toHaveBeenCalledTimes(2) - }) - - it('allows a shared watch attach even when the root watch cap is full', async () => { - mockSubscribe.mockResolvedValue({ unsubscribe: vi.fn() }) - - for (let index = 0; index < 20; index += 1) { - const dir = path.join(tmpDir, `watched-${index}`) - await fs.mkdir(dir) - await dispatcher.callRequest( - 'fs.watch', - { rootPath: dir }, - { - clientId: index + 1, - isStale: () => false - } - ) - } - - await expect( - dispatcher.callRequest( - 'fs.watch', - { rootPath: path.join(tmpDir, 'watched-0') }, - { - clientId: 99, - isStale: () => false - } - ) - ).resolves.toBeUndefined() - }) - - it('releases a client watch when the dispatcher detaches that client', async () => { - const unsubscribe = vi.fn() - mockSubscribe.mockResolvedValue({ unsubscribe }) - - await dispatcher.callRequest( - 'fs.watch', - { rootPath: tmpDir }, - { - clientId: 7, - isStale: () => false - } - ) - dispatcher.detachClient(7) - - expect(unsubscribe).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts deleted file mode 100644 index 0e131646c08..00000000000 --- a/src/relay/fs-handler.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { readRelayDirectoryBounded } from './fs-directory-listing' -import { listRelayMarkdownDocuments } from './fs-markdown-document-listing' -import { markdownDocumentsFromRelativePaths } from '../shared/markdown-document-paths' -import { joinSearchRoot } from '../shared/text-search-paths' -import { quickOpenRecentCandidateSet } from '../shared/quick-open-recent-candidates' -import { QUICK_OPEN_SEARCH_VERSION } from '../shared/quick-open-path-search' -import { pathsExistOnRelay } from './fs-path-existence' -import { tmpdir } from 'node:os' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import type { RelayContext } from './context' -// Why: RelayContext is accepted in the constructor for protocol back-compat -// (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. -import { expandTilde } from './context' -import { DEFAULT_MAX_RESULTS, searchWithRg } from './fs-handler-utils' -import { searchWithGitGrep } from './fs-handler-git-fallback' -import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' -import { runListFilesScan } from './fs-list-files-fallback-chain' -import { - lstatRelayPath, - readRelayDir, - realpathRelayPath, - statRelayPath -} from './fs-path-metadata-requests' -import { - copyRelayPath, - createRelayDir, - createRelayDirNoClobber, - createRelayFile, - deleteRelayPath, - renameRelayPath, - renameRelayPathNoClobber, - writeRelayFile -} from './fs-path-mutation-requests' -import { buildExcludePathPrefixes } from '../shared/quick-open-filter' -import { resolveQuickOpenResultLimit } from '../shared/quick-open-listing-limits' -import { maybeStreamRpcResponse, type GitResponseStreamRegistry } from './git-response-stream' -import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' -import { readRelayFileRange } from './fs-handler-file-range' -import { FileRangeReadRequestError } from '../shared/file-range-read' -import { - readVerifiedTerminalArtifact, - writeVerifiedTerminalArtifact -} from './fs-handler-terminal-artifact' -import { RelayStreamRegistry } from './fs-stream-registry' -import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' -import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' -import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' -import { - readAuthorizedDocPreviewFile, - type DocPreviewFileAccessRequest -} from '../shared/doc-preview-file-access' - -export class FsHandler { - private dispatcher: RelayDispatcher - private watchRegistry: RelayFilesystemWatchRegistry - private streamRegistry = new RelayStreamRegistry() - private listFilesScans = new ListFilesScanCoordinator() - private readonly responseStreams: GitResponseStreamRegistry | undefined - - constructor( - dispatcher: RelayDispatcher, - _context: RelayContext, - watcherPool?: RelayWatcherProcessPool, - // Why passed in rather than owned: GitHandler registers the `git.responseAck` route every pump - // is credited through, and a client keys reassembly on `streamId` alone — see the header of - // git-response-stream.ts. Without one this handler answers plainly, which is the pre-streaming - // behavior rather than a stream nothing can credit. - responseStreams?: GitResponseStreamRegistry - ) { - this.responseStreams = responseStreams - this.dispatcher = dispatcher - this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool) - this.registerHandlers() - this.dispatcher.onClientDetached?.(() => { - // Why: a detached client's fs.streamAck frames will never arrive; wake - // any pump parked on the ack window so it re-checks staleness and exits - // instead of stranding its open file handle. - this.streamRegistry.wakeAllAckWaiters() - }) - } - - getWatchRegistry(): RelayFilesystemWatchRegistry { - return this.watchRegistry - } - - private registerHandlers(): void { - this.dispatcher.onRequest('fs.readDir', (p) => readRelayDir(p)) - this.dispatcher.onRequest('fs.readDirBounded', async (p, c) => { - if (typeof p.dirPath !== 'string') { - throw new Error('Invalid directory path') - } - const entries = await readRelayDirectoryBounded(p.dirPath, c?.signal, { - followSymlinks: typeof p.followSymlinks === 'boolean' ? p.followSymlinks : undefined - }) - return this.responseStreams - ? maybeStreamRpcResponse(entries, p, c, this.responseStreams, this.dispatcher) - : entries - }) - this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) - this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) - this.dispatcher.onRequest('fs.readFileRange', (p) => this.readFileRange(p)) - this.dispatcher.onRequest('fs.readDocPreview', (p) => - readAuthorizedDocPreviewFile(p as DocPreviewFileAccessRequest) - ) - this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p)) - this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) - this.dispatcher.onRequest('fs.writeFile', (p) => writeRelayFile(p)) - this.dispatcher.onRequest('fs.writeTerminalArtifact', (p) => this.writeTerminalArtifact(p)) - this.dispatcher.onRequest('fs.pathsExist', pathsExistOnRelay) - this.dispatcher.onRequest('fs.stat', (p) => statRelayPath(p)) - this.dispatcher.onRequest('fs.lstat', (p) => lstatRelayPath(p)) - this.dispatcher.onRequest('fs.deletePath', (p) => deleteRelayPath(p, this.watchRegistry)) - this.dispatcher.onRequest('fs.createFile', (p) => createRelayFile(p)) - this.dispatcher.onRequest('fs.createDir', (p) => createRelayDir(p)) - this.dispatcher.onRequest('fs.createDirNoClobber', (p) => createRelayDirNoClobber(p)) - this.dispatcher.onRequest('fs.rename', (p) => renameRelayPath(p)) - this.dispatcher.onRequest('fs.renameNoClobber', (p) => renameRelayPathNoClobber(p)) - this.dispatcher.onRequest('fs.copy', (p) => copyRelayPath(p)) - this.dispatcher.onRequest('fs.realpath', (p) => realpathRelayPath(p)) - this.dispatcher.onRequest('fs.search', (p, context) => this.search(p, context)) - this.dispatcher.onRequest('fs.getCapabilities', async () => ({ - quickOpenSearchVersion: QUICK_OPEN_SEARCH_VERSION, - rangedReadVersion: 1, - pathExistenceBatchVersion: 1 - })) - this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) - this.dispatcher.onRequest('fs.listMarkdownDocuments', async (p, c) => { - if (typeof p.rootPath !== 'string') { - throw new Error('Invalid Markdown discovery root') - } - const rootPath = expandTilde(p.rootPath) - const documents = await listRelayMarkdownDocuments(rootPath, c?.signal).catch( - async (error) => { - if (!(error instanceof RipgrepUnavailableError)) { - throw error - } - const paths = await this.listFiles({ rootPath }, c) - if ( - !Array.isArray(paths) || - !paths.every((path): path is string => typeof path === 'string') - ) { - throw new Error('Invalid fallback file listing') - } - return markdownDocumentsFromRelativePaths(rootPath, paths).map((document) => ({ - ...document, - filePath: joinSearchRoot(rootPath, document.relativePath) - })) - } - ) - return this.responseStreams - ? maybeStreamRpcResponse(documents, p, c, this.responseStreams, this.dispatcher) - : documents - }) - this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) - this.dispatcher.onRequest('fs.watch', (p, context) => - this.watchRegistry.watch( - expandTilde(p.rootPath as string), - context, - typeof p.watchId === 'number' && Number.isSafeInteger(p.watchId) ? p.watchId : undefined - ) - ) - this.dispatcher.onRequest('fs.unwatchAndWait', (p, context) => - this.watchRegistry.unwatchAndWait(expandTilde(p.rootPath as string), context) - ) - this.dispatcher.onNotification('fs.unwatch', (p, context) => - this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context) - ) - this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) - this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) - } - - private async readFile(params: Record) { - const filePath = expandTilde(params.filePath as string) - return readRelayFileContent(filePath) - } - - // Why hand-checked: relay params arrive as raw casts with no schema. The - // offsets are validated inside readRelayFileRange, next to the read syscall. - private async readFileRange(params: Record) { - if (typeof params.filePath !== 'string' || params.filePath.length === 0) { - throw new FileRangeReadRequestError('fs.readFileRange requires a filePath') - } - return readRelayFileRange(expandTilde(params.filePath), params.position, params.length) - } - - private async readTerminalArtifact(params: Record) { - return readVerifiedTerminalArtifact({ - ...params, - filePath: expandTilde(params.filePath as string) - }) - } - - private async readFileStream(params: Record, context?: RequestContext) { - const filePath = expandTilde(params.filePath as string) - const ctx = context ?? { clientId: 0, isStale: () => false } - return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, { - // Why: only target the requesting client when the dispatcher actually - // routed this request (context present) — direct-call tests and legacy - // paths keep broadcast semantics. - ...(context ? { clientId: context.clientId } : {}), - paceWithAcks: params.flowControl === 'ack' - }) - } - - private async tempDir(): Promise { - return tmpdir() - } - - private cancelStream(params: Record): void { - const streamId = params.streamId as number | undefined - if (typeof streamId === 'number') { - this.streamRegistry.abort(streamId) - } - } - - private streamAck(params: Record): void { - const streamId = params.streamId as number | undefined - const seq = params.seq as number | undefined - if (typeof streamId === 'number' && typeof seq === 'number') { - this.streamRegistry.recordAck(streamId, seq) - } - } - - private async writeTerminalArtifact(params: Record) { - return writeVerifiedTerminalArtifact({ - ...params, - filePath: expandTilde(params.filePath as string) - }) - } - - private async search(params: Record, context?: RequestContext) { - const query = params.query as string - const rootPath = expandTilde(params.rootPath as string) - const caseSensitive = params.caseSensitive as boolean | undefined - const wholeWord = params.wholeWord as boolean | undefined - const useRegex = params.useRegex as boolean | undefined - const includePattern = params.includePattern as string | undefined - const excludePattern = params.excludePattern as string | undefined - const maxResults = Math.min( - (params.maxResults as number) || DEFAULT_MAX_RESULTS, - DEFAULT_MAX_RESULTS - ) - - const options = { - caseSensitive, - wholeWord, - useRegex, - includePattern, - excludePattern, - maxResults, - signal: context?.signal - } - try { - return await searchWithRg(rootPath, query, options) - } catch (error) { - if (!(error instanceof RipgrepUnavailableError)) { - throw error - } - return searchWithGitGrep(rootPath, query, options) - } - } - - private async listFiles( - params: Record, - context?: RequestContext - ): Promise { - const rootPath = expandTilde(params.rootPath as string) - // Why no host-side default: #17954 made an oversized reply streamable, so a caller that names no - // limit gets its whole listing instead of an unannounced prefix it would report as complete. - // A requested limit is still clamped to the shared ceiling the scan's retention budget assumes. - const maxResults = - typeof params.maxResults === 'number' && - Number.isInteger(params.maxResults) && - params.maxResults > 0 - ? resolveQuickOpenResultLimit(params.maxResults) - : undefined - const searchQuery = - typeof params.searchQuery === 'string' && params.searchQuery.trim().length > 0 - ? params.searchQuery - : undefined - // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees - // don't get double-scanned. The shared helper validates the shape and - // normalizes into root-relative prefixes; malformed input yields [] so - // the request still succeeds (older apps omit the field entirely). - const candidatePaths = params.candidatePaths - if ( - candidatePaths !== undefined && - (!Array.isArray(candidatePaths) || - !candidatePaths.every((path): path is string => typeof path === 'string')) - ) { - throw new Error('Invalid Quick Open recent candidates.') - } - const options = { - ...(candidatePaths === undefined - ? {} - : { candidatePaths: [...quickOpenRecentCandidateSet(candidatePaths)] }), - - ...(typeof params.includeIgnored === 'boolean' - ? { includeIgnored: params.includeIgnored } - : {}), - ...(typeof params.followSymlinks === 'boolean' - ? { followSymlinks: params.followSymlinks } - : {}) - } - const excludePathPrefixes = buildExcludePathPrefixes(rootPath, params.excludePaths) - // Why #7721: full-tree scans are the relay's most expensive request; the - // coordinator caps them at one per client, coalescing duplicates and - // aborting a stale scan when the workspace changes or the host cancels. - const files = await this.listFilesScans.run({ - clientId: context?.clientId ?? 0, - key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery, options]), - signal: context?.signal, - start: (signal) => - runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery, options) - }) - // Why: a full listing of a real monorepo serializes past the 1 MiB control lane — Orca's own - // checkout is 22.6k paths averaging 58 characters, so a 20,001-row page is ~1.2MB — and the - // legacy-response lane it demotes to is refused under unrelated producer load. Streaming makes - // size stop being a correctness question instead of picking a row or byte ceiling to refuse at. - // A client that did not opt in still gets the plain array, exactly as before. - return this.responseStreams - ? maybeStreamRpcResponse(files, params, context, this.responseStreams, this.dispatcher) - : files - } - - private async workspaceSpaceScan(params: Record, context: RequestContext) { - const rootPath = expandTilde(params.rootPath as string) - return scanWorkspaceSpaceDirectory(rootPath, context) - } - - dispose(): void { - this.watchRegistry.dispose() - void this.disposeFileStreams().catch((error: unknown) => { - process.stderr.write(`[relay] file stream shutdown failed: ${String(error)}\n`) - }) - } - - disposeFileStreams = (): Promise => this.streamRegistry.disposeAll() - - disposeWatchers = (): Promise => this.watchRegistry.disposeAndWait() - - reopen(): void { - this.streamRegistry.reopen() - this.watchRegistry.reopen() - } -} diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts deleted file mode 100644 index 30933e226fc..00000000000 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * End-to-end in-process regression tests for #7721: cancellable fs.listFiles. - * - * Wires the client-side SshChannelMultiplexer to the relay-side - * RelayDispatcher + FsHandler through an in-memory pipe (no SSH). The scan - * body is a controllable fake so the tests are deterministic: - * - aborting the client request sends rpc.cancel and stops the relay scan, - * - interactive fs.readDir is served while a scan is in flight, - * - a scan for a different workspace supersedes the previous one, - * - identical concurrent requests coalesce into one scan. - */ -import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import * as path from 'node:path' -import { tmpdir } from 'node:os' - -const { fakeListFiles } = vi.hoisted(() => { - type ScanRecord = { - rootPath: string - signal: AbortSignal | undefined - resolve: (files: string[]) => void - } - const scans: ScanRecord[] = [] - const fakeListFiles = Object.assign( - vi.fn( - ( - rootPath: string, - _excludes: readonly string[] = [], - options: { signal?: AbortSignal } = {} - ) => - new Promise((resolve, reject) => { - scans.push({ rootPath, signal: options.signal, resolve }) - options.signal?.addEventListener( - 'abort', - () => - // Mirror the real scanners: surface the abort reason (e.g. the - // "superseded" error) so the dispatcher reports it to the host. - reject( - options.signal?.reason instanceof Error - ? options.signal.reason - : new Error('File listing cancelled') - ), - { once: true } - ) - }) - ), - { scans } - ) - return { fakeListFiles } -}) - -vi.mock('./fs-handler-utils', async (importOriginal) => { - const original = (await importOriginal()) as Record - return { - ...original, - listFilesWithRg: fakeListFiles - } -}) - -import { - SshChannelMultiplexer, - type MultiplexerTransport -} from '../main/ssh/ssh-channel-multiplexer' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { RelayContext } from './context' -import { FsHandler } from './fs-handler' -import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator' - -async function flushPipe(): Promise { - // The in-memory pipe defers each hop with setImmediate; a few macrotask - // turns guarantee request/notification frames have crossed both directions. - for (let i = 0; i < 10; i++) { - await new Promise((resolve) => setImmediate(resolve)) - } -} - -describe('Integration: cancellable fs.listFiles (#7721)', () => { - let tmpDir: string - let mux: SshChannelMultiplexer - let dispatcher: RelayDispatcher - let fsHandler: FsHandler - - beforeEach(() => { - fakeListFiles.mockClear() - fakeListFiles.scans.length = 0 - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-listfiles-cancel-')) - - let relayFeedFn: (data: Buffer) => void - const clientDataCallbacks: ((data: Buffer) => void)[] = [] - const clientTransport: MultiplexerTransport = { - write: (data: Buffer) => { - setImmediate(() => relayFeedFn?.(data)) - }, - onData: (cb) => { - clientDataCallbacks.push(cb) - }, - onClose: () => {} - } - dispatcher = new RelayDispatcher((data: Buffer) => { - setImmediate(() => { - for (const cb of clientDataCallbacks) { - cb(data) - } - }) - }) - relayFeedFn = (data: Buffer) => dispatcher.feed(data) - fsHandler = new FsHandler(dispatcher, new RelayContext()) - mux = new SshChannelMultiplexer(clientTransport) - }) - - afterEach(async () => { - mux.dispose() - dispatcher.dispose() - fsHandler.dispose() - await rm(tmpDir, { recursive: true, force: true }) - }) - - it('serves fs.readDir while a full-tree scan is in flight', async () => { - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - - const scanPromise = mux.request('fs.listFiles', { rootPath: '/big/workspace' }) - await flushPipe() - expect(fakeListFiles.scans).toHaveLength(1) - - // The interactive request must complete while the scan is still pending. - const entries = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { name: string }[] - expect(entries.map((e) => e.name)).toEqual(['a.txt']) - - fakeListFiles.scans[0].resolve(['src/index.ts']) - await expect(scanPromise).resolves.toEqual(['src/index.ts']) - }) - - it('client abort sends rpc.cancel and stops the relay-side scan', async () => { - const controller = new AbortController() - const scanPromise = mux.request( - 'fs.listFiles', - { rootPath: '/big/workspace' }, - { signal: controller.signal } - ) - await flushPipe() - expect(fakeListFiles.scans).toHaveLength(1) - expect(fakeListFiles.scans[0].signal?.aborted).toBe(false) - - controller.abort() - await expect(scanPromise).rejects.toThrow('was cancelled') - await flushPipe() - - // The rpc.cancel notification must have aborted the scan on the relay. - expect(fakeListFiles.scans[0].signal?.aborted).toBe(true) - }) - - it('a scan for a different workspace supersedes the previous scan', async () => { - const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) - // Attach the rejection handler up front: the error response arrives on a - // macrotask inside flushPipe, before any later `await expect` could. - const firstOutcome = first.then( - () => null, - (err: Error) => err - ) - await flushPipe() - expect(fakeListFiles.scans).toHaveLength(1) - - const second = mux.request('fs.listFiles', { rootPath: '/workspace/b' }) - await flushPipe() - expect(fakeListFiles.scans).toHaveLength(2) - - // The stale scan is aborted and its request fails fast — it does not run - // to completion behind the new one and does not hit the 30s timeout. - expect(fakeListFiles.scans[0].signal?.aborted).toBe(true) - const firstError = await firstOutcome - expect(firstError?.message).toContain(LIST_FILES_SUPERSEDED_MESSAGE) - - fakeListFiles.scans[1].resolve(['b.ts']) - await expect(second).resolves.toEqual(['b.ts']) - }) - - it('identical concurrent requests coalesce into a single scan', async () => { - const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) - const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) - await flushPipe() - - expect(fakeListFiles.scans).toHaveLength(1) - fakeListFiles.scans[0].resolve(['shared.ts']) - await expect(first).resolves.toEqual(['shared.ts']) - await expect(second).resolves.toEqual(['shared.ts']) - }) - - it('cancelling one coalesced requester keeps the scan alive for the other', async () => { - const controller = new AbortController() - const first = mux.request( - 'fs.listFiles', - { rootPath: '/workspace/a' }, - { signal: controller.signal } - ) - const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) - await flushPipe() - expect(fakeListFiles.scans).toHaveLength(1) - - controller.abort() - await expect(first).rejects.toThrow('was cancelled') - await flushPipe() - expect(fakeListFiles.scans[0].signal?.aborted).toBe(false) - - fakeListFiles.scans[0].resolve(['still.ts']) - await expect(second).resolves.toEqual(['still.ts']) - }) -}) diff --git a/src/relay/fs-list-files-dispatch-test-harness.ts b/src/relay/fs-list-files-dispatch-test-harness.ts deleted file mode 100644 index 5f05dd83889..00000000000 --- a/src/relay/fs-list-files-dispatch-test-harness.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' -import { RelayContext } from './context' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { FsHandler } from './fs-handler' - -export function createRelayFileListingRequestHarness() { - const receive: ((data: Buffer) => void)[] = [] - const dispatcher = new RelayDispatcher((data) => { - setImmediate(() => receive.forEach((callback) => callback(data))) - return true - }) - const mux = new SshChannelMultiplexer({ - write: (data) => { - setImmediate(() => dispatcher.feed(data)) - }, - onData: (callback) => { - receive.push(callback) - }, - onClose: () => {} - }) - const handler = new FsHandler(dispatcher, new RelayContext()) - return { - request: async (params: Record): Promise => { - const value = await mux.request('fs.listFiles', params) - if ( - !Array.isArray(value) || - !value.every((path): path is string => typeof path === 'string') - ) { - throw new Error('Expected a file-path array') - } - return value - }, - dispose: () => { - mux.dispose() - dispatcher.dispose() - handler.dispose() - } - } -} diff --git a/src/relay/fs-list-files-fallback-chain.ts b/src/relay/fs-list-files-fallback-chain.ts deleted file mode 100644 index fbc629f2648..00000000000 --- a/src/relay/fs-list-files-fallback-chain.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { runProcess } from '@orca/process-host' -import { listFilesWithRg } from './fs-handler-utils' -import { listFilesWithGit } from './fs-handler-git-fallback' -import { listFilesWithReaddir } from './fs-handler-readdir-fallback' -import { - isFileListingCancellation, - throwIfFileListingCancelled -} from '../shared/file-listing-cancellation' -import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk' -import { buildInstallRgMessage, buildRipgrepRequiredMessage } from '../shared/quick-open-install-rg' -import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' -import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' -import { QuickOpenPathRanker } from '../shared/quick-open-path-search' - -export async function runListFilesScan( - rootPath: string, - excludePathPrefixes: string[], - signal: AbortSignal, - maxResults?: number, - searchQuery?: string, - options: { includeIgnored?: boolean; followSymlinks?: boolean; candidatePaths?: string[] } = {} -): Promise { - throwIfFileListingCancelled(signal) - try { - return await listFilesWithRg(rootPath, excludePathPrefixes, { - signal, - maxResults, - searchQuery, - ...options - }) - } catch (error) { - throwIfFileListingCancelled(signal) - if (!(error instanceof RipgrepUnavailableError)) { - throw error - } - } - if ( - searchQuery !== undefined || - options.includeIgnored === false || - options.followSymlinks || - options.candidatePaths !== undefined - ) { - throw new Error(await buildRipgrepRequiredMessage()) - } - // Detect Git ancestry so folder roots inside a checkout still honor its ignores. - const isGitRepo = await runProcess({ - program: 'git', - args: ['rev-parse', '--is-inside-work-tree'], - cwd: rootPath, - env: buildRelayCommandEnv(), - timeoutMs: 5_000, - signal - }) - .then((result) => result.code === 0 && !result.timedOut) - .catch(() => false) - throwIfFileListingCancelled(signal) - if (isGitRepo) { - // Why: a git monorepo parent fills nested-repo subtrees via the readdir - // walk, which can exhaust the same cap/deadline. Translate only those - // budget errors into install-rg guidance; genuine git failures keep - // their own messages. - try { - return rankFallbackFiles( - await listFilesWithGit(rootPath, excludePathPrefixes, { - signal, - maxResults: searchQuery === undefined ? maxResults : undefined - }), - searchQuery, - maxResults - ) - } catch (err) { - if (isQuickOpenReaddirBudgetError(err)) { - throw new Error(await buildInstallRgMessage(err)) - } - throw err - } - } - // Why: the readdir walker rejects on cap/deadline instead of returning a - // partial list (design doc: silent truncation is worse than an explicit - // error). On a home-root without rg that's almost always an install-rg - // problem, so translate the opaque cap error into actionable guidance - // the user can act on directly from the error toast. - try { - return rankFallbackFiles( - await listFilesWithReaddir(rootPath, excludePathPrefixes, { - signal, - maxResults: searchQuery === undefined ? maxResults : undefined - }), - searchQuery, - maxResults - ) - } catch (err) { - // Why: a cancelled scan is not an rg-availability problem; wrapping it - // in install-rg guidance would surface bogus advice on the client. - if (isFileListingCancellation(err)) { - throw err - } - throw new Error(await buildInstallRgMessage(err)) - } -} - -function rankFallbackFiles( - files: readonly string[], - query: string | undefined, - limit: number | undefined -): string[] { - if (query === undefined) { - return [...files] - } - const ranker = new QuickOpenPathRanker(query, limit ?? 16) - for (const file of files) { - ranker.consider(file) - } - return ranker.result().paths -} diff --git a/src/relay/fs-list-files-large-response.integration.test.ts b/src/relay/fs-list-files-large-response.integration.test.ts deleted file mode 100644 index 5fb9f935623..00000000000 --- a/src/relay/fs-list-files-large-response.integration.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * #12547: a full `fs.listFiles` reply for a real monorepo does not fit the relay's control lane. - * - * Orca's own checkout is ~22.6k tracked paths averaging 58 characters, so a 20,001-row page - * serializes to ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, which demotes it to the - * `legacy-response` lane where an unrelated producer backlog can refuse it. Refusing at a fixed row - * or byte ceiling only moves where that shows up; streaming removes it, so these run the real - * dispatcher, the real FsHandler and the real client multiplexer over an in-memory pipe and assert - * an over-budget listing arrives intact — in both wire directions. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { runListFilesScanMock } = vi.hoisted(() => ({ runListFilesScanMock: vi.fn() })) - -vi.mock('./fs-list-files-fallback-chain', () => ({ runListFilesScan: runListFilesScanMock })) -vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) - -import { - SshChannelMultiplexer, - type MultiplexerTransport -} from '../main/ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' -import { RelayContext } from './context' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' -import { FsHandler } from './fs-handler' -import { GitHandler } from './git-handler' -import { GitResponseStreamRegistry } from './git-response-stream' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' - -/** Shaped like this repository: `packages//src/...`, ~58 characters. */ -function monorepoPaths(count: number): string[] { - return Array.from( - { length: count }, - (_, index) => - `packages/pkg-${String(index % 64).padStart(2, '0')}/src/renderer/components/entry-${String(index).padStart(6, '0')}.tsx` - ) -} - -describe('Integration: an over-budget fs.listFiles reply (#12547)', () => { - let mux: SshChannelMultiplexer - let dispatcher: RelayDispatcher - let fsHandler: FsHandler - let gitHandler: GitHandler - let writtenFrames: number[] - - beforeEach(() => { - runListFilesScanMock.mockReset() - writtenFrames = [] - - let relayFeed: (data: Buffer) => void - const clientDataCallbacks: ((data: Buffer) => void)[] = [] - const clientTransport: MultiplexerTransport = { - write: (data: Buffer) => { - setImmediate(() => relayFeed?.(data)) - }, - onData: (cb) => { - clientDataCallbacks.push(cb) - }, - onClose: () => {} - } - dispatcher = new RelayDispatcher((data: Buffer) => { - writtenFrames.push(data.length) - setImmediate(() => { - for (const cb of clientDataCallbacks) { - cb(data) - } - }) - return true - }) - relayFeed = (data: Buffer) => dispatcher.feed(data) - // Why: the same single registry production wires, so `git.responseAck` — registered by - // GitHandler — credits the pump an fs.listFiles stream parks on. - const responseStreams = new GitResponseStreamRegistry() - const context = new RelayContext() - fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) - gitHandler = new GitHandler(dispatcher, context, undefined, responseStreams) - mux = new SshChannelMultiplexer(clientTransport) - }) - - afterEach(() => { - mux.dispose() - dispatcher.dispose() - fsHandler.dispose() - gitHandler.dispose() - }) - - it('delivers a page too large for the control lane, in chunks no frame has to carry', async () => { - const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS) - // Precondition, measured from the payload rather than asserted between two constants: this is - // the listing that does not fit, which is what makes the rest of the test mean anything. - expect(Buffer.byteLength(JSON.stringify(files), 'utf8')).toBeGreaterThan( - DISPATCHER_CONTROL_QUEUE_MAX_BYTES - ) - runListFilesScanMock.mockResolvedValue(files) - - const received = await requestGitStreamable(mux, 'fs.listFiles', { - rootPath: '/remote/root', - maxResults: QUICK_OPEN_LISTING_MAX_RESULTS - }) - - expect(received).toEqual(files) - expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) - }) - - it('still answers a client that never opts into streaming, with the whole array', async () => { - const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS) - runListFilesScanMock.mockResolvedValue(files) - - // Why: an old client sends neither `__streamResponse` nor `maxResults`. It gets one plain frame - // on the legacy-response lane, as it did before this call ever learned to stream. - const received = await mux.request('fs.listFiles', { rootPath: '/remote/root' }) - - expect(received).toEqual(files) - expect(Math.max(...writtenFrames)).toBeGreaterThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) - }) - - it('leaves a reply that fits on the plain response path', async () => { - const files = monorepoPaths(100) - runListFilesScanMock.mockResolvedValue(files) - - const received = await requestGitStreamable(mux, 'fs.listFiles', { - rootPath: '/remote/root', - maxResults: 100 - }) - - expect(received).toEqual(files) - expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) - }) -}) diff --git a/src/relay/fs-list-files-passes.ts b/src/relay/fs-list-files-passes.ts deleted file mode 100644 index 618d2d1ae23..00000000000 --- a/src/relay/fs-list-files-passes.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { RipgrepLaunchFailureError } from '../shared/ripgrep-process-availability' -export async function runRelayFileListingPasses( - options: { - includeIgnored?: boolean - searchQuery?: string - maxResults?: number - candidatePaths?: string[] - }, - primary: string[], - ignoredPass: string[], - runPass: (args: string[]) => Promise, - resultCount: () => number -): Promise { - if (options.includeIgnored === false) { - return runPass(primary) - } - // Unordered candidate checks and ranked scans need only the broader pass. - if ( - options.candidatePaths !== undefined || - options.searchQuery !== undefined || - options.maxResults === undefined - ) { - return runPass(ignoredPass) - } - await runPass(primary) - if (resultCount() < options.maxResults) { - await runPass(ignoredPass) - } -} - -export function retryRelayFileListingPass( - run: () => Promise, - isCanceled: () => boolean -): Promise { - return run().catch((error: unknown) => { - if (!(error instanceof RipgrepLaunchFailureError) || isCanceled()) { - throw error - } - return run() - }) -} diff --git a/src/relay/fs-list-files-scan-coordinator.test.ts b/src/relay/fs-list-files-scan-coordinator.test.ts deleted file mode 100644 index a142c120cdb..00000000000 --- a/src/relay/fs-list-files-scan-coordinator.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - LIST_FILES_SUPERSEDED_MESSAGE, - ListFilesScanCoordinator -} from './fs-list-files-scan-coordinator' -import { - FileListingCancelledError, - isFileListingCancellation -} from '../shared/file-listing-cancellation' - -type Deferred = { - promise: Promise - resolve: (files: string[]) => void - reject: (error: Error) => void -} - -function deferred(): Deferred { - let resolve!: (files: string[]) => void - let reject!: (error: Error) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -/** Scan runner that resolves/rejects on demand and rejects when aborted. */ -function controllableScan(): { - start: (signal: AbortSignal) => Promise - starts: AbortSignal[] - finish: (files: string[]) => void -} { - const starts: AbortSignal[] = [] - let current: Deferred | null = null - return { - starts, - start: (signal: AbortSignal) => { - starts.push(signal) - const scan = deferred() - current = scan - signal.addEventListener( - 'abort', - () => { - scan.reject( - signal.reason instanceof Error ? signal.reason : new FileListingCancelledError() - ) - }, - { once: true } - ) - return scan.promise - }, - finish: (files: string[]) => current?.resolve(files) - } -} - -describe('ListFilesScanCoordinator', () => { - it('coalesces same-key concurrent requests into one scan', async () => { - const coordinator = new ListFilesScanCoordinator() - const scan = controllableScan() - - const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) - const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) - - expect(scan.starts).toHaveLength(1) - scan.finish(['x.ts']) - await expect(first).resolves.toEqual(['x.ts']) - await expect(second).resolves.toEqual(['x.ts']) - }) - - it('supersedes a different-key scan: aborts the old one and rejects it fast', async () => { - const coordinator = new ListFilesScanCoordinator() - const scan = controllableScan() - - const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scan.start }) - const second = coordinator.run({ clientId: 1, key: 'workspace-b', start: scan.start }) - - expect(scan.starts).toHaveLength(2) - expect(scan.starts[0].aborted).toBe(true) - await expect(first).rejects.toThrow(LIST_FILES_SUPERSEDED_MESSAGE) - await first.catch((err) => expect(isFileListingCancellation(err)).toBe(true)) - - expect(scan.starts[1].aborted).toBe(false) - scan.finish(['b.ts']) - await expect(second).resolves.toEqual(['b.ts']) - }) - - it('keeps scans from different clients independent', async () => { - const coordinator = new ListFilesScanCoordinator() - const scanA = controllableScan() - const scanB = controllableScan() - - const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scanA.start }) - const second = coordinator.run({ clientId: 2, key: 'workspace-b', start: scanB.start }) - - expect(scanA.starts[0].aborted).toBe(false) - scanA.finish(['a.ts']) - scanB.finish(['b.ts']) - await expect(first).resolves.toEqual(['a.ts']) - await expect(second).resolves.toEqual(['b.ts']) - }) - - it('aborts the scan when its only requester cancels', async () => { - const coordinator = new ListFilesScanCoordinator() - const scan = controllableScan() - const requester = new AbortController() - - const result = coordinator.run({ - clientId: 1, - key: 'a', - signal: requester.signal, - start: scan.start - }) - - requester.abort() - expect(scan.starts[0].aborted).toBe(true) - await expect(result).rejects.toSatisfy(isFileListingCancellation) - }) - - it('keeps a coalesced scan alive while another requester still waits', async () => { - const coordinator = new ListFilesScanCoordinator() - const scan = controllableScan() - const first = new AbortController() - const second = new AbortController() - - const firstResult = coordinator.run({ - clientId: 1, - key: 'a', - signal: first.signal, - start: scan.start - }) - const secondResult = coordinator.run({ - clientId: 1, - key: 'a', - signal: second.signal, - start: scan.start - }) - - first.abort() - expect(scan.starts[0].aborted).toBe(false) - // The aborting requester observes its own cancellation immediately, - // while the shared scan keeps running for the sibling. - await expect(firstResult).rejects.toSatisfy(isFileListingCancellation) - - second.abort() - expect(scan.starts[0].aborted).toBe(true) - await expect(secondResult).rejects.toSatisfy(isFileListingCancellation) - }) - - it('rejects an aborted coalesced requester while the sibling still resolves', async () => { - const coordinator = new ListFilesScanCoordinator() - const scan = controllableScan() - const first = new AbortController() - - const firstResult = coordinator.run({ - clientId: 1, - key: 'a', - signal: first.signal, - start: scan.start - }) - const secondResult = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) - - first.abort() - scan.finish(['kept.ts']) - - await expect(firstResult).rejects.toSatisfy(isFileListingCancellation) - await expect(secondResult).resolves.toEqual(['kept.ts']) - }) - - it('rejects immediately when the requester is already cancelled', async () => { - const coordinator = new ListFilesScanCoordinator() - const start = vi.fn() - const requester = new AbortController() - requester.abort() - - await expect( - coordinator.run({ - clientId: 1, - key: 'a', - signal: requester.signal, - start: start as unknown as (signal: AbortSignal) => Promise - }) - ).rejects.toSatisfy(isFileListingCancellation) - expect(start).not.toHaveBeenCalled() - }) - - it('starts a fresh scan for the same key after the previous one settled', async () => { - const coordinator = new ListFilesScanCoordinator() - const scan = controllableScan() - - const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) - scan.finish(['old.ts']) - await expect(first).resolves.toEqual(['old.ts']) - - const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) - expect(scan.starts).toHaveLength(2) - scan.finish(['new.ts']) - await expect(second).resolves.toEqual(['new.ts']) - }) - - it('does not join a scan that is already aborted; starts a replacement', async () => { - const coordinator = new ListFilesScanCoordinator() - const scan = controllableScan() - const requester = new AbortController() - - const first = coordinator.run({ - clientId: 1, - key: 'a', - signal: requester.signal, - start: scan.start - }) - requester.abort() - await expect(first).rejects.toSatisfy(isFileListingCancellation) - - // The aborted entry may still be in the map until its promise settles; - // a new same-key request must get a live scan, not the dead one. - const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) - expect(scan.starts).toHaveLength(2) - expect(scan.starts[1].aborted).toBe(false) - scan.finish(['fresh.ts']) - await expect(second).resolves.toEqual(['fresh.ts']) - }) -}) diff --git a/src/relay/fs-list-files-scan-coordinator.ts b/src/relay/fs-list-files-scan-coordinator.ts deleted file mode 100644 index 6e6e30c5f0d..00000000000 --- a/src/relay/fs-list-files-scan-coordinator.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Single-flight coordinator for fs.listFiles full-tree scans (#7721). - * - * Why: rapid workspace switching used to stack N concurrent full-tree scans - * on the single-threaded relay and its one SSH channel, starving small - * interactive fs.readDir/fs.stat requests past their 30s timeout. This - * coordinator guarantees at most one scan in flight per client: - * - a request for the same root/excludes joins the in-flight scan - * (Quick Open + file-explorer filter share one scan), - * - a request for a different root supersedes it — the old scan is aborted - * and its request fails fast instead of running to completion (this also - * protects against older Orca clients that never send rpc.cancel), - * - when every joined requester cancels (rpc.cancel / client detach), the - * scan is aborted so abandoned work stops immediately. - */ -import { - FileListingCancelledError, - fileListingCancellationError -} from '../shared/file-listing-cancellation' - -export const LIST_FILES_SUPERSEDED_MESSAGE = 'File listing superseded by a newer request' - -type ScanEntry = { - key: string - controller: AbortController - promise: Promise - attachedCount: number -} - -export class ListFilesScanCoordinator { - private readonly scansByClient = new Map() - - run(opts: { - clientId: number - key: string - signal?: AbortSignal - start: (signal: AbortSignal) => Promise - }): Promise { - const { clientId, key, signal, start } = opts - if (signal?.aborted) { - return Promise.reject(fileListingCancellationError(signal)) - } - - const existing = this.scansByClient.get(clientId) - if (existing && existing.key === key && !existing.controller.signal.aborted) { - return this.attach(existing, signal) - } - if (existing) { - existing.controller.abort(new FileListingCancelledError(LIST_FILES_SUPERSEDED_MESSAGE)) - } - - const controller = new AbortController() - const entry: ScanEntry = { - key, - controller, - promise: Promise.resolve([]), - attachedCount: 0 - } - this.scansByClient.set(clientId, entry) - entry.promise = start(controller.signal).finally(() => { - if (this.scansByClient.get(clientId) === entry) { - this.scansByClient.delete(clientId) - } - }) - return this.attach(entry, signal) - } - - private attach(entry: ScanEntry, signal?: AbortSignal): Promise { - entry.attachedCount++ - if (!signal) { - return entry.promise - } - // Why: an aborting requester must see its own cancellation even though - // the shared scan keeps running (and may later resolve) for coalesced - // siblings — so each attachment gets its own promise. - return new Promise((resolve, reject) => { - let settled = false - const settle = (complete: () => void): void => { - if (settled) { - return - } - settled = true - signal.removeEventListener('abort', onAbort) - complete() - } - const onAbort = (): void => - settle(() => { - entry.attachedCount-- - const cancellation = fileListingCancellationError(signal) - // Why: only stop the shared scan when nobody is left waiting on it — - // one requester cancelling must not break a coalesced sibling. - if (entry.attachedCount <= 0) { - entry.controller.abort(cancellation) - } - reject(cancellation) - }) - signal.addEventListener('abort', onAbort, { once: true }) - entry.promise.then( - (files) => settle(() => resolve(files)), - (error) => settle(() => reject(error)) - ) - }) - } -} diff --git a/src/relay/fs-markdown-document-launch.test.ts b/src/relay/fs-markdown-document-launch.test.ts deleted file mode 100644 index b79171badce..00000000000 --- a/src/relay/fs-markdown-document-launch.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { beforeEach, expect, it, vi } from 'vitest' -import type * as RipgrepAvailability from '../shared/ripgrep-process-availability' - -const { spawnMock, cwdUsableMock } = vi.hoisted(() => ({ - spawnMock: vi.fn(), - cwdUsableMock: vi.fn() -})) -vi.mock('@orca/process-host', () => ({ spawnProcess: spawnMock })) -vi.mock('./relay-bundled-ripgrep', () => ({ resolveRelayRipgrepCommand: () => '/tools/rg' })) -vi.mock('../shared/ripgrep-process-availability', async (importOriginal) => ({ - ...(await importOriginal()), - isRipgrepSpawnCwdUsable: cwdUsableMock -})) - -import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' -import { listRelayMarkdownDocuments } from './fs-markdown-document-listing' - -class ListingProcess extends EventEmitter { - stdout = new PassThrough() - stderr = new PassThrough() - pid: number | undefined = undefined - exitCode: number | null = null - signalCode = null - kill = vi.fn(() => true) -} - -let child: ListingProcess -beforeEach(() => { - child = new ListingProcess() - spawnMock.mockReset().mockReturnValue(child) - cwdUsableMock.mockReset().mockResolvedValue(true) -}) - -it('tags only a missing launch in a usable root for the existing listing fallback', async () => { - const result = listRelayMarkdownDocuments('/repo') - child.emit('error', Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) - await expect(result).rejects.toThrow(RipgrepUnavailableError) -}) - -it('keeps an unreachable root out of the missing-binary fallback', async () => { - cwdUsableMock.mockResolvedValue(false) - const result = listRelayMarkdownDocuments('/repo') - child.emit('error', Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) - await expect(result).rejects.toThrow('Search root is not reachable') -}) - -it('keeps unusable native launchers on the existing listing fallback', async () => { - child.pid = 123 - child.exitCode = 127 - const result = listRelayMarkdownDocuments('/repo') - child.emit('close', 127, null) - await expect(result).rejects.toThrow(RipgrepUnavailableError) -}) - -it.each(['EMFILE', 'EAGAIN'])( - 'preserves %s pressure without retrying another scan', - async (code) => { - const error = Object.assign(new Error(`spawn ${code}`), { code }) - const result = listRelayMarkdownDocuments('/repo') - child.emit('error', error) - await expect(result).rejects.toBe(error) - expect(cwdUsableMock).not.toHaveBeenCalled() - } -) - -it('preserves readable SSH documents after an unreadable subtree', async () => { - child.pid = 123 - const result = listRelayMarkdownDocuments('/repo') - child.stdout.write('./README.md\0') - child.stderr.write('Permission denied') - child.emit('close', 2, null) - await expect(result).resolves.toEqual([ - { - filePath: '/repo/README.md', - relativePath: 'README.md', - basename: 'README.md', - name: 'README' - } - ]) - expect(cwdUsableMock).not.toHaveBeenCalled() -}) - -it('still rejects a permission failure that produced no readable documents', async () => { - child.pid = 123 - const result = listRelayMarkdownDocuments('/repo') - child.stderr.write('Permission denied') - child.emit('close', 2, null) - await expect(result).rejects.toThrow('Permission denied') - expect(cwdUsableMock).not.toHaveBeenCalled() -}) - -it('does not accept an incomplete record with the historical partial listing policy', async () => { - child.pid = 123 - const result = listRelayMarkdownDocuments('/repo') - child.stdout.write('./README.md\0./truncated') - child.emit('close', 2, null) - await expect(result).rejects.toThrow('Incomplete path') - expect(cwdUsableMock).not.toHaveBeenCalled() -}) diff --git a/src/relay/fs-markdown-document-listing.test.ts b/src/relay/fs-markdown-document-listing.test.ts deleted file mode 100644 index 08795cb8cff..00000000000 --- a/src/relay/fs-markdown-document-listing.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { expect, it, vi } from 'vitest' -import { bundledRipgrepCommand } from '../main/ripgrep/bundled-ripgrep-path' -import { isMarkdownDocumentName } from '../shared/markdown-document-paths' -import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' -import { listFilesWithRg } from './fs-handler-list-files' -import { listRelayMarkdownDocuments } from './fs-markdown-document-listing' -import { RelayContext } from './context' -import { FsHandler } from './fs-handler' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -it.skipIf(process.platform === 'win32')( - 'keeps readable SSH Markdown documents when a child directory is unreadable', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-markdown-permissions-')) - const locked = join(root, 'locked') - configureRelayBundledRipgrep(bundledRipgrepCommand()) - try { - await mkdir(locked) - await writeFile(join(root, 'README.md'), '') - await writeFile(join(locked, 'private.md'), '') - await chmod(locked, 0) - expect(await listFilesWithRg(root)).toEqual(['README.md']) - await expect(listRelayMarkdownDocuments(root)).resolves.toEqual([ - { - filePath: join(root, 'README.md'), - relativePath: 'README.md', - basename: 'README.md', - name: 'README' - } - ]) - } finally { - await chmod(locked, 0o700) - configureRelayBundledRipgrep(undefined) - await rm(root, { recursive: true, force: true }) - } - } -) - -it('keeps Markdown discovery useful on folder hosts without uploaded or PATH ripgrep', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-markdown-no-rg-')) - configureRelayBundledRipgrep(undefined) - vi.stubEnv('PATH', root) - vi.stubEnv('Path', root) - vi.stubEnv('CARGO_HOME', root) - const handlers = new Map) => Promise>() - const dispatcher = { - onRequest: (method: string, callback: (params: Record) => Promise) => - handlers.set(method, callback), - onNotification: vi.fn(), - onClientDetached: vi.fn(() => () => {}) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Filesystem registration uses only the three dispatcher hooks supplied by this fixture. - const handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - try { - await mkdir(join(root, '.claude')) - await writeFile(join(root, '.claude', 'instructions.md'), '') - await writeFile(join(root, 'README.md'), '') - const listMarkdown = handlers.get('fs.listMarkdownDocuments') - if (!listMarkdown) { - throw new Error('Markdown discovery handler is missing') - } - await expect(listMarkdown({ rootPath: root })).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ relativePath: '.claude/instructions.md' }), - expect.objectContaining({ relativePath: 'README.md' }) - ]) - ) - } finally { - handler.dispose() - vi.unstubAllEnvs() - configureRelayBundledRipgrep(undefined) - await rm(root, { recursive: true, force: true }) - } -}) - -it('preserves SSH Markdown visibility when discovery moves off the full file inventory', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-markdown-')) - configureRelayBundledRipgrep(bundledRipgrepCommand()) - try { - for (const path of [ - 'README.md', - '.config/settings.md', - '.claude/instructions.MDX', - '.github/template.md', - '.cache/hidden.md', - 'node_modules/dependency.md', - 'ignored.md', - 'excluded.md', - 'source.ts' - ]) { - await mkdir(dirname(join(root, path)), { recursive: true }) - await writeFile(join(root, path), '') - } - await writeFile(join(root, '.gitignore'), 'ignored.md\n') - await writeFile(join(root, '.ignore'), 'excluded.md\n') - const baseline = (await listFilesWithRg(root)).filter(isMarkdownDocumentName).sort() - expect(baseline).toEqual([ - '.claude/instructions.MDX', - '.config/settings.md', - '.github/template.md', - 'README.md', - 'ignored.md' - ]) - const documents = await listRelayMarkdownDocuments(root) - expect(documents.map((document) => document.relativePath).sort()).toEqual(baseline) - } finally { - configureRelayBundledRipgrep(undefined) - await rm(root, { recursive: true, force: true }) - } -}) diff --git a/src/relay/fs-markdown-document-listing.ts b/src/relay/fs-markdown-document-listing.ts deleted file mode 100644 index 2aaee521b88..00000000000 --- a/src/relay/fs-markdown-document-listing.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { spawnProcess } from '@orca/process-host' -import { - collectMarkdownDocuments, - MARKDOWN_DOCUMENT_GLOB -} from '../shared/node-markdown-document-listing' -import { buildRgArgsForQuickOpen } from '../shared/quick-open-filter' -import { - isRipgrepSpawnCwdUsable, - isRipgrepUnavailableExit, - isTransientRipgrepSpawnError, - ripgrepMissingCwdError, - RipgrepUnavailableError -} from '../shared/ripgrep-process-availability' -import { resolveRelayRipgrepCommand } from './relay-bundled-ripgrep' -import { expandTilde } from './context' - -export async function listRelayMarkdownDocuments(rootPath: string, signal?: AbortSignal) { - signal?.throwIfAborted() - const command = resolveRelayRipgrepCommand() - if (!command) { - throw new RipgrepUnavailableError() - } - const expandedRoot = expandTilde(rootPath) - const child = spawnProcess({ - program: command, - args: [ - '--type-add', - `orcamarkdown:${MARKDOWN_DOCUMENT_GLOB}`, - '--type', - 'orcamarkdown', - ...buildRgArgsForQuickOpen({ - searchRoot: '.', - excludePathPrefixes: [], - forceSlashSeparator: true - }).ignoredPass - ], - cwd: expandedRoot, - stdio: ['ignore', 'pipe', 'pipe'] - }) - try { - return await collectMarkdownDocuments(child, expandedRoot, false, signal, { - allowPartialListing: true - }) - } catch (error) { - signal?.throwIfAborted() - if ( - !isTransientRipgrepSpawnError(error) && - isRipgrepUnavailableExit(child, child.exitCode, child.signalCode, { - classifyNativeLauncherExit: true - }) - ) { - throw (await isRipgrepSpawnCwdUsable(expandedRoot)) - ? new RipgrepUnavailableError() - : ripgrepMissingCwdError(expandedRoot) - } - throw error - } -} diff --git a/src/relay/fs-path-existence.ts b/src/relay/fs-path-existence.ts deleted file mode 100644 index 48ee51cda2b..00000000000 --- a/src/relay/fs-path-existence.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { statRelayPath } from './fs-path-metadata-requests' -import { capturePathExistence, validatePathExistenceBatch } from '../shared/path-existence-batch' - -export async function pathsExistOnRelay(params: Record) { - const paths = params.filePaths - validatePathExistenceBatch(paths) - return Promise.all( - paths.map((filePath) => - capturePathExistence(async () => { - try { - await statRelayPath({ filePath }) - return true - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return false - } - throw error - } - }) - ) - ) -} diff --git a/src/relay/fs-path-metadata-requests.test.ts b/src/relay/fs-path-metadata-requests.test.ts deleted file mode 100644 index f981223ca6f..00000000000 --- a/src/relay/fs-path-metadata-requests.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { readdirMock, statMock } = vi.hoisted(() => ({ - readdirMock: vi.fn(), - statMock: vi.fn() -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const original = (await importOriginal()) as Record - return { ...original, readdir: readdirMock, stat: statMock } -}) - -import { readRelayDir } from './fs-path-metadata-requests' - -function dirEntry(args: { name: string; directory?: boolean; symlink?: boolean }) { - return { - name: args.name, - isDirectory: () => args.directory ?? false, - isSymbolicLink: () => args.symlink ?? false - } -} - -describe('readRelayDir', () => { - beforeEach(() => { - readdirMock.mockReset() - statMock.mockReset() - }) - - it('creates asynchronous classification work only for symlinks in a large listing', async () => { - readdirMock.mockResolvedValue([ - ...Array.from({ length: 1_000 }, (_, index) => dirEntry({ name: `file-${index}.txt` })), - dirEntry({ name: 'directory', directory: true }), - dirEntry({ name: 'directory-link', symlink: true }), - dirEntry({ name: 'file-link', symlink: true }) - ]) - statMock - .mockResolvedValueOnce({ isDirectory: () => true }) - .mockResolvedValueOnce({ isDirectory: () => false }) - - const originalAll = Promise.all.bind(Promise) - let promiseBearingEntries = -1 - const allSpy = vi.spyOn(Promise, 'all').mockImplementation(((values: Iterable) => { - const entries = Array.from(values) - promiseBearingEntries = entries.filter((entry) => entry instanceof Promise).length - return originalAll(entries) - }) as typeof Promise.all) - - try { - const result = await readRelayDir({ dirPath: '/repo' }) - - expect(result.slice(0, 2)).toEqual([ - { name: 'directory', isDirectory: true, isSymlink: false }, - { name: 'directory-link', isDirectory: true, isSymlink: true } - ]) - expect(result.find((entry) => entry.name === 'file-link')).toEqual({ - name: 'file-link', - isDirectory: false, - isSymlink: true - }) - } finally { - allSpy.mockRestore() - } - - expect(statMock).toHaveBeenCalledTimes(2) - expect(promiseBearingEntries).toBe(2) - }) -}) diff --git a/src/relay/fs-path-metadata-requests.ts b/src/relay/fs-path-metadata-requests.ts deleted file mode 100644 index 07523cba70a..00000000000 --- a/src/relay/fs-path-metadata-requests.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { readdir, stat, lstat, realpath } from 'node:fs/promises' -import type { Dirent } from 'node:fs' -import { join } from 'node:path' -import { sortDirEntries } from '../shared/file-name-sort' -import { forEachWithConcurrency } from '../shared/map-with-concurrency' -import { expandTilde } from './context' - -async function resolveSymlinkDirectoryEntry( - dirPath: string, - entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean } -): Promise { - try { - // Why: the file explorer needs target type for symlinked directories so a - // workspace link to an external folder expands instead of opening as a file. - return (await stat(join(dirPath, entry.name))).isDirectory() - } catch { - return false - } -} - -function fileStatFromLstat(stats: Awaited>) { - let type: 'file' | 'directory' | 'symlink' = 'file' - if (stats.isDirectory()) { - type = 'directory' - } else if (stats.isSymbolicLink()) { - type = 'symlink' - } - return { - size: stats.size, - type, - mtime: stats.mtimeMs, - mtimeMs: stats.mtimeMs, - dev: stats.dev, - ino: stats.ino, - nlink: stats.nlink - } -} - -// Why bounded: a pnpm `node_modules` is hundreds-to-thousands of package symlinks, and one -// unbounded `Promise.all` of stats from a single readDir saturates libuv's four-thread pool — -// delaying every other relay filesystem operation, including the interactive reads the -// list-files scan coordinator exists to protect. Matches the cap every other bounded probe in -// this codebase uses. -const SYMLINK_DIRECTORY_PROBE_CONCURRENCY = 8 - -export async function readRelayDir(params: Record) { - const dirPath = expandTilde(params.dirPath as string) - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped: { name: string; isDirectory: boolean; isSymlink: boolean }[] = [] - const symlinkEntries: { entry: Dirent; mappedEntry: (typeof mapped)[number] }[] = [] - for (const entry of entries) { - const mappedEntry = { - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - } - mapped.push(mappedEntry) - if (!mappedEntry.isDirectory && mappedEntry.isSymlink) { - symlinkEntries.push({ entry, mappedEntry }) - } - } - if (params.followSymlinks !== false && symlinkEntries.length > 0) { - await forEachWithConcurrency( - symlinkEntries, - SYMLINK_DIRECTORY_PROBE_CONCURRENCY, - async ({ entry, mappedEntry }) => { - mappedEntry.isDirectory = await resolveSymlinkDirectoryEntry(dirPath, entry) - } - ) - } - return sortDirEntries(mapped) -} - -export async function statRelayPath(params: Record) { - const filePath = expandTilde(params.filePath as string) - const stats = await lstat(filePath) - if (stats.isSymbolicLink()) { - try { - // Why: callers use stat to decide whether to read a path or enumerate - // it; symlink-to-directory must behave like its target for that choice. - const targetStats = await stat(filePath) - return { - size: targetStats.size, - type: targetStats.isDirectory() ? 'directory' : 'file', - mtime: targetStats.mtimeMs, - mtimeMs: targetStats.mtimeMs, - dev: targetStats.dev, - ino: targetStats.ino, - nlink: targetStats.nlink - } - } catch { - return { size: stats.size, type: 'symlink', mtime: stats.mtimeMs } - } - } - return fileStatFromLstat(stats) -} - -export async function lstatRelayPath(params: Record) { - const filePath = expandTilde(params.filePath as string) - return fileStatFromLstat(await lstat(filePath)) -} - -export async function realpathRelayPath(params: Record) { - const filePath = expandTilde(params.filePath as string) - return await realpath(filePath) -} diff --git a/src/relay/fs-path-metadata-symlink-concurrency.test.ts b/src/relay/fs-path-metadata-symlink-concurrency.test.ts deleted file mode 100644 index 3a342a79e7c..00000000000 --- a/src/relay/fs-path-metadata-symlink-concurrency.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as FsPromisesModule from 'node:fs/promises' - -const statCalls = vi.hoisted(() => ({ inFlight: 0, peak: 0, total: 0 })) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - stat: async (...args: Parameters) => { - statCalls.inFlight += 1 - statCalls.total += 1 - statCalls.peak = Math.max(statCalls.peak, statCalls.inFlight) - try { - return await actual.stat(...args) - } finally { - statCalls.inFlight -= 1 - } - } - } -}) - -const { readRelayDir } = await import('./fs-path-metadata-requests') - -describe('relay readDir symlink probes', () => { - let root: string - let targetRoot: string - - beforeEach(() => { - statCalls.inFlight = 0 - statCalls.peak = 0 - statCalls.total = 0 - root = mkdtempSync(join(tmpdir(), 'orca-relay-readdir-')) - // Kept outside `root` so the listing contains only the symlinks under test. - targetRoot = mkdtempSync(join(tmpdir(), 'orca-relay-readdir-target-')) - const target = join(targetRoot, 'target') - mkdirSync(target) - writeFileSync(join(target, 'index.js'), '') - // A pnpm-shaped node_modules: many package symlinks in one directory. Junctions on - // Windows: plain symlinks need Developer Mode there. - for (let index = 0; index < 60; index += 1) { - symlinkSync( - target, - join(root, `pkg-${index}`), - process.platform === 'win32' ? 'junction' : 'dir' - ) - } - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - rmSync(targetRoot, { recursive: true, force: true }) - }) - - it('bounds concurrent symlink stats instead of issuing one per entry at once', async () => { - const entries = await readRelayDir({ dirPath: root }) - - expect(statCalls.total).toBe(60) - // Exactly the cap: every worker enters `stat` before any resolves, so the peak proves the - // probes overlap and that no more than 8 ever do. Unbounded, all 60 would be in flight, - // saturating libuv's four-thread pool and stalling every other relay filesystem read. - expect(statCalls.peak).toBe(8) - // Behaviour is unchanged: every symlink still resolves to its target's kind. - expect(entries).toHaveLength(60) - expect(entries.every((entry) => entry.isDirectory && entry.isSymlink)).toBe(true) - }) -}) diff --git a/src/relay/fs-path-mutation-requests.ts b/src/relay/fs-path-mutation-requests.ts deleted file mode 100644 index 957dc6c6c4d..00000000000 --- a/src/relay/fs-path-mutation-requests.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { writeFile, stat, lstat, mkdir, rename, cp, rm } from 'node:fs/promises' -import { expandTilde } from './context' -import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' -import type { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -export async function writeRelayFile(params: Record) { - const filePath = expandTilde(params.filePath as string) - const content = params.content as string - try { - const fileStats = await lstat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - await writeFile(filePath, content, 'utf-8') -} - -export async function deleteRelayPath( - params: Record, - watchRegistry: RelayFilesystemWatchRegistry -) { - const targetPath = expandTilde(params.targetPath as string) - const recursive = params.recursive as boolean | undefined - const stats = await stat(targetPath) - if (stats.isDirectory() && !recursive) { - throw new Error('Cannot delete directory without recursive flag') - } - const remove = () => rm(targetPath, { recursive: !!recursive, force: true }) - if (stats.isDirectory()) { - // Why: forced orphan cleanup bypasses git.removeWorktree but must hold - // the same relay-wide watcher fence through recursive deletion. - await watchRegistry.runWithRemovalFence(targetPath, remove) - return - } - await remove() -} - -export async function createRelayFile(params: Record) { - const filePath = expandTilde(params.filePath as string) - const { dirname } = await import('node:path') - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) -} - -export async function createRelayDir(params: Record) { - const dirPath = expandTilde(params.dirPath as string) - await mkdir(dirPath, { recursive: true }) -} - -export async function createRelayDirNoClobber(params: Record) { - const dirPath = expandTilde(params.dirPath as string) - await mkdir(dirPath, { recursive: false }) -} - -export async function renameRelayPath(params: Record) { - const oldPath = expandTilde(params.oldPath as string) - const newPath = expandTilde(params.newPath as string) - await rename(oldPath, newPath) -} - -export async function renameRelayPathNoClobber(params: Record) { - const oldPath = expandTilde(params.oldPath as string) - const newPath = expandTilde(params.newPath as string) - // Why: user-facing file renames must not inherit fs.rename's overwrite - // behavior; keep the guard inside the relay so SSH checks the remote FS. - await assertNoClobberRenameDestinationAvailable(oldPath, newPath) - await rename(oldPath, newPath) -} - -export async function copyRelayPath(params: Record) { - const source = expandTilde(params.source as string) - const destination = expandTilde(params.destination as string) - try { - await cp(source, destination, { recursive: true, force: false, errorOnExist: true }) - } catch (error) { - const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined - if (code === 'EEXIST' || code === 'ERR_FS_CP_EEXIST') { - throw new Error('EEXIST: destination already exists') - } - throw error - } -} diff --git a/src/relay/fs-search-cancellation.test.ts b/src/relay/fs-search-cancellation.test.ts deleted file mode 100644 index 24e34796425..00000000000 --- a/src/relay/fs-search-cancellation.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { ChildProcess } from 'node:child_process' -import type * as ChildProcessModule from 'node:child_process' -import type * as RipgrepAvailability from '../shared/ripgrep-process-availability' -import { getEventListeners } from 'node:events' -import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { spawnMock, resolveCommand, retryOnPath, classifyFailure } = vi.hoisted(() => ({ - spawnMock: vi.fn(), - resolveCommand: vi.fn((): string | null => '/tools/rg'), - retryOnPath: vi.fn(async () => false), - classifyFailure: vi.fn( - async (): Promise<'cwd-unreachable' | 'ripgrep-unavailable'> => 'ripgrep-unavailable' - ) -})) -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: spawnMock -})) -vi.mock('../shared/ripgrep-process-availability', async (importOriginal) => ({ - ...(await importOriginal()), - classifyRipgrepLaunchFailure: classifyFailure -})) -vi.mock('./relay-bundled-ripgrep', () => ({ - resolveRelayRipgrepCommand: resolveCommand, - pathRipgrepCommand: () => '/tools/rg', - retryRipgrepOnPathAfterLaunchFailure: retryOnPath -})) - -import { searchWithRg } from './fs-handler-utils' -import { searchWithGitGrep } from './fs-handler-git-fallback' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { FsHandler } from './fs-handler' -import { RelayContext } from './context' -import { encodeJsonRpcFrame } from '../wsl-guest/protocol' - -function createProcess(spawned = true) { - const child = new ChildProcess() - Object.defineProperties(child, { - stdout: { value: new PassThrough() }, - stderr: { value: new PassThrough() } - }) - Object.defineProperty(child, 'pid', { value: spawned ? 4321 : undefined }) - child.kill = vi.fn(() => true) - return child -} - -beforeEach(() => { - vi.useFakeTimers() - spawnMock.mockReset() - resolveCommand.mockReturnValue('/tools/rg') - retryOnPath.mockResolvedValue(false) - classifyFailure.mockResolvedValue('ripgrep-unavailable') -}) -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - -describe.each([ - { name: 'ripgrep', search: searchWithRg }, - { name: 'git grep', search: searchWithGitGrep } -])('relay $name search cancellation', ({ search }) => { - it('does not start a child for an already canceled request', async () => { - const controller = new AbortController() - controller.abort() - await expect( - search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('releases a canceled search even when its child never reports an exit', async () => { - const child = createProcess() - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const result = search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) - const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) - child.stdout?.emit('data', 'unfinished output') - - controller.abort() - await rejected - - expect(child.kill).toHaveBeenCalledOnce() - expect(child.stdout?.listenerCount('data')).toBe(0) - expect(child.stderr?.listenerCount('data')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) - child.emit('close', 0, null) - expect(spawnMock).toHaveBeenCalledOnce() - }) - - it('removes the abort listener after normal completion', async () => { - const child = createProcess() - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const result = search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) - child.emit('close', 0, null) - await expect(result).resolves.toEqual({ files: [], totalMatches: 0, truncated: false }) - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - controller.abort() - expect(child.kill).not.toHaveBeenCalled() - }) - - it('absorbs a queued spawn error after cancellation without signaling a missing PID', async () => { - const child = createProcess(false) - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const result = search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) - const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) - controller.abort() - await rejected - expect(child.kill).not.toHaveBeenCalled() - expect(() => child.emit('error', new Error('spawn ENOENT'))).not.toThrow() - expect(child.listenerCount('error')).toBe(0) - }) -}) - -it('does not retry on PATH after cancellation during launch-failure diagnosis', async () => { - let finishRetry: (retry: boolean) => void = () => undefined - retryOnPath.mockReturnValueOnce( - new Promise((resolve) => { - finishRetry = resolve - }) - ) - const child = createProcess(false) - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const result = searchWithRg('/remote/root', 'needle', { - maxResults: 100, - signal: controller.signal - }) - const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) - child.emit('error', new Error('spawn ENOENT')) - controller.abort() - await rejected - finishRetry(true) - await Promise.resolve() - expect(spawnMock).toHaveBeenCalledOnce() -}) - -it('settles cancellation while launch-failure classification is still pending', async () => { - let finishClassification: (failure: 'ripgrep-unavailable') => void = () => undefined - classifyFailure.mockReturnValueOnce( - new Promise((resolve) => { - finishClassification = resolve - }) - ) - const child = createProcess(false) - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const result = searchWithRg('/remote/root', 'needle', { - maxResults: 100, - signal: controller.signal - }) - const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) - child.emit('error', new Error('spawn ENOENT')) - await Promise.resolve() - controller.abort() - await rejected - finishClassification('ripgrep-unavailable') - await Promise.resolve() - expect(spawnMock).toHaveBeenCalledOnce() - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) -}) - -describe.each(['ripgrep', 'git grep'])('relay dispatcher cancels %s', (backend) => { - it.each(['detach', 'rpc.cancel', 'dispose'])( - 'removes every search child and listener on %s', - async (cause) => { - if (backend === 'git grep') { - resolveCommand.mockReturnValue(null) - } - const children: ReturnType[] = [] - spawnMock.mockImplementation(() => { - const child = createProcess() - children.push(child) - return child - }) - const dispatcher = new RelayDispatcher(() => true) - const handler = new FsHandler(dispatcher, new RelayContext(), { - dispose: vi.fn(), - forgetRoot: vi.fn(), - disposeAndWait: vi.fn(async () => {}), - reopen: vi.fn(), - subscribe: vi.fn() - }) - try { - const client = dispatcher.attachClient(() => true) - for (let id = 1; id <= 25; id++) { - dispatcher.feedClient( - client, - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id, - method: 'fs.search', - params: { rootPath: '/remote/root', query: 'needle' } - }, - id, - 0 - ) - ) - } - await Promise.resolve() - expect(children).toHaveLength(25) - if (cause === 'detach') { - dispatcher.detachClient(client) - } else if (cause === 'dispose') { - dispatcher.dispose() - } else { - for (let id = 1; id <= 25; id++) { - dispatcher.feedClient( - client, - encodeJsonRpcFrame( - { jsonrpc: '2.0', method: 'rpc.cancel', params: { id } }, - id + 25, - 0 - ) - ) - } - } - await Promise.resolve() - for (const child of children) { - expect(child.kill).toHaveBeenCalledOnce() - expect(child.stdout?.listenerCount('data')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } - expect(spawnMock).toHaveBeenCalledTimes(25) - } finally { - handler.dispose() - dispatcher.dispose() - for (const child of children) { - child.emit('close', 0, null) - } - } - expect(vi.getTimerCount()).toBe(0) - } - ) -}) diff --git a/src/relay/fs-search-diagnosis-cancellation.test.ts b/src/relay/fs-search-diagnosis-cancellation.test.ts deleted file mode 100644 index ff25d9e3f78..00000000000 --- a/src/relay/fs-search-diagnosis-cancellation.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { ChildProcess } from 'node:child_process' -import type * as ChildProcessModule from 'node:child_process' -import type * as FsPromises from 'node:fs/promises' -import { PassThrough } from 'node:stream' -import { getEventListeners } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -const { spawnMock, statMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), statMock: vi.fn() })) -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: spawnMock -})) -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), - stat: statMock, - access: vi.fn(async () => undefined) -})) -vi.mock('./relay-bundled-ripgrep', () => ({ - resolveRelayRipgrepCommand: () => '/tools/rg', - pathRipgrepCommand: () => '/tools/rg', - retryRipgrepOnPathAfterLaunchFailure: async () => false -})) -import { searchWithRg } from './fs-handler-utils' -import { classifyRipgrepLaunchFailure } from '../shared/ripgrep-process-availability' -import { searchWithGitGrep } from './fs-handler-git-search' - -function child(spawned: boolean) { - const result = new ChildProcess() - Object.defineProperties(result, { - stdout: { value: new PassThrough() }, - stderr: { value: new PassThrough() } - }) - Object.defineProperty(result, 'pid', { value: spawned ? 4321 : undefined }) - result.kill = vi.fn(() => true) - return result -} -beforeEach(() => { - vi.useFakeTimers() - spawnMock.mockReset() - statMock.mockReset().mockResolvedValue({ isDirectory: () => false }) -}) -afterEach(() => vi.useRealTimers()) -it('does not start a version probe after canceled launch diagnosis resumes', async () => { - vi.useFakeTimers() - let completeStat: (value: { isDirectory: () => boolean }) => void = () => undefined - statMock.mockReturnValue( - new Promise((resolve) => { - completeStat = resolve - }) - ) - const failed = child(false) - const probe = child(true) - spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) - const controller = new AbortController() - const search = searchWithRg('/missing/root', 'needle', { - maxResults: 100, - signal: controller.signal - }) - const canceled = expect(search).rejects.toMatchObject({ name: 'AbortError' }) - failed.emit('error', new Error('spawn ENOENT')) - for (let tick = 0; tick < 5; tick += 1) { - await Promise.resolve() - } - expect(statMock).toHaveBeenCalledOnce() - controller.abort() - await canceled - completeStat({ isDirectory: () => false }) - for (let tick = 0; tick < 10; tick += 1) { - await Promise.resolve() - } - const observed = spawnMock.mock.calls.map((args) => [args[0], args[1]]) - probe.emit('close', 0) - expect(observed).toEqual([['/tools/rg', expect.not.arrayContaining(['--version'])]]) -}) - -for (const [name, search] of [ - ['rg', searchWithRg], - ['git', searchWithGitGrep] -] as const) { - it.each(['emit', 'throw'])( - `settles ${name} cancellation when kill fails via %s`, - async (mode) => { - const process = child(true) - process.kill = vi.fn(() => { - const error = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) - if (mode === 'throw') { - throw error - } - process.emit('error', error) - return false - }) - spawnMock.mockReturnValue(process) - const controller = new AbortController() - const result = search('/root', 'needle', { maxResults: 100, signal: controller.signal }) - const canceled = expect(result).rejects.toMatchObject({ name: 'AbortError' }) - expect(() => controller.abort()).not.toThrow() - await canceled - expect(process.kill).toHaveBeenCalledOnce() - expect(process.stdout?.listenerCount('data')).toBe(0) - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) - } - ) -} - -it('terminates an active version probe and skips remaining candidates on abort', async () => { - const probe = child(true) - spawnMock.mockReturnValue(probe) - const controller = new AbortController() - const diagnosis = classifyRipgrepLaunchFailure( - '/missing/root', - ['/tools/rg', '/fallback/rg'], - {}, - controller.signal - ) - const canceled = expect(diagnosis).rejects.toMatchObject({ name: 'AbortError' }) - for (let tick = 0; tick < 5; tick += 1) { - await Promise.resolve() - } - expect(spawnMock).toHaveBeenCalledOnce() - controller.abort() - await canceled - expect(probe.kill).toHaveBeenCalledOnce() - expect(probe.listenerCount('close')).toBe(0) - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) - probe.emit('close', 0) - expect(spawnMock).toHaveBeenCalledOnce() -}) - -it('preserves candidate fallback and cwd classification for a live caller', async () => { - const missing = child(true) - const available = child(true) - spawnMock.mockReturnValueOnce(missing).mockReturnValueOnce(available) - const controller = new AbortController() - const diagnosis = classifyRipgrepLaunchFailure( - '/missing/root', - ['/tools/rg', '/fallback/rg'], - {}, - controller.signal - ) - for (let tick = 0; tick < 5; tick += 1) { - await Promise.resolve() - } - missing.emit('close', 1) - for (let tick = 0; tick < 5; tick += 1) { - await Promise.resolve() - } - available.emit('close', 0) - await expect(diagnosis).resolves.toBe('cwd-unreachable') - expect(spawnMock.mock.calls.map(([program, args]) => [program, args])).toEqual([ - ['/tools/rg', ['--version']], - ['/fallback/rg', ['--version']] - ]) - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) - expect(missing.kill).not.toHaveBeenCalled() - expect(available.kill).not.toHaveBeenCalled() -}) - -it.each(['emit', 'throw'])( - 'settles version-probe cancellation when kill fails via %s', - async (mode) => { - const probe = child(true) - probe.kill = vi.fn(() => { - const error = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) - if (mode === 'throw') { - throw error - } - probe.emit('error', error) - return false - }) - spawnMock.mockReturnValue(probe) - const controller = new AbortController() - const diagnosis = classifyRipgrepLaunchFailure( - '/missing/root', - ['/tools/rg', '/fallback/rg'], - {}, - controller.signal - ) - const canceled = expect(diagnosis).rejects.toMatchObject({ name: 'AbortError' }) - for (let tick = 0; tick < 5; tick += 1) { - await Promise.resolve() - } - expect(() => controller.abort()).not.toThrow() - await canceled - expect(probe.kill).toHaveBeenCalledOnce() - expect(spawnMock).toHaveBeenCalledOnce() - expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) - expect(vi.getTimerCount()).toBe(0) - } -) diff --git a/src/relay/fs-search-errors-real.test.ts b/src/relay/fs-search-errors-real.test.ts deleted file mode 100644 index eaf7fb9633c..00000000000 --- a/src/relay/fs-search-errors-real.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { rgPath } from '@vscode/ripgrep-universal' -import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' -import { searchWithRg } from './fs-handler-utils' - -let root: string -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-search-errors-')) - configureRelayBundledRipgrep(rgPath) - await writeFile(join(root, 'source.txt'), 'needle\n') -}) -afterEach(async () => { - configureRelayBundledRipgrep(undefined) - await rm(root, { recursive: true, force: true }) -}) - -it('reports an invalid regular expression as an error', async () => { - await expect(searchWithRg(root, '[', { useRegex: true, maxResults: 100 })).rejects.toThrow( - /regex parse error|unclosed character class/ - ) -}) - -it('distinguishes no matches from invalid syntax', async () => { - await expect(searchWithRg(root, 'absent', { maxResults: 100 })).resolves.toEqual({ - files: [], - totalMatches: 0, - truncated: false - }) -}) - -it('retains intentional capped results after stopping the process', async () => { - await writeFile(join(root, 'source.txt'), 'needle\n'.repeat(1000)) - const result = await searchWithRg(root, 'needle', { maxResults: 2 }) - expect(result.totalMatches).toBe(2) - expect(result.truncated).toBe(true) -}) diff --git a/src/relay/fs-search-line-fragments.test.ts b/src/relay/fs-search-line-fragments.test.ts deleted file mode 100644 index fc584efb346..00000000000 --- a/src/relay/fs-search-line-fragments.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) - -import { searchWithGitGrep } from './fs-handler-git-fallback' -import { searchWithRg } from './fs-handler-utils' - -function createProcess() { - return Object.assign(new EventEmitter(), { - stdout: new PassThrough(), - stderr: new EventEmitter(), - kill: vi.fn() - }) -} - -const searchCases = [ - { - name: 'ripgrep', - search: searchWithRg, - encode: (text: string, line: number) => - JSON.stringify({ - type: 'match', - data: { - path: { text: '/remote/root/unicode.ts' }, - lines: { text: `${text}\n` }, - line_number: line, - submatches: [{ start: 0, end: 3 }] - } - }) - }, - { - name: 'git grep', - search: searchWithGitGrep, - encode: (text: string, line: number) => `unicode.ts\0${line}\0${text}` - } -] - -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() - spawnMock.mockReset() -}) - -describe.each(searchCases)('relay $name line fragments', ({ search, encode }) => { - async function run(chunks: Buffer[]) { - const child = createProcess() - spawnMock.mockReturnValueOnce(child) - const result = search('/remote/root', 'hit', { maxResults: 100 }) - for (const chunk of chunks) { - child.stdout!.write(chunk) - } - child.emit('close', 0, null) - const value = await result - expect(child.stdout!.listenerCount('data')).toBe(0) - expect(child.stderr!.listenerCount('data')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.kill).not.toHaveBeenCalled() - return value - } - - it('preserves decoded Unicode, batched lines, empty lines and the final unterminated match', async () => { - const text = 'hit café 漢字 🐋' - const wire = Buffer.from( - `${encode(text, 1)}\n\n${encode('hit second', 2)}\n${encode(text, 3)}`, - 'utf8' - ) - const complete = await run([wire]) - const fragmented = await run(Array.from(wire, (byte) => Buffer.from([byte]))) - expect(fragmented).toEqual(complete) - expect(fragmented.totalMatches).toBe(3) - expect(fragmented.truncated).toBe(false) - expect(fragmented.files[0].matches.map((match) => match.line)).toEqual([1, 2, 3]) - expect(fragmented.files[0].matches[0].lineContent).toBe(text) - expect(fragmented.files[0].matches[2].lineContent).toBe(text) - }) - - it('does not repeatedly split the growing partial output of a large matching line', async () => { - const wire = Buffer.from(`${encode(`hit ${'x'.repeat(1024 * 1024)}`, 7)}\n`, 'utf8') - const complete = await run([wire]) - const chunks: Buffer[] = [] - for (let offset = 0; offset < wire.length; offset += 4096) { - chunks.push(wire.subarray(offset, offset + 4096)) - } - // Method-shaped type: a call-signature capture would reject `split`'s splitter-object overload. - const originalSplit: { split(separator: unknown, limit?: number): string[] }['split'] = - String.prototype.split - let scannedCharacters = 0 - const spy = vi.spyOn(String.prototype, 'split').mockImplementation(function ( - this: string, - separator: unknown, - limit?: number - ) { - if (separator === '\n') { - scannedCharacters += this.length - } - return originalSplit.call(this, separator, limit) - }) - let fragmented - try { - fragmented = await run(chunks) - } finally { - spy.mockRestore() - } - expect(fragmented).toEqual(complete) - expect(fragmented.totalMatches).toBe(1) - expect(fragmented.files[0].matches[0].line).toBe(7) - expect(scannedCharacters).toBe(0) - }) - - it('discards an unfinished line on timeout and detaches the output listeners', async () => { - vi.useFakeTimers() - const child = createProcess() - spawnMock.mockReturnValueOnce(child) - const result = search('/remote/root', 'hit', { maxResults: 100 }) - child.stdout!.emit('data', `${encode('hit complete', 1)}\n${encode('hit partial', 2)}`) - await vi.runOnlyPendingTimersAsync() - const value = await result - expect(value.totalMatches).toBe(1) - expect(value.truncated).toBe(true) - expect(child.kill).toHaveBeenCalled() - expect(child.stdout!.listenerCount('data')).toBe(0) - expect(child.stderr!.listenerCount('data')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - expect(vi.getTimerCount()).toBe(0) - child.stdout!.emit('data', '\n') - child.emit('close', 0, null) - expect(value.totalMatches).toBe(1) - }) -}) diff --git a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts deleted file mode 100644 index a73c888a2c6..00000000000 --- a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * Regression: SSH typing latency under bulk file-stream load. - * - * The relay and the client share ONE ordered SSH channel. If the relay - * enqueues an entire file's fs.streamChunk frames into the outbound pipe - * at once, an interactive pty.data echo emitted mid-stream queues behind - * megabytes of bulk data and typing feels seconds-slow. - * - * These tests model the SSH channel as a congestible in-memory pipe and - * assert deterministic byte bounds instead of wall-clock latency: - * - with a saturated sink, the relay stalls the pump on write backpressure - * so at most ~1 chunk frame sits ahead of a pty echo; - * - with a fast sink but an unresponsive client, the fs.streamAck credit - * window bounds the in-flight backlog; - * - legacy clients that never ack still receive the full stream. - */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import * as path from 'node:path' -import { tmpdir } from 'node:os' -import { randomBytes } from 'node:crypto' - -import { - SshChannelMultiplexer, - type MultiplexerTransport -} from '../main/ssh/ssh-channel-multiplexer' -import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader' - -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { SinkWriteSettlement } from '../wsl-guest/dispatcher' -import { RelayContext } from './context' -import { FsHandler } from './fs-handler' -import { STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' - -// One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header. -const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512 -// Node pipe/socket sinks report saturation via write() === false past the HWM. -const SINK_HIGH_WATER_MARK = 64 * 1024 - -async function waitUntil( - predicate: () => boolean, - what: string, - timeoutMs = 10_000 -): Promise { - const deadline = Date.now() + timeoutMs - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error(`waitUntil timed out: ${what}`) - } - await new Promise((r) => setImmediate(r)) - } -} - -/** Waits until `read()` stops changing for `stableTurns` macrotask turns. */ -async function waitUntilSettled(read: () => number, stableTurns = 25): Promise { - let last = read() - let stable = 0 - while (stable < stableTurns) { - await new Promise((r) => setImmediate(r)) - const current = read() - if (current === last) { - stable += 1 - } else { - stable = 0 - last = current - } - } -} - -type Harness = { - mux: SshChannelMultiplexer - dispatcher: RelayDispatcher - fsHandler: FsHandler - queuedBytes: () => number - /** Deliver every queued relay→client buffer to the client mux. */ - deliverAll: () => void - /** Start delivering continuously on each macrotask turn. */ - startAutoDeliver: () => void - dispose: () => void -} - -function createHarness(opts: { congested: boolean }): Harness { - let relayFeed: ((data: Buffer) => void) | null = null - const clientDataCallbacks: ((data: Buffer) => void)[] = [] - - const clientTransport: MultiplexerTransport = { - write: (data: Buffer) => { - // Client → relay: keystrokes and acks flow on the opposite direction of - // the duplex channel; they are not blocked by relay→client congestion. - setImmediate(() => relayFeed?.(data)) - }, - onData: (cb) => { - clientDataCallbacks.push(cb) - }, - onClose: () => {} - } - - const outQueue: { - data: Buffer - settle: (result: SinkWriteSettlement) => void - }[] = [] - let queuedBytes = 0 - const drainWaiters = new Set<() => void>() - const fireDrainIfIdle = (): void => { - if (queuedBytes > 0) { - return - } - for (const cb of Array.from(drainWaiters)) { - drainWaiters.delete(cb) - cb() - } - } - - const dispatcher = new RelayDispatcher( - (data: Buffer, settle) => { - outQueue.push({ data, settle }) - queuedBytes += data.length - if (!opts.congested) { - return true - } - return queuedBytes < SINK_HIGH_WATER_MARK - }, - { - supportsWriteCallback: true, - writableLength: () => queuedBytes, - writableHighWaterMark: () => SINK_HIGH_WATER_MARK, - waitWriteDrain: (cb: () => void) => { - drainWaiters.add(cb) - fireDrainIfIdle() - } - } - ) - relayFeed = (data: Buffer) => dispatcher.feed(data) - - const deliverAll = (): void => { - while (outQueue.length > 0) { - const { data, settle } = outQueue.shift()! - queuedBytes -= data.length - for (const cb of clientDataCallbacks) { - cb(data) - } - settle({ ok: true }) - } - fireDrainIfIdle() - } - - let autoDeliverTimer: ReturnType | null = null - const startAutoDeliver = (): void => { - if (autoDeliverTimer) { - return - } - autoDeliverTimer = setInterval(deliverAll, 1) - } - - const context = new RelayContext() - const fsHandler = new FsHandler(dispatcher, context) - const mux = new SshChannelMultiplexer(clientTransport) - - return { - mux, - dispatcher, - fsHandler, - queuedBytes: () => queuedBytes, - deliverAll, - startAutoDeliver, - dispose: () => { - if (autoDeliverTimer) { - clearInterval(autoDeliverTimer) - } - mux.dispose() - dispatcher.dispose() - fsHandler.dispose() - } - } -} - -describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { - let tmpDir: string - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-hol-')) - }) - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }) - }) - - it('bounds bulk bytes queued ahead of a pty echo when the channel is congested', async () => { - const harness = createHarness({ congested: true }) - try { - const filePath = path.join(tmpDir, 'big.png') - const original = randomBytes(3 * 1024 * 1024) // 12 chunks - writeFileSync(filePath, original) - - // Relay-side fake PTY: echoes input back immediately, mirroring - // PtyHandler's interactive fast path (dispatcher.notify on echo). - let queuedBytesAheadOfEcho = -1 - harness.dispatcher.onNotification('pty.data', (params) => { - queuedBytesAheadOfEcho = harness.queuedBytes() - harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) - }) - - const readPromise = readFileViaStream(harness.mux, filePath) - // Deliver until the client has the stream metadata, then congest fully. - await waitUntil(() => harness.queuedBytes() > 0, 'metadata response queued') - harness.deliverAll() - // Let the pump run to whatever bound it enforces (pre-fix: whole file). - await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued') - await waitUntilSettled(() => harness.queuedBytes()) - - // Type one key while the stream is congested. - harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) - await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') - - // The echo must not sit behind an unbounded chunk backlog: at most one - // in-flight bulk frame (the write that saturated the sink) plus slack. - expect(queuedBytesAheadOfEcho).toBeLessThan(FRAMED_CHUNK_BYTES) - - // Un-congest: the stream must still complete with intact content. - harness.startAutoDeliver() - const result = await readPromise - expect(Buffer.from(result.content, 'base64').equals(original)).toBe(true) - } finally { - harness.dispose() - } - }, 30_000) - - it('caps in-flight chunks via the fs.streamAck credit window when the client stalls', async () => { - const harness = createHarness({ congested: false }) - try { - const filePath = path.join(tmpDir, 'big.png') - writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks - - const receivedSeqs: number[] = [] - harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { - receivedSeqs.push(params.seq as number) - }) - let streamEnded = false - harness.mux.onNotificationByMethod('fs.streamEnd', () => { - streamEnded = true - }) - - harness.startAutoDeliver() - // Raw ack-capable request without sending any acks: models a client - // whose main thread is too busy to process chunks. - const metadata = (await harness.mux.request('fs.readFileStream', { - filePath, - flowControl: 'ack' - })) as { streamId: number } - - await waitUntil(() => receivedSeqs.length > 0, 'first chunk received') - await waitUntilSettled(() => receivedSeqs.length) - - // Without acks the relay must stop at the credit window, not flood - // the remaining chunks. - expect(receivedSeqs.length).toBeLessThanOrEqual(5) - expect(streamEnded).toBe(false) - - // Acking releases the window and the stream completes. - const totalChunks = 12 - for (let seq = 0; seq < totalChunks; seq += 1) { - harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq }) - } - await waitUntil(() => streamEnded, 'stream completed after acks') - expect(receivedSeqs).toEqual(Array.from({ length: totalChunks }, (_, i) => i)) - } finally { - harness.dispose() - } - }, 30_000) - - it('still delivers the full stream to legacy clients that never ack', async () => { - const harness = createHarness({ congested: false }) - try { - const filePath = path.join(tmpDir, 'legacy.png') - const original = randomBytes(1024 * 1024 + 12345) - writeFileSync(filePath, original) - - const chunks = new Map() - let streamEnded = false - harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { - chunks.set(params.seq as number, Buffer.from(params.data as string, 'base64')) - }) - harness.mux.onNotificationByMethod('fs.streamEnd', () => { - streamEnded = true - }) - - harness.startAutoDeliver() - // Legacy request shape: no flowControl param, and no acks ever sent. - await harness.mux.request('fs.readFileStream', { filePath }) - await waitUntil(() => streamEnded, 'legacy stream completed') - - const reassembled = Buffer.concat( - Array.from(chunks.entries()) - .sort(([a], [b]) => a - b) - .map(([, buf]) => buf) - ) - expect(reassembled.equals(original)).toBe(true) - } finally { - harness.dispose() - } - }, 30_000) -}) diff --git a/src/relay/fs-stream-registry-shutdown.test.ts b/src/relay/fs-stream-registry-shutdown.test.ts deleted file mode 100644 index c79dfb860d4..00000000000 --- a/src/relay/fs-stream-registry-shutdown.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { FileHandle } from 'node:fs/promises' -import { describe, expect, it, vi } from 'vitest' -import { RelayStreamRegistry } from './fs-stream-registry' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -async function flushTasks(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function handle(close: ReturnType): FileHandle { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry only ever calls close() on a handle. - return { close } as unknown as FileHandle -} - -describe('RelayStreamRegistry shutdown', () => { - it('retains failed late pre-registration cleanup after the initial disposal snapshot', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const failure = new Error('late handle close failed') - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(undefined) - const result = registry.disposeAll().catch((error: unknown) => error) - await flushTasks() - const openedHandle = handle(close) - expect(() => registry.register(openedHandle)).toThrow('relay_file_stream_shutdown_fenced') - await expect(registry.releaseUnregisteredHandle(openedHandle)).rejects.toBe(failure) - finishMetadata() - expect(await result).toMatchObject({ - message: 'relay_file_stream_shutdown_incomplete', - errors: [failure] - }) - expect(registry.size()).toBe(1) - await registry.disposeAll() - expect(registry.size()).toBe(0) - expect(close).toHaveBeenCalledTimes(2) - }) - - it('waits for a late pre-registration close owned by an admitted metadata operation', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const finished = vi.fn() - const drain = registry.disposeAll().then(finished) - await flushTasks() - const cleanup = registry.releaseUnregisteredHandle(handle(close)).finally(finishMetadata) - await flushTasks() - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([cleanup, drain]) - expect(registry.size()).toBe(0) - }) - - it('joins a release already waiting for the file handle to close', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const release = registry.release(id) - let disposed = false - const dispose = registry.disposeAll().then(() => { - disposed = true - }) - - try { - await flushTasks() - expect(disposed).toBe(false) - expect(close).toHaveBeenCalledTimes(1) - expect(registry.isAborted(id)).toBe(true) - } finally { - pending.resolve() - await Promise.all([release, dispose]) - } - expect(disposed).toBe(true) - expect(registry.size()).toBe(0) - }) - - it('coalesces concurrent release calls until the single close settles', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const first = registry.release(id) - let secondSettled = false - const second = registry.release(id).then(() => { - secondSettled = true - }) - - try { - await flushTasks() - expect(close).toHaveBeenCalledTimes(1) - expect(secondSettled).toBe(false) - } finally { - pending.resolve() - await Promise.all([first, second]) - } - expect(registry.size()).toBe(0) - }) - - it('retains a failed close for a later disposal retry', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const id = registry.register(handle(close)) - - await expect(registry.release(id)).rejects.toBe(failure) - expect(registry.size()).toBe(1) - expect(registry.isAborted(id)).toBe(true) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(2) - expect(registry.size()).toBe(0) - }) - - it('waits for every close before rejecting disposal and retries only failed handles', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const failedClose = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const pending = deferred() - const pendingClose = vi.fn(() => pending.promise) - registry.register(handle(failedClose)) - registry.register(handle(pendingClose)) - let settled = false - const result = registry.disposeAll().then( - () => { - settled = true - return undefined - }, - (error: unknown) => { - settled = true - return error - } - ) - - try { - await flushTasks() - expect(settled).toBe(false) - expect(failedClose).toHaveBeenCalledTimes(1) - expect(pendingClose).toHaveBeenCalledTimes(1) - } finally { - pending.resolve() - } - const error = await result - expect(error).toBeInstanceOf(AggregateError) - expect(error).toMatchObject({ errors: [failure] }) - expect(registry.size()).toBe(1) - - await registry.disposeAll() - expect(failedClose).toHaveBeenCalledTimes(2) - expect(pendingClose).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) - - it('tolerates EBADF as an already-closed handle', async () => { - const registry = new RelayStreamRegistry() - const close = vi.fn().mockRejectedValue(Object.assign(new Error('closed'), { code: 'EBADF' })) - const id = registry.register(handle(close)) - - await registry.release(id) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) -}) diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts deleted file mode 100644 index 55e043d328c..00000000000 --- a/src/relay/fs-stream-registry.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { FileHandle } from 'node:fs/promises' -import { settlesWithin } from './settles-within' -import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from '../wsl-guest/protocol' -import { RelayStreamAckWindow } from './relay-stream-ack-window' - -type StreamEntry = { handle: FileHandle; ack: RelayStreamAckWindow } - -// Why: a stalled but connected client can hold a terminal-frame slot open indefinitely. -const OPERATION_SETTLE_DEADLINE_MS = 10_000 - -export class TooManyStreamsError extends Error { - readonly code = RelayErrorCode.TooManyStreams - constructor() { - super(`Too many concurrent streams (max ${MAX_CONCURRENT_STREAMS})`) - } -} - -export class RelayStreamRegistry { - private streams = new Map() - private nextId = 1 - private disposed = false - private closing = new Map>() - private closeFailures = new Map() - private operations = new Set>() - // Why: terminal frames ride the non-dropping control lane, which destroys the link at 256 frames / 1 MB. - // Holding a per-client slot until the frame settles caps queued terminal frames at - // MAX_CONCURRENT_STREAMS per client (the control queue is per client); each slot is a - // registry operation, so shutdown waits for undelivered terminal frames too. - private pendingTerminalFramesByClient = new Map() - - constructor(private readonly operationDeadlineMs = OPERATION_SETTLE_DEADLINE_MS) {} - - /** Tracks work that may still open or release a handle; disposeAll waits for it. */ - beginOperation(): () => void { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolve!: () => void - const pending = new Promise((settle) => { - resolve = settle - }) - this.operations.add(pending) - return () => { - this.operations.delete(pending) - resolve() - } - } - - /** Holds a per-client slot until a stream's terminal frame settles; see comment. */ - reserveTerminalFrameSlot(clientId: number): () => void { - const pending = this.pendingTerminalFramesByClient.get(clientId) ?? 0 - if (pending >= MAX_CONCURRENT_STREAMS) { - throw new TooManyStreamsError() - } - const finish = this.beginOperation() - this.pendingTerminalFramesByClient.set(clientId, pending + 1) - let released = false - return () => { - if (released) { - return - } - released = true - finish() - const remaining = (this.pendingTerminalFramesByClient.get(clientId) ?? 1) - 1 - // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. - if (remaining <= 0) { - this.pendingTerminalFramesByClient.delete(clientId) - return - } - this.pendingTerminalFramesByClient.set(clientId, remaining) - } - } - - register(handle: FileHandle): number { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } - if (this.streams.size >= MAX_CONCURRENT_STREAMS) { - throw new TooManyStreamsError() - } - return this.retainHandle(handle) - } - - /** Closes a handle opened outside a stream; a failed close stays retained for disposal retry. */ - releaseUnregisteredHandle(handle: FileHandle): Promise { - return this.release(this.retainHandle(handle)) - } - - private retainHandle(handle: FileHandle): number { - const streamId = this.nextId++ - this.streams.set(streamId, { handle, ack: new RelayStreamAckWindow() }) - return streamId - } - - abort(streamId: number): void { - this.streams.get(streamId)?.ack.abort() - } - - isAborted(streamId: number): boolean { - return this.streams.get(streamId)?.ack.aborted ?? true - } - - get(streamId: number): StreamEntry | undefined { - return this.streams.get(streamId) - } - - recordAck(streamId: number, seq: number): void { - this.streams.get(streamId)?.ack.recordAck(seq) - } - - ackedThroughSeq(streamId: number): number { - return this.streams.get(streamId)?.ack.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER - } - - /** Resolves on the next ack/abort/release for this stream, or after the - * stall-recheck interval so callers can re-evaluate staleness. */ - waitForAck(streamId: number): Promise { - return this.streams.get(streamId)?.ack.wait() ?? Promise.resolve() - } - - /** Wake every parked pump (all streams) so it re-checks staleness — used - * when a client detaches and its acks will never arrive. */ - wakeAllAckWaiters(): void { - for (const entry of this.streams.values()) { - entry.ack.wake() - } - } - - /** Concurrent callers share one close; only EBADF counts as already closed. */ - release(streamId: number): Promise { - const pending = this.closing.get(streamId) - if (pending) { - return pending - } - const entry = this.streams.get(streamId) - if (!entry) { - return Promise.resolve() - } - this.abort(streamId) - const close = Promise.resolve() - .then(() => entry.handle.close()) - .catch((error: unknown) => { - if (!isErrorWithCode(error, 'EBADF')) { - this.closeFailures.set(streamId, error) - throw error - } - }) - .then(() => { - this.streams.delete(streamId) - this.closeFailures.delete(streamId) - }) - .finally(() => { - this.closing.delete(streamId) - }) - this.closing.set(streamId, close) - return close - } - - size(): number { - return this.streams.size - } - - /** Fences new streams until {@link reopen}; rejects while any handle is unclosed so a retry can finish. */ - async disposeAll(): Promise { - this.disposed = true - // Why: flag every stream as aborted so any in-flight pump exits its loop - // cleanly on the next iteration boundary instead of seeing EBADF when - // release closes the handle out from under an in-flight read. - for (const id of this.streams.keys()) { - this.abort(id) - } - const ids = Array.from(this.streams.keys()) - const results = await Promise.allSettled(ids.map((id) => this.release(id))) - const settled = await settlesWithin(Promise.all(this.operations), this.operationDeadlineMs) - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (!settled) { - failures.push(new Error('relay_file_stream_operations_unsettled')) - } - if (failures.length > 0 || this.streams.size > 0) { - throw new AggregateError( - [...new Set([...failures, ...this.closeFailures.values()])], - 'relay_file_stream_shutdown_incomplete' - ) - } - } - - reopen(): void { - this.disposed = false - } -} - -function isErrorWithCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} diff --git a/src/relay/git-branch-delete-refusal-parity.test.ts b/src/relay/git-branch-delete-refusal-parity.test.ts deleted file mode 100644 index b71cb20d4f4..00000000000 --- a/src/relay/git-branch-delete-refusal-parity.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * The relay and the desktop each carried their own `getErrorText`, and they had - * drifted: the relay read `message` + `stderr` + `stdout`, the desktop only - * `message` + `stderr`. So a `git branch -d` refusal that arrived on `stdout` - * routed the SSH removal through prune-and-retry while the local removal gave up - * and preserved the branch. - * - * These tests push the same failure through both published removal entry points — - * `removeWorktreeOp` (what `git.removeWorktree` runs on the host) and `removeWorktree` - * (the local runner) — and require the same branch-deletion commands and the same - * `RemoveWorktreeResult`. A second error-text reader on either side fails here. - */ -import type * as FsPromises from 'node:fs/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { gitExecFileAsyncMock, resolveGitDirMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - resolveGitDirMock: vi.fn() -})) - -vi.mock('../main/git/runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock, - gitExecFileSync: vi.fn(), - translateWslOutputPaths: (output: string) => output -})) - -vi.mock('../main/git/status', () => ({ - resolveGitDir: resolveGitDirMock, - runWithGitReadCacheInvalidation: (run: () => Promise) => run() -})) - -vi.mock('fs/promises', async () => { - const actual = await vi.importActual('fs/promises') - return { - ...actual, - stat: vi.fn(async () => { - throw enoent() - }), - readFile: vi.fn() - } -}) - -import { GitCapabilityCache } from '../shared/git-capability-cache' -import type { RemoveWorktreeResult } from '../shared/worktree/create-types' -import { clearGitCapabilityStateForTests } from '../main/git/git-capability-state' -import { _resetWorktreeScanCacheForTests, removeWorktree } from '../main/git/worktree' -import { __resetSparseCheckoutStateCacheForTests } from '../main/git/worktree-sparse-checkout-cache' -import type { GitExec } from './git-handler-ops' -import { removeWorktreeOp } from './git-handler-worktree-ops' - -const REPO_PATH = '/repo' -const WORKTREE_PATH = '/repo-feature' -const BRANCH = 'feature/test' - -function enoent(): Error { - return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -} - -/** Only the branch-deletion phase; the two entry points legitimately reach it by different routes. */ -function branchDeletionCalls(calls: string[][]): string[] { - return calls - .map((args) => args.join(' ')) - .filter((call) => call.startsWith('branch ') || call === 'worktree prune') -} - -function worktreeListPorcelain(withFeature: boolean): string { - const blocks = [[`worktree ${REPO_PATH}`, 'HEAD abc123', 'branch refs/heads/main']] - if (withFeature) { - blocks.push([`worktree ${WORKTREE_PATH}`, 'HEAD def456', `branch refs/heads/${BRANCH}`]) - } - return `${blocks.map((block) => block.join('\n')).join('\n\n')}\n` -} - -type RefusalStream = 'stdout' | 'stderr' - -const REFUSAL_TEXT = `error: cannot delete branch '${BRANCH}' used by worktree at '/repo-stale'` - -/** - * A `branch -d` rejection carrying the refusal on exactly one stream. `message` stays - * generic so the assertion is about the stream, not about Node's stderr echo. - */ -function branchDeleteRefusal(stream: RefusalStream): Error { - return Object.assign(new Error('Command failed: git branch -d'), { - code: 1, - stdout: stream === 'stdout' ? REFUSAL_TEXT : '', - stderr: stream === 'stderr' ? REFUSAL_TEXT : '' - }) -} - -/** Refuses the first `branch -d`, accepts the retry that follows `worktree prune`. */ -function scriptRelayGit(stream: RefusalStream): { - git: GitExec - calls: string[][] -} { - const calls: string[][] = [] - let branchDeleteCount = 0 - const git = vi.fn(async (args) => { - calls.push(args) - if (args[0] === 'rev-parse') { - return { stdout: `${REPO_PATH}/.git\n`, stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeListPorcelain(true), stderr: '' } - } - if (args[0] === 'branch' && args[1] === '-d') { - branchDeleteCount += 1 - if (branchDeleteCount === 1) { - throw branchDeleteRefusal(stream) - } - return { stdout: '', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - return { git, calls } -} - -function scriptDesktopGit(stream: RefusalStream): string[][] { - const calls: string[][] = [] - let branchDeleteCount = 0 - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - calls.push(args) - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeListPorcelain(branchDeleteCount === 0), stderr: '' } - } - if (args[0] === 'branch' && args[1] === '-d') { - branchDeleteCount += 1 - if (branchDeleteCount === 1) { - throw branchDeleteRefusal(stream) - } - return { stdout: '', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - return calls -} - -async function removeOverRelay( - stream: RefusalStream -): Promise<{ result: RemoveWorktreeResult; branchCalls: string[] }> { - const { git, calls } = scriptRelayGit(stream) - const result = await removeWorktreeOp( - git, - { worktreePath: WORKTREE_PATH }, - new GitCapabilityCache() - ) - return { result, branchCalls: branchDeletionCalls(calls) } -} - -async function removeLocally( - stream: RefusalStream -): Promise<{ result: RemoveWorktreeResult; branchCalls: string[] }> { - const calls = scriptDesktopGit(stream) - const result = await removeWorktree(REPO_PATH, WORKTREE_PATH) - return { result, branchCalls: branchDeletionCalls(calls) } -} - -beforeEach(() => { - clearGitCapabilityStateForTests() - _resetWorktreeScanCacheForTests() - __resetSparseCheckoutStateCacheForTests() - gitExecFileAsyncMock.mockReset() - resolveGitDirMock.mockReset() - resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) -}) - -describe('relay/desktop branch-delete refusal parity', () => { - it('prunes and retries on both paths when the refusal arrives on stdout', async () => { - const relay = await removeOverRelay('stdout') - const local = await removeLocally('stdout') - - expect(relay.branchCalls).toEqual(local.branchCalls) - expect(relay.result).toEqual(local.result) - expect(local.branchCalls).toEqual([ - `branch -d -- ${BRANCH}`, - 'worktree prune', - `branch -d -- ${BRANCH}` - ]) - expect(local.result).toEqual({}) - }) - - it('prunes and retries on both paths when the refusal arrives on stderr, as real Git sends it', async () => { - const relay = await removeOverRelay('stderr') - const local = await removeLocally('stderr') - - expect(relay.branchCalls).toEqual(local.branchCalls) - expect(relay.result).toEqual(local.result) - expect(local.branchCalls).toEqual([ - `branch -d -- ${BRANCH}`, - 'worktree prune', - `branch -d -- ${BRANCH}` - ]) - }) -}) diff --git a/src/relay/git-buffer-overflow.ts b/src/relay/git-buffer-overflow.ts deleted file mode 100644 index 8f9a80cc812..00000000000 --- a/src/relay/git-buffer-overflow.ts +++ /dev/null @@ -1,23 +0,0 @@ -export function isGitBufferOverflowError(error: unknown): boolean { - return ( - !!error && - typeof error === 'object' && - (('code' in error && - (error.code === 'ENOBUFS' || error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')) || - ('message' in error && - typeof error.message === 'string' && - /^(?:(?:stdout|stderr) maxBuffer length exceeded|git (?:stdout|stderr|output) exceeded maxBuffer\.)$/.test( - error.message - ))) - ) -} - -export function isGitReadInterruptedError(error: unknown): boolean { - return ( - !!error && - typeof error === 'object' && - (('name' in error && error.name === 'AbortError') || - ('timedOut' in error && error.timedOut === true) || - ('killed' in error && error.killed === true && !isGitBufferOverflowError(error))) - ) -} diff --git a/src/relay/git-command-admission.test.ts b/src/relay/git-command-admission.test.ts deleted file mode 100644 index 91b6a712d8f..00000000000 --- a/src/relay/git-command-admission.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProcessSpec } from '@orca/process-host/process-spec' -import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' - -const { capture } = vi.hoisted(() => ({ capture: vi.fn() })) -vi.mock('@orca/process-host', () => ({ runProcess: capture })) - -import { - _resetRelayGitAdmissionForTests, - runGitToTermination -} from './git-handler-command-termination' - -describe('relay Git command ownership', () => { - let scheduler: GitAdmissionScheduler - const success = { code: 0, signal: null, stdout: 'result', stderr: '', timedOut: false } - - beforeEach(() => { - scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) - _resetRelayGitAdmissionForTests(scheduler) - capture.mockReset() - }) - afterEach(() => _resetRelayGitAdmissionForTests()) - - it('bounds reads while preserving explicit write and network timeout policy', async () => { - capture.mockImplementation(async (spec: ProcessSpec) => { - spec.onChildTerminated?.() - return success - }) - await runGitToTermination( - ['-c', 'core.quotePath=false', 'show', 'HEAD:file'], - { cwd: '/repo' }, - undefined - ) - await runGitToTermination(['fetch', 'origin'], { cwd: '/repo' }, undefined) - await runGitToTermination(['reset', '--quiet'], { cwd: '/repo', timeout: 800 }, undefined) - expect(capture.mock.calls.map(([spec]) => spec.timeoutMs)).toEqual([120_000, null, 800]) - expect(capture.mock.calls[0][0]).toMatchObject({ - terminationBarrier: true, - killOnOutputLimit: true - }) - }) - - it('cancels a queued read without spawning or releasing an active child', async () => { - let finish!: () => void - capture.mockImplementationOnce( - (spec: ProcessSpec) => - new Promise((resolve) => { - finish = () => { - spec.onChildTerminated?.() - resolve(success) - } - }) - ) - const active = runGitToTermination(['show', 'HEAD:file'], { cwd: '/repo' }, undefined) - await vi.waitFor(() => expect(capture).toHaveBeenCalledTimes(1)) - const controller = new AbortController() - const queued = runGitToTermination( - ['show', 'HEAD:other'], - { cwd: '/repo', signal: controller.signal }, - undefined - ) - const rejection = expect(queued).rejects.toMatchObject({ name: 'AbortError' }) - controller.abort() - await rejection - expect(capture).toHaveBeenCalledTimes(1) - expect(scheduler.snapshot()).toMatchObject({ queued: 0, budgets: { general: { baseUsed: 1 } } }) - finish() - await active - expect(scheduler.snapshot().budgets.general.baseUsed).toBe(0) - }) - - it('holds admission after a capture rejects until child termination is reported', async () => { - let reportTermination: (() => void) | undefined - capture.mockImplementationOnce(async (spec: ProcessSpec) => { - reportTermination = spec.onChildTerminated - throw new Error('capture failed before close') - }) - await expect(runGitToTermination(['status'], { cwd: '/repo' }, undefined)).rejects.toThrow( - 'before close' - ) - expect(scheduler.snapshot().budgets.general.baseUsed).toBe(1) - reportTermination?.() - expect(scheduler.snapshot().budgets.general.baseUsed).toBe(0) - }) - - it('rejects truncated zero-exit output instead of parsing an incomplete result', async () => { - capture.mockImplementationOnce(async (spec: ProcessSpec) => { - spec.onChildTerminated?.() - return { ...success, outputTruncated: true } - }) - await expect(runGitToTermination(['log'], { cwd: '/repo' }, undefined)).rejects.toMatchObject({ - code: 'ENOBUFS' - }) - }) - - it('allows truncated diagnostic tails without terminating a successful clone', async () => { - capture.mockImplementationOnce(async (spec: ProcessSpec, mode: string) => { - expect(mode).toBe('tail') - expect(spec).toMatchObject({ maxOutputBytes: 4096, killOnOutputLimit: false }) - spec.onChildTerminated?.() - return { ...success, outputTruncated: true } - }) - await expect( - runGitToTermination( - ['clone', '--progress', 'source', 'target'], - { cwd: '/repo', maxBuffer: 4096, outputCapture: 'tail' }, - undefined - ) - ).resolves.toEqual({ stdout: 'result', stderr: '' }) - expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) - }) - - it.each([ - { code: 128, timedOut: false, signal: null, message: 'fatal: repository unavailable' }, - { code: null, timedOut: true, signal: 'SIGTERM', message: 'git clone timed out.' } - ])('preserves a noisy clone failure or deadline: $message', async (failure) => { - capture.mockImplementationOnce(async (spec: ProcessSpec) => { - spec.onChildTerminated?.() - return { - ...success, - ...failure, - stderr: 'fatal: repository unavailable', - outputTruncated: true - } - }) - await expect( - runGitToTermination(['clone'], { cwd: '/repo', outputCapture: 'tail' }, undefined) - ).rejects.toMatchObject({ code: failure.code, message: failure.message }) - }) - - it('returns captured bytes without round-tripping through UTF-8', async () => { - const bytes = Buffer.from([0, 255, 254, 128, 65]) - capture.mockImplementationOnce(async (spec: ProcessSpec) => { - expect(spec.captureStdoutAsBytes).toBe(true) - spec.onChildTerminated?.() - return { ...success, stdout: '', stdoutBytes: bytes } - }) - const result = await runGitToTermination( - ['show', 'HEAD:file'], - { cwd: '/repo', captureStdoutAsBytes: true }, - undefined - ) - expect(result.stdoutBytes).toEqual(bytes) - }) -}) diff --git a/src/relay/git-diff-cancellation.test.ts b/src/relay/git-diff-cancellation.test.ts deleted file mode 100644 index f6dea13ec2a..00000000000 --- a/src/relay/git-diff-cancellation.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createGitHandlerRelay } from './git-handler-test-harness' -import type { GitHandlerOperationHost } from './git-handler-operation-context' - -describe('relay diff request cancellation', () => { - it('lets one client cancel without stopping another client sharing the same read', async () => { - const { handler, dispatcher } = createGitHandlerRelay() - let finish!: () => void - const ready = new Promise((resolve) => { - finish = () => resolve(Buffer.from('content\n')) - }) - const signals: AbortSignal[] = [] - const gitBuffer = vi.fn(async (_args, _cwd, options) => { - if (options?.signal) { - signals.push(options.signal) - } - return ready - }) - Object.assign(handler, { gitBuffer, git: async () => ({ stdout: '', stderr: '' }) }) - const params = { worktreePath: '/repo', filePath: 'file.txt', staged: true } - const first = new AbortController() - const second = new AbortController() - const canceled = dispatcher.callRequest('git.diff', params, { - isStale: () => false, - signal: first.signal - }) - const remaining = dispatcher.callRequest('git.diff', params, { - isStale: () => false, - signal: second.signal - }) - await vi.waitFor(() => expect(gitBuffer).toHaveBeenCalledTimes(2)) - const rejected = expect(canceled).rejects.toMatchObject({ name: 'AbortError' }) - first.abort() - await rejected - expect(signals.every((signal) => !signal.aborted)).toBe(true) - finish() - await expect(remaining).resolves.toMatchObject({ - originalContent: 'content\n', - modifiedContent: 'content\n' - }) - handler.dispose() - }) - - it('stops the shared subprocess reads when their last client cancels', async () => { - const { handler, dispatcher } = createGitHandlerRelay() - const signals: AbortSignal[] = [] - const gitBuffer = vi.fn(async (_args, _cwd, options) => { - const signal = options?.signal - if (!signal) { - throw new Error('Request cancellation was not passed to the blob read.') - } - signals.push(signal) - return new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - }) - Object.assign(handler, { gitBuffer, git: async () => ({ stdout: '', stderr: '' }) }) - const controller = new AbortController() - const pending = dispatcher.callRequest( - 'git.diff', - { worktreePath: '/repo', filePath: 'file.txt', staged: true }, - { - isStale: () => false, - signal: controller.signal - } - ) - await vi.waitFor(() => expect(gitBuffer).toHaveBeenCalledTimes(2)) - const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - controller.abort() - await rejected - expect(signals.every((signal) => signal.aborted)).toBe(true) - handler.dispose() - }) -}) diff --git a/src/relay/git-diff-result.ts b/src/relay/git-diff-result.ts deleted file mode 100644 index 726f8801d35..00000000000 --- a/src/relay/git-diff-result.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as path from 'node:path' -import { getLargeDiffRenderLimit } from '../shared/large-diff-render-limit' -import { PREVIEWABLE_MIME } from './git-handler-utils' - -export function buildDiffResult( - originalContent: string, - modifiedContent: string, - originalIsBinary: boolean, - modifiedIsBinary: boolean, - filePath?: string -) { - if (originalIsBinary || modifiedIsBinary) { - const ext = filePath ? path.extname(filePath).toLowerCase() : '' - const mimeType = PREVIEWABLE_MIME[ext] - return { - kind: 'binary' as const, - originalContent, - modifiedContent, - originalIsBinary, - modifiedIsBinary, - ...(mimeType ? { isImage: true, mimeType } : {}) - } - } - - const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent }) - if (largeDiffRenderLimit.limited) { - return { - kind: 'text' as const, - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false, - largeDiffRenderLimit - } - } - - return { - kind: 'text' as const, - originalContent, - modifiedContent, - originalIsBinary: false, - modifiedIsBinary: false - } -} diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts deleted file mode 100644 index 3f1dd0f2413..00000000000 --- a/src/relay/git-exec-validator.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { validateGitExecArgs } from './git-exec-validator' - -function expectAllowed(args: string[]): void { - expect(() => validateGitExecArgs(args)).not.toThrow() -} - -function expectBlocked(args: string[], message: string): void { - expect(() => validateGitExecArgs(args)).toThrow(message) -} - -describe('validateGitExecArgs', () => { - describe('allowed read-only subcommands', () => { - it.each([ - [['rev-parse', '--show-toplevel']], - [['branch', '--list']], - [['log', '--oneline', '-10']], - [['show-ref', '--heads']], - [['show-ref', '--verify', '--quiet', '--', 'refs/remotes/origin/main']], - [['ls-remote', 'origin']], - [['remote', '-v']], - [['remote', 'get-url', 'origin']], - [['remote', 'show', 'origin']], - [['symbolic-ref', 'HEAD']], - [['symbolic-ref', '--short', 'HEAD']], - [['merge-base', 'main', 'HEAD']], - [['diff', '--cached', '--name-status']], - [['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff']], - [['ls-files', '--error-unmatch', 'foo.txt']], - [['config', '--get', 'user.name']], - [['config', '--get-all', 'remote.origin.url']], - [['config', '--list']], - [['config', '-l']], - [['config', '--get-regexp', 'user']], - [['check-ref-format', '--branch', 'feature/ssh-pr-head']], - [['for-each-ref', '--format=%(refname)', 'refs/remotes']], - [ - [ - 'for-each-ref', - '--format=%(refname)%00%(refname:short)', - '--sort=-committerdate', - 'refs/heads/*foo*' - ] - ] - ])('allows %j', (args) => { - expectAllowed(args) - }) - }) - - describe('blocked subcommands', () => { - it('rejects empty args', () => { - expectBlocked([], 'git subcommand not allowed: (empty)') - }) - - it.each([ - 'push', - 'pull', - 'checkout', - 'reset', - 'rebase', - 'merge', - 'stash', - 'clean', - 'gc', - 'reflog', - 'tag', - 'fetch', - 'worktree' - ])('rejects %s', (cmd) => { - expectBlocked([cmd], 'git subcommand not allowed') - }) - }) - - describe('global flags before subcommand', () => { - it.each([ - [['-c', 'core.sshCommand=evil', 'log']], - [['--no-pager', 'log']], - [['-C', '/tmp', 'status']] - ])('rejects %j', (args) => { - expectBlocked(args, 'Global git flags before the subcommand are not allowed') - }) - }) - - describe('global denied flags', () => { - it.each([ - [['log', '--output', '/tmp/leak']], - [['log', '--output=/tmp/leak']], - [['log', '-o', '/tmp/leak']], - [['rev-parse', '--exec-path=/evil']], - [['log', '--work-tree=/other']], - [['log', '--git-dir=/other/.git']], - // Pin global-deny coverage on for-each-ref so a future allowlist - // refactor that bypassed GLOBAL_DENIED_FLAGS for this subcommand fails - // loudly. The first round of for-each-ref enablement omitted these. - [['for-each-ref', '--git-dir=/other/.git', '--format=%(refname)']], - [['for-each-ref', '--output=/tmp/leak', '--format=%(refname)']], - [['for-each-ref', '--work-tree=/other']] - ])('rejects %j', (args) => { - expectBlocked(args, 'Dangerous git flags are not allowed') - }) - - it('does not false-positive on unrelated =value flags', () => { - expectAllowed(['log', '--format=%H']) - expectAllowed(['log', '--pretty=oneline']) - }) - }) - - describe('git config', () => { - it('rejects config without read-only flag', () => { - expectBlocked(['config', 'user.name', 'Evil'], 'restricted to read-only operations') - }) - - it.each([ - ['--add'], - ['--unset'], - ['--unset-all'], - ['--replace-all'], - ['--rename-section'], - ['--remove-section'], - ['--edit'], - ['-e'], - ['--file=/etc/passwd'], - ['-f'], - ['--global'], - ['--system'] - ])('rejects config with write flag %s', (flag) => { - expectBlocked( - ['config', '--list', flag, 'val'], - 'git config write operations are not allowed' - ) - }) - }) - - describe('git branch', () => { - it('allows safe branch flags', () => { - expectAllowed(['branch', '--list']) - expectAllowed(['branch', '-a']) - expectAllowed(['branch', '-r']) - }) - - it.each(['-d', '-D', '--delete', '-m', '-M', '--move', '-c', '-C', '--copy'])( - 'rejects branch %s', - (flag) => { - expectBlocked(['branch', flag, 'name'], 'Destructive git branch flags') - } - ) - - it('catches --delete=value compound syntax', () => { - expectBlocked(['branch', '--delete=feature'], 'Destructive git branch flags') - }) - }) - - describe('git remote', () => { - it.each(['rm', 'rename', 'set-url', 'set-head', 'set-branches', 'prune', 'update'])( - 'rejects remote %s', - (subcmd) => { - expectBlocked(['remote', subcmd, 'arg'], 'Destructive git remote operations') - } - ) - - it('skips flags when finding remote subcommand', () => { - expectBlocked(['remote', '-v', 'add', 'evil', 'url'], 'Destructive git remote operations') - }) - - it('allows the fork-PR remote add and remove shapes', () => { - expectAllowed([ - 'remote', - 'add', - 'pr-contributor-orca', - 'https://github.com/contributor/orca.git' - ]) - expectAllowed(['remote', 'add', 'pr-contributor-orca', 'git@github.com:contributor/orca.git']) - expectAllowed(['remote', 'remove', 'pr-contributor-orca']) - }) - - it.each([ - // Extra or missing operands are not a shape main ever sends. - [['remote', 'add', 'fork']], - [['remote', 'add', 'fork', 'https://github.com/contributor/orca.git', '--tags']], - [['remote', 'remove']], - [['remote', 'remove', 'fork', 'extra']], - // Names and URLs must pass the same rules the pushTarget RPCs enforce. - [['remote', 'add', '--mirror=push', 'https://github.com/contributor/orca.git']], - [['remote', 'add', '../escape', 'https://github.com/contributor/orca.git']], - [['remote', 'remove', '-f']], - [['remote', 'add', 'fork', 'ext::sh -c payload']], - [['remote', 'add', 'fork', 'https://evil.test/contributor/orca.git']], - [['remote', 'add', 'fork', '/etc/passwd']] - ])('rejects unsafe remote write args %j', (args) => { - expectBlocked(args, 'Destructive git remote operations') - }) - }) - - describe('git symbolic-ref', () => { - it('allows read operations', () => { - expectAllowed(['symbolic-ref', 'HEAD']) - expectAllowed(['symbolic-ref', '--short', 'HEAD']) - expectAllowed(['symbolic-ref', '-q', 'HEAD']) - }) - - it.each(['-d', '--delete', '-m'])('rejects symbolic-ref %s', (flag) => { - expectBlocked(['symbolic-ref', flag, 'HEAD'], 'git symbolic-ref write operations') - }) - - it('rejects two positional args (write form)', () => { - expectBlocked( - ['symbolic-ref', 'HEAD', 'refs/heads/main'], - 'git symbolic-ref write operations' - ) - }) - - it('catches --delete=value compound syntax', () => { - expectBlocked(['symbolic-ref', '--delete=HEAD'], 'git symbolic-ref write operations') - }) - }) - - describe('git diff', () => { - it('rejects unstaged diff reads', () => { - expectBlocked(['diff', '--name-status'], 'restricted to staged changes') - }) - - it('rejects arbitrary refs and pathspecs', () => { - expectBlocked(['diff', '--cached', 'HEAD~1'], 'git diff flag not allowed') - expectBlocked(['diff', '--cached', '--', 'src/file.ts'], 'git diff flag not allowed') - }) - - it('rejects no-index reads', () => { - expectBlocked(['diff', '--cached', '--no-index', '/etc/passwd'], 'git diff flag not allowed') - }) - }) - - describe('git clone', () => { - it('allows only the project setup clone shape', () => { - expectAllowed(['clone', '--', 'https://github.com/stablyai/orca.git', 'orca']) - expectAllowed(['clone', '--progress', '--', 'git@github.com:stablyai/orca.git', 'orca']) - }) - - it.each([ - [['clone', 'https://github.com/stablyai/orca.git']], - [['clone', 'https://github.com/stablyai/orca.git', 'orca']], - [['clone', '--depth=1', '--', 'https://github.com/stablyai/orca.git', 'orca']], - [['clone', '--', 'https://github.com/stablyai/orca.git', '.']], - [['clone', '--', 'https://github.com/stablyai/orca.git', '..']], - [['clone', '--', 'https://github.com/stablyai/orca.git', 'nested/orca']], - [['clone', '--', 'https://github.com/stablyai/orca.git', 'nested\\orca']] - ])('rejects unsafe clone args %j', (args) => { - expectBlocked(args, 'git clone') - }) - }) - - describe('git init and empty commit', () => { - it('allows only the SSH create-project init and empty commit shapes', () => { - expectAllowed(['init']) - expectAllowed(['commit', '--allow-empty', '-m', 'Initial commit']) - }) - - it.each([ - [['init', '--bare']], - [['init', '/tmp/other']], - [['commit']], - [['commit', '-am', 'message']], - [['commit', '--allow-empty']], - [['commit', '--allow-empty', '-m', '']] - ])('rejects unsafe create-project write args %j', (args) => { - expectBlocked(args, 'via exec is restricted') - }) - }) -}) diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts deleted file mode 100644 index 9f9b5866ebc..00000000000 --- a/src/relay/git-exec-validator.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Git exec argument validation for the relay's git.exec handler. - * - * Why: oxlint max-lines requires files to stay under 300 lines. - * Extracted from git-handler-ops.ts to keep both files under the limit. - */ - -import { - isSafeGitRemoteName, - isSafePushTargetRemoteUrl -} from '../shared/git-push-target-validation' - -// Why: only read-only git subcommands are allowed via exec, except for the -// exact init/empty-commit shapes used by SSH Create Project after the parent -// directory has already been validated by main, and the exact fork-remote -// add/remove shapes used by SSH fork-PR worktrees. -const ALLOWED_GIT_SUBCOMMANDS = new Set([ - 'rev-parse', - 'branch', - 'log', - 'show-ref', - 'ls-remote', - 'remote', - 'symbolic-ref', - 'merge-base', - 'diff', - 'ls-files', - 'clone', - 'init', - 'commit', - 'for-each-ref', - 'check-ref-format', - 'config' -]) -const CONFIG_READ_ONLY_FLAGS = new Set(['--get', '--get-all', '--list', '--get-regexp', '-l']) -// Why: checking presence of a read-only flag is insufficient — a request could -// include both --list (passes the check) and --add (performs a write). Reject -// known write operations explicitly. -const CONFIG_WRITE_FLAGS = new Set([ - '--add', - '--unset', - '--unset-all', - '--replace-all', - '--rename-section', - '--remove-section', - '--edit', - '-e', - // Why: --file redirects config reads to an arbitrary file, enabling path - // traversal (e.g. `--file /etc/passwd --list` leaks file contents). - '--file', - '-f', - '--global', - '--system' -]) -const BRANCH_DESTRUCTIVE_FLAGS = new Set([ - '-d', - '-D', - '--delete', - '-m', - '-M', - '--move', - '-c', - '-C', - '--copy' -]) - -// Why: these flags are dangerous across ALL subcommands — --output writes to -// arbitrary paths, --exec-path changes where git loads helpers from, --work-tree -// and --git-dir escape the validated worktree. -const GLOBAL_DENIED_FLAGS = new Set(['--output', '-o', '--exec-path', '--work-tree', '--git-dir']) - -const REMOTE_WRITE_SUBCOMMANDS = new Set([ - 'add', - 'remove', - 'rm', - 'rename', - 'set-head', - 'set-branches', - 'set-url', - 'prune', - 'update' -]) -const SYMBOLIC_REF_WRITE_FLAGS = new Set(['-d', '--delete', '-m']) -const DIFF_ALLOWED_FLAGS = new Set([ - '--cached', - '--staged', - '--name-status', - '--patch', - '--minimal', - '--no-color', - '--no-ext-diff' -]) - -// Why: fork-PR worktrees on an SSH host must add the contributor's fork as a -// remote (and drop it again when the last worktree using it is removed). Allow -// only those two exact shapes, held to the same remote-name and URL rules the -// relay already enforces on every pushTarget-carrying RPC. Everything else -- -// set-url, rename, prune, flags before the action -- stays blocked. -function isAllowedRemoteWriteInvocation(args: string[]): boolean { - if (args[1] === 'add') { - return args.length === 4 && isSafeGitRemoteName(args[2]) && isSafePushTargetRemoteUrl(args[3]) - } - if (args[1] === 'remove') { - return args.length === 3 && isSafeGitRemoteName(args[2]) - } - return false -} - -function validateCloneArgs(args: string[]): void { - // Why: project-host setup needs remote clone, but git.exec must not become a - // general write surface. Permit only `git clone [--progress] -- `. - const allowed = args[1] === '--progress' ? args.slice(2) : args.slice(1) - if (allowed.length !== 3 || allowed[0] !== '--') { - throw new Error('git clone via exec is restricted to clone [--progress] -- ') - } - const targetDir = allowed[2] - if ( - !targetDir || - targetDir === '.' || - targetDir === '..' || - targetDir.includes('/') || - targetDir.includes('\\') || - targetDir.includes('\0') - ) { - throw new Error('git clone target directory must be a single safe path segment') - } -} - -function validateInitArgs(args: string[]): void { - if (args.length !== 1) { - throw new Error('git init via exec is restricted to init with no arguments') - } -} - -function validateCommitArgs(args: string[]): void { - if (args.length !== 4 || args[1] !== '--allow-empty' || args[2] !== '-m' || !args[3]) { - throw new Error('git commit via exec is restricted to commit --allow-empty -m ') - } -} - -// Why: git accepts --flag=value compound syntax (e.g. --file=/etc/passwd), -// which bypasses exact-match Set.has() checks. This helper catches both forms. -function matchesDeniedFlag(arg: string, denySet: Set): boolean { - if (denySet.has(arg)) { - return true - } - const eqIdx = arg.indexOf('=') - if (eqIdx > 0) { - return denySet.has(arg.slice(0, eqIdx)) - } - return false -} - -export function validateGitExecArgs(args: string[]): void { - // Why: git accepts `-c key=value` before the subcommand, which can override - // config and execute arbitrary commands (e.g. core.sshCommand). Reject any - // arguments before the subcommand that look like global git flags. - let subcommandIdx = 0 - while (subcommandIdx < args.length && args[subcommandIdx].startsWith('-')) { - subcommandIdx++ - } - if (subcommandIdx > 0) { - throw new Error('Global git flags before the subcommand are not allowed') - } - - const subcommand = args[0] - if (!subcommand || !ALLOWED_GIT_SUBCOMMANDS.has(subcommand)) { - throw new Error(`git subcommand not allowed: ${subcommand ?? '(empty)'}`) - } - const restArgs = args.slice(1) - - if (restArgs.some((a) => matchesDeniedFlag(a, GLOBAL_DENIED_FLAGS))) { - throw new Error('Dangerous git flags are not allowed via exec') - } - - if (subcommand === 'config') { - if (!restArgs.some((a) => CONFIG_READ_ONLY_FLAGS.has(a))) { - throw new Error('git config is restricted to read-only operations (--get, --list, etc.)') - } - if (restArgs.some((a) => matchesDeniedFlag(a, CONFIG_WRITE_FLAGS))) { - throw new Error('git config write operations are not allowed via exec') - } - } - if (subcommand === 'init') { - validateInitArgs(args) - } - if (subcommand === 'commit') { - validateCommitArgs(args) - } - if (subcommand === 'branch') { - if (restArgs.some((a) => matchesDeniedFlag(a, BRANCH_DESTRUCTIVE_FLAGS))) { - throw new Error('Destructive git branch flags are not allowed via exec') - } - } - if (subcommand === 'remote') { - const remoteSubcmd = restArgs.find((a) => !a.startsWith('-')) - if ( - remoteSubcmd && - REMOTE_WRITE_SUBCOMMANDS.has(remoteSubcmd) && - !isAllowedRemoteWriteInvocation(args) - ) { - throw new Error('Destructive git remote operations are not allowed via exec') - } - } - if (subcommand === 'symbolic-ref') { - if (restArgs.some((a) => matchesDeniedFlag(a, SYMBOLIC_REF_WRITE_FLAGS))) { - throw new Error('git symbolic-ref write operations are not allowed via exec') - } - const positionalArgs = restArgs.filter((a) => !a.startsWith('-')) - if (positionalArgs.length >= 2) { - throw new Error('git symbolic-ref write operations are not allowed via exec') - } - } - if (subcommand === 'diff') { - // Why: SSH commit-message generation only needs read-only staged diffs. - // Keep this narrow so `git.exec` cannot become a general file reader via - // arbitrary revisions, pathspecs, or --no-index. - if (!restArgs.some((a) => a === '--cached' || a === '--staged')) { - throw new Error('git diff via exec is restricted to staged changes') - } - const unsupportedArg = restArgs.find((a) => !DIFF_ALLOWED_FLAGS.has(a)) - if (unsupportedArg) { - throw new Error(`git diff flag not allowed via exec: ${unsupportedArg}`) - } - } - if (subcommand === 'clone') { - validateCloneArgs(args) - } -} diff --git a/src/relay/git-handler-blob-readers.test.ts b/src/relay/git-handler-blob-readers.test.ts deleted file mode 100644 index 960106b21a2..00000000000 --- a/src/relay/git-handler-blob-readers.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - readBlobAtIndex, - readBlobAtOid, - readUnstagedLeft, - type GitBufferExec -} from './git-handler-ops' - -describe('git blob readers', () => { - it('normalizes Windows separators before reading OID blobs', async () => { - const gitBuffer = vi.fn().mockResolvedValue(Buffer.from('head-content')) - - const result = await readBlobAtOid(gitBuffer, '/repo', 'HEAD', 'src\\file.ts') - - expect(gitBuffer).toHaveBeenCalledWith( - ['show', '--end-of-options', 'HEAD:src/file.ts'], - '/repo' - ) - expect(result.content).toBe('head-content') - }) - - it('marks OID blobs that overflow maxBuffer as binary', async () => { - const gitBuffer = vi.fn().mockRejectedValue( - Object.assign(new Error('stdout maxBuffer length exceeded'), { - code: 'ENOBUFS', - killed: true, - signal: 'SIGTERM' - }) - ) - - const result = await readBlobAtOid(gitBuffer, '/repo', 'HEAD', 'large.log') - - expect(result).toEqual({ content: '', isBinary: true }) - }) - - it('normalizes Windows separators before reading index blobs', async () => { - const gitBuffer = vi.fn().mockResolvedValue(Buffer.from('index-content')) - - const result = await readBlobAtIndex(gitBuffer, '/repo', 'src\\file.ts') - - expect(gitBuffer).toHaveBeenCalledWith(['show', '--end-of-options', ':src/file.ts'], '/repo') - expect(result.content).toBe('index-content') - }) - - it('marks index blobs that overflow maxBuffer as binary', async () => { - const gitBuffer = vi.fn().mockRejectedValue( - Object.assign(new Error('git stdout exceeded maxBuffer.'), { - code: 'ENOBUFS', - killed: true, - signal: 'SIGTERM' - }) - ) - - const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log') - - // Why: overflow is size-capped content, not a staged deletion (missing: false). - expect(result).toEqual({ content: '', isBinary: true, missing: false }) - }) - - it('does not spawn a HEAD read for a successfully read empty index blob', async () => { - const gitBuffer = vi.fn().mockResolvedValue(Buffer.alloc(0)) - - const result = await readUnstagedLeft(gitBuffer, '/repo', 'empty.txt') - - expect(result.content).toBe('') - expect(gitBuffer).toHaveBeenCalledExactlyOnceWith( - ['show', '--end-of-options', ':empty.txt'], - '/repo' - ) - }) -}) diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts deleted file mode 100644 index d1ab16bb6b4..00000000000 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ /dev/null @@ -1,446 +0,0 @@ -// Ref mutation tests use fictitious repositories; administrative reservations have real-Git coverage. -vi.mock('../shared/git-worktree-admin', () => ({ - isBranchReservedByWorktreeOperation: vi.fn().mockResolvedValue(false) -})) - -import { describe, expect, it, vi } from 'vitest' -import * as path from 'node:path' -import { GitCapabilityCache } from '../shared/git-capability-cache' -import type { GitExec } from './git-handler-ops' -import { removeWorktreeOp } from './git-handler-worktree-ops' -import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' - -function removeWorktreeWithCapabilityCache( - git: GitExec, - params: Parameters[1] -) { - return removeWorktreeOp(git, params, new GitCapabilityCache()) -} - -function worktreeList(...entries: { path: string; branch?: string }[]): string { - return entries - .map((entry, index) => - [ - `worktree ${entry.path}`, - `HEAD ${index}`, - ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []) - ].join('\n') - ) - .join('\n\n') -} - -function resolvedRepoPath(): string { - return path.posix.resolve('/repo-feature', '/repo/.git', '..') -} - -describe('forceDeletePreservedRelayBranch', () => { - it('deletes a preserved branch at the expected head', async () => { - const calls: string[][] = [] - const git = vi.fn(async (args) => { - calls.push(args) - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await expect( - forceDeletePreservedRelayBranch(git, '/repo', 'feature/test', 'abc123') - ).resolves.toBeUndefined() - - expect(calls).toEqual([ - ['worktree', 'list', '--porcelain'], - ['update-ref', '-d', 'refs/heads/feature/test', 'abc123'], - ['worktree', 'list', '--porcelain'], - ['config', '--remove-section', 'branch.feature/test'] - ]) - }) - - it('maps only ref-moved update-ref delete failures to the preserved-branch message', async () => { - const git = vi.fn(async (args) => { - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } - } - if (args[0] === 'update-ref' && args[1] === '-d') { - throw new Error('cannot lock ref') - } - return { stdout: '', stderr: '' } - }) - - await expect( - forceDeletePreservedRelayBranch(git, '/repo', 'feature/test', 'abc123') - ).rejects.toThrow( - 'Local branch "feature/test" changed after the workspace was deleted. Review it before deleting it.' - ) - expect(git).not.toHaveBeenCalledWith( - ['config', '--remove-section', 'branch.feature/test'], - '/repo' - ) - }) - - it('keeps the checked-out message when the branch is checked out before delete', async () => { - const git = vi.fn(async (args) => { - if (args[0] === 'worktree' && args[1] === 'list') { - return { - stdout: worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ), - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await expect( - forceDeletePreservedRelayBranch(git, '/repo', 'feature/test', 'abc123') - ).rejects.toThrow('Local branch "feature/test" is checked out in another worktree.') - expect(git).not.toHaveBeenCalledWith( - ['update-ref', '-d', 'refs/heads/feature/test', 'abc123'], - '/repo' - ) - }) - - it('restores the ref and keeps the checked-out message after a concurrent checkout', async () => { - let listCount = 0 - const git = vi.fn(async (args) => { - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? worktreeList({ path: '/repo', branch: 'main' }) - : worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ), - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await expect( - forceDeletePreservedRelayBranch(git, '/repo', 'feature/test', 'abc123') - ).rejects.toThrow('Local branch "feature/test" is checked out in another worktree.') - expect(git).toHaveBeenCalledWith( - ['update-ref', '-d', 'refs/heads/feature/test', 'abc123'], - '/repo' - ) - expect(git).toHaveBeenCalledWith( - ['update-ref', 'refs/heads/feature/test', 'abc123', ''], - '/repo' - ) - }) - - it('swallows branch config removal failures after deleting the ref', async () => { - const git = vi.fn(async (args) => { - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } - } - if (args[0] === 'config' && args[1] === '--remove-section') { - throw new Error('missing section') - } - return { stdout: '', stderr: '' } - }) - - await expect( - forceDeletePreservedRelayBranch(git, '/repo', 'feature/test', 'abc123') - ).resolves.toBeUndefined() - }) - - it.each([ - ['empty branch name', '', 'abc123'], - ['empty expected head', 'feature/test', ''], - ['leading dash branch name', '-feature', 'abc123'], - ['NUL branch name', 'feature\0test', 'abc123'] - ])('rejects invalid input: %s', async (_label, branchName, expectedHead) => { - const git = vi.fn() - - await expect( - forceDeletePreservedRelayBranch(git, '/repo', branchName, expectedHead) - ).rejects.toThrow() - expect(git).not.toHaveBeenCalled() - }) -}) - -describe('removeWorktreeOp branch cleanup', () => { - it('deletes a squash-merged SSH branch when merging it into the base is a no-op', async () => { - let zListCount = 0 - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) { - zListCount += 1 - return { - stdout: - zListCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } - } - if (args[0] === 'branch' && args[1] === '-d') { - throw new Error('error: the branch feature/test is not fully merged') - } - if (args[0] === 'config' && args[1] === '--get') { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { - return { stdout: 'base123\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD^{commit}')) { - return { stdout: 'base123\n', stderr: '' } - } - if (args[0] === 'merge-tree') { - return { stdout: 'tree123\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('base123^{tree}')) { - return { stdout: 'tree123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).resolves.toEqual({}) - - expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) - expect(git).toHaveBeenCalledWith( - ['merge-tree', '--write-tree', 'base123', 'refs/heads/feature/test'], - expect.any(String) - ) - expect(git).toHaveBeenCalledWith( - ['update-ref', '-d', 'refs/heads/feature/test', '1'], - expect.any(String) - ) - expect(git).toHaveBeenCalledWith( - ['config', '--remove-section', 'branch.feature/test'], - expect.any(String) - ) - expect(git).not.toHaveBeenCalledWith(['remote'], expect.any(String)) - }) - - it('deletes a squash-merged SSH branch with branch-only merge commits via expected head', async () => { - let zListCount = 0 - const git = vi.fn(async (args, _cwd, opts) => { - if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) { - zListCount += 1 - return { - stdout: - zListCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } - } - if (args[0] === 'branch' && args[1] === '-d') { - throw new Error('error: the branch feature/test is not fully merged') - } - if (args[0] === 'config' && args[1] === '--get') { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { - return { stdout: 'target123\n', stderr: '' } - } - if (args[0] === 'merge-tree') { - return { - stdout: args[2] === 'squash123' ? 'squash-tree\n' : 'merged-tree\n', - stderr: '' - } - } - if (args[0] === 'rev-parse' && args.includes('target123^{tree}')) { - return { stdout: 'target-tree\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('squash123^{tree}')) { - return { stdout: 'squash-tree\n', stderr: '' } - } - if (args[0] === 'rev-list' && args.includes('--right-only')) { - return { stdout: '1\n', stderr: '' } - } - if (args[0] === 'merge-base') { - return { stdout: 'base123\n', stderr: '' } - } - if (args[0] === 'diff') { - return { stdout: 'branch net diff\n', stderr: '' } - } - if (args[0] === 'rev-list' && args.includes('--ancestry-path')) { - return { stdout: 'squash123\n', stderr: '' } - } - if (args[0] === 'show') { - return { stdout: 'squash diff\n', stderr: '' } - } - if (args[0] === 'patch-id' && opts?.stdin === 'branch net diff\n') { - return { - stdout: 'patch123 0000000000000000000000000000000000000000\n', - stderr: '' - } - } - if (args[0] === 'patch-id' && opts?.stdin === 'squash diff\n') { - return { stdout: 'patch123 squash123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).resolves.toEqual({}) - - expect(git).toHaveBeenCalledWith( - ['update-ref', '-d', 'refs/heads/feature/test', '1'], - expect.any(String) - ) - expect(git).toHaveBeenCalledWith(['patch-id', '--stable'], expect.any(String), { - stdin: 'branch net diff\n' - }) - expect(git).toHaveBeenCalledWith(['patch-id', '--stable'], expect.any(String), { - stdin: 'squash diff\n' - }) - }) - - it('refreshes the saved remote base before deleting a safe-delete-rejected SSH branch', async () => { - const calls: { args: string[]; cwd: string }[] = [] - let zListCount = 0 - const git = vi.fn(async (args, cwd) => { - calls.push({ args, cwd }) - if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) { - zListCount += 1 - return { - stdout: - zListCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } - } - if (args[0] === 'branch' && args[1] === '-d') { - throw new Error('error: the branch feature/test is not fully merged') - } - if (args[0] === 'config' && args[1] === '--get') { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'fetch') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { - return { stdout: 'base123\n', stderr: '' } - } - if (args[0] === 'merge-tree') { - return { stdout: 'tree123\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('base123^{tree}')) { - return { stdout: 'tree123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).resolves.toEqual({}) - - const commandIndex = (expectedArgs: string[]) => - calls.findIndex(({ args }) => JSON.stringify(args) === JSON.stringify(expectedArgs)) - const fetchIndex = commandIndex(['fetch', '--prune', 'origin']) - const mergeTreeArgs = ['merge-tree', '--write-tree', 'base123', 'refs/heads/feature/test'] - const mergeTreeIndexes = calls.flatMap(({ args }, index) => - JSON.stringify(args) === JSON.stringify(mergeTreeArgs) ? [index] : [] - ) - const updateRefIndex = commandIndex(['update-ref', '-d', 'refs/heads/feature/test', '1']) - - expect(fetchIndex).toBeGreaterThanOrEqual(0) - expect(calls[fetchIndex]?.cwd).toBe(resolvedRepoPath()) - expect(mergeTreeIndexes).toHaveLength(1) - expect(fetchIndex).toBeLessThan(mergeTreeIndexes[0]) - expect(mergeTreeIndexes[0]).toBeLessThan(updateRefIndex) - expect(fetchIndex).toBeLessThan(updateRefIndex) - }) - - it('preserves an already-merged SSH branch when cleanup races after worktree removal', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let zListCount = 0 - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) { - zListCount += 1 - return { - stdout: - zListCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } - } - if (args[0] === 'branch' && args[1] === '-d') { - throw new Error('error: the branch feature/test is not fully merged') - } - if (args[0] === 'config' && args[1] === '--get') { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { - return { stdout: 'base123\n', stderr: '' } - } - if (args[0] === 'rev-list') { - return { stdout: '0\n', stderr: '' } - } - if (args[0] === 'cherry') { - return { stdout: '- 1 fix: already squash-merged\n', stderr: '' } - } - if (args[0] === 'update-ref' && args[1] === '-d') { - throw new Error('cannot lock ref') - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).resolves.toEqual({ - preservedBranch: { branchName: 'feature/test', head: '1' } - }) - - expect(warnSpy).toHaveBeenCalledWith( - 'relay removeWorktree: failed to delete already-merged local branch "feature/test" after removing worktree', - expect.objectContaining({ message: 'cannot lock ref' }) - ) - expect(warnSpy).toHaveBeenCalledWith( - 'relay removeWorktree: preserved local branch "feature/test" after removing worktree (not fully merged)', - expect.any(Error) - ) - warnSpy.mockRestore() - }) -}) diff --git a/src/relay/git-handler-branch-cleanup.ts b/src/relay/git-handler-branch-cleanup.ts deleted file mode 100644 index 759269aba1d..00000000000 --- a/src/relay/git-handler-branch-cleanup.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - branchHasNoUnmergedChangesWithLazyTargetRefresh, - getBranchCleanupTargetRefs -} from '../shared/git-branch-cleanup' -import type { GitCapabilityCache } from '../shared/git-capability-cache' -import type { GitExec } from './git-handler-ops' -import { expandTilde } from './context' -import { isBranchReservedByWorktreeOperation } from '../shared/git-worktree-admin' -import { parseWorktreeList } from '../shared/git-worktree-porcelain-parser' - -export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( - git: GitExec, - repoPath: string, - branchName: string, - branchHead: string, - capabilities: GitCapabilityCache -): Promise { - const runGit = (args: string[], options?: { stdin?: string }) => - options ? git(args, repoPath, options) : git(args, repoPath) - const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) - // Why: SSH worktrees hit the same squash-merge shape as local worktrees. - // Git's no-op merge proof lets us clean up only branches whose changes - // already exist on the saved base ref. - if ( - !(await branchHasNoUnmergedChangesWithLazyTargetRefresh( - runGit, - branchName, - targetRefs, - capabilities - )) - ) { - return false - } - await deleteRelayBranchAtExpectedHead(git, repoPath, branchName, branchHead) - return true -} - -export async function forceDeletePreservedRelayBranch( - git: GitExec, - repoPath: string, - branchName: string, - expectedHead: string -): Promise { - if (!branchName || branchName.includes('\0') || branchName.startsWith('-')) { - throw new Error('Invalid branch name for preserved branch delete.') - } - if (!expectedHead) { - throw new Error('Expected branch head is required for preserved branch delete.') - } - await deleteRelayBranchAtExpectedHead(git, repoPath, branchName, expectedHead, () => { - return new Error( - `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` - ) - }) -} - -async function deleteRelayBranchAtExpectedHead( - git: GitExec, - repoPath: string, - branchName: string, - expectedHead: string, - mapUpdateRefError?: (error: unknown) => Error -): Promise { - if (await isRelayBranchCheckedOut(git, repoPath, branchName)) { - throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) - } - try { - await git(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) - } catch (error) { - // Why: only stale ref writes get the force-delete message; checkout guards - // and removeWorktree cleanup still rely on their distinct/raw failures. - throw mapUpdateRefError?.(error) ?? error - } - try { - if (await isRelayBranchCheckedOut(git, repoPath, branchName)) { - throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) - } - } catch (error) { - try { - await git(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath) - } catch (restoreError) { - console.warn( - `relay removeWorktree: failed to restore local branch "${branchName}" after concurrent checkout`, - restoreError - ) - } - throw error - } - try { - await git(['config', '--remove-section', `branch.${branchName}`], repoPath) - } catch { - // Best-effort parity with `git branch -D`; stale config is harmless after - // the expected ref was deleted. - } -} - -async function isRelayBranchCheckedOut( - git: GitExec, - repoPath: string, - branchName: string -): Promise { - const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) - const worktrees = parseWorktreeList(stdout) - return ( - worktrees.some( - (worktree) => - typeof worktree.branch === 'string' && - worktree.branch.replace(/^refs\/heads\//, '') === branchName - ) || isBranchReservedByWorktreeOperation(expandTilde(repoPath), branchName, worktrees) - ) -} diff --git a/src/relay/git-handler-branch-compare.test.ts b/src/relay/git-handler-branch-compare.test.ts deleted file mode 100644 index 98c61c2928d..00000000000 --- a/src/relay/git-handler-branch-compare.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { branchCompare, type GitExec } from './git-handler-ops' - -function deferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve } -} - -describe('relay branchCompare', () => { - it.each([40, 64])( - 'skips change and count reads for identical %i-character commit tips', - async (length) => { - const oid = 'a'.repeat(length) - const git = vi.fn(async (args) => { - if (args[0] === 'branch') { - return { stdout: 'feature\n', stderr: '' } - } - if (['rev-parse', 'merge-base'].includes(args[0] ?? '')) { - return { stdout: `${oid}\n`, stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - const loadBranchChanges = vi.fn(async () => []) - - await expect(branchCompare(git, '/repo', 'main', loadBranchChanges)).resolves.toEqual({ - summary: { - baseRef: 'main', - baseOid: oid, - compareRef: 'feature', - headOid: oid, - mergeBase: oid, - changedFiles: 0, - commitsAhead: 0, - commitsBehind: 0, - status: 'ready' - }, - entries: [] - }) - expect(git.mock.calls.some(([args]) => args[0] === 'merge-base')).toBe(true) - expect(loadBranchChanges).not.toHaveBeenCalled() - expect(git.mock.calls.some(([args]) => args[0] === 'rev-list')).toBe(false) - } - ) - - it('launches independent Git reads before waiting for any result', async () => { - const branch = deferred<{ stdout: string; stderr: string }>() - const head = deferred<{ stdout: string; stderr: string }>() - const base = deferred<{ stdout: string; stderr: string }>() - const changes = deferred[]>() - const git = vi.fn((args) => { - if (args[0] === 'branch') { - return branch.promise - } - if (args[0] === 'rev-parse' && args.includes('HEAD')) { - return head.promise - } - if (args[0] === 'rev-parse') { - return base.promise - } - if (args[0] === 'merge-base') { - return Promise.resolve({ stdout: 'merge-base\n', stderr: '' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '2\t1\n', stderr: '' }) - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - const loadBranchChanges = vi.fn(() => changes.promise) - - const pending = branchCompare(git, '/repo', 'origin/main', loadBranchChanges) - await Promise.resolve() - - expect(git.mock.calls.map(([args]) => args.join(' '))).toEqual([ - 'branch --show-current', - 'rev-parse --verify HEAD', - 'rev-parse --verify origin/main' - ]) - - branch.resolve({ stdout: 'feature\n', stderr: '' }) - head.resolve({ stdout: 'head\n', stderr: '' }) - base.resolve({ stdout: 'base\n', stderr: '' }) - await vi.waitFor(() => expect(loadBranchChanges).toHaveBeenCalledOnce()) - expect(git.mock.calls.some(([args]) => args[0] === 'rev-list')).toBe(true) - changes.resolve([{ path: 'file.ts' }]) - - await expect(pending).resolves.toMatchObject({ - summary: { - compareRef: 'feature', - changedFiles: 1, - commitsAhead: 1, - commitsBehind: 2, - status: 'ready' - } - }) - }) -}) diff --git a/src/relay/git-handler-branch-diff-equivalence.test.ts b/src/relay/git-handler-branch-diff-equivalence.test.ts deleted file mode 100644 index ff399d92a7c..00000000000 --- a/src/relay/git-handler-branch-diff-equivalence.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { - createMockDispatcher, - gitCommit, - gitInit, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -// Why this file exists: the pinned route replaced the legacy route's own -// `diff --name-status -M -C` rediscovery with values the caller already holds. -// These tests drive the real product contract against real Git and assert the -// two routes agree entry-for-entry, so "SSH still renders what it used to" is -// evidence rather than an argument about call sites. - -type BranchCompareResult = { - summary: { mergeBase: string; headOid: string; status: string; changedFiles: number } - entries: { path: string; oldPath?: string; status: string }[] -} - -type DiffEntry = Record - -function git(repoPath: string, args: string[]): string { - return execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) -} - -/** - * Builds one repo whose base..head range exercises every shape the review - * panel can hand to a single-file branch diff. - */ -function buildScenarioRepo(): { repoPath: string; baseOid: string } { - const repoPath = mkdtempSync(path.join(tmpdir(), 'relay-branch-diff-equivalence-')) - gitInit(repoPath) - - const write = (relativePath: string, contents: string | Buffer): void => { - const target = path.join(repoPath, relativePath) - mkdirSync(path.dirname(target), { recursive: true }) - writeFileSync(target, contents) - } - - write('modified.txt', 'before\n') - write('deleted.txt', 'doomed\n') - write('renamed-from.txt', 'stable rename payload\n') - write('renamed-and-edited-from.txt', 'rename plus edit, line one\nline two\nline three\n') - write('copied-source.txt', 'copy me, line one\nline two\nline three\nline four\n') - write('nested/deep/inner.txt', 'nested before\n') - write('spaced name.txt', 'spaced before\n') - write('ünïcode-ページ.txt', 'unicode before\n') - write('binary-modified.bin', Buffer.from([0, 1, 2, 3, 0, 255])) - write('binary-deleted.bin', Buffer.from([9, 8, 7, 0])) - write('emptied.txt', 'about to be emptied\n') - write('crlf.txt', 'crlf before\r\nsecond line\r\n') - write('mode-changed.sh', '#!/bin/sh\necho hi\n') - gitCommit(repoPath, 'base') - const baseOid = git(repoPath, ['rev-parse', 'HEAD']).trim() - - write('modified.txt', 'after\n') - rmSync(path.join(repoPath, 'deleted.txt')) - rmSync(path.join(repoPath, 'binary-deleted.bin')) - git(repoPath, ['mv', 'renamed-from.txt', 'renamed-to.txt']) - git(repoPath, ['mv', 'renamed-and-edited-from.txt', 'renamed-and-edited-to.txt']) - write('renamed-and-edited-to.txt', 'rename plus edit, line one\nline two CHANGED\nline three\n') - write('copied-target.txt', 'copy me, line one\nline two\nline three\nline four\n') - write('added.txt', 'brand new\n') - write('binary-added.bin', Buffer.from([4, 4, 0, 4])) - write('binary-modified.bin', Buffer.from([0, 1, 2, 3, 0, 254])) - write('nested/deep/inner.txt', 'nested after\n') - write('spaced name.txt', 'spaced after\n') - write('ünïcode-ページ.txt', 'unicode after\n') - write('emptied.txt', '') - write('crlf.txt', 'crlf after\r\nsecond line\r\n') - git(repoPath, ['update-index', '--chmod=+x', 'mode-changed.sh']) - gitCommit(repoPath, 'head') - - return { repoPath, baseOid } -} - -describe('pinned and legacy branch diff equivalence against real Git', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - let repoPath = '' - let baseOid = '' - - beforeEach(() => { - dispatcher = createMockDispatcher() - handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - const built = buildScenarioRepo() - repoPath = built.repoPath - baseOid = built.baseOid - }) - - afterEach(() => { - handler.dispose() - if (repoPath) { - rmSync(repoPath, { recursive: true, force: true }) - } - }) - - async function branchCompare(): Promise { - return (await dispatcher.callRequest('git.branchCompare', { - worktreePath: repoPath, - baseRef: baseOid - })) as BranchCompareResult - } - - async function branchDiff(params: Record): Promise { - return (await dispatcher.callRequest('git.branchDiff', { - worktreePath: repoPath, - baseRef: baseOid, - includePatch: true, - ...params - })) as DiffEntry[] - } - - it('produces identical results for every changed file the review panel can open', async () => { - const compare = await branchCompare() - expect(compare.summary.status).toBe('ready') - // Guard the guard: a truncated scenario set would make this test vacuously pass. - expect(compare.entries.length).toBeGreaterThanOrEqual(14) - - const divergences: string[] = [] - for (const entry of compare.entries) { - // Exactly what the renderer sends: paths from the compare entry list, - // OIDs from the compare summary that produced that same list. - const callerParams = { filePath: entry.path, oldPath: entry.oldPath } - const legacy = await branchDiff(callerParams) - const pinned = await branchDiff({ - ...callerParams, - baseRef: compare.summary.mergeBase, - headOid: compare.summary.headOid - }) - - if (JSON.stringify(legacy) !== JSON.stringify(pinned)) { - divergences.push( - `${entry.status} ${entry.path}${entry.oldPath ? ` (from ${entry.oldPath})` : ''}\n` + - ` legacy: ${JSON.stringify(legacy)}\n pinned: ${JSON.stringify(pinned)}` - ) - } - } - - expect(divergences.join('\n')).toBe('') - }) - - it('agrees on content for renames, additions, deletions and binaries specifically', async () => { - const compare = await branchCompare() - const byPath = new Map(compare.entries.map((entry) => [entry.path, entry])) - - // Why assert content and not just equality: two identically-empty results - // would satisfy the equivalence test above while rendering nothing. - const rename = byPath.get('renamed-and-edited-to.txt') - expect(rename?.oldPath).toBe('renamed-and-edited-from.txt') - const [renamePinned] = await branchDiff({ - baseRef: compare.summary.mergeBase, - headOid: compare.summary.headOid, - filePath: rename!.path, - oldPath: rename!.oldPath - }) - expect(renamePinned).toMatchObject({ - originalContent: 'rename plus edit, line one\nline two\nline three\n', - modifiedContent: 'rename plus edit, line one\nline two CHANGED\nline three\n' - }) - - const [addedPinned] = await branchDiff({ - baseRef: compare.summary.mergeBase, - headOid: compare.summary.headOid, - filePath: 'added.txt' - }) - expect(addedPinned).toMatchObject({ originalContent: '', modifiedContent: 'brand new\n' }) - - const [deletedPinned] = await branchDiff({ - baseRef: compare.summary.mergeBase, - headOid: compare.summary.headOid, - filePath: 'deleted.txt' - }) - expect(deletedPinned).toMatchObject({ originalContent: 'doomed\n', modifiedContent: '' }) - - const [binaryPinned] = await branchDiff({ - baseRef: compare.summary.mergeBase, - headOid: compare.summary.headOid, - filePath: 'binary-modified.bin' - }) - expect(binaryPinned).toMatchObject({ kind: 'binary' }) - }) - - it('holds the pinned revision when HEAD moves mid-review, where legacy drifts', async () => { - const compare = await branchCompare() - writeFileSync(path.join(repoPath, 'modified.txt'), 'drifted after the snapshot\n') - gitCommit(repoPath, 'drift') - - const pinned = await branchDiff({ - baseRef: compare.summary.mergeBase, - headOid: compare.summary.headOid, - filePath: 'modified.txt' - }) - const legacy = await branchDiff({ filePath: 'modified.txt' }) - - expect(pinned[0]).toMatchObject({ modifiedContent: 'after\n' }) - // The divergence is the fix: legacy silently re-resolves live HEAD. - expect(legacy[0]).toMatchObject({ modifiedContent: 'drifted after the snapshot\n' }) - }) -}) diff --git a/src/relay/git-handler-branch-diff-ops.ts b/src/relay/git-handler-branch-diff-ops.ts deleted file mode 100644 index 6a85c7f6630..00000000000 --- a/src/relay/git-handler-branch-diff-ops.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { isGitReadInterruptedError } from './git-buffer-overflow' -import { buildDiffResult } from './git-diff-result' -import { readBlobAtOid, type GitBufferExec } from './git-handler-ops' - -const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ - -function assertFullGitObjectId(value: unknown, label: string): asserts value is string { - if (typeof value !== 'string' || !FULL_GIT_OBJECT_ID_PATTERN.test(value)) { - throw new Error(`${label} must be a full git object id`) - } -} - -export function isFullGitObjectId(value: unknown): value is string { - return typeof value === 'string' && FULL_GIT_OBJECT_ID_PATTERN.test(value) -} - -export function parseOptionalBranchDiffHeadOid( - params: Record -): string | undefined { - const { headOid } = params - // Why: GitBranchCompareSummary.headOid is `string | null`, so a mixed-version - // client can put an explicit null on the wire. Treat it as unpinned rather - // than rejecting a request the legacy path would have served. - if (headOid == null) { - return undefined - } - assertFullGitObjectId(headOid, 'headOid') - return headOid -} - -export async function branchDiffEntryAtPinnedOids( - gitBuffer: GitBufferExec, - worktreePath: string, - baseOid: string, - headOid: string, - filePath: string, - oldPath?: string -) { - assertFullGitObjectId(baseOid, 'baseRef') - assertFullGitObjectId(headOid, 'headOid') - try { - const [left, right] = await Promise.all([ - readBlobAtOid(gitBuffer, worktreePath, baseOid, oldPath ?? filePath), - readBlobAtOid(gitBuffer, worktreePath, headOid, filePath) - ]) - return [buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, filePath)] - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - return [ - { - kind: 'text' as const, - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - ] - } -} diff --git a/src/relay/git-handler-branch-diff.test.ts b/src/relay/git-handler-branch-diff.test.ts deleted file mode 100644 index 32ffd4a6475..00000000000 --- a/src/relay/git-handler-branch-diff.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RelayContext } from './context' -import { - branchDiffEntryAtPinnedOids, - isFullGitObjectId, - parseOptionalBranchDiffHeadOid -} from './git-handler-branch-diff-ops' -import { GitHandler } from './git-handler' -import { branchDiffEntries, type GitBufferExec, type GitExec } from './git-handler-ops' -import { - createMockDispatcher, - gitCommit, - gitInit, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -const BASE_OID = 'a'.repeat(40) -const HEAD_OID = 'b'.repeat(40) -const OTHER_HEAD_OID = 'c'.repeat(40) -const MERGE_BASE_OID = 'd'.repeat(40) -const FILE_PATH = 'src/file.ts' - -type GitBufferTarget = { - gitBuffer(args: string[], cwd: string): Promise -} - -type GitTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> -} - -function deferredBuffer(): { promise: Promise; resolve: (content: string) => void } { - let resolve!: (value: Buffer) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve: (content) => resolve(Buffer.from(content)) } -} - -describe('pinned relay branch diff operation', () => { - it.each([ - { - name: 'addition', - status: `A\0${FILE_PATH}\0`, - left: new Error('missing'), - right: Buffer.from('added\n') - }, - { - name: 'deletion', - status: `D\0${FILE_PATH}\0`, - left: Buffer.from('deleted\n'), - right: new Error('missing') - }, - { - name: 'binary content', - status: `M\0${FILE_PATH}\0`, - left: Buffer.from([0, 1]), - right: Buffer.from([0, 2]) - }, - { - name: 'blob read failure', - status: `M\0${FILE_PATH}\0`, - left: new Error('left failed'), - right: new Error('right failed') - } - ])('matches the legacy $name result', async ({ status, left, right }) => { - const createGitBuffer = (): GitBufferExec => - vi.fn(async (args) => { - const value = args[2].startsWith(`${BASE_OID}:`) ? left : right - if (value instanceof Error) { - throw value - } - return value - }) - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse' && args.includes('HEAD')) { - return { stdout: `${HEAD_OID}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' || args[0] === 'merge-base') { - return { stdout: `${BASE_OID}\n`, stderr: '' } - } - return { stdout: status, stderr: '' } - }) - - const pinned = await branchDiffEntryAtPinnedOids( - createGitBuffer(), - '/repo', - BASE_OID, - HEAD_OID, - FILE_PATH - ) - const legacy = await branchDiffEntries(git, createGitBuffer(), '/repo', BASE_OID, { - includePatch: true, - filePath: FILE_PATH - }) - - expect(pinned).toEqual(legacy) - }) - - // Why: GitBranchCompareSummary.headOid is `string | null`, so an unpinned - // snapshot arrives as an explicit null and must stay servable. - it.each([ - { name: 'absent', params: {} }, - { name: 'null', params: { headOid: null } }, - { name: 'undefined', params: { headOid: undefined } } - ])('treats a $name head OID as unpinned', ({ params }) => { - expect(parseOptionalBranchDiffHeadOid(params)).toBeUndefined() - }) - - it.each([ - { name: 'SHA-1', headOid: HEAD_OID }, - { name: 'SHA-256', headOid: 'b'.repeat(64) } - ])('returns a supplied $name head OID', ({ headOid }) => { - expect(parseOptionalBranchDiffHeadOid({ headOid })).toBe(headOid) - }) - - it.each([{ headOid: 'abc123' }, { headOid: '' }, { headOid: 'main' }, { headOid: 123 }])( - 'rejects the supplied malformed head OID $headOid', - ({ headOid }) => { - expect(() => parseOptionalBranchDiffHeadOid({ headOid })).toThrow( - 'headOid must be a full git object id' - ) - } - ) - - it('recognizes only full object ids', () => { - expect(isFullGitObjectId(BASE_OID)).toBe(true) - expect(isFullGitObjectId('A'.repeat(64))).toBe(true) - for (const value of ['origin/main', 'abc123', '', 'g'.repeat(40), null, undefined, 123, {}]) { - expect(isFullGitObjectId(value)).toBe(false) - } - }) - - // Why: the route guards this today, but the export is reachable on its own — - // a symbolic ref here would resolve `git show origin/main:` at live HEAD. - it('rejects a symbolic ref at the module boundary', async () => { - const gitBuffer = vi.fn(async () => Buffer.from('')) - await expect( - branchDiffEntryAtPinnedOids(gitBuffer, '/repo', 'origin/main', HEAD_OID, FILE_PATH) - ).rejects.toThrow('baseRef must be a full git object id') - await expect( - branchDiffEntryAtPinnedOids(gitBuffer, '/repo', BASE_OID, 'HEAD', FILE_PATH) - ).rejects.toThrow('headOid must be a full git object id') - expect(gitBuffer).not.toHaveBeenCalled() - }) -}) - -describe('GitHandler pinned branch diff route', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - - beforeEach(() => { - dispatcher = createMockDispatcher() - handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - }) - - afterEach(() => { - handler.dispose() - vi.restoreAllMocks() - }) - - function request(overrides: Record = {}): Promise { - return dispatcher.callRequest('git.branchDiff', { - worktreePath: '/repo', - baseRef: BASE_OID, - headOid: HEAD_OID, - includePatch: true, - filePath: FILE_PATH, - ...overrides - }) - } - - function mockLegacyGit(rawDiff = '') { - return vi.spyOn(handler as unknown as GitTarget, 'git').mockImplementation(async (args) => { - if (args[0] === 'rev-parse' && args.includes('HEAD')) { - return { stdout: `${HEAD_OID}\n`, stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: `${BASE_OID}\n`, stderr: '' } - } - if (args[0] === 'merge-base') { - return { stdout: `${MERGE_BASE_OID}\n`, stderr: '' } - } - return { stdout: rawDiff, stderr: '' } - }) - } - - it('starts exactly two pinned blob reads concurrently without metadata commands', async () => { - const left = deferredBuffer() - const right = deferredBuffer() - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - .mockImplementation((args) => { - if (args[2] === `${BASE_OID}:${FILE_PATH}`) { - return left.promise - } - if (args[2] === `${HEAD_OID}:${FILE_PATH}`) { - return right.promise - } - throw new Error(`unexpected blob spec: ${args[2]}`) - }) - const gitSpy = vi - .spyOn(handler as unknown as GitTarget, 'git') - .mockRejectedValue(new Error('metadata command should not run')) - - const resultPromise = request() - await vi.waitFor(() => expect(gitBufferSpy).toHaveBeenCalledTimes(2)) - - expect(gitSpy).not.toHaveBeenCalled() - expect(gitBufferSpy.mock.calls.map(([args]) => args)).toEqual([ - ['show', '--end-of-options', `${BASE_OID}:${FILE_PATH}`], - ['show', '--end-of-options', `${HEAD_OID}:${FILE_PATH}`] - ]) - - left.resolve('before\n') - right.resolve('after\n') - await expect(resultPromise).resolves.toEqual([ - { - kind: 'text', - originalContent: 'before\n', - modifiedContent: 'after\n', - originalIsBinary: false, - modifiedIsBinary: false - } - ]) - }) - - it('reads the old path only from the base OID', async () => { - const oldPath = 'src/old-file.ts' - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - .mockImplementation(async (args) => Buffer.from(args[2].startsWith(BASE_OID) ? 'old' : 'new')) - - await request({ oldPath }) - - expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ - `${BASE_OID}:${oldPath}`, - `${HEAD_OID}:${FILE_PATH}` - ]) - }) - - it('coalesces the same head OID while keeping different heads independent', async () => { - const pending = deferredBuffer() - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - .mockImplementation(() => pending.promise) - - const requests = [request(), request(), request({ headOid: OTHER_HEAD_OID })] - await vi.waitFor(() => expect(gitBufferSpy).toHaveBeenCalledTimes(4)) - - const specs = gitBufferSpy.mock.calls.map(([args]) => args[2]) - expect(specs.filter((spec) => spec === `${BASE_OID}:${FILE_PATH}`)).toHaveLength(2) - expect(specs.filter((spec) => spec === `${HEAD_OID}:${FILE_PATH}`)).toHaveLength(1) - expect(specs.filter((spec) => spec === `${OTHER_HEAD_OID}:${FILE_PATH}`)).toHaveLength(1) - - pending.resolve('content\n') - await Promise.all(requests) - }) - - it('rejects every supplied malformed head OID before running Git', async () => { - const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - const invalidHeadOids = [7, {}, '', 'a'.repeat(39), 'g'.repeat(40)] - - for (const headOid of invalidHeadOids) { - await expect(request({ headOid })).rejects.toThrow('headOid must be a full git object id') - } - - expect(gitBufferSpy).not.toHaveBeenCalled() - }) - - it('accepts SHA-256 object IDs', async () => { - const baseOid = 'A'.repeat(64) - const headOid = 'b'.repeat(64) - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - .mockResolvedValue(Buffer.from('content\n')) - - await request({ baseRef: baseOid, headOid }) - - expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ - `${baseOid}:${FILE_PATH}`, - `${headOid}:${FILE_PATH}` - ]) - }) - - // Why: a symbolic base ref used to select the pinned route and then throw. - it('selects the pinned route only when the base ref is a full object id', async () => { - const gitSpy = mockLegacyGit(`M\0${FILE_PATH}\0`) - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - .mockResolvedValue(Buffer.from('content\n')) - - await expect(request({ baseRef: 'origin/main' })).resolves.toHaveLength(1) - - expect(gitSpy.mock.calls.map(([args]) => args.join(' '))).toEqual([ - 'rev-parse --verify HEAD', - 'rev-parse --verify origin/main', - `merge-base ${BASE_OID} ${HEAD_OID}`, - `diff --name-status -z -M -C ${MERGE_BASE_OID} ${HEAD_OID} --` - ]) - expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ - `${MERGE_BASE_OID}:${FILE_PATH}`, - `${HEAD_OID}:${FILE_PATH}` - ]) - - gitSpy.mockClear() - gitBufferSpy.mockClear() - await request() - - expect(gitSpy).not.toHaveBeenCalled() - expect(gitBufferSpy.mock.calls.map(([args]) => args[2])).toEqual([ - `${BASE_OID}:${FILE_PATH}`, - `${HEAD_OID}:${FILE_PATH}` - ]) - }) - - it('serves an explicitly null head OID through the legacy path', async () => { - const gitSpy = mockLegacyGit(`M\0${FILE_PATH}\0`) - const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - - await expect(request({ headOid: null, includePatch: false })).resolves.toEqual([ - { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - ]) - - expect(gitSpy).toHaveBeenCalledTimes(4) - expect(gitBufferSpy).not.toHaveBeenCalled() - }) - - it('uses the legacy path when patch or file-path conditions are not met', async () => { - const gitSpy = mockLegacyGit() - const gitBufferSpy = vi.spyOn(handler as unknown as GitBufferTarget, 'gitBuffer') - - await Promise.all([ - request({ baseRef: 'main', includePatch: false }), - request({ baseRef: 'main', filePath: '' }), - request({ baseRef: 'main', filePath: { malformed: true } }) - ]) - - expect(gitSpy).toHaveBeenCalledTimes(12) - expect(gitBufferSpy).not.toHaveBeenCalled() - }) - - it('keeps the requested head pinned after repository HEAD moves', async () => { - const repoPath = mkdtempSync(path.join(tmpdir(), 'relay-pinned-branch-diff-')) - try { - gitInit(repoPath) - writeFileSync(path.join(repoPath, 'file.txt'), 'base\n') - gitCommit(repoPath, 'base') - const baseOid = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: repoPath, - encoding: 'utf8' - }).trim() - - writeFileSync(path.join(repoPath, 'file.txt'), 'pinned\n') - gitCommit(repoPath, 'pinned') - const pinnedHeadOid = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: repoPath, - encoding: 'utf8' - }).trim() - - writeFileSync(path.join(repoPath, 'file.txt'), 'moved\n') - gitCommit(repoPath, 'moved') - - const result = (await request({ - worktreePath: repoPath, - baseRef: baseOid, - headOid: pinnedHeadOid, - filePath: 'file.txt' - })) as { originalContent: string; modifiedContent: string }[] - - expect(result[0]).toMatchObject({ - originalContent: 'base\n', - modifiedContent: 'pinned\n' - }) - expect( - execFileSync('git', ['show', 'HEAD:file.txt'], { cwd: repoPath, encoding: 'utf8' }) - ).toBe('moved\n') - } finally { - rmSync(repoPath, { recursive: true, force: true }) - } - }) -}) diff --git a/src/relay/git-handler-check-ignore.test.ts b/src/relay/git-handler-check-ignore.test.ts deleted file mode 100644 index 678798bf748..00000000000 --- a/src/relay/git-handler-check-ignore.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { GitExec } from './git-handler-ops' -import { checkIgnoredPathsOp } from './git-handler-check-ignore' -import { GIT_CHECK_IGNORE_TIMEOUT_MS } from '../shared/git-check-ignore-stdio' - -describe('checkIgnoredPathsOp', () => { - it('passes exact paths over one bounded NUL-delimited stdin invocation', async () => { - const git = vi.fn().mockResolvedValue({ - stdout: 'dist/bundle.js\0line\nbreak.txt\0', - stderr: '' - }) - - await expect( - checkIgnoredPathsOp(git, { - worktreePath: '/repo', - paths: ['dist/bundle.js', 'line\nbreak.txt', 'src/index.ts'] - }) - ).resolves.toEqual(['dist/bundle.js', 'line\nbreak.txt']) - - expect(git).toHaveBeenCalledWith( - ['-c', 'core.quotePath=false', 'check-ignore', '-z', '--stdin'], - '/repo', - { - stdin: 'dist/bundle.js\0line\nbreak.txt\0src/index.ts\0', - timeout: GIT_CHECK_IGNORE_TIMEOUT_MS - } - ) - }) - - it('treats git exit code 1 as no ignored paths', async () => { - const noMatches = Object.assign(new Error('no ignored paths'), { - code: 1, - stdout: '' - }) - const git = vi.fn().mockRejectedValue(noMatches) - - await expect( - checkIgnoredPathsOp(git, { - worktreePath: '/repo', - paths: ['src/index.ts'] - }) - ).resolves.toEqual([]) - }) -}) diff --git a/src/relay/git-handler-check-ignore.ts b/src/relay/git-handler-check-ignore.ts deleted file mode 100644 index e458aba7af6..00000000000 --- a/src/relay/git-handler-check-ignore.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { GitExec } from './git-handler-ops' -import { - encodeGitCheckIgnorePaths, - GIT_CHECK_IGNORE_STDIN_ARGS, - GIT_CHECK_IGNORE_TIMEOUT_MS, - parseGitCheckIgnorePaths, - splitGitCheckIgnorePathsByStdinBytes -} from '../shared/git-check-ignore-stdio' - -export async function checkIgnoredPathsOp( - git: GitExec, - params: Record -): Promise { - const worktreePath = params.worktreePath as string - const paths = Array.isArray(params.paths) - ? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0) - : [] - const ignored: string[] = [] - for (const chunk of splitGitCheckIgnorePathsByStdinBytes(paths)) { - try { - const { stdout } = await git([...GIT_CHECK_IGNORE_STDIN_ARGS], worktreePath, { - stdin: encodeGitCheckIgnorePaths(chunk), - timeout: GIT_CHECK_IGNORE_TIMEOUT_MS - }) - ignored.push(...parseGitCheckIgnorePaths(stdout)) - } catch (error) { - const gitError = error as Error & { code?: number | string; stdout?: string } - if (gitError.code !== 1) { - throw error - } - ignored.push(...parseGitCheckIgnorePaths(gitError.stdout ?? '')) - } - } - return ignored -} diff --git a/src/relay/git-handler-clone-progress-capture.test.ts b/src/relay/git-handler-clone-progress-capture.test.ts deleted file mode 100644 index 4e2bda7da31..00000000000 --- a/src/relay/git-handler-clone-progress-capture.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ChildProcess } from 'node:child_process' -import type * as ChildProcessModule from 'node:child_process' -import { createFakeSpawnedChild } from '../shared/__fixtures__/fake-spawned-child' -import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' -import { createGitHandlerRelay } from './git-handler-test-harness' -import { _resetRelayGitAdmissionForTests } from './git-handler-command-termination' - -const { spawn, signalTree, forceTree } = vi.hoisted(() => ({ - spawn: vi.fn<(program: string, args: readonly string[]) => ChildProcess>(), - signalTree: vi.fn<(child: ChildProcess, signal?: NodeJS.Signals) => Promise>(), - forceTree: vi.fn<(child: ChildProcess) => Promise>() -})) -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn -})) -vi.mock('@orca/process-host/process-tree-termination', () => ({ - signalProcessTree: signalTree, - forceTerminateProcessTree: forceTree -})) - -const request = { - args: ['clone', '--progress', '--', 'https://example.com/repository.git', 'destination'], - cwd: process.cwd(), - progressId: 'clone-progress' -} -const noise = Buffer.from(`Receiving objects: 42%\r${'x'.repeat(65_512)}\r`) - -function emitNoise(child: ChildProcess): void { - for (let index = 0; index < 193; index++) { - child.stderr?.emit('data', noise) - } -} - -describe('relay clone progress capture', () => { - let relay: ReturnType - let scheduler: GitAdmissionScheduler - let child: ChildProcess - - beforeEach(() => { - vi.useFakeTimers() - vi.stubEnv('GIT_SSH_COMMAND', 'ssh') - scheduler = new GitAdmissionScheduler({ networkCap: 1, networkHeadroom: 0 }) - _resetRelayGitAdmissionForTests(scheduler) - child = createFakeSpawnedChild() - spawn.mockReset().mockReturnValue(child) - signalTree.mockReset().mockResolvedValue(false) - forceTree.mockReset().mockResolvedValue(false) - relay = createGitHandlerRelay() - }) - - afterEach(() => { - relay.handler.dispose() - _resetRelayGitAdmissionForTests() - vi.unstubAllEnvs() - vi.useRealTimers() - }) - - it('completes after more than 10 MiB of progress with bounded final output', async () => { - const pending = relay.dispatcher.callRequest('git.clone', request) - await vi.advanceTimersByTimeAsync(0) - expect(spawn).toHaveBeenCalledOnce() - expect(scheduler.snapshot().budgets.network.baseUsed).toBe(1) - expect(noise.length * 193).toBeGreaterThan(10 * 1024 * 1024) - emitNoise(child) - const finalStderr = 'Receiving objects: 100%\rCLONE_FINAL_TAIL\n' - child.stderr?.emit('data', Buffer.from(finalStderr)) - child.stdout?.emit('data', Buffer.from(`${'o'.repeat(8192)}STDOUT_FINAL\n`)) - expect(signalTree).not.toHaveBeenCalled() - expect(forceTree).not.toHaveBeenCalled() - child.emit('exit', 0, null) - child.emit('close', 0, null) - - await expect(pending).resolves.toEqual({ - stdout: `${'o'.repeat(8192)}STDOUT_FINAL\n`.slice(-4096), - stderr: `${noise.toString()}${finalStderr}`.slice(-4096) - }) - expect(relay.dispatcher.notify).toHaveBeenCalledTimes(194) - expect(relay.dispatcher.notify).toHaveBeenLastCalledWith('git.cloneProgress', { - progressId: request.progressId, - phase: 'Receiving objects', - percent: 100 - }) - expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('reports the final fatal diagnostic after noisy progress and exit 128', async () => { - const pending = relay.dispatcher.callRequest('git.clone', request) - const rejection = expect(pending).rejects.toThrow('Clone failed: fatal: repository unavailable') - await vi.advanceTimersByTimeAsync(0) - expect(spawn).toHaveBeenCalledOnce() - emitNoise(child) - child.stderr?.emit('data', Buffer.from('fatal: repository unavailable\n')) - child.emit('exit', 128, null) - child.emit('close', 128, null) - await rejection - expect(signalTree).not.toHaveBeenCalled() - expect(forceTree).not.toHaveBeenCalled() - expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('holds admission on cancellation until the clone tree is confirmed terminated', async () => { - let confirmTermination: (terminated: boolean) => void = () => { - throw new Error('Termination verification has not started') - } - forceTree.mockImplementation( - () => - new Promise((resolve) => { - confirmTermination = resolve - }) - ) - const controller = new AbortController() - const pending = relay.dispatcher.callRequest('git.clone', request, { - isStale: () => false, - signal: controller.signal - }) - const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - let settled = false - void pending.then( - () => { - settled = true - }, - () => { - settled = true - } - ) - await vi.advanceTimersByTimeAsync(0) - expect(spawn).toHaveBeenCalledOnce() - emitNoise(child) - expect(signalTree).not.toHaveBeenCalled() - controller.abort() - await vi.advanceTimersByTimeAsync(2000) - expect(signalTree).toHaveBeenCalledWith(child, undefined) - expect(forceTree).toHaveBeenCalledWith(child) - expect(settled).toBe(false) - expect(scheduler.snapshot().budgets.network.baseUsed).toBe(1) - - confirmTermination(true) - await vi.advanceTimersByTimeAsync(0) - await rejection - expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) - child.emit('exit', null, 'SIGKILL') - child.emit('close', null, 'SIGKILL') - expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) -}) diff --git a/src/relay/git-handler-command-termination.ts b/src/relay/git-handler-command-termination.ts deleted file mode 100644 index f8cb07382f5..00000000000 --- a/src/relay/git-handler-command-termination.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - signalProcessTree, - forceTerminateProcessTree -} from '@orca/process-host/process-tree-termination' -import type { ProcessTerminationBarrier } from '@orca/process-host/process-spec' -import { runProcess } from '@orca/process-host' -import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' -import type { GitAdmissionRequest } from '../shared/git-admission-state' -import { gitCommandTimeoutMs } from '../shared/git-command-timeout' - -export const MAX_GIT_BUFFER = 10 * 1024 * 1024 -let scheduler = new GitAdmissionScheduler() - -export function _resetRelayGitAdmissionForTests(replacement = new GitAdmissionScheduler()): void { - scheduler = replacement -} - -export const acquireRelayGitAdmission = (request: GitAdmissionRequest) => scheduler.acquire(request) - -type GitTerminationOptions = { - cwd?: string - env?: NodeJS.ProcessEnv - timeout?: number - maxBuffer?: number - signal?: AbortSignal - captureStdoutAsBytes?: boolean - outputCapture?: 'tail' - observeStderr?: ProcessTerminationBarrier['observeStderr'] -} - -export async function runGitToTermination( - args: string[], - options: GitTerminationOptions, - stdin: string | undefined -): Promise<{ stdout: string; stderr: string; stdoutBytes?: Buffer }> { - const grant = await acquireRelayGitAdmission({ - args, - cwd: options.cwd ?? '.', - signal: options.signal - }) - // A rejected capture can precede child termination; the child owns the grant. - const result = await runProcess( - { - program: 'git', - args, - cwd: options.cwd, - env: options.env, - timeoutMs: gitCommandTimeoutMs(args, options.timeout) ?? null, - maxOutputBytes: options.maxBuffer ?? MAX_GIT_BUFFER, - captureStdoutAsBytes: options.captureStdoutAsBytes, - killOnOutputLimit: options.outputCapture !== 'tail', - signal: options.signal, - terminationBarrier: options.observeStderr - ? { - observeStderr: options.observeStderr, - signal: signalProcessTree, - force: forceTerminateProcessTree - } - : true, - onChildTerminated: grant.release, - ...(stdin === undefined ? {} : { input: stdin }) - }, - options.outputCapture - ) - const outputExceeded = result.outputTruncated === true && options.outputCapture !== 'tail' - if ( - result.code === 0 && - !result.signal && - !result.timedOut && - !options.signal?.aborted && - !outputExceeded - ) { - return { - stdout: result.stdout, - stderr: result.stderr, - ...(result.stdoutBytes ? { stdoutBytes: result.stdoutBytes } : {}) - } - } - const error = new Error( - outputExceeded - ? 'git output exceeded maxBuffer.' - : result.timedOut - ? `git ${args[0] ?? 'command'} timed out.` - : options.signal?.aborted - ? 'The operation was aborted.' - : result.stderr.trim() || `git ${args[0] ?? 'command'} failed.` - ) - if (options.signal?.aborted) { - error.name = 'AbortError' - } - throw Object.assign(error, { - code: outputExceeded ? 'ENOBUFS' : result.code, - timedOut: result.timedOut, - killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, - signal: result.signal, - stdout: result.stdoutBytes ?? result.stdout, - stderr: result.stderr - }) -} diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts deleted file mode 100644 index 6e5eda44cbe..00000000000 --- a/src/relay/git-handler-commit-diff-ops.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { isGitReadInterruptedError } from './git-buffer-overflow' -import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' -import { gitChangeListArgs, parseGitChangeList } from '../shared/git-change-list' -import { buildDiffResult } from './git-diff-result' -import { parseGitRevListCommitAndFirstParentOid } from '../shared/git-rev-list-output' - -const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ - -function assertFullGitObjectId(value: string, label: string): void { - if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { - throw new Error(`${label} must be a full git object id`) - } -} - -export async function commitCompare(git: GitExec, worktreePath: string, commitId: string) { - assertFullGitObjectId(commitId, 'commitId') - let commitOid = '' - let parentOid: string | null = null - let parentReadFailure: { error: unknown } | undefined - try { - const { stdout } = await git( - ['rev-list', '--parents', '-n', '1', '--end-of-options', `${commitId}^{commit}`], - worktreePath - ) - ;({ commitOid, parentOid } = parseGitRevListCommitAndFirstParentOid(stdout)) - } catch (error) { - // Why: preserve invalid-commit versus a resolved commit with unreadable parents on failure. - try { - const { stdout } = await git( - ['rev-parse', '--verify', '--end-of-options', `${commitId}^{commit}`], - worktreePath - ) - commitOid = stdout.trim() - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - return { - summary: { - commitOid: '', - parentOid: null, - compareRef: commitId, - baseRef: 'parent', - changedFiles: 0, - status: 'invalid-commit', - errorMessage: `Commit ${commitId} could not be resolved in this repository.` - }, - entries: [] - } - } - parentReadFailure = { error } - } - - const summary = { - commitOid, - parentOid: null as string | null, - compareRef: commitOid.slice(0, 7), - baseRef: 'empty tree', - changedFiles: 0, - status: 'ready' as const - } - - try { - if (parentReadFailure) { - throw parentReadFailure.error - } - summary.parentOid = parentOid - summary.baseRef = parentOid ? parentOid.slice(0, 7) : 'empty tree' - - const { stdout } = await git(gitChangeListArgs(summary.parentOid, commitOid), worktreePath) - const entries = parseGitChangeList(stdout) - summary.changedFiles = entries.length - return { summary, entries } - } catch (error) { - return { - summary: { - ...summary, - status: 'error', - errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' - }, - entries: [] - } - } -} - -export async function commitDiffEntry( - gitBuffer: GitBufferExec, - worktreePath: string, - args: { - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - } -) { - assertFullGitObjectId(args.commitOid, 'commitOid') - if (args.parentOid) { - assertFullGitObjectId(args.parentOid, 'parentOid') - } - try { - const oldPath = args.oldPath ?? args.filePath - const [left, right] = await Promise.all([ - args.parentOid - ? readBlobAtOid(gitBuffer, worktreePath, args.parentOid, oldPath) - : Promise.resolve({ content: '', isBinary: false }), - readBlobAtOid(gitBuffer, worktreePath, args.commitOid, args.filePath) - ]) - return buildDiffResult( - left.content, - right.content, - left.isBinary, - right.isBinary, - args.filePath - ) - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - return { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - } -} diff --git a/src/relay/git-handler-commit-metadata.test.ts b/src/relay/git-handler-commit-metadata.test.ts deleted file mode 100644 index dd81d834cf1..00000000000 --- a/src/relay/git-handler-commit-metadata.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { unlink, writeFile } from 'node:fs/promises' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { commitCompare } from './git-handler-commit-diff-ops' -import type { GitExec } from './git-handler-ops' -import { gitCommit, gitInit } from './git-handler-test-setup' -import { createGitTempDir, removeGitTempDir } from './git-handler-test-harness' - -describe('relay commit metadata resolution', () => { - let repo: string - let git: ReturnType> - - beforeEach(async () => { - repo = createGitTempDir() - gitInit(repo) - await writeFile(path.join(repo, 'file.txt'), 'root\n') - gitCommit(repo, 'root') - git = vi.fn(async (args, cwd) => { - const result = await runProcess({ program: 'git', args, cwd }) - if (result.code !== 0) { - throw new Error(result.stderr) - } - return { stdout: result.stdout, stderr: result.stderr } - }) - }) - - afterEach(async () => { - await removeGitTempDir(repo) - }) - - async function oid(ref: string): Promise { - return (await git(['rev-parse', ref], repo)).stdout.trim() - } - - async function nextCommit(): Promise<{ parent: string; commit: string }> { - const parent = await oid('HEAD') - await writeFile(path.join(repo, 'file.txt'), 'child\n') - gitCommit(repo, 'child') - return { parent, commit: await oid('HEAD') } - } - - it('resolves a root commit and loads its changes in two Git calls', async () => { - const commit = await oid('HEAD') - git.mockClear() - - const result = await commitCompare(git, repo, commit) - - expect(result.summary).toMatchObject({ status: 'ready', commitOid: commit, parentOid: null }) - expect(result.entries).toEqual([{ path: 'file.txt', status: 'added', added: 1, removed: 0 }]) - expect(git).toHaveBeenCalledTimes(2) - }) - - it('peels an annotated tag object id while retaining its first commit parent', async () => { - const { commit, parent } = await nextCommit() - await git(['tag', '-a', 'test-tag', '-m', 'annotated'], repo) - const tag = await oid('test-tag') - expect(tag).not.toBe(commit) - git.mockClear() - - const result = await commitCompare(git, repo, tag) - - expect(result.summary).toMatchObject({ status: 'ready', commitOid: commit, parentOid: parent }) - expect(git).toHaveBeenCalledTimes(2) - }) - - it('honors the shallow boundary rather than exposing an unavailable raw parent', async () => { - const { commit } = await nextCommit() - await writeFile(path.join(repo, '.git', 'shallow'), `${commit}\n`) - - const result = await commitCompare(git, repo, commit) - - expect(result.summary).toMatchObject({ status: 'ready', commitOid: commit, parentOid: null }) - }) - - it('preserves the resolved commit when reading a missing parent fails', async () => { - const { commit, parent } = await nextCommit() - await unlink(path.join(repo, '.git', 'objects', parent.slice(0, 2), parent.slice(2))) - git.mockClear() - - const result = await commitCompare(git, repo, commit) - - expect(result.summary).toMatchObject({ status: 'error', commitOid: commit }) - expect(result.entries).toEqual([]) - expect(git.mock.calls.map(([args]) => args[0])).toEqual(['rev-list', 'rev-parse']) - }) - - it('distinguishes a blob object from a valid commit', async () => { - const blob = await oid('HEAD:file.txt') - - const result = await commitCompare(git, repo, blob) - - expect(result.summary).toMatchObject({ status: 'invalid-commit', commitOid: '' }) - }) - - it('rejects arbitrary revision expressions before executing Git', async () => { - git.mockClear() - await expect(commitCompare(git, repo, 'HEAD~1..HEAD')).rejects.toThrow('full git object id') - expect(git).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/git-handler-comparison-operations.ts b/src/relay/git-handler-comparison-operations.ts deleted file mode 100644 index fa32bf95981..00000000000 --- a/src/relay/git-handler-comparison-operations.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { RequestContext } from '../wsl-guest/dispatcher' -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { branchCompare as branchCompareOp } from './git-handler-ops' -import { commitCompare as commitCompareOp } from './git-handler-commit-diff-ops' -import { gitChangeListArgs, parseGitChangeList } from '../shared/git-change-list' -import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error' -import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status' -import { assertValidGitPushTarget } from '../shared/git-push-target-validation' -import { getPublishTargetStatus } from '../shared/git-publish-target-status' -import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' - -export class GitHandlerComparisonOperations extends GitHandlerOperationContext { - async branchCompare(params: Record, context?: RequestContext) { - const worktreePath = params.worktreePath as string - const baseRef = params.baseRef as string - // Why: reject flag-like base refs to prevent rev-parse option injection. - if (baseRef.startsWith('-')) { - throw new Error('Base ref must not start with "-"') - } - const gitBound = this.gitForSignal(context?.signal) - const result = await branchCompareOp( - gitBound, - worktreePath, - baseRef, - async (mergeBase, headOid) => { - const { stdout } = await gitBound(gitChangeListArgs(mergeBase, headOid), worktreePath) - return parseGitChangeList(stdout) - } - ) - context?.signal?.throwIfAborted() - return result - } - - async commitCompare(params: Record, context?: RequestContext) { - const worktreePath = params.worktreePath as string - const commitId = params.commitId as string - const result = await commitCompareOp(this.gitForSignal(context?.signal), worktreePath, commitId) - context?.signal?.throwIfAborted() - return result - } - - async upstreamStatus(params: Record, context?: RequestContext) { - const worktreePath = params.worktreePath as string - const git = this.gitForSignal(context?.signal) - - try { - if (params.pushTarget !== undefined) { - assertValidGitPushTarget(params.pushTarget) - const pushTarget = params.pushTarget - await git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) - return await getPublishTargetStatus( - (args) => git(args, worktreePath), - pushTarget, - (upstreamName) => - this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName, context?.signal) - ) - } - return await getEffectiveGitUpstreamStatus( - (args) => git(args, worktreePath), - (upstreamName) => - this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName, context?.signal) - ) - } catch (error) { - context?.signal?.throwIfAborted() - // Why: suppress only the expected no-upstream error; surface all others. - if (isNoUpstreamError(error)) { - return { hasUpstream: false, ahead: 0, behind: 0 } - } - // Why: match fetch/push/pull normalization so execFile preamble and local paths don't leak to the renderer. - throw new Error(normalizeGitErrorMessage(error, 'upstream')) - } finally { - context?.signal?.throwIfAborted() - } - } - - private async getBehindCommitsArePatchEquivalent( - worktreePath: string, - upstreamName: string, - signal?: AbortSignal - ): Promise { - try { - const { stdout } = await this.gitForSignal(signal)( - [ - 'log', - '--no-show-signature', - '--no-color', - '--oneline', - '--cherry-mark', - '--right-only', - `HEAD...${upstreamName}`, - '--' - ], - worktreePath - ) - return upstreamOnlyCommitsArePatchEquivalent(stdout) - } catch { - // Why: this only identifies stale post-rebase upstreams; if the probe fails over SSH, keep the conservative pull-first sync path. - return false - } - } -} diff --git a/src/relay/git-handler-diff-blobs.test.ts b/src/relay/git-handler-diff-blobs.test.ts deleted file mode 100644 index 37a6962de9c..00000000000 --- a/src/relay/git-handler-diff-blobs.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { commitDiffEntry } from './git-handler-commit-diff-ops' -import { computeDiff, type GitBufferExec } from './git-handler-ops' -import { gitCommit, gitInit, type MockDispatcher } from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from './git-handler-test-harness' -import type { GitHandler } from './git-handler' - -describe('relay diff blob reads', () => { - let tmpDir: string - let dispatcher: MockDispatcher - let handler: GitHandler - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - }) - - afterEach(async () => { - handler.dispose() - await removeGitTempDir(tmpDir) - }) - - it('shows an untracked text file named maxBuffer as text', async () => { - gitInit(tmpDir) - await writeFile(path.join(tmpDir, 'base.txt'), 'base\n') - gitCommit(tmpDir, 'initial') - await writeFile(path.join(tmpDir, 'maxBuffer'), 'plain text addition\n') - const result = await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'maxBuffer', - staged: false - }) - expect(result).toMatchObject({ - kind: 'text', - originalContent: '', - modifiedContent: 'plain text addition\n' - }) - }) - - it('uses an empty index blob as the unstaged baseline without reading HEAD', async () => { - gitInit(tmpDir) - await writeFile(path.join(tmpDir, 'file.txt'), 'committed\n') - gitCommit(tmpDir, 'initial') - await writeFile(path.join(tmpDir, 'file.txt'), '') - const staged = await runProcess({ program: 'git', args: ['add', 'file.txt'], cwd: tmpDir }) - expect(staged.code).toBe(0) - await writeFile(path.join(tmpDir, 'file.txt'), 'working\n') - - const result = await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'file.txt', - staged: false - }) - - expect(result).toMatchObject({ - kind: 'text', - originalContent: '', - modifiedContent: 'working\n' - }) - }) - - it('still falls back to HEAD after a staged deletion', async () => { - gitInit(tmpDir) - await writeFile(path.join(tmpDir, 'file.txt'), 'committed\n') - gitCommit(tmpDir, 'initial') - const removed = await runProcess({ - program: 'git', - args: ['rm', '--cached', 'file.txt'], - cwd: tmpDir - }) - expect(removed.code).toBe(0) - await writeFile(path.join(tmpDir, 'file.txt'), 'working\n') - - const result = await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'file.txt', - staged: false - }) - - expect(result).toMatchObject({ - kind: 'text', - originalContent: 'committed\n', - modifiedContent: 'working\n' - }) - }) -}) - -describe('independent relay blob reads', () => { - function deferredBlobs() { - const releases: (() => void)[] = [] - const gitBuffer = vi.fn( - (args) => - new Promise((resolve) => { - const content = - args[2].startsWith(':') || args[2].startsWith('b'.repeat(40)) - ? 'modified\n' - : 'original\n' - releases.push(() => resolve(Buffer.from(content))) - }) - ) - return { gitBuffer, releases } - } - - it.each(['staged', 'commit'] as const)( - 'starts both %s sides before either completes', - async (kind) => { - const { gitBuffer, releases } = deferredBlobs() - const resultPromise = - kind === 'staged' - ? computeDiff(gitBuffer, '/repo', 'file.txt', true) - : commitDiffEntry(gitBuffer, '/repo', { - commitOid: 'b'.repeat(40), - parentOid: 'a'.repeat(40), - filePath: 'file.txt' - }) - const readsStartedTogether = gitBuffer.mock.calls.length - // Why iterative: the old sequential implementation starts its second read after the first resolves. - for (let i = 0; i < 2; i += 1) { - releases[i]?.() - await Promise.resolve() - await Promise.resolve() - } - const result = await resultPromise - - expect(readsStartedTogether).toBe(2) - expect(gitBuffer).toHaveBeenCalledTimes(2) - expect(result).toMatchObject({ - kind: 'text', - originalContent: 'original\n', - modifiedContent: 'modified\n' - }) - } - ) - - it('reads only the right side for a root commit', async () => { - const gitBuffer = vi.fn().mockResolvedValue(Buffer.from('added\n')) - const result = await commitDiffEntry(gitBuffer, '/repo', { - commitOid: 'b'.repeat(40), - filePath: 'file.txt' - }) - - expect(gitBuffer).toHaveBeenCalledTimes(1) - expect(result).toMatchObject({ originalContent: '', modifiedContent: 'added\n' }) - }) -}) diff --git a/src/relay/git-handler-diff-read-coalescing.test.ts b/src/relay/git-handler-diff-read-coalescing.test.ts deleted file mode 100644 index dd511545949..00000000000 --- a/src/relay/git-handler-diff-read-coalescing.test.ts +++ /dev/null @@ -1,484 +0,0 @@ -/** - * GitHandler relay diff-read coalescing: in-flight sharing of identical - * diff/branchDiff/commitDiff blob reads, and the invalidation points that - * force a fresh read. - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as path from 'node:path' -import { mkdirSync, writeFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import type { GitHandler } from './git-handler' -import { gitInit, gitCommit, type MockDispatcher } from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir, - type GitBufferSpyTarget, - type GitSpyTarget -} from './git-handler-test-harness' - -function deferredRelayBuffer(content: string): { - promise: Promise - resolve: () => void -} { - let resolve!: (value: Buffer) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { - promise, - resolve: () => resolve(Buffer.from(content)) - } -} - -async function waitForSpyCalls(mock: ReturnType, calls: number): Promise { - for (let i = 0; i < 20; i++) { - if (mock.mock.calls.length >= calls) { - return - } - await new Promise((resolve) => setTimeout(resolve, 0)) - } -} - -describe('GitHandler', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - let tmpDir: string - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - describe('branchDiff', () => { - it('coalesces concurrent identical git.diff reads while in flight and reads fresh after settle', async () => { - const leftBlob = deferredRelayBuffer('left\n') - const rightBlob = deferredRelayBuffer('right\n') - const pendingBuffers = [leftBlob, rightBlob] - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockImplementation(async () => pendingBuffers.shift()!.promise) - - const reads = Array.from({ length: 8 }, () => - dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: true - }) - ) - - await waitForSpyCalls(gitBufferSpy, 1) - leftBlob.resolve() - await waitForSpyCalls(gitBufferSpy, 2) - rightBlob.resolve() - - await Promise.all(reads) - - expect(gitBufferSpy).toHaveBeenCalledTimes(2) - - gitBufferSpy - .mockResolvedValueOnce(Buffer.from('fresh-left\n')) - .mockResolvedValueOnce(Buffer.from('fresh-right\n')) - - await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: true - }) - - expect(gitBufferSpy).toHaveBeenCalledTimes(4) - }) - - it('clears pending git.diff reads when status runs', async () => { - const firstBlob = deferredRelayBuffer('left\n') - const secondBlob = deferredRelayBuffer('fresh-left\n') - const pendingBuffers = [firstBlob, secondBlob] - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockImplementation(async () => pendingBuffers.shift()!.promise) - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockResolvedValue({ stdout: '', stderr: '' }) - - const first = dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: false - }) - await waitForSpyCalls(gitBufferSpy, 1) - - await dispatcher.callRequest('git.status', { worktreePath: tmpDir }) - - const second = dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: false - }) - await waitForSpyCalls(gitBufferSpy, 2) - - firstBlob.resolve() - secondBlob.resolve() - await Promise.all([first, second]) - - expect(gitBufferSpy).toHaveBeenCalledTimes(2) - expect(gitSpy).toHaveBeenCalled() - }) - - it('clears pending git.diff reads when a mutation runs', async () => { - const firstBlob = deferredRelayBuffer('left\n') - const secondBlob = deferredRelayBuffer('fresh-left\n') - const pendingBuffers = [firstBlob, secondBlob] - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockImplementation(async () => pendingBuffers.shift()!.promise) - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockResolvedValue({ stdout: '', stderr: '' }) - - const first = dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: false - }) - await waitForSpyCalls(gitBufferSpy, 1) - - await dispatcher.callRequest('git.stage', { worktreePath: tmpDir, filePath: 'src/file.ts' }) - - const second = dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: false - }) - await waitForSpyCalls(gitBufferSpy, 2) - - firstBlob.resolve() - secondBlob.resolve() - await Promise.all([first, second]) - - expect(gitBufferSpy).toHaveBeenCalledTimes(2) - expect(gitSpy).toHaveBeenCalledWith(['add', '--', ':(literal)src/file.ts'], tmpDir) - const submodulePathReads = gitSpy.mock.calls.filter( - ([args]) => args[0] === 'config' && args.includes('.gitmodules') - ) - expect(submodulePathReads).toHaveLength(2) - }) - - it('clears pending git.diff reads when a narrow ref fetch runs', async () => { - const firstBlob = deferredRelayBuffer('left\n') - const secondBlob = deferredRelayBuffer('fresh-left\n') - const pendingBuffers = [firstBlob, secondBlob] - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockImplementation(async () => pendingBuffers.shift()!.promise) - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - const first = dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: false - }) - await waitForSpyCalls(gitBufferSpy, 1) - - await dispatcher.callRequest('git.fetchRemoteTrackingRef', { - worktreePath: tmpDir, - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/main', - skipAutoMaintenance: true - }) - - const second = dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: false - }) - await waitForSpyCalls(gitBufferSpy, 2) - - firstBlob.resolve() - secondBlob.resolve() - await Promise.all([first, second]) - - expect(gitBufferSpy).toHaveBeenCalledTimes(2) - expect(gitSpy).toHaveBeenCalledWith( - [ - '-c', - 'maintenance.auto=false', - '-c', - 'maintenance.commit-graph.auto=0', - '-c', - 'gc.auto=0', - 'fetch', - '--no-tags', - 'origin', - '+refs/heads/main:refs/remotes/origin/main' - ], - tmpDir - ) - }) - - it('coalesces concurrent identical git.branchDiff reads while in flight', async () => { - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockImplementation(async (args: string[]) => { - if (args[0] === 'rev-parse' && args.includes('HEAD')) { - return { stdout: `${'c'.repeat(40)}\n`, stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: `${'b'.repeat(40)}\n`, stderr: '' } - } - if (args[0] === 'merge-base') { - return { stdout: `${'a'.repeat(40)}\n`, stderr: '' } - } - if (args.includes('--name-status')) { - return { - stdout: `M\0src/file.ts\0`, - stderr: '' - } - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - const leftBlob = deferredRelayBuffer('left\n') - const rightBlob = deferredRelayBuffer('right\n') - const pendingBuffers = [leftBlob, rightBlob] - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockImplementation(async () => pendingBuffers.shift()!.promise) - - const reads = Array.from({ length: 8 }, () => - dispatcher.callRequest('git.branchDiff', { - worktreePath: tmpDir, - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts' - }) - ) - - await waitForSpyCalls(gitBufferSpy, 1) - leftBlob.resolve() - await waitForSpyCalls(gitBufferSpy, 2) - rightBlob.resolve() - - await Promise.all(reads) - - expect(gitBufferSpy).toHaveBeenCalledTimes(2) - expect(gitSpy).toHaveBeenCalledTimes(4) - }) - - it('coalesces concurrent identical git.commitDiff reads while in flight', async () => { - const leftBlob = deferredRelayBuffer('left\n') - const rightBlob = deferredRelayBuffer('right\n') - const pendingBuffers = [leftBlob, rightBlob] - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockImplementation(async () => pendingBuffers.shift()!.promise) - - const reads = Array.from({ length: 8 }, () => - dispatcher.callRequest('git.commitDiff', { - worktreePath: tmpDir, - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts' - }) - ) - - await waitForSpyCalls(gitBufferSpy, 1) - leftBlob.resolve() - await waitForSpyCalls(gitBufferSpy, 2) - rightBlob.resolve() - - await Promise.all(reads) - - expect(gitBufferSpy).toHaveBeenCalledTimes(2) - }) - - it('coalesces parentless root git.commitDiff reads without a left-side blob', async () => { - const rightBlob = deferredRelayBuffer('right\n') - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockImplementation(async () => rightBlob.promise) - - const reads = Array.from({ length: 8 }, () => - dispatcher.callRequest('git.commitDiff', { - worktreePath: tmpDir, - commitOid: 'c'.repeat(40), - parentOid: null, - filePath: 'src/file.ts' - }) - ) - - await waitForSpyCalls(gitBufferSpy, 1) - rightBlob.resolve() - await Promise.all(reads) - - expect(gitBufferSpy).toHaveBeenCalledTimes(1) - }) - - it('keeps distinct relay diff keys independent', async () => { - const gitBufferSpy = vi - .spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer') - .mockResolvedValue(Buffer.from('blob\n')) - - await Promise.all([ - dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: true - }), - dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'src/file.ts', - staged: false, - compareAgainstHead: true - }) - ]) - - expect(gitBufferSpy).toHaveBeenCalledTimes(3) - - gitBufferSpy.mockClear() - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockImplementation(async (args: string[]) => { - if (args[0] === 'rev-parse' && args.includes('HEAD')) { - return { stdout: `${'c'.repeat(40)}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('develop')) { - return { stdout: `${'d'.repeat(40)}\n`, stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: `${'b'.repeat(40)}\n`, stderr: '' } - } - if (args[0] === 'merge-base' && args.includes('d'.repeat(40))) { - return { stdout: `${'e'.repeat(40)}\n`, stderr: '' } - } - if (args[0] === 'merge-base') { - return { stdout: `${'a'.repeat(40)}\n`, stderr: '' } - } - if (args.includes('--name-status')) { - return { - stdout: `M\0src/file.ts\0`, - stderr: '' - } - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - await Promise.all([ - dispatcher.callRequest('git.branchDiff', { - worktreePath: tmpDir, - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts' - }), - dispatcher.callRequest('git.branchDiff', { - worktreePath: tmpDir, - baseRef: 'main', - includePatch: false, - filePath: 'src/file.ts' - }), - dispatcher.callRequest('git.branchDiff', { - worktreePath: tmpDir, - baseRef: 'develop', - includePatch: true, - filePath: 'src/file.ts' - }) - ]) - - expect(gitSpy).toHaveBeenCalledTimes(12) - expect(gitBufferSpy).toHaveBeenCalledTimes(4) - - gitBufferSpy.mockClear() - - await Promise.all([ - dispatcher.callRequest('git.commitDiff', { - worktreePath: tmpDir, - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts' - }), - dispatcher.callRequest('git.commitDiff', { - worktreePath: tmpDir, - commitOid: 'c'.repeat(40), - parentOid: 'a'.repeat(40), - filePath: 'src/file.ts' - }), - dispatcher.callRequest('git.commitDiff', { - worktreePath: tmpDir, - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-a.ts' - }), - dispatcher.callRequest('git.commitDiff', { - worktreePath: tmpDir, - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-b.ts' - }) - ]) - - expect(gitBufferSpy).toHaveBeenCalledTimes(8) - }) - - it('retries relay diff reads after an in-flight rejection settles', async () => { - const invalidRequest = { - worktreePath: tmpDir, - commitOid: 'not-a-full-oid', - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts' - } - const first = dispatcher.callRequest('git.commitDiff', invalidRequest) - const firstBurst = [ - first, - ...Array.from({ length: 7 }, () => dispatcher.callRequest('git.commitDiff', invalidRequest)) - ] - - await expect(Promise.all(firstBurst)).rejects.toThrow( - 'commitOid must be a full git object id' - ) - - const retry = dispatcher.callRequest('git.commitDiff', invalidRequest) - expect(retry).not.toBe(first) - await expect(retry).rejects.toThrow('commitOid must be a full git object id') - }) - - it.each(process.platform === 'win32' ? ['sample.md'] : ['sample.md', 'tab\tline\n"日本語.md'])( - 'preserves literal branch-diff path characters: %s', - async (name) => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - - const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - - execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - const utf8Dir = path.join(tmpDir, 'docs', '日本語') - mkdirSync(utf8Dir, { recursive: true }) - writeFileSync(path.join(utf8Dir, name), 'hello') - gitCommit(tmpDir, 'feature commit') - - const result = await dispatcher.callRequest('git.branchDiff', { - worktreePath: tmpDir, - baseRef, - filePath: `docs/日本語/${name}` - }) - - expect(result).toHaveLength(1) - } - ) - }) -}) diff --git a/src/relay/git-handler-diff-retry.test.ts b/src/relay/git-handler-diff-retry.test.ts deleted file mode 100644 index 14dd0a4eefa..00000000000 --- a/src/relay/git-handler-diff-retry.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createGitHandlerRelay } from './git-handler-test-harness' -import type { GitHandlerOperationHost } from './git-handler-operation-context' - -function deferredBlob() { - let resolve: (value: Buffer) => void = () => { - throw new Error('Deferred promise is not initialized') - } - const promise = new Promise((nextResolve) => { - resolve = nextResolve - }) - return { promise, resolve } -} - -const comparisons = [ - { method: 'git.diff', params: { staged: true } }, - { - method: 'git.branchDiff', - params: { baseRef: 'a'.repeat(40), headOid: 'b'.repeat(40), includePatch: true } - }, - { - method: 'git.commitDiff', - params: { parentOid: 'a'.repeat(40), commitOid: 'b'.repeat(40) } - } -] - -beforeEach(() => vi.useFakeTimers()) -afterEach(() => vi.useRealTimers()) - -describe('relay diff retries after a hung read', () => { - it.each(comparisons)( - '$method starts a fresh read after 30 seconds', - async ({ method, params }) => { - const { handler, dispatcher } = createGitHandlerRelay() - const oldBlob = deferredBlob() - const freshBlob = deferredBlob() - const signals: AbortSignal[] = [] - const gitBuffer = vi.fn( - async (_args, _cwd, options) => { - if (!options?.signal) { - throw new Error('The diff read did not receive its shared cancellation signal') - } - signals.push(options.signal) - return signals.length <= 2 ? oldBlob.promise : freshBlob.promise - } - ) - Object.assign(handler, { gitBuffer, git: async () => ({ stdout: '', stderr: '' }) }) - const request = { worktreePath: '/repo', filePath: 'file.txt', ...params } - const resultFor = (content: string) => { - const diff = { originalContent: content, modifiedContent: content } - return method === 'git.branchDiff' ? [diff] : diff - } - try { - const first = dispatcher.callRequest(method, request) - await vi.advanceTimersByTimeAsync(29_999) - const joined = dispatcher.callRequest(method, request) - await vi.advanceTimersByTimeAsync(0) - expect(gitBuffer).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(1) - const retry = dispatcher.callRequest(method, request) - await vi.advanceTimersByTimeAsync(0) - expect(gitBuffer).toHaveBeenCalledTimes(4) - expect(signals.every((signal) => !signal.aborted)).toBe(true) - - oldBlob.resolve(Buffer.from('old content\n')) - await expect(first).resolves.toMatchObject(resultFor('old content\n')) - await expect(joined).resolves.toMatchObject(resultFor('old content\n')) - const retryJoin = dispatcher.callRequest(method, request) - await vi.advanceTimersByTimeAsync(0) - expect(gitBuffer).toHaveBeenCalledTimes(4) - freshBlob.resolve(Buffer.from('fresh content\n')) - await expect(retry).resolves.toMatchObject(resultFor('fresh content\n')) - await expect(retryJoin).resolves.toMatchObject(resultFor('fresh content\n')) - expect(vi.getTimerCount()).toBe(0) - } finally { - handler.dispose() - } - } - ) -}) diff --git a/src/relay/git-handler-discard-operations.ts b/src/relay/git-handler-discard-operations.ts deleted file mode 100644 index f5e6fc1770c..00000000000 --- a/src/relay/git-handler-discard-operations.ts +++ /dev/null @@ -1,132 +0,0 @@ -import * as path from 'node:path' -import { GitHandlerOperationContext, GIT_BULK_CHUNK_SIZE } from './git-handler-operation-context' -import { - removeSafeUntrackedDiscardTarget, - removeSafeUntrackedDiscardTargets -} from '../shared/git-discard-path-safety' -import { partitionTrackedPathSpecs } from '../shared/git-tracked-pathspecs' -import { detectConflictOperation } from './git-handler-status-ops' -import { encodeGitPathspecs } from '../shared/git-pathspec-stdin' - -const BULK_CHUNK_SIZE = GIT_BULK_CHUNK_SIZE - -export class GitHandlerDiscardOperations extends GitHandlerOperationContext { - private assertInWorktree(worktreePath: string, filePath: string): string { - const resolved = path.resolve(worktreePath, filePath) - const rel = path.relative(path.resolve(worktreePath), resolved) - // Why: empty rel or '.' means the path IS the worktree root; reject (with parent-escaping paths) so a discard can't wipe the whole worktree. - if ( - !rel || - rel === '.' || - rel === '..' || - rel.startsWith(`..${path.sep}`) || - path.isAbsolute(rel) - ) { - throw new Error(`Path "${filePath}" resolves outside the worktree`) - } - return resolved - } - - async discard(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePath = params.filePath as string - try { - this.assertInWorktree(worktreePath, filePath) - - let tracked = false - try { - await this.git( - ['ls-files', '--error-unmatch', '--', this.literalPathspec(filePath)], - worktreePath - ) - tracked = true - } catch { - // untracked - } - - if (tracked) { - await this.git( - ['restore', '--worktree', '--', this.literalPathspec(filePath)], - worktreePath - ) - return - } - - await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => - this.cleanUntrackedPaths(worktreePath, [targetPath]) - ) - } finally { - this.clearGitMutationReadCaches() - } - } - - async bulkDiscard(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePaths = params.filePaths as string[] - if (filePaths.length === 0) { - return - } - try { - for (const filePath of filePaths) { - this.assertInWorktree(worktreePath, filePath) - } - - const trackedPathSpecs: string[] = [] - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - const { stdout } = await this.git( - ['ls-files', '-z', '--', ...chunk.map((p) => this.literalPathspec(p))], - worktreePath - ) - // Why: a selected tracked directory can make `ls-files -z` return enough descendants for push(...split) to exceed the argument limit. - for (const trackedPathSpec of stdout.split('\0')) { - if (trackedPathSpec) { - trackedPathSpecs.push(trackedPathSpec) - } - } - } - - const { trackedPaths, untrackedPaths } = partitionTrackedPathSpecs( - filePaths, - trackedPathSpecs - ) - await removeSafeUntrackedDiscardTargets( - worktreePath, - untrackedPaths, - (targetPaths) => this.cleanUntrackedPaths(worktreePath, targetPaths), - async () => { - if (trackedPaths.length === 0) { - return - } - await this.git( - ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], - worktreePath, - { stdin: encodeGitPathspecs(trackedPaths.map((p) => this.literalPathspec(p))) } - ) - } - ) - } finally { - this.clearGitMutationReadCaches() - } - } - - private async cleanUntrackedPaths(worktreePath: string, filePaths: readonly string[]) { - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - if (chunk.length > 0) { - // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await this.git( - ['clean', '-ffdx', '--', ...chunk.map((p) => this.literalPathspec(p))], - worktreePath - ) - } - } - } - - async conflictOperation(params: Record) { - const worktreePath = params.worktreePath as string - return detectConflictOperation(worktreePath) - } -} diff --git a/src/relay/git-handler-exec-operations.ts b/src/relay/git-handler-exec-operations.ts deleted file mode 100644 index cd43ad0c170..00000000000 --- a/src/relay/git-handler-exec-operations.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { RequestContext } from '../wsl-guest/dispatcher' -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { validateGitExecArgs } from './git-handler-ops' -import { gitExecMutatesRepository } from '../shared/git-exec-mutation' -import { normalizeGitErrorMessage } from '../shared/git-remote-error' -import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' - -export class GitHandlerExecOperations extends GitHandlerOperationContext { - async exec(params: Record, context?: RequestContext) { - const args = params.args as string[] - const cwd = params.cwd as string - - validateGitExecArgs(args) - const run = () => this.git(args, cwd, { signal: context?.signal }) - const { stdout, stderr } = gitExecMutatesRepository(args) - ? await this.runWithGitReadCacheClear(run) - : await run() - return this.maybeStreamResponse({ stdout, stderr }, params, context) - } - - async clone(params: Record, context?: RequestContext) { - const args = params.args as string[] - const cwd = params.cwd as string - const progressId = params.progressId - validateGitExecArgs(args) - if (typeof progressId !== 'string' || progressId.length === 0) { - throw new Error('Missing clone progress id.') - } - if (args[0] !== 'clone') { - throw new Error('git.clone only supports clone commands.') - } - return await this.runWithGitReadCacheClear(() => - this.spawnClone(args, cwd, progressId, context) - ) - } - - // Why: generic git.exec blocks all `git config` writes outright (CONFIG_READ_ONLY_FLAGS), - // so a deferred fork remote's provenance marker (#17828) needs its own narrow RPC that - // only ever writes this fixed key shape, mirroring renameCurrentBranch below. - async markRemoteOrcaCreated(params: Record) { - const repoPath = params.repoPath - const remoteName = params.remoteName - if (typeof repoPath !== 'string' || typeof remoteName !== 'string' || !remoteName) { - throw new Error('Invalid remote provenance marker request.') - } - if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) { - throw new Error('Invalid remote name for provenance marker.') - } - await this.git(['config', `remote.${remoteName}.orca-created`, 'true'], repoPath) - } - - async renameCurrentBranch(params: Record) { - return this.runWithGitReadCacheClear(async () => { - const worktreePath = params.worktreePath - const newBranch = params.newBranch - if (typeof worktreePath !== 'string' || typeof newBranch !== 'string') { - throw new Error('Invalid branch rename request.') - } - if (newBranch.startsWith('-')) { - throw new Error('Branch name must not start with "-".') - } - try { - // Why: generic git.exec blocks destructive branch flags; this narrow RPC permits only the already-checked current-branch rename. - await this.git(['check-ref-format', '--branch', newBranch], worktreePath) - await this.git(['branch', '-m', newBranch], worktreePath) - } catch (error) { - throw new Error(normalizeGitErrorMessage(error)) - } - }) - } - - async forceDeletePreservedBranch(params: Record) { - const repoPath = params.repoPath - const branchName = params.branchName - const expectedHead = params.expectedHead - if ( - typeof repoPath !== 'string' || - typeof branchName !== 'string' || - typeof expectedHead !== 'string' - ) { - throw new Error('Invalid preserved branch force-delete request.') - } - // Why: empty repoPath would target the relay's own cwd with a destructive update-ref, and NUL bytes can't reach git safely — reject both. - if (!repoPath || repoPath.includes('\0') || expectedHead.includes('\0')) { - throw new Error('Invalid preserved branch force-delete request.') - } - return this.runWithGitReadCacheClear(() => - forceDeletePreservedRelayBranch(this.git.bind(this), repoPath, branchName, expectedHead) - ) - } -} diff --git a/src/relay/git-handler-fetch-operations.ts b/src/relay/git-handler-fetch-operations.ts deleted file mode 100644 index a2ee8646109..00000000000 --- a/src/relay/git-handler-fetch-operations.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { RequestContext } from '../wsl-guest/dispatcher' -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { assertValidGitPushTarget } from '../shared/git-push-target-validation' -import type { GitPushTarget } from '../shared/worktree/types' -import { normalizeGitErrorMessage, isExecKilledError } from '../shared/git-remote-error' -import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync' -import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' -import { - githubPullRequestHeadLocalRef, - gitlabMergeRequestHeadLocalRef, - isSafeReviewHeadFetchRemote, - isValidReviewHeadNumber, - reviewHeadRemoteRefComponent, - REVIEW_HEAD_FETCH_TIMEOUT_MS -} from '../shared/review-head-tracking-ref' - -export class GitHandlerFetchOperations extends GitHandlerOperationContext { - async fetch(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - try { - try { - if (params.pushTarget !== undefined) { - assertValidGitPushTarget(params.pushTarget) - const pushTarget = params.pushTarget as GitPushTarget - await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) - await this.git(['fetch', '--prune', pushTarget.remoteName], worktreePath) - return - } - await this.git(['fetch', '--prune'], worktreePath) - } catch (error) { - // Why: normalize like local gitFetch so SSH users get actionable messages, not raw stderr (may embed credentials). - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - async forkSync(params: Record, context?: RequestContext) { - return this.runWithGitReadCacheClear(async () => { - const worktreePath = params.worktreePath as string - const expectedUpstream = validateGitForkSyncExpectedUpstream(params.expectedUpstream, { - required: true - }) - const controller = new AbortController() - const abortFromContext = () => controller.abort() - if (context?.signal?.aborted) { - controller.abort() - } else { - context?.signal?.addEventListener('abort', abortFromContext, { once: true }) - } - const timeout = setTimeout(() => controller.abort(), 60_000) - try { - return await syncForkDefaultBranch( - (args) => - this.git(args, worktreePath, { - nonInteractive: true, - signal: controller.signal - }), - { expectedUpstream } - ) - } catch (error) { - throw new Error(normalizeGitErrorMessage(error, 'push')) - } finally { - clearTimeout(timeout) - context?.signal?.removeEventListener('abort', abortFromContext) - } - }) - } - - async fetchRemoteTrackingRef(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const remote = params.remote - const branch = params.branch - const ref = params.ref - const skipAutoMaintenance = params.skipAutoMaintenance - try { - if (typeof remote !== 'string' || typeof branch !== 'string' || typeof ref !== 'string') { - throw new Error('Invalid remote-tracking fetch request.') - } - if (skipAutoMaintenance !== undefined && typeof skipAutoMaintenance !== 'boolean') { - throw new Error('Invalid remote-tracking fetch maintenance option.') - } - if (remote.startsWith('-') || branch.startsWith('-')) { - throw new Error('Remote-tracking fetch inputs must not start with "-".') - } - if (ref !== `refs/remotes/${remote}/${branch}`) { - throw new Error('Remote-tracking ref does not match the requested remote and branch.') - } - - try { - const { stdout } = await this.git(['remote'], worktreePath) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - if (!remotes.includes(remote)) { - throw new Error(`Remote "${remote}" is not configured.`) - } - await this.git(['check-ref-format', `refs/heads/${branch}`], worktreePath) - await this.git(['check-ref-format', ref], worktreePath) - await this.git( - [ - ...(skipAutoMaintenance ? GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS : []), - 'fetch', - '--no-tags', - remote, - `+refs/heads/${branch}:${ref}` - ], - worktreePath - ) - } catch (error) { - // Why: create-worktree needs a write-capable fetch that generic git.exec rejects; narrow RPC keeps the allowlist tight. - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - // Why: the durable review-head ref embeds the remote's identity, and a - // missing remote must fail with an actionable message, not a raw fetch error. - private async reviewHeadRemoteComponent(worktreePath: string, remote: string): Promise { - let remoteUrl: string - try { - const { stdout } = await this.git(['remote', 'get-url', remote], worktreePath) - remoteUrl = stdout.trim() - } catch { - remoteUrl = '' - } - if (!remoteUrl) { - throw new Error(`Remote "${remote}" is not configured.`) - } - return reviewHeadRemoteRefComponent(remote, remoteUrl) - } - - async fetchGitLabMergeRequestHead(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const remote = params.remote - const mrIid = params.mrIid - try { - if (typeof remote !== 'string' || !isValidReviewHeadNumber(mrIid)) { - throw new Error('Invalid GitLab merge request fetch request.') - } - const mergeRequestIid = mrIid - if (!isSafeReviewHeadFetchRemote(remote)) { - throw new Error('GitLab merge request fetch remote must not start with "-".') - } - - try { - const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) - // Why: GitLab fork heads need a dedicated write RPC and ref outside refs/heads/*. - // Return the exact written path so the client does not re-hash a second get-url. - const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mergeRequestIid) - await this.git( - [ - 'fetch', - '--no-tags', - remote, - `+refs/merge-requests/${mergeRequestIid}/head:${localRef}` - ], - worktreePath, - { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } - ) - return { localRef } - } catch (error) { - // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. - if (isExecKilledError(error)) { - throw new Error( - `Fetching refs/merge-requests/${mergeRequestIid}/head from "${remote}" timed out.` - ) - } - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - async fetchGitHubPullRequestHead(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const remote = params.remote - const prNumber = params.prNumber - try { - if (typeof remote !== 'string' || !isValidReviewHeadNumber(prNumber)) { - throw new Error('Invalid GitHub pull request fetch request.') - } - if (!isSafeReviewHeadFetchRemote(remote)) { - throw new Error('GitHub pull request fetch remote must not start with "-".') - } - - try { - const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) - // Why: return the written path so resolve can rev-parse the same ref the host wrote. - const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) - await this.git( - ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], - worktreePath, - { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } - ) - return { localRef } - } catch (error) { - // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. - if (isExecKilledError(error)) { - throw new Error(`Fetching refs/pull/${prNumber}/head from "${remote}" timed out.`) - } - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } -} diff --git a/src/relay/git-handler-file-diff.test.ts b/src/relay/git-handler-file-diff.test.ts deleted file mode 100644 index 3e1f90ea779..00000000000 --- a/src/relay/git-handler-file-diff.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * GitHandler per-file diff payloads: worktree/staged diffs, submodule diff and - * status routing, and branch-compare entries. - */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { execFileSync } from 'node:child_process' -import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../shared/large-diff-render-limit' -import { gitInit, gitCommit, type MockDispatcher } from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - normalizeGitFileText, - removeGitTempDir -} from './git-handler-test-harness' - -describe('GitHandler', () => { - let dispatcher: MockDispatcher - let tmpDir: string - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher } = createGitHandlerRelay()) - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - describe('diff', () => { - it('returns text diff for modified file', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'original') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') - - const result = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'file.txt', - staged: false - })) as { kind: string; originalContent: string; modifiedContent: string } - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('original') - expect(result.modifiedContent).toBe('modified') - }) - - it('returns staged diff', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'original') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'staged-content') - execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) - - const result = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'file.txt', - staged: true - })) as { kind: string; originalContent: string; modifiedContent: string } - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('original') - expect(result.modifiedContent).toBe('staged-content') - }) - - it('omits over-limit text bodies before returning diff payloads', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'original') - gitCommit(tmpDir, 'initial') - const oversizedText = 'a'.repeat(MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1) - writeFileSync(path.join(tmpDir, 'file.txt'), oversizedText) - - const result = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'file.txt', - staged: false - })) as { - kind: string - originalContent: string - modifiedContent: string - largeDiffRenderLimit?: { limited: boolean; reason?: string; characterCount?: number } - } - - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('') - expect(result.modifiedContent).toBe('') - expect(result.largeDiffRenderLimit?.limited).toBe(true) - expect(result.largeDiffRenderLimit?.reason).toBe('character-count') - expect(result.largeDiffRenderLimit?.characterCount).toBe( - oversizedText.length + 'original'.length - ) - }) - - it('returns diff for tracked files in valid dot-dot-prefixed directories', async () => { - gitInit(tmpDir) - mkdirSync(path.join(tmpDir, '..fixtures')) - writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'original') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'modified') - - const result = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: '..fixtures/file.txt', - staged: false - })) as { kind: string; originalContent: string; modifiedContent: string } - - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('original') - expect(result.modifiedContent).toBe('modified') - }) - - it('rejects diff paths that traverse outside the worktree', async () => { - gitInit(tmpDir) - - await expect( - dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: '../outside.txt', - staged: false - }) - ).rejects.toThrow('outside the worktree') - }) - }) - - describe('submodule', () => { - const extraDirs: string[] = [] - - afterEach(async () => { - await Promise.all( - extraDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })) - ) - }) - - // Why: `git submodule add` against a local path is blocked since git 2.38 unless protocol.file.allow=always is set. - function addSubmodule(parent: string, name: string): string { - const src = mkdtempSync(path.join(tmpdir(), 'relay-subsrc-')) - extraDirs.push(src) - gitInit(src) - writeFileSync(path.join(src, 'lib.txt'), 'v1\n') - gitCommit(src, 'sub initial') - execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', src, name], { - cwd: parent, - stdio: 'pipe' - }) - execFileSync('git', ['commit', '-m', 'add submodule'], { cwd: parent, stdio: 'pipe' }) - return path.join(parent, name) - } - - it('returns inner per-file changes via git.submoduleStatus', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'root.txt'), 'root') - gitCommit(tmpDir, 'initial') - const sub = addSubmodule(tmpDir, 'flutter_mine') - writeFileSync(path.join(sub, 'lib.txt'), 'v2\n') - - const result = (await dispatcher.callRequest('git.submoduleStatus', { - worktreePath: tmpDir, - submodulePath: 'flutter_mine' - })) as { entries: { path?: unknown; status?: unknown; area?: unknown }[] } - - const inner = result.entries.find((e) => e.path === 'lib.txt') - expect(inner).toBeDefined() - expect(inner!.status).toBe('modified') - expect(inner!.area).toBe('unstaged') - }) - - it('rejects submoduleStatus paths that escape the worktree', async () => { - gitInit(tmpDir) - await expect( - dispatcher.callRequest('git.submoduleStatus', { - worktreePath: tmpDir, - submodulePath: '../outside' - }) - ).rejects.toThrow('outside the worktree') - }) - - it('routes inner submodule files into the submodule worktree diff', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'root.txt'), 'root') - gitCommit(tmpDir, 'initial') - const sub = addSubmodule(tmpDir, 'flutter_mine') - writeFileSync(path.join(sub, 'lib.txt'), 'v2\n') - - const result = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'flutter_mine/lib.txt', - staged: false - })) as { kind: string; originalContent: string; modifiedContent: string } - - expect(result.kind).toBe('text') - expect(normalizeGitFileText(result.originalContent)).toBe('v1\n') - expect(normalizeGitFileText(result.modifiedContent)).toBe('v2\n') - }) - - it('synthesizes a Subproject commit pointer diff for the gitlink root', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'root.txt'), 'root') - gitCommit(tmpDir, 'initial') - const sub = addSubmodule(tmpDir, 'flutter_mine') - const oldOid = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: sub, - encoding: 'utf-8' - }).trim() - writeFileSync(path.join(sub, 'lib.txt'), 'v2\n') - execFileSync('git', ['add', 'lib.txt'], { cwd: sub, stdio: 'pipe' }) - gitCommit(sub, 'sub second') - const newOid = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: sub, - encoding: 'utf-8' - }).trim() - - const result = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'flutter_mine', - staged: false - })) as { kind: string; originalContent: string; modifiedContent: string } - - expect(result.kind).toBe('text') - expect(result.originalContent).toBe(`Subproject commit ${oldOid}\n`) - expect(result.modifiedContent).toBe(`Subproject commit ${newOid}\n`) - }) - - // Why: a moved gitlink with a clean submodule has no uncommitted rows, so status/diff must surface the committed commit-range changes. - it('lists commit-range files and diffs them when the pointer moved', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'root.txt'), 'root') - gitCommit(tmpDir, 'initial') - const sub = addSubmodule(tmpDir, 'flutter_mine') - writeFileSync(path.join(sub, 'lib.txt'), 'v2\n') - execFileSync('git', ['add', 'lib.txt'], { cwd: sub, stdio: 'pipe' }) - gitCommit(sub, 'sub second') - - const status = (await dispatcher.callRequest('git.submoduleStatus', { - worktreePath: tmpDir, - submodulePath: 'flutter_mine' - })) as { entries: { path?: unknown; status?: unknown; area?: unknown }[] } - const ranged = status.entries.find((e) => e.path === 'lib.txt') - expect(ranged).toBeDefined() - expect(ranged!.status).toBe('modified') - expect(ranged!.area).toBe('unstaged') - - const diff = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'flutter_mine/lib.txt', - staged: false - })) as { kind: string; originalContent: string; modifiedContent: string } - expect(diff.kind).toBe('text') - expect(normalizeGitFileText(diff.originalContent)).toBe('v1\n') - expect(normalizeGitFileText(diff.modifiedContent)).toBe('v2\n') - }) - - it('lists and diffs staged submodule pointer changes from parent HEAD to index', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'root.txt'), 'root') - gitCommit(tmpDir, 'initial') - const sub = addSubmodule(tmpDir, 'flutter_mine') - writeFileSync(path.join(sub, 'lib.txt'), 'v2\n') - execFileSync('git', ['add', 'lib.txt'], { cwd: sub, stdio: 'pipe' }) - gitCommit(sub, 'sub second') - execFileSync('git', ['add', 'flutter_mine'], { cwd: tmpDir, stdio: 'pipe' }) - - const status = (await dispatcher.callRequest('git.submoduleStatus', { - worktreePath: tmpDir, - submodulePath: 'flutter_mine', - area: 'staged' - })) as { entries: { path?: unknown; status?: unknown; area?: unknown }[] } - const ranged = status.entries.find((e) => e.path === 'lib.txt') - expect(ranged).toBeDefined() - expect(ranged!.status).toBe('modified') - expect(ranged!.area).toBe('unstaged') - - const diff = (await dispatcher.callRequest('git.diff', { - worktreePath: tmpDir, - filePath: 'flutter_mine/lib.txt', - staged: true - })) as { kind: string; originalContent: string; modifiedContent: string } - expect(diff.kind).toBe('text') - expect(normalizeGitFileText(diff.originalContent)).toBe('v1\n') - expect(normalizeGitFileText(diff.modifiedContent)).toBe('v2\n') - }) - }) - - describe('branchCompare', () => { - it('compares branch against base', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - - execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature') - gitCommit(tmpDir, 'feature commit') - - const result = (await dispatcher.callRequest('git.branchCompare', { - worktreePath: tmpDir, - baseRef: 'master' - })) as { summary: Record; entries: Record[] } - - // May be 'master' or error if default branch is 'main' - if (result.summary.status === 'ready') { - expect(result.entries.length).toBeGreaterThan(0) - expect(result.summary.commitsAhead).toBe(1) - } - }) - - // Why: regression for #1503 on the branch-diff path; without -c core.quotePath=false diff paths are octal-escaped. - it('preserves UTF-8 paths in branch-compare entries', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - - // Capture the default branch name so the test works regardless of init.defaultBranch (master vs main). - const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - - execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - const utf8Dir = path.join(tmpDir, 'docs', '日本語') - mkdirSync(utf8Dir, { recursive: true }) - writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') - gitCommit(tmpDir, 'feature commit') - - const result = (await dispatcher.callRequest('git.branchCompare', { - worktreePath: tmpDir, - baseRef - })) as { summary: Record; entries: Record[] } - - expect(result.summary.status).toBe('ready') - const entry = result.entries.find((e) => - typeof e.path === 'string' ? e.path.endsWith('sample.md') : false - ) - expect(entry).toBeDefined() - expect(entry!.path).toBe('docs/日本語/sample.md') - }) - - it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - - execFileSync('git', ['checkout', '--orphan', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['rm', '-rf', '.'], { cwd: tmpDir, stdio: 'pipe' }) - - const result = (await dispatcher.callRequest('git.branchCompare', { - worktreePath: tmpDir, - baseRef - })) as { summary: Record; entries: Record[] } - - expect(result.summary).toMatchObject({ - baseRef, - compareRef: 'feature', - headOid: null, - changedFiles: 0, - commitsAhead: 0, - status: 'ready' - }) - expect(result.summary.baseOid).toMatch(/^[0-9a-f]{40}$/) - expect(result.entries).toEqual([]) - }) - }) -}) diff --git a/src/relay/git-handler-fork-remote-exec.test.ts b/src/relay/git-handler-fork-remote-exec.test.ts deleted file mode 100644 index 484ee59e7f8..00000000000 --- a/src/relay/git-handler-fork-remote-exec.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * The relay's git.exec must carry the fork-PR remote setup that SSH worktree - * creation performs: adding the contributor's fork as a remote, and dropping it - * again when the last worktree using it is removed. - */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import { execFileSync } from 'node:child_process' -import { gitInit, type MockDispatcher } from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from './git-handler-test-harness' - -const FORK_REMOTE = 'pr-contributor-orca' -const FORK_URL = 'https://github.com/contributor/orca.git' - -describe('GitHandler git.exec fork remote', () => { - let dispatcher: MockDispatcher - let tmpDir: string - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher } = createGitHandlerRelay()) - gitInit(tmpDir) - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - function configuredRemotes(): string { - return execFileSync('git', ['remote'], { cwd: tmpDir, encoding: 'utf-8' }).trim() - } - - it('adds and removes the fork remote', async () => { - await dispatcher.callRequest('git.exec', { - args: ['remote', 'add', FORK_REMOTE, FORK_URL], - cwd: tmpDir - }) - expect(configuredRemotes()).toBe(FORK_REMOTE) - - const url = (await dispatcher.callRequest('git.exec', { - args: ['remote', 'get-url', FORK_REMOTE], - cwd: tmpDir - })) as { stdout: string } - expect(url.stdout.trim()).toBe(FORK_URL) - - await dispatcher.callRequest('git.exec', { - args: ['remote', 'remove', FORK_REMOTE], - cwd: tmpDir - }) - expect(configuredRemotes()).toBe('') - }) - - it('still refuses to repoint an existing remote', async () => { - await expect( - dispatcher.callRequest('git.exec', { - args: ['remote', 'set-url', 'origin', FORK_URL], - cwd: tmpDir - }) - ).rejects.toThrow('Destructive git remote operations are not allowed via exec') - }) -}) diff --git a/src/relay/git-handler-local-base-ref-refresh.ts b/src/relay/git-handler-local-base-ref-refresh.ts deleted file mode 100644 index 3dd9b7db302..00000000000 --- a/src/relay/git-handler-local-base-ref-refresh.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { GitExec } from './git-handler-ops' -import { readRelayWorktreeList } from './git-handler-worktree-ops' -import type { GitCapabilityCache } from '../shared/git-capability-cache' -import { createCoalescingKeyedRunner } from '../shared/coalescing-keyed-runner' -import { - fastForwardLocalBaseBranch, - inspectLocalBaseBranch, - type LocalBaseBranchFastForwardOutcome, - type LocalBaseBranchGit, - type LocalBaseBranchInspection, - type LocalBaseBranchRefs -} from '../shared/worktree/local-base-branch-fast-forward' - -// Why: the host owns this; one run per branch at a time here also serializes creates from every client of this relay. -const runPerLocalBaseBranch = createCoalescingKeyedRunner() - -export async function refreshLocalBaseRefForWorktreeCreateOp( - git: GitExec, - params: Record, - capabilities: GitCapabilityCache -): Promise { - const refs = await readLocalBaseBranchRefs(git, params) - const host = relayLocalBaseBranchGit(git, capabilities) - return runPerLocalBaseBranch(`${refs.repoPath}\0${refs.fullRef}`, refs.remoteTrackingRef, () => - fastForwardLocalBaseBranch(host, refs) - ) -} - -export async function inspectLocalBaseRefForWorktreeCreateOp( - git: GitExec, - params: Record, - capabilities: GitCapabilityCache -): Promise { - const refs = await readLocalBaseBranchRefs(git, params) - return inspectLocalBaseBranch(relayLocalBaseBranchGit(git, capabilities), refs) -} - -async function readLocalBaseBranchRefs( - git: GitExec, - params: Record -): Promise { - const { repoPath, fullRef, remoteTrackingRef } = params - if ( - typeof repoPath !== 'string' || - typeof fullRef !== 'string' || - typeof remoteTrackingRef !== 'string' - ) { - throw new Error('Invalid local base ref refresh request.') - } - if (!fullRef.startsWith('refs/heads/') || !remoteTrackingRef.startsWith('refs/remotes/')) { - throw new Error('Invalid local base ref refresh refs.') - } - await git(['check-ref-format', fullRef], repoPath) - await git(['check-ref-format', remoteTrackingRef], repoPath) - return { repoPath, fullRef, remoteTrackingRef } -} - -function relayLocalBaseBranchGit( - git: GitExec, - capabilities: GitCapabilityCache -): LocalBaseBranchGit { - return { - exec: (args, cwd) => git(args, cwd), - listWorktrees: (repoPath) => readRelayWorktreeList(git, repoPath, capabilities) - } -} diff --git a/src/relay/git-handler-object-diff-operations.ts b/src/relay/git-handler-object-diff-operations.ts deleted file mode 100644 index bb668664e9c..00000000000 --- a/src/relay/git-handler-object-diff-operations.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { RequestContext } from '../wsl-guest/dispatcher' -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { branchDiffEntries } from './git-handler-ops' -import { - branchDiffEntryAtPinnedOids, - isFullGitObjectId, - parseOptionalBranchDiffHeadOid -} from './git-handler-branch-diff-ops' -import { commitDiffEntry } from './git-handler-commit-diff-ops' -import { stableInFlightKey } from '../shared/in-flight-promise-dedupe' - -export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { - async reviewDiff(params: Record, context?: RequestContext) { - const { worktreePath, mergeBase, format } = params - if ( - typeof worktreePath !== 'string' || - !worktreePath || - worktreePath.includes('\0') || - !isFullGitObjectId(mergeBase) || - (format !== 'name-status' && format !== 'patch') - ) { - throw new Error('Invalid review diff request.') - } - const flags = - format === 'name-status' - ? ['--name-status'] - : ['--patch', '--minimal', '--no-color', '--no-ext-diff'] - const result = await this.git(['diff', ...flags, `${mergeBase}..HEAD`, '--'], worktreePath, { - signal: context?.signal, - disableOptionalLocks: true - }) - context?.signal?.throwIfAborted() - return this.maybeStreamResponse(result, params, context) - } - - async branchDiff(params: Record, context?: RequestContext) { - const worktreePath = params.worktreePath as string - const baseRef = params.baseRef as string - if (baseRef.startsWith('-')) { - throw new Error('Base ref must not start with "-"') - } - const headOid = parseOptionalBranchDiffHeadOid(params) - const options = { - includePatch: params.includePatch as boolean | undefined, - filePath: params.filePath as string | undefined, - oldPath: params.oldPath as string | undefined - } - const result = await this.gitDiffReadDedupe.lease( - stableInFlightKey([ - 'branchDiff', - worktreePath, - baseRef, - headOid ?? null, - options.includePatch ?? null, - options.filePath ?? null, - options.oldPath ?? null - ]), - context?.signal, - (signal) => { - if ( - headOid && - isFullGitObjectId(baseRef) && - options.includePatch === true && - typeof options.filePath === 'string' && - options.filePath.length > 0 - ) { - return branchDiffEntryAtPinnedOids( - this.gitBufferForSignal(signal), - worktreePath, - baseRef, - headOid, - options.filePath, - options.oldPath - ) - } - return branchDiffEntries( - this.gitForSignal(signal), - this.gitBufferForSignal(signal), - worktreePath, - baseRef, - options - ) - } - ) - return this.maybeStreamResponse(result, params, context) - } - - async commitDiff(params: Record, context?: RequestContext) { - const worktreePath = params.worktreePath as string - const args = { - commitOid: params.commitOid as string, - parentOid: params.parentOid as string | null | undefined, - filePath: params.filePath as string, - oldPath: params.oldPath as string | undefined - } - const result = await this.gitDiffReadDedupe.lease( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null - ]), - context?.signal, - (signal) => commitDiffEntry(this.gitBufferForSignal(signal), worktreePath, args) - ) - return this.maybeStreamResponse(result, params, context) - } -} diff --git a/src/relay/git-handler-operation-context.ts b/src/relay/git-handler-operation-context.ts deleted file mode 100644 index cf79b4a089a..00000000000 --- a/src/relay/git-handler-operation-context.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { RequestContext } from '../wsl-guest/dispatcher' -import type { GitStatusReadLeaseOwner } from '../shared/git-status-read-lease-owner' -import type { GitCapabilityCache } from '../shared/git-capability-cache' -import type { SubmodulePathsCache } from './git-handler-submodule-ops' -import type { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -export const GIT_BULK_CHUNK_SIZE = 100 - -export type GitHandlerCommandOptions = { - maxBuffer?: number - disableOptionalLocks?: boolean - signal?: AbortSignal - nonInteractive?: boolean - stdin?: string - timeout?: number - terminationBarrier?: boolean -} - -export type GitHandlerCommandResult = { stdout: string; stderr: string } -export type GitHandlerWatcherRegistry = Pick - -export type GitHandlerOperationHost = { - readonly gitDiffReadDedupe: GitStatusReadLeaseOwner - readonly gitCapabilities: GitCapabilityCache - readonly submodulePathsCache: SubmodulePathsCache - readonly watcherRegistry: GitHandlerWatcherRegistry | undefined - git( - args: string[], - cwd: string, - opts?: GitHandlerCommandOptions - ): Promise - gitBuffer(args: string[], cwd: string, opts?: GitHandlerCommandOptions): Promise - spawnClone( - args: string[], - cwd: string, - progressId: string, - context?: RequestContext - ): Promise - clearGitMutationReadCaches(): void - runWithGitReadCacheClear(run: () => Promise): Promise - maybeStreamResponse( - result: unknown, - params: Record, - context: RequestContext | undefined - ): unknown -} - -export abstract class GitHandlerOperationContext { - constructor(private readonly host: GitHandlerOperationHost) {} - - protected get gitDiffReadDedupe(): GitStatusReadLeaseOwner { - return this.host.gitDiffReadDedupe - } - - protected get gitCapabilities(): GitCapabilityCache { - return this.host.gitCapabilities - } - - protected get submodulePathsCache(): SubmodulePathsCache { - return this.host.submodulePathsCache - } - - protected get watcherRegistry(): GitHandlerWatcherRegistry | undefined { - return this.host.watcherRegistry - } - - protected git( - args: string[], - cwd: string, - opts?: GitHandlerCommandOptions - ): Promise { - return this.host.git(args, cwd, opts) - } - - protected gitBuffer( - args: string[], - cwd: string, - opts?: GitHandlerCommandOptions - ): Promise { - return this.host.gitBuffer(args, cwd, opts) - } - - protected gitForSignal(signal?: AbortSignal) { - return signal - ? (args: string[], cwd: string, opts?: GitHandlerCommandOptions) => - this.git(args, cwd, { ...opts, signal }) - : this.git.bind(this) - } - - protected gitBufferForSignal(signal?: AbortSignal) { - return signal - ? (args: string[], cwd: string) => this.gitBuffer(args, cwd, { signal }) - : this.gitBuffer.bind(this) - } - - protected spawnClone( - args: string[], - cwd: string, - progressId: string, - context?: RequestContext - ): Promise { - return this.host.spawnClone(args, cwd, progressId, context) - } - - protected clearGitMutationReadCaches(): void { - this.host.clearGitMutationReadCaches() - } - - protected runWithGitReadCacheClear(run: () => Promise): Promise { - return this.host.runWithGitReadCacheClear(run) - } - - protected maybeStreamResponse( - result: unknown, - params: Record, - context: RequestContext | undefined - ): unknown { - return this.host.maybeStreamResponse(result, params, context) - } - - protected literalPathspec(filePath: string): string { - // Why: source-control selections are concrete paths, not user-authored Git globs. - return `:(literal)${filePath}` - } -} diff --git a/src/relay/git-handler-operation-set.ts b/src/relay/git-handler-operation-set.ts deleted file mode 100644 index fda295edb7e..00000000000 --- a/src/relay/git-handler-operation-set.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { GitHandlerOperationHost } from './git-handler-operation-context' -import { GitHandlerReadOperations } from './git-handler-read-operations' -import { GitHandlerWorktreeChangeOperations } from './git-handler-worktree-change-operations' -import { GitHandlerDiscardOperations } from './git-handler-discard-operations' -import { GitHandlerComparisonOperations } from './git-handler-comparison-operations' -import { GitHandlerFetchOperations } from './git-handler-fetch-operations' -import { GitHandlerSyncOperations } from './git-handler-sync-operations' -import { GitHandlerObjectDiffOperations } from './git-handler-object-diff-operations' -import { GitHandlerExecOperations } from './git-handler-exec-operations' -import { GitHandlerWorktreeOperations } from './git-handler-worktree-operations' - -export function createGitHandlerOperationSet(host: GitHandlerOperationHost) { - const read = new GitHandlerReadOperations(host) - const changes = new GitHandlerWorktreeChangeOperations(host) - const discard = new GitHandlerDiscardOperations(host) - const comparison = new GitHandlerComparisonOperations(host) - const fetch = new GitHandlerFetchOperations(host) - const sync = new GitHandlerSyncOperations(host) - const objectDiff = new GitHandlerObjectDiffOperations(host) - const exec = new GitHandlerExecOperations(host) - const worktree = new GitHandlerWorktreeOperations(host) - - return { - read, - changes, - discard, - comparison, - fetch, - sync, - objectDiff, - exec, - worktree - } -} - -export type GitHandlerOperationSet = ReturnType diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts deleted file mode 100644 index 4339808d652..00000000000 --- a/src/relay/git-handler-ops.ts +++ /dev/null @@ -1,337 +0,0 @@ -/** - * Higher-level git operations extracted from git-handler.ts. - * - * Why: oxlint max-lines requires files to stay under 300 lines. - * These async operations accept a git executor callback so they - * remain decoupled from the GitHandler class. - */ -import * as path from 'node:path' -import { isMissingGitBlobPath } from '../shared/git-blob-absence' -import { bufferToBlob } from './git-handler-utils' -import { parseGitChangeList } from '../shared/git-change-list' -import { buildDiffResult } from './git-diff-result' -import { isGitBufferOverflowError, isGitReadInterruptedError } from './git-buffer-overflow' -import { readWorkingDiffFile } from './git-working-file-read' - -// ─── Executor types ────────────────────────────────────────────────── - -export type GitExec = ( - args: string[], - cwd: string, - opts?: { - maxBuffer?: number - disableOptionalLocks?: boolean - signal?: AbortSignal - stdin?: string - timeout?: number - } -) => Promise<{ stdout: string; stderr: string }> - -export type GitBufferExec = (args: string[], cwd: string) => Promise - -// ─── Blob reading ──────────────────────────────────────────────────── - -export async function readBlobAtOid( - gitBuffer: GitBufferExec, - cwd: string, - oid: string, - filePath: string -): Promise<{ content: string; isBinary: boolean }> { - // Why: Git's `:` syntax expects forward slashes even on Windows. - const gitPath = filePath.replace(/\\/g, '/') - try { - const buf = await gitBuffer(['show', '--end-of-options', `${oid}:${gitPath}`], cwd) - return bufferToBlob(buf, filePath) - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - if (isGitBufferOverflowError(error)) { - return { content: '', isBinary: true } - } - return { content: '', isBinary: false } - } -} - -export async function readBlobAtIndex( - gitBuffer: GitBufferExec, - cwd: string, - filePath: string -): Promise<{ content: string; isBinary: boolean; missing: boolean }> { - // Why: Git's `:` syntax expects forward slashes even on Windows. - const gitPath = filePath.replace(/\\/g, '/') - try { - const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd) - return { ...bufferToBlob(buf, filePath), missing: false } - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - if (isGitBufferOverflowError(error)) { - return { content: '', isBinary: true, missing: false } - } - return { content: '', isBinary: false, missing: isMissingGitBlobPath(error, gitPath) } - } -} - -export async function readUnstagedLeft( - gitBuffer: GitBufferExec, - cwd: string, - filePath: string -): Promise<{ content: string; isBinary: boolean }> { - const index = await readBlobAtIndex(gitBuffer, cwd, filePath) - if (!index.missing) { - return index - } - return readBlobAtOid(gitBuffer, cwd, 'HEAD', filePath) -} - -// ─── Diff ──────────────────────────────────────────────────────────── - -export async function computeDiff( - git: GitBufferExec, - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead = false -) { - let originalContent = '' - let modifiedContent = '' - let originalIsBinary = false - let modifiedIsBinary = false - let modifiedDeleted = false - - try { - if (staged) { - const [left, right] = await Promise.all([ - readBlobAtOid(git, worktreePath, 'HEAD', filePath), - readBlobAtIndex(git, worktreePath, filePath) - ]) - originalContent = left.content - originalIsBinary = left.isBinary - modifiedContent = right.content - modifiedIsBinary = right.isBinary - modifiedDeleted = right.missing - } else { - const left = compareAgainstHead - ? await readBlobAtOid(git, worktreePath, 'HEAD', filePath) - : await readUnstagedLeft(git, worktreePath, filePath) - originalContent = left.content - originalIsBinary = left.isBinary - - const right = await readWorkingDiffFile(path.join(worktreePath, filePath)) - modifiedContent = right.content - modifiedIsBinary = right.isBinary - modifiedDeleted = right.missing - } - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - // Fallback to empty - } - - const result = buildDiffResult( - originalContent, - modifiedContent, - originalIsBinary, - modifiedIsBinary, - filePath - ) - // Why: mark a proven deletion so previewers can fall back to the original bytes - // without mistaking a read failure's empty modified side for a deletion. - if (result.kind === 'binary' && modifiedDeleted) { - return { ...result, modifiedDeleted: true } - } - return result -} - -// ─── Branch compare ────────────────────────────────────────────────── - -export async function branchCompare( - git: GitExec, - worktreePath: string, - baseRef: string, - loadBranchChanges: (mergeBase: string, headOid: string) => Promise[]> -) { - const summary: Record = { - baseRef, - baseOid: null, - compareRef: 'HEAD', - headOid: null, - mergeBase: null, - changedFiles: 0, - status: 'loading' - } - - const readCompareRef = async (): Promise => { - try { - const { stdout } = await git(['branch', '--show-current'], worktreePath) - return stdout.trim() || 'HEAD' - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - return 'HEAD' - } - } - const readOid = (ref: string) => - git(['rev-parse', '--verify', ref], worktreePath).then( - ({ stdout }) => ({ ok: true as const, oid: stdout.trim() }), - (error) => ({ ok: false as const, error }) - ) - const [compareRef, headOidResult, baseOidResult] = await Promise.all([ - readCompareRef(), - readOid('HEAD'), - readOid(baseRef) - ]) - summary.compareRef = compareRef - - if (!headOidResult.ok) { - if (baseOidResult.ok) { - summary.baseOid = baseOidResult.oid - // Why: new remote worktrees can be on an unborn branch until the first - // commit. There are no committed branch changes yet; surfacing this as a - // compare error makes the source-control panel look broken. - summary.changedFiles = 0 - summary.commitsAhead = 0 - summary.commitsBehind = 0 - summary.status = 'ready' - return { summary, entries: [] } - } - summary.status = 'unborn-head' - summary.errorMessage = - 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' - return { summary, entries: [] } - } - - const headOid = headOidResult.oid - summary.headOid = headOid - if (!baseOidResult.ok) { - summary.status = 'invalid-base' - summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` - return { summary, entries: [] } - } - const baseOid = baseOidResult.oid - summary.baseOid = baseOid - - let mergeBase: string - try { - const { stdout } = await git(['merge-base', baseOid, headOid], worktreePath) - mergeBase = stdout.trim() - summary.mergeBase = mergeBase - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - summary.status = 'no-merge-base' - summary.errorMessage = `This branch and ${baseRef} do not share a merge base, so compare-to-base is unavailable.` - return { summary, entries: [] } - } - - // Git must confirm equal raw tips are the same commit before skipping the reads. - if (baseOid === headOid && mergeBase === headOid) { - summary.commitsAhead = 0 - summary.commitsBehind = 0 - summary.status = 'ready' - return { summary, entries: [] } - } - - try { - const [entries, { stdout: countOut }] = await Promise.all([ - loadBranchChanges(mergeBase, headOid), - git(['rev-list', '--left-right', '--count', `${baseOid}...${headOid}`], worktreePath) - ]) - summary.changedFiles = entries.length - const [behindOut = '', aheadOut = ''] = countOut.trim().split(/\s+/) - summary.commitsAhead = Number.parseInt(aheadOut, 10) || 0 - summary.commitsBehind = Number.parseInt(behindOut, 10) || 0 - summary.status = 'ready' - return { summary, entries } - } catch (error) { - summary.status = 'error' - summary.errorMessage = error instanceof Error ? error.message : 'Failed to load branch compare' - return { summary, entries: [] } - } -} - -// ─── Branch diff ───────────────────────────────────────────────────── - -export async function branchDiffEntries( - git: GitExec, - gitBuffer: GitBufferExec, - worktreePath: string, - baseRef: string, - opts: { includePatch?: boolean; filePath?: string; oldPath?: string } -) { - let headOid: string - let mergeBase: string - try { - const { stdout: headOut } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath) - headOid = headOut.trim() - - const { stdout: baseOut } = await git(['rev-parse', '--verify', baseRef], worktreePath) - const baseOid = baseOut.trim() - - const { stdout: mbOut } = await git(['merge-base', baseOid, headOid], worktreePath) - mergeBase = mbOut.trim() - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - return [] - } - - const { stdout } = await git( - ['diff', '--name-status', '-z', '-M', '-C', mergeBase, headOid, '--'], - worktreePath - ) - const allChanges = parseGitChangeList(stdout, 'name-status') - - // Why: the IPC handler for single-file branch diff sends filePath/oldPath - // to avoid reading blobs for every changed file — only the matched file. - let changes = allChanges - if (opts.filePath) { - changes = allChanges.filter( - (c) => - c.path === opts.filePath || - c.oldPath === opts.filePath || - (opts.oldPath && (c.path === opts.oldPath || c.oldPath === opts.oldPath)) - ) - } - - if (!opts.includePatch) { - return changes.map(() => ({ - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - })) - } - - const results: Record[] = [] - for (const change of changes) { - const fp = change.path - const oldP = change.oldPath ?? fp - try { - const left = await readBlobAtOid(gitBuffer, worktreePath, mergeBase, oldP) - const right = await readBlobAtOid(gitBuffer, worktreePath, headOid, fp) - results.push(buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, fp)) - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - results.push({ - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - }) - } - } - return results -} - -export { validateGitExecArgs } from './git-exec-validator' diff --git a/src/relay/git-handler-pull-reconciliation.test.ts b/src/relay/git-handler-pull-reconciliation.test.ts deleted file mode 100644 index 5db00d16202..00000000000 --- a/src/relay/git-handler-pull-reconciliation.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { execFileSync } from 'node:child_process' -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { tmpdir } from 'node:os' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { - createMockDispatcher, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -describe('GitHandler pull reconciliation', () => { - let dispatcher: MockDispatcher - let tmpDir: string - let gitEnv: NodeJS.ProcessEnv - let previousGitConfigGlobal: string | undefined - let previousGitConfigNosystem: string | undefined - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-pull-reconciliation-')) - const globalGitConfigPath = path.join(tmpDir, 'global-gitconfig') - writeFileSync(globalGitConfigPath, '') - previousGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL - previousGitConfigNosystem = process.env.GIT_CONFIG_NOSYSTEM - process.env.GIT_CONFIG_GLOBAL = globalGitConfigPath - process.env.GIT_CONFIG_NOSYSTEM = '1' - gitEnv = { - ...process.env, - GIT_CONFIG_GLOBAL: globalGitConfigPath, - GIT_CONFIG_NOSYSTEM: '1' - } - dispatcher = createMockDispatcher() - const ctx = new RelayContext() - new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) - }) - - afterEach(async () => { - restoreGitEnv('GIT_CONFIG_GLOBAL', previousGitConfigGlobal) - restoreGitEnv('GIT_CONFIG_NOSYSTEM', previousGitConfigNosystem) - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - function execGit(cwd: string, args: string[]): string { - return execFileSync('git', args, { - cwd, - encoding: 'utf-8', - env: gitEnv, - stdio: 'pipe' - }) - } - - function configureIdentity(cwd: string): void { - execGit(cwd, ['config', 'user.email', 'test@test.com']) - execGit(cwd, ['config', 'user.name', 'Test']) - } - - function commitAll(cwd: string, message: string): void { - execGit(cwd, ['add', '.']) - execGit(cwd, ['commit', '-m', message]) - } - - function createDivergentFixture(): string { - const bareDir = path.join(tmpDir, 'origin.git') - const consumerDir = path.join(tmpDir, 'consumer') - const producerDir = path.join(tmpDir, 'producer') - - execGit(tmpDir, ['init', '--bare', bareDir]) - execGit(tmpDir, ['clone', bareDir, consumerDir]) - configureIdentity(consumerDir) - writeFileSync(path.join(consumerDir, 'base.txt'), 'base\n') - commitAll(consumerDir, 'initial') - execGit(consumerDir, ['push', '--set-upstream', 'origin', 'HEAD']) - - execGit(tmpDir, ['clone', bareDir, producerDir]) - configureIdentity(producerDir) - writeFileSync(path.join(producerDir, 'remote.txt'), 'remote\n') - commitAll(producerDir, 'remote') - execGit(producerDir, ['push']) - - writeFileSync(path.join(consumerDir, 'local.txt'), 'local\n') - commitAll(consumerDir, 'local') - - return consumerDir - } - - it('falls back to a merge when divergent branches have no configured strategy', async () => { - const consumerDir = createDivergentFixture() - - await expect( - dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) - ).resolves.not.toThrow() - - // Merge reconciliation yields a two-parent commit and both sides' files. - const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) - expect(parentRefs).toHaveLength(2) - expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) - expect(existsSync(path.join(consumerDir, 'local.txt'))).toBe(true) - expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }, 15_000) - - it('preserves configured fast-forward-only pull semantics on divergent branches', async () => { - const consumerDir = createDivergentFixture() - execGit(consumerDir, ['config', 'pull.ff', 'only']) - - // Why: an explicit ff-only policy must still fail on divergence rather than - // getting silently reconciled by the merge fallback. - await expect( - dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) - ).rejects.toThrow() - - const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) - expect(parentRefs).toHaveLength(1) - expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }, 15_000) - - it('preserves configured rebase pull semantics', async () => { - const consumerDir = createDivergentFixture() - execGit(consumerDir, ['config', 'pull.rebase', 'true']) - - await expect( - dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) - ).resolves.not.toThrow() - - const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) - expect(parentRefs).toHaveLength(1) - expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) - expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }, 15_000) -}) - -function restoreGitEnv( - name: 'GIT_CONFIG_GLOBAL' | 'GIT_CONFIG_NOSYSTEM', - value: string | undefined -): void { - if (value === undefined) { - delete process.env[name] - return - } - process.env[name] = value -} diff --git a/src/relay/git-handler-push-target.test.ts b/src/relay/git-handler-push-target.test.ts deleted file mode 100644 index 3b9e85ab6e6..00000000000 --- a/src/relay/git-handler-push-target.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { resolveRelayPushTarget } from './git-handler-push-target' - -type GitArgs = string[] - -function gitForConfig(config: { - branch?: string - pushRemote?: string | Error - pushDefault?: string | Error - branchRemote?: string | Error - merge?: string - base?: string | Error - remotes?: string[] - remoteUrls?: Record -}) { - const branch = config.branch ?? 'feature/fix' - const merge = config.merge ?? `refs/heads/${branch}` - return vi.fn(async (args: GitArgs) => { - if (args[0] === 'symbolic-ref') { - return { stdout: `${branch}\n`, stderr: '' } - } - if (args[0] === 'config' && args[1] === '--list') { - const values = [ - [`branch.${branch}.pushremote`, config.pushRemote], - ['remote.pushdefault', config.pushDefault], - [`branch.${branch}.remote`, config.branchRemote], - [`branch.${branch}.merge`, merge], - [`branch.${branch}.base`, config.base] - ] - return { - stdout: values - .filter(([, value]) => typeof value === 'string') - .map(([key, value]) => `${key}\n${value}\0`) - .join(''), - stderr: '' - } - } - if (args[0] === 'remote' && args[1] === '-v') { - return { - stdout: (config.remotes ?? []) - .flatMap((name) => { - const url = config.remoteUrls?.[name] ?? '' - return [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`] - }) - .join('\n'), - stderr: '' - } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: `${config.remotes?.join('\n') ?? ''}\n`, stderr: '' } - } - if (args[0] === 'remote' && args[1] === 'get-url') { - const remoteUrl = config.remoteUrls?.[args[2] ?? ''] - if (!remoteUrl) { - throw new Error('missing remote URL') - } - return { stdout: `${remoteUrl}\n`, stderr: '' } - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) -} - -describe('resolveRelayPushTarget', () => { - it('uses branch pushRemote for a configured review head branch', async () => { - const git = gitForConfig({ - pushRemote: 'fork', - branchRemote: 'fork', - merge: 'refs/heads/contributor/fix' - }) - - await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({ - remote: 'fork', - refspec: 'HEAD:contributor/fix' - }) - expect(git.mock.calls.filter(([args]) => args[0] === 'config')).toEqual([ - [['config', '--list', '-z'], '/repo'] - ]) - }) - - it('uses remote.pushDefault when branch pushRemote is missing', async () => { - const git = gitForConfig({ - pushRemote: new Error('missing pushRemote'), - pushDefault: 'fork', - branchRemote: 'origin' - }) - - await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({ - remote: 'fork', - refspec: 'HEAD:feature/fix' - }) - }) - - it('uses an explicit push target without reading branch config', async () => { - const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - - await expect( - resolveRelayPushTarget(git, '/repo', { - remoteName: 'fork', - branchName: 'feature/head' - }) - ).resolves.toEqual({ - remote: 'fork', - refspec: 'HEAD:feature/head' - }) - expect(git).toHaveBeenCalledWith(['check-ref-format', '--branch', 'feature/head'], '/repo') - }) -}) diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts deleted file mode 100644 index 57a39e632d3..00000000000 --- a/src/relay/git-handler-push-target.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { assertValidGitPushTarget } from '../shared/git-push-target-validation' -import { - resolveConfiguredGitPushTarget, - type ResolvedGitPushTarget -} from '../shared/git-push-target-resolution' -import type { GitPushTarget } from '../shared/worktree/types' - -type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> - -export async function resolveRelayPushTarget( - git: RelayGit, - worktreePath: string, - pushTarget: unknown -): Promise { - if (pushTarget === undefined) { - return resolveConfiguredGitPushTarget((args) => git(args, worktreePath)) - } - assertValidGitPushTarget(pushTarget) - const explicitTarget: GitPushTarget = pushTarget - // Why here and not in the shared resolver: an explicit target arrives over the wire, - // so the host re-validates its shape and asks Git to vet the branch name itself. - await git(['check-ref-format', '--branch', explicitTarget.branchName], worktreePath) - return { - remote: explicitTarget.remoteName, - refspec: `HEAD:${explicitTarget.branchName}` - } -} diff --git a/src/relay/git-handler-read-operations.ts b/src/relay/git-handler-read-operations.ts deleted file mode 100644 index b031694ec44..00000000000 --- a/src/relay/git-handler-read-operations.ts +++ /dev/null @@ -1,185 +0,0 @@ -import * as path from 'node:path' -import type { RequestContext } from '../wsl-guest/dispatcher' -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { getStatusOp } from './git-handler-status-ops' -import { streamRelayGitStdout } from './git-stdout-stream' -import { capGitStatusEntries, resolveGitStatusLimit } from '../shared/git-status-limit' -import { - buildSubmoduleInnerCommitRangeDiff, - computeSubmodulePointerDiff, - computeSubmoduleRangeEntries, - findContainingSubmodule, - listSubmodulePathsCached, - resolveSubmoduleWorktreePath, - resolveSubmoduleCommitRange -} from './git-handler-submodule-ops' -import { computeDiff, type GitExec } from './git-handler-ops' -import { checkIgnoredPathsOp } from './git-handler-check-ignore' -import { loadGitHistoryFromExecutor } from '../shared/git-history' -import { stableInFlightKey } from '../shared/in-flight-promise-dedupe' - -function resolveSubmoduleStatusArea( - params: Record -): 'staged' | 'unstaged' | 'untracked' { - if (params.area === 'staged' || params.area === 'unstaged' || params.area === 'untracked') { - return params.area - } - return 'unstaged' -} - -export class GitHandlerReadOperations extends GitHandlerOperationContext { - async getStatus(params: Record, context: RequestContext) { - this.gitDiffReadDedupe.invalidate() - return getStatusOp(this.git.bind(this), streamRelayGitStdout, params, { - signal: context.signal - }) - } - - // Why: fetch per-file submodule changes from the submodule worktree. - async getSubmoduleStatus(params: Record, context: RequestContext) { - const worktreePath = params.worktreePath as string - const submodulePath = params.submodulePath as string - const area = resolveSubmoduleStatusArea(params) - const staged = area === 'staged' - const resolved = resolveSubmoduleWorktreePath(worktreePath, submodulePath) - const limit = resolveGitStatusLimit(params.limit) - // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work. - const workingResult = staged - ? { entries: [], conflictOperation: 'unknown' } - : await getStatusOp( - this.git.bind(this), - streamRelayGitStdout, - { - ...params, - worktreePath: resolved - }, - { signal: context.signal } - ) - // Why: pointer/range probes are part of the same SSH request and must not outlive its cancellation. - const requestGit: GitExec = (args, cwd, options) => - this.git(args, cwd, { ...options, signal: context.signal }) - // Why: moved clean gitlinks need committed changes surfaced. - const { fromOid, toOid } = await resolveSubmoduleCommitRange( - requestGit, - worktreePath, - submodulePath, - staged - ) - if (fromOid && toOid && fromOid !== toOid) { - const rangeEntries = await computeSubmoduleRangeEntries(requestGit, resolved, fromOid, toOid) - if (staged) { - return { ...workingResult, ...capGitStatusEntries(rangeEntries, limit) } - } - const rangePaths = new Set(rangeEntries.map((entry) => entry.path)) - const entries = [ - ...rangeEntries, - ...workingResult.entries.filter((entry) => !rangePaths.has(entry.path)) - ] - return { - ...workingResult, - ...capGitStatusEntries(entries, limit, workingResult) - } - } - if (staged) { - return { ...workingResult, entries: [] } - } - return workingResult - } - - async checkIgnored(params: Record, context?: RequestContext) { - const result = await checkIgnoredPathsOp(this.gitForSignal(context?.signal), params) - context?.signal?.throwIfAborted() - return result - } - - async history(params: Record, context?: RequestContext) { - const worktreePath = params.worktreePath as string - const result = await loadGitHistoryFromExecutor( - this.gitForSignal(context?.signal), - worktreePath, - { - limit: typeof params.limit === 'number' ? params.limit : undefined, - baseRef: typeof params.baseRef === 'string' ? params.baseRef : null - } - ) - context?.signal?.throwIfAborted() - return result - } - - async getDiff(params: Record, context?: RequestContext) { - const worktreePath = params.worktreePath as string - const filePath = params.filePath as string - // Why: validate relative paths to prevent traversal outside the worktree. - const resolved = path.resolve(worktreePath, filePath) - const rel = path.relative(path.resolve(worktreePath), resolved) - if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { - throw new Error(`Path "${filePath}" resolves outside the worktree`) - } - const staged = params.staged as boolean - const compareAgainstHead = params.compareAgainstHead as boolean | undefined - // Why: register dedupe before awaiting so identical reads coalesce. - const result = await this.gitDiffReadDedupe.lease( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - context?.signal, - async (signal) => { - // Why: route gitlink roots to pointer diffs and inner files to their submodule worktree. - const submodulePaths = await listSubmodulePathsCached( - this.gitForSignal(signal), - worktreePath, - this.submodulePathsCache - ) - if (submodulePaths.length > 0) { - const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath) - if (matchedSubmodule) { - const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') - if (normalizedFilePath === matchedSubmodule) { - return computeSubmodulePointerDiff( - this.gitForSignal(signal), - worktreePath, - matchedSubmodule, - staged, - compareAgainstHead - ) - } - const submoduleWorktreePath = resolveSubmoduleWorktreePath( - worktreePath, - matchedSubmodule - ) - const innerPath = normalizedFilePath.slice(matchedSubmodule.length + 1) - const { fromOid, toOid } = await resolveSubmoduleCommitRange( - this.gitForSignal(signal), - worktreePath, - matchedSubmodule, - staged - ) - // Why: a moved gitlink (clean worktree) keeps inner changes in committed history, so diff the two commits; otherwise read the working-tree blob. - if (fromOid && toOid && fromOid !== toOid) { - return buildSubmoduleInnerCommitRangeDiff( - this.gitBufferForSignal(signal), - submoduleWorktreePath, - innerPath, - fromOid, - toOid - ) - } - return computeDiff( - this.gitBufferForSignal(signal), - submoduleWorktreePath, - innerPath, - staged, - compareAgainstHead - ) - } - } - return computeDiff( - this.gitBufferForSignal(signal), - worktreePath, - filePath, - staged, - compareAgainstHead - ) - } - ) - return this.maybeStreamResponse(result, params, context) - } -} diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts deleted file mode 100644 index f492ecf845a..00000000000 --- a/src/relay/git-handler-registration.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import type { GitHandlerOperationSet } from './git-handler-operation-set' - -export function registerGitHandlers( - dispatcher: RelayDispatcher, - handlers: GitHandlerOperationSet, - responseAck: (params: Record, context: RequestContext) => void, - cancelResponseStream: (params: Record, context: RequestContext) => void -): void { - dispatcher.onRequest('git.status', (p, context) => handlers.read.getStatus(p, context)) - dispatcher.onRequest('git.submoduleStatus', (p, context) => - handlers.read.getSubmoduleStatus(p, context) - ) - dispatcher.onRequest('git.checkIgnored', (p, context) => handlers.read.checkIgnored(p, context)) - dispatcher.onRequest('git.history', (p, context) => handlers.read.history(p, context)) - dispatcher.onRequest('git.commit', (p) => handlers.changes.commit(p)) - dispatcher.onRequest('git.diff', (p, context) => handlers.read.getDiff(p, context)) - dispatcher.onRequest('git.stage', (p) => handlers.changes.stage(p)) - dispatcher.onRequest('git.unstage', (p) => handlers.changes.unstage(p)) - dispatcher.onRequest('git.bulkStage', (p) => handlers.changes.bulkStage(p)) - dispatcher.onRequest('git.bulkUnstage', (p) => handlers.changes.bulkUnstage(p)) - dispatcher.onRequest('git.abortMerge', (p) => handlers.changes.abortMerge(p)) - dispatcher.onRequest('git.abortRebase', (p) => handlers.changes.abortRebase(p)) - dispatcher.onRequest('git.checkout', (p) => handlers.changes.checkout(p)) - dispatcher.onRequest('git.localBranches', (p) => handlers.changes.localBranches(p)) - dispatcher.onRequest('git.discard', (p) => handlers.discard.discard(p)) - dispatcher.onRequest('git.bulkDiscard', (p) => handlers.discard.bulkDiscard(p)) - dispatcher.onRequest('git.conflictOperation', (p) => handlers.discard.conflictOperation(p)) - dispatcher.onRequest('git.branchCompare', (p, context) => - handlers.comparison.branchCompare(p, context) - ) - dispatcher.onRequest('git.commitCompare', (p, context) => - handlers.comparison.commitCompare(p, context) - ) - dispatcher.onRequest('git.upstreamStatus', (p, context) => - handlers.comparison.upstreamStatus(p, context) - ) - dispatcher.onRequest('git.fetch', (p) => handlers.fetch.fetch(p)) - dispatcher.onRequest('git.forkSync', (p, context) => handlers.fetch.forkSync(p, context)) - dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => - handlers.fetch.fetchRemoteTrackingRef(p) - ) - dispatcher.onRequest('git.fetchGitHubPullRequestHead', (p) => - handlers.fetch.fetchGitHubPullRequestHead(p) - ) - dispatcher.onRequest('git.fetchGitLabMergeRequestHead', (p) => - handlers.fetch.fetchGitLabMergeRequestHead(p) - ) - // Why: the durable-ref variant is a distinct method name so an old relay - // (which only knows FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead) - // returns -32601 and the client can prompt a reconnect instead of silently - // resolving a stale/missing ref. Both names share the durable handler: a - // refspec fetch still writes FETCH_HEAD, so old clients keep their semantics. - dispatcher.onRequest('git.fetchGitLabMergeRequestHeadRef', (p) => - handlers.fetch.fetchGitLabMergeRequestHead(p) - ) - dispatcher.onRequest('git.push', (p) => handlers.sync.push(p)) - dispatcher.onRequest('git.pull', (p, context) => handlers.sync.pull(p, context)) - dispatcher.onRequest('git.fastForward', (p, context) => handlers.sync.fastForward(p, context)) - dispatcher.onRequest('git.rebaseFromBase', (p, context) => - handlers.sync.rebaseFromBase(p, context) - ) - dispatcher.onRequest('git.branchDiff', (p, context) => handlers.objectDiff.branchDiff(p, context)) - dispatcher.onRequest('git.reviewDiff', (p, context) => handlers.objectDiff.reviewDiff(p, context)) - dispatcher.onRequest('git.commitDiff', (p, context) => handlers.objectDiff.commitDiff(p, context)) - dispatcher.onRequest('git.listWorktrees', (p, context) => - handlers.worktree.listWorktrees(p, context) - ) - dispatcher.onRequest('git.addWorktree', (p) => handlers.worktree.addWorktree(p)) - dispatcher.onRequest('git.removeWorktree', (p) => handlers.worktree.removeWorktree(p)) - dispatcher.onRequest('git.worktreeIsClean', (p) => handlers.worktree.worktreeIsClean(p)) - dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => - handlers.worktree.refreshLocalBaseRefForWorktreeCreate(p) - ) - dispatcher.onRequest('git.inspectLocalBaseRefForWorktreeCreate', (p) => - handlers.worktree.inspectLocalBaseRefForWorktreeCreate(p) - ) - dispatcher.onRequest('git.markRemoteOrcaCreated', (p) => handlers.exec.markRemoteOrcaCreated(p)) - dispatcher.onRequest('git.renameCurrentBranch', (p) => handlers.exec.renameCurrentBranch(p)) - dispatcher.onRequest('git.forceDeletePreservedBranch', (p) => - handlers.exec.forceDeletePreservedBranch(p) - ) - dispatcher.onRequest('git.exec', (p, context) => handlers.exec.exec(p, context)) - dispatcher.onRequest('git.clone', (p, context) => handlers.exec.clone(p, context)) - dispatcher.onRequest('git.isGitRepo', (p) => handlers.worktree.isGitRepo(p)) - dispatcher.onNotification('git.responseAck', (p, context) => responseAck(p, context)) - dispatcher.onNotification('git.cancelResponseStream', (p, context) => - cancelResponseStream(p, context) - ) -} diff --git a/src/relay/git-handler-remote-sync.test.ts b/src/relay/git-handler-remote-sync.test.ts deleted file mode 100644 index 4f7adfd5499..00000000000 --- a/src/relay/git-handler-remote-sync.test.ts +++ /dev/null @@ -1,749 +0,0 @@ -/** - * GitHandler remote synchronization RPCs: upstream divergence, fetch, fork - * sync, fast-forward, and the narrow review-head fetches for GitHub pull - * requests and GitLab merge requests. - */ -import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { execFileSync } from 'node:child_process' -import { reviewHeadRemoteRefComponent } from '../shared/review-head-tracking-ref' -import { gitInit, gitCommit, type MockDispatcher } from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir, - type GitSpyTarget -} from './git-handler-test-harness' - -describe('GitHandler', () => { - let dispatcher: MockDispatcher - let tmpDir: string - let gitTarget: GitSpyTarget - - beforeEach(() => { - tmpDir = createGitTempDir() - const relay = createGitHandlerRelay() - dispatcher = relay.dispatcher - gitTarget = relay.handler as unknown as GitSpyTarget - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - describe('remote operations', () => { - it('returns upstream divergence for tracked branches', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - - const result = (await dispatcher.callRequest('git.upstreamStatus', { - worktreePath: tmpDir - })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } - - expect(result.hasUpstream).toBe(false) - expect(result.ahead).toBe(0) - expect(result.behind).toBe(0) - }) - - it('reports ahead/behind counts against a real upstream remote', async () => { - // Why: exercise the configured-upstream happy path (rev-parse HEAD@{u} + rev-list --left-right) the no-upstream test misses. - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - - execFileSync('git', ['remote', 'add', 'origin', bareDir], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['push', '--set-upstream', 'origin', branch], { - cwd: tmpDir, - stdio: 'pipe' - }) - - // Juggle local commits/resets to produce specific ahead/behind counts vs. upstream. - writeFileSync(path.join(tmpDir, 'ahead1.txt'), 'a1') - gitCommit(tmpDir, 'ahead1') - writeFileSync(path.join(tmpDir, 'ahead2.txt'), 'a2') - gitCommit(tmpDir, 'ahead2') - // Push so remote is at ahead2 (so after we reset below, we are behind). - execFileSync('git', ['push', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' }) - // Reset local back to the first commit: 0 ahead, 2 behind. - execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' }) - - const result = (await dispatcher.callRequest('git.upstreamStatus', { - worktreePath: tmpDir - })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } - - expect(result.hasUpstream).toBe(true) - expect(result.upstreamName).toBe(`origin/${branch}`) - expect(result.ahead).toBe(0) - expect(result.behind).toBe(2) - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('reports ahead/behind counts against a configured local-branch upstream', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - - execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['branch', '--set-upstream-to', baseRef], { - cwd: tmpDir, - stdio: 'pipe' - }) - writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature') - gitCommit(tmpDir, 'feature commit') - - const result = (await dispatcher.callRequest('git.upstreamStatus', { - worktreePath: tmpDir - })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } - - expect(result.hasUpstream).toBe(true) - expect(result.upstreamName).toBe(baseRef) - expect(result.ahead).toBe(1) - expect(result.behind).toBe(0) - }) - - it('fetches from a configured remote without throwing', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['push', '--set-upstream', 'origin', branch], { - cwd: tmpDir, - stdio: 'pipe' - }) - - await expect( - dispatcher.callRequest('git.fetch', { worktreePath: tmpDir }) - ).resolves.not.toThrow() - - // FETCH_HEAD exists only after a successful fetch, confirming the remote was actually contacted. - await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined() - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('rebases from the original fork point after a remote force-push', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-rebase-bare-')) - const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-rebase-producer-')) - const producerDir = path.join(producerParent, 'repo') - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'base') - const branch = execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - const forkPoint = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['push', '--set-upstream', 'origin', branch], { - cwd: tmpDir, - stdio: 'pipe' - }) - - execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) - execFileSync('git', ['checkout', branch], { cwd: producerDir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { - cwd: producerDir, - stdio: 'pipe' - }) - execFileSync('git', ['config', 'user.name', 'Test'], { cwd: producerDir, stdio: 'pipe' }) - writeFileSync(path.join(producerDir, 'discarded.txt'), 'discarded') - gitCommit(producerDir, 'discarded remote commit') - execFileSync('git', ['push'], { cwd: producerDir, stdio: 'pipe' }) - execFileSync('git', ['fetch', 'origin'], { cwd: tmpDir, stdio: 'pipe' }) - - execFileSync('git', ['checkout', '-b', 'feature', `origin/${branch}`], { - cwd: tmpDir, - stdio: 'pipe' - }) - writeFileSync(path.join(tmpDir, 'topic.txt'), 'topic') - gitCommit(tmpDir, 'topic commit') - - execFileSync('git', ['reset', '--hard', forkPoint], { cwd: producerDir, stdio: 'pipe' }) - writeFileSync(path.join(producerDir, 'replacement.txt'), 'replacement') - gitCommit(producerDir, 'replacement remote commit') - execFileSync('git', ['push', '--force', 'origin', branch], { - cwd: producerDir, - stdio: 'pipe' - }) - - await dispatcher.callRequest('git.rebaseFromBase', { - worktreePath: tmpDir, - baseRef: `origin/${branch}` - }) - - await expect(fs.access(path.join(tmpDir, 'replacement.txt'))).resolves.toBeUndefined() - await expect(fs.access(path.join(tmpDir, 'topic.txt'))).resolves.toBeUndefined() - await expect(fs.access(path.join(tmpDir, 'discarded.txt'))).rejects.toThrow() - expect( - execFileSync('git', ['rev-parse', `origin/${branch}`], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - ).toBe( - execFileSync('git', ['rev-parse', branch], { - cwd: producerDir, - encoding: 'utf-8' - }).trim() - ) - expect( - execFileSync('git', ['for-each-ref', '--format=%(refname)', 'refs/orca/rebase'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - ).toBe('') - } finally { - await Promise.all([ - fs.rm(bareDir, { recursive: true, force: true }), - fs.rm(producerParent, { recursive: true, force: true }) - ]) - } - }, 15_000) - - it('rebases the selected linked worktree without moving the source worktree', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-linked-rebase-bare-')) - const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-linked-rebase-producer-')) - const producerDir = path.join(producerParent, 'repo') - const targetParent = mkdtempSync(path.join(tmpdir(), 'relay-git-linked-rebase-target-')) - const targetDir = path.join(targetParent, 'feature') - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'base') - const baseBranch = execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['push', '--set-upstream', 'origin', baseBranch], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['worktree', 'add', '-b', 'feature', targetDir, 'HEAD'], { - cwd: tmpDir, - stdio: 'pipe' - }) - writeFileSync(path.join(targetDir, 'topic.txt'), 'topic') - gitCommit(targetDir, 'topic') - writeFileSync(path.join(tmpDir, 'source-dirty.txt'), 'leave me alone') - const sourceHeadBefore = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - const sourceStatusBefore = execFileSync('git', ['status', '--short'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - - execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { - cwd: producerDir, - stdio: 'pipe' - }) - execFileSync('git', ['config', 'user.name', 'Test'], { cwd: producerDir, stdio: 'pipe' }) - execFileSync('git', ['checkout', baseBranch], { cwd: producerDir, stdio: 'pipe' }) - writeFileSync(path.join(producerDir, 'latest.txt'), 'latest') - gitCommit(producerDir, 'latest base') - const latestBaseOid = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: producerDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['push', 'origin', baseBranch], { cwd: producerDir, stdio: 'pipe' }) - - await dispatcher.callRequest('git.rebaseFromBase', { - worktreePath: targetDir, - baseRef: `origin/${baseBranch}` - }) - - expect(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir }).toString().trim()).toBe( - sourceHeadBefore - ) - expect(execFileSync('git', ['status', '--short'], { cwd: tmpDir, encoding: 'utf-8' })).toBe( - sourceStatusBefore - ) - expect( - execFileSync('git', ['rev-parse', 'HEAD~1'], { cwd: targetDir, encoding: 'utf-8' }).trim() - ).toBe(latestBaseOid) - expect( - execFileSync('git', ['reflog', '-1', '--format=%gs'], { - cwd: targetDir, - encoding: 'utf-8' - }) - ).toContain('rebase (finish)') - expect( - execFileSync('git', ['for-each-ref', '--format=%(refname)', 'refs/orca/rebase'], { - cwd: targetDir, - encoding: 'utf-8' - }).trim() - ).toBe('') - } finally { - await Promise.all([ - fs.rm(bareDir, { recursive: true, force: true }), - fs.rm(producerParent, { recursive: true, force: true }), - fs.rm(targetParent, { recursive: true, force: true }) - ]) - } - }, 15_000) - - it('fast-forwards an unborn branch from the selected remote base', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-unborn-bare-')) - const producerDir = mkdtempSync(path.join(tmpdir(), 'relay-git-unborn-producer-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(producerDir) - writeFileSync(path.join(producerDir, 'base.txt'), 'base') - gitCommit(producerDir, 'base') - const branch = execFileSync('git', ['branch', '--show-current'], { - cwd: producerDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { - cwd: producerDir, - stdio: 'pipe' - }) - execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' }) - - gitInit(tmpDir) - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - - await dispatcher.callRequest('git.rebaseFromBase', { - worktreePath: tmpDir, - baseRef: `origin/${branch}` - }) - - await expect(fs.access(path.join(tmpDir, 'base.txt'))).resolves.toBeUndefined() - expect(execFileSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: tmpDir })).toBeTruthy() - } finally { - await Promise.all([ - fs.rm(bareDir, { recursive: true, force: true }), - fs.rm(producerDir, { recursive: true, force: true }) - ]) - } - }) - - it('cancels the active rebase fetch and still removes its private ref', async () => { - const controller = new AbortController() - let rejectFetch!: (error: Error) => void - const fetchStarted = new Promise((resolve) => { - vi.spyOn(gitTarget, 'git').mockImplementation(async (args, _cwd, options) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'merge-base') { - return { stdout: 'fork-point\n', stderr: '' } - } - if (args[0] === 'fetch') { - resolve() - return new Promise((_resolve, reject) => { - rejectFetch = reject - options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { - once: true - }) - }) - } - return { stdout: '', stderr: '' } - }) - }) - - const request = dispatcher.callRequest( - 'git.rebaseFromBase', - { worktreePath: tmpDir, baseRef: 'origin/main' }, - { isStale: () => false, signal: controller.signal } - ) - await fetchStarted - controller.abort() - await expect(request).rejects.toThrow('aborted') - - const calls = vi.mocked(gitTarget.git).mock.calls - expect(calls.some(([args]) => args[0] === 'rebase')).toBe(false) - const cleanup = calls.find(([args]) => args[0] === 'update-ref') - expect(cleanup).toBeDefined() - expect(cleanup?.[2]?.signal).toBeUndefined() - expect(rejectFetch).toBeTypeOf('function') - }) - - it('fetches the explicit publish target remote', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-fork-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - execFileSync('git', ['remote', 'add', 'fork', bareDir], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['push', 'fork', 'HEAD:feature/fix'], { - cwd: tmpDir, - stdio: 'pipe' - }) - - await expect( - dispatcher.callRequest('git.fetch', { - worktreePath: tmpDir, - pushTarget: { remoteName: 'fork', branchName: 'feature/fix' } - }) - ).resolves.not.toThrow() - - await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined() - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('fast-forwards the tracked branch with ff-only pull semantics', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) - const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-')) - const producerDir = path.join(producerParent, 'repo') - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['push', '--set-upstream', 'origin', branch], { - cwd: tmpDir, - stdio: 'pipe' - }) - - execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { - cwd: producerDir, - stdio: 'pipe' - }) - execFileSync('git', ['config', 'user.name', 'Test'], { - cwd: producerDir, - stdio: 'pipe' - }) - writeFileSync(path.join(producerDir, 'remote.txt'), 'remote') - gitCommit(producerDir, 'remote commit') - execFileSync('git', ['push', 'origin', branch], { - cwd: producerDir, - stdio: 'pipe' - }) - - await dispatcher.callRequest('git.fastForward', { worktreePath: tmpDir }) - - await expect(fs.readFile(path.join(tmpDir, 'remote.txt'), 'utf-8')).resolves.toBe('remote') - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - await fs.rm(producerParent, { recursive: true, force: true }) - } - }) - - it('rejects malformed fork sync expected upstream metadata', async () => { - await expect( - dispatcher.callRequest('git.forkSync', { - worktreePath: tmpDir, - expectedUpstream: { owner: ' ', repo: 'orca' } - }) - ).rejects.toThrow('Invalid expected upstream.') - }) - - it('rejects fork sync requests without expected upstream metadata', async () => { - await expect( - dispatcher.callRequest('git.forkSync', { - worktreePath: tmpDir - }) - ).rejects.toThrow('Expected upstream is required.') - }) - - it('aborts fork sync when the relay request is canceled', async () => { - gitInit(tmpDir) - const controller = new AbortController() - controller.abort() - - await expect( - dispatcher.callRequest( - 'git.forkSync', - { worktreePath: tmpDir, expectedUpstream: { owner: 'stablyai', repo: 'orca' } }, - { isStale: () => false, signal: controller.signal } - ) - ).rejects.toThrow(/abort/i) - }) - - it('refreshes one remote-tracking ref from a configured remote', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) - const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-')) - const producerDir = path.join(producerParent, 'repo') - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['push', '--set-upstream', 'origin', branch], { - cwd: tmpDir, - stdio: 'pipe' - }) - - execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { - cwd: producerDir, - stdio: 'pipe' - }) - execFileSync('git', ['config', 'user.name', 'Test'], { - cwd: producerDir, - stdio: 'pipe' - }) - writeFileSync(path.join(producerDir, 'base.txt'), 'updated') - gitCommit(producerDir, 'remote update') - execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' }) - const expected = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: producerDir, - encoding: 'utf-8' - }).trim() - - await dispatcher.callRequest('git.fetchRemoteTrackingRef', { - worktreePath: tmpDir, - remote: 'origin', - branch, - ref: `refs/remotes/origin/${branch}` - }) - - const actual = execFileSync('git', ['rev-parse', `refs/remotes/origin/${branch}`], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(expected) - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - await fs.rm(producerParent, { recursive: true, force: true }) - } - }) - - it('rejects remote-tracking refreshes that target a different ref', async () => { - gitInit(tmpDir) - execFileSync('git', ['remote', 'add', 'origin', tmpDir], { cwd: tmpDir, stdio: 'pipe' }) - - await expect( - dispatcher.callRequest('git.fetchRemoteTrackingRef', { - worktreePath: tmpDir, - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/other' - }) - ).rejects.toThrow('Remote-tracking ref does not match the requested remote and branch.') - }) - - it('fetches GitHub pull request heads through the narrow fetch RPC', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-github-pr-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'pr.txt'), 'head') - gitCommit(tmpDir, 'pr head') - const expected = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['push', 'origin', 'HEAD:refs/pull/42/head'], { - cwd: tmpDir, - stdio: 'pipe' - }) - - const result = (await dispatcher.callRequest('git.fetchGitHubPullRequestHead', { - worktreePath: tmpDir, - remote: 'origin', - prNumber: 42 - })) as { localRef: string } - - // The ref is scoped by remote identity so soft-keep can never serve - // another project's PR #42 out of the same object database. - const component = reviewHeadRemoteRefComponent('origin', bareDir) - expect(result.localRef).toBe(`refs/orca/pull/${component}/42`) - const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(expected) - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('rejects invalid GitHub pull request head fetch requests', async () => { - await expect( - dispatcher.callRequest('git.fetchGitHubPullRequestHead', { - worktreePath: tmpDir, - remote: '-origin', - prNumber: 42 - }) - ).rejects.toThrow('GitHub pull request fetch remote must not start with "-".') - await expect( - dispatcher.callRequest('git.fetchGitHubPullRequestHead', { - worktreePath: tmpDir, - remote: 'origin', - prNumber: 0 - }) - ).rejects.toThrow('Invalid GitHub pull request fetch request.') - }) - - it('fetches GitLab merge request heads through the narrow fetch RPC', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'mr.txt'), 'head') - gitCommit(tmpDir, 'mr head') - const expected = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['push', 'origin', 'HEAD:refs/merge-requests/42/head'], { - cwd: tmpDir, - stdio: 'pipe' - }) - - const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHead', { - worktreePath: tmpDir, - remote: 'origin', - mrIid: 42 - })) as { localRef: string } - - // The head is fetched into a dedicated ref (not shared FETCH_HEAD) so a - // concurrent fetch can't retarget the caller's rev-parse of the checkout. - const component = reviewHeadRemoteRefComponent('origin', bareDir) - expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/42`) - const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(expected) - // Legacy contract: pre-durable-ref desktop clients call this method name - // and then resolve FETCH_HEAD, which a refspec fetch still writes. - const fetchHead = execFileSync('git', ['rev-parse', '--verify', 'FETCH_HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(fetchHead).toBe(expected) - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('fetches GitLab merge request heads through the versioned durable-ref RPC', async () => { - const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-ref-bare-')) - try { - execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'mr.txt'), 'head') - gitCommit(tmpDir, 'mr head') - const expected = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['push', 'origin', 'HEAD:refs/merge-requests/77/head'], { - cwd: tmpDir, - stdio: 'pipe' - }) - - // Why: new clients call the versioned name; old relays 404 it and prompt reconnect. - const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHeadRef', { - worktreePath: tmpDir, - remote: 'origin', - mrIid: 77 - })) as { localRef: string } - - const component = reviewHeadRemoteRefComponent('origin', bareDir) - expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/77`) - const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(expected) - } finally { - await fs.rm(bareDir, { recursive: true, force: true }) - } - }) - - it('rejects invalid GitLab merge request head fetch requests', async () => { - await expect( - dispatcher.callRequest('git.fetchGitLabMergeRequestHead', { - worktreePath: tmpDir, - remote: '-origin', - mrIid: 42 - }) - ).rejects.toThrow('GitLab merge request fetch remote must not start with "-".') - await expect( - dispatcher.callRequest('git.fetchGitLabMergeRequestHead', { - worktreePath: tmpDir, - remote: 'origin', - mrIid: 0 - }) - ).rejects.toThrow('Invalid GitLab merge request fetch request.') - }) - - it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => { - // Why: the catch only swallows "no upstream"; other errors must surface so auth/corruption failures aren't masked. - const nonRepoDir = path.join(tmpDir, 'not-a-repo') - await fs.mkdir(nonRepoDir, { recursive: true }) - - await expect( - dispatcher.callRequest('git.upstreamStatus', { worktreePath: nonRepoDir }) - ).rejects.toThrow(/not a git repository/i) - }) - }) -}) diff --git a/src/relay/git-handler-review-draft.test.ts b/src/relay/git-handler-review-draft.test.ts deleted file mode 100644 index bf20de570d9..00000000000 --- a/src/relay/git-handler-review-draft.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { readFile, unlink, writeFile } from 'node:fs/promises' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as processRunner from '@orca/process-host' -import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' -import { GIT_READ_TIMEOUT_MS } from '../shared/git-command-timeout' -import type { GitHandler } from './git-handler' -import type { GitHandlerOperationHost } from './git-handler-operation-context' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from './git-handler-test-harness' -import { gitCommit, gitInit, type MockDispatcher } from './git-handler-test-setup' -import { _resetRelayGitAdmissionForTests } from './git-handler-command-termination' - -const BASE_OID = 'a'.repeat(40) -const commandResult = { stdout: 'M\tfile.txt\n', stderr: '' } -const processResult = { ...commandResult, code: 0, signal: null, timedOut: false } - -function checkedCommandResult(result: unknown): { stdout: string; stderr: string } { - if ( - !result || - typeof result !== 'object' || - !('stdout' in result) || - typeof result.stdout !== 'string' || - !('stderr' in result) || - typeof result.stderr !== 'string' - ) { - throw new Error('Expected the review diff command response') - } - return { stdout: result.stdout, stderr: result.stderr } -} - -describe.each([2, 4])('relay review draft diff with %i Git slots', (generalCap) => { - let dispatcher: MockDispatcher - let handler: GitHandler - let repo: string - let scheduler: GitAdmissionScheduler - - beforeEach(() => { - scheduler = new GitAdmissionScheduler({ generalCap, generalHeadroom: 0 }) - _resetRelayGitAdmissionForTests(scheduler) - repo = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - }) - - afterEach(async () => { - handler.dispose() - vi.restoreAllMocks() - try { - await removeGitTempDir(repo) - expect(scheduler.snapshot().queued).toBe(0) - expect( - Object.values(scheduler.snapshot().budgets).every( - (budget) => budget.baseUsed === 0 && budget.headroomUsed === 0 - ) - ).toBe(true) - } finally { - _resetRelayGitAdmissionForTests() - } - }) - - function reviewDiff(overrides: Record = {}, signal?: AbortSignal) { - return dispatcher.callRequest( - 'git.reviewDiff', - { worktreePath: repo, mergeBase: BASE_OID, format: 'name-status', ...overrides }, - { isStale: () => false, signal } - ) - } - - async function runRepoGit(args: string[]): Promise { - const result = await processRunner.runProcess({ program: 'git', args, cwd: repo }) - if (result.code !== 0) { - throw new Error(result.stderr || `Git fixture command failed: ${args[0]}`) - } - return result.stdout - } - - it.each([ - { worktreePath: undefined }, - { worktreePath: null }, - { worktreePath: 1 }, - { worktreePath: '' }, - { worktreePath: 'repo\0other' }, - { mergeBase: undefined }, - { mergeBase: null }, - { mergeBase: 'main' }, - { mergeBase: 'HEAD~1..HEAD' }, - { mergeBase: '--output=result.patch' }, - { mergeBase: 'a'.repeat(39) }, - { mergeBase: 'a'.repeat(41) }, - { mergeBase: 'a'.repeat(63) }, - { mergeBase: 'a'.repeat(65) }, - { mergeBase: 'g'.repeat(40) }, - { mergeBase: `${BASE_OID}\0` }, - { format: undefined }, - { format: null }, - { format: '' }, - { format: 'raw' }, - { format: ['patch'] }, - { format: '--output=result.patch' } - ])('rejects malformed request fields before running Git: %j', async (overrides) => { - const git = vi.fn().mockResolvedValue(commandResult) - Object.assign(handler, { git }) - - await expect(reviewDiff(overrides)).rejects.toThrow('Invalid review diff request.') - expect(git).not.toHaveBeenCalled() - }) - - it.each([ - { format: 'name-status', mergeBase: BASE_OID, flags: ['--name-status'] }, - { - format: 'patch', - mergeBase: 'A'.repeat(64), - flags: ['--patch', '--minimal', '--no-color', '--no-ext-diff'] - } - ])('routes $format through the fixed command with a full object id', async (request) => { - const git = vi.fn().mockResolvedValue(commandResult) - Object.assign(handler, { git }) - const controller = new AbortController() - - await expect( - reviewDiff( - { ...request, args: ['fetch', 'origin'], filePath: 'other.txt', timeout: 0 }, - controller.signal - ) - ).resolves.toEqual(commandResult) - expect(git).toHaveBeenCalledExactlyOnceWith( - ['diff', ...request.flags, `${request.mergeBase}..HEAD`, '--'], - repo, - { signal: controller.signal, disableOptionalLocks: true } - ) - }) - - it.each([ - ['fetch', 'origin'], - ['range-diff', 'HEAD~2..HEAD~1', 'HEAD~1..HEAD'], - ['diff', '--name-status', `${BASE_OID}..HEAD`], - ['diff', '--cached', '--patch', `${BASE_OID}..HEAD`] - ])('keeps generic git.exec restricted for %j', async (...args) => { - const git = vi.fn().mockResolvedValue(commandResult) - Object.assign(handler, { git }) - - await expect(dispatcher.callRequest('git.exec', { args, cwd: repo })).rejects.toThrow( - /not allowed|restricted to staged changes/ - ) - expect(git).not.toHaveBeenCalled() - }) - - it('passes the existing read timeout and cancellation signal to the process runner', async () => { - const runProcess = vi.spyOn(processRunner, 'runProcess').mockImplementation(async (spec) => { - spec.onChildTerminated?.() - return processResult - }) - const controller = new AbortController() - - await expect(reviewDiff({ timeout: 0 }, controller.signal)).resolves.toEqual(commandResult) - expect(runProcess).toHaveBeenCalledTimes(1) - const spec = runProcess.mock.calls[0]?.[0] - expect(spec?.program).toBe('git') - expect(spec?.args).toEqual(['diff', '--name-status', `${BASE_OID}..HEAD`, '--']) - expect(spec?.cwd).toBe(repo) - expect(spec?.signal).toBe(controller.signal) - expect(spec?.timeoutMs).toBe(GIT_READ_TIMEOUT_MS) - expect(spec?.terminationBarrier).toBe(true) - expect(spec?.env?.GIT_OPTIONAL_LOCKS).toBe('0') - }) - - it('rejects a read that crossed its timeout instead of returning a partial diff', async () => { - vi.spyOn(processRunner, 'runProcess').mockImplementation(async (spec) => { - spec.onChildTerminated?.() - return { ...processResult, timedOut: true } - }) - - await expect(reviewDiff()).rejects.toMatchObject({ timedOut: true }) - }) - - it('holds a timed-out read grant until the child termination is reported', async () => { - let reportTermination: (() => void) | undefined - vi.spyOn(processRunner, 'runProcess').mockImplementation(async (spec) => { - reportTermination = spec.onChildTerminated - expect(spec.terminationBarrier).toBe(true) - return { ...processResult, timedOut: true } - }) - - try { - await expect(reviewDiff()).rejects.toMatchObject({ timedOut: true }) - expect(reportTermination).toBeTypeOf('function') - expect(scheduler.snapshot().budgets.general?.baseUsed).toBe(1) - } finally { - reportTermination?.() - } - expect(scheduler.snapshot().budgets.general?.baseUsed).toBe(0) - }) - - it('rejects cancellation before spawning any Git process', async () => { - const runProcess = vi.spyOn(processRunner, 'runProcess') - const controller = new AbortController() - controller.abort() - - await expect(reviewDiff({}, controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(runProcess).not.toHaveBeenCalled() - }) - - it('rejects a completed read when the request was cancelled during execution', async () => { - const controller = new AbortController() - vi.spyOn(processRunner, 'runProcess').mockImplementation(async (spec) => { - expect(spec.signal).toBe(controller.signal) - controller.abort() - spec.onChildTerminated?.() - return processResult - }) - - await expect(reviewDiff({}, controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('checks cancellation again before publishing a successful command response', async () => { - const controller = new AbortController() - const reason = new Error('Draft request cancelled') - const git = vi.fn(async () => { - controller.abort(reason) - return commandResult - }) - Object.assign(handler, { git }) - - await expect(reviewDiff({}, controller.signal)).rejects.toBe(reason) - }) - - it('reads only committed changes and preserves dirty files, the index, and HEAD', async () => { - gitInit(repo) - await writeFile(path.join(repo, 'file.txt'), 'base\n') - await writeFile(path.join(repo, 'removed.txt'), 'removed\n') - gitCommit(repo, 'base') - const mergeBase = (await runRepoGit(['rev-parse', 'HEAD'])).trim() - await writeFile(path.join(repo, 'file.txt'), 'committed\n') - await writeFile(path.join(repo, 'added.txt'), 'added\n') - await unlink(path.join(repo, 'removed.txt')) - gitCommit(repo, 'feature') - await writeFile(path.join(repo, 'file.txt'), 'staged\n') - await runRepoGit(['add', 'file.txt']) - await writeFile(path.join(repo, 'file.txt'), 'unstaged\n') - await writeFile(path.join(repo, 'untracked.txt'), 'untracked\n') - await runRepoGit(['config', 'color.ui', 'always']) - await runRepoGit(['config', 'diff.external', path.join(repo, 'must-not-run')]) - const indexBefore = await readFile(path.join(repo, '.git', 'index')) - const statusBefore = await runRepoGit(['status', '--porcelain']) - const headBefore = await runRepoGit(['rev-parse', 'HEAD']) - - const names = checkedCommandResult(await reviewDiff({ mergeBase })) - const patch = checkedCommandResult(await reviewDiff({ mergeBase, format: 'patch' })) - - expect(names.stdout).toBe('A\tadded.txt\nM\tfile.txt\nD\tremoved.txt\n') - expect(patch.stdout).toContain('-base\n+committed\n') - expect(patch.stdout).toContain('new file mode') - expect(patch.stdout).toContain('deleted file mode') - expect(patch.stdout).not.toMatch(/staged|unstaged|untracked/) - expect(patch.stdout).not.toContain('\u001b[') - expect(names.stderr).toBe('') - expect(patch.stderr).toBe('') - expect(await readFile(path.join(repo, '.git', 'index'))).toEqual(indexBefore) - expect(await runRepoGit(['status', '--porcelain'])).toBe(statusBefore) - expect(await runRepoGit(['rev-parse', 'HEAD'])).toBe(headBefore) - expect(await readFile(path.join(repo, 'file.txt'), 'utf8')).toBe('unstaged\n') - }) -}) diff --git a/src/relay/git-handler-stage-worktree-scope.test.ts b/src/relay/git-handler-stage-worktree-scope.test.ts deleted file mode 100644 index 9a2616ed117..00000000000 --- a/src/relay/git-handler-stage-worktree-scope.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as path from 'node:path' -import * as fs from 'node:fs/promises' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { execFileSync } from 'node:child_process' -import { GitHandler } from './git-handler' -import { RelayContext } from './context' -import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { MethodHandler } from '../wsl-guest/dispatcher-contract' -import { gitCommit, gitInit } from './git-handler-test-setup' - -const OVER_CAP = DEFAULT_GIT_STATUS_LIMIT + 50 - -function names(dir: string, args: string[]): string[] { - return execFileSync('git', args, { cwd: dir, encoding: 'utf-8' }).split('\n').filter(Boolean) -} - -function fileName(i: number): string { - return `big/f${String(i).padStart(5, '0')}.txt` -} - -describe('GitHandler — bulkStage scope for a capped listing', () => { - let dispatcher: RelayDispatcher - let handlers: Map - let tmpDir: string - - async function callRequest(method: string, params: Record): Promise { - const handler = handlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params, { clientId: 1, isStale: () => false }) - } - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-stage-scope-')) - dispatcher = new RelayDispatcher(() => {}) - handlers = new Map() - vi.spyOn(dispatcher, 'onRequest').mockImplementation((method, handler) => { - handlers.set(method, handler) - }) - new GitHandler(dispatcher, new RelayContext()) - gitInit(tmpDir) - mkdirSync(path.join(tmpDir, 'big')) - for (let i = 0; i < OVER_CAP; i++) { - writeFileSync(path.join(tmpDir, fileName(i)), 'a\n') - } - gitCommit(tmpDir, 'initial') - for (let i = 0; i < OVER_CAP; i++) { - writeFileSync(path.join(tmpDir, fileName(i)), 'b\n') - } - writeFileSync(path.join(tmpDir, 'zz-new.txt'), 'new\n') - }) - - afterEach(async () => { - dispatcher.dispose() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - async function listedPaths(): Promise { - const status = await callRequest('git.status', { worktreePath: tmpDir }) - expect(status).toMatchObject({ didHitLimit: true }) - const entries = - typeof status === 'object' && status && 'entries' in status ? status.entries : [] - return Array.isArray(entries) ? entries.map((entry) => String(entry.path)) : [] - } - - it('stages only the listed paths without a scope, leaving the rest unstaged', async () => { - const reply = await callRequest('git.bulkStage', { - worktreePath: tmpDir, - filePaths: await listedPaths() - }) - - expect(reply).toBeUndefined() - - expect(names(tmpDir, ['diff', '--name-only'])).toHaveLength(OVER_CAP - DEFAULT_GIT_STATUS_LIMIT) - }) - - it('stages every change for scope all, beyond the capped listing', async () => { - const reply = await callRequest('git.bulkStage', { - worktreePath: tmpDir, - filePaths: [], - scope: 'all' - }) - - // The receipt is what tells a client this relay ran the scope rather than ignoring it. - expect(reply).toEqual({ stagedScope: 'all' }) - - expect(names(tmpDir, ['diff', '--name-only'])).toEqual([]) - expect(names(tmpDir, ['diff', '--cached', '--name-only'])).toHaveLength(OVER_CAP + 1) - }) - - it('stages tracked changes only for scope tracked', async () => { - await callRequest('git.bulkStage', { - worktreePath: tmpDir, - filePaths: [], - scope: 'tracked' - }) - - expect(names(tmpDir, ['diff', '--name-only'])).toEqual([]) - expect(names(tmpDir, ['ls-files', '--others', '--exclude-standard'])).toEqual(['zz-new.txt']) - }) - - it('ignores an unknown scope and stages the listed paths', async () => { - await callRequest('git.bulkStage', { - worktreePath: tmpDir, - filePaths: [fileName(0)], - scope: 'everything' - }) - - expect(names(tmpDir, ['diff', '--cached', '--name-only'])).toEqual(['big/f00000.txt']) - }) -}) diff --git a/src/relay/git-handler-staging.test.ts b/src/relay/git-handler-staging.test.ts deleted file mode 100644 index b113afd30c4..00000000000 --- a/src/relay/git-handler-staging.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Tests for GitHandler commit and staging operations. - * - * Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit. - */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import * as path from 'node:path' -import * as fs from 'node:fs/promises' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { execFileSync } from 'node:child_process' -import { GitHandler } from './git-handler' -import { RelayContext } from './context' -import { - createMockDispatcher, - gitInit, - gitCommit, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -const PATHSPEC_SELECTED_FILE = '[k]eep.log' -const PATHSPEC_MATCHING_FILE = 'keep.log' -const PATHSPEC_MUTATION_CASES = [ - { - mode: 'single-file', - stageMethod: 'git.stage', - unstageMethod: 'git.unstage', - selection: { filePath: PATHSPEC_SELECTED_FILE } - }, - { - mode: 'bulk', - stageMethod: 'git.bulkStage', - unstageMethod: 'git.bulkUnstage', - selection: { filePaths: [PATHSPEC_SELECTED_FILE] } - } -] as const - -function createPathspecCollisionChanges(dir: string): void { - gitInit(dir) - writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected') - writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching') - gitCommit(dir, 'initial') - writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected modified') - writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching modified') -} - -describe('GitHandler — commit & staging', () => { - let dispatcher: MockDispatcher - let tmpDir: string - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-staging-')) - dispatcher = createMockDispatcher() - const ctx = new RelayContext() - // eslint-disable-next-line no-new - new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) - }) - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - describe('commit', () => { - it('commits staged changes and returns success', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'content') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') - execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) - - const result = (await dispatcher.callRequest('git.commit', { - worktreePath: tmpDir, - message: 'feat: relay commit' - })) as { success: boolean; error?: string } - - expect(result).toEqual({ success: true }) - const latestMessage = execFileSync('git', ['log', '-1', '--format=%s'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(latestMessage).toBe('feat: relay commit') - }) - - // Why: covers the error-extraction path in commitChangesRelay - // (git-handler-worktree-ops.ts). Running `git commit` with nothing staged - // exits non-zero and writes a "nothing to commit" message; we assert the - // relay surfaces a non-empty error string so the UI can display it. - it('returns a non-empty error when the commit fails', async () => { - gitInit(tmpDir) - - const result = (await dispatcher.callRequest('git.commit', { - worktreePath: tmpDir, - message: 'no changes' - })) as { success: boolean; error?: string } - - expect(result.success).toBe(false) - expect(typeof result.error).toBe('string') - expect((result.error ?? '').length).toBeGreaterThan(0) - // Why: exact phrasing can vary across git versions, so match the - // stable substring "nothing" rather than the full "nothing to commit". - expect((result.error ?? '').toLowerCase()).toContain('nothing') - }) - }) - - describe('stage and unstage', () => { - it.each(PATHSPEC_MUTATION_CASES)( - 'treats $mode stage paths with Git glob characters as literals', - async ({ stageMethod, selection }) => { - createPathspecCollisionChanges(tmpDir) - - await dispatcher.callRequest(stageMethod, { worktreePath: tmpDir, ...selection }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output.trim()).toBe(PATHSPEC_SELECTED_FILE) - } - ) - - it.each(PATHSPEC_MUTATION_CASES)( - 'treats $mode unstage paths with Git glob characters as literals', - async ({ unstageMethod, selection }) => { - createPathspecCollisionChanges(tmpDir) - execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) - - await dispatcher.callRequest(unstageMethod, { worktreePath: tmpDir, ...selection }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output.trim()).toBe(PATHSPEC_MATCHING_FILE) - } - ) - - it('stages multiple files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - gitCommit(tmpDir, 'initial') - - writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') - - await dispatcher.callRequest('git.bulkStage', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt'] - }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output).toContain('a.txt') - expect(output).toContain('b.txt') - }) - - it('unstages multiple files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - gitCommit(tmpDir, 'initial') - - writeFileSync(path.join(tmpDir, 'a.txt'), 'changed') - writeFileSync(path.join(tmpDir, 'b.txt'), 'changed') - execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) - - await dispatcher.callRequest('git.bulkUnstage', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt'] - }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output.trim()).toBe('') - }) - }) -}) diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts deleted file mode 100644 index b8e379ed262..00000000000 --- a/src/relay/git-handler-status-ops.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import * as fs from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitExec } from './git-handler-ops' -import type { RelayGitStreamExec } from './git-stdout-stream' -import { getStatusOp } from './git-handler-status-ops' -import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' -import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' -import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit' - -const LARGE_STATUS_ENTRY_COUNT = 150_000 - -function streamGitFromCapture(git: GitExec): RelayGitStreamExec { - return async (args, cwd, options) => { - const { stdout } = await git(args, cwd, { - disableOptionalLocks: options.disableOptionalLocks, - signal: options.signal - }) - return { stoppedEarly: options.onStdout(stdout) === true } - } -} - -function buildLargeStatusOutput(count: number): string { - const lines: string[] = [] - for (let index = 0; index < count; index += 1) { - lines.push(`1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt`) - } - return lines.join('\n') -} - -function buildBranchStatusOutput(head: string, branch: string): string { - return [`# branch.oid ${head}`, `# branch.head ${branch}`].join('\n') -} - -describe('getStatusOp', () => { - let tmpDir: string - - beforeEach(() => { - clearNoEffectiveUpstreamStatusCache() - clearGitStatusLineStatsCache() - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) - }) - - afterEach(async () => { - vi.useRealTimers() - clearNoEffectiveUpstreamStatusCache() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - it('truncates huge status lists at the limit and flags didHitLimit', async () => { - let emittedEntries = 0 - const git = vi.fn(async (args) => { - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - const streamGit = vi.fn(async (_args, _cwd, options) => { - for (let index = 0; index < LARGE_STATUS_ENTRY_COUNT; index += 1) { - emittedEntries += 1 - if ( - options.onStdout( - `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt\n` - ) === true - ) { - return { stoppedEarly: true } - } - } - return { stoppedEarly: false } - }) - - const result = await getStatusOp(git, streamGit, { worktreePath: tmpDir }) - - expect(result.didHitLimit).toBe(true) - expect(result.statusLength).toBe(DEFAULT_GIT_STATUS_LIMIT + 1) - expect(result.entries).toHaveLength(DEFAULT_GIT_STATUS_LIMIT) - expect(emittedEntries).toBe(DEFAULT_GIT_STATUS_LIMIT + 1) - expect(streamGit).toHaveBeenCalledWith( - expect.arrayContaining(['status', '--porcelain=v2']), - tmpDir, - expect.objectContaining({ disableOptionalLocks: true }) - ) - expect(result.entries[0]).toEqual({ - path: 'generated-0.txt', - status: 'added', - area: 'staged' - }) - // numstat (diff) must be skipped when the limit was hit. - expect(git.mock.calls.some(([args]) => args.includes('diff'))).toBe(false) - }) - - it('returns the full list and no limit flag when under the limit', async () => { - const statusOutput = buildLargeStatusOutput(5) - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: statusOutput, stderr: '' } - } - if (args.includes('diff')) { - return { stdout: '', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - limit: 10_000 - }) - - expect(result.didHitLimit).toBeUndefined() - expect(result.entries).toHaveLength(5) - }) - - it('returns exactly the cap without a false limit signal', async () => { - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: buildLargeStatusOutput(3), stderr: '' } - } - if (args.includes('diff')) { - return { stdout: '', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - limit: 3 - }) - - expect(result.entries).toHaveLength(3) - expect(result.didHitLimit).toBeUndefined() - expect(result.statusLength).toBeUndefined() - }) - - it('caps unmerged conflicts and keeps the visible conflict rows', async () => { - const lines = [ - 'u UU S... 160000 160000 160000 160000 aa bb cc vendor/submodule', - ...Array.from( - { length: 3 }, - (_, i) => `u UU N... 100644 100644 100644 100644 aa bb cc conflict-${i}.ts` - ) - ].join('\n') - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: `${lines}\n`, stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - limit: 2 - }) - - expect(result.didHitLimit).toBe(true) - expect(result.statusLength).toBe(3) - expect(result.entries).toHaveLength(2) - expect(result.entries.map((entry) => entry.path)).toEqual(['conflict-0.ts', 'conflict-1.ts']) - expect(result.entries.every((entry) => entry.conflictStatus === 'unresolved')).toBe(true) - expect(git).toHaveBeenCalledTimes(1) - }) - - it('keeps an early conflict ahead of later ordinary rows at the cap', async () => { - const lines = [ - '? before.ts', - 'u UU N... 100644 100644 100644 100644 aa bb cc conflict.ts', - '? after.ts' - ].join('\n') - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: `${lines}\n`, stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - limit: 2 - }) - - expect(result.didHitLimit).toBe(true) - expect(result.entries.map((entry) => entry.path)).toEqual(['before.ts', 'conflict.ts']) - expect(result.entries[1]).toMatchObject({ - conflictKind: 'both_modified', - conflictStatus: 'unresolved' - }) - }) - - it('reuses unchanged line stats only for hinted safety reads', async () => { - const statusOutput = `${buildBranchStatusOutput('head-1', '(detached)')}\n1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts` - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: statusOutput, stderr: '' } - } - if (args.includes('diff')) { - return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - const reused = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - reuseLineStats: true - }) - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - - expect(reused.entries).toContainEqual( - expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) - ) - expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(2) - }) - - it('omits line stats without overwriting the reusable line-stats cache', async () => { - const statusOutput = `${buildBranchStatusOutput('head-skip', '(detached)')}\n1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts` - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: statusOutput, stderr: '' } - } - if (args.includes('diff')) { - return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - const withStats = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir - }) - const withoutStats = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - includeLineStats: false - }) - const reused = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - reuseLineStats: true - }) - - expect(withoutStats.entries).toEqual( - withStats.entries.map(({ added: _added, removed: _removed, ...entry }) => entry) - ) - expect(reused.entries).toEqual(withStats.entries) - expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(1) - }) - - it('forwards the request abort signal to status and numstat subprocesses', async () => { - const controller = new AbortController() - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { - stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', - stderr: '' - } - } - if (args.includes('diff')) { - return { stdout: '1\t0\tsrc/a.ts\n', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - await getStatusOp( - git, - streamGitFromCapture(git), - { worktreePath: tmpDir }, - { signal: controller.signal } - ) - - expect(git.mock.calls).not.toHaveLength(0) - for (const [, , options] of git.mock.calls) { - expect(options?.signal).toBe(controller.signal) - } - }) - - it('caches no-effective-upstream probes across status polls for the same head', async () => { - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } - } - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - - const first = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - const firstCallCount = git.mock.calls.length - const second = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - - expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - expect(second.upstreamStatus).toEqual(first.upstreamStatus) - expect(git.mock.calls).toHaveLength(firstCallCount + 1) - expect( - git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) - ).toHaveLength(1) - expect( - git.mock.calls.filter( - ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') - ) - ).toHaveLength(1) - }) - - it('keeps no-effective-upstream probes cached beyond thirty seconds', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } - } - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature') - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - throw new Error('missing remote branch') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - vi.setSystemTime(31_000) - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - - expect( - git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) - ).toHaveLength(1) - }) - - it('coalesces concurrent no-effective-upstream probes', async () => { - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } - } - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - await Promise.resolve() - throw new Error('fatal: no upstream configured for branch feature') - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - await Promise.resolve() - throw new Error('missing remote branch') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - - await Promise.all([ - getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }), - getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }), - getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - ]) - - expect( - git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) - ).toHaveLength(1) - expect( - git.mock.calls.filter( - ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') - ) - ).toHaveLength(1) - }) - - it('invalidates cached no-effective-upstream probes when the branch changes', async () => { - let branch = 'feature' - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: buildBranchStatusOutput('abc123', branch), stderr: '' } - } - if (args[0] === 'symbolic-ref') { - return { stdout: `${branch}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error(`fatal: no upstream configured for branch ${branch}`) - } - if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { - throw new Error('missing remote branch') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - branch = 'other-feature' - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - - expect( - git.mock.calls - .filter( - ([args]) => - args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/')) - ) - .map(([args]) => args.at(-1)) - ).toEqual(['refs/remotes/origin/feature', 'refs/remotes/origin/other-feature']) - }) - - it('does not cache a configured push target signal', async () => { - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: buildBranchStatusOutput('abc123', 'feature/fix'), stderr: '' } - } - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature/fix\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature/fix') - } - if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) { - return { stdout: 'fork\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - throw new Error('missing push default') - } - if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) { - return { stdout: 'fork\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) { - return { stdout: 'refs/heads/feature/fix\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.feature/fix.base')) { - throw new Error('missing branch base') - } - if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/'))) { - throw new Error('missing remote branch') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - - expect( - git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) - ).toHaveLength(2) - }) -}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts deleted file mode 100644 index 33c014f227e..00000000000 --- a/src/relay/git-handler-status-ops.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Status and conflict-detection operations extracted from git-handler.ts. - * Why: split to satisfy oxlint max-lines (300); pure data ops on git state, no class coupling. - */ -import * as path from 'node:path' -import { existsSync } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { parseUnmergedEntry } from '../shared/git-status-conflict-entries' -import type { GitExec } from './git-handler-ops' -import type { RelayGitStreamExec } from './git-stdout-stream' -import type { GitUpstreamStatus } from '../shared/git-status-types' -import { StatusPorcelainParser } from '../shared/git-status-porcelain-parser' -import { splitRemoteBranchName } from '../shared/git-effective-upstream' -import { collectGitStatusLineStatInputs } from '../shared/git-status-line-stat-inputs' -import { readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' -import { - applyLineStats, - collectUntrackedAdditions, - parseNumstat, - type GitLineStats -} from '../shared/git-uncommitted-line-stats' -import { resolveGitStatusLimit } from '../shared/git-status-limit' -import { - beginGitStatusLineStatsCacheWrite, - clearGitStatusLineStatsCacheKey, - reuseOrRecomputeGitStatusLineStats -} from '../shared/git-status-line-stats-cache' -import { - readGitBranchLineTotalMergeBaseParam, - type GitBranchLineTotal -} from '../shared/git-branch-line-total' -import { buildBranchLineTotalInput } from './git-status-branch-line-total' - -export async function resolveGitDir(worktreePath: string): Promise { - const dotGitPath = path.join(worktreePath, '.git') - try { - const contents = await readFile(dotGitPath, 'utf-8') - const match = contents.match(/^gitdir:\s*(.+)\s*$/m) - if (match) { - return path.resolve(worktreePath, match[1]) - } - } catch { - // .git is a directory, not a file - } - return dotGitPath -} - -export async function detectConflictOperation(worktreePath: string): Promise { - const gitDir = await resolveGitDir(worktreePath) - try { - if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) { - return 'merge' - } - if ( - existsSync(path.join(gitDir, 'rebase-merge')) || - existsSync(path.join(gitDir, 'rebase-apply')) - ) { - return 'rebase' - } - if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) { - return 'cherry-pick' - } - } catch { - // fs error — treat as no conflict operation - } - return 'unknown' -} - -export async function getStatusOp( - git: GitExec, - streamGit: RelayGitStreamExec, - params: Record, - options: { signal?: AbortSignal } = {} -): Promise<{ - entries: Record[] - conflictOperation: string - head?: string - branch?: string - upstreamStatus?: GitUpstreamStatus - ignoredPaths?: string[] - didHitLimit?: boolean - statusLength?: number - branchLineTotal?: GitBranchLineTotal -}> { - const worktreePath = params.worktreePath as string - const lineStatsCacheKey = `relay\0${worktreePath}` - const lineStatsWriteToken = - params.includeLineStats === false ? null : beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) - const includeIgnored = params.includeIgnored === true - // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. - const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( - params.branchLineTotalMergeBase - ) - // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. - const limit = resolveGitStatusLimit(params.limit) - const conflictPromise = detectConflictOperation(worktreePath) - // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. - const statusArgs = [ - '-c', - 'core.quotePath=false', - '-c', - 'diff.autoRefreshIndex=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ] - if (includeIgnored) { - statusArgs.push('--ignored=matching') - } - // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. - const statusSettlementPromise = Promise.allSettled([ - (async () => { - const parser = new StatusPorcelainParser() - const result = await streamGit(statusArgs, worktreePath, { - // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. - disableOptionalLocks: true, - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!result.stoppedEarly) { - parser.finish() - } - return { parser, stoppedEarly: result.stoppedEarly } - })() - ]) - const conflictOperation = await conflictPromise - const entries: Record[] = [] - let head: string | undefined - let branch: string | undefined - let upstreamStatus: GitUpstreamStatus | undefined - let ignoredPaths: string[] = [] - let didHitLimit = false - let statusLength = 0 - let statusSucceeded = false - let branchLineTotal: GitBranchLineTotal | undefined - - try { - const [statusResult] = await statusSettlementPromise - if (statusResult.status === 'rejected') { - throw statusResult.reason - } - const { parser, stoppedEarly } = statusResult.value - head = parser.branch.head - branch = parser.branch.branch - ignoredPaths = parser.ignoredPaths - statusLength = parser.statusLength - didHitLimit = stoppedEarly - statusSucceeded = true - const { upstreamName, upstreamAheadBehind } = parser.branch - upstreamStatus = upstreamName - ? { - hasUpstream: true, - upstreamName, - ahead: upstreamAheadBehind?.ahead ?? 0, - behind: upstreamAheadBehind?.behind ?? 0 - } - : { hasUpstream: false, ahead: 0, behind: 0 } - - if (!didHitLimit) { - if (shouldProbeEffectiveUpstreamStatus(branch, upstreamStatus?.upstreamName)) { - const branchName = getShortBranchName(branch) - if (branchName) { - try { - // Why: one request's abort must not reject this shared status probe. - upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, - (args) => git(args, worktreePath), - { - bypassCache: params.bypassEffectiveUpstreamNegativeCache === true - } - ) - } catch { - // Why: keep returning working-tree entries even if the upstream probe hits a transient SSH/git ref error. - } - } - } - } - - // Why: resolve deferred conflicts in Git's output order so the cap cannot hide - // an early conflict behind ordinary rows that appeared later in the stream. - for (const record of parser.statusRecords) { - if (didHitLimit && entries.length >= limit) { - break - } - if (record.type === 'entry') { - entries.push(record.entry as Record) - } else { - const entry = await parseUnmergedEntry(worktreePath, record.line) - if (entry) { - entries.push(entry) - } - } - } - } catch (error) { - // Why: an aborted scan must reject, not resolve as a completed (empty) status result. - if (options.signal?.aborted) { - throw error - } - // not a git repo or git not available - } - - // Why: skip numstat after the limit to avoid reintroducing its cost. - if (!didHitLimit && lineStatsWriteToken !== null) { - const branchLineTotalInput = buildBranchLineTotalInput( - git, - worktreePath, - entries, - // Why: a failed scan leaves the untracked list untrustworthy, so the total - // would under-count — omit it rather than publish a confident wrong number. - statusSucceeded ? branchLineTotalMergeBase : undefined, - options.signal - ) - // Why: passed in so the ranged diff runs alongside the per-area numstats, not after them. - ;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({ - cacheKey: lineStatsCacheKey, - head, - entries, - writeToken: lineStatsWriteToken, - reuse: params.reuseLineStats === true, - isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(git, worktreePath, entries, options.signal), - ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) - })) - } else if (lineStatsWriteToken !== null) { - clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) - } - - // Why: a late abort (during unmerged/upstream/line-stats work) must still reject, not resolve as completed. - if (options.signal?.aborted) { - const error = new Error('The operation was aborted.') - error.name = 'AbortError' - throw error - } - - return { - entries, - conflictOperation, - head, - branch, - upstreamStatus, - ...(includeIgnored ? { ignoredPaths } : {}), - ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), - ...(branchLineTotal ? { branchLineTotal } : {}) - } -} - -async function runNumstat( - git: GitExec, - worktreePath: string, - cached: boolean, - signal?: AbortSignal -): Promise | null> { - try { - const { stdout } = await git( - [ - '-c', - 'core.quotePath=false', - '-c', - 'diff.autoRefreshIndex=false', - 'diff', - ...(cached ? ['--cached'] : []), - '-z', - '--numstat', - '-M' - ], - worktreePath, - { disableOptionalLocks: true, signal } - ) - return parseNumstat(stdout) - } catch (error) { - // Why: an aborted pass must reject so a cancelled scan is never treated as completed. - if (signal?.aborted) { - throw error - } - // Why: null (vs an empty map) tells the caller the pass is incomplete and must not be cached. - return null - } -} - -/** Returns false when a numstat pass failed, so callers skip caching it. */ -async function attachLineStats( - git: GitExec, - worktreePath: string, - entries: Record[], - signal?: AbortSignal -): Promise { - if (entries.length === 0) { - return true - } - const { hasStaged, hasUnstaged, untrackedPaths } = collectGitStatusLineStatInputs(entries) - const emptyStats = new Map() - const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ - hasStaged ? runNumstat(git, worktreePath, true, signal) : Promise.resolve(emptyStats), - hasUnstaged ? runNumstat(git, worktreePath, false, signal) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths, signal) - ]) - for (const entry of entries) { - const filePath = entry.path as string - const area = entry.area - applyLineStats( - entry as { added?: number; removed?: number }, - area === 'staged' - ? (stagedStats ?? emptyStats).get(filePath) - : area === 'unstaged' - ? (unstagedStats ?? emptyStats).get(filePath) - : untrackedStats.get(filePath) - ) - } - return stagedStats !== null && unstagedStats !== null -} - -function getShortBranchName(branch: string | undefined): string | null { - const prefix = 'refs/heads/' - return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null -} - -function shouldProbeEffectiveUpstreamStatus( - branch: string | undefined, - upstreamName: string | undefined -): boolean { - const branchName = getShortBranchName(branch) - if (!branchName) { - return false - } - if (!upstreamName) { - return true - } - const parsed = splitRemoteBranchName(upstreamName) - return parsed?.remoteName === 'origin' && parsed.branchName !== branchName -} diff --git a/src/relay/git-handler-submodule-cache-invalidation.test.ts b/src/relay/git-handler-submodule-cache-invalidation.test.ts deleted file mode 100644 index 57b6a5d2276..00000000000 --- a/src/relay/git-handler-submodule-cache-invalidation.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { - createMockDispatcher, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -type GitHandlerSpies = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> - gitBuffer(args: string[], cwd: string): Promise - spawnClone( - args: string[], - cwd: string, - progressId: string - ): Promise<{ stdout: string; stderr: string }> -} - -describe('GitHandler submodule cache invalidation', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - let target: GitHandlerSpies - - beforeEach(() => { - dispatcher = createMockDispatcher() - handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - target = handler as unknown as GitHandlerSpies - vi.spyOn(target, 'git').mockResolvedValue({ stdout: '', stderr: '' }) - vi.spyOn(target, 'gitBuffer').mockResolvedValue(Buffer.from('content\n')) - }) - - afterEach(() => { - handler.dispose() - }) - - it.each([ - { - name: 'clone', - mutate: async () => { - vi.spyOn(target, 'spawnClone').mockResolvedValue({ stdout: '', stderr: '' }) - await dispatcher.callRequest('git.clone', { - args: ['clone', '--', 'https://example.com/repo.git', 'repo'], - cwd: '/projects', - progressId: 'clone-test' - }) - } - }, - { - name: 'mutating git.exec', - mutate: () => - dispatcher.callRequest('git.exec', { - args: ['commit', '--allow-empty', '-m', 'initialize'], - cwd: '/repo' - }) - } - ])('clears a cached empty .gitmodules result around $name', async ({ mutate }) => { - const diffRequest = { - worktreePath: '/repo', - filePath: 'src/file.ts', - staged: false - } - - await dispatcher.callRequest('git.diff', diffRequest) - await mutate() - await dispatcher.callRequest('git.diff', diffRequest) - - const gitSpy = vi.mocked(target.git) - const submodulePathReads = gitSpy.mock.calls.filter( - ([args]) => args[0] === 'config' && args.includes('.gitmodules') - ) - expect(submodulePathReads).toHaveLength(2) - }) -}) diff --git a/src/relay/git-handler-submodule-ops.test.ts b/src/relay/git-handler-submodule-ops.test.ts deleted file mode 100644 index 1dd86946539..00000000000 --- a/src/relay/git-handler-submodule-ops.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as path from 'node:path' -import type { GitExec } from './git-handler-ops' -import { - MAX_SUBMODULE_PATHS_CACHE_ENTRIES, - SUBMODULE_PATHS_CACHE_TTL_MS, - clearSubmodulePathsCache, - createSubmodulePathsCache, - getSubmodulePathsCacheCount, - listSubmodulePathsCached, - resolveSubmoduleWorktreePath -} from './git-handler-submodule-ops' - -function gitmodulesExec(paths: string[]): { git: GitExec; calls: () => number } { - let calls = 0 - const git: GitExec = async (args) => { - if (args[0] === 'config' && args.includes('.gitmodules')) { - calls += 1 - return { - stdout: paths.map((p, i) => `submodule.sub${i}.path ${p}`).join('\n'), - stderr: '' - } - } - return { stdout: '', stderr: '' } - } - return { git, calls: () => calls } -} - -describe('listSubmodulePathsCached', () => { - it.each([ - new DOMException('Canceled', 'AbortError'), - Object.assign(new Error('Deadline exceeded'), { timedOut: true }), - Object.assign(new Error('Child terminated'), { killed: true }) - ])('does not cache an interrupted catalog read: %s', async (error) => { - let calls = 0 - const git: GitExec = async () => { - if (++calls === 1) { - throw error - } - return { stdout: 'submodule.lib.path vendor/lib\n', stderr: '' } - } - const cache = createSubmodulePathsCache() - await expect(listSubmodulePathsCached(git, '/repo', cache, 1_000)).rejects.toBe(error) - expect(getSubmodulePathsCacheCount(cache)).toBe(0) - await expect(listSubmodulePathsCached(git, '/repo', cache, 1_001)).resolves.toEqual([ - 'vendor/lib' - ]) - expect(calls).toBe(2) - }) - - it('reads .gitmodules once for repeated diffs on the same worktree within TTL', async () => { - const { git, calls } = gitmodulesExec(['vendor/lib']) - const cache = createSubmodulePathsCache() - - const first = await listSubmodulePathsCached(git, '/repo', cache, 1_000) - const second = await listSubmodulePathsCached(git, '/repo', cache, 1_500) - - expect(first).toEqual(['vendor/lib']) - expect(second).toEqual(['vendor/lib']) - expect(calls()).toBe(1) - }) - - it('re-reads after the TTL expires', async () => { - const { git, calls } = gitmodulesExec(['vendor/lib']) - const cache = createSubmodulePathsCache() - - await listSubmodulePathsCached(git, '/repo', cache, 1_000) - await listSubmodulePathsCached(git, '/repo', cache, 1_000 + SUBMODULE_PATHS_CACHE_TTL_MS + 1) - - expect(calls()).toBe(2) - }) - - it('reads separately for different worktrees', async () => { - const { git, calls } = gitmodulesExec(['vendor/lib']) - const cache = createSubmodulePathsCache() - - await listSubmodulePathsCached(git, '/repo-a', cache, 1_000) - await listSubmodulePathsCached(git, '/repo-b', cache, 1_000) - - expect(calls()).toBe(2) - }) - - it('prunes expired entries when a different remote worktree misses', async () => { - const { git } = gitmodulesExec(['vendor/lib']) - const cache = createSubmodulePathsCache() - - await listSubmodulePathsCached(git, '/repo-a', cache, 0) - await listSubmodulePathsCached(git, '/repo-b', cache, 0) - expect(getSubmodulePathsCacheCount(cache)).toBe(2) - - await listSubmodulePathsCached(git, '/repo-c', cache, SUBMODULE_PATHS_CACHE_TTL_MS + 1) - expect(getSubmodulePathsCacheCount(cache)).toBe(1) - }) - - it('caches an empty result so a submodule-free repo is not re-read', async () => { - let calls = 0 - const git: GitExec = async () => { - calls += 1 - throw new Error('fatal: No such file or directory') - } - const cache = createSubmodulePathsCache() - - const first = await listSubmodulePathsCached(git, '/repo', cache, 1_000) - const second = await listSubmodulePathsCached(git, '/repo', cache, 1_200) - - expect(first).toEqual([]) - expect(second).toEqual([]) - expect(calls).toBe(1) - }) - - it('stays bounded through prolonged remote-worktree churn and retains recent entries', async () => { - const { git, calls } = gitmodulesExec(['vendor/lib']) - const cache = createSubmodulePathsCache() - let now = 0 - - for (let wave = 0; wave < 4; wave += 1) { - for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES; i += 1) { - await listSubmodulePathsCached(git, `/wave-${wave}-repo-${i}`, cache, now) - } - expect(getSubmodulePathsCacheCount(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES) - now += SUBMODULE_PATHS_CACHE_TTL_MS + 1 - } - - await listSubmodulePathsCached(git, '/retained-repo', cache, now) - for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1; i += 1) { - await listSubmodulePathsCached(git, `/final-repo-${i}`, cache, now) - } - await listSubmodulePathsCached(git, '/retained-repo', cache, now) - await listSubmodulePathsCached( - git, - `/final-repo-${MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1}`, - cache, - now - ) - await listSubmodulePathsCached(git, '/overflow-repo', cache, now) - - expect(getSubmodulePathsCacheCount(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES) - const callsBeforeReads = calls() - await listSubmodulePathsCached(git, '/retained-repo', cache, now) - expect(calls()).toBe(callsBeforeReads) - await listSubmodulePathsCached(git, '/final-repo-0', cache, now) - expect(calls()).toBe(callsBeforeReads + 1) - }) - - it('does not let a pre-mutation SSH read repopulate the cache', async () => { - let resolveOldRead: ((value: { stdout: string; stderr: string }) => void) | undefined - let calls = 0 - const git: GitExec = () => { - calls += 1 - if (calls > 1) { - return Promise.resolve({ stdout: 'submodule.lib.path fresh-lib\n', stderr: '' }) - } - return new Promise((resolve) => { - resolveOldRead = resolve - }) - } - const cache = createSubmodulePathsCache() - - const oldRead = listSubmodulePathsCached(git, '/repo', cache, 1_000) - expect(resolveOldRead).toBeTypeOf('function') - clearSubmodulePathsCache(cache) - resolveOldRead?.({ stdout: 'submodule.lib.path old-lib\n', stderr: '' }) - - await expect(oldRead).resolves.toEqual(['old-lib']) - expect(getSubmodulePathsCacheCount(cache)).toBe(0) - await expect(listSubmodulePathsCached(git, '/repo', cache, 1_001)).resolves.toEqual([ - 'fresh-lib' - ]) - expect(calls).toBe(2) - }) -}) - -describe('resolveSubmoduleWorktreePath', () => { - it('resolves relative submodule paths inside the selected worktree', () => { - expect(resolveSubmoduleWorktreePath('/repo', 'vendor/lib')).toBe( - path.resolve('/repo', 'vendor/lib') - ) - }) - - it('rejects empty, absolute, null-byte, and escaping paths', () => { - expect(() => resolveSubmoduleWorktreePath('/repo', '')).toThrow('Access denied') - expect(() => resolveSubmoduleWorktreePath('/repo', path.resolve('/tmp/outside'))).toThrow( - 'Access denied' - ) - expect(() => resolveSubmoduleWorktreePath('/repo', 'vendor\0lib')).toThrow('Access denied') - expect(() => resolveSubmoduleWorktreePath('/repo', '../outside')).toThrow('Access denied') - }) -}) diff --git a/src/relay/git-handler-submodule-ops.ts b/src/relay/git-handler-submodule-ops.ts deleted file mode 100644 index 0e3a0b96f88..00000000000 --- a/src/relay/git-handler-submodule-ops.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Submodule diff routing for the SSH relay. - * - * Why: the parent repo lists a submodule as a single gitlink row, and a gitlink - * path can't be read as a blob (`git show HEAD:` is a "bad object"). These - * helpers route a gitlink root to a synthesized pointer diff and resolve the - * configured submodule paths so inner files recurse into the submodule worktree. - * Split from git-handler-ops.ts to keep that file under the max-lines budget. - */ -import * as path from 'node:path' -import { buildDiffResult } from './git-diff-result' -import { isGitReadInterruptedError } from './git-buffer-overflow' -import { gitChangeListArgs, parseGitChangeList } from '../shared/git-change-list' -import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' - -/** - * Short TTL for the configured-submodule-paths cache, matching the local - * handler (src/main/git/status.ts) so a burst of diff clicks on a worktree - * doesn't re-read `.gitmodules` over the (possibly high-latency) SSH link. - */ -export const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 -export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 -type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } -export type SubmodulePathsCache = { - entries: Map - generation: number -} - -export function createSubmodulePathsCache(): SubmodulePathsCache { - return { entries: new Map(), generation: 0 } -} - -export function clearSubmodulePathsCache(cache: SubmodulePathsCache): void { - cache.entries.clear() - // Why: a pre-mutation SSH read must not restore stale .gitmodules paths - // after the mutation invalidated them. - cache.generation += 1 -} - -export function getSubmodulePathsCacheCount(cache: SubmodulePathsCache): number { - return cache.entries.size -} - -function getCachedSubmodulePaths( - cache: SubmodulePathsCache, - worktreePath: string, - now: number -): string[] | null { - const cached = cache.entries.get(worktreePath) - if (!cached) { - return null - } - if (cached.expiresAt <= now) { - cache.entries.delete(worktreePath) - return null - } - cache.entries.delete(worktreePath) - cache.entries.set(worktreePath, cached) - return cached.paths -} - -function pruneExpiredSubmodulePaths(cache: SubmodulePathsCache, now: number): void { - for (const [worktreePath, entry] of cache.entries) { - if (entry.expiresAt <= now) { - cache.entries.delete(worktreePath) - } - } -} - -function rememberSubmodulePaths( - cache: SubmodulePathsCache, - worktreePath: string, - paths: string[], - now: number -): void { - cache.entries.delete(worktreePath) - cache.entries.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) - while (cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { - const oldestPath = cache.entries.keys().next().value - if (oldestPath === undefined) { - break - } - cache.entries.delete(oldestPath) - } -} - -/** - * Cached variant of {@link listSubmodulePaths}. The cache is passed in (held by - * the GitHandler instance) so it is naturally bound to the connection lifecycle - * and never leaks across relay instances or tests. An empty result is cached - * too, so a submodule-free repo doesn't re-read `.gitmodules` on every diff. - */ -export async function listSubmodulePathsCached( - git: GitExec, - worktreePath: string, - cache: SubmodulePathsCache, - now: number = Date.now() -): Promise { - const cached = getCachedSubmodulePaths(cache, worktreePath, now) - if (cached) { - return cached - } - // Why: prune on misses so disconnected worktrees cannot accumulate while - // repeated SSH diff clicks keep their O(1) cache-hit path. - pruneExpiredSubmodulePaths(cache, now) - const cacheGeneration = cache.generation - const paths = await listSubmodulePaths(git, worktreePath) - if (cacheGeneration === cache.generation) { - rememberSubmodulePaths(cache, worktreePath, paths, now) - } - return paths -} - -/** - * Configured submodule paths (relative, forward-slash) read from `.gitmodules`. - * Used to route gitlink/inner diffs without an index-wide `ls-files` scan. - */ -export async function listSubmodulePaths(git: GitExec, worktreePath: string): Promise { - try { - const { stdout } = await git( - ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], - worktreePath - ) - return stdout - .split(/\r?\n/) - .map((line) => { - const spaceIndex = line.indexOf(' ') - return spaceIndex === -1 - ? '' - : line - .slice(spaceIndex + 1) - .trim() - .replace(/\/+$/, '') - }) - .filter((value) => value.length > 0) - } catch (error) { - if (isGitReadInterruptedError(error)) { - throw error - } - return [] - } -} - -export function findContainingSubmodule(submodulePaths: string[], filePath: string): string | null { - const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') - let best: string | null = null - for (const sub of submodulePaths) { - if (normalized === sub || normalized.startsWith(`${sub}/`)) { - if (!best || sub.length > best.length) { - best = sub - } - } - } - return best -} - -// Why: .gitmodules is repo-controlled; validate its paths before relay reads -// or diffs inside a submodule worktree. -export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string { - if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) { - throw new Error('Access denied: invalid submodule path') - } - const resolved = path.resolve(worktreePath, submodulePath) - const rel = path.relative(path.resolve(worktreePath), resolved) - if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { - throw new Error('Access denied: submodule path resolves outside the worktree') - } - return resolved -} - -async function readGitlinkOidFromTree( - git: GitExec, - worktreePath: string, - ref: string, - submodulePath: string -): Promise { - try { - const { stdout } = await git(['ls-tree', ref, '--', submodulePath], worktreePath) - return stdout.match(/^160000 commit ([0-9a-f]+)\t/m)?.[1] ?? '' - } catch { - return '' - } -} - -async function readGitlinkOidFromIndex( - git: GitExec, - worktreePath: string, - submodulePath: string -): Promise { - try { - const { stdout } = await git(['ls-files', '-s', '--', submodulePath], worktreePath) - return stdout.match(/^160000 ([0-9a-f]+) /m)?.[1] ?? '' - } catch { - return '' - } -} - -async function readWorkingSubmoduleHead( - git: GitExec, - submoduleWorktreePath: string -): Promise { - try { - const { stdout } = await git(['rev-parse', 'HEAD'], submoduleWorktreePath) - return stdout.trim() - } catch { - return '' - } -} - -/** - * Resolve the submodule's recorded commit (parent index, falling back to HEAD) - * and its checked-out worktree commit. When these differ the gitlink moved. - */ -export async function resolveSubmoduleCommitRange( - git: GitExec, - worktreePath: string, - submodulePath: string, - staged = false -): Promise<{ fromOid: string; toOid: string }> { - const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) - const fromOid = staged - ? await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath) - : (await readGitlinkOidFromIndex(git, worktreePath, submodulePath)) || - (await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath)) - const toOid = staged - ? await readGitlinkOidFromIndex(git, worktreePath, submodulePath) - : await readWorkingSubmoduleHead(git, submoduleWorktreePath) - return { fromOid, toOid } -} - -/** - * List files changed between two submodule commits as status rows (area - * `unstaged`), so an expanded moved-pointer submodule shows its committed file - * changes instead of an empty working-tree status. - */ -export async function computeSubmoduleRangeEntries( - git: GitExec, - submoduleWorktreePath: string, - fromOid: string, - toOid: string -): Promise[]> { - try { - const { stdout } = await git(gitChangeListArgs(fromOid, toOid), submoduleWorktreePath) - return parseGitChangeList(stdout).map((entry) => ({ ...entry, area: 'unstaged' })) - } catch { - return [] - } -} - -/** - * Diff a file inside a submodule across two of its commits (recorded vs - * checked-out), mirroring the local handler's commit-range route. - */ -export async function buildSubmoduleInnerCommitRangeDiff( - gitBuffer: GitBufferExec, - submoduleWorktreePath: string, - innerPath: string, - fromOid: string, - toOid: string -) { - const left = await readBlobAtOid(gitBuffer, submoduleWorktreePath, fromOid, innerPath) - const right = await readBlobAtOid(gitBuffer, submoduleWorktreePath, toOid, innerPath) - return buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, innerPath) -} - -/** - * Synthesize a gitlink pointer diff (one-line `Subproject commit ` swap), - * matching git's own rendering of submodule commit changes. - */ -export async function computeSubmodulePointerDiff( - git: GitExec, - worktreePath: string, - submodulePath: string, - staged: boolean, - compareAgainstHead = false -) { - const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) - let leftOid = '' - let rightOid = '' - if (staged) { - leftOid = await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath) - rightOid = await readGitlinkOidFromIndex(git, worktreePath, submodulePath) - } else if (compareAgainstHead) { - leftOid = await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath) - rightOid = await readWorkingSubmoduleHead(git, submoduleWorktreePath) - } else { - leftOid = - (await readGitlinkOidFromIndex(git, worktreePath, submodulePath)) || - (await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath)) - rightOid = await readWorkingSubmoduleHead(git, submoduleWorktreePath) - } - return buildDiffResult( - leftOid ? `Subproject commit ${leftOid}\n` : '', - rightOid ? `Subproject commit ${rightOid}\n` : '', - false, - false, - submodulePath - ) -} diff --git a/src/relay/git-handler-submodule-status-cancellation.test.ts b/src/relay/git-handler-submodule-status-cancellation.test.ts deleted file mode 100644 index 316a2d4ef20..00000000000 --- a/src/relay/git-handler-submodule-status-cancellation.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { - createMockDispatcher, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -describe('GitHandler submodule status cancellation', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - let worktreePath: string - - beforeEach(() => { - worktreePath = mkdtempSync(join(tmpdir(), 'relay-submodule-status-cancel-')) - dispatcher = createMockDispatcher() - handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - }) - - afterEach(async () => { - handler.dispose() - await rm(worktreePath, { recursive: true, force: true }) - }) - - it('rejects a cancelled request before spawning submodule Git work', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - dispatcher.callRequest( - 'git.submoduleStatus', - { worktreePath, submodulePath: 'vendor/library' }, - { isStale: () => false, signal: controller.signal } - ) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) -}) diff --git a/src/relay/git-handler-sync-operations.ts b/src/relay/git-handler-sync-operations.ts deleted file mode 100644 index a05213585db..00000000000 --- a/src/relay/git-handler-sync-operations.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { RequestContext } from '../wsl-guest/dispatcher' -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { resolveRelayPushTarget } from './git-handler-push-target' -import { normalizeGitErrorMessage, runPullWithDivergenceFallback } from '../shared/git-remote-error' -import { assertValidGitPushTarget } from '../shared/git-push-target-validation' -import type { GitCommandRunner } from '../shared/git-publish-target-status' -import type { GitPushTarget } from '../shared/worktree/types' -import { resolveEffectiveGitUpstream } from '../shared/git-effective-upstream' -import { runWithGitWorktreeOperationLock } from '../shared/git-worktree-operation-lock' -import { - REBASE_FROM_BASE_OPERATION_TIMEOUT_MS, - REBASE_SOURCE_FETCH_TIMEOUT_MS, - resolveGitRemoteRebaseSource -} from '../shared/git-rebase-source' -import { isNoWriteFetchHeadUnsupportedError } from '../shared/git-fetch-head-capability' - -export class GitHandlerSyncOperations extends GitHandlerOperationContext { - async push(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - // Why: mirror src/main/git/remote.ts — push to a configured upstream when present so SSH worktrees with non-origin targets aren't repointed. - void params.publish - try { - try { - const target = await resolveRelayPushTarget( - this.git.bind(this), - worktreePath, - params.pushTarget - ) - const args = [ - 'push', - ...(params.forceWithLease === true ? ['--force-with-lease'] : []), - '--set-upstream', - ...(target ? [target.remote, target.refspec] : ['origin', 'HEAD']) - ] - await this.git(args, worktreePath) - } catch (error) { - // Why: mirror local gitPush normalization so SSH users get "non-fast-forward / pull first" guidance instead of raw git stderr. - throw new Error(normalizeGitErrorMessage(error, 'push')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async pullWithArgs( - params: Record, - pullArgs: string[], - signal?: AbortSignal - ) { - const worktreePath = params.worktreePath as string - return runWithGitWorktreeOperationLock(worktreePath, signal, () => - this.runPullWithArgsUnlocked(params, pullArgs) - ) - } - - private async runPullWithArgsUnlocked( - params: Record, - pullArgs: string[] - ): Promise { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const runPull = async (effectiveArgs: string[]): Promise => { - if (params.pushTarget !== undefined) { - assertValidGitPushTarget(params.pushTarget) - const pushTarget = params.pushTarget as GitPushTarget - await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) - await this.git( - ['pull', ...effectiveArgs, pushTarget.remoteName, pushTarget.branchName], - worktreePath - ) - return - } - const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath)) - if (upstream && !upstream.isConfiguredUpstream) { - // Why: legacy Orca branches may track origin/main while pushes target origin/; pull the same effective branch the UI reports. - await this.git( - ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName], - worktreePath - ) - return - } - await this.git(['pull', ...effectiveArgs], worktreePath) - } - - try { - try { - await runPullWithDivergenceFallback(pullArgs, runPull) - } catch (error) { - // Why: mirror local gitPull normalization so SSH users get actionable messages instead of raw git stderr. - throw new Error(normalizeGitErrorMessage(error, 'pull')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - async pull(params: Record, context?: RequestContext) { - // Why: plain `git pull` honors user merge/rebase/ff policy. - await this.pullWithArgs(params, [], context?.signal) - } - - async fastForward(params: Record, context?: RequestContext) { - await this.pullWithArgs(params, ['--ff-only'], context?.signal) - } - - async rebaseFromBase(params: Record, context?: RequestContext) { - return runWithGitWorktreeOperationLock(params.worktreePath as string, context?.signal, () => - this.runRebaseFromBase(params, context) - ) - } - - private async runRebaseFromBase(params: Record, context?: RequestContext) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const baseRef = params.baseRef as string - let rebaseRef: string | null = null - const controller = new AbortController() - const abortFromContext = () => controller.abort() - if (context?.signal?.aborted) { - controller.abort() - } else { - context?.signal?.addEventListener('abort', abortFromContext, { once: true }) - } - const timeout = setTimeout(() => controller.abort(), REBASE_FROM_BASE_OPERATION_TIMEOUT_MS) - try { - try { - const source = await resolveGitRemoteRebaseSource( - ((args) => - this.git(args, worktreePath, { - signal: controller.signal, - terminationBarrier: true - })) as GitCommandRunner, - baseRef - ) - let forkPoint: string | null = null - let hasHead = true - try { - const { stdout } = await this.git( - ['merge-base', '--fork-point', `refs/remotes/${source.displayName}`, 'HEAD'], - worktreePath, - { signal: controller.signal, terminationBarrier: true } - ) - forkPoint = stdout.trim() || null - } catch { - // A first fetch or an unhelpful reflog falls back to Git's merge-base behavior. - try { - await this.git(['rev-parse', '--verify', 'HEAD'], worktreePath, { - signal: controller.signal, - terminationBarrier: true - }) - } catch { - hasHead = false - } - } - // Why: concurrent fetches can replace FETCH_HEAD and remote-tracking refs between fetch and rebase. - rebaseRef = `refs/orca/rebase/${randomUUID()}` - const fetchArgs = [ - source.remoteName, - `+refs/heads/${source.branchName}:${rebaseRef}`, - `+refs/heads/${source.branchName}:refs/remotes/${source.displayName}` - ] - await this.gitCapabilities.runWithFallback( - 'fetch-no-write-fetch-head', - () => - this.git(['fetch', '--no-write-fetch-head', ...fetchArgs], worktreePath, { - timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, - signal: controller.signal, - terminationBarrier: true - }), - () => - this.git(['fetch', ...fetchArgs], worktreePath, { - timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, - signal: controller.signal, - terminationBarrier: true - }), - isNoWriteFetchHeadUnsupportedError - ) - await this.git( - hasHead - ? forkPoint - ? ['rebase', '--onto', rebaseRef, forkPoint] - : ['rebase', rebaseRef] - : ['merge', '--ff-only', rebaseRef], - worktreePath, - { signal: controller.signal, terminationBarrier: true } - ) - } catch (error) { - throw new Error(normalizeGitErrorMessage(error, 'pull')) - } - } finally { - if (rebaseRef) { - try { - await this.git(['update-ref', '-d', rebaseRef], worktreePath) - } catch { - // Cleanup must not hide the fetch or rebase result. - } - } - clearTimeout(timeout) - context?.signal?.removeEventListener('abort', abortFromContext) - this.clearGitMutationReadCaches() - } - } -} diff --git a/src/relay/git-handler-termination.test.ts b/src/relay/git-handler-termination.test.ts deleted file mode 100644 index 5b3550e0ef4..00000000000 --- a/src/relay/git-handler-termination.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) - -vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) - -import { createGitHandlerRelay } from './git-handler-test-harness' - -type GitTerminationTarget = { - git( - args: string[], - cwd: string, - options: { signal?: AbortSignal; terminationBarrier: true; timeout?: number } - ): Promise<{ stdout: string; stderr: string }> -} - -describe('GitHandler termination barrier', () => { - beforeEach(() => runProcessMock.mockReset()) - - it('rejects a zero-exit result that crossed its timeout', async () => { - runProcessMock.mockResolvedValue({ - code: 0, - signal: null, - stdout: '', - stderr: '', - timedOut: true - }) - const { handler } = createGitHandlerRelay() - const target = handler as unknown as GitTerminationTarget - - await expect( - target.git(['status'], '/repo', { terminationBarrier: true, timeout: 1 }) - ).rejects.toThrow('git status timed out.') - }) - - it('rejects a zero-exit result after caller cancellation', async () => { - runProcessMock.mockResolvedValue({ - code: 0, - signal: null, - stdout: '', - stderr: '', - timedOut: false - }) - const controller = new AbortController() - controller.abort() - const { handler } = createGitHandlerRelay() - const target = handler as unknown as GitTerminationTarget - - await expect( - target.git(['status'], '/repo', { - signal: controller.signal, - terminationBarrier: true - }) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) -}) diff --git a/src/relay/git-handler-test-harness.ts b/src/relay/git-handler-test-harness.ts deleted file mode 100644 index b2f5cd5af8c..00000000000 --- a/src/relay/git-handler-test-harness.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Shared per-test scaffolding for the GitHandler relay suites: a throwaway repo - * directory, a GitHandler bound to a mock dispatcher, and the spy-target shapes - * used to stub the handler's private git runners. - */ -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { GitHandler } from './git-handler' -import { RelayContext } from './context' -import { - createMockDispatcher, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -export type GitBufferSpyTarget = { - gitBuffer(args: string[], cwd: string): Promise -} - -export type GitSpyTarget = { - git( - args: string[], - cwd: string, - opts?: { signal?: AbortSignal } - ): Promise<{ stdout: string; stderr: string }> -} - -export function createGitTempDir(): string { - return mkdtempSync(path.join(tmpdir(), 'relay-git-')) -} - -export async function removeGitTempDir(dir: string): Promise { - await fs.rm(dir, { recursive: true, force: true }) -} - -export function createGitHandlerRelay(): { dispatcher: MockDispatcher; handler: GitHandler } { - const dispatcher = createMockDispatcher() - const ctx = new RelayContext() - const handler = new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) - return { dispatcher, handler } -} - -export function normalizeGitFileText(content: string): string { - return content.replace(/\r\n/g, '\n') -} diff --git a/src/relay/git-handler-test-setup.ts b/src/relay/git-handler-test-setup.ts deleted file mode 100644 index 9eba417894b..00000000000 --- a/src/relay/git-handler-test-setup.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Shared test utilities for git-handler tests. - * - * Why: oxlint max-lines (300) requires splitting large test suites. - * This module exports the mock dispatcher factory and git helpers - * so multiple test files can reuse them without duplication. - */ -import { vi } from 'vitest' -import { execFileSync } from 'node:child_process' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -const TEST_GIT_USER_EMAIL = 'test@test.com' -const TEST_GIT_USER_NAME = 'Test' - -// Why: declare an explicit type so the inferred return type of -// createMockDispatcher doesn't transitively reference `@vitest/spy`'s -// internal `Procedure` type (from `vi.fn(...)`). Without this annotation, -// TS2883 fires under `pnpm run tc:node` because the generated .d.ts would -// need to name a type that isn't portably resolvable from this module. -export type MockDispatcher = { - onRequest: ( - method: string, - handler: ( - params: Record, - context: { isStale: () => boolean; signal?: AbortSignal } - ) => Promise - ) => void - onNotification: (method: string, handler: (params: Record) => void) => void - notify: (method: string, params?: Record) => void - _requestHandlers: Map< - string, - ( - params: Record, - context: { isStale: () => boolean; signal?: AbortSignal } - ) => Promise - > - callRequest( - method: string, - params?: Record, - context?: { isStale: () => boolean; signal?: AbortSignal } - ): Promise -} - -export function createMockDispatcher(): MockDispatcher { - const requestHandlers = new Map< - string, - ( - params: Record, - context: { isStale: () => boolean; signal?: AbortSignal } - ) => Promise - >() - - return { - onRequest: vi.fn( - ( - method: string, - handler: ( - params: Record, - context: { isStale: () => boolean; signal?: AbortSignal } - ) => Promise - ) => { - requestHandlers.set(method, handler) - } - ), - onNotification: vi.fn(), - notify: vi.fn(), - _requestHandlers: requestHandlers, - async callRequest( - method: string, - params: Record = {}, - context: { isStale: () => boolean; signal?: AbortSignal } = { isStale: () => false } - ) { - const handler = requestHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params, context) - } - } -} - -export function gitInit(dir: string): void { - execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', TEST_GIT_USER_EMAIL], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.name', TEST_GIT_USER_NAME], { cwd: dir, stdio: 'pipe' }) -} - -export function gitCommit(dir: string, message: string): void { - execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) - // Why: `git submodule add` creates a checkout that does not inherit the - // source repo's local identity config, and CI may have no global identity. - execFileSync( - 'git', - [ - '-c', - `user.email=${TEST_GIT_USER_EMAIL}`, - '-c', - `user.name=${TEST_GIT_USER_NAME}`, - 'commit', - '-m', - message, - '--allow-empty' - ], - { cwd: dir, stdio: 'pipe' } - ) -} - -export type { RelayDispatcher } diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts deleted file mode 100644 index 8e2aa57f766..00000000000 --- a/src/relay/git-handler-utils.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isUnsupportedWorktreeListZError } from './git-handler-utils' - -describe('isUnsupportedWorktreeListZError', () => { - it('detects an unknown-switch usage error from stderr when the exit code is absent', () => { - // Isolates the regex fallback: no numeric code, so only the stderr text - // (a runner that dropped the exit code) can classify the rejection. - const error = Object.assign(new Error('worktree list -z'), { - stderr: "error: unknown switch `z'\nusage: git worktree list []\n" - }) - expect(isUnsupportedWorktreeListZError(error)).toBe(true) - }) - - it('detects a localized (non-English) usage error via exit code 129', () => { - // The SSH remote may run under a non-English locale where the stderr text is - // translated; the numeric exit code must still classify the -z rejection. - const error = Object.assign(new Error('worktree list -z'), { - code: 129, - stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n' - }) - expect(isUnsupportedWorktreeListZError(error)).toBe(true) - }) - - it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => { - const error = Object.assign(new Error('fatal'), { - code: 128, - stderr: 'fatal: unable to read tree\n' - }) - expect(isUnsupportedWorktreeListZError(error)).toBe(false) - }) -}) diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts deleted file mode 100644 index ab0d5a5439b..00000000000 --- a/src/relay/git-handler-utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Pure parsing helpers extracted from git-handler.ts. - * - * Why: oxlint max-lines requires files to stay under 300 lines. - * These functions have no side-effects and depend only on their arguments, - * making them easy to test independently. - */ -import * as path from 'node:path' -import { isBinaryBuffer } from '../shared/binary-buffer' -export { isUnsupportedWorktreeListZError } from '../shared/git-worktree-command-capabilities' - -// ─── Binary / blob helpers ─────────────────────────────────────────── - -export const PREVIEWABLE_MIME: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} - -export function bufferToBlob( - buffer: Buffer, - filePath?: string -): { content: string; isBinary: boolean } { - const binary = isBinaryBuffer(buffer) - if (binary) { - const ext = filePath ? path.extname(filePath).toLowerCase() : '' - const previewable = !!PREVIEWABLE_MIME[ext] - return { content: previewable ? buffer.toString('base64') : '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } -} diff --git a/src/relay/git-handler-working-tree-changes.test.ts b/src/relay/git-handler-working-tree-changes.test.ts deleted file mode 100644 index 699b3f02fa9..00000000000 --- a/src/relay/git-handler-working-tree-changes.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -/** - * GitHandler working-tree change reporting and mutation: status entries, - * ignored-path checks, stage/unstage, and discard (single + bulk). - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { execFileSync } from 'node:child_process' -import type { GitHandler } from './git-handler' -import { gitInit, gitCommit, type MockDispatcher } from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from './git-handler-test-harness' - -describe('GitHandler', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - let tmpDir: string - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - describe('status', () => { - it('returns empty entries for clean repo', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - gitCommit(tmpDir, 'initial') - - const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: Record[] - conflictOperation: string - head?: string - branch?: string - } - expect(result.entries).toEqual([]) - expect(result.conflictOperation).toBe('unknown') - expect(result.branch).toMatch(/^refs\/heads\//) - expect(typeof result.head).toBe('string') - }) - - it('detects untracked files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'tracked.txt'), 'tracked') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'new.txt'), 'new') - - const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: { - path?: unknown - status?: unknown - area?: unknown - added?: unknown - removed?: unknown - }[] - } - const untracked = result.entries.find((e) => e.path === 'new.txt') - expect(untracked).toBeDefined() - expect(untracked!.status).toBe('untracked') - expect(untracked!.area).toBe('untracked') - expect(untracked!.added).toBe(1) - expect(untracked!.removed).toBeUndefined() - }) - - it('returns ignored paths only when requested', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n') - gitCommit(tmpDir, 'initial') - mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }) - writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled') - writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret') - - const defaultResult = (await dispatcher.callRequest('git.status', { - worktreePath: tmpDir - })) as { - ignoredPaths?: string[] - } - const ignoredResult = (await dispatcher.callRequest('git.status', { - worktreePath: tmpDir, - includeIgnored: true - })) as { - ignoredPaths?: string[] - } - - expect('ignoredPaths' in defaultResult).toBe(false) - expect(ignoredResult.ignoredPaths).toEqual(expect.arrayContaining(['dist/', '.env'])) - }) - - it('checks ignored status for selected paths', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n') - gitCommit(tmpDir, 'initial') - mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }) - writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled') - writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret') - - const result = (await dispatcher.callRequest('git.checkIgnored', { - worktreePath: tmpDir, - paths: ['dist/bundle.js', 'src/index.ts', '.env'] - })) as string[] - - expect(result).toEqual(expect.arrayContaining(['dist/bundle.js', '.env'])) - expect(result).not.toContain('src/index.ts') - }) - - it('detects modified files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'original') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') - - const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: { - path?: unknown - status?: unknown - area?: unknown - added?: unknown - removed?: unknown - }[] - } - const modified = result.entries.find((e) => e.path === 'file.txt') - expect(modified).toBeDefined() - expect(modified!.status).toBe('modified') - expect(modified!.area).toBe('unstaged') - expect(modified!.added).toBe(1) - expect(modified!.removed).toBe(1) - }) - - it('detects staged files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'original') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') - execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) - - const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: { - path?: unknown - status?: unknown - area?: unknown - added?: unknown - removed?: unknown - }[] - } - const staged = result.entries.find((e) => e.area === 'staged') - expect(staged).toBeDefined() - expect(staged!.status).toBe('modified') - expect(staged!.added).toBe(1) - expect(staged!.removed).toBe(1) - }) - - // Why: regression for #1503 — default core.quotePath=true octal-escapes non-ASCII paths (breaks sidebar + blob reads). - it('preserves UTF-8 paths in status output', async () => { - gitInit(tmpDir) - const utf8Dir = path.join(tmpDir, 'docs', '日本語') - mkdirSync(utf8Dir, { recursive: true }) - writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') - - const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: Record[] - } - const entry = result.entries.find((e) => - typeof e.path === 'string' ? e.path.endsWith('sample.md') : false - ) - expect(entry).toBeDefined() - expect(entry!.path).toBe('docs/日本語/sample.md') - }) - - // Why: regression for #1503 on the porcelain v2 type-1 (tracked+modified) parser branch, which the untracked '?' test misses. - it('preserves UTF-8 paths for tracked-modified entries', async () => { - gitInit(tmpDir) - const utf8Dir = path.join(tmpDir, 'docs', '日本語') - mkdirSync(utf8Dir, { recursive: true }) - const utf8File = path.join(utf8Dir, 'sample.md') - writeFileSync(utf8File, 'original') - gitCommit(tmpDir, 'initial') - writeFileSync(utf8File, 'modified') - - const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { - entries: Record[] - } - const entry = result.entries.find((e) => - typeof e.path === 'string' ? e.path.endsWith('sample.md') : false - ) - expect(entry).toBeDefined() - expect(entry!.path).toBe('docs/日本語/sample.md') - expect(entry!.status).toBe('modified') - expect(entry!.area).toBe('unstaged') - }) - }) - - describe('stage and unstage', () => { - it('stages a file', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'content') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') - - await dispatcher.callRequest('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output.trim()).toBe('file.txt') - }) - - it('unstages a file', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'content') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') - execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) - - await dispatcher.callRequest('git.unstage', { worktreePath: tmpDir, filePath: 'file.txt' }) - - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output.trim()).toBe('') - }) - }) - - describe('discard', () => { - it('discards changes to tracked file', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'original') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') - - await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'file.txt' }) - - const content = await fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8') - expect(content).toBe('original') - }) - - it('deletes untracked file on discard', async () => { - gitInit(tmpDir) - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'new.txt'), 'untracked') - - await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'new.txt' }) - await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() - }) - - it('treats untracked discard paths with Git glob characters as literal paths', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, '.gitignore'), 'ignored.log\n') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, '[k]eep.log'), 'selected') - writeFileSync(path.join(tmpDir, 'keep.log'), 'unrelated') - writeFileSync(path.join(tmpDir, 'ignored.log'), 'ignored') - - await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '[k]eep.log' }) - - await expect(fs.access(path.join(tmpDir, '[k]eep.log'))).rejects.toThrow() - await expect(fs.access(path.join(tmpDir, 'keep.log'))).resolves.toBeUndefined() - await expect(fs.access(path.join(tmpDir, 'ignored.log'))).resolves.toBeUndefined() - }) - - it('treats tracked discard paths with Git glob characters as literal paths', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, '[k]eep.log'), 'selected') - writeFileSync(path.join(tmpDir, 'keep.log'), 'keep') - gitCommit(tmpDir, 'track log fixtures') - writeFileSync(path.join(tmpDir, '[k]eep.log'), 'selected modified') - writeFileSync(path.join(tmpDir, 'keep.log'), 'keep modified') - - await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '[k]eep.log' }) - - await expect(fs.readFile(path.join(tmpDir, '[k]eep.log'), 'utf-8')).resolves.toBe('selected') - await expect(fs.readFile(path.join(tmpDir, 'keep.log'), 'utf-8')).resolves.toBe( - 'keep modified' - ) - }) - - it('bulk discards tracked and untracked files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') - writeFileSync(path.join(tmpDir, 'new.txt'), 'untracked') - - await dispatcher.callRequest('git.bulkDiscard', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt', 'new.txt'] - }) - - await expect(fs.readFile(path.join(tmpDir, 'a.txt'), 'utf-8')).resolves.toBe('a') - await expect(fs.readFile(path.join(tmpDir, 'b.txt'), 'utf-8')).resolves.toBe('b') - await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() - }) - - it('preserves bulk discard action selection and original path order for path edges', async () => { - const filePaths = ['new', 'docs\\', '[ab].txt', 'docs///', 'new', 'src/file', 'docs\\'] - const gitMock = vi - .spyOn( - handler as unknown as { - git: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> - }, - 'git' - ) - .mockResolvedValueOnce({ stdout: 'docs/readme\0src/file-extra\0[ab].txt\0', stderr: '' }) - .mockResolvedValue({ stdout: '', stderr: '' }) - - await dispatcher.callRequest('git.bulkDiscard', { worktreePath: tmpDir, filePaths }) - - expect(gitMock.mock.calls.map(([args]) => args)).toEqual([ - ['ls-files', '-z', '--', ...filePaths.map((filePath) => `:(literal)${filePath}`)], - ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], - ['clean', '-ffdx', '--', ':(literal)new', ':(literal)new', ':(literal)src/file'] - ]) - expect(gitMock).toHaveBeenNthCalledWith( - 2, - ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], - tmpDir, - { stdin: ':(literal)docs\\\0:(literal)[ab].txt\0:(literal)docs///\0:(literal)docs\\\0' } - ) - }) - - it('handles large tracked path lists during bulk discard classification', async () => { - const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`) - .join('\0') - .concat('\0') - const gitMock = vi - .spyOn( - handler as unknown as { - git: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> - }, - 'git' - ) - .mockResolvedValueOnce({ stdout: trackedStdout, stderr: '' }) - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - - await dispatcher.callRequest('git.bulkDiscard', { - worktreePath: tmpDir, - filePaths: ['docs'] - }) - - expect(gitMock).toHaveBeenNthCalledWith( - 2, - ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], - tmpDir, - { stdin: ':(literal)docs\0' } - ) - }) - - it('rejects path traversal', async () => { - gitInit(tmpDir) - await expect( - dispatcher.callRequest('git.discard', { - worktreePath: tmpDir, - filePath: '../../../etc/passwd' - }) - ).rejects.toThrow('outside the worktree') - }) - - it('rejects bulk discard path traversal', async () => { - gitInit(tmpDir) - await expect( - dispatcher.callRequest('git.bulkDiscard', { - worktreePath: tmpDir, - filePaths: ['file.txt', '../../../etc/passwd'] - }) - ).rejects.toThrow('outside the worktree') - }) - - it('rejects untracked child paths through symlinked parents', async () => { - gitInit(tmpDir) - gitCommit(tmpDir, 'initial') - const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) - const outsideFile = path.join(outsideDir, 'keep.txt') - writeFileSync(outsideFile, 'outside') - symlinkSync( - outsideDir, - path.join(tmpDir, 'link'), - process.platform === 'win32' ? 'junction' : 'dir' - ) - - try { - await expect( - dispatcher.callRequest('git.discard', { - worktreePath: tmpDir, - filePath: 'link/keep.txt' - }) - ).rejects.toThrow('outside the worktree') - await expect(fs.access(outsideFile)).resolves.toBeUndefined() - } finally { - await fs.rm(outsideDir, { recursive: true, force: true }) - } - }) - - it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => { - gitInit(tmpDir) - gitCommit(tmpDir, 'initial') - const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) - const outsideFile = path.join(outsideDir, 'keep.txt') - const untrackedFile = path.join(tmpDir, 'new.txt') - writeFileSync(outsideFile, 'outside') - writeFileSync(untrackedFile, 'untracked') - symlinkSync( - outsideDir, - path.join(tmpDir, 'link'), - process.platform === 'win32' ? 'junction' : 'dir' - ) - - try { - await expect( - dispatcher.callRequest('git.bulkDiscard', { - worktreePath: tmpDir, - filePaths: ['new.txt', 'link/keep.txt'] - }) - ).rejects.toThrow('outside the worktree') - await expect(fs.access(outsideFile)).resolves.toBeUndefined() - await expect(fs.access(untrackedFile)).resolves.toBeUndefined() - } finally { - await fs.rm(outsideDir, { recursive: true, force: true }) - } - }) - }) -}) diff --git a/src/relay/git-handler-worktree-change-operations.ts b/src/relay/git-handler-worktree-change-operations.ts deleted file mode 100644 index c954aca0708..00000000000 --- a/src/relay/git-handler-worktree-change-operations.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { commitChangesRelay } from './git-handler-worktree-ops' -import { encodeGitPathspecs } from '../shared/git-pathspec-stdin' -import { - parseGitStageWorktreeScope, - stageGitWorktreeScope, - type GitStageWorktreeScopeReceipt -} from '../shared/git-stage-worktree-scope' - -export class GitHandlerWorktreeChangeOperations extends GitHandlerOperationContext { - async stage(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePath = params.filePath as string - try { - await this.git(['add', '--', this.literalPathspec(filePath)], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - async commit(params: Record): Promise<{ success: boolean; error?: string }> { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const message = params.message as string - try { - return await commitChangesRelay(this.git.bind(this), worktreePath, message) - } finally { - this.clearGitMutationReadCaches() - } - } - - async unstage(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePath = params.filePath as string - try { - // Reset treats an unborn HEAD as an empty tree, preserving the working file. - await this.git(['reset', '--quiet', '--', this.literalPathspec(filePath)], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - async bulkStage( - params: Record - ): Promise { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePaths = params.filePaths as string[] - // Why: optional so older clients keep the listed-paths behavior. - const scope = parseGitStageWorktreeScope(params.scope) - try { - if (scope) { - // Why: the receipt is how the client tells this relay from an older one that ignored `scope`. - return await stageGitWorktreeScope(scope, (args, stdin) => - this.git(args, worktreePath, stdin === undefined ? undefined : { stdin }) - ) - } - if (filePaths.length === 0) { - return undefined - } - await this.git(['add', '--pathspec-from-file=-', '--pathspec-file-nul'], worktreePath, { - stdin: encodeGitPathspecs(filePaths.map((filePath) => this.literalPathspec(filePath))) - }) - return undefined - } finally { - this.clearGitMutationReadCaches() - } - } - - async bulkUnstage(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePaths = params.filePaths as string[] - try { - if (filePaths.length === 0) { - return - } - await this.git( - ['reset', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'], - worktreePath, - { stdin: encodeGitPathspecs(filePaths.map((filePath) => this.literalPathspec(filePath))) } - ) - } finally { - this.clearGitMutationReadCaches() - } - } - - async abortMerge(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - try { - await this.git(['merge', '--abort'], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - async abortRebase(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - try { - await this.git(['rebase', '--abort'], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - async checkout(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const branch = params.branch as string - // Defense-in-depth: reject `-`-prefixed branch tokens to block flag injection (this relay entrypoint is reachable independently of the RPC schema). - if (typeof branch !== 'string' || branch.length === 0 || branch.startsWith('-')) { - throw new Error('invalid_branch_name') - } - try { - await this.git(['checkout', branch, '--'], worktreePath) - return { ok: true as const, branch } - } finally { - this.clearGitMutationReadCaches() - } - } - - async localBranches(params: Record) { - const worktreePath = params.worktreePath as string - const { stdout } = await this.git( - ['for-each-ref', '--format=%(HEAD)%09%(refname:short)', 'refs/heads/'], - worktreePath - ) - let current: string | null = null - const branches: string[] = [] - for (const line of stdout.split('\n')) { - if (line.length === 0) { - continue - } - const [marker, name] = line.split('\t') - if (!name) { - continue - } - if (marker === '*') { - current = name - } - branches.push(name) - } - branches.sort((a, b) => (a === current ? -1 : b === current ? 1 : 0)) - return { current, branches } - } -} diff --git a/src/relay/git-handler-worktree-clean.test.ts b/src/relay/git-handler-worktree-clean.test.ts deleted file mode 100644 index f351c0bd50b..00000000000 --- a/src/relay/git-handler-worktree-clean.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { GitExec } from './git-handler-ops' -import { worktreeIsCleanOp } from './git-handler-worktree-ops' - -describe('worktreeIsCleanOp', () => { - it('reports clean SSH worktrees without returning porcelain output', async () => { - const git = vi.fn(async () => ({ stdout: '\n', stderr: '' })) - - await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ - clean: true, - stdout: undefined - }) - - expect(git).toHaveBeenCalledWith( - ['status', '--porcelain', '--untracked-files=all'], - '/repo-feature' - ) - }) - - it('returns porcelain output for dirty SSH worktrees', async () => { - const stdout = ' M src/file.ts\n?? scratch.txt\n' - const git = vi.fn(async () => ({ stdout, stderr: '' })) - - await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ - clean: false, - stdout - }) - }) -}) diff --git a/src/relay/git-handler-worktree-existence-cancellation.test.ts b/src/relay/git-handler-worktree-existence-cancellation.test.ts deleted file mode 100644 index 1cf07e20913..00000000000 --- a/src/relay/git-handler-worktree-existence-cancellation.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as FsPromises from 'node:fs/promises' -import type { GitWorktreeInfo } from '../shared/worktree/types' - -const { statProbe } = vi.hoisted(() => ({ - statProbe: vi.fn<(worktreePath: string) => Promise>() -})) -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), - stat: statProbe -})) -vi.mock('../shared/git-worktree-admin', () => ({ - annotateWorktreeLocksFromAdmin: vi.fn() -})) - -import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' - -function listedWorktree(index: number): GitWorktreeInfo { - return { - path: `/remote/repo/task-${index}`, - head: 'a'.repeat(40), - branch: `refs/heads/task-${index}`, - isBare: false, - isMainWorktree: false - } -} - -function nextTurn(): Promise { - return new Promise((resolve) => setImmediate(resolve)) -} - -beforeEach(() => { - statProbe.mockReset() -}) - -describe('relay worktree existence cancellation', () => { - it('rejects before eight pending stats finish and starts no more after they settle', async () => { - const rows = Array.from({ length: 32 }, (_, index) => listedWorktree(index)) - const controller = new AbortController() - const reason = new Error('Remote listing closed') - const releases: (() => void)[] = [] - statProbe.mockImplementation(() => new Promise((resolve) => releases.push(resolve))) - const outcome = vi.fn<(result: unknown) => void>() - const observed = annotatePrunableWorktreesByExistence(rows, controller.signal).then( - (result) => outcome(result), - (error: unknown) => outcome(error) - ) - try { - await nextTurn() - expect(statProbe).toHaveBeenCalledTimes(8) - controller.abort(reason) - await nextTurn() - expect(outcome).toHaveBeenCalledExactlyOnceWith(reason) - expect(statProbe).toHaveBeenCalledTimes(8) - } finally { - releases.splice(0).forEach((release) => release()) - } - await observed - await nextTurn() - expect(statProbe).toHaveBeenCalledTimes(8) - expect(outcome).toHaveBeenCalledExactlyOnceWith(reason) - expect(rows.every((row) => row.prunable === undefined)).toBe(true) - }) - - it('starts no stats for an already-aborted request, including an empty catalog', async () => { - const controller = new AbortController() - const reason = new Error('Already closed') - controller.abort(reason) - for (const rows of [[listedWorktree(0)], []]) { - await expect(annotatePrunableWorktreesByExistence(rows, controller.signal)).rejects.toBe( - reason - ) - } - await nextTurn() - expect(statProbe).not.toHaveBeenCalled() - }) - - it('handles synchronous abort in the first stat without abandoning rejected workers', async () => { - const controller = new AbortController() - const reason = new Error('First probe closed the request') - const releases: (() => void)[] = [] - const unhandled: unknown[] = [] - const onUnhandled = (error: unknown): void => { - unhandled.push(error) - } - process.on('unhandledRejection', onUnhandled) - statProbe.mockImplementation(() => { - controller.abort(reason) - return new Promise((resolve) => releases.push(resolve)) - }) - try { - await expect( - annotatePrunableWorktreesByExistence( - Array.from({ length: 32 }, (_, index) => listedWorktree(index)), - controller.signal - ) - ).rejects.toBe(reason) - expect(statProbe).toHaveBeenCalledTimes(1) - releases.splice(0).forEach((release) => release()) - await nextTurn() - expect(statProbe).toHaveBeenCalledTimes(1) - expect(unhandled).toEqual([]) - } finally { - releases.splice(0).forEach((release) => release()) - process.off('unhandledRejection', onUnhandled) - } - }) -}) diff --git a/src/relay/git-handler-worktree-git-capabilities.test.ts b/src/relay/git-handler-worktree-git-capabilities.test.ts deleted file mode 100644 index 6a71f6e388b..00000000000 --- a/src/relay/git-handler-worktree-git-capabilities.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { - createMockDispatcher, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> -} - -const WORKTREE_LIST_OUTPUT = `worktree /repo -HEAD abc123 -branch refs/heads/main -` - -describe('relay worktree Git capabilities', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - - beforeEach(() => { - dispatcher = createMockDispatcher() - handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - }) - - it('does not repeat a known-unsupported -z probe on later scans', async () => { - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockImplementation((args: string[]) => { - if (args.includes('-z')) { - return Promise.reject( - Object.assign(new Error('git usage error'), { - code: 129, - stderr: 'usage: git worktree list []\n' - }) - ) - } - return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) - }) - - await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - - expect(gitSpy.mock.calls.map(([args]) => args)).toEqual([ - ['worktree', 'list', '--porcelain', '-z'], - ['worktree', 'list', '--porcelain'], - ['worktree', 'list', '--porcelain'] - ]) - }) - - it('re-probes after a relay handler is replaced', async () => { - const mockOldGit = (target: GitHandler) => - vi.spyOn(target as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => { - if (args.includes('-z')) { - return Promise.reject( - Object.assign(new Error('git usage error'), { - code: 129, - stderr: 'usage: git worktree list []\n' - }) - ) - } - return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) - }) - const firstGit = mockOldGit(handler) - const replacementDispatcher = createMockDispatcher() - const replacementHandler = new GitHandler( - replacementDispatcher as unknown as RelayDispatcher, - new RelayContext() - ) - const replacementGit = mockOldGit(replacementHandler) - - await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - await replacementDispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - - expect(firstGit.mock.calls.filter(([args]) => args.includes('-z'))).toHaveLength(1) - expect(replacementGit.mock.calls.filter(([args]) => args.includes('-z'))).toHaveLength(1) - }) - - it('does not repeat a known-unsupported rev-parse --path-format probe', async () => { - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockImplementation((args: string[]) => { - if (args[0] === 'worktree') { - return Promise.resolve({ - stdout: 'worktree /git-store/project.git\nHEAD abc123\nbranch refs/heads/main\n', - stderr: '' - }) - } - if (args.includes('--path-format=absolute')) { - return Promise.reject( - Object.assign(new Error('unknown option: --path-format=absolute'), { - stderr: 'error: unknown option `path-format=absolute`\n' - }) - ) - } - return Promise.resolve({ - stdout: '/repo\n/git-store/project.git\n/git-store/project.git\n', - stderr: '' - }) - }) - - await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - - const revParseCalls = gitSpy.mock.calls.filter(([args]) => args[0] === 'rev-parse') - expect(revParseCalls.map(([args]) => args)).toEqual([ - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir', '--git-dir'], - ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'], - ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'] - ]) - }) -}) diff --git a/src/relay/git-handler-worktree-inspection.test.ts b/src/relay/git-handler-worktree-inspection.test.ts deleted file mode 100644 index fb7e86718f8..00000000000 --- a/src/relay/git-handler-worktree-inspection.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * GitHandler worktree inspection: porcelain worktree listing (path - * normalization, cancellation, exotic paths) and the clean/dirty probe. - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import type { GitHandler } from './git-handler' -import { gitInit, gitCommit, type MockDispatcher } from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir, - type GitSpyTarget -} from './git-handler-test-harness' - -describe('GitHandler', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - let tmpDir: string - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - describe('listWorktrees', () => { - it('lists worktrees for a repo', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - gitCommit(tmpDir, 'initial') - - const result = (await dispatcher.callRequest('git.listWorktrees', { - repoPath: tmpDir - })) as Record[] - expect(result.length).toBeGreaterThanOrEqual(1) - expect(result[0].isMainWorktree).toBe(true) - }) - - it('passes request cancellation to the git worktree list subprocess', async () => { - const controller = new AbortController() - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockRejectedValue(new Error('aborted')) - - await expect( - dispatcher.callRequest( - 'git.listWorktrees', - { repoPath: tmpDir }, - { isStale: () => false, signal: controller.signal } - ) - ).rejects.toThrow('aborted') - expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, { - signal: controller.signal - }) - }) - - it.skipIf(process.platform === 'win32')( - 'normalizes the main worktree path for a separate-git-dir repo', - async () => { - const sourcePath = path.join(tmpDir, 'source') - const worktreePath = path.join(tmpDir, 'worktree') - const gitDirPath = path.join(tmpDir, 'git-store', 'project.git') - mkdirSync(sourcePath) - mkdirSync(path.dirname(gitDirPath), { recursive: true }) - gitInit(sourcePath) - writeFileSync(path.join(sourcePath, 'file.txt'), 'hello') - gitCommit(sourcePath, 'initial') - - execFileSync('git', [ - 'clone', - '--quiet', - `--separate-git-dir=${gitDirPath}`, - sourcePath, - worktreePath - ]) - - const result = (await dispatcher.callRequest('git.listWorktrees', { - repoPath: await fs.realpath(worktreePath) - })) as Record[] - const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true) - - expect(mainWorktree).toMatchObject({ - path: await fs.realpath(worktreePath), - isMainWorktree: true - }) - expect(mainWorktree?.path).not.toBe(await fs.realpath(gitDirPath)) - } - ) - - it.skipIf(process.platform === 'win32')( - 'leaves an ordinary repo reached via a symlinked path unchanged', - async () => { - // A symlink alias defeats the path-string gate; the git-common-dir gate must still skip rewrite for an ordinary repo. - const repoPath = path.join(tmpDir, 'plain-repo') - mkdirSync(repoPath) - gitInit(repoPath) - writeFileSync(path.join(repoPath, 'file.txt'), 'hello') - gitCommit(repoPath, 'initial') - const linkedRepoPath = path.join(tmpDir, 'linked-repo') - symlinkSync(repoPath, linkedRepoPath) - - const result = (await dispatcher.callRequest('git.listWorktrees', { - repoPath: linkedRepoPath - })) as Record[] - const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true) - - expect(mainWorktree).toMatchObject({ - path: await fs.realpath(repoPath), - isMainWorktree: true - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'leaves a bare main entry unchanged when scanned via its linked worktree', - async () => { - // A bare main entry IS the git-common-dir; only the repo path's own git dir shows it is linked. - const sourcePath = path.join(tmpDir, 'source') - mkdirSync(sourcePath) - gitInit(sourcePath) - writeFileSync(path.join(sourcePath, 'file.txt'), 'hello') - gitCommit(sourcePath, 'initial') - const barePath = path.join(tmpDir, 'project.git') - execFileSync('git', ['clone', '--bare', '--quiet', sourcePath, barePath], { stdio: 'pipe' }) - const linkedWorktreePath = path.join(tmpDir, 'linked-wt') - execFileSync('git', ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'], { - cwd: barePath, - stdio: 'pipe' - }) - const resolvedLinked = await fs.realpath(linkedWorktreePath) - - const result = (await dispatcher.callRequest('git.listWorktrees', { - repoPath: resolvedLinked - })) as Record[] - - expect(result.find((worktree) => worktree.isMainWorktree === true)?.path).toBe( - await fs.realpath(barePath) - ) - expect(result.filter((worktree) => worktree.path === resolvedLinked)).toHaveLength(1) - } - ) - - it.skipIf(process.platform === 'win32')( - 'leaves the main entry unchanged when scanned via a linked worktree', - async () => { - // The git-common-dir gate must skip rewrite so a linked worktree's main entry isn't overwritten with its own toplevel. - const repoPath = path.join(tmpDir, 'main-repo') - mkdirSync(repoPath) - gitInit(repoPath) - writeFileSync(path.join(repoPath, 'file.txt'), 'hello') - gitCommit(repoPath, 'initial') - const linkedWorktreePath = path.join(tmpDir, 'linked-wt') - execFileSync('git', ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'], { - cwd: repoPath, - stdio: 'pipe' - }) - const resolvedLinked = await fs.realpath(linkedWorktreePath) - - const result = (await dispatcher.callRequest('git.listWorktrees', { - repoPath: resolvedLinked - })) as Record[] - const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true) - - expect(mainWorktree).toMatchObject({ - path: await fs.realpath(repoPath), - isMainWorktree: true - }) - expect(mainWorktree?.path).not.toBe(resolvedLinked) - } - ) - - it.skipIf(process.platform === 'win32')( - 'lists worktrees whose paths contain newlines', - async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - gitCommit(tmpDir, 'initial') - const worktreePath = path.join( - path.dirname(tmpDir), - `${path.basename(tmpDir)}-linked\nremote` - ) - - try { - execFileSync( - 'git', - ['worktree', 'add', '--quiet', '-b', 'feature/newline', worktreePath], - { - cwd: tmpDir, - stdio: 'pipe' - } - ) - const realWorktreePath = await fs.realpath(worktreePath) - - const result = (await dispatcher.callRequest('git.listWorktrees', { - repoPath: tmpDir - })) as Record[] - - expect(result.map((worktree) => worktree.path)).toContain(realWorktreePath) - } finally { - await fs.rm(worktreePath, { recursive: true, force: true }) - } - } - ) - }) - - describe('worktreeIsClean', () => { - it('can ignore untracked files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'tracked.txt'), 'initial') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'scratch.txt'), 'untracked') - - await expect( - dispatcher.callRequest('git.worktreeIsClean', { worktreePath: tmpDir }) - ).resolves.toEqual({ - clean: false, - stdout: expect.stringContaining('scratch.txt') - }) - await expect( - dispatcher.callRequest('git.worktreeIsClean', { - worktreePath: tmpDir, - includeUntracked: false - }) - ).resolves.toEqual({ clean: true }) - }) - }) -}) diff --git a/src/relay/git-handler-worktree-list-authority.test.ts b/src/relay/git-handler-worktree-list-authority.test.ts deleted file mode 100644 index 8b8a606dbba..00000000000 --- a/src/relay/git-handler-worktree-list-authority.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Issue #14004: a relay-side worktree-list failure must stay a failure across the relay/provider - * boundary. Converting it to `[]` reports an unreadable catalog as an authoritative empty one, and - * downstream reconciliation uses that to authorize missing-worktree teardown. - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { - createMockDispatcher, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> -} - -const WORKTREE_LIST_OUTPUT = `worktree /repo -HEAD abc123 -branch refs/heads/main -` - -/** Git <2.36 rejects `worktree list -z` with a usage error, which routes the handler to the fallback lane. */ -function unsupportedZError(): Error { - return Object.assign(new Error('git usage error'), { - code: 129, - stderr: 'usage: git worktree list []\n' - }) -} - -describe('relay worktree-list authority (#14004)', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - - beforeEach(() => { - dispatcher = createMockDispatcher() - handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - }) - - it('rejects instead of reporting an empty catalog when the fallback listing fails', async () => { - vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => - args.includes('-z') - ? Promise.reject(unsupportedZError()) - : Promise.reject( - Object.assign(new Error('fatal: not a git repository'), { code: 128, stderr: '' }) - ) - ) - - await expect( - dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - ).rejects.toThrow('not a git repository') - }) - - it('rejects a timed-out fallback listing on a host whose -z support is already known absent', async () => { - const gitSpy = vi - .spyOn(handler as unknown as GitSpyTarget, 'git') - .mockImplementation((args: string[]) => - args.includes('-z') - ? Promise.reject(unsupportedZError()) - : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) - ) - // Prime the capability cache so the probe is not repeated; later scans go straight to the fallback. - await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - - gitSpy.mockRejectedValue(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' })) - - await expect( - dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - ).rejects.toThrow('ETIMEDOUT') - expect(gitSpy.mock.calls.at(-1)?.[0]).toEqual(['worktree', 'list', '--porcelain']) - }) - - it('republishes the catalog when a later fallback listing succeeds', async () => { - let failListing = true - vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => { - if (args.includes('-z')) { - return Promise.reject(unsupportedZError()) - } - return failListing - ? Promise.reject(new Error('transient relay failure')) - : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) - }) - - await expect( - dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) - ).rejects.toThrow('transient relay failure') - - failListing = false - const result = (await dispatcher.callRequest('git.listWorktrees', { - repoPath: '/repo' - })) as Record[] - expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ path: '/repo', isMainWorktree: true }) - }) -}) diff --git a/src/relay/git-handler-worktree-list-cancellation.test.ts b/src/relay/git-handler-worktree-list-cancellation.test.ts deleted file mode 100644 index 9297e3984c8..00000000000 --- a/src/relay/git-handler-worktree-list-cancellation.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as FsPromises from 'node:fs/promises' -import type { GitWorktreeInfo } from '../shared/worktree/types' -import type { GitHandlerOperationHost } from './git-handler-operation-context' -import { createGitHandlerRelay } from './git-handler-test-harness' - -const { statProbe, annotateLocks } = vi.hoisted(() => ({ - statProbe: vi.fn<(worktreePath: string) => Promise>(), - annotateLocks: - vi.fn< - ( - repoPath: string, - rows: GitWorktreeInfo[], - options?: { signal?: AbortSignal } - ) => Promise - >() -})) -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), - stat: statProbe -})) -vi.mock('../shared/git-worktree-admin', () => ({ - annotateWorktreeLocksFromAdmin: annotateLocks -})) - -function porcelainRow(worktreePath: string): string { - return `worktree ${worktreePath}\nHEAD ${'a'.repeat(40)}\nbranch refs/heads/main\n\n` -} - -function unsupportedZError(): Error { - return Object.assign(new Error('git usage error'), { - code: 129, - stderr: 'usage: git worktree list []\n' - }) -} - -function unsupportedPathFormatError(): Error { - return Object.assign(new Error('unsupported path format'), { - stderr: 'error: unknown option `path-format=absolute`\n' - }) -} - -function nextTurn(): Promise { - return new Promise((resolve) => setImmediate(resolve)) -} - -let relay: ReturnType - -beforeEach(() => { - relay = createGitHandlerRelay() - statProbe.mockReset() - annotateLocks.mockReset().mockImplementation(async (_repoPath, rows) => rows) -}) -afterEach(() => relay.handler.dispose()) - -describe('relay worktree listing request cancellation', () => { - it('forwards the dispatcher signal through the old-Git existence fallback', async () => { - const controller = new AbortController() - const reason = new Error('Remote request closed') - const porcelain = - porcelainRow('/remote/repo') + - Array.from({ length: 32 }, (_, index) => porcelainRow(`/remote/repo/task-${index}`)).join('') - const git = vi.fn(async (args) => { - if (args.includes('-z')) { - throw unsupportedZError() - } - return { stdout: porcelain, stderr: '' } - }) - Object.assign(relay.handler, { git }) - const releases: (() => void)[] = [] - statProbe.mockImplementation(() => new Promise((resolve) => releases.push(resolve))) - const outcome = vi.fn<(result: unknown) => void>() - const observed = relay.dispatcher - .callRequest( - 'git.listWorktrees', - { repoPath: '/remote/repo' }, - { isStale: () => false, signal: controller.signal } - ) - .then( - (result) => outcome(result), - (error: unknown) => outcome(error) - ) - try { - await nextTurn() - expect(statProbe).toHaveBeenCalledTimes(8) - expect(git.mock.calls.map(([, , options]) => options?.signal)).toEqual([ - controller.signal, - controller.signal - ]) - expect(annotateLocks).toHaveBeenCalledWith('/remote/repo', expect.any(Array), { - signal: controller.signal - }) - controller.abort(reason) - await nextTurn() - expect(outcome).toHaveBeenCalledExactlyOnceWith(reason) - } finally { - releases.splice(0).forEach((release) => release()) - } - await observed - await nextTurn() - expect(statProbe).toHaveBeenCalledTimes(8) - expect(outcome).toHaveBeenCalledExactlyOnceWith(reason) - }) - - it.each(['preferred', 'fallback'] as const)( - 'preserves cancellation from the %s normalization command', - async (mode) => { - const controller = new AbortController() - const reason = new Error('Normalization canceled') - const git = vi.fn(async (args, _cwd, options) => { - expect(options?.signal).toBe(controller.signal) - if (args[0] === 'worktree') { - return { stdout: porcelainRow('/remote/git-store'), stderr: '' } - } - if (mode === 'fallback' && args.includes('--path-format=absolute')) { - throw unsupportedPathFormatError() - } - controller.abort(reason) - throw reason - }) - Object.assign(relay.handler, { git }) - await expect( - relay.dispatcher.callRequest( - 'git.listWorktrees', - { repoPath: '/remote/repo' }, - { isStale: () => false, signal: controller.signal } - ) - ).rejects.toBe(reason) - const locationCommands = git.mock.calls - .filter(([args]) => args[0] === 'rev-parse') - .map(([args]) => args) - expect(locationCommands).toEqual( - mode === 'preferred' - ? [ - [ - 'rev-parse', - '--path-format=absolute', - '--show-toplevel', - '--git-common-dir', - '--git-dir' - ] - ] - : [ - [ - 'rev-parse', - '--path-format=absolute', - '--show-toplevel', - '--git-common-dir', - '--git-dir' - ], - ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'] - ] - ) - expect(statProbe).not.toHaveBeenCalled() - } - ) - - it.each(['preferred', 'fallback'] as const)( - 'keeps separate-git-dir normalization working with the %s command', - async (mode) => { - const controller = new AbortController() - const git = vi.fn(async (args, _cwd, options) => { - expect(options?.signal).toBe(controller.signal) - if (args[0] === 'worktree') { - return { stdout: porcelainRow('/remote/git-store'), stderr: '' } - } - if (mode === 'fallback' && args.includes('--path-format=absolute')) { - throw unsupportedPathFormatError() - } - return { stdout: '/remote/repo\n/remote/git-store\n/remote/git-store\n', stderr: '' } - }) - Object.assign(relay.handler, { git }) - await expect( - relay.dispatcher.callRequest( - 'git.listWorktrees', - { repoPath: '/remote/repo' }, - { isStale: () => false, signal: controller.signal } - ) - ).resolves.toEqual([ - expect.objectContaining({ - path: '/remote/repo', - isMainWorktree: true - }) - ]) - expect(statProbe).not.toHaveBeenCalled() - } - ) - - it('rejects a pre-aborted dispatcher request before normalization or existence probes', async () => { - const controller = new AbortController() - const reason = new Error('Already closed') - controller.abort(reason) - const git = vi.fn(async () => ({ - stdout: porcelainRow('/remote/git-store'), - stderr: '' - })) - Object.assign(relay.handler, { git }) - await expect( - relay.dispatcher.callRequest( - 'git.listWorktrees', - { repoPath: '/remote/repo' }, - { isStale: () => false, signal: controller.signal } - ) - ).rejects.toBe(reason) - expect(git).toHaveBeenCalledOnce() - expect(annotateLocks).not.toHaveBeenCalled() - expect(statProbe).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/git-handler-worktree-list.test.ts b/src/relay/git-handler-worktree-list.test.ts deleted file mode 100644 index a947be7fd0f..00000000000 --- a/src/relay/git-handler-worktree-list.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' -import type { GitWorktreeInfo } from '../shared/worktree/types' - -function listedWorktree(fields: Partial & { path: string }): GitWorktreeInfo { - return { - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: false, - ...fields - } -} - -const tempRoots: string[] = [] - -async function createTempDir(): Promise { - const root = await mkdtemp(path.join(tmpdir(), 'orca-relay-prunable-')) - tempRoots.push(root) - return root -} - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('annotatePrunableWorktreesByExistence', () => { - it('marks linked worktrees with missing directories as prunable', async () => { - const liveDir = await createTempDir() - const missingDir = path.join(liveDir, 'deleted-worktree') - - const annotated = await annotatePrunableWorktreesByExistence([ - listedWorktree({ path: liveDir, isMainWorktree: true }), - listedWorktree({ path: path.join(liveDir, 'also-missing-main'), isMainWorktree: true }), - listedWorktree({ path: liveDir }), - listedWorktree({ path: missingDir }) - ]) - - expect(annotated[0]?.prunable).toBeUndefined() - // Git never marks the main worktree prunable; repo-level handling owns it. - expect(annotated[1]?.prunable).toBeUndefined() - expect(annotated[2]?.prunable).toBeUndefined() - expect(annotated[3]).toMatchObject({ path: missingDir, prunable: true }) - }) - - it('shields locked registrations, mirroring git prunable semantics', async () => { - const liveDir = await createTempDir() - const missingDir = path.join(liveDir, 'deleted-locked-worktree') - - const annotated = await annotatePrunableWorktreesByExistence([ - listedWorktree({ path: liveDir, isMainWorktree: true }), - listedWorktree({ path: missingDir, locked: true, lockReason: 'agent session' }) - ]) - - expect(annotated[1]?.prunable).toBeUndefined() - }) -}) diff --git a/src/relay/git-handler-worktree-list.ts b/src/relay/git-handler-worktree-list.ts deleted file mode 100644 index 53c034d1593..00000000000 --- a/src/relay/git-handler-worktree-list.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { throwIfSignalAborted, waitForPromiseWithSignal } from '../shared/abort-signal-reason' -import { annotateWorktreeLocksFromAdmin } from '../shared/git-worktree-admin' -import { expandTilde } from './context' -import { stat } from 'node:fs/promises' -import type { GitCapabilityCache } from '../shared/git-capability-cache' -import type { GitExec } from './git-handler-ops' -import { isUnsupportedWorktreeListZError } from './git-handler-utils' -import { parseWorktreeList } from '../shared/git-worktree-porcelain-parser' -import type { GitWorktreeInfo } from '../shared/worktree/types' - -export type RelayWorktreeInfo = { - path: string - branch?: string - head?: string - locked?: boolean - lockReason?: string -} - -export async function readRelayWorktreeList( - git: GitExec, - repoPath: string, - capabilities: GitCapabilityCache -): Promise { - return capabilities.runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath) - return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true })) - }, - async () => { - // Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible. - const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) - return normalizeRelayWorktrees( - await annotateWorktreeLocksFromAdmin(expandTilde(repoPath), parseWorktreeList(stdout)) - ) - }, - isUnsupportedWorktreeListZError - ) -} - -const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 - -/** Why: Git <2.31 does not emit the `prunable` porcelain annotation, so probe - * each linked worktree path directly instead of treating a stale registration - * as a live workspace (issue #8389). Runs on the `-z`-unsupported fallback - * (Git <2.36); on Git 2.31–2.35 the annotation is already parsed, so this is a - * harmless backstop. The relay owns the filesystem, so a plain stat is - * authoritative. */ -export async function annotatePrunableWorktreesByExistence( - worktrees: GitWorktreeInfo[], - signal?: AbortSignal -): Promise { - const annotated = [...worktrees] - let nextIndex = 0 - - async function probeNext(): Promise { - while (nextIndex < worktrees.length) { - throwIfSignalAborted(signal) - const index = nextIndex - nextIndex += 1 - const worktree = worktrees[index] - const worktreePath = worktree?.path ?? '' - // Locks protect missing linked worktrees; repository failures own the main row. - if ( - !worktreePath || - worktree.isMainWorktree === true || - worktree.isBare === true || - worktree.locked === true || - worktree.prunable === true - ) { - continue - } - try { - await stat(worktreePath) - } catch (err) { - if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') { - annotated[index] = { ...worktree, prunable: true } - } - } - } - } - - const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length) - await waitForPromiseWithSignal( - Promise.all(Array.from({ length: workerCount }, () => probeNext())), - signal - ) - throwIfSignalAborted(signal) - return annotated -} - -function normalizeRelayWorktrees(worktrees: GitWorktreeInfo[]): RelayWorktreeInfo[] { - return worktrees - .map((worktree) => ({ - path: worktree.path, - head: worktree.head, - branch: worktree.branch, - locked: worktree.locked === true ? true : undefined, - lockReason: worktree.lockReason - })) - .filter((worktree) => worktree.path.length > 0) -} diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts deleted file mode 100644 index 14c19a3802d..00000000000 --- a/src/relay/git-handler-worktree-operations.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { throwIfSignalAborted } from '../shared/abort-signal-reason' -import { annotateWorktreeLocksFromAdmin } from '../shared/git-worktree-admin' -import * as path from 'node:path' -import type { RequestContext } from '../wsl-guest/dispatcher' -import { expandTilde } from './context' -import { GitHandlerOperationContext } from './git-handler-operation-context' -import { isUnsupportedWorktreeListZError } from './git-handler-utils' -import { parseWorktreeList } from '../shared/git-worktree-porcelain-parser' -import type { GitWorktreeInfo } from '../shared/worktree/types' -import { - addWorktreeOp, - areRelayWorktreePathsEqual, - removeWorktreeOp, - worktreeIsCleanOp -} from './git-handler-worktree-ops' -import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' -import { - inspectLocalBaseRefForWorktreeCreateOp, - refreshLocalBaseRefForWorktreeCreateOp -} from './git-handler-local-base-ref-refresh' -import { - hasUnsupportedRevParsePathFormatEcho, - isUnsupportedRevParsePathFormatError -} from '../shared/git-worktree-command-capabilities' - -function isWindowsAbsolutePath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') -} - -function resolveRelayPath(repoPath: string, value: string): string { - if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { - return value - } - // Old Git ignores `--path-format=absolute`; resolve relative paths against repoPath by path shape. - return isWindowsAbsolutePath(repoPath) - ? path.win32.resolve(repoPath, value) - : path.posix.resolve(repoPath, value) -} - -type RelayRepoLocation = { topLevel: string; commonDir: string; gitDir: string } - -function parseRelayRepoLocation(repoPath: string, output: string): RelayRepoLocation | undefined { - // Old git (pre `--path-format`) echoes the unknown flag and exits 0; drop `-`-prefixed lines, take the last three paths. - // Strip only the trailing CR, not surrounding spaces — git paths may legitimately start or end with a space. - const lines = output - .split('\n') - .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) - .filter((line) => line.length > 0 && !line.startsWith('-')) - if (lines.length < 3) { - return undefined - } - const [topLevel, commonDir, gitDir] = lines.slice(-3) - return { - topLevel: resolveRelayPath(repoPath, topLevel), - commonDir: resolveRelayPath(repoPath, commonDir), - gitDir: resolveRelayPath(repoPath, gitDir) - } -} - -export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { - async isGitRepo(params: Record) { - const dirPath = params.dirPath as string - try { - const { stdout } = await this.git(['rev-parse', '--show-toplevel'], dirPath) - return { isRepo: true, rootPath: stdout.trim() } - } catch { - return { isRepo: false, rootPath: null } - } - } - - private async readRepoLocation( - repoPath: string, - signal?: AbortSignal - ): Promise { - try { - return await this.gitCapabilities.runWithFallback( - 'rev-parse-path-format', - async () => { - const { stdout } = await this.git( - [ - 'rev-parse', - '--path-format=absolute', - '--show-toplevel', - '--git-common-dir', - '--git-dir' - ], - repoPath, - { signal } - ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: old Git echoes the unknown option and exits zero; remember the signal though the paths still parse. - this.gitCapabilities.rememberUnsupported('rev-parse-path-format') - } - return parseRelayRepoLocation(repoPath, stdout) - }, - async () => { - const { stdout } = await this.git( - ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'], - repoPath, - { signal } - ) - return parseRelayRepoLocation(repoPath, stdout) - }, - isUnsupportedRevParsePathFormatError - ) - } catch { - throwIfSignalAborted(signal) - return undefined - } - } - - private async normalizeMainWorktreePath( - repoPath: string, - worktrees: GitWorktreeInfo[], - signal?: AbortSignal - ): Promise { - throwIfSignalAborted(signal) - const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree === true) - const mainWorktree = worktrees[mainIndex] - const mainPath = mainWorktree?.path ?? '' - // Expand `~` so legacy tilde SSH repo paths match git's absolute path, sparing a rev-parse per poll. - const resolvedRepoPath = expandTilde(repoPath) - if (!mainPath || areRelayWorktreePathsEqual(mainPath, resolvedRepoPath)) { - return worktrees - } - - const location = await this.readRepoLocation(resolvedRepoPath, signal) - throwIfSignalAborted(signal) - if (!location) { - return worktrees - } - - // Why: only separate-git-dir/submodule repos have main entry == git-common-dir; gate on it so we don't clobber a linked worktree's real root. - if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) { - return worktrees - } - // Why: a linked worktree of a bare/separate-git-dir repo passes the gate above too; relabelling would repeat its path (#23631). - if (!areRelayWorktreePathsEqual(location.gitDir, location.commonDir)) { - return worktrees - } - - const normalized = [...worktrees] - normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } - return normalized - } - - async listWorktrees(params: Record, context?: RequestContext) { - const repoPath = params.repoPath as string - return this.gitCapabilities.runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { - signal: context?.signal - }) - return this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), - context?.signal - ) - }, - async () => { - // Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). - // Why no catch (#14004): swallowing to `[]` would report an unreadable catalog as an authoritative - // empty one, and callers use that to authorize missing-worktree teardown. Let the failure propagate. - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { - signal: context?.signal - }) - const normalized = await this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout), - context?.signal - ) - // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). - return annotatePrunableWorktreesByExistence( - await annotateWorktreeLocksFromAdmin(expandTilde(repoPath), normalized, { - signal: context?.signal - }), - context?.signal - ) - }, - isUnsupportedWorktreeListZError - ) - } - - async addWorktree(params: Record) { - return this.runWithGitReadCacheClear(() => addWorktreeOp(this.git.bind(this), params)) - } - - async removeWorktree(params: Record) { - const remove = () => - this.runWithGitReadCacheClear(() => - removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) - ) - const worktreePath = params.worktreePath - return this.watcherRegistry && typeof worktreePath === 'string' - ? this.watcherRegistry.runWithRemovalFence(expandTilde(worktreePath), remove) - : remove() - } - - async worktreeIsClean(params: Record) { - return worktreeIsCleanOp(this.git.bind(this), params) - } - - async refreshLocalBaseRefForWorktreeCreate(params: Record) { - return this.runWithGitReadCacheClear(() => - refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) - ) - } - - async inspectLocalBaseRefForWorktreeCreate(params: Record) { - return inspectLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) - } -} diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts deleted file mode 100644 index 452159fe2a2..00000000000 --- a/src/relay/git-handler-worktree-ops.test.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import * as path from 'node:path' -import { GitCapabilityCache } from '../shared/git-capability-cache' -import type { GitExec } from './git-handler-ops' -import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' - -function removeWorktreeWithCapabilityCache( - git: GitExec, - params: Parameters[1] -) { - return removeWorktreeOp(git, params, new GitCapabilityCache()) -} - -function worktreeList(...entries: { path: string; branch?: string }[]): string { - return entries - .map((entry, index) => - [ - `worktree ${entry.path}`, - `HEAD ${index}`, - ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []) - ].join('\n') - ) - .join('\n\n') -} - -function resolvedRepoPath(): string { - return path.posix.resolve('/repo-feature', '/repo/.git', '..') -} - -describe('addWorktreeOp', () => { - it('writes durable branch base config after creating an SSH new-branch worktree', async () => { - const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - - await addWorktreeOp( - git, - { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main' - }, - 'linux' - ) - - expect(git.mock.calls.map((call) => call[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], - [ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/test', - '/repo-feature', - 'refs/remotes/origin/main' - ], - [ - 'config', - '--local', - '--replace-all', - 'branch.feature/test.base', - 'refs/remotes/origin/main' - ], - ['config', '--get', 'push.autoSetupRemote'] - ]) - expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'config', - '--local', - 'branch.feature/test.remote', - 'origin' - ]) - expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'config', - '--local', - 'branch.feature/test.merge', - 'refs/heads/main' - ]) - }) - - it('does not write branch base config when checking out an existing SSH branch', async () => { - const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - - await addWorktreeOp( - git, - { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main', - checkoutExistingBranch: true - }, - 'linux' - ) - - expect(git.mock.calls.map((call) => call[0])).toEqual([ - ['worktree', 'add', '/repo-feature', 'feature/test'] - ]) - }) - - it('does not write branch base config when SSH creation has no base', async () => { - const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - - await addWorktreeOp( - git, - { - repoPath: '/repo', - branchName: 'feature/no-base', - targetDir: '/repo-feature' - }, - 'linux' - ) - - expect(git.mock.calls.map((call) => call[0])).toEqual([ - ['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'], - ['config', '--get', 'push.autoSetupRemote'] - ]) - }) - - it('enables long paths when the SSH execution host is Windows', async () => { - // Why: only the host's OS matters — a macOS client can drive a Windows SSH host, - // which hits the same MAX_PATH ceiling (issue #15785). - const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - - await addWorktreeOp( - git, - { - repoPath: 'C:\\repo', - branchName: 'feature/test', - targetDir: 'C:\\repo-feature', - checkoutExistingBranch: true - }, - 'win32' - ) - - expect(git.mock.calls.map((call) => call[0])).toEqual([ - ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'] - ]) - }) - - it('keeps --no-checkout ahead of -b once the long-path prefix is present', async () => { - const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - - await addWorktreeOp( - git, - { - repoPath: 'C:\\repo', - branchName: 'feature/test', - targetDir: 'C:\\repo-feature', - noCheckout: true - }, - 'win32' - ) - - expect(git.mock.calls[0][0]).toEqual([ - '-c', - 'core.longpaths=true', - 'worktree', - 'add', - '--no-track', - '--no-checkout', - '-b', - 'feature/test', - 'C:\\repo-feature' - ]) - }) - - it('omits the long-path option on a WSL UNC target on a Windows SSH host', async () => { - const git = vi.fn(async () => ({ stdout: '', stderr: '' })) - - await addWorktreeOp( - git, - { - repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', - branchName: 'feature/test', - targetDir: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', - checkoutExistingBranch: true - }, - 'win32' - ) - - expect(git.mock.calls[0][0]).toEqual([ - 'worktree', - 'add', - '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', - 'feature/test' - ]) - }) - - it('warns and unsets stale branch base config when SSH base persistence fails', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const git = vi.fn(async (args) => { - if (args[0] === 'config' && args[2] === '--replace-all') { - throw new Error('config locked') - } - return { stdout: '', stderr: '' } - }) - - await expect( - addWorktreeOp( - git, - { - repoPath: '/repo', - branchName: 'feature/test', - targetDir: '/repo-feature', - base: 'origin/main' - }, - 'linux' - ) - ).resolves.toBeUndefined() - - expect(warnSpy).toHaveBeenCalledWith( - 'relay addWorktree: failed to set branch.feature/test.base for /repo-feature', - expect.any(Error) - ) - expect(git.mock.calls.map((call) => call[0])).toContainEqual([ - 'config', - '--local', - '--unset-all', - 'branch.feature/test.base' - ]) - warnSpy.mockRestore() - }) -}) - -describe('removeWorktreeOp', () => { - it('rejects a locked SSH worktree before invoking remove', async () => { - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { - stdout: `${worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - )}\nlocked remote agent`, - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent.') - expect(git).not.toHaveBeenCalledWith( - ['worktree', 'remove', '/repo-feature'], - expect.any(String) - ) - }) - - it('deletes the now-unused branch after removing an SSH worktree', async () => { - const calls: string[] = [] - let listCount = 0 - const git = vi.fn(async (args, cwd) => { - calls.push(`${cwd}$ ${args.join(' ')}`) - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - - expect(calls).toEqual([ - '/repo-feature$ rev-parse --git-common-dir', - `${resolvedRepoPath()}$ worktree list --porcelain -z`, - `${resolvedRepoPath()}$ worktree remove /repo-feature`, - `${resolvedRepoPath()}$ branch -d -- feature/test` - ]) - }) - - it('preserves Git refusal even when parent status cannot reveal unpublished submodule commits', async () => { - const calls: string[] = [] - let listCount = 0 - const git = vi.fn(async (args, cwd) => { - calls.push(`${cwd}$ ${args.join(' ')}`) - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - if (args[0] === 'worktree' && args[1] === 'remove' && !args.includes('--force')) { - throw Object.assign(new Error('git worktree remove failed'), { - stderr: 'fatal: working trees containing submodules cannot be moved or removed' - }) - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).rejects.toThrow('git worktree remove failed') - expect(calls).toEqual([ - '/repo-feature$ rev-parse --git-common-dir', - `${resolvedRepoPath()}$ worktree list --porcelain -z`, - `${resolvedRepoPath()}$ worktree remove /repo-feature` - ]) - }) - - it('preserves Git refusal for a dirty submodule worktree', async () => { - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { - stdout: worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ), - stderr: '' - } - } - if (args[0] === 'worktree' && args[1] === 'remove') { - throw Object.assign(new Error('git worktree remove failed'), { - stderr: 'fatal: working trees containing submodules cannot be moved or removed' - }) - } - if (args[0] === 'status') { - return { stdout: ' M sub\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).rejects.toThrow('git worktree remove failed') - expect(git).not.toHaveBeenCalledWith( - ['worktree', 'remove', '--force', '/repo-feature'], - expect.any(String) - ) - }) - - it('does not force-retry when the caller already forced SSH removal', async () => { - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { - stdout: worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ), - stderr: '' - } - } - if (args[0] === 'worktree' && args[1] === 'remove') { - throw Object.assign(new Error('git worktree remove failed'), { - stderr: 'fatal: working trees containing submodules cannot be moved or removed' - }) - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true }) - ).rejects.toThrow('git worktree remove failed') - expect( - git.mock.calls.filter( - ([args]) => args[0] === 'worktree' && args[1] === 'remove' && args.includes('--force') - ) - ).toHaveLength(1) - expect(git).not.toHaveBeenCalledWith( - ['status', '--porcelain', '--untracked-files=all'], - expect.any(String) - ) - }) - - it('preserves the branch (does not throw) when `branch -d` refuses an unmerged branch', async () => { - let listCount = 0 - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - if (args[0] === 'branch' && args[1] === '-d') { - throw new Error('error: the branch feature/test is not fully merged') - } - return { stdout: '', stderr: '' } - }) - - // The unmerged-branch refusal must be surfaced without failing workspace removal. - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).resolves.toEqual({ - preservedBranch: { branchName: 'feature/test', head: '1' } - }) - - expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) - expect(git).not.toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String)) - }) - - it('force-deletes the just-created branch during failed sparse setup rollback', async () => { - let listCount = 0 - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await removeWorktreeWithCapabilityCache(git, { - worktreePath: '/repo-feature', - force: true, - forceBranchDelete: true - }) - - expect(git).toHaveBeenCalledWith( - ['worktree', 'remove', '--force', '/repo-feature'], - expect.any(String) - ) - expect(git).toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String)) - expect(git).not.toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) - }) - - it('does not let force override a locked SSH worktree', async () => { - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { - stdout: `${worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - )}\nlocked remote agent`, - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await expect( - removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true }) - ).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent') - - expect(git).not.toHaveBeenCalledWith( - ['worktree', 'remove', '--force', '/repo-feature'], - expect.any(String) - ) - }) - - it('skips branch deletion entirely when deleteBranch is false', async () => { - const calls: string[] = [] - const git = vi.fn(async (args, cwd) => { - calls.push(`${cwd}$ ${args.join(' ')}`) - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - return { - stdout: worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ), - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await removeWorktreeWithCapabilityCache(git, { - worktreePath: '/repo-feature', - deleteBranch: false - }) - - expect(calls).toEqual([ - '/repo-feature$ rev-parse --git-common-dir', - `${resolvedRepoPath()}$ worktree list --porcelain -z`, - `${resolvedRepoPath()}$ worktree remove /repo-feature` - ]) - }) - - it('keeps the branch when Git reports another SSH worktree still uses it', async () => { - let listCount = 0 - const git = vi.fn(async (args, _cwd) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : worktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-other', branch: 'feature/test' } - ), - stderr: '' - } - } - if (args[0] === 'branch' && args[1] === '-d') { - throw new Error( - "error: cannot delete branch 'feature/test' used by worktree at '/repo-other'" - ) - } - return { stdout: '', stderr: '' } - }) - - await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - - expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) - expect(git).toHaveBeenCalledWith(['worktree', 'prune'], expect.any(String)) - expect(git).not.toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String)) - }) -}) diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts deleted file mode 100644 index 4095851a82f..00000000000 --- a/src/relay/git-handler-worktree-ops.ts +++ /dev/null @@ -1,198 +0,0 @@ -import * as path from 'node:path' -import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' -import { windowsLongPathGitArgs } from '../shared/windows-long-path-git-args' -import type { GitExec } from './git-handler-ops' -export { removeWorktreeOp } from './git-handler-worktree-remove' -export { readRelayWorktreeList } from './git-handler-worktree-list' - -async function persistRelayWorktreeCreationBase( - git: GitExec, - targetDir: string, - branchName: string, - effectiveBase: string -): Promise { - const configKey = `branch.${branchName}.base` - try { - await git(['config', '--local', '--replace-all', configKey, effectiveBase], targetDir) - } catch (error) { - console.warn(`relay addWorktree: failed to set ${configKey} for ${targetDir}`, error) - try { - // Why: SSH worktree creation shares branch config by name; clear stale - // metadata if replacing an old same-name base fails. - await git(['config', '--local', '--unset-all', configKey], targetDir) - } catch (unsetError) { - console.warn( - `relay addWorktree: failed to unset stale ${configKey} for ${targetDir}`, - unsetError - ) - } - } -} - -export async function addWorktreeOp( - git: GitExec, - params: Record, - // Why: only the execution host's OS matters here — the client may be macOS while the SSH host is Windows. - platform: NodeJS.Platform = process.platform -): Promise { - const repoPath = params.repoPath as string - const branchName = params.branchName as string - const targetDir = params.targetDir as string - const base = params.base as string | undefined - const checkoutExistingBranch = params.checkoutExistingBranch === true - const noCheckout = params.noCheckout === true - - // Why: a branchName starting with '-' would be interpreted as a git flag, - // potentially changing the command's semantics (e.g. "--detach"). - if (branchName.startsWith('-') || (base && base.startsWith('-'))) { - throw new Error('Branch name and base ref must not start with "-"') - } - - // Why: --no-track + push.autoSetupRemote=true mirrors the local - // addWorktree path (src/main/git/worktree.ts). Keeping the SSH path in - // sync prevents a transport-only divergence where "Orca creates a - // worktree" produces a different `git status` / `git push` UX based on - // whether the repo is local or SSH-mounted. See full design rationale - // (state machine, common-dir scope, old-git fallback) in the comments - // around src/main/git/worktree.ts addWorktree — those invariants apply - // identically here. - const effectiveBase = - base && !checkoutExistingBranch - ? await resolveWorktreeAddBaseRef(base, async (qualifiedRef) => { - try { - await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], repoPath) - return true - } catch { - return false - } - }) - : undefined - - // Why: a Windows SSH host hits the same MAX_PATH ceiling as a local Windows checkout. - const longPathArgs = windowsLongPathGitArgs(targetDir, platform) - const args = checkoutExistingBranch - ? [...longPathArgs, 'worktree', 'add', targetDir, branchName] - : [...longPathArgs, 'worktree', 'add', '--no-track', '-b', branchName, targetDir] - if (!checkoutExistingBranch && noCheckout) { - // Why: offset by the global-option prefix so --no-checkout still lands before -b. - args.splice(longPathArgs.length + 3, 0, '--no-checkout') - } - if (effectiveBase) { - args.push(effectiveBase) - } - - await git(args, repoPath) - - if (checkoutExistingBranch) { - return - } - - if (effectiveBase) { - await persistRelayWorktreeCreationBase(git, targetDir, branchName, effectiveBase) - } - - // Why: best-effort write so a deliberate user value (any scope) is - // preserved and a real read failure is not silently overwritten. Final - // catch is warn-only — if the remote host's git is <2.37 the value is - // ignored at push time and the user falls back to `git push -u` once. - // (Note: it is the SSH host's git that matters here, not the client's.) - // Mirrors local addWorktree exactly. - try { - let alreadySet = false - try { - await git(['config', '--get', 'push.autoSetupRemote'], targetDir) - alreadySet = true - } catch (readError) { - // Why: `git config --get` exits 1 only when the key is unset at every - // scope. Any other code is a real read failure (corrupt config, - // locked file) — surface it via the outer catch instead of falling - // through to overwrite the user's actual value. - const code = (readError as { code?: unknown })?.code - if (code !== 1) { - throw readError - } - } - if (!alreadySet) { - await git(['config', '--local', 'push.autoSetupRemote', 'true'], targetDir) - } - } catch (error) { - console.warn(`relay addWorktree: failed to set push.autoSetupRemote for ${targetDir}`, error) - } -} - -function isPosixAbsolutePath(value: string): boolean { - return value.startsWith('/') -} - -function isWindowsAbsolutePath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') -} - -function normalizeRelayWorktreePathForCompare(value: string): string { - if (isPosixAbsolutePath(value)) { - return path.posix.normalize(path.posix.resolve(value)) - } - if (isWindowsAbsolutePath(value)) { - return path.win32.normalize(path.win32.resolve(value)) - } - return path.normalize(path.resolve(value)) -} - -export function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean { - const left = normalizeRelayWorktreePathForCompare(leftPath) - const right = normalizeRelayWorktreePathForCompare(rightPath) - const compareCaseInsensitive = isWindowsAbsolutePath(leftPath) && isWindowsAbsolutePath(rightPath) - return compareCaseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right -} - -export async function worktreeIsCleanOp( - git: GitExec, - params: Record -): Promise<{ clean: boolean; stdout?: string }> { - const worktreePath = params.worktreePath as string - const includeUntracked = params.includeUntracked !== false - const { stdout } = await git( - ['status', '--porcelain', includeUntracked ? '--untracked-files=all' : '--untracked-files=no'], - worktreePath - ) - const clean = !stdout.trim() - return { clean, stdout: clean ? undefined : stdout } -} - -export async function commitChangesRelay( - git: GitExec, - worktreePath: string, - message: string -): Promise<{ success: boolean; error?: string }> { - // Why: defense-in-depth. The IPC handler at src/main/ipc/filesystem.ts validates - // the message, but a relay caller (future automation, or an SSH client connecting - // to the relay directly) could bypass that path. Reject empty/whitespace messages - // here so we surface a clear error instead of git's opaque failure. - if (typeof message !== 'string' || message.trim().length === 0) { - return { success: false, error: 'Commit message is required' } - } - - try { - await git(['commit', '-m', message], worktreePath) - return { success: true } - } catch (error) { - // Why: surface whichever channel carries the useful message. Pre-commit/GPG - // hook failures write to stderr; "nothing to commit, working tree clean" - // writes to stdout. Try stderr first, fall back to stdout, then error.message. - // Mirrors commitChanges in src/main/git/status.ts — keep the two paths in sync. - const readStringField = (field: string): string | null => { - if (typeof error === 'object' && error && field in error) { - const v = (error as Record)[field] - if (typeof v === 'string' && v.length > 0) { - return v - } - } - return null - } - const errorMessage = - readStringField('stderr') ?? - readStringField('stdout') ?? - (error instanceof Error ? error.message : 'Commit failed') - return { success: false, error: errorMessage } - } -} diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts deleted file mode 100644 index 11d1a866cd6..00000000000 --- a/src/relay/git-handler-worktree-paths.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type * as WorktreeAdmin from '../shared/git-worktree-admin' -import { describe, expect, it, vi } from 'vitest' -import * as path from 'node:path' -import { GitCapabilityCache } from '../shared/git-capability-cache' -import type { GitExec } from './git-handler-ops' -import { removeWorktreeOp } from './git-handler-worktree-ops' - -vi.mock('../shared/git-worktree-admin', async (importActual) => ({ - ...(await importActual()), - annotateWorktreeLocksFromAdmin: async (_repoPath: string, rows: unknown[]) => rows -})) - -function removeWorktreeWithCapabilityCache( - git: GitExec, - params: Parameters[1] -) { - return removeWorktreeOp(git, params, new GitCapabilityCache()) -} - -function lineWorktreeList(...entries: { path: string; branch?: string }[]): string { - return entries - .map((entry, index) => - [ - `worktree ${entry.path}`, - `HEAD ${index}`, - ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []) - ].join('\n') - ) - .join('\n\n') -} - -function nulWorktreeList(...entries: { path: string; branch?: string }[]): string { - return entries - .map((entry, index) => - [ - `worktree ${entry.path}`, - `HEAD ${index}`, - ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []), - '' - ].join('\0') - ) - .join('\0') -} - -function resolvedRepoPath(): string { - return path.posix.resolve('/repo-feature', '/repo/.git', '..') -} - -describe('relay worktree path parsing', () => { - it('deletes the matching branch for SSH worktrees whose paths contain newlines', async () => { - const worktreePath = '/repo-feature\nremote' - let listCount = 0 - const git = vi.fn(async (args) => { - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? nulWorktreeList( - { path: '/repo', branch: 'main' }, - { path: worktreePath, branch: 'feature/newline' } - ) - : nulWorktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await removeWorktreeWithCapabilityCache(git, { worktreePath }) - - expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/newline'], resolvedRepoPath()) - }) - - it('falls back to line-block worktree listing when remote Git rejects -z', async () => { - const calls: string[] = [] - let listCount = 0 - const git = vi.fn(async (args, cwd) => { - calls.push(`${cwd}$ ${args.join(' ')}`) - if (args[0] === 'rev-parse') { - return { stdout: '/repo/.git\n', stderr: '' } - } - if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) { - throw Object.assign(new Error("unknown switch `z'"), { - stderr: "error: unknown switch `z'" - }) - } - if (args[0] === 'worktree' && args[1] === 'list') { - listCount += 1 - return { - stdout: - listCount === 1 - ? lineWorktreeList( - { path: '/repo', branch: 'main' }, - { path: '/repo-feature', branch: 'feature/test' } - ) - : lineWorktreeList({ path: '/repo', branch: 'main' }), - stderr: '' - } - } - return { stdout: '', stderr: '' } - }) - - await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - - expect(calls).toEqual([ - '/repo-feature$ rev-parse --git-common-dir', - `${resolvedRepoPath()}$ worktree list --porcelain -z`, - `${resolvedRepoPath()}$ worktree list --porcelain`, - `${resolvedRepoPath()}$ worktree remove /repo-feature`, - `${resolvedRepoPath()}$ branch -d -- feature/test` - ]) - }) -}) diff --git a/src/relay/git-handler-worktree-provisioning.test.ts b/src/relay/git-handler-worktree-provisioning.test.ts deleted file mode 100644 index 9764f9b8b9b..00000000000 --- a/src/relay/git-handler-worktree-provisioning.test.ts +++ /dev/null @@ -1,735 +0,0 @@ -/** - * GitHandler worktree provisioning: the local base-ref refresh that precedes a - * worktree create, and the addWorktree state machine (base ref qualification, - * push.autoSetupRemote probing, failure handling). - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { writeFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import { GitHandler } from './git-handler' -import { RelayContext } from './context' -import { - createMockDispatcher, - gitInit, - gitCommit, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir, - type GitSpyTarget -} from './git-handler-test-harness' - -describe('GitHandler', () => { - let dispatcher: MockDispatcher - let tmpDir: string - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher } = createGitHandlerRelay()) - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - function currentBranch(cwd: string): string { - return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd, - encoding: 'utf-8' - }).trim() - } - - function currentBranchFullRef(cwd: string): string { - return `refs/heads/${currentBranch(cwd)}` - } - - function reportedWorktreePath(cwd: string): string { - return ( - execFileSync('git', ['worktree', 'list', '--porcelain'], { - cwd, - encoding: 'utf-8' - }) - .split(/\r?\n/) - .find((line) => line.startsWith('worktree ')) - ?.slice('worktree '.length) - .trim() ?? cwd - ) - } - - describe('refreshLocalBaseRefForWorktreeCreate', () => { - function setupMockedRefreshHandler() { - const localDispatcher = createMockDispatcher() - const localHandler = new GitHandler( - localDispatcher as unknown as RelayDispatcher, - new RelayContext() - ) - const gitMock = - vi.fn< - ( - args: string[], - cwd: string, - opts?: { maxBuffer?: number } - ) => Promise<{ stdout: string; stderr: string }> - >() - ;(localHandler as unknown as { git: typeof gitMock }).git = gitMock - return { localDispatcher, gitMock } - } - - function revParse(ref: string): string { - return execFileSync('git', ['rev-parse', ref], { cwd: tmpDir, encoding: 'utf-8' }).trim() - } - - // The checked-out branch is one commit behind refs/remotes/origin/main, which changes base.txt. - function initBehindRepo(): { branchRef: string; localSha: string; remoteSha: string } { - gitInit(tmpDir) - execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const localSha = revParse('HEAD') - writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') - gitCommit(tmpDir, 'remote update') - const remoteSha = revParse('HEAD') - execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['reset', '--hard', localSha], { cwd: tmpDir, stdio: 'pipe' }) - return { branchRef: currentBranchFullRef(tmpDir), localSha, remoteSha } - } - - it('fast-forwards the owning worktree to the remote-tracking ref on the host', async () => { - const { branchRef, remoteSha } = initBehindRepo() - - await expect( - dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: branchRef, - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'updated', ownerWorktreePath: reportedWorktreePath(tmpDir) }) - - expect(revParse('HEAD')).toBe(remoteSha) - await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('remote') - }) - - it('fast-forwards a non-checked-out local branch via update-ref', async () => { - const { localSha, remoteSha } = initBehindRepo() - execFileSync('git', ['branch', 'main-copy', localSha], { cwd: tmpDir, stdio: 'pipe' }) - - await expect( - dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: 'refs/heads/main-copy', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'updated' }) - - expect(revParse('refs/heads/main-copy')).toBe(remoteSha) - }) - - it('reports nothing to do for a local branch that does not exist yet', async () => { - initBehindRepo() - - await expect( - dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: 'refs/heads/not-created-yet', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'nothing_to_do' }) - }) - - it.each([ - [ - 'refs outside heads/remotes', - { fullRef: 'refs/tags/main', remoteTrackingRef: 'refs/remotes/origin/main' }, - 'Invalid local base ref refresh refs.' - ], - [ - 'a non-string ref', - { fullRef: 42, remoteTrackingRef: 'refs/remotes/origin/main' }, - 'Invalid local base ref refresh request.' - ], - [ - 'a missing remote-tracking ref', - { fullRef: 'refs/heads/main' }, - 'Invalid local base ref refresh request.' - ] - ])('rejects %s without touching git', async (_case, params, message) => { - const { localDispatcher, gitMock } = setupMockedRefreshHandler() - - for (const method of [ - 'git.refreshLocalBaseRefForWorktreeCreate', - 'git.inspectLocalBaseRefForWorktreeCreate' - ]) { - await expect( - localDispatcher.callRequest(method, { repoPath: '/repo', ...params }) - ).rejects.toThrow(message) - } - expect(gitMock).not.toHaveBeenCalled() - }) - - it('rejects a ref name git itself refuses', async () => { - gitInit(tmpDir) - - await expect( - dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: 'refs/heads/bad..name', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).rejects.toThrow() - }) - - it('leaves a dirty owner worktree and its edit alone', async () => { - const { branchRef, localSha } = initBehindRepo() - writeFileSync(path.join(tmpDir, 'base.txt'), 'local dirty') - - await expect( - dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: branchRef, - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ - status: 'skipped_dirty_worktree', - ownerWorktreePath: reportedWorktreePath(tmpDir) - }) - - expect(revParse('HEAD')).toBe(localSha) - await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('local dirty') - }) - - it('does not move diverged local refs', async () => { - const { localSha } = initBehindRepo() - execFileSync('git', ['checkout', '-q', '-b', 'main-copy', localSha], { cwd: tmpDir }) - writeFileSync(path.join(tmpDir, 'local.txt'), 'local') - gitCommit(tmpDir, 'local update') - const divergedSha = revParse('refs/heads/main-copy') - - await expect( - dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: 'refs/heads/main-copy', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'skipped_not_fast_forward' }) - - expect(revParse('refs/heads/main-copy')).toBe(divergedSha) - }) - - it('inspects how far local is behind without moving it', async () => { - const { branchRef, localSha, remoteSha } = initBehindRepo() - - await expect( - dispatcher.callRequest('git.inspectLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: branchRef, - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ - status: 'behind', - behind: 1, - localOid: localSha, - remoteOid: remoteSha, - ownerWorktreePath: reportedWorktreePath(tmpDir) - }) - - expect(revParse('HEAD')).toBe(localSha) - await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('base') - }) - - function mockBehindOwnerGit( - gitMock: ReturnType['gitMock'], - onMerge: () => Promise = async () => {} - ) { - let localOid = 'old-local-oid' - gitMock.mockImplementation(async (args: string[]) => { - const command = args.find((arg, index) => !arg.startsWith('-') && args[index - 1] !== '-c') - if (command === 'check-ref-format' || command === 'status') { - return { stdout: '', stderr: '' } - } - if (command === 'rev-parse') { - const oid = args[2] === 'refs/remotes/origin/main^{commit}' ? 'remote-oid' : localOid - return { stdout: `${oid}\n`, stderr: '' } - } - if (command === 'rev-list') { - return { stdout: '0\t2\n', stderr: '' } - } - if (command === 'worktree') { - return { - stdout: 'worktree /repo\0HEAD old-local-oid\0branch refs/heads/main\0\0', - stderr: '' - } - } - if (command === 'symbolic-ref') { - return { stdout: 'refs/heads/main\n', stderr: '' } - } - if (command === 'merge') { - await onMerge() - localOid = 'remote-oid' - return { stdout: '', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) - } - - it('fast-forwards the owner with merge --ff-only and hooks disabled, never reset or update-ref', async () => { - const { localDispatcher, gitMock } = setupMockedRefreshHandler() - mockBehindOwnerGit(gitMock) - - await expect( - localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: '/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'updated', ownerWorktreePath: '/repo' }) - - const merge = gitMock.mock.calls.find(([args]) => args.includes('merge')) - expect(merge?.[0]).toEqual( - expect.arrayContaining([ - 'core.hooksPath=/dev/null', - 'branch.main.mergeOptions=', - '--ff-only', - 'recursive', - '--no-verify-signatures', - 'remote-oid' - ]) - ) - expect(merge?.[1]).toBe('/repo') - const commands = gitMock.mock.calls.map(([args]) => args[0]) - expect(commands).not.toContain('reset') - expect(commands).not.toContain('update-ref') - }) - - it('runs one refresh per branch at a time for every client of the relay', async () => { - const { localDispatcher, gitMock } = setupMockedRefreshHandler() - let releaseMerge!: () => void - const mergeHeld = new Promise((resolve) => (releaseMerge = resolve)) - mockBehindOwnerGit(gitMock, () => mergeHeld) - const params = { - repoPath: '/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - } - - const first = localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', params) - await vi.waitFor(() => - expect(gitMock.mock.calls.some(([args]) => args.includes('merge'))).toBe(true) - ) - const second = localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', params) - // Let the second request get past validation and as far as it can while the merge is held. - await vi.waitFor(() => - expect(gitMock.mock.calls.filter(([args]) => args[0] === 'check-ref-format').length).toBe(4) - ) - await new Promise((resolve) => setTimeout(resolve, 20)) - releaseMerge() - - await expect(first).resolves.toEqual({ status: 'updated', ownerWorktreePath: '/repo' }) - // The joiner's follow-up run finds local already at the target. - await expect(second).resolves.toEqual({ status: 'nothing_to_do' }) - expect(gitMock.mock.calls.filter(([args]) => args.includes('merge'))).toHaveLength(1) - }) - - // A fork on the real host: the checked-out branch behind origin/main, one commit behind upstream/main. - function initForkRepo(): { branchRef: string; upstreamSha: string } { - const { branchRef, localSha, remoteSha } = initBehindRepo() - execFileSync('git', ['checkout', '-q', '-b', 'fork-upstream', remoteSha], { cwd: tmpDir }) - writeFileSync(path.join(tmpDir, 'base.txt'), 'upstream') - gitCommit(tmpDir, 'upstream update') - const upstreamSha = revParse('HEAD') - execFileSync('git', ['checkout', '-q', '-'], { cwd: tmpDir }) - execFileSync('git', ['update-ref', 'refs/remotes/upstream/main', upstreamSha], { - cwd: tmpDir - }) - execFileSync('git', ['branch', '-q', '-D', 'fork-upstream'], { cwd: tmpDir }) - expect(revParse('HEAD')).toBe(localSha) - return { branchRef, upstreamSha } - } - - /** Sends refreshes in order, each reaching the relay's per-branch queue while the first merge is held. */ - async function refreshWhileFirstMergeHeld(branchRef: string, remotes: string[]) { - const { dispatcher: relay, handler } = createGitHandlerRelay() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: GitHandler really has this private git runner; the spy wraps the real one. - const target = handler as unknown as GitSpyTarget - const realGit = target.git.bind(handler) - let releaseMerge!: () => void - const mergeHeld = new Promise((resolve) => (releaseMerge = resolve)) - let merges = 0 - let activeMerges = 0 - let maxConcurrentMerges = 0 - let refFormatChecks = 0 - vi.spyOn(target, 'git').mockImplementation(async (args, cwd, opts) => { - if (args.includes('check-ref-format')) { - const result = await realGit(args, cwd, opts) - refFormatChecks += 1 - return result - } - if (!args.includes('merge')) { - return realGit(args, cwd, opts) - } - merges += 1 - activeMerges += 1 - maxConcurrentMerges = Math.max(maxConcurrentMerges, activeMerges) - try { - if (merges === 1) { - await mergeHeld - } - return await realGit(args, cwd, opts) - } finally { - activeMerges -= 1 - } - }) - - const results: Promise[] = [] - for (const [index, remote] of remotes.entries()) { - results.push( - relay.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: branchRef, - remoteTrackingRef: `refs/remotes/${remote}/main` - }) - ) - if (index === 0) { - await vi.waitFor(() => expect(merges).toBe(1)) - } else { - await vi.waitFor(() => expect(refFormatChecks).toBe(2 * (index + 1))) - await new Promise((resolve) => setTimeout(resolve, 20)) - } - } - releaseMerge() - return { results: await Promise.all(results), maxConcurrentMerges: () => maxConcurrentMerges } - } - - it('moves the branch to a target a later client asked for from another remote', async () => { - const { branchRef, upstreamSha } = initForkRepo() - - const { results, maxConcurrentMerges } = await refreshWhileFirstMergeHeld(branchRef, [ - 'origin', - 'upstream', - 'origin' - ]) - - const owner = reportedWorktreePath(tmpDir) - // The third is the existing ahead-of-requested-remote rule (local is past origin/main), not a sharing artifact. - expect(results).toEqual([ - { status: 'updated', ownerWorktreePath: owner }, - { status: 'updated', ownerWorktreePath: owner }, - { status: 'skipped_not_fast_forward' } - ]) - expect(revParse('HEAD')).toBe(upstreamSha) - expect(maxConcurrentMerges()).toBe(1) - }) - - it('never answers a client with the outcome of another remote target', async () => { - const { branchRef, upstreamSha } = initForkRepo() - - const { results, maxConcurrentMerges } = await refreshWhileFirstMergeHeld(branchRef, [ - 'upstream', - 'upstream', - 'origin' - ]) - - // The third is the existing ahead-of-requested-remote rule (local is past origin/main), not a sharing artifact. - expect(results).toEqual([ - { status: 'updated', ownerWorktreePath: reportedWorktreePath(tmpDir) }, - { status: 'nothing_to_do' }, - { status: 'skipped_not_fast_forward' } - ]) - expect(revParse('HEAD')).toBe(upstreamSha) - expect(maxConcurrentMerges()).toBe(1) - }) - - it('reports an error without mutating when worktree ownership cannot be listed', async () => { - const { localDispatcher, gitMock } = setupMockedRefreshHandler() - mockBehindOwnerGit(gitMock) - const base = gitMock.getMockImplementation()! - gitMock.mockImplementation(async (args, cwd) => { - if (args[0] === 'worktree') { - throw new Error('worktree list failed') - } - return base(args, cwd) - }) - - await expect( - localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: '/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'skipped_error' }) - - const commands = gitMock.mock.calls.map(([args]) => args) - expect(commands.some((args) => args.includes('merge') || args[0] === 'update-ref')).toBe( - false - ) - }) - }) - - describe('addWorktree', () => { - // Why: mock git to control exit codes (e.g. --get exit 1 vs other) deterministically, independent of host git config. - function setupMockedHandler(roots: string[]) { - const ctx = new RelayContext() - for (const r of roots) { - ctx.registerRoot(r) - } - const localDispatcher = createMockDispatcher() - const handler = new GitHandler(localDispatcher as unknown as RelayDispatcher, ctx) - const gitMock = - vi.fn< - ( - args: string[], - cwd: string, - opts?: { maxBuffer?: number } - ) => Promise<{ stdout: string; stderr: string }> - >() - ;(handler as unknown as { git: typeof gitMock }).git = gitMock - return { localDispatcher, gitMock } - } - - it('passes --no-track and writes push.autoSetupRemote when unset', async () => { - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/remotes/origin/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set - - await localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/test', - targetDir: '/relay/wt', - base: 'origin/main' - }) - - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], - [ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/test', - '/relay/wt', - 'refs/remotes/origin/main' - ], - [ - 'config', - '--local', - '--replace-all', - 'branch.feature/test.base', - 'refs/remotes/origin/main' - ], - ['config', '--get', 'push.autoSetupRemote'], - ['config', '--local', 'push.autoSetupRemote', 'true'] - ]) - // cwd for worktree add is repoPath; cwd for config calls is targetDir. - expect(gitMock.mock.calls[0]?.[1]).toBe('/relay/repo') - expect(gitMock.mock.calls[1]?.[1]).toBe('/relay/repo') - expect(gitMock.mock.calls[2]?.[1]).toBe('/relay/wt') - expect(gitMock.mock.calls[3]?.[1]).toBe('/relay/wt') - expect(gitMock.mock.calls[4]?.[1]).toBe('/relay/wt') - }) - - it('checks out a selected existing local branch without creating a new branch', async () => { - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - - await localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/test', - targetDir: '/relay/wt', - base: 'feature/test', - checkoutExistingBranch: true - }) - - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['worktree', 'add', '/relay/wt', 'feature/test'] - ]) - }) - - it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => { - // Why: a local tag named 'main' makes bare-name `worktree add ... main` ambiguous; refs/heads/ disambiguates. - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set - - await localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/disambig', - targetDir: '/relay/wt', - base: 'main' - }) - - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], - ['worktree', 'add', '--no-track', '-b', 'feature/disambig', '/relay/wt', 'refs/heads/main'], - ['config', '--local', '--replace-all', 'branch.feature/disambig.base', 'refs/heads/main'], - ['config', '--get', 'push.autoSetupRemote'], - ['config', '--local', 'push.autoSetupRemote', 'true'] - ]) - }) - - it('passes --no-checkout when sparse setup will checkout after configuration', async () => { - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set - - await localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/sparse', - targetDir: '/relay/wt', - base: 'origin/main', - noCheckout: true - }) - - expect(gitMock.mock.calls[1]?.[0]).toEqual([ - 'worktree', - 'add', - '--no-track', - '--no-checkout', - '-b', - 'feature/sparse', - '/relay/wt', - 'refs/remotes/origin/main' - ]) - }) - - it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => { - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockResolvedValueOnce({ stdout: 'false\n', stderr: '' }) // --get returns value - - await localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/preserve', - targetDir: '/relay/wt', - base: 'main' - }) - - // No --local set: --get succeeded so we preserve the user's value. - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], - ['worktree', 'add', '--no-track', '-b', 'feature/preserve', '/relay/wt', 'main'], - ['config', '--local', '--replace-all', 'branch.feature/preserve.base', 'main'], - ['config', '--get', 'push.autoSetupRemote'] - ]) - }) - - it('treats --get success with empty stdout as "already set" (key present but blank)', async () => { - // Why: --get exits 0 for any value including empty string, so an empty value must not fall through to set-true. - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --get success, empty value - - await localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/empty', - targetDir: '/relay/wt', - base: 'main' - }) - - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], - ['worktree', 'add', '--no-track', '-b', 'feature/empty', '/relay/wt', 'main'], - ['config', '--local', '--replace-all', 'branch.feature/empty.base', 'main'], - ['config', '--get', 'push.autoSetupRemote'] - ]) - }) - - it('does not write --local when --get fails with non-unset code (corrupt config)', async () => { - // Why: only --get exit 1 means "unset"; any other code is a real read failure, so don't fall through to set-true. - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get non-unset - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await expect( - localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/corrupt', - targetDir: '/relay/wt', - base: 'main' - }) - ).resolves.toBeUndefined() - - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], - ['worktree', 'add', '--no-track', '-b', 'feature/corrupt', '/relay/wt', 'main'], - ['config', '--local', '--replace-all', 'branch.feature/corrupt.base', 'main'], - ['config', '--get', 'push.autoSetupRemote'] - ]) - expect(warnSpy).toHaveBeenCalledWith( - 'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt', - expect.any(Error) - ) - warnSpy.mockRestore() - }) - - it('warns but resolves when --local set fails (write-failure is warn-only)', async () => { - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset - gitMock.mockRejectedValueOnce(new Error('config locked')) // --local set fails - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await expect( - localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/writefail', - targetDir: '/relay/wt', - base: 'main' - }) - ).resolves.toBeUndefined() - - expect(warnSpy).toHaveBeenCalledWith( - 'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt', - expect.any(Error) - ) - warnSpy.mockRestore() - }) - - it('does not write config when worktree add itself fails', async () => { - // Why: config probes must run only after worktree add succeeds (never against an uncreated dir). - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} - gitMock.mockRejectedValueOnce(new Error('worktree add failed')) - - await expect( - localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/fail', - targetDir: '/relay/wt', - base: 'main' - }) - ).rejects.toThrow('worktree add failed') - - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], - ['worktree', 'add', '--no-track', '-b', 'feature/fail', '/relay/wt', 'main'] - ]) - }) - }) -}) diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts deleted file mode 100644 index dd47498c479..00000000000 --- a/src/relay/git-handler-worktree-remove.ts +++ /dev/null @@ -1,190 +0,0 @@ -import * as path from 'node:path' -import type { RemoveWorktreeResult } from '../shared/worktree/create-types' -import { isBranchCheckedOutInWorktreeError } from '../shared/git-branch-delete-refusal' -import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' -import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' -import type { GitExec } from './git-handler-ops' -import type { GitCapabilityCache } from '../shared/git-capability-cache' -import { readRelayWorktreeList } from './git-handler-worktree-list' - -function normalizeLocalBranchRef(branch: string): string { - return branch.replace(/^refs\/heads\//, '') -} - -function isPosixAbsolutePath(value: string): boolean { - return value.startsWith('/') -} - -function isWindowsAbsolutePath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') -} - -function resolveRelayRepoPath(worktreePath: string, commonDir: string): string { - if (isPosixAbsolutePath(worktreePath) || isPosixAbsolutePath(commonDir)) { - // Why: tests can run on Windows while the relay operates on SSH/POSIX - // paths; the default path API would reinterpret "/repo" as "G:\repo". - return path.posix.resolve(worktreePath, commonDir, '..') - } - if (isWindowsAbsolutePath(worktreePath) || isWindowsAbsolutePath(commonDir)) { - return path.win32.resolve(worktreePath, commonDir, '..') - } - return path.resolve(worktreePath, commonDir, '..') -} - -function normalizeRelayWorktreePathForCompare(value: string): string { - if (isPosixAbsolutePath(value)) { - return path.posix.normalize(path.posix.resolve(value)) - } - if (isWindowsAbsolutePath(value)) { - return path.win32.normalize(path.win32.resolve(value)) - } - return path.normalize(path.resolve(value)) -} - -function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean { - const left = normalizeRelayWorktreePathForCompare(leftPath) - const right = normalizeRelayWorktreePathForCompare(rightPath) - const compareCaseInsensitive = isWindowsAbsolutePath(leftPath) && isWindowsAbsolutePath(rightPath) - return compareCaseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right -} - -async function listRelayWorktreesForRemoval( - git: GitExec, - repoPath: string, - capabilities: GitCapabilityCache -) { - try { - return await readRelayWorktreeList(git, repoPath, capabilities) - } catch { - return [] - } -} - -async function deleteRelayBranchAfterWorktreeRemoval( - git: GitExec, - repoPath: string, - branchName: string, - forceBranchDelete: boolean -): Promise<'deleted' | 'checked-out'> { - const deleteFlag = forceBranchDelete ? '-D' : '-d' - try { - await git(['branch', deleteFlag, '--', branchName], repoPath) - return 'deleted' - } catch (error) { - if (!isBranchCheckedOutInWorktreeError(error)) { - throw error - } - } - - try { - // Why: branch deletion is the cheap live-checkout guard. Only prune when - // Git reports a checked-out branch, which may be stale worktree metadata. - await git(['worktree', 'prune'], repoPath) - } catch (error) { - console.warn( - `relay removeWorktree: failed to prune worktrees before deleting branch "${branchName}"`, - error - ) - return 'checked-out' - } - - try { - await git(['branch', deleteFlag, '--', branchName], repoPath) - return 'deleted' - } catch (error) { - if (isBranchCheckedOutInWorktreeError(error)) { - return 'checked-out' - } - throw error - } -} - -export async function removeWorktreeOp( - git: GitExec, - params: Record, - capabilities: GitCapabilityCache -): Promise { - const worktreePath = params.worktreePath as string - const force = params.force as boolean | undefined - const deleteBranch = params.deleteBranch !== false - const forceBranchDelete = params.forceBranchDelete === true - - let repoPath = worktreePath - try { - const { stdout } = await git(['rev-parse', '--git-common-dir'], worktreePath) - const commonDir = stdout.trim() - if (commonDir && commonDir !== '.git') { - repoPath = resolveRelayRepoPath(worktreePath, commonDir) - } - } catch { - // fall through with worktreePath as repo - } - - const worktreesBeforeRemoval = await listRelayWorktreesForRemoval(git, repoPath, capabilities) - const removedWorktree = worktreesBeforeRemoval.find((worktree) => - areRelayWorktreePathsEqual(worktree.path, worktreePath) - ) - const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') - const branchHead = removedWorktree?.head ?? '' - - assertWorktreeUnlockedForRemoval(removedWorktree) - - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) - await git(args, repoPath) - - if (!branchName) { - return {} - } - if (!deleteBranch) { - return {} - } - - // Why: SSH worktree deletion should mirror local deletion. Dropping the - // branch also removes its upstream config, which lets fork-remotes cleanup - // after the last PR review worktree is gone. - try { - // Why: use `-d` (not `-D`) to mirror the local removeWorktree fix. - const branchDeleteResult = await deleteRelayBranchAfterWorktreeRemoval( - git, - repoPath, - branchName, - forceBranchDelete - ) - if (branchDeleteResult === 'checked-out') { - return {} - } - return {} - } catch (error) { - if (!forceBranchDelete && branchHead) { - try { - if ( - await deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( - git, - repoPath, - branchName, - branchHead, - capabilities - ) - ) { - return {} - } - } catch (alreadyMergedDeleteError) { - // Why: worktree is gone; preserve branch recovery on cleanup races. - console.warn( - `relay removeWorktree: failed to delete already-merged local branch "${branchName}" after removing worktree`, - alreadyMergedDeleteError - ) - } - } - // Expected when the branch still has unmerged/unpublished commits: keep it. - console.warn( - `relay removeWorktree: preserved local branch "${branchName}" after removing worktree (not fully merged)`, - error - ) - return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } } - } -} diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts deleted file mode 100644 index c6ba3142585..00000000000 --- a/src/relay/git-handler.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * GitHandler RPC registration surface plus the branch/HEAD lifecycle RPCs: - * merge/rebase aborts, checkout, branch rename, preserved-branch deletion, - * commit history, and conflict-operation detection. - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { writeFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import { GitHandler } from './git-handler' -import { RelayContext } from './context' -import { - gitInit, - gitCommit, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - normalizeGitFileText, - removeGitTempDir -} from './git-handler-test-harness' - -describe('GitHandler', () => { - let dispatcher: MockDispatcher - let handler: GitHandler - let tmpDir: string - - beforeEach(() => { - tmpDir = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - }) - - afterEach(async () => { - await removeGitTempDir(tmpDir) - }) - - it('registers all expected handlers', () => { - const methods = Array.from(dispatcher._requestHandlers.keys()) - expect(methods).toContain('git.status') - expect(methods).toContain('git.checkIgnored') - expect(methods).toContain('git.history') - expect(methods).toContain('git.commit') - expect(methods).toContain('git.diff') - expect(methods).toContain('git.stage') - expect(methods).toContain('git.unstage') - expect(methods).toContain('git.bulkStage') - expect(methods).toContain('git.bulkUnstage') - expect(methods).toContain('git.abortMerge') - expect(methods).toContain('git.abortRebase') - expect(methods).toContain('git.checkout') - expect(methods).toContain('git.localBranches') - expect(methods).toContain('git.discard') - expect(methods).toContain('git.bulkDiscard') - expect(methods).toContain('git.conflictOperation') - expect(methods).toContain('git.branchCompare') - expect(methods).toContain('git.upstreamStatus') - expect(methods).toContain('git.fetch') - expect(methods).toContain('git.forkSync') - expect(methods).toContain('git.fetchRemoteTrackingRef') - expect(methods).toContain('git.fetchGitHubPullRequestHead') - expect(methods).toContain('git.fetchGitLabMergeRequestHead') - expect(methods).toContain('git.fetchGitLabMergeRequestHeadRef') - expect(methods).toContain('git.push') - expect(methods).toContain('git.pull') - expect(methods).toContain('git.fastForward') - expect(methods).toContain('git.rebaseFromBase') - expect(methods).toContain('git.branchDiff') - expect(methods).toContain('git.listWorktrees') - expect(methods).toContain('git.addWorktree') - expect(methods).toContain('git.removeWorktree') - expect(methods).toContain('git.worktreeIsClean') - expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate') - expect(methods).toContain('git.inspectLocalBaseRefForWorktreeCreate') - expect(methods).toContain('git.markRemoteOrcaCreated') - expect(methods).toContain('git.renameCurrentBranch') - expect(methods).toContain('git.forceDeletePreservedBranch') - expect(methods).toContain('git.exec') - expect(methods).toContain('git.clone') - expect(methods).toContain('git.isGitRepo') - }) - - it('runs remote worktree deletion inside the relay watcher fence', async () => { - const removalError = new Error('fenced before Git') - const runWithRemovalFence = vi.fn(async () => { - throw removalError - }) - handler.dispose() - handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext(), { - runWithRemovalFence - }) - - await expect( - dispatcher.callRequest('git.removeWorktree', { worktreePath: '/repo-feature' }) - ).rejects.toBe(removalError) - expect(runWithRemovalFence).toHaveBeenCalledWith('/repo-feature', expect.any(Function)) - }) - - describe('abortMerge', () => { - it('aborts an in-progress merge', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n') - gitCommit(tmpDir, 'initial') - const baseBranch = execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8', - stdio: 'pipe' - }).trim() - execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n') - gitCommit(tmpDir, 'feature change') - execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n') - gitCommit(tmpDir, 'main change') - - expect(() => - execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - ).toThrow() - await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).resolves.toBeUndefined() - - await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir }) - - await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow() - await expect( - fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8').then(normalizeGitFileText) - ).resolves.toBe('main\n') - }) - }) - - describe('abortRebase', () => { - it('aborts an in-progress rebase', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n') - gitCommit(tmpDir, 'initial') - const baseBranch = execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8', - stdio: 'pipe' - }).trim() - execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n') - gitCommit(tmpDir, 'feature change') - execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n') - gitCommit(tmpDir, 'main change') - execFileSync('git', ['checkout', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - - expect(() => - execFileSync('git', ['rebase', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) - ).toThrow() - await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).resolves.toBeUndefined() - - await dispatcher.callRequest('git.abortRebase', { worktreePath: tmpDir }) - - await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).rejects.toThrow() - await expect(fs.access(path.join(tmpDir, '.git', 'rebase-apply'))).rejects.toThrow() - await expect( - fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8').then(normalizeGitFileText) - ).resolves.toBe('feature\n') - }) - }) - - describe('checkout / localBranches', () => { - it('switches to an existing local branch and lists branches current-first', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n') - gitCommit(tmpDir, 'initial') - const baseBranch = execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8', - stdio: 'pipe' - }).trim() - execFileSync('git', ['branch', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) - - const before = (await dispatcher.callRequest('git.localBranches', { - worktreePath: tmpDir - })) as { current: string | null; branches: string[] } - expect(before.current).toBe(baseBranch) - expect(before.branches).toContain('feature') - expect(before.branches[0]).toBe(baseBranch) - - await dispatcher.callRequest('git.checkout', { worktreePath: tmpDir, branch: 'feature' }) - - expect( - execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8', - stdio: 'pipe' - }).trim() - ).toBe('feature') - - const after = (await dispatcher.callRequest('git.localBranches', { - worktreePath: tmpDir - })) as { current: string | null; branches: string[] } - expect(after.current).toBe('feature') - expect(after.branches[0]).toBe('feature') - }) - }) - - describe('markRemoteOrcaCreated', () => { - it('writes the provenance marker via config, not the generic git.exec path', async () => { - gitInit(tmpDir) - execFileSync('git', ['remote', 'add', 'pr-contributor-orca', 'https://example.com/x.git'], { - cwd: tmpDir - }) - - await dispatcher.callRequest('git.markRemoteOrcaCreated', { - repoPath: tmpDir, - remoteName: 'pr-contributor-orca' - }) - - const value = execFileSync( - 'git', - ['config', '--get', 'remote.pr-contributor-orca.orca-created'], - { cwd: tmpDir, encoding: 'utf-8' } - ).trim() - expect(value).toBe('true') - }) - - it('rejects a remote name that is not a plain config-key segment', async () => { - gitInit(tmpDir) - await expect( - dispatcher.callRequest('git.markRemoteOrcaCreated', { - repoPath: tmpDir, - remoteName: 'bad name; rm -rf' - }) - ).rejects.toThrow('Invalid remote name for provenance marker.') - }) - }) - - describe('renameCurrentBranch', () => { - it('renames only the checked-out branch through the narrow RPC', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - gitCommit(tmpDir, 'initial') - execFileSync('git', ['checkout', '-b', 'you/Nautilus'], { cwd: tmpDir }) - - await dispatcher.callRequest('git.renameCurrentBranch', { - worktreePath: tmpDir, - newBranch: 'you/fix-auth' - }) - - const current = execFileSync('git', ['branch', '--show-current'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(current).toBe('you/fix-auth') - }) - - it('rejects branch names that look like flags', async () => { - gitInit(tmpDir) - await expect( - dispatcher.callRequest('git.renameCurrentBranch', { - worktreePath: tmpDir, - newBranch: '-bad' - }) - ).rejects.toThrow('Branch name must not start with "-"') - }) - }) - - describe('forceDeletePreservedBranch', () => { - function headOf(cwd: string, ref: string): string { - return execFileSync('git', ['rev-parse', ref], { cwd, encoding: 'utf-8' }).trim() - } - - it('deletes a preserved branch at its expected head through the narrow RPC', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - gitCommit(tmpDir, 'initial') - execFileSync('git', ['branch', 'feature/preserved'], { cwd: tmpDir, stdio: 'pipe' }) - const head = headOf(tmpDir, 'refs/heads/feature/preserved') - - await dispatcher.callRequest('git.forceDeletePreservedBranch', { - repoPath: tmpDir, - branchName: 'feature/preserved', - expectedHead: head - }) - - const refs = execFileSync('git', ['branch', '--list', 'feature/preserved'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(refs).toBe('') - }) - - it('refuses to delete when the branch moved past the expected head', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - gitCommit(tmpDir, 'initial') - const staleHead = headOf(tmpDir, 'HEAD') - execFileSync('git', ['checkout', '-b', 'feature/preserved'], { cwd: tmpDir, stdio: 'pipe' }) - // Advance the branch so the saved (stale) head no longer matches its tip. - gitCommit(tmpDir, 'second') - execFileSync('git', ['checkout', '-'], { cwd: tmpDir, stdio: 'pipe' }) - - await expect( - dispatcher.callRequest('git.forceDeletePreservedBranch', { - repoPath: tmpDir, - branchName: 'feature/preserved', - expectedHead: staleHead - }) - ).rejects.toThrow('changed after the workspace was deleted') - const refs = execFileSync('git', ['branch', '--list', 'feature/preserved'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(refs).toContain('feature/preserved') - }) - - it('rejects an empty repoPath at the RPC boundary', async () => { - await expect( - dispatcher.callRequest('git.forceDeletePreservedBranch', { - repoPath: '', - branchName: 'feature/preserved', - expectedHead: 'abc123' - }) - ).rejects.toThrow('Invalid preserved branch force-delete request.') - }) - }) - - describe('history', () => { - it('returns bounded git history for a repo', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') - gitCommit(tmpDir, 'second') - - const result = (await dispatcher.callRequest('git.history', { - worktreePath: tmpDir, - limit: 10 - })) as { - items: { subject: string; displayId?: string }[] - currentRef?: { category?: string; revision?: string } - hasMore: boolean - limit: number - } - - expect(result.items.map((item) => item.subject)).toEqual(['second', 'initial']) - expect(result.currentRef?.category).toBe('branches') - expect(result.currentRef?.revision).toMatch(/^[0-9a-f]{40}$/) - expect(result.items[0]?.displayId).toHaveLength(7) - expect(result.hasMore).toBe(false) - expect(result.limit).toBe(10) - }) - }) - - describe('conflictOperation', () => { - it('returns unknown for normal repo', async () => { - gitInit(tmpDir) - gitCommit(tmpDir, 'initial') - - const result = await dispatcher.callRequest('git.conflictOperation', { worktreePath: tmpDir }) - expect(result).toBe('unknown') - }) - }) -}) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts deleted file mode 100644 index b2f425548ef..00000000000 --- a/src/relay/git-handler.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import type { RelayContext } from './context' -import { expandTilde } from './context' -import { MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES } from '../shared/in-flight-promise-dedupe' -import { GitStatusReadLeaseOwner } from '../shared/git-status-read-lease-owner' -import { GitCapabilityCache } from '../shared/git-capability-cache' -import { - clearSubmodulePathsCache, - createSubmodulePathsCache, - type SubmodulePathsCache -} from './git-handler-submodule-ops' -import { GitResponseStreamRegistry, maybeStreamRpcResponse } from './git-response-stream' -import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' -import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' -import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from '../wsl-guest/relay-command-env' -import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' -import type { - GitHandlerCommandOptions, - GitHandlerCommandResult, - GitHandlerWatcherRegistry -} from './git-handler-operation-context' -import { createGitHandlerOperationSet } from './git-handler-operation-set' -import { registerGitHandlers } from './git-handler-registration' -import { resolveGitFetchHeadCommand, runWithGitFetchHeadLock } from '../shared/git-fetch-head-lock' -import { MAX_GIT_BUFFER, runGitToTermination } from './git-handler-command-termination' -import { classifyGitCommand, findGitSubcommandIndex } from '../shared/git-command-classification' -import { - GIT_SSH_CONFIG_ARGS, - parseGitSshConfig, - buildGitSshPolicyEnv -} from '../shared/git-ssh-policy-env' - -export class GitHandler { - private dispatcher: RelayDispatcher - private readonly gitDiffReadDedupe = new GitStatusReadLeaseOwner( - MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES, - 30_000 - ) - private readonly gitCapabilities = new GitCapabilityCache() - // Why: cache .gitmodules per instance to avoid SSH reads and test leakage. - private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache() - - // Why: RelayContext accepted for protocol back-compat (docs/relay-fs-allowlist-removal.md) but no longer consulted on git ops. - constructor( - dispatcher: RelayDispatcher, - _context: RelayContext, - private readonly watcherRegistry?: GitHandlerWatcherRegistry, - // Why: use the bulk lane so large responses do not block interactive PTY echo. This handler - // registers the `git.responseAck` route below, so in production it takes the relay's single - // registry and FsHandler is handed the same one — see the header of git-response-stream.ts for - // why a second registry both collides on stream ids and stalls on credit. - private readonly responseStreams: GitResponseStreamRegistry = new GitResponseStreamRegistry() - ) { - this.dispatcher = dispatcher - const handlers = createGitHandlerOperationSet({ - gitDiffReadDedupe: this.gitDiffReadDedupe, - gitCapabilities: this.gitCapabilities, - submodulePathsCache: this.submodulePathsCache, - watcherRegistry: this.watcherRegistry, - git: (args, cwd, opts) => - opts === undefined ? this.git(args, cwd) : this.git(args, cwd, opts), - gitBuffer: (args, cwd, opts) => this.gitBuffer(args, cwd, opts), - spawnClone: (args, cwd, progressId, context) => - this.spawnClone(args, cwd, progressId, context), - clearGitMutationReadCaches: () => this.clearGitMutationReadCaches(), - runWithGitReadCacheClear: (run) => this.runWithGitReadCacheClear(run), - maybeStreamResponse: (result, params, context) => - this.maybeStreamResponse(result, params, context) - }) - registerGitHandlers( - this.dispatcher, - handlers, - (params, context) => this.responseAck(params, context), - (params, context) => this.cancelResponseStream(params, context) - ) - // Why: a detached client's git.responseAck frames never arrive; wake any pump parked on the ack window so it re-checks staleness and exits. - this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll()) - } - - dispose(): void { - this.responseStreams.disposeAll() - this.clearGitMutationReadCaches() - } - - private responseAck(params: Record, context: RequestContext): void { - const streamId = params.streamId - const seq = params.seq - if (typeof streamId === 'number' && typeof seq === 'number') { - this.responseStreams.recordAck(streamId, seq, context.clientId) - } - } - - private cancelResponseStream(params: Record, context: RequestContext): void { - const streamId = params.streamId - if (typeof streamId === 'number') { - this.responseStreams.abort(streamId, context.clientId) - } - } - - // Why: opt-in streaming — old clients/relays omit the flag and fall back to the plain result. - private maybeStreamResponse( - result: unknown, - params: Record, - context: RequestContext | undefined - ): unknown { - return maybeStreamRpcResponse(result, params, context, this.responseStreams, this.dispatcher) - } - - private clearGitMutationReadCaches(): void { - this.gitDiffReadDedupe.invalidate() - invalidateGitBranchLineTotalInFlight() - clearGitStatusLineStatsCache() - clearSubmodulePathsCache(this.submodulePathsCache) - } - - private async runWithGitReadCacheClear(run: () => Promise): Promise { - // Why: git mutations can stale in-flight diff/.gitmodules reads; clear before and after so later reads cannot join them. - this.clearGitMutationReadCaches() - try { - return await run() - } finally { - this.clearGitMutationReadCaches() - } - } - - private async git( - args: string[], - cwd: string, - opts?: GitHandlerCommandOptions - ): Promise { - const expandedCwd = expandTilde(cwd) - const run = async (): Promise<{ stdout: string; stderr: string }> => { - const env = - classifyGitCommand(args) === 'network' - ? await this.networkSshEnv(args, expandedCwd, opts?.signal) - : opts?.nonInteractive - ? buildRelayUnattendedGitEnv() - : buildRelayGitEnv() - if (opts?.disableOptionalLocks) { - env.GIT_OPTIONAL_LOCKS = '0' - } - return runGitToTermination( - args, - { - cwd: expandedCwd, - env, - maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, - timeout: opts?.timeout, - signal: opts?.signal - }, - opts?.stdin - ) - } - const command = resolveGitFetchHeadCommand(args, expandedCwd) - return command.needsLock - ? runWithGitFetchHeadLock(command.cwd, opts?.signal, run, command.gitDir) - : run() - } - - private async networkSshEnv( - args: readonly string[], - cwd: string, - signal?: AbortSignal - ): Promise { - const env = buildRelayUnattendedGitEnv() - if (env.GIT_SSH_COMMAND || env.GIT_SSH) { - return env - } - const subcommandIndex = findGitSubcommandIndex(args) - let config = parseGitSshConfig('') - try { - const { stdout } = await this.git( - [...args.slice(0, Math.max(0, subcommandIndex)), ...GIT_SSH_CONFIG_ARGS], - cwd, - { signal, timeout: 2500, nonInteractive: true } - ) - config = parseGitSshConfig(stdout) - } catch (error) { - if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 1) { - throw error - } - } - return buildGitSshPolicyEnv(env, config.command, config.variant).env - } - - private async gitBuffer( - args: string[], - cwd: string, - opts?: GitHandlerCommandOptions - ): Promise { - const result = await runGitToTermination( - args, - { - cwd: expandTilde(cwd), - env: buildRelayGitEnv(), - captureStdoutAsBytes: true, - signal: opts?.signal, - timeout: opts?.timeout, - maxBuffer: opts?.maxBuffer - }, - undefined - ) - if (!result.stdoutBytes) { - throw new Error('Git byte capture returned no bytes.') - } - return result.stdoutBytes - } - - private async spawnClone( - args: string[], - cwd: string, - progressId: string, - context?: RequestContext - ): Promise<{ stdout: string; stderr: string }> { - const env = await this.networkSshEnv(args, expandTilde(cwd), context?.signal) - try { - const result = await runGitToTermination( - args, - { - cwd: expandTilde(cwd), - env, - signal: context?.signal, - maxBuffer: 4096, - outputCapture: 'tail', - observeStderr: (chunk) => { - for (const line of chunk.toString('utf8').split(/[\r\n]+/)) { - const match = line.match(/^([\w\s]+):\s+(\d+)%/) - if (match) { - this.dispatcher.notify('git.cloneProgress', { - progressId, - phase: match[1].trim(), - percent: Number.parseInt(match[2], 10) - }) - } - } - } - }, - undefined - ) - return result - } catch (error) { - if (context?.signal?.aborted) { - throw error - } - throw new Error( - `Clone failed: ${getGitCloneFailureMessage(error instanceof Error ? error.message : String(error))}` - ) - } - } -} diff --git a/src/relay/git-porcelain-local-parity.test.ts b/src/relay/git-porcelain-local-parity.test.ts deleted file mode 100644 index aae3f4263cc..00000000000 --- a/src/relay/git-porcelain-local-parity.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Issue #18280: the SSH relay used to carry its own copies of the worktree-list and - * unmerged-entry porcelain parsers, and they drifted — the relay copy had no `sparse` - * branch and never C-quote-decoded a conflict path. These tests push one porcelain - * fixture through the relay's published call sites and the desktop's, and require the - * answers to be identical. A second parser reintroduced on either side fails here. - */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { parseWorktreeList } from '../main/git/worktree' -import type { GitStatusEntry } from '../shared/git-status-types' -import type { GitWorktreeInfo } from '../shared/worktree/types' -import { parseUnmergedEntry } from '../shared/git-status-conflict-entries' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { getStatusOp } from './git-handler-status-ops' -import type { GitExec } from './git-handler-ops' -import type { RelayGitStreamExec } from './git-stdout-stream' -import { - createMockDispatcher, - type MockDispatcher, - type RelayDispatcher -} from './git-handler-test-setup' - -type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> -} - -const tempRoots: string[] = [] - -async function createTempDir(prefix: string): Promise { - const root = await mkdtemp(path.join(tmpdir(), prefix)) - tempRoots.push(root) - return root -} - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -async function createWorktreeFixture(prefix: string): Promise { - const mainPath = await createTempDir(prefix) - // Why: the Git <2.36 fallback lane probes each linked path's existence; a missing - // directory would add a relay-only `prunable` annotation and muddy the comparison. - await mkdir(path.join(mainPath, 'sparse-wt')) - await mkdir(path.join(mainPath, 'locked-wt')) - await mkdir(path.join(mainPath, '.git')) - await writeFile(path.join(mainPath, '.git', 'HEAD'), 'ref: refs/heads/main\n') - return mainPath -} - -async function listWorktreesOverRelay( - mainPath: string, - git: (args: string[]) => Promise<{ stdout: string; stderr: string }> -): Promise { - const { dispatcher, handler } = createRelay() - vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation(git) - return (await dispatcher.callRequest('git.listWorktrees', { - repoPath: mainPath - })) as GitWorktreeInfo[] -} - -function buildWorktreePorcelainBlocks(mainPath: string): string[][] { - return [ - [`worktree ${mainPath}`, 'HEAD abc123', 'branch refs/heads/main'], - // `sparse` is Git 2.28+; older hosts omit the line and `isSparse` is correctly absent. - [ - `worktree ${path.join(mainPath, 'sparse-wt')}`, - 'HEAD def456', - 'branch refs/heads/sparse', - 'sparse' - ], - [ - `worktree ${path.join(mainPath, 'locked-wt')}`, - 'HEAD 111111', - 'branch refs/heads/locked', - 'locked "held \\303\\251"' - ], - [ - `worktree ${path.join(mainPath, 'stale-wt')}`, - 'HEAD 222222', - 'branch refs/heads/stale', - 'prunable gitdir file points to non-existent location' - ] - ] -} - -function toLinePorcelain(blocks: string[][]): string { - return `${blocks.map((block) => block.join('\n')).join('\n\n')}\n` -} - -function toNulPorcelain(blocks: string[][]): string { - // Git's `-z` porcelain terminates every field with NUL and every block with an extra NUL. - return blocks.map((block) => `${block.join('\0')}\0\0`).join('') -} - -/** Git <2.36 rejects `worktree list -z` with a usage error, routing the handler to the fallback lane. */ -function unsupportedZError(): Error { - return Object.assign(new Error('git usage error'), { - code: 129, - stderr: 'usage: git worktree list []\n' - }) -} - -function createRelay(): { dispatcher: MockDispatcher; handler: GitHandler } { - const dispatcher = createMockDispatcher() - const handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - return { dispatcher, handler } -} - -describe('relay/desktop worktree-list porcelain parity', () => { - it('answers the -z lane with exactly what the desktop parser produces, including isSparse', async () => { - const mainPath = await createWorktreeFixture('orca-parity-wt-') - const porcelain = toNulPorcelain(buildWorktreePorcelainBlocks(mainPath)) - - const relayWorktrees = await listWorktreesOverRelay(mainPath, async () => ({ - stdout: porcelain, - stderr: '' - })) - - expect(relayWorktrees).toEqual(parseWorktreeList(porcelain, { nulDelimited: true })) - expect(relayWorktrees[1]).toMatchObject({ branch: 'refs/heads/sparse', isSparse: true }) - }) - - it('answers the Git <2.36 fallback lane identically', async () => { - const mainPath = await createWorktreeFixture('orca-parity-wt-fallback-') - const porcelain = toLinePorcelain(buildWorktreePorcelainBlocks(mainPath)) - - const relayWorktrees = await listWorktreesOverRelay(mainPath, async (args) => { - if (args.includes('-z')) { - throw unsupportedZError() - } - return { stdout: porcelain, stderr: '' } - }) - - expect(relayWorktrees).toEqual(parseWorktreeList(porcelain)) - expect(relayWorktrees[1]).toMatchObject({ isSparse: true }) - }) - - it('leaves isSparse absent on a Git 2.25 host that never emits the sparse line', async () => { - const mainPath = await createTempDir('orca-parity-wt-baseline-') - const porcelain = toNulPorcelain([ - [`worktree ${mainPath}`, 'HEAD abc123', 'branch refs/heads/main'] - ]) - - const relayWorktrees = await listWorktreesOverRelay(mainPath, async () => ({ - stdout: porcelain, - stderr: '' - })) - - expect(relayWorktrees).toEqual(parseWorktreeList(porcelain, { nulDelimited: true })) - expect(relayWorktrees[0]).not.toHaveProperty('isSparse') - }) -}) - -describe('relay/desktop unmerged-entry porcelain parity', () => { - it('resolves C-quoted conflict paths and the working-tree probe the same way', async () => { - const worktreePath = await createTempDir('orca-parity-conflict-') - await writeFile(path.join(worktreePath, 'present é.ts'), 'conflict\n') - const unmergedLines = [ - 'u UU N... 100644 100644 100644 100644 aa bb cc plain.ts', - 'u UD N... 100644 100644 000000 100644 aa bb cc "present \\303\\251.ts"', - // mW=000000: real Git reports an absent working-tree path this way, and the file is not created below. - 'u UD N... 100644 100644 000000 000000 aa bb cc "missing \\303\\251.ts"', - 'u DD N... 100644 100644 000000 000000 aa bb cc both-gone.ts' - ] - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: `${unmergedLines.join('\n')}\n`, stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - const streamGit: RelayGitStreamExec = async (args, cwd, options) => { - const { stdout } = await git(args, cwd, { signal: options.signal }) - return { stoppedEarly: options.onStdout(stdout) === true } - } - - const relayResult = await getStatusOp(git, streamGit, { - worktreePath, - includeLineStats: false - }) - - const desktopEntries: (GitStatusEntry | null)[] = [] - for (const line of unmergedLines) { - desktopEntries.push(await parseUnmergedEntry(worktreePath, line)) - } - - expect(relayResult.entries).toEqual(desktopEntries) - expect(relayResult.entries).toEqual([ - { - path: 'plain.ts', - area: 'unstaged', - status: 'modified', - conflictKind: 'both_modified', - conflictStatus: 'unresolved' - }, - { - path: 'present é.ts', - area: 'unstaged', - status: 'modified', - conflictKind: 'deleted_by_them', - conflictStatus: 'unresolved' - }, - { - path: 'missing é.ts', - area: 'unstaged', - status: 'deleted', - conflictKind: 'deleted_by_them', - conflictStatus: 'unresolved' - }, - { - path: 'both-gone.ts', - area: 'unstaged', - status: 'deleted', - conflictKind: 'both_deleted', - conflictStatus: 'unresolved' - } - ]) - }) - - it('drops submodule conflicts on both paths', async () => { - const worktreePath = await createTempDir('orca-parity-conflict-submodule-') - const line = 'u UU S... 160000 160000 160000 160000 aa bb cc vendor/submodule' - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: `${line}\n`, stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - const streamGit: RelayGitStreamExec = async (args, cwd, options) => { - const { stdout } = await git(args, cwd, { signal: options.signal }) - return { stoppedEarly: options.onStdout(stdout) === true } - } - - const relayResult = await getStatusOp(git, streamGit, { - worktreePath, - includeLineStats: false - }) - - expect(await parseUnmergedEntry(worktreePath, line)).toBeNull() - expect(relayResult.entries).toEqual([]) - }) -}) diff --git a/src/relay/git-push-target-local-parity.test.ts b/src/relay/git-push-target-local-parity.test.ts deleted file mode 100644 index ce97369b73d..00000000000 --- a/src/relay/git-push-target-local-parity.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Push-target resolution decides which remote a plain `git push` hits, and a wrong - * answer is not recoverable by retrying. The relay and the desktop used to carry - * identical ~160-line copies of it; they now share one implementation. - * - * These tests script one repository's Git config and require `git.push` over the real - * relay dispatcher and the desktop's `gitPush` to emit the *same push argv*, plus the - * argv each case is supposed to produce — so a second implementation on either side - * fails here even if it is wrong in the same direction on both. - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) - -vi.mock('../main/git/runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock -})) - -import { gitPush } from '../main/git/remote' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { createMockDispatcher, type RelayDispatcher } from './git-handler-test-setup' - -const WORKTREE_PATH = '/worktree' - -type GitConfigFixture = { - /** Empty means detached HEAD: `symbolic-ref --quiet --short HEAD` prints nothing. */ - branch: string - merge?: string - branchRemote?: string - pushRemote?: string - pushDefault?: string - base?: string - /** remote name -> fetch URL, as `git remote -v` prints it. */ - remotes?: Record -} - -type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> -} - -/** One scripted repository, driven identically by both hosts. */ -function scriptGit(fixture: GitConfigFixture) { - const configValues = new Map() - const put = (key: string, value: string | undefined): void => { - if (value !== undefined) { - configValues.set(key, value) - } - } - put(`branch.${fixture.branch}.merge`, fixture.merge) - put(`branch.${fixture.branch}.remote`, fixture.branchRemote) - put(`branch.${fixture.branch}.pushRemote`, fixture.pushRemote) - put(`branch.${fixture.branch}.base`, fixture.base) - put('remote.pushDefault', fixture.pushDefault) - - const calls: string[][] = [] - return { - calls, - run: async (args: string[]): Promise<{ stdout: string; stderr: string }> => { - calls.push(args) - if (args[0] === 'symbolic-ref') { - return { stdout: `${fixture.branch}\n`, stderr: '' } - } - if (args[0] === 'config' && args[1] === '--list') { - return { - stdout: Array.from( - configValues, - ([key, value]) => - `${key.replace(/[^.]+$/, (variable) => variable.toLowerCase())}\n${value}\0` - ).join(''), - stderr: '' - } - } - if (args[0] === 'config' && args[1] === '--get') { - const value = configValues.get(args[2] ?? '') - // Why throw: `git config --get` exits 1 for a missing key, and the resolver's - // fallback chain reads that rejection, not an empty string. - if (value === undefined) { - throw Object.assign(new Error('missing config key'), { code: 1 }) - } - return { stdout: `${value}\n`, stderr: '' } - } - if (args[0] === 'remote' && args[1] === '-v') { - const lines = Object.entries(fixture.remotes ?? {}).flatMap(([name, url]) => [ - `${name}\t${url} (fetch)`, - `${name}\t${url} (push)` - ]) - return { stdout: `${lines.join('\n')}\n`, stderr: '' } - } - if (args[0] === 'push') { - return { stdout: '', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - } - } -} - -function pushArgv(calls: string[][]): string[] { - const push = calls.find((args) => args[0] === 'push') - if (!push) { - throw new Error('no push command was issued') - } - return push -} - -async function pushOverRelay(fixture: GitConfigFixture): Promise { - const dispatcher = createMockDispatcher() - const handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) - const script = scriptGit(fixture) - vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args) => script.run(args)) - await dispatcher.callRequest('git.push', { worktreePath: WORKTREE_PATH }) - expect(script.calls.filter((args) => args[0] === 'config')).toEqual([['config', '--list', '-z']]) - return pushArgv(script.calls) -} - -async function pushLocally(fixture: GitConfigFixture): Promise { - const script = scriptGit(fixture) - gitExecFileAsyncMock.mockImplementation((args: string[]) => script.run(args)) - await gitPush(WORKTREE_PATH) - expect(script.calls.filter((args) => args[0] === 'config')).toEqual([['config', '--list', '-z']]) - return pushArgv(script.calls) -} - -async function expectSamePushArgv(fixture: GitConfigFixture, expected: string[]): Promise { - const relayArgv = await pushOverRelay(fixture) - const localArgv = await pushLocally(fixture) - expect(relayArgv).toEqual(localArgv) - expect(localArgv).toEqual(expected) -} - -const FIRST_PUBLISH = ['push', '--set-upstream', 'origin', 'HEAD'] - -beforeEach(() => { - gitExecFileAsyncMock.mockReset() -}) - -describe('relay/desktop push-target parity', () => { - it.each([ - [ - { branch: 'feature/fix', pushRemote: 'fork', pushDefault: 'other', branchRemote: 'origin' }, - 'fork' - ], - [{ branch: 'feature/fix', pushDefault: 'fork', branchRemote: 'origin' }, 'fork'], - [{ branch: 'feature/fix', branchRemote: 'fork' }, 'fork'] - ] as const)( - 'first-publishes to the configured remote without branch.merge (%j)', - async (fixture, remote) => { - await expectSamePushArgv(fixture, ['push', '--set-upstream', remote, 'HEAD']) - } - ) - - it('normalizes a URL-valued remote on first publish', async () => { - await expectSamePushArgv( - { - branch: 'feature/fix', - pushRemote: 'git@example.invalid:contributor/repo.git', - remotes: { fork: 'git@example.invalid:contributor/repo.git' } - }, - ['push', '--set-upstream', 'fork', 'HEAD'] - ) - }) - - it('keeps a local-repository push remote out of first-publish targets', async () => { - await expectSamePushArgv({ branch: 'feature/fix', pushDefault: '.' }, FIRST_PUBLISH) - }) - - it('sends a review branch to the fork its pushDefault names', async () => { - await expectSamePushArgv( - { - branch: 'review/pr-1738', - merge: 'refs/heads/contributor/fix', - branchRemote: 'fork', - pushDefault: 'fork' - }, - ['push', '--set-upstream', 'fork', 'HEAD:contributor/fix'] - ) - }) - - it('refuses to inherit origin/main as a destination for a differently named branch', async () => { - // branch.merge belongs to branch.remote; a branch tracking origin/main must - // first-publish under its own name rather than push onto main. - await expectSamePushArgv( - { - branch: 'feature/fix', - merge: 'refs/heads/main', - branchRemote: 'origin' - }, - FIRST_PUBLISH - ) - }) - - it('refuses a pushDefault fork whose branch.remote names a different remote', async () => { - await expectSamePushArgv( - { - branch: 'review/pr-1738', - merge: 'refs/heads/contributor/fix', - branchRemote: 'origin', - pushDefault: 'fork' - }, - FIRST_PUBLISH - ) - }) - - it('refuses when branch.base names the same remote branch as branch.merge', async () => { - await expectSamePushArgv( - { - branch: 'feature/fix', - merge: 'refs/heads/release', - branchRemote: 'fork', - pushRemote: 'fork', - base: 'fork/release' - }, - FIRST_PUBLISH - ) - }) - - it('resolves a URL-valued pushRemote back to its remote name', async () => { - await expectSamePushArgv( - { - branch: 'review/pr-1738', - merge: 'refs/heads/contributor/fix', - branchRemote: 'git@example.invalid:contributor/repo.git', - pushRemote: 'git@example.invalid:contributor/repo.git', - remotes: { - origin: 'git@example.invalid:upstream/repo.git', - fork: 'git@example.invalid:contributor/repo.git' - } - }, - ['push', '--set-upstream', 'fork', 'HEAD:contributor/fix'] - ) - }) - - it('treats a local-repository remote as no configured target', async () => { - await expectSamePushArgv( - { - branch: 'feature/fix', - merge: 'refs/heads/feature/fix', - branchRemote: '.' - }, - FIRST_PUBLISH - ) - }) - - it('first-publishes a branch with no configured remote at all', async () => { - await expectSamePushArgv( - { branch: 'feature/fix', merge: 'refs/heads/feature/fix' }, - FIRST_PUBLISH - ) - }) -}) diff --git a/src/relay/git-response-pty-echo-backpressure.integration.test.ts b/src/relay/git-response-pty-echo-backpressure.integration.test.ts deleted file mode 100644 index cf60f4ab437..00000000000 --- a/src/relay/git-response-pty-echo-backpressure.integration.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Regression: SSH typing latency under large git responses. - * - * The relay and the client share ONE ordered SSH channel. A large git diff/exec - * response sent as a single JSON-RPC frame queues megabytes into the outbound - * pipe at once; an interactive pty.data echo emitted mid-response then sits - * behind all of it and typing feels seconds-slow. - * - * These tests model the SSH channel as a congestible in-memory pipe and assert - * deterministic byte bounds instead of wall-clock latency: - * - WITHOUT the fix (client does not opt in), the whole response queues ahead - * of a pty echo; - * - WITH the fix (client opts in), the response streams on the bulk lane and - * at most ~1 chunk frame sits ahead of the echo; - * - both paths reassemble the identical git result. - */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { execFileSync } from 'node:child_process' -import * as path from 'node:path' -import { tmpdir } from 'node:os' - -import { - SshChannelMultiplexer, - type MultiplexerTransport -} from '../main/ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' - -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { SinkWriteSettlement } from '../wsl-guest/dispatcher' -import { RelayContext } from './context' -import { GitHandler } from './git-handler' -import { GIT_RESPONSE_CHUNK_SIZE } from '../wsl-guest/protocol' - -// One framed git.responseChunk: base64 (4/3) + JSON envelope + header slack. -const FRAMED_CHUNK_BYTES = Math.ceil((GIT_RESPONSE_CHUNK_SIZE * 4) / 3) + 512 -const SINK_HIGH_WATER_MARK = 64 * 1024 - -async function waitUntil( - predicate: () => boolean, - what: string, - timeoutMs = 10_000 -): Promise { - const deadline = Date.now() + timeoutMs - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error(`waitUntil timed out: ${what}`) - } - await new Promise((r) => setImmediate(r)) - } -} - -async function waitUntilSettled(read: () => number, stableTurns = 25): Promise { - let last = read() - let stable = 0 - while (stable < stableTurns) { - await new Promise((r) => setImmediate(r)) - const current = read() - if (current === last) { - stable += 1 - } else { - stable = 0 - last = current - } - } -} - -type Harness = { - mux: SshChannelMultiplexer - dispatcher: RelayDispatcher - gitHandler: GitHandler - queuedBytes: () => number - deliverAll: () => void - startAutoDeliver: () => void - dispose: () => void -} - -function createHarness(opts: { congested: boolean }): Harness { - let relayFeed: ((data: Buffer) => void) | null = null - const clientDataCallbacks: ((data: Buffer) => void)[] = [] - - const clientTransport: MultiplexerTransport = { - write: (data: Buffer) => { - // Client → relay (keystrokes, acks) is the opposite duplex direction and - // is not blocked by relay→client congestion. - setImmediate(() => relayFeed?.(data)) - }, - onData: (cb) => { - clientDataCallbacks.push(cb) - }, - onClose: () => {} - } - - const outQueue: { - data: Buffer - settle: (result: SinkWriteSettlement) => void - }[] = [] - let queuedBytes = 0 - const drainWaiters = new Set<() => void>() - const fireDrainIfIdle = (): void => { - if (queuedBytes > 0) { - return - } - for (const cb of Array.from(drainWaiters)) { - drainWaiters.delete(cb) - cb() - } - } - - const dispatcher = new RelayDispatcher( - (data: Buffer, settle) => { - outQueue.push({ data, settle }) - queuedBytes += data.length - if (!opts.congested) { - return true - } - return queuedBytes < SINK_HIGH_WATER_MARK - }, - { - supportsWriteCallback: true, - writableLength: () => queuedBytes, - writableHighWaterMark: () => SINK_HIGH_WATER_MARK, - waitWriteDrain: (cb: () => void) => { - drainWaiters.add(cb) - fireDrainIfIdle() - } - } - ) - relayFeed = (data: Buffer) => dispatcher.feed(data) - - const deliverAll = (): void => { - while (outQueue.length > 0) { - const { data, settle } = outQueue.shift()! - queuedBytes -= data.length - for (const cb of clientDataCallbacks) { - cb(data) - } - settle({ ok: true }) - } - fireDrainIfIdle() - } - - let autoDeliverTimer: ReturnType | null = null - const startAutoDeliver = (): void => { - if (autoDeliverTimer) { - return - } - autoDeliverTimer = setInterval(deliverAll, 1) - } - - const context = new RelayContext() - const gitHandler = new GitHandler(dispatcher, context) - const mux = new SshChannelMultiplexer(clientTransport) - - return { - mux, - dispatcher, - gitHandler, - queuedBytes: () => queuedBytes, - deliverAll, - startAutoDeliver, - dispose: () => { - if (autoDeliverTimer) { - clearInterval(autoDeliverTimer) - } - mux.dispose() - dispatcher.dispose() - gitHandler.dispose() - } - } -} - -// Build a repo whose staged diff for `big.txt` is several MB so git.diff -// returns a payload well over the stream threshold. -function makeRepoWithLargeStagedDiff(dir: string): void { - const env = { ...process.env } - const run = (args: string[]): void => { - execFileSync('git', args, { cwd: dir, stdio: 'pipe', env }) - } - run(['init']) - run(['config', 'user.email', 'test@test.com']) - run(['config', 'user.name', 'Test']) - // Distinct non-repeating lines keep the diff from compressing to nothing. - // Stay under the render limits (120k lines / 6M chars) so the diff result - // carries the full ~4MB content and exceeds the stream threshold. - const lines: string[] = [] - for (let i = 0; i < 12_000; i += 1) { - lines.push(`line ${i} ${'x'.repeat(100)}`) - } - writeFileSync(path.join(dir, 'big.txt'), lines.join('\n')) - run(['add', 'big.txt']) -} - -describe('large git response vs pty.data echo head-of-line blocking', () => { - let tmpDir: string - let repoDir: string - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-hol-')) - repoDir = path.join(tmpDir, 'repo') - mkdirSync(repoDir, { recursive: true }) - makeRepoWithLargeStagedDiff(repoDir) - }) - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }) - }) - - it('WITHOUT opt-in: the whole diff queues ahead of a pty echo (single-frame HOL)', async () => { - const harness = createHarness({ congested: true }) - try { - let queuedBytesAheadOfEcho = -1 - harness.dispatcher.onNotification('pty.data', (params) => { - queuedBytesAheadOfEcho = harness.queuedBytes() - harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) - }) - - // Plain request WITHOUT __streamResponse: single-frame response, as today. - const diffPromise = harness.mux.request('git.diff', { - worktreePath: repoDir, - filePath: 'big.txt', - staged: true - }) - // Let the whole single JSON-RPC frame land in the congested pipe. - await waitUntil(() => harness.queuedBytes() > SINK_HIGH_WATER_MARK, 'diff frame queued') - await waitUntilSettled(() => harness.queuedBytes()) - - harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) - await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') - - // The echo sits behind the entire (multi-hundred-KB) diff frame — far - // more than a single bulk chunk would be. - expect(queuedBytesAheadOfEcho).toBeGreaterThan(512 * 1024) - - harness.startAutoDeliver() - const result = (await diffPromise) as { diff?: string } - expect(typeof result).toBe('object') - } finally { - harness.dispose() - } - }, 30_000) - - it('WITH opt-in: the diff streams on the bulk lane and the echo stays bounded', async () => { - const harness = createHarness({ congested: true }) - try { - let queuedBytesAheadOfEcho = -1 - harness.dispatcher.onNotification('pty.data', (params) => { - queuedBytesAheadOfEcho = harness.queuedBytes() - harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) - }) - - const diffPromise = requestGitStreamable(harness.mux, 'git.diff', { - worktreePath: repoDir, - filePath: 'big.txt', - staged: true - }) - // Deliver the sentinel response, then let the pump run into congestion. - await waitUntil(() => harness.queuedBytes() > 0, 'sentinel queued') - harness.deliverAll() - await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued') - await waitUntilSettled(() => harness.queuedBytes()) - - harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) - await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') - - // At most one in-flight bulk frame (the write that saturated the sink) - // plus slack sits ahead of the echo. - expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES) - - harness.startAutoDeliver() - const streamed = (await diffPromise) as Record - expect(typeof streamed).toBe('object') - expect(streamed).not.toHaveProperty('__orcaGitResponseStream') - } finally { - harness.dispose() - } - }, 30_000) - - it('streamed result equals the single-frame result', async () => { - const harness = createHarness({ congested: false }) - try { - harness.startAutoDeliver() - const params = { worktreePath: repoDir, filePath: 'big.txt', staged: true } - // Why: request both concurrently so the shared 1ms delivery pump drives - // the plain frame and the streamed chunk/ack round-trips without either - // starving the other on a slow CI event loop. - const [plain, streamed] = await Promise.all([ - harness.mux.request('git.diff', params), - requestGitStreamable(harness.mux, 'git.diff', params) - ]) - expect(streamed).toEqual(plain) - } finally { - harness.dispose() - } - }, 30_000) - - it('rejects with an inactivity timeout when the stream stalls after the sentinel', async () => { - const harness = createHarness({ congested: false }) - try { - // Deliver the sentinel so reassembly begins, then stop delivering chunks - // to model a relay pump that wedged while the channel stayed up. Without - // the client-side inactivity deadline this promise would hang forever. - const streamedPromise = requestGitStreamable( - harness.mux, - 'git.diff', - { worktreePath: repoDir, filePath: 'big.txt', staged: true }, - { inactivityTimeoutMs: 250 } - ) - await waitUntil(() => harness.queuedBytes() > 0, 'sentinel queued') - harness.deliverAll() // sentinel only; chunks stay undelivered - await expect(streamedPromise).rejects.toThrow(/stalled/) - } finally { - harness.dispose() - } - }, 30_000) -}) diff --git a/src/relay/git-response-stream-ownership.test.ts b/src/relay/git-response-stream-ownership.test.ts deleted file mode 100644 index 3e23ae2e7f1..00000000000 --- a/src/relay/git-response-stream-ownership.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from '../wsl-guest/protocol' - -async function flushPump(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('GitResponseStreamRegistry client ownership', () => { - const registries: GitResponseStreamRegistry[] = [] - - afterEach(() => { - for (const registry of registries) { - registry.disposeAll() - } - registries.length = 0 - }) - - it('ignores acknowledgements and cancellation from a different relay client', async () => { - const ownerClientId = 7 - const notifyBulk = vi.fn().mockResolvedValue(undefined) - const dispatcher = { - notifyBulk, - notify: vi.fn() - } as unknown as RelayDispatcher - const context: RequestContext = { - clientId: ownerClientId, - isStale: () => false - } - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const payload = Buffer.alloc(GIT_RESPONSE_CHUNK_SIZE * (STREAM_ACK_WINDOW_CHUNKS * 3)) - const marker = registry.startStream(payload, dispatcher, context) - const streamId = marker.__orcaGitResponseStream.streamId - - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - - registry.recordAck(streamId, 10_000, ownerClientId + 1) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - - registry.abort(streamId, ownerClientId + 1) - registry.recordAck(streamId, STREAM_ACK_WINDOW_CHUNKS - 1, ownerClientId) - await flushPump() - expect(notifyBulk.mock.calls.length).toBeGreaterThan(STREAM_ACK_WINDOW_CHUNKS) - }) - - it('contains a secondary failure while reporting a stream error', async () => { - const notifyBulk = vi.fn().mockRejectedValue(new Error('socket closed')) - const dispatcher = { notifyBulk, notify: vi.fn() } as unknown as RelayDispatcher - const registry = new GitResponseStreamRegistry() - registries.push(registry) - - registry.startStream(Buffer.from('payload'), dispatcher, { - clientId: 7, - isStale: () => false - }) - - await flushPump() - await flushPump() - - expect(notifyBulk).toHaveBeenCalledTimes(2) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - expect(notifyBulk.mock.calls[1]?.[0]).toBe('git.responseError') - }) - - it('uses encoded producer capacity without collapsing chunks during saturation', async () => { - let releaseFirst!: () => void - const firstWrite = new Promise((resolve) => { - releaseFirst = resolve - }) - const notifyBulk = vi - .fn() - .mockImplementationOnce(() => firstWrite) - .mockResolvedValue(undefined) - const producerDataBudget = vi.fn(() => 8) - const dispatcher = { - notifyBulk, - producerDataBudget - } as unknown as RelayDispatcher - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const ownerClientId = 7 - - const marker = registry.startStream(Buffer.alloc(12), dispatcher, { - clientId: ownerClientId, - isStale: () => false - }) - const streamId = marker.__orcaGitResponseStream.streamId - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(1) - - registry.recordAck(streamId, 1_000, ownerClientId) - releaseFirst() - await flushPump() - await flushPump() - - expect(producerDataBudget).toHaveBeenCalledExactlyOnceWith( - 'git.responseChunk', - { streamId, seq: 12 }, - ownerClientId - ) - expect(marker.__orcaGitResponseStream.chunkCount).toBe(2) - expect(notifyBulk).toHaveBeenCalledTimes(3) - const chunks = notifyBulk.mock.calls - .filter(([method]) => method === 'git.responseChunk') - .map(([, params]) => params as { data: string }) - expect(chunks).toHaveLength(2) - expect(chunks.every(({ data }) => data.length <= 8)).toBe(true) - expect(chunks.map(({ data }) => Buffer.from(data, 'base64').length)).toEqual([6, 6]) - }) -}) diff --git a/src/relay/git-response-stream-retention.test.ts b/src/relay/git-response-stream-retention.test.ts deleted file mode 100644 index 8898eaecdb5..00000000000 --- a/src/relay/git-response-stream-retention.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { createHash } from 'node:crypto' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { GitResponseStreamRegistry } from './git-response-stream' - -async function collect(): Promise { - if (typeof globalThis.gc !== 'function') { - throw new Error('Run with the repository Vitest --expose-gc config') - } - for (let round = 0; round < 4; round++) { - await new Promise((resolve) => setImmediate(resolve)) - globalThis.gc() - } -} - -class DigestingGitDispatcher extends RelayDispatcher { - readonly digest = createHash('sha256') - chunksSeen = 0 - ended = false - - constructor(private readonly registry: GitResponseStreamRegistry) { - super(() => true) - } - - override async notifyBulk(method: string, params?: Record): Promise { - if (method === 'git.responseEnd') { - this.ended = true - return - } - if ( - method !== 'git.responseChunk' || - typeof params?.streamId !== 'number' || - typeof params.seq !== 'number' || - typeof params.data !== 'string' - ) { - throw new Error('Expected a complete Git response chunk') - } - this.digest.update(Buffer.from(params.data, 'base64')) - this.chunksSeen++ - if (params.seq < 54) { - this.registry.recordAck(params.streamId, params.seq, 1) - } - } -} - -function startLargeReply(registry: GitResponseStreamRegistry, dispatcher: RelayDispatcher) { - const result = { stdout: Buffer.alloc(9 * 1024 * 1024, 120).toString('utf8'), stderr: '' } - const payload = Buffer.from(JSON.stringify(result), 'utf8') - return { - marker: registry.startStream(payload, dispatcher, { clientId: 1, isStale: () => false }), - digest: createHash('sha256').update(payload).digest('hex') - } -} - -afterEach(() => vi.restoreAllMocks()) - -describe('Git response consumed chunk ownership', () => { - it('releases sent copies while an acknowledged large reply waits for its remaining credits', async () => { - const registry = new GitResponseStreamRegistry() - const dispatcher = new DigestingGitDispatcher(registry) - try { - await collect() - const baseline = process.memoryUsage().heapUsed - const { marker, digest } = startLargeReply(registry, dispatcher) - await collect() - - expect(dispatcher.chunksSeen).toBe(58) - expect(dispatcher.ended).toBe(false) - expect(process.memoryUsage().heapUsed - baseline).toBeLessThan(6 * 1024 * 1024) - - registry.recordAck(marker.__orcaGitResponseStream.streamId, Number.MAX_SAFE_INTEGER, 1) - await collect() - expect(dispatcher.chunksSeen).toBe(marker.__orcaGitResponseStream.chunkCount) - expect(dispatcher.ended).toBe(true) - expect(dispatcher.digest.digest('hex')).toBe(digest) - } finally { - registry.disposeAll() - await new Promise((resolve) => setImmediate(resolve)) - dispatcher.dispose() - } - }) - - it('preserves queued chunk values and the admission snapshot through a blocked sink', async () => { - const registry = new GitResponseStreamRegistry() - const dispatcher = new RelayDispatcher(() => true) - let releaseWrite: () => void = () => {} - const blockedWrite = new Promise((resolve) => { - releaseWrite = resolve - }) - const notifyBulk = vi - .spyOn(dispatcher, 'notifyBulk') - .mockImplementationOnce(() => blockedWrite) - .mockResolvedValue(undefined) - vi.spyOn(dispatcher, 'producerDataBudget').mockReturnValue(8) - try { - const payload = Buffer.from('A😀Bé\0end', 'utf8') - const expected = Buffer.from(payload) - const marker = registry.startStream(payload, dispatcher, { - clientId: 1, - isStale: () => false - }) - payload.fill(120) - await new Promise((resolve) => setImmediate(resolve)) - expect(notifyBulk).toHaveBeenCalledOnce() - registry.recordAck(marker.__orcaGitResponseStream.streamId, Number.MAX_SAFE_INTEGER, 1) - releaseWrite() - await new Promise((resolve) => setImmediate(resolve)) - const parts = notifyBulk.mock.calls.flatMap(([method, params]) => - method === 'git.responseChunk' && typeof params?.data === 'string' - ? [Buffer.from(params.data, 'base64')] - : [] - ) - expect(Buffer.concat(parts)).toEqual(expected) - expect(notifyBulk.mock.calls.map(([method]) => method)).toEqual([ - 'git.responseChunk', - 'git.responseChunk', - 'git.responseEnd' - ]) - } finally { - releaseWrite() - registry.disposeAll() - await new Promise((resolve) => setImmediate(resolve)) - dispatcher.dispose() - } - }) -}) diff --git a/src/relay/git-response-stream-shutdown.test.ts b/src/relay/git-response-stream-shutdown.test.ts deleted file mode 100644 index bb05a79f2c0..00000000000 --- a/src/relay/git-response-stream-shutdown.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from '../wsl-guest/protocol' - -const context: RequestContext = { clientId: 7, isStale: () => false } - -async function flushPump(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('GitResponseStreamRegistry shutdown', () => { - const registries: GitResponseStreamRegistry[] = [] - - function fixture(): { - registry: GitResponseStreamRegistry - dispatcher: RelayDispatcher - notifyBulk: ReturnType - } { - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const notifyBulk = vi.fn().mockResolvedValue(undefined) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the response pump only calls notifyBulk (producerDataBudget is optional). - return { registry, notifyBulk, dispatcher: { notifyBulk } as unknown as RelayDispatcher } - } - - afterEach(async () => { - await Promise.all(registries.splice(0).map((registry) => registry.disposeAllAndWait())) - }) - - it('drains a scheduled producer without publishing any frames', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream(Buffer.from('payload'), dispatcher, context) - - await registry.disposeAllAndWait() - await flushPump() - - expect(notifyBulk).not.toHaveBeenCalled() - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await registry.disposeAllAndWait() - }) - - it('waits for an in-flight final chunk and never publishes responseEnd after abort', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('one chunk'), dispatcher, context) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(1) - - let drained = false - const firstDrain = registry.disposeAllAndWait().then(() => { - drained = true - }) - const secondDrain = registry.disposeAllAndWait() - try { - await flushPump() - expect(drained).toBe(false) - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - } finally { - write.resolve() - await Promise.all([firstDrain, secondDrain]) - } - - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('rejects, so shutdown defers, when a pump stays parked past the drain deadline', async () => { - const registry = new GitResponseStreamRegistry(20) - const parked = deferred() - const notifyBulk = vi.fn(() => parked.promise) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the response pump only calls notifyBulk (producerDataBudget is optional). - const dispatcher = { notifyBulk } as unknown as RelayDispatcher - registry.startStream(Buffer.from('one chunk'), dispatcher, context) - await flushPump() - - await expect(registry.disposeAllAndWait()).rejects.toThrow( - 'relay_response_stream_operations_unsettled' - ) - parked.resolve() - await registry.disposeAllAndWait() - }) - - it('wakes producers parked on client ACKs without waiting for the stall timer', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream( - Buffer.alloc(GIT_RESPONSE_CHUNK_SIZE * (STREAM_ACK_WINDOW_CHUNKS + 1)), - dispatcher, - context - ) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - await flushPump() - - expect(drained).toBe(true) - await drain - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - expect(notifyBulk.mock.calls.every(([method]) => method === 'git.responseChunk')).toBe(true) - }) - - it('waits for an already-published responseEnd to settle', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const endWrite = deferred() - notifyBulk.mockResolvedValueOnce(undefined).mockImplementationOnce(() => endWrite.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - expect(notifyBulk.mock.calls.map(([method]) => method)).toEqual([ - 'git.responseChunk', - 'git.responseEnd' - ]) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - await flushPump() - expect(drained).toBe(false) - } finally { - endWrite.resolve() - await drain - } - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(2) - }) - - it('suppresses error publication when an aborted in-flight write rejects', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - let rejectWrite!: (error: Error) => void - notifyBulk.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectWrite = reject - }) - ) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - - const drain = registry.disposeAllAndWait() - rejectWrite(new Error('channel closed during shutdown')) - await drain - - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('synchronous disposal also permanently fences admission and retains the pending drain', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - registry.disposeAll() - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await flushPump() - expect(drained).toBe(false) - } finally { - write.resolve() - await drain - } - - expect(notifyBulk).toHaveBeenCalledTimes(1) - await registry.disposeAllAndWait() - }) -}) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts deleted file mode 100644 index 86c0f9f986c..00000000000 --- a/src/relay/git-response-stream.ts +++ /dev/null @@ -1,247 +0,0 @@ -// Streams large RPC responses onto the bulk lane in chunks instead of one -// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data -// echo on the shared SSH channel. Shares RelayStreamAckWindow with the fs -// read stream (see fs-handler-file-read.ts) but the payload is an in-memory -// serialized string rather than a file handle. -// -// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire -// spelling and are permanent, the way an opcode number is, so a second handler -// that needs streaming (`fs.listFiles` is the first) shares this instance rather -// than minting its own. A second registry is not an option: a client keys -// reassembly on `streamId` alone, so two would hand out the same id and -// cross-feed each other's chunks, and only the handler that registers -// `git.responseAck` can credit the ack window a pump parks on — the other's -// streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See -// `relay-runtime-services.ts` for the wiring. -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { - GIT_RESPONSE_CHUNK_SIZE, - GIT_RESPONSE_STREAM_THRESHOLD, - STREAM_ACK_WINDOW_CHUNKS, - type GitResponseStreamMarker -} from '../wsl-guest/protocol' -import { settlesWithin } from './settles-within' -import { RelayStreamAckWindow } from './relay-stream-ack-window' -import { errorMessage } from '../shared/error-message' - -type GitResponseStreamEntry = { ownerClientId: number; ack: RelayStreamAckWindow } - -/** Serialized git responses are chunked as base64 so multi-byte UTF-8 - * sequences never split across a chunk boundary (the client concatenates the - * decoded bytes and parses once). */ -function encodeChunks(payload: Buffer, chunkBytes = GIT_RESPONSE_CHUNK_SIZE): string[] { - const chunks: string[] = [] - for (let offset = 0; offset < payload.length; offset += chunkBytes) { - chunks.push(payload.subarray(offset, offset + chunkBytes).toString('base64')) - } - return chunks -} - -// Why: a pump parked on a stalled but connected client's bulk lane is not woken by abort. -const PUMP_DRAIN_DEADLINE_MS = 10_000 - -export class GitResponseStreamRegistry { - private streams = new Map() - private nextId = 1 - private disposed = false - private readonly pendingPumps = new Set>() - - constructor(private readonly pumpDrainDeadlineMs = PUMP_DRAIN_DEADLINE_MS) {} - - private register(ownerClientId: number): number { - if (this.disposed) { - throw new Error('relay_response_stream_shutdown_fenced') - } - const streamId = this.nextId++ - this.streams.set(streamId, { ownerClientId, ack: new RelayStreamAckWindow() }) - return streamId - } - - recordAck(streamId: number, seq: number, clientId: number): void { - const entry = this.streams.get(streamId) - if (!entry || entry.ownerClientId !== clientId) { - return - } - entry.ack.recordAck(seq) - } - - abort(streamId: number, clientId: number): void { - const entry = this.streams.get(streamId) - if (entry?.ownerClientId === clientId) { - entry.ack.abort() - } - } - - /** Wake every parked pump so it re-checks staleness — used when a client - * detaches and its acks will never arrive. */ - wakeAll(): void { - for (const entry of this.streams.values()) { - entry.ack.wake() - } - } - - /** - * Register a stream for `payload`, kick off the bulk-lane pump on a later - * task (so the sentinel response reaches the client first), and return the - * sentinel marker to send as the RPC result. - */ - startStream( - payload: Buffer, - dispatcher: RelayDispatcher, - context: RequestContext - ): GitResponseStreamMarker { - const streamId = this.register(context.clientId) - const base64Budget = - dispatcher.producerDataBudget?.( - 'git.responseChunk', - { streamId, seq: payload.length }, - context.clientId - ) ?? Number.MAX_SAFE_INTEGER - const sinkChunkBytes = Math.floor(Math.max(0, base64Budget) / 4) * 3 - if (sinkChunkBytes === 0) { - this.streams.delete(streamId) - throw new Error('Git response stream has no encoded producer capacity') - } - const chunks = encodeChunks(payload, Math.min(GIT_RESPONSE_CHUNK_SIZE, sinkChunkBytes)) - // Why: kick the pump off the response task so the client sees the sentinel - // (and can subscribe/reassemble) before the first chunk frame arrives. - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let finish!: () => void - const completion = new Promise((resolve) => { - finish = () => { - this.pendingPumps.delete(completion) - resolve() - } - }) - this.pendingPumps.add(completion) - setImmediate(() => { - void this.pump(streamId, chunks, dispatcher, context).then(finish, finish) - }) - return { - __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } - } - } - - private async pump( - streamId: number, - chunks: string[], - dispatcher: RelayDispatcher, - context: RequestContext - ): Promise { - const entry = this.streams.get(streamId) - if (!entry) { - return - } - const clientId = context.clientId - let seq = 0 - let endReason: 'end' | 'aborted' | 'stale' = 'end' - try { - for (seq = 0; seq < chunks.length; seq += 1) { - if (context.isStale()) { - endReason = 'stale' - break - } - if (entry.ack.aborted) { - endReason = 'aborted' - break - } - // Why: credit window — the client acks each chunk, bounding how many - // bulk bytes a keystroke echo can queue behind on the shared channel. - while ( - seq - entry.ack.ackedThroughSeq > STREAM_ACK_WINDOW_CHUNKS && - !context.isStale() && - !entry.ack.aborted - ) { - await entry.ack.wait() - } - if (context.isStale()) { - endReason = 'stale' - break - } - if (entry.ack.aborted) { - endReason = 'aborted' - break - } - // Why: notifyBulk waits out sink saturation so chunk frames never pile - // up in the outbound pipe ahead of interactive pty.data frames. - await dispatcher.notifyBulk( - 'git.responseChunk', - { streamId, seq, data: chunks[seq] }, - { - clientId - } - ) - // Sent chunks are never retried; ACK waits must not retain their encoded copies. - chunks[seq] = '' - } - // Why: disposal may abort while the final chunk write is in flight. - if (endReason === 'end' && !entry.ack.aborted && !context.isStale()) { - await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId }) - } - } catch (err) { - if (!context.isStale() && !entry.ack.aborted) { - try { - await dispatcher.notifyBulk( - 'git.responseError', - { - streamId, - message: errorMessage(err) - }, - { clientId } - ) - } catch { - // Why: the original failure may be the owning channel closing; a - // second send failure must not escape this detached pump. - } - } - } finally { - this.streams.delete(streamId) - } - } - - /** Fences new streams until {@link reopen} and aborts every pump; disposeAllAndWait awaits them. */ - disposeAll(): void { - this.disposed = true - for (const entry of this.streams.values()) { - entry.ack.abort() - } - this.streams.clear() - } - - async disposeAllAndWait(): Promise { - this.disposeAll() - if (!(await settlesWithin(Promise.all(this.pendingPumps), this.pumpDrainDeadlineMs))) { - throw new Error('relay_response_stream_operations_unsettled') - } - } - - reopen(): void { - this.disposed = false - } -} - -/** - * Opt-in response streaming, shared by every handler that can answer with a - * payload too large for one control-lane frame. - * - * `__streamResponse` is its own negotiation in both directions: an old client - * never sends it and gets the plain result, and an old relay ignores it and - * answers plainly, which the client detects by the sentinel marker being absent. - * So there is no new method and no capability to advertise. - */ -export function maybeStreamRpcResponse( - result: unknown, - params: Record, - context: RequestContext | undefined, - registry: GitResponseStreamRegistry, - dispatcher: RelayDispatcher -): unknown { - if (params.__streamResponse !== true || !context) { - return result - } - const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') - if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { - return result - } - return registry.startStream(payload, dispatcher, context) -} diff --git a/src/relay/git-review-draft-binary-compatibility.test.ts b/src/relay/git-review-draft-binary-compatibility.test.ts deleted file mode 100644 index 451da5d9106..00000000000 --- a/src/relay/git-review-draft-binary-compatibility.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from './git-handler-test-harness' - -const binary = process.env.ORCA_GIT_COMPAT_BINARY -const image = process.env.ORCA_GIT_COMPAT_IMAGE -const expectedVersion = process.env.ORCA_GIT_COMPAT_VERSION -const dockerUser = - typeof process.getuid === 'function' && typeof process.getgid === 'function' - ? ['--user', `${process.getuid()}:${process.getgid()}`] - : [] - -describe.skipIf(!binary && !image)('review draft real Git compatibility', () => { - let fixturePath = '' - let fixtureCwd = '' - - async function git(args: string[]): Promise<{ stdout: string; stderr: string }> { - const result = await runProcess({ - program: image ? 'docker' : (binary ?? 'git'), - args: image - ? [ - 'run', - '--rm', - '--network=none', - ...dockerUser, - '-v', - `${fixturePath}:/repo`, - '-w', - '/repo', - image, - '-c', - 'safe.directory=/repo', - ...args - ] - : args, - cwd: fixturePath, - env: { - PATH: process.env.PATH, - SystemRoot: process.env.SystemRoot, - GIT_EXEC_PATH: process.env.GIT_EXEC_PATH, - GIT_CONFIG_NOSYSTEM: '1', - GIT_TERMINAL_PROMPT: '0' - }, - timeoutMs: 30_000, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (result.code !== 0 || result.signal || result.timedOut || result.outputTruncated) { - throw new Error(result.stderr || 'Git compatibility command failed.') - } - return { stdout: result.stdout, stderr: result.stderr } - } - - beforeAll(async () => { - fixturePath = createGitTempDir() - fixtureCwd = image ? '/repo' : fixturePath - expect((await git(['--version'])).stdout).toContain(`git version ${expectedVersion}`) - }) - afterAll(async () => { - if (fixturePath) { - await removeGitTempDir(fixturePath) - } - }) - it('reads complete review drafts through the relay fixed diff formats', async () => { - await git(['init', '-q']) - await git(['config', 'user.name', 'Review Compatibility']) - await git(['config', 'user.email', 'review@example.invalid']) - await writeFile(join(fixturePath, 'evidence.txt'), 'before\n') - await git(['add', 'evidence.txt']) - await git(['commit', '-qm', 'initial']) - const mergeBase = (await git(['rev-parse', 'HEAD'])).stdout.trim() - await writeFile(join(fixturePath, 'evidence.txt'), 'after\n') - await git(['commit', '-qam', 'review evidence']) - const before = (await git(['status', '--porcelain'])).stdout - const { dispatcher, handler } = createGitHandlerRelay() - Object.assign(handler, { git }) - try { - await expect( - dispatcher.callRequest('git.reviewDiff', { - worktreePath: fixtureCwd, - mergeBase, - format: 'name-status' - }) - ).resolves.toEqual({ stdout: 'M\tevidence.txt\n', stderr: '' }) - const patch = await dispatcher.callRequest('git.reviewDiff', { - worktreePath: fixtureCwd, - mergeBase, - format: 'patch' - }) - expect(patch).toMatchObject({ stdout: expect.stringContaining('-before\n+after\n') }) - expect((await git(['status', '--porcelain'])).stdout).toBe(before) - } finally { - handler.dispose() - } - }) -}) diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts deleted file mode 100644 index 35146c3249e..00000000000 --- a/src/relay/git-status-branch-line-total.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -/** - * Relay-side contract for the status pass's branch line total: the chip is an - * exact number or absent, and costs nothing when nobody asked for it. - */ -import { execFile, execFileSync } from 'node:child_process' -import { mkdtempSync } from 'node:fs' -import * as fs from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { promisify } from 'node:util' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - invalidateGitBranchLineTotalInFlight, - isGitBranchLineTotalMergeBase -} from '../shared/git-branch-line-total' -import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' -import type { GitExec } from './git-handler-ops' -import { getStatusOp } from './git-handler-status-ops' -import type { RelayGitStreamExec } from './git-stdout-stream' -import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' - -type GitCall = Parameters - -const execFileAsync = promisify(execFile) -const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567' -const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' -// Untracked additions ride on top of the ranged diff, so the fixture needs a real file. -const UNTRACKED_FILE = 'notes.md' -const UNTRACKED_LINES = 4 -// No fixture path here looks like test or generated code, so it is all source. -const NO_LINES = { added: 0, removed: 0 } -const STATUS_OUTPUT = [ - '# branch.oid 1111111111111111111111111111111111111111', - '# branch.head (detached)', - '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', - `? ${UNTRACKED_FILE}` -].join('\n') - -function isRangedNumstat(args: string[]): boolean { - return ( - args.includes('diff') && - args.includes('--numstat') && - args.includes('-z') && - isGitBranchLineTotalMergeBase(args.at(-2)) - ) -} - -function rangedDiffCalls(calls: readonly GitCall[]): string[][] { - return calls.map(([args]) => args).filter((args) => isRangedNumstat(args)) -} - -function streamGitFromCapture(git: GitExec): RelayGitStreamExec { - return async (args, cwd, options) => { - const { stdout } = await git(args, cwd, { - disableOptionalLocks: options.disableOptionalLocks, - signal: options.signal - }) - return { stoppedEarly: options.onStdout(stdout) === true } - } -} - -/** Mock host; the fixture-repo cases below run real git instead. */ -function createMockGit(overrides: { - status?: string - ranged?: () => Promise<{ stdout: string; stderr: string }> - areaNumstat?: string -}) { - return vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' } - } - if (isRangedNumstat(args)) { - return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\0', stderr: '' } - } - if (args.includes('diff')) { - return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\0', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) -} - -const realGitExec: GitExec = async (args, cwd, opts) => { - const { stdout, stderr } = await execFileAsync('git', args, { - cwd, - encoding: 'utf8', - ...(opts?.signal ? { signal: opts.signal } : {}), - ...(opts?.timeout ? { timeout: opts.timeout } : {}) - }) - return { stdout, stderr } -} - -function runFixtureGit(repo: string, args: string[]): string { - return execFileSync( - 'git', - [ - '-c', - 'user.email=test@test.com', - '-c', - 'user.name=Test', - '-c', - 'commit.gpgSign=false', - ...args - ], - { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } - ).trim() -} - -/** - * Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the - * file the branch deleted, +4 untracked. The pure rename contributes nothing. - */ -async function seedBranchFixture(repo: string): Promise { - await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true }) - execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) - await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n') - await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n') - await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n') - runFixtureGit(repo, ['add', '.']) - runFixtureGit(repo, ['commit', '-m', 'base']) - const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) - - runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) - await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n') - await fs.rm(path.join(repo, 'doomed.txt')) - runFixtureGit(repo, ['add', '-A']) - runFixtureGit(repo, ['commit', '-m', 'branch commit']) - - runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt']) - await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n') - await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') - return mergeBase -} - -describe('getStatusOp branch line total', () => { - let tmpDir: string - - beforeEach(async () => { - clearNoEffectiveUpstreamStatusCache() - clearGitStatusLineStatsCache() - invalidateGitBranchLineTotalInFlight() - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-')) - await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') - }) - - afterEach(async () => { - clearNoEffectiveUpstreamStatusCache() - clearGitStatusLineStatsCache() - invalidateGitBranchLineTotalInFlight() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - it('runs no ranged diff and returns no total when the merge base param is absent', async () => { - const git = createMockGit({}) - - const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) - - expect(rangedDiffCalls(git.mock.calls)).toEqual([]) - expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) - expect(result.branchLineTotal).toBeUndefined() - // The per-area numstats the CHANGES rows need still ran, so this is a - // zero-cost omission rather than a disabled status pass. - expect(result.entries).toContainEqual( - expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) - ) - }) - - it('sums the ranged diff and untracked additions against a real fixture repo', async () => { - const mergeBase = await seedBranchFixture(tmpDir) - const git = vi.fn(realGitExec) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: mergeBase - }) - - expect(result.branchLineTotal).toEqual({ - added: 3 + UNTRACKED_LINES, - removed: 2, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - expect(rangedDiffCalls(git.mock.calls)).toEqual([ - [ - '-c', - 'core.quotePath=false', - '-c', - 'diff.autoRefreshIndex=false', - 'diff', - '-z', - '--numstat', - '-M', - mergeBase, - '--' - ] - ]) - }) - - it('omits the total when the status listing hit its limit', async () => { - const manyEntries = Array.from( - { length: 6 }, - (_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt` - ).join('\n') - const git = createMockGit({ status: manyEntries }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - limit: 3, - branchLineTotalMergeBase: MERGE_BASE - }) - - expect(result.didHitLimit).toBe(true) - // The untracked list is truncated, so any total would silently under-count. - expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) - expect(rangedDiffCalls(git.mock.calls)).toEqual([]) - }) - - it('omits the total, never zero, when the ranged diff fails', async () => { - const git = createMockGit({ - ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef')) - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - - expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) - expect(result.branchLineTotal).toBeUndefined() - expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) - // A failed ranged diff must not take the per-file rows down with it. - expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 })) - }) - - it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => { - await seedBranchFixture(tmpDir) - const git = vi.fn(realGitExec) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' - }) - - expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) - expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) - expect(result.entries.length).toBeGreaterThan(0) - }) - - it('omits the total when the ranged diff exceeds its time budget', async () => { - const git = createMockGit({ - ranged: () => { - const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT') - error.killed = true - return Promise.reject(error) - } - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - - expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) - expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) - // The budget is handed to the subprocess rather than raced on a wall clock. - const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2] - expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) }) - }) - - it('omits the total when the status scan itself failed', async () => { - const git = vi.fn(async (args) => { - if (args.includes('status')) { - throw new Error('fatal: not a git repository') - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - - expect(result.entries).toEqual([]) - // A failed scan yields an empty untracked list, which would read as "no - // untracked additions" rather than "unknown". - expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) - expect(rangedDiffCalls(git.mock.calls)).toEqual([]) - }) - - // Relay params are an untyped bag, so the merge base must be proven to be an - // object name before it can be spliced into an argv. - it.each([ - ['a flag-shaped value', '--upload-pack=x'], - ['a rev name', 'HEAD'], - ['a ref path', 'refs/heads/main'], - ['a range', `${MERGE_BASE}..HEAD`], - ['a number', 123], - ['null', null], - ['an empty string', ''] - ])('rejects %s before it reaches a git argv', async (_label, value) => { - const git = createMockGit({}) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: value - }) - - expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) - expect(rangedDiffCalls(git.mock.calls)).toEqual([]) - for (const [args] of git.mock.calls) { - expect(args).not.toContain(String(value)) - } - }) - - it('answers a rejected merge base with the byte-identical no-merge-base response', async () => { - const gitWithout = createMockGit({}) - const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), { - worktreePath: tmpDir - }) - clearGitStatusLineStatsCache() - const gitRejected = createMockGit({}) - const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), { - worktreePath: tmpDir, - branchLineTotalMergeBase: '--upload-pack=x' - }) - - expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam)) - expect(gitRejected.mock.calls.map(([args]) => args)).toEqual( - gitWithout.mock.calls.map(([args]) => args) - ) - }) - - it('rejects an aborted scan instead of resolving a partial total', async () => { - const controller = new AbortController() - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: STATUS_OUTPUT, stderr: '' } - } - if (isRangedNumstat(args)) { - controller.abort() - const error = new Error('The operation was aborted.') - error.name = 'AbortError' - throw error - } - if (args.includes('diff')) { - return { stdout: '3\t2\tsrc/a.ts\0', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - await expect( - getStatusOp( - git, - streamGitFromCapture(git), - { worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE }, - { signal: controller.signal } - ) - ).rejects.toThrow(/abort/i) - expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) - }) - - it('reuses the cached total instead of re-running the ranged diff', async () => { - const git = createMockGit({}) - - const first = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - const reused = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE, - reuseLineStats: true - }) - - expect(first.branchLineTotal).toEqual({ - added: 12 + UNTRACKED_LINES, - removed: 5, - mergeBase: MERGE_BASE, - test: NO_LINES, - generated: NO_LINES - }) - expect(reused.branchLineTotal).toEqual(first.branchLineTotal) - expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) - }) - - it('coalesces concurrent status passes onto one ranged diff', async () => { - // The renderer's own in-flight refs only dedupe one renderer; a second - // window or an fs-watcher burst must not run the ranged diff twice. - const git = vi.fn(async (args) => { - if (args.includes('status')) { - return { stdout: STATUS_OUTPUT, stderr: '' } - } - if (isRangedNumstat(args)) { - await new Promise((resolve) => setTimeout(resolve, 20)) - return { stdout: '12\t5\tsrc/a.ts\0', stderr: '' } - } - if (args.includes('diff')) { - return { stdout: '3\t2\tsrc/a.ts\0', stderr: '' } - } - throw new Error(`Unexpected git command: ${args.join(' ')}`) - }) - - const [first, second] = await Promise.all([ - getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }), - getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - ]) - - expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) - expect(first.branchLineTotal).toEqual(second.branchLineTotal) - expect(first.branchLineTotal).toEqual({ - added: 12 + UNTRACKED_LINES, - removed: 5, - mergeBase: MERGE_BASE, - test: NO_LINES, - generated: NO_LINES - }) - }) - - it('recomputes rather than reusing a total measured against another fork point', async () => { - const git = createMockGit({}) - - await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - const moved = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: OTHER_MERGE_BASE, - reuseLineStats: true - }) - - expect(moved.branchLineTotal).toEqual({ - added: 12 + UNTRACKED_LINES, - removed: 5, - mergeBase: OTHER_MERGE_BASE, - test: NO_LINES, - generated: NO_LINES - }) - expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) - }) - - // Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field - // is safe only while every reader survives its absence. - describe('wire compatibility', () => { - it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => { - const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - // An old server's payload and a new server's "not known exact" payload are - // the same thing on the wire: no key at all. - const overTheWire = JSON.parse(JSON.stringify(result)) as { - branchLineTotal?: { added: number; removed: number } - } - - expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false) - expect(overTheWire.branchLineTotal).toBeUndefined() - expect(overTheWire.branchLineTotal?.added).toBeUndefined() - expect(overTheWire.branchLineTotal?.removed).toBeUndefined() - }) - - it('keeps the new request param out of the status argv an old server would run', async () => { - const git = createMockGit({}) - - await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - - const statusArgs = git.mock.calls - .map(([args]) => args) - .filter((args) => args.includes('status')) - expect(statusArgs).toHaveLength(1) - expect(statusArgs[0]).not.toContain(MERGE_BASE) - }) - - it('publishes finite counts when the ranged diff reports only binaries', async () => { - const git = createMockGit({ - ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' }) - }) - - const result = await getStatusOp(git, streamGitFromCapture(git), { - worktreePath: tmpDir, - branchLineTotalMergeBase: MERGE_BASE - }) - - expect(result.branchLineTotal).toEqual({ - added: UNTRACKED_LINES, - removed: 0, - mergeBase: MERGE_BASE, - test: NO_LINES, - generated: NO_LINES - }) - expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) - expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) - }) - }) -}) diff --git a/src/relay/git-status-branch-line-total.ts b/src/relay/git-status-branch-line-total.ts deleted file mode 100644 index c2fe6556a0e..00000000000 --- a/src/relay/git-status-branch-line-total.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Relay-side wiring for the status pass's branch line total. - * Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300). - */ -import { - computeGitBranchLineTotal, - GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, - type GitBranchLineTotal -} from '../shared/git-branch-line-total' -import type { GitExec } from './git-handler-ops' - -/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */ -export function buildBranchLineTotalInput( - git: GitExec, - worktreePath: string, - entries: Record[], - mergeBase: string | undefined, - signal?: AbortSignal -): { mergeBase: string; compute: () => Promise } | undefined { - if (!mergeBase) { - return undefined - } - return { - mergeBase, - compute: () => - computeGitBranchLineTotal({ - worktreePath, - // Matches the `relay\0` line-stats cache-key convention. - hostKey: 'relay', - mergeBase, - untrackedPaths: entries - .filter((entry) => entry.area === 'untracked') - .map((entry) => entry.path as string), - runDiffNumstat: (args, diffSignal) => - git(args, worktreePath, { - // Why: a working-tree diff must not take index.lock away from terminal Git. - disableOptionalLocks: true, - signal: diffSignal, - timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS - }).then(({ stdout }) => stdout), - ...(signal ? { signal } : {}) - }) - } -} diff --git a/src/relay/git-status-upstream-negative-cache.test.ts b/src/relay/git-status-upstream-negative-cache.test.ts deleted file mode 100644 index c69fa11e4e8..00000000000 --- a/src/relay/git-status-upstream-negative-cache.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - clearNoEffectiveUpstreamStatusCache, - clearNoEffectiveUpstreamStatusCacheEntry, - getNoEffectiveUpstreamStatusCacheCountForTests, - getNoEffectiveUpstreamStatusGenerationCountForTests, - readOrProbeNoEffectiveUpstreamStatus -} from './git-status-upstream-negative-cache' - -function isConfigListSnapshotCommand(args: string[]): boolean { - return args[0] === 'config' && args[1] === '--list' && args[2] === '-z' -} - -function emptyGitConfigSnapshot(): { stdout: string } { - return { stdout: 'core.repositoryformatversion\n0\0' } -} - -describe('relay upstream negative cache', () => { - beforeEach(() => { - clearNoEffectiveUpstreamStatusCache() - }) - - afterEach(() => { - clearNoEffectiveUpstreamStatusCache() - }) - - it('bypasses cached no-effective-upstream status when requested', async () => { - let originBranchExists = false - const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature') - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - if (originBranchExists) { - return { stdout: 'abc123\n' } - } - throw new Error('missing remote branch') - } - if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { - return { stdout: '0\t1\n' } - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - const identity = { worktreePath: '/repo', branchName: 'feature' } - - const first = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - originBranchExists = true - const automatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { - bypassCache: true - }) - - expect(first).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - expect(automatic).toEqual(first) - expect(strict).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 0, - behind: 1 - }) - }) - - it('keeps an older automatic negative probe from overwriting a strict positive result', async () => { - let originBranchExists = false - let deferredOriginReject: ((error: Error) => void) | null = null - const runGit = vi.fn(async (args: string[]) => { - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature') - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - if (originBranchExists) { - return { stdout: 'abc123\n' } - } - return await new Promise<{ stdout: string }>((_, reject) => { - deferredOriginReject = reject - }) - } - if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { - return { stdout: '0\t1\n' } - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - const identity = { worktreePath: '/repo', branchName: 'feature' } - - const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) - - originBranchExists = true - const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { - bypassCache: true - }) - if (!deferredOriginReject) { - throw new Error('expected deferred origin reject') - } - ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) - const staleAutomatic = await automatic - const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - - expect(strict).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 0, - behind: 1 - }) - expect(staleAutomatic).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - expect(nextAutomatic).toEqual(strict) - }) - - it('does not trim generation for an unresolved automatic probe', async () => { - let originBranchExists = false - let deferredOriginReject: ((error: Error) => void) | null = null - const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature') - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - if (originBranchExists) { - return { stdout: 'abc123\n' } - } - return await new Promise<{ stdout: string }>((_, reject) => { - deferredOriginReject = reject - }) - } - if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { - return { stdout: '0\t1\n' } - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - const identity = { worktreePath: '/repo', branchName: 'feature' } - - const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) - - originBranchExists = true - const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { - bypassCache: true - }) - for (let index = 0; index < 512; index += 1) { - const branchName = `other-${index}` - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath: '/repo', branchName }, - async (args) => { - if (args[0] === 'symbolic-ref') { - return { stdout: `${branchName}\n` } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error(`fatal: no upstream configured for branch ${branchName}`) - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { - return { stdout: 'abc123\n' } - } - if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { - return { stdout: '0\t1\n' } - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }, - { bypassCache: true } - ) - } - if (!deferredOriginReject) { - throw new Error('expected deferred origin reject') - } - ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) - await automatic - const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - - expect(strict).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 0, - behind: 1 - }) - expect(nextAutomatic).toEqual(strict) - expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) - }) - - it('does not trim generation for a cleared automatic probe before it settles', async () => { - let originBranchExists = false - let deferredOriginReject: ((error: Error) => void) | null = null - const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature') - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - if (originBranchExists) { - return { stdout: 'abc123\n' } - } - return await new Promise<{ stdout: string }>((_, reject) => { - deferredOriginReject = reject - }) - } - if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { - return { stdout: '0\t1\n' } - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - const identity = { worktreePath: '/repo', branchName: 'feature' } - - const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) - - originBranchExists = true - clearNoEffectiveUpstreamStatusCacheEntry(identity) - for (let index = 0; index < 512; index += 1) { - const branchName = `other-${index}` - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath: '/repo', branchName }, - async (args) => { - if (args[0] === 'symbolic-ref') { - return { stdout: `${branchName}\n` } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error(`fatal: no upstream configured for branch ${branchName}`) - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { - return { stdout: 'abc123\n' } - } - if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { - return { stdout: '0\t1\n' } - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }, - { bypassCache: true } - ) - } - if (!deferredOriginReject) { - throw new Error('expected deferred origin reject') - } - ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) - await automatic - const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) - - expect(nextAutomatic).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 0, - behind: 1 - }) - expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) - }) - - it('bounds no-effective-upstream entries', async () => { - for (let index = 0; index < 513; index += 1) { - const branchName = `feature-${index}` - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath: '/repo', branchName }, - async (args) => { - if (args[0] === 'symbolic-ref') { - return { stdout: `${branchName}\n` } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error(`fatal: no upstream configured for branch ${branchName}`) - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { - throw new Error('missing remote branch') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - } - ) - } - - expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(512) - expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) - }) - - it('bounds write-generation entries from positive strict probes', async () => { - for (let index = 0; index < 513; index += 1) { - const branchName = `feature-${index}` - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath: '/repo', branchName }, - async (args) => { - if (args[0] === 'symbolic-ref') { - return { stdout: `${branchName}\n` } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error(`fatal: no upstream configured for branch ${branchName}`) - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { - return { stdout: 'abc123\n' } - } - if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { - return { stdout: '0\t1\n' } - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }, - { bypassCache: true } - ) - } - - expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(0) - expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) - }) - - it('coalesces no-upstream config reads into one snapshot subprocess', async () => { - const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n' } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error('fatal: no upstream configured for branch feature') - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { - throw new Error('missing remote branch') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - - const status = await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath: '/repo', branchName: 'feature' }, - runGit - ) - const configListCalls = runGit.mock.calls.filter((call) => - isConfigListSnapshotCommand(call[0] as string[]) - ) - const configGetCalls = runGit.mock.calls.filter((call) => { - const args = call[0] as string[] - return args[0] === 'config' && args[1] === '--get' - }) - - expect(configListCalls).toHaveLength(1) - expect(configGetCalls).toHaveLength(0) - expect(status.hasUpstream).toBe(false) - }) -}) diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts deleted file mode 100644 index b598393314a..00000000000 --- a/src/relay/git-status-upstream-negative-cache.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' -import type { GitCommandRunner } from '../shared/git-effective-upstream' -import type { GitUpstreamStatus } from '../shared/git-status-types' - -const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000 -const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512 - -type NoEffectiveUpstreamCacheIdentity = { - worktreePath: string - branchName: string - upstreamName?: string -} - -type NoEffectiveUpstreamCacheEntry = { - status: GitUpstreamStatus - expiresAt: number -} - -const noEffectiveUpstreamByIdentity = new Map() -const noEffectiveUpstreamInFlight = new Map>() -const retiredNoEffectiveUpstreamInFlight = new Map>() -const noEffectiveUpstreamWriteGeneration = new Map() - -function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string { - return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') -} - -function readCachedNoEffectiveUpstreamStatus( - cacheKey: string, - nowMs = Date.now() -): GitUpstreamStatus | null { - const entry = noEffectiveUpstreamByIdentity.get(cacheKey) - if (!entry) { - return null - } - if (entry.expiresAt <= nowMs) { - noEffectiveUpstreamByIdentity.delete(cacheKey) - return null - } - return entry.status -} - -function hasPendingNoEffectiveUpstreamProbe(cacheKey: string): boolean { - return ( - noEffectiveUpstreamInFlight.has(cacheKey) || retiredNoEffectiveUpstreamInFlight.has(cacheKey) - ) -} - -function trimNoEffectiveUpstreamWriteGeneration(): void { - for (const cacheKey of noEffectiveUpstreamWriteGeneration.keys()) { - if (noEffectiveUpstreamWriteGeneration.size <= MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) { - break - } - if (hasPendingNoEffectiveUpstreamProbe(cacheKey)) { - continue - } - noEffectiveUpstreamWriteGeneration.delete(cacheKey) - } -} - -function cacheNoEffectiveUpstreamStatus( - cacheKey: string, - status: GitUpstreamStatus, - probedSameNameOriginRef: boolean, - writeGeneration: number, - nowMs = Date.now() -): void { - // Why: hasConfiguredPushTarget controls publish behavior; keep that signal - // fresh rather than serving a stale positive from status polling. - if (status.hasUpstream || status.hasConfiguredPushTarget) { - noEffectiveUpstreamByIdentity.delete(cacheKey) - noEffectiveUpstreamWriteGeneration.set(cacheKey, writeGeneration + 1) - trimNoEffectiveUpstreamWriteGeneration() - return - } - if ((noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { - return - } - // Why: only cache negatives after probing origin/; other resolution - // paths can fail without proving the same-name publish branch is absent. - if (!probedSameNameOriginRef) { - return - } - noEffectiveUpstreamByIdentity.set(cacheKey, { - status, - expiresAt: nowMs + NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS - }) - while (noEffectiveUpstreamByIdentity.size > MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) { - const oldest = noEffectiveUpstreamByIdentity.keys().next() - if (oldest.done) { - break - } - noEffectiveUpstreamByIdentity.delete(oldest.value) - noEffectiveUpstreamWriteGeneration.delete(oldest.value) - } - trimNoEffectiveUpstreamWriteGeneration() -} - -export async function readOrProbeNoEffectiveUpstreamStatus( - identity: NoEffectiveUpstreamCacheIdentity, - runGit: GitCommandRunner, - options: { bypassCache?: boolean } = {} -): Promise { - const cacheKey = noEffectiveUpstreamCacheKey(identity) - if (options.bypassCache !== true) { - const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) - if (cachedStatus) { - return cachedStatus - } - - const inFlight = noEffectiveUpstreamInFlight.get(cacheKey) - if (inFlight) { - return inFlight - } - } - - let probedSameNameOriginRef = false - const writeGeneration = noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0 - const probe = getEffectiveGitUpstreamStatus((args) => { - if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) { - probedSameNameOriginRef = true - } - return runGit(args) - }).then((status) => { - cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration) - return status - }) - if (options.bypassCache !== true) { - noEffectiveUpstreamInFlight.set(cacheKey, probe) - } - try { - return await probe - } finally { - if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) { - noEffectiveUpstreamInFlight.delete(cacheKey) - trimNoEffectiveUpstreamWriteGeneration() - } - } -} - -export function clearNoEffectiveUpstreamStatusCache(): void { - noEffectiveUpstreamByIdentity.clear() - noEffectiveUpstreamInFlight.clear() - retiredNoEffectiveUpstreamInFlight.clear() - noEffectiveUpstreamWriteGeneration.clear() -} - -export function clearNoEffectiveUpstreamStatusCacheEntry( - identity: NoEffectiveUpstreamCacheIdentity -): void { - const cacheKey = noEffectiveUpstreamCacheKey(identity) - retireNoEffectiveUpstreamProbe(cacheKey) - noEffectiveUpstreamByIdentity.delete(cacheKey) - noEffectiveUpstreamInFlight.delete(cacheKey) - noEffectiveUpstreamWriteGeneration.set( - cacheKey, - (noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) + 1 - ) -} - -function retireNoEffectiveUpstreamProbe(cacheKey: string): void { - const retiredProbe = noEffectiveUpstreamInFlight.get(cacheKey) - if (!retiredProbe) { - return - } - retiredNoEffectiveUpstreamInFlight.set(cacheKey, retiredProbe) - void retiredProbe - .finally(() => { - if (retiredNoEffectiveUpstreamInFlight.get(cacheKey) === retiredProbe) { - retiredNoEffectiveUpstreamInFlight.delete(cacheKey) - trimNoEffectiveUpstreamWriteGeneration() - } - }) - .catch(() => undefined) -} - -export function getNoEffectiveUpstreamStatusCacheCountForTests(): number { - return noEffectiveUpstreamByIdentity.size -} - -export function getNoEffectiveUpstreamStatusGenerationCountForTests(): number { - return noEffectiveUpstreamWriteGeneration.size -} diff --git a/src/relay/git-stdout-stream.test.ts b/src/relay/git-stdout-stream.test.ts deleted file mode 100644 index 5018f15ecb3..00000000000 --- a/src/relay/git-stdout-stream.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type * as ProcessRunner from '@orca/process-host' -import type * as ProcessTreeTermination from '@orca/process-host/process-tree-termination' -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { spawnMock, terminateMock } = vi.hoisted(() => ({ - spawnMock: vi.fn(), - terminateMock: vi.fn() -})) - -vi.mock('@orca/process-host', async (importActual) => ({ - ...(await importActual()), - spawnProcess: spawnMock -})) -vi.mock('@orca/process-host/process-tree-termination', async (importActual) => ({ - ...(await importActual()), - forceTerminateProcessTree: terminateMock -})) - -import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' -import { GIT_READ_TIMEOUT_MS } from '../shared/git-command-timeout' -import { - _resetRelayGitAdmissionForTests, - acquireRelayGitAdmission -} from './git-handler-command-termination' -import { streamRelayGitStdout } from './git-stdout-stream' - -type MockChild = EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - pid?: number -} - -function createChild(): MockChild { - const child = new EventEmitter() as MockChild - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - child.pid = 1234 - return child -} - -async function waitForSpawn(count = 1): Promise { - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(count), { interval: 1 }) -} - -describe('streamRelayGitStdout', () => { - beforeEach(() => { - spawnMock.mockReset() - terminateMock.mockReset().mockResolvedValue(true) - _resetRelayGitAdmissionForTests( - new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) - ) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('decodes split UTF-8 chunks and stops the child at the parser limit', async () => { - const child = createChild() - spawnMock.mockReturnValue(child) - let output = '' - const pending = streamRelayGitStdout(['status', '--porcelain=v2'], '/repo', { - disableOptionalLocks: true, - onStdout: (chunk) => { - output += chunk - return output.includes('\n') - } - }) - await waitForSpawn() - const bytes = Buffer.from('? café-😀.txt\n') - const emojiStart = bytes.indexOf(Buffer.from('😀')) - child.stdout.emit('data', bytes.subarray(0, emojiStart + 2)) - child.stdout.emit('data', bytes.subarray(emojiStart + 2)) - - await expect(pending).resolves.toEqual({ stoppedEarly: true }) - expect(output).toBe('? café-😀.txt\n') - expect(terminateMock).toHaveBeenCalledWith(child) - expect(spawnMock).toHaveBeenCalledWith( - expect.objectContaining({ - program: 'git', - args: ['status', '--porcelain=v2'], - cwd: '/repo', - env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }), - stdio: ['ignore', 'pipe', 'pipe'], - detached: process.platform !== 'win32' - }) - ) - expect(child.stdout.listenerCount('data')).toBe(0) - child.emit('close', 0) - }) - - it('rejects parser failures after terminating and detaching the child', async () => { - const child = createChild() - spawnMock.mockReturnValue(child) - const pending = streamRelayGitStdout(['status'], '/repo', { - onStdout: () => { - throw new Error('parser failed') - } - }) - await waitForSpawn() - const rejection = expect(pending).rejects.toThrow('parser failed') - child.stdout.emit('data', Buffer.from('? file.ts\n')) - - await rejection - expect(terminateMock).toHaveBeenCalledWith(child) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', 0) - expect(child.listenerCount('close')).toBe(0) - }) - - it('aborts an in-flight child and rejects instead of returning partial status', async () => { - const child = createChild() - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const pending = streamRelayGitStdout(['status'], '/repo', { - signal: controller.signal, - onStdout: () => {} - }) - await waitForSpawn() - const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - controller.abort() - - await rejection - expect(terminateMock).toHaveBeenCalledWith(child) - expect(child.listenerCount('error')).toBe(1) - child.emit('close', 0) - expect(child.listenerCount('error')).toBe(0) - }) - - it('handles a late spawn error after abort cleanup', async () => { - const child = createChild() - child.pid = undefined - spawnMock.mockReturnValue(child) - const controller = new AbortController() - const pending = streamRelayGitStdout(['status'], '/repo', { - signal: controller.signal, - onStdout: () => {} - }) - await waitForSpawn() - const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - - controller.abort() - await rejection - - expect(() => child.emit('error', new Error('spawn git ENOENT'))).not.toThrow() - expect(child.listenerCount('error')).toBe(0) - }) - - it('bounds stderr and cleans up after command failure', async () => { - const child = createChild() - spawnMock.mockReturnValue(child) - const pending = streamRelayGitStdout(['status'], '/repo', { - maxBuffer: 64, - onStdout: () => {} - }) - await waitForSpawn() - const rejection = expect(pending).rejects.toThrow('git exited with 128: fatal: nope') - child.stderr.emit('data', Buffer.from('fatal: nope')) - child.emit('close', 128) - - await rejection - expect(terminateMock).not.toHaveBeenCalled() - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - }) - - it('removes an aborted queued read without spawning it', async () => { - const held = await acquireRelayGitAdmission({ args: ['status'], cwd: '/repo' }) - const controller = new AbortController() - const pending = streamRelayGitStdout(['status'], '/repo', { - signal: controller.signal, - onStdout: () => {} - }) - const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - controller.abort() - await rejection - expect(spawnMock).not.toHaveBeenCalled() - held.release() - const child = createChild() - spawnMock.mockReturnValue(child) - const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) - await waitForSpawn() - child.emit('close', 0) - await expect(next).resolves.toEqual({ stoppedEarly: false }) - }) - - it('rechecks an abort after admission before spawning', async () => { - const controller = new AbortController() - const pending = streamRelayGitStdout(['status'], '/repo', { - signal: controller.signal, - onStdout: () => {} - }) - controller.abort() - await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - expect(spawnMock).not.toHaveBeenCalled() - const grant = await acquireRelayGitAdmission({ args: ['status'], cwd: '/repo' }) - grant.release() - }) - - it('returns a capped result immediately while retaining admission until child close', async () => { - const firstChild = createChild() - const secondChild = createChild() - spawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild) - const first = streamRelayGitStdout(['status'], '/repo', { onStdout: () => true }) - await waitForSpawn() - firstChild.stdout.emit('data', Buffer.from('? capped\n')) - await expect(first).resolves.toEqual({ stoppedEarly: true }) - const second = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) - await Promise.resolve() - expect(spawnMock).toHaveBeenCalledOnce() - expect(() => firstChild.stderr.emit('error', new Error('late pipe error'))).not.toThrow() - firstChild.emit('close', null) - await waitForSpawn(2) - expect(spawnMock).toHaveBeenCalledTimes(2) - secondChild.emit('close', 0) - await second - }) - - it.each([undefined, 25])( - 'bounds an admitted read by its default or explicit deadline: %s', - async (timeout) => { - vi.useFakeTimers() - const child = createChild() - const nextChild = createChild() - spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(nextChild) - const pending = streamRelayGitStdout(['status'], '/repo', { timeout, onStdout: () => {} }) - const rejection = expect(pending).rejects.toMatchObject({ - name: 'GitCommandTimeoutError', - timedOut: true - }) - await waitForSpawn() - await vi.advanceTimersByTimeAsync(timeout ?? GIT_READ_TIMEOUT_MS) - await rejection - expect(terminateMock).toHaveBeenCalledWith(child) - const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) - await Promise.resolve() - expect(spawnMock).toHaveBeenCalledOnce() - child.emit('close', null) - await waitForSpawn(2) - expect(spawnMock).toHaveBeenCalledTimes(2) - nextChild.emit('close', 0) - await next - expect(vi.getTimerCount()).toBe(0) - } - ) - - it('retains an aborted child grant until its close', async () => { - const child = createChild() - const nextChild = createChild() - spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(nextChild) - const controller = new AbortController() - const pending = streamRelayGitStdout(['status'], '/repo', { - signal: controller.signal, - onStdout: () => {} - }) - const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - await waitForSpawn() - controller.abort() - await rejection - const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) - await Promise.resolve() - expect(spawnMock).toHaveBeenCalledOnce() - child.emit('close', null) - await waitForSpawn(2) - expect(spawnMock).toHaveBeenCalledTimes(2) - nextChild.emit('close', 0) - await next - }) - - it('releases admission on a synchronous spawn failure', async () => { - spawnMock.mockImplementationOnce(() => { - throw new Error('spawn failed') - }) - await expect(streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} })).rejects.toThrow( - 'spawn failed' - ) - const child = createChild() - spawnMock.mockReturnValueOnce(child) - const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) - await waitForSpawn(2) - child.emit('close', 0) - await next - }) - - it('releases on confirmed asynchronous spawn failure without waiting for close', async () => { - const child = createChild() - child.pid = undefined - spawnMock.mockReturnValueOnce(child) - const pending = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) - const rejection = expect(pending).rejects.toThrow('ENOENT') - await waitForSpawn() - child.emit('error', new Error('ENOENT')) - await rejection - const grant = await acquireRelayGitAdmission({ args: ['status'], cwd: '/repo' }) - grant.release() - expect(child.listenerCount('close')).toBe(0) - }) -}) diff --git a/src/relay/git-stdout-stream.ts b/src/relay/git-stdout-stream.ts deleted file mode 100644 index c30cdfb00b0..00000000000 --- a/src/relay/git-stdout-stream.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { StringDecoder } from 'node:string_decoder' -import { spawnProcess } from '@orca/process-host' -import { forceTerminateProcessTree } from '@orca/process-host/process-tree-termination' -import { createChildTerminationReporter } from '@orca/process-host/child-termination-reporter' -import { GitCommandTimeoutError, gitCommandTimeoutMs } from '../shared/git-command-timeout' -import { expandTilde } from './context' -import { buildRelayGitEnv } from '../wsl-guest/relay-command-env' -import { acquireRelayGitAdmission } from './git-handler-command-termination' - -const DEFAULT_RELAY_GIT_STREAM_MAX_BYTES = 10 * 1024 * 1024 - -export type RelayGitStreamOptions = { - disableOptionalLocks?: boolean - signal?: AbortSignal - maxBuffer?: number - timeout?: number - onStdout: (chunk: string) => boolean | void -} - -export type RelayGitStreamExec = ( - args: string[], - cwd: string, - options: RelayGitStreamOptions -) => Promise<{ stoppedEarly: boolean }> - -function createAbortError(): Error { - const error = new Error('The operation was aborted.') - error.name = 'AbortError' - return error -} - -/** Stream Git stdout on the relay host and allow the consumer to stop it early. */ -export const streamRelayGitStdout: RelayGitStreamExec = async (args, cwd, options) => { - const maxBuffer = options.maxBuffer ?? DEFAULT_RELAY_GIT_STREAM_MAX_BYTES - const resolvedCwd = expandTilde(cwd) - const grant = await acquireRelayGitAdmission({ args, cwd: resolvedCwd, signal: options.signal }) - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - grant.release() - reject(createAbortError()) - return - } - - const termination = createChildTerminationReporter(grant.release) - let child - try { - const env = buildRelayGitEnv() - if (options.disableOptionalLocks) { - env.GIT_OPTIONAL_LOCKS = '0' - } - child = spawnProcess({ - program: 'git', - args, - cwd: resolvedCwd, - env, - stdio: ['ignore', 'pipe', 'pipe'], - detached: process.platform !== 'win32' - }) - } catch (error) { - termination.report() - reject(error instanceof Error ? error : new Error(String(error))) - return - } - - let settled = false - let stoppedEarly = false - let stdoutBytes = 0 - let stderrBytes = 0 - let stderr = '' - // Why: filenames may contain UTF-8 characters split across stream chunks; - // stateful decoding keeps the porcelain record intact. - const stdoutDecoder = new StringDecoder('utf8') - const stderrDecoder = new StringDecoder('utf8') - let deadline: ReturnType | undefined - - const cleanup = (): void => { - child.stdout.off('data', onStdoutData) - child.stderr.off('data', onStderrData) - options.signal?.removeEventListener('abort', onAbort) - clearTimeout(deadline) - stdoutDecoder.end() - stderrDecoder.end() - } - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - if (error) { - reject(Object.assign(error, { stderr })) - } else { - resolve({ stoppedEarly }) - } - } - // Parser completion leaves the child holding admission until termination is observed. - const releaseChild = (): void => { - termination.report() - child.off('error', onError) - child.off('close', onClose) - child.stdout.off('error', onError) - child.stderr.off('error', onError) - } - const stopWithError = (error: Error): void => { - void forceTerminateProcessTree(child).catch(() => {}) - finish(error) - } - - function onStdoutData(chunk: Buffer): void { - stdoutBytes += chunk.byteLength - if (stdoutBytes > maxBuffer) { - stopWithError(new Error('git stdout exceeded maxBuffer.')) - return - } - const decoded = stdoutDecoder.write(chunk) - if (!decoded) { - return - } - try { - if (options.onStdout(decoded) === true) { - // Why: the status cap is a successful partial result, so detach and - // resolve immediately after stopping Git instead of awaiting close. - stoppedEarly = true - void forceTerminateProcessTree(child).catch(() => {}) - finish() - } - } catch (error) { - stopWithError(error instanceof Error ? error : new Error(String(error))) - } - } - function onStderrData(chunk: Buffer): void { - stderrBytes += chunk.byteLength - if (stderrBytes > maxBuffer) { - stopWithError(new Error('git stderr exceeded maxBuffer.')) - return - } - stderr += stderrDecoder.write(chunk) - } - function onError(error: Error): void { - if (!child.pid) { - releaseChild() - } - if (!settled && child.pid) { - void forceTerminateProcessTree(child).catch(() => {}) - } - finish(error) - } - function onClose(code: number | null): void { - releaseChild() - if (stoppedEarly || code === 0) { - finish() - } else { - finish(new Error(`git exited with ${code}: ${stderr}`)) - } - } - function onAbort(): void { - stopWithError(createAbortError()) - } - - child.stdout.on('data', onStdoutData) - child.stderr.on('data', onStderrData) - child.stdout.on('error', onError) - child.stderr.on('error', onError) - child.on('error', onError) - child.on('close', onClose) - options.signal?.addEventListener('abort', onAbort, { once: true }) - const timeoutMs = gitCommandTimeoutMs(args, options.timeout) - if (timeoutMs !== undefined && timeoutMs > 0) { - deadline = setTimeout(() => { - stopWithError(Object.assign(new GitCommandTimeoutError(timeoutMs), { timedOut: true })) - }, timeoutMs) - deadline.unref() - } - if (options.signal?.aborted) { - onAbort() - } - }) -} diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts deleted file mode 100644 index 7f9824ead94..00000000000 --- a/src/relay/git-working-file-read.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { readWorkingDiffFile } from './git-working-file-read' - -describe('readWorkingDiffFile', () => { - let tmpDir: string | null = null - - afterEach(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }) - } - tmpDir = null - }) - - it('reads normal text working-tree files', async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) - const filePath = path.join(tmpDir, 'file.txt') - await writeFile(filePath, 'hello') - - await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ - content: 'hello', - isBinary: false, - missing: false - }) - }) - - it('marks oversized working-tree files as binary before diffing', async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) - const filePath = path.join(tmpDir, 'large.log') - await writeFile(filePath, Buffer.alloc(10 * 1024 * 1024 + 1, 'a')) - - await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ - content: '', - isBinary: true, - missing: false - }) - }) - - it('base64-encodes a previewable image by its extension', async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) - const filePath = path.join(tmpDir, 'icon.png') - const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]) - await writeFile(filePath, pngBytes) - - await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ - content: pngBytes.toString('base64'), - isBinary: true, - missing: false - }) - }) - - it('reports an absent working-tree file as a deletion', async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) - const filePath = path.join(tmpDir, 'deleted.png') - - await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ - content: '', - isBinary: false, - missing: true - }) - }) - - it('returns base64 content for previewable image working-tree files', async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) - const filePath = path.join(tmpDir, 'image.png') - // Why: PNG signature + trailing null byte forces the binary-detection heuristic to trip. - const pngBuffer = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.from([0x00]) - ]) - await writeFile(filePath, pngBuffer) - - await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ - content: pngBuffer.toString('base64'), - isBinary: true, - missing: false - }) - }) - - it('returns empty content for non-previewable binary working-tree files', async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) - const filePath = path.join(tmpDir, 'archive.bin') - await writeFile(filePath, Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03])) - - await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ - content: '', - isBinary: true, - missing: false - }) - }) -}) diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts deleted file mode 100644 index 05cf1ebcdce..00000000000 --- a/src/relay/git-working-file-read.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFile, stat } from 'node:fs/promises' -import { bufferToBlob } from './git-handler-utils' - -const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024 - -export async function readWorkingDiffFile( - absPath: string -): Promise<{ content: string; isBinary: boolean; missing: boolean }> { - let fileStat - try { - fileStat = await stat(absPath) - } catch (error) { - // Why: ENOENT means the working-tree file is genuinely gone (a deletion); - // any other stat error is a read failure we must not mistake for one, since - // callers fall back to the original bytes only for a proven deletion. - const missing = (error as NodeJS.ErrnoException)?.code === 'ENOENT' - return { content: '', isBinary: false, missing } - } - if (!fileStat.isFile()) { - return { content: '', isBinary: false, missing: true } - } - if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { - // Why: mirror local git diff reads, which cap blob transfer at 10MB. - return { content: '', isBinary: true, missing: false } - } - try { - const buffer = await readFile(absPath) - // Why: bufferToBlob needs the path's extension to know an image is - // previewable; omitting it made every relay-side binary diff empty. - return { ...bufferToBlob(buffer, absPath), missing: false } - } catch { - // Why: the file exists but could not be read — a read failure, not a deletion. - return { content: '', isBinary: false, missing: false } - } -} diff --git a/src/relay/hermes-output-run-files.ts b/src/relay/hermes-output-run-files.ts deleted file mode 100644 index 31aa213abe7..00000000000 --- a/src/relay/hermes-output-run-files.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { existsSync } from 'node:fs' -import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' -import { HERMES_HOME, HERMES_OUTPUT_DIR } from './external-automation-storage-paths' -import type { HermesOutputRunRef } from './hermes-run-correlation' - -const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ -const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 -const REFERENCED_LOG_HEADING = '## Latest log file' -const LATEST_LOG_PATH_PATTERN = - /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i - -function runAtFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}-${month}-${day}T${hour}:${minute}:${second}` -} - -function runKeyFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}${month}${day}_${hour}${minute}${second}` -} - -function cleanRunPreview(value: string): string | null { - const normalized = value - .replace(/```[\s\S]*?```/g, ' ') - .replace(/[#*_`>()]/g, ' ') - .replaceAll('[', ' ') - .replaceAll(']', ' ') - .replace(/\s+/g, ' ') - .trim() - if (!normalized) { - return null - } - return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized -} - -function parseHermesOutput(content: string): { - status: 'completed' | 'failed' | 'unknown' - outputPreview: string | null - outputContent: string - error: string | null -} { - const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content) - const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content) - const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content) - const error = errorMatch ? cleanRunPreview(errorMatch[1]) : null - return { - status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown', - outputPreview: cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content), - outputContent: content, - error - } -} - -function extractLatestLogPath(content: string): string | null { - const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() - if (!rawPath) { - return null - } - return rawPath.replace(/^`|`$/g, '').trim() -} - -export async function readHermesReferencedLogFile(content: string): Promise<{ - path: string - content: string - truncated: boolean -} | null> { - const logPath = extractLatestLogPath(content) - if (!logPath || !isAbsolute(logPath)) { - return null - } - try { - const homeRealPath = await realpath(HERMES_HOME) - const logRealPath = await realpath(logPath) - const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) - if ( - relativeToHermesHome === '..' || - relativeToHermesHome.startsWith(`..${sep}`) || - isAbsolute(relativeToHermesHome) - ) { - return null - } - const logStat = await stat(logPath) - if (!logStat.isFile()) { - return null - } - if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { - return { - path: logPath, - content: await readFile(logPath, 'utf-8'), - truncated: false - } - } - const file = await open(logPath, 'r') - try { - const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) - await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES) - return { - path: logPath, - content: buffer.toString('utf-8'), - truncated: true - } - } finally { - await file.close() - } - } catch { - return null - } -} - -async function appendReferencedLogFile(content: string): Promise { - if (content.includes(REFERENCED_LOG_HEADING)) { - return content - } - const logFile = await readHermesReferencedLogFile(content) - if (!logFile) { - return content - } - const note = logFile.truncated - ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` - : null - return [ - content, - '---', - REFERENCED_LOG_HEADING, - '', - `Path: ${logFile.path}`, - note, - '```text', - logFile.content.trimEnd(), - '```' - ] - .filter((part) => part !== null) - .join('\n\n') -} - -export async function readHermesOutputFileRunRefs(jobId: string): Promise { - const outputDir = join(HERMES_OUTPUT_DIR, jobId) - if (!existsSync(outputDir)) { - return [] - } - const entries = await readdir(outputDir, { withFileTypes: true }) - return entries - .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) - .map((entry) => ({ - kind: 'output', - id: `${jobId}:${entry.name}`, - job_id: jobId, - run_at: runAtFromHermesOutputFile(entry.name), - run_key: runKeyFromHermesOutputFile(entry.name), - output_path: join(outputDir, entry.name) - })) -} - -export async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { - try { - const content = await readFile(ref.output_path, 'utf-8') - const parsed = parseHermesOutput(content) - const outputContent = await appendReferencedLogFile(parsed.outputContent) - return { - id: ref.id, - job_id: ref.job_id, - run_at: ref.run_at, - run_key: ref.run_key, - status: parsed.status, - output_preview: parsed.outputPreview, - output_content: outputContent, - error: parsed.error, - output_path: ref.output_path - } - } catch (error) { - return { - id: ref.id, - job_id: ref.job_id, - run_at: ref.run_at, - run_key: ref.run_key, - status: 'unknown', - output_preview: null, - output_content: null, - error: error instanceof Error ? error.message : String(error), - output_path: ref.output_path - } - } -} diff --git a/src/relay/hermes-run-correlation.test.ts b/src/relay/hermes-run-correlation.test.ts deleted file mode 100644 index c009792e63f..00000000000 --- a/src/relay/hermes-run-correlation.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mergeHermesOutputAndSessionRuns } from './hermes-run-correlation' - -function run(runKey: string, content: string): Record { - return { - id: runKey, - run_key: runKey, - output_preview: content, - output_content: content - } -} - -describe('mergeHermesOutputAndSessionRuns', () => { - it('prefers an exact run key and appends its full session transcript', () => { - const output = run('20260516_090000', 'summary') - const exact = run('20260516_090000', 'exact transcript') - const nearerButDifferent = run('20260516_085959', 'other transcript') - - const result = mergeHermesOutputAndSessionRuns([output], [nearerButDifferent, exact]) as Record< - string, - unknown - >[] - - expect(result[0].output_content).toBe( - 'summary\n\n---\n\n## Full session log\n\nexact transcript' - ) - expect(result[1]).toBe(nearerButDifferent) - }) - - it('matches only the nearest earlier session inside the 24-hour window', () => { - const output = run('20260516_090000', 'summary') - const later = run('20260516_090001', 'later') - const tooOld = run('20260515_085959', 'too old') - const nearestEarlier = run('20260516_085959', 'nearest earlier') - - const result = mergeHermesOutputAndSessionRuns( - [output], - [later, tooOld, nearestEarlier] - ) as Record[] - - expect(result[0].output_content).toContain('nearest earlier') - expect(result.slice(1)).toEqual([later, tooOld]) - }) - - it('uses each session run at most once', () => { - const first = run('20260516_090000', 'first') - const second = run('20260516_090100', 'second') - const session = run('20260516_085900', 'one transcript') - - const result = mergeHermesOutputAndSessionRuns([first, second], [session]) as Record< - string, - unknown - >[] - - expect( - result.filter((item) => String(item.output_content).includes('one transcript')) - ).toHaveLength(1) - }) -}) diff --git a/src/relay/hermes-run-correlation.ts b/src/relay/hermes-run-correlation.ts deleted file mode 100644 index f98d71c2402..00000000000 --- a/src/relay/hermes-run-correlation.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { HermesSessionRunIndex } from '../shared/hermes-session-run-index' -const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ -const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 -const FULL_SESSION_LOG_HEADING = '## Full session log' - -export type HermesOutputRunRef = { - kind: 'output' - id: string - job_id: string - run_at: string | null - run_key: string | null - output_path: string -} - -export type HermesSessionRunRef = { - kind: 'session' - id: string - job_id: string - run_at: string | null - run_key: string | null -} - -export type HermesMergedRunRef = { - id: string - job_id: string - run_at: string | null - run_key: string | null - output: HermesOutputRunRef | null - session: HermesSessionRunRef | null -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function getRunKey(run: unknown): string | null { - return isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() ? run.run_key : null -} - -function getRunOutputContent(run: unknown): string | null { - return isRecord(run) && typeof run.output_content === 'string' && run.output_content.trim() - ? run.output_content - : null -} - -function getRunOutputPreview(run: unknown): string | null { - return isRecord(run) && typeof run.output_preview === 'string' && run.output_preview.trim() - ? run.output_preview - : null -} - -function sortableTimeFromRunKey(runKey: string | null): number { - if (!runKey) { - return Number.NaN - } - const match = HERMES_RUN_KEY_PATTERN.exec(runKey) - if (!match) { - return Number.NaN - } - const [, year, month, day, hour, minute, second] = match - return Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ) -} - -function mergeOutputAndSessionContent( - outputContent: string | null, - sessionContent: string | null -): string | null { - if (!sessionContent) { - return outputContent - } - if (!outputContent) { - return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` - } - if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { - return outputContent - } - return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` -} - -export function mergeHermesOutputAndSessionRuns( - outputRuns: unknown[], - sessionRuns: unknown[] -): unknown[] { - const sessionIndex = new HermesSessionRunIndex( - outputRuns.length > 0 ? sessionRuns.map(getRunKey) : [], - sortableTimeFromRunKey, - MAX_SESSION_OUTPUT_GAP_MS - ) - const usedSessionRunIndexes = sessionIndex.used - const mergedOutputRuns = outputRuns.map((outputRun) => { - if (!isRecord(outputRun)) { - return outputRun - } - const sessionRunIndex = sessionIndex.find(getRunKey(outputRun)) - if (sessionRunIndex === null) { - return outputRun - } - const sessionRun = sessionRuns[sessionRunIndex] - if (!isRecord(sessionRun)) { - return outputRun - } - sessionIndex.use(sessionRunIndex) - return { - ...outputRun, - output_preview: getRunOutputPreview(outputRun) ?? getRunOutputPreview(sessionRun), - output_content: mergeOutputAndSessionContent( - getRunOutputContent(outputRun), - getRunOutputContent(sessionRun) - ) - } - }) - return [ - ...mergedOutputRuns, - ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) - ] -} - -export function mergeHermesOutputAndSessionRunRefs( - outputRefs: HermesOutputRunRef[], - sessionRefs: HermesSessionRunRef[] -): HermesMergedRunRef[] { - const sessionIndex = new HermesSessionRunIndex( - outputRefs.length > 0 ? sessionRefs.map(getRunKey) : [], - sortableTimeFromRunKey, - MAX_SESSION_OUTPUT_GAP_MS - ) - const usedSessionRunIndexes = sessionIndex.used - const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = sessionIndex.find(getRunKey(outputRef)) - const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] - if (sessionRunIndex !== null) { - sessionIndex.use(sessionRunIndex) - } - return { - id: outputRef.id, - job_id: outputRef.job_id, - run_at: outputRef.run_at, - run_key: outputRef.run_key, - output: outputRef, - session: sessionRef - } - }) - return [ - ...mergedOutputRefs, - ...sessionRefs - .filter((_, index) => !usedSessionRunIndexes.has(index)) - .map((sessionRef) => ({ - id: sessionRef.id, - job_id: sessionRef.job_id, - run_at: sessionRef.run_at, - run_key: sessionRef.run_key, - output: null, - session: sessionRef - })) - ] -} diff --git a/src/relay/hermes-run-history.test.ts b/src/relay/hermes-run-history.test.ts deleted file mode 100644 index 4c24c7d94f9..00000000000 --- a/src/relay/hermes-run-history.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { HermesRunHistory, type HermesRunHistorySources } from './hermes-run-history' -import type { HermesOutputRunRef } from './hermes-run-correlation' - -function outputRef(day: number, jobId = 'job-1'): HermesOutputRunRef { - const dayText = String(day).padStart(2, '0') - return { - kind: 'output', - id: `${jobId}:2026-05-${dayText}_09-00-00.md`, - job_id: jobId, - run_at: `2026-05-${dayText}T09:00:00`, - run_key: `202605${dayText}_090000`, - output_path: `/output/${jobId}/${dayText}.md` - } -} - -function sources(overrides: Partial = {}): HermesRunHistorySources { - return { - readOutputRefs: async () => [], - readSessionRefs: () => [], - readOutputRun: async (ref) => ref, - readSessionRun: () => null, - ...overrides - } -} - -describe('HermesRunHistory', () => { - it('paginates refs before hydrating run content', async () => { - const readOutputRun = vi.fn(async (ref: HermesOutputRunRef) => ref) - const history = new HermesRunHistory( - sources({ - readOutputRefs: async () => [outputRef(16), outputRef(15), outputRef(14)], - readOutputRun - }) - ) - - const result = await history.listRuns({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 2 - }) - - expect(result.total).toBe(3) - expect(result.runs).toEqual([outputRef(16), outputRef(15)]) - expect(readOutputRun).toHaveBeenCalledTimes(2) - }) - - it('uses a count-only path without hydrating content', async () => { - const readOutputRun = vi.fn() - const history = new HermesRunHistory( - sources({ readOutputRefs: async () => [outputRef(16), outputRef(15)], readOutputRun }) - ) - - await expect( - history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - ).resolves.toEqual({ total: 2, runs: [] }) - expect(readOutputRun).not.toHaveBeenCalled() - }) - - it('deduplicates concurrent count reads', async () => { - let resolveRefs: (refs: HermesOutputRunRef[]) => void = () => {} - const readOutputRefs = vi.fn( - () => - new Promise((resolve) => { - resolveRefs = resolve - }) - ) - const history = new HermesRunHistory(sources({ readOutputRefs })) - - const first = history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - const second = history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - expect(readOutputRefs).toHaveBeenCalledTimes(1) - resolveRefs([outputRef(16), outputRef(15)]) - - await expect(Promise.all([first, second])).resolves.toEqual([ - { total: 2, runs: [] }, - { total: 2, runs: [] } - ]) - }) - - it('evicts a failed count read so the next call retries', async () => { - const readError = new Error('output directory unavailable') - const readOutputRefs = vi - .fn<() => Promise>() - .mockRejectedValueOnce(readError) - .mockResolvedValueOnce([outputRef(16)]) - const history = new HermesRunHistory(sources({ readOutputRefs })) - - await expect( - history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - ).rejects.toBe(readError) - await expect( - history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - ).resolves.toEqual({ total: 1, runs: [] }) - expect(readOutputRefs).toHaveBeenCalledTimes(2) - }) - - it('clears a cached count for one lifecycle-mutated job', async () => { - const readOutputRefs = vi - .fn<() => Promise>() - .mockResolvedValueOnce([outputRef(15)]) - .mockResolvedValueOnce([outputRef(16), outputRef(15)]) - const history = new HermesRunHistory(sources({ readOutputRefs })) - - await history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - await history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - history.clearRunCount('job-1') - - await expect( - history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) - ).resolves.toEqual({ total: 2, runs: [] }) - expect(readOutputRefs).toHaveBeenCalledTimes(2) - }) - - it('returns empty OpenClaw history before validating its job ID', async () => { - const history = new HermesRunHistory(sources()) - - await expect( - history.listRuns({ provider: 'openclaw', jobId: '--invalid', pageSize: 25 }) - ).resolves.toEqual({ total: 0, runs: [] }) - }) - - it('evicts the oldest cached job at the 200-entry bound', async () => { - const readOutputRefs = vi.fn(async (jobId: string) => - jobId === 'job-0' ? [outputRef(15, jobId)] : [] - ) - const history = new HermesRunHistory(sources({ readOutputRefs })) - - await history.listRuns({ provider: 'hermes', jobId: 'job-0', pageSize: 0 }) - for (let index = 1; index <= 200; index += 1) { - await history.listRuns({ provider: 'hermes', jobId: `job-${index}`, pageSize: 0 }) - } - await history.listRuns({ provider: 'hermes', jobId: 'job-0', pageSize: 0 }) - - expect(readOutputRefs).toHaveBeenCalledTimes(202) - }) -}) diff --git a/src/relay/hermes-run-history.ts b/src/relay/hermes-run-history.ts deleted file mode 100644 index 4715f5cd455..00000000000 --- a/src/relay/hermes-run-history.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { - EXTERNAL_AUTOMATION_JOB_ID_PATTERN, - externalAutomationProvider -} from './external-automation-provider' -import { - mergeHermesOutputAndSessionRunRefs, - mergeHermesOutputAndSessionRuns, - type HermesMergedRunRef, - type HermesOutputRunRef, - type HermesSessionRunRef -} from './hermes-run-correlation' -import { readHermesOutputFileRun, readHermesOutputFileRunRefs } from './hermes-output-run-files' -import { - readHermesSessionDbRunById, - readHermesSessionDbRunRefs -} from './hermes-session-run-database' - -const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 -const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 - -type HermesRunCountCacheEntry = { - promise: Promise - expiresAt: number -} - -export type HermesRunHistorySources = { - readOutputRefs: (jobId: string) => Promise - readSessionRefs: (jobId: string) => HermesSessionRunRef[] - readOutputRun: (ref: HermesOutputRunRef) => Promise - readSessionRun: (jobId: string, runId: string) => unknown -} - -const DEFAULT_SOURCES: HermesRunHistorySources = { - readOutputRefs: readHermesOutputFileRunRefs, - readSessionRefs: readHermesSessionDbRunRefs, - readOutputRun: readHermesOutputFileRun, - readSessionRun: readHermesSessionDbRunById -} - -function getRawRunId(run: unknown): string { - if (typeof run === 'object' && run !== null && 'id' in run) { - return String(run.id) - } - return '' -} - -function getRawRunTime(run: unknown): number { - if (typeof run !== 'object' || run === null || !('run_at' in run)) { - return Number.NaN - } - return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN -} - -export class HermesRunHistory { - private readonly runCountCache = new Map() - - constructor(private readonly sources: HermesRunHistorySources = DEFAULT_SOURCES) {} - - async listRuns(params: Record = {}): Promise<{ - total: number - runs: unknown[] - }> { - const provider = externalAutomationProvider(params.provider) - const jobId = params.jobId - const page = - typeof params.page === 'number' && Number.isFinite(params.page) - ? Math.max(1, Math.floor(params.page)) - : 1 - const pageSize = - typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) - ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) - : 25 - if (provider !== 'hermes') { - return { total: 0, runs: [] } - } - if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - if (pageSize === 0) { - return { total: await this.readRunCount(jobId), runs: [] } - } - const runRefs = await this.readRunRefs(jobId) - const start = (page - 1) * pageSize - return { - total: runRefs.length, - runs: await Promise.all( - runRefs.slice(start, start + pageSize).map((ref) => this.hydrateRunRef(jobId, ref)) - ) - } - } - - clearRunCount(jobId?: string): void { - if (jobId) { - this.runCountCache.delete(jobId) - return - } - this.runCountCache.clear() - } - - private async readRunRefs(jobId: string): Promise { - const outputRuns = await this.sources.readOutputRefs(jobId) - return mergeHermesOutputAndSessionRunRefs(outputRuns, this.sources.readSessionRefs(jobId)).sort( - (a, b) => { - const aTime = getRawRunTime(a) - const bTime = getRawRunTime(b) - if (Number.isFinite(aTime) && Number.isFinite(bTime)) { - return bTime - aTime - } - return getRawRunId(b).localeCompare(getRawRunId(a)) - } - ) - } - - private async hydrateRunRef(jobId: string, ref: HermesMergedRunRef): Promise { - const outputRun = ref.output ? await this.sources.readOutputRun(ref.output) : null - const sessionRun = ref.session ? this.sources.readSessionRun(jobId, ref.session.id) : null - return ( - mergeHermesOutputAndSessionRuns( - outputRun ? [outputRun] : [], - sessionRun ? [sessionRun] : [] - )[0] ?? - outputRun ?? - sessionRun ?? - ref - ) - } - - private async readRunCount(jobId: string): Promise { - const now = Date.now() - const cached = this.runCountCache.get(jobId) - if (cached && cached.expiresAt > now) { - return cached.promise - } - if (cached) { - this.runCountCache.delete(jobId) - } - this.pruneRunCountCache(now) - const entry: HermesRunCountCacheEntry = { - promise: this.readRunRefs(jobId).then((refs) => refs.length), - expiresAt: Number.POSITIVE_INFINITY - } - this.runCountCache.set(jobId, entry) - try { - const count = await entry.promise - entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS - return count - } catch (error) { - if (this.runCountCache.get(jobId) === entry) { - this.runCountCache.delete(jobId) - } - throw error - } - } - - private pruneRunCountCache(now: number): void { - for (const [jobId, entry] of this.runCountCache) { - if (entry.expiresAt <= now) { - this.runCountCache.delete(jobId) - } - } - while (this.runCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { - const oldestJobId = this.runCountCache.keys().next().value - if (oldestJobId === undefined) { - return - } - this.runCountCache.delete(oldestJobId) - } - } -} diff --git a/src/relay/hermes-session-run-database.ts b/src/relay/hermes-session-run-database.ts deleted file mode 100644 index 3cdd8badecb..00000000000 --- a/src/relay/hermes-session-run-database.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { HERMES_STATE_DB } from './external-automation-storage-paths' -import type { HermesSessionRunRef } from './hermes-run-correlation' - -type SqliteStatement = { - get: (...args: unknown[]) => Record | undefined - all: (...args: unknown[]) => Record[] -} - -type SqliteDatabase = { - prepare: (sql: string) => SqliteStatement - close: () => void -} - -type DatabaseConstructor = new ( - path: string, - options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } -) => SqliteDatabase - -type NodeSqliteDatabaseSync = new ( - path: string, - options?: { readOnly?: boolean; timeout?: number } -) => SqliteDatabase - -const requireOptional = createRequire(__filename) -let databaseConstructor: DatabaseConstructor | null | undefined - -function getDatabaseConstructor(): DatabaseConstructor | null { - if (databaseConstructor !== undefined) { - return databaseConstructor - } - try { - const loaded = requireOptional('node:sqlite') as { DatabaseSync?: NodeSqliteDatabaseSync } - const DatabaseSync = loaded.DatabaseSync - if (typeof DatabaseSync !== 'function') { - databaseConstructor = null - return databaseConstructor - } - const SqliteDatabaseSync = DatabaseSync - databaseConstructor = class RelaySqliteDatabase { - private readonly db: SqliteDatabase - - constructor( - path: string, - options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} - ) { - if (options.fileMustExist && !existsSync(path)) { - throw new Error(`SQLite database does not exist: ${path}`) - } - this.db = new SqliteDatabaseSync(path, { - readOnly: options.readonly, - timeout: options.timeout - }) - } - - prepare(sql: string): SqliteStatement { - return this.db.prepare(sql) - } - - close(): void { - this.db.close() - } - } - } catch { - databaseConstructor = null - } - return databaseConstructor -} - -function escapeSqlLike(value: string): string { - return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') -} - -function runAtFromUnixSeconds(value: unknown): string | null { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return null - } - const date = new Date(value * 1000) - return Number.isNaN(date.getTime()) ? null : date.toISOString() -} - -function formatSessionMessages(messages: Record[]): string | null { - if (messages.length === 0) { - return null - } - return messages - .map((message) => { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - `## ${role}${toolName ? ` / ${toolName}` : ''}`, - reasoning ? `### Reasoning\n\n${reasoning}` : null, - content || '(empty)' - ].filter(Boolean) - return parts.join('\n\n') - }) - .join('\n\n---\n\n') -} - -export function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { - if (!existsSync(HERMES_STATE_DB)) { - return [] - } - const Database = getDatabaseConstructor() - if (!Database) { - return [] - } - try { - const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) - try { - const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%` - const rows = db - .prepare( - `SELECT id, started_at - FROM sessions - WHERE id LIKE ? ESCAPE '\\' - ORDER BY started_at DESC` - ) - .all(pattern) as Record[] - return rows.map((row) => { - const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` - return { - kind: 'session', - id: runId, - job_id: jobId, - run_at: runAtFromUnixSeconds(row.started_at), - run_key: runId.split(`${jobId}_`).at(-1) ?? null - } - }) - } finally { - db.close() - } - } catch { - return [] - } -} - -export function readHermesSessionDbRunById(jobId: string, runId: string): unknown { - if (!existsSync(HERMES_STATE_DB)) { - return null - } - const Database = getDatabaseConstructor() - if (!Database) { - return null - } - try { - const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) - try { - const row = db - .prepare( - `SELECT id, title, started_at, ended_at, end_reason, model, message_count, - input_tokens, output_tokens, estimated_cost_usd - FROM sessions - WHERE id = ?` - ) - .get(runId) as Record | undefined - if (!row) { - return null - } - const messages = db - .prepare( - `SELECT role, content, tool_name, reasoning, reasoning_content - FROM messages - WHERE session_id = ? - ORDER BY timestamp, id` - ) - .all(runId) as Record[] - const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null - const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null - const messageCount = typeof row.message_count === 'number' ? row.message_count : null - const tokenCount = - (typeof row.input_tokens === 'number' ? row.input_tokens : 0) + - (typeof row.output_tokens === 'number' ? row.output_tokens : 0) - const summaryParts = [ - title, - model ? `Model: ${model}` : null, - messageCount !== null ? `${messageCount} messages` : null, - tokenCount > 0 ? `${tokenCount} tokens` : null - ].filter(Boolean) - return { - id: runId, - job_id: jobId, - run_at: runAtFromUnixSeconds(row.started_at), - run_key: runId.split(`${jobId}_`).at(-1) ?? null, - status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', - output_preview: summaryParts.join(' · ') || null, - output_content: formatSessionMessages(messages), - error: null, - output_path: HERMES_STATE_DB - } - } finally { - db.close() - } - } catch { - return null - } -} diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts deleted file mode 100644 index 8e5cea00501..00000000000 --- a/src/relay/integration.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * End-to-end in-process integration test. - * - * Wires the client-side SshChannelMultiplexer directly to the relay-side - * RelayDispatcher through an in-memory pipe — no SSH, no subprocess. - * Validates the full JSON-RPC roundtrip: client request → framing → - * relay decode → handler → response → framing → client decode → result. - */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { rm, readFile } from 'node:fs/promises' -import * as path from 'node:path' -import { tmpdir } from 'node:os' -import { execFileSync } from 'node:child_process' -import { randomBytes } from 'node:crypto' - -import { - SshChannelMultiplexer, - type MultiplexerTransport -} from '../main/ssh/ssh-channel-multiplexer' - -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { RelayContext } from './context' -import { FsHandler } from './fs-handler' -import { GitHandler } from './git-handler' - -function gitInit(dir: string): void { - execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }) -} - -function gitCommit(dir: string, message: string): void { - execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['commit', '-m', message], { cwd: dir, stdio: 'pipe' }) -} - -describe('Integration: Client Mux ↔ Relay Dispatcher', () => { - let tmpDir: string - let mux: SshChannelMultiplexer - let dispatcher: RelayDispatcher - let fsHandler: FsHandler - beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-e2e-')) - - // Build the in-memory pipe - let relayFeedFn: (data: Buffer) => void - - const clientDataCallbacks: ((data: Buffer) => void)[] = [] - const clientCloseCallbacks: (() => void)[] = [] - - const clientTransport: MultiplexerTransport = { - write: (data: Buffer) => { - // Client → Relay - setImmediate(() => relayFeedFn?.(data)) - }, - onData: (cb) => { - clientDataCallbacks.push(cb) - }, - onClose: (cb) => { - clientCloseCallbacks.push(cb) - } - } - - // Relay side - dispatcher = new RelayDispatcher((data: Buffer) => { - // Relay → Client - setImmediate(() => { - for (const cb of clientDataCallbacks) { - cb(data) - } - }) - }) - - relayFeedFn = (data: Buffer) => dispatcher.feed(data) - - // Register handlers on the relay - const context = new RelayContext() - fsHandler = new FsHandler(dispatcher, context) - new GitHandler(dispatcher, context) - - // Create client mux - mux = new SshChannelMultiplexer(clientTransport) - }) - - afterEach(async () => { - mux.dispose() - dispatcher.dispose() - fsHandler.dispose() - await rm(tmpDir, { recursive: true, force: true }) - }) - - // ─── Filesystem ───────────────────────────────────────────────── - - describe('Filesystem operations', () => { - it('readDir returns directory entries', async () => { - writeFileSync(path.join(tmpDir, 'hello.txt'), 'world') - writeFileSync(path.join(tmpDir, 'readme.md'), '# Hi') - - const result = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { - name: string - isDirectory: boolean - isSymlink: boolean - }[] - - expect(result.length).toBe(2) - const names = result.map((e) => e.name).sort() - expect(names).toEqual(['hello.txt', 'readme.md']) - }) - - it('readFile returns text content', async () => { - writeFileSync(path.join(tmpDir, 'data.txt'), 'some content') - - const result = (await mux.request('fs.readFile', { - filePath: path.join(tmpDir, 'data.txt') - })) as { content: string; isBinary: boolean } - - expect(result.content).toBe('some content') - expect(result.isBinary).toBe(false) - }) - - // Why this one stays: writeRelayFile resolves to undefined, so the response frame - // carries neither `result` nor `error` — the one payload shape the other cases here - // do not produce, and one a mock dispatcher never has to settle. The client sees - // `null`, not `undefined`: the member is absent from the frame, not carried as void. - it('writeFile creates/overwrites file content', async () => { - const filePath = path.join(tmpDir, 'output.txt') - - await expect( - mux.request('fs.writeFile', { filePath, content: 'written via relay' }) - ).resolves.toBeNull() - - const content = await readFile(filePath, 'utf-8') - expect(content).toBe('written via relay') - }) - - it('readFileStream round-trip preserves a 12 MB binary file', async () => { - const filePath = path.join(tmpDir, 'big.png') - const original = randomBytes(12 * 1024 * 1024) - writeFileSync(filePath, original) - const { readFileViaStream } = await import('../main/ssh/ssh-filesystem-stream-reader') - const { content } = await readFileViaStream(mux, filePath) - expect(Buffer.from(content, 'base64').equals(original)).toBe(true) - }, 30_000) - }) - - // ─── Git ──────────────────────────────────────────────────────── - - describe('Git operations', () => { - beforeEach(() => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'file.txt'), 'initial') - gitCommit(tmpDir, 'initial commit') - }) - - it('git.diff returns original and modified content', async () => { - writeFileSync(path.join(tmpDir, 'file.txt'), 'updated content') - - const result = (await mux.request('git.diff', { - worktreePath: tmpDir, - filePath: 'file.txt', - staged: false - })) as { kind: string; originalContent: string; modifiedContent: string } - - expect(result.kind).toBe('text') - expect(result.originalContent).toBe('initial') - expect(result.modifiedContent).toBe('updated content') - }) - }) - - // ─── Error propagation ────────────────────────────────────────── - - describe('Error propagation', () => { - it('method-not-found error for unknown methods', async () => { - await expect(mux.request('nonexistent.method', {})).rejects.toThrow('Method not found') - }) - - it('handler errors propagate as JSON-RPC errors', async () => { - await expect( - mux.request('fs.readFile', { filePath: '/does/not/exist/at/all' }) - ).rejects.toThrow() - }) - }) - - // ─── Notifications ────────────────────────────────────────────── - - describe('Notifications', () => { - it('relay notifications reach the client mux', async () => { - const received: { method: string; params: Record }[] = [] - mux.onNotification((method, params) => { - received.push({ method, params }) - }) - - // Trigger a fs operation that causes the relay to send notifications - // (e.g., write a file — no notification expected for this, so we - // test notification plumbing directly via the relay dispatcher) - dispatcher.notify('custom.event', { key: 'value' }) - - // Wait for the async delivery through setImmediate - await new Promise((r) => setTimeout(r, 50)) - - expect(received.length).toBe(1) - expect(received[0].method).toBe('custom.event') - expect(received[0].params).toEqual({ key: 'value' }) - }) - }) -}) diff --git a/src/relay/managed-hook-installer.test.ts b/src/relay/managed-hook-installer.test.ts deleted file mode 100644 index 4f80225e06b..00000000000 --- a/src/relay/managed-hook-installer.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD } from '../shared/agent-hook-relay' -import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' -import { registerManagedHookInstaller, type ManagedHookRuntime } from './managed-hook-installer' - -function captureHandler(loadRuntime: () => ManagedHookRuntime): MethodHandler { - let handler: MethodHandler | undefined - registerManagedHookInstaller( - { - onRequest: (method, nextHandler) => { - expect(method).toBe(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) - handler = nextHandler - } - }, - loadRuntime - ) - return handler! -} - -function context(signal?: AbortSignal): RequestContext { - return { clientId: 1, isStale: () => signal?.aborted ?? false, signal } -} - -describe('registerManagedHookInstaller', () => { - it('forwards request cancellation to the remote runtime', async () => { - const controller = new AbortController() - const installManagedHooks = vi.fn().mockResolvedValue({ installers: 14, errors: 0 }) - const handler = captureHandler(() => ({ installManagedHooks })) - - await expect(handler({ agents: ['codex'] }, context(controller.signal))).resolves.toEqual({ - installers: 14, - errors: 0 - }) - expect(installManagedHooks).toHaveBeenCalledWith({ - signal: controller.signal, - agents: ['codex'] - }) - }) - - it('does not load or start the runtime for an already-cancelled request', async () => { - const controller = new AbortController() - controller.abort() - const loadRuntime = vi.fn() - const handler = captureHandler(loadRuntime) - - await expect(handler({}, context(controller.signal))).rejects.toMatchObject({ - name: 'AbortError' - }) - expect(loadRuntime).not.toHaveBeenCalled() - }) - - it('forwards only a valid negotiated server-key fingerprint', async () => { - const installManagedHooks = vi.fn().mockResolvedValue({ installers: 14, errors: 0 }) - const handler = captureHandler(() => ({ installManagedHooks })) - const fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - - await handler({ hostKeyFingerprint: fingerprint, agents: ['codex'] }, context()) - await handler({ hostKeyFingerprint: 'ssh://untrusted-host', agents: ['codex'] }, context()) - - expect(installManagedHooks).toHaveBeenNthCalledWith(1, { - signal: undefined, - hostKeyFingerprint: fingerprint, - agents: ['codex'] - }) - expect(installManagedHooks).toHaveBeenNthCalledWith(2, { - signal: undefined, - agents: ['codex'] - }) - }) - - it('fails closed when the detected agent allowlist is omitted', async () => { - const installManagedHooks = vi.fn().mockResolvedValue({ installers: 0, errors: 0 }) - const handler = captureHandler(() => ({ installManagedHooks })) - - await handler({}, context()) - - expect(installManagedHooks).toHaveBeenCalledWith({ - signal: undefined, - agents: [] - }) - }) - - it('validates, deduplicates, and forwards the detected agent allowlist', async () => { - const installManagedHooks = vi.fn().mockResolvedValue({ installers: 1, errors: 0 }) - const handler = captureHandler(() => ({ installManagedHooks })) - - await handler({ agents: ['codex', 'codex'] }, context()) - - expect(installManagedHooks).toHaveBeenCalledWith({ - signal: undefined, - agents: ['codex'] - }) - await expect(handler({ agents: ['unknown'] }, context())).rejects.toThrow( - 'invalid_managed_hook_agents' - ) - }) - - it('forwards only a parseable Claude execution-host version', async () => { - const installManagedHooks = vi.fn().mockResolvedValue({ installers: 1, errors: 0 }) - const handler = captureHandler(() => ({ installManagedHooks })) - - await handler({ agents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' }, context()) - await handler({ agents: ['claude'], claudeVersion: 'unknown' }, context()) - - expect(installManagedHooks).toHaveBeenNthCalledWith(1, { - signal: undefined, - agents: ['claude'], - claudeVersion: '2.1.261' - }) - expect(installManagedHooks).toHaveBeenNthCalledWith(2, { - signal: undefined, - agents: ['claude'] - }) - }) -}) diff --git a/src/relay/managed-hook-installer.ts b/src/relay/managed-hook-installer.ts deleted file mode 100644 index 20c4fde66b8..00000000000 --- a/src/relay/managed-hook-installer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { join } from 'node:path' -import { - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - type AgentHookInstallManagedHooksParams -} from '../shared/agent-hook-relay' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import type { AgentHookTarget } from '../shared/agent-hook-types' -import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets' -import { parseClaudeCliVersion } from '../main/claude/claude-hook-event-versions' - -export type ManagedHookInstallSummary = { - installers: number - errors: number -} - -export type ManagedHookRuntime = { - installManagedHooks: (options?: { - signal?: AbortSignal - hostKeyFingerprint?: string - agents?: readonly AgentHookTarget[] - claudeVersion?: string - }) => Promise -} - -const SHA256_HOST_KEY_PATTERN = /^SHA256:[A-Za-z\d+/]{43}$/ - -function readHostKeyFingerprint(params: unknown): string | undefined { - const fingerprint = (params as Partial | null) - ?.hostKeyFingerprint - return typeof fingerprint === 'string' && SHA256_HOST_KEY_PATTERN.test(fingerprint) - ? fingerprint - : undefined -} - -function readAgents(params: unknown): AgentHookTarget[] { - const raw = (params as Partial | null)?.agents - if (raw === undefined) { - return [] - } - if (!Array.isArray(raw) || !raw.every(isManagedAgentHookTarget)) { - throw new Error('invalid_managed_hook_agents') - } - return [...new Set(raw)] -} - -function readClaudeVersion(params: unknown): string | undefined { - const raw = - params !== null && typeof params === 'object' && 'claudeVersion' in params - ? params.claudeVersion - : null - return parseClaudeCliVersion(typeof raw === 'string' ? raw : null) ?? undefined -} - -let managedHookRuntime: ManagedHookRuntime | null = null - -function loadManagedHookRuntime(): ManagedHookRuntime { - if (!managedHookRuntime) { - // Why: keep the sizeable installer implementation out of relay startup and - // its narrow TS project while still executing it in-process on the remote. - managedHookRuntime = require(join(__dirname, 'managed-hook-runtime.js')) as ManagedHookRuntime - } - return managedHookRuntime -} - -export function registerManagedHookInstaller( - dispatcher: Pick, - loadRuntime: () => ManagedHookRuntime = loadManagedHookRuntime -): void { - dispatcher.onRequest( - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - async (params, context: RequestContext): Promise => { - context.signal?.throwIfAborted() - const hostKeyFingerprint = readHostKeyFingerprint(params) - const agents = readAgents(params) - const claudeVersion = readClaudeVersion(params) - return await loadRuntime().installManagedHooks({ - signal: context.signal, - ...(hostKeyFingerprint ? { hostKeyFingerprint } : {}), - agents, - ...(claudeVersion ? { claudeVersion } : {}) - }) - } - ) -} diff --git a/src/relay/mock-descendant-sweep.ts b/src/relay/mock-descendant-sweep.ts deleted file mode 100644 index 8af046417f9..00000000000 --- a/src/relay/mock-descendant-sweep.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { vi } from 'vitest' - -// Mock PTYs reuse the runner PID; never enumerate or signal its real descendants. -vi.mock('../main/pty-descendant-termination', () => ({ - killWithDescendantSweep: async (_pid: number, killRoot: () => void): Promise => { - killRoot() - } -})) diff --git a/src/relay/node-pty-binding-survey.test.ts b/src/relay/node-pty-binding-survey.test.ts deleted file mode 100644 index 8787713c117..00000000000 --- a/src/relay/node-pty-binding-survey.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { - chmodSync, - mkdirSync, - mkdtempSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import process from 'node:process' -import { afterEach, describe, expect, it } from 'vitest' -import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' -import { - collectNodePtyUnavailableDiagnosis, - readNodeGypBuildRecord, - resolveNodePtyInstallDir, - surveyNodePtyBinding -} from './node-pty-binding-survey' -import { formatNodePtyUnavailableMessage } from './node-pty-unavailable-diagnosis' - -const HOST = { platform: process.platform, arch: process.arch } -/** What node-pty throws once its loader has replaced the real cause with its last miss. */ -const FLATTENED = - 'Failed to load native module: pty.node, checked: build/Release, build/Debug, ' + - `prebuilds/${process.platform}-${process.arch}: Error: Cannot find module './pty.node'` - -const roots: string[] = [] - -function fixture(options: { binding?: boolean; configGypi?: string } = {}): string { - const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) - roots.push(root) - const dir = join(root, 'node-pty') - mkdirSync(join(dir, 'lib'), { recursive: true }) - writeFileSync(join(dir, 'lib', 'index.js'), 'module.exports = {}\n') - writeFileSync(join(dir, 'lib', 'utils.js'), 'exports.loadNativeModule = () => ({})\n') - if (options.binding) { - mkdirSync(join(dir, 'build', 'Release'), { recursive: true }) - // Deliberately not a valid addon: the point is to make the dynamic loader talk. - for (const name of ['pty.node', 'conpty.node']) { - writeFileSync(join(dir, 'build', 'Release', name), 'not an addon\n') - } - } - if (options.configGypi !== undefined) { - mkdirSync(join(dir, 'build'), { recursive: true }) - writeFileSync(join(dir, 'build', 'config.gypi'), options.configGypi) - } - return dir -} - -afterEach(() => { - while (roots.length > 0) { - rmSync(roots.pop()!, { recursive: true, force: true }) - } -}) - -describe('surveyNodePtyBinding', () => { - it('finds the file node-pty itself would open, and lists where it looked when there is none', () => { - const withBinding = surveyNodePtyBinding(fixture({ binding: true }), HOST) - expect(withBinding?.bindingPath).toMatch(/build[/\\]Release[/\\](con)?pty\.node$/) - - const without = surveyNodePtyBinding(fixture(), HOST) - expect(without?.bindingPath).toBeNull() - expect(without?.searched).toHaveLength(3) - expect(without?.searched.join(' ')).toContain(`prebuilds/${process.platform}-${process.arch}`) - }) -}) - -describe('readNodeGypBuildRecord', () => { - it('reads what node-gyp configured for, past its leading comment lines', () => { - const dir = fixture({ - configGypi: - '# Do not edit. File was generated by node-gyp\'s "configure" step\n' + - '{ "variables": { "node_module_version": 127, "target_arch": "arm64" } }\n' - }) - expect(readNodeGypBuildRecord(dir)).toEqual({ nodeAbi: '127', arch: 'arm64' }) - }) - - it('answers nothing rather than guessing when there is no build record', () => { - expect(readNodeGypBuildRecord(fixture())).toEqual({ nodeAbi: null, arch: null }) - }) -}) - -describe('resolveNodePtyInstallDir', () => { - it('finds the install an ancestor node_modules supplies, as the bare import does', () => { - // realpath: the resolver answers the real path, and macOS tmpdir is a link. - const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-node-pty-'))) - roots.push(root) - const installDir = join(root, 'node_modules', 'node-pty') - mkdirSync(installDir, { recursive: true }) - writeFileSync(join(installDir, 'package.json'), '{}\n') - - expect(resolveNodePtyInstallDir(join(root, 'relay', 'abc123'))).toBe(installDir) - }) - - it('answers nothing when no node_modules up the tree holds node-pty', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) - roots.push(root) - - expect(resolveNodePtyInstallDir(root)).toBeNull() - }) -}) - -describe('collectNodePtyUnavailableDiagnosis', () => { - it("recovers the dynamic loader's own words that node-pty threw away", async () => { - // The whole defect in one assertion: what reaches the relay is FLATTENED, which names - // no cause; the diagnosis must carry what the loader actually said about the file. - const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: fixture({ binding: true }), - error: new Error(FLATTENED) - }) - expect(diagnosis.status).toBe('blocked') - expect(diagnosis.rawError).toBeTruthy() - expect(isFlattenedNodePtyLoaderMessage(diagnosis.rawError!)).toBe(false) - expect(diagnosis.rawError).not.toBe(FLATTENED) - expect(diagnosis.rawError).toMatch(/pty\.node/) - }, 20_000) - - it("prefers node-gyp's build record over a loader message that named no fault", async () => { - const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: fixture({ - binding: true, - configGypi: '{ "variables": { "node_module_version": 4242, "target_arch": "x64" } }' - }), - error: new Error(FLATTENED) - }) - // A garbage binary reads differently per platform (mach-o vs ELF), so only assert the - // build record is consulted when the loader message did not name the fault itself. - if (diagnosis.reason === 'abi_mismatch') { - expect(formatNodePtyUnavailableMessage(diagnosis)).toContain( - `built for Node ABI 4242, this host runs ABI ${process.versions.modules}` - ) - } else { - expect(['arch_mismatch', 'load_failed', 'load_crashed']).toContain(diagnosis.reason) - } - }, 20_000) - - it('diagnoses a node-pty directory that does not exist instead of calling it unverifiable (#20386)', async () => { - // What a Linux host without a compiler gets: the deploy reinstalls with node-pty removed. - const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) - roots.push(root) - const missingDir = join(root, 'node-pty') - const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: missingDir, - error: new Error(`no node-pty at ${join(missingDir, 'lib', 'index.js')}`) - }) - expect(diagnosis.status).toBe('blocked') - expect(['toolchain_missing', 'dependency_missing']).toContain(diagnosis.reason) - expect(diagnosis.survey).toMatchObject({ installed: false, bindingPath: null }) - expect(diagnosis.toolchain === null).toBe(process.platform !== 'linux') - expect(formatNodePtyUnavailableMessage(diagnosis)).toContain( - `node-pty is not installed at ${missingDir}` - ) - }, 20_000) - - it.skipIf(process.platform === 'win32')( - 'treats a node-pty link whose target is gone as not installed', - async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) - roots.push(root) - const linked = join(root, 'node-pty') - symlinkSync(join(root, 'gone'), linked) - const diagnosis = await collectNodePtyUnavailableDiagnosis({ nodePtyDir: linked }) - expect(diagnosis.survey).toMatchObject({ installed: false, bindingPath: null }) - }, - 20_000 - ) - - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'keeps a directory it was refused as unverifiable rather than absent', - async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) - roots.push(root) - const locked = join(root, 'locked') - mkdirSync(join(locked, 'node-pty'), { recursive: true }) - chmodSync(locked, 0o000) - try { - const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: join(locked, 'node-pty'), - error: new Error(FLATTENED) - }) - expect(diagnosis.status).toBe('unverifiable') - expect(diagnosis.detail).toContain('EACCES') - const text = formatNodePtyUnavailableMessage(diagnosis) - expect(text).toContain('could not establish why') - // It still has to be reportable: the raw error is the only thing an issue can quote. - expect(text).toContain(FLATTENED) - } finally { - chmodSync(locked, 0o755) - } - } - ) - - it('probes the host toolchain only when nothing was compiled', async () => { - const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: fixture(), - error: new Error(FLATTENED) - }) - expect(diagnosis.survey?.bindingPath).toBeNull() - expect(['toolchain_missing', 'dependency_missing']).toContain(diagnosis.reason) - // Non-Linux hosts ship a node-pty prebuild, so a toolchain answer there would be noise. - expect(diagnosis.toolchain === null).toBe(process.platform !== 'linux') - - const compiled = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: fixture({ binding: true }), - error: new Error(FLATTENED) - }) - expect(compiled.toolchain).toBeNull() - }, 20_000) -}) diff --git a/src/relay/node-pty-binding-survey.ts b/src/relay/node-pty-binding-survey.ts deleted file mode 100644 index 5e8b0edb935..00000000000 --- a/src/relay/node-pty-binding-survey.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Gather the evidence a node-pty spawn failure needs, on the host that failed. - * - * Three sources, because no single one is sufficient: - * - * 1. What is on disk where node-pty's loader looks, and what node-gyp recorded it was - * configured for (`build/config.gypi`). This answers "wrong ABI / wrong arch" even - * when the loader said nothing useful, and it is the only source available when the - * binding is absent entirely. - * 2. The dynamic loader's own words, recovered by dlopen'ing the file node-pty would - * have opened. node-pty's loader rethrows only its LAST attempt, so the real message - * is otherwise destroyed before the relay sees it. This runs in a CHILD process: a - * binding that aborts inside the loader would take the relay down with it, and a - * relay that dies is a reconnect loop rather than an error message. - * 3. The host's C/C++ toolchain, but only when nothing was compiled — "install - * build-essential" is the right answer for a compile that never ran, and noise for a - * binary that exists and is simply wrong. - * - * Every step is best-effort and failure-tolerant: whatever cannot be established is - * reported as unestablished rather than guessed (docs/reference/ssh-execution-boundary.md). - */ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' -import { release } from 'node:os' -import process from 'node:process' -import { runProcess } from '@orca/process-host' -import { - buildToolchainProbeCommand, - parseBuildToolchainProbe, - type BuildToolchainStatus -} from '../main/ssh/build-toolchain-diagnosis' -import { detectNativeHostAbi } from '../main/orcad/native-host-abi' -import { - buildNodePtyLoadProbeScript, - readNodePtyProbeOutcome -} from '../main/orcad/node-pty-precondition' -import { - diagnoseNodePtyUnavailable, - type NodePtyBindingSurvey, - type NodePtyDiagnosisInput, - type NodePtyUnavailableDiagnosis, - type NodePtyUnavailableHost -} from './node-pty-unavailable-diagnosis' - -/** Bounded so a wedged loader delays one spawn rejection, not the relay. */ -const LOAD_PROBE_TIMEOUT_MS = 10_000 -const TOOLCHAIN_PROBE_TIMEOUT_MS = 5_000 - -/** node-pty's own search order, so the file surveyed is the file it would have opened. */ -function bindingSearchDirs(platform: NodeJS.Platform, arch: string): string[] { - return ['build/Release', 'build/Debug', `prebuilds/${platform}-${arch}`] -} - -/** Windows defers to conpty.node on builds that have ConPTY, exactly as node-pty picks it. */ -function bindingBaseName(platform: NodeJS.Platform): string { - if (platform !== 'win32') { - return 'pty' - } - return Number(release().split('.')[2]) >= 18309 ? 'conpty' : 'pty' -} - -export function surveyNodePtyBinding( - nodePtyDir: string, - host: Pick -): NodePtyBindingSurvey | null { - const name = bindingBaseName(host.platform) - const searched = bindingSearchDirs(host.platform, host.arch) - let bindingPath: string | null = null - try { - for (const dir of searched) { - for (const root of [nodePtyDir, join(nodePtyDir, 'lib')]) { - const candidate = join(root, dir, `${name}.node`) - if (existsSync(candidate)) { - bindingPath = candidate - break - } - } - if (bindingPath) { - break - } - } - } catch { - return null - } - const built = readNodeGypBuildRecord(nodePtyDir) - return { - moduleDir: nodePtyDir, - installed: true, - bindingPath, - searched, - builtNodeAbi: built.nodeAbi, - builtArch: built.arch - } -} - -/** - * What node-gyp configured this build for. - * - * Why this file and not the binary: `build/config.gypi` is written by `node-gyp - * configure` from the headers it downloaded, so it names the ABI and architecture the - * `.node` was compiled against without parsing ELF. It survives a build that later - * failed, which is the case where the loader has nothing to say. - */ -export function readNodeGypBuildRecord(nodePtyDir: string): { - nodeAbi: string | null - arch: string | null -} { - try { - const raw = readFileSync(join(nodePtyDir, 'build', 'config.gypi'), 'utf8') - // node-gyp prefixes the JSON with `# Do not edit…` comment lines. - const body = raw - .split('\n') - .filter((line) => !line.trim().startsWith('#')) - .join('\n') - const variables = (JSON.parse(body) as { variables?: Record }).variables - const nodeAbi = variables?.node_module_version - const arch = variables?.target_arch - return { - nodeAbi: nodeAbi === undefined || nodeAbi === null ? null : String(nodeAbi), - arch: typeof arch === 'string' && arch.length > 0 ? arch : null - } - } catch { - return { nodeAbi: null, arch: null } - } -} - -/** - * The loader's verdict on the binding, recovered out of process. - * - * Returns the pieces `diagnoseNodePtyUnavailable` reads; a probe that could not run - * answers `unverifiableBecause` rather than a cause, because it established nothing. - */ -async function probeNodePtyLoader( - nodePtyDir: string -): Promise> { - let result - try { - result = await runProcess({ - program: process.execPath, - args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)], - timeoutMs: LOAD_PROBE_TIMEOUT_MS - }) - } catch (error) { - return { - unverifiableBecause: `the node-pty load probe could not be started (${(error as Error).message})` - } - } - const outcome = readNodePtyProbeOutcome(result) - switch (outcome.kind) { - case 'loaderError': - return { loaderError: outcome.message } - case 'signalled': - return { probeSignal: outcome.signal } - case 'unanswered': - return { unverifiableBecause: outcome.detail } - // `loaded` here means the binding is fine under plain Node while the relay's own - // require failed — real, and not something the loader can explain. `noBinary` and - // `unexplained` are both better answered by the on-disk survey than by the probe. - case 'loaded': - case 'noBinary': - case 'unexplained': - return {} - } -} - -/** node-pty has no Linux prebuild, so only there does a missing toolchain explain anything. */ -async function probeRelayBuildToolchain( - platform: NodeJS.Platform -): Promise { - if (platform !== 'linux') { - return null - } - try { - const result = await runProcess({ - program: '/bin/sh', - args: ['-c', buildToolchainProbeCommand()], - timeoutMs: TOOLCHAIN_PROBE_TIMEOUT_MS - }) - return result.timedOut ? null : parseBuildToolchainProbe(result.stdout) - } catch { - return null - } -} - -/** The install a bare `node-pty` import from `fromDir` loads — Node walks up, so it can be an ancestor's. */ -export function resolveNodePtyInstallDir(fromDir: string): string | null { - try { - return dirname(createRequire(join(fromDir, 'relay.js')).resolve('node-pty/package.json')) - } catch { - return null - } -} - -// Only ENOENT is absence — the relay observing its own host. `stat` judges the directory the -// loader reads, so a dangling link is absent; `existsSync` would also answer false for EACCES. -function readNodePtyDirPresence( - nodePtyDir: string -): 'present' | 'absent' | { unverifiable: string } { - try { - statSync(nodePtyDir) - return 'present' - } catch (error) { - const code = error instanceof Error && 'code' in error ? String(error.code) : null - if (code === 'ENOENT') { - return 'absent' - } - return { - unverifiable: `the relay could not read its node-pty install directory (${code ?? readErrorMessage(error) ?? 'unknown error'})` - } - } -} - -function readErrorMessage(error: unknown): string | null { - if (error instanceof Error) { - return error.message - } - return typeof error === 'string' && error.length > 0 ? error : null -} - -/** - * Everything above, in the order that makes each step's cost conditional on the previous - * one's answer. Called only on the failure path, so a spawn that works pays nothing. - */ -export async function collectNodePtyUnavailableDiagnosis(options: { - nodePtyDir: string - error?: unknown -}): Promise { - const abi = detectNativeHostAbi() - const host: NodePtyUnavailableHost = { ...abi, nodeVersion: process.version } - const requireError = readErrorMessage(options.error) - const presence = readNodePtyDirPresence(options.nodePtyDir) - if (typeof presence === 'object') { - return diagnoseNodePtyUnavailable({ - host, - survey: null, - requireError, - unverifiableBecause: presence.unverifiable - }) - } - const survey: NodePtyBindingSurvey | null = - presence === 'absent' - ? { - moduleDir: options.nodePtyDir, - installed: false, - bindingPath: null, - searched: [], - builtNodeAbi: null, - builtArch: null - } - : surveyNodePtyBinding(options.nodePtyDir, host) - const probed = survey?.bindingPath ? await probeNodePtyLoader(options.nodePtyDir) : {} - const toolchain = - survey && !survey.bindingPath ? await probeRelayBuildToolchain(host.platform) : null - return diagnoseNodePtyUnavailable({ ...probed, host, survey, requireError, toolchain }) -} diff --git a/src/relay/node-pty-unavailable-diagnosis.test.ts b/src/relay/node-pty-unavailable-diagnosis.test.ts deleted file mode 100644 index ca661e4bd2e..00000000000 --- a/src/relay/node-pty-unavailable-diagnosis.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - diagnoseNodePtyUnavailable, - formatNodePtyUnavailableMessage, - type NodePtyBindingSurvey, - type NodePtyDiagnosisInput, - toTerminalUnavailableCause, - type NodePtyUnavailableHost -} from './node-pty-unavailable-diagnosis' -import { parseBuildToolchainProbe } from '../main/ssh/build-toolchain-diagnosis' -import { - mayRepairFromCause, - parseTerminalUnavailableCause -} from '../shared/terminal-unavailable-cause' - -const UBUNTU_2004: NodePtyUnavailableHost = { - platform: 'linux', - arch: 'x64', - libc: 'glibc', - glibcVersion: '2.31', - nodeAbi: '115', - nodeVersion: 'v20.11.0' -} - -const MODULE_DIR = '/opt/orca/relay/node_modules/node-pty' -const SEARCHED = ['build/Release', 'build/Debug', 'prebuilds/linux-x64'] - -const INSTALLED: NodePtyBindingSurvey = { - moduleDir: MODULE_DIR, - installed: true, - bindingPath: `${MODULE_DIR}/build/Release/pty.node`, - searched: SEARCHED, - builtNodeAbi: null, - builtArch: null -} - -const NOTHING_INSTALLED: NodePtyBindingSurvey = { ...INSTALLED, bindingPath: null } - -/** What node-pty itself throws: the real cause replaced by its LAST directory miss. */ -const FLATTENED = - 'Failed to load native module: pty.node, checked: build/Release, build/Debug, ' + - "prebuilds/linux-x64: Error: Cannot find module '../prebuilds/linux-x64//pty.node'" - -const diagnose = (overrides: Partial = {}) => - diagnoseNodePtyUnavailable({ - host: UBUNTU_2004, - survey: INSTALLED, - requireError: FLATTENED, - ...overrides - }) - -const message = (overrides: Partial = {}) => - formatNodePtyUnavailableMessage(diagnose(overrides)) - -const toolchain = (present: readonly string[]) => - parseBuildToolchainProbe([...present.map((tool) => `HAVE ${tool}`), 'PKG apt-get'].join('\n')) - -describe('diagnoseNodePtyUnavailable', () => { - it("never treats node-pty's flattened wrapper as the cause", () => { - // node-pty rethrows only its last directory miss, so acting on that text sends the - // user to install a module that is already installed. - expect( - diagnose({ survey: NOTHING_INSTALLED, toolchain: toolchain(['make', 'g++', 'python3']) }) - ).toMatchObject({ reason: 'dependency_missing' }) - expect(diagnose().reason).not.toBe('dependency_missing') - }) - - it('names the glibc the host actually has next to the one the binary needs', () => { - const verdict = diagnose({ - loaderError: - "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /opt/orca/node_modules/node-pty/build/Release/pty.node)" - }) - expect(verdict).toMatchObject({ status: 'blocked', reason: 'libc_floor' }) - const text = formatNodePtyUnavailableMessage(verdict) - expect(text).toContain('GLIBC_2.34') - expect(text).toContain('glibc 2.31') - // The remedy is a rebuild; offering "install build-essential" here is a wrong answer. - expect(text).not.toContain('build tools') - }) - - it('names both ABI numbers from the loader message', () => { - const text = message({ - loaderError: - 'The module was compiled against a different Node.js version using NODE_MODULE_VERSION 115. ' + - 'This version of Node.js requires NODE_MODULE_VERSION 127.' - }) - expect(text).toContain('built for Node ABI 115, this host runs ABI 127') - expect(text).toContain('v20.11.0') - }) - - it("reads the ABI mismatch off node-gyp's build record when the loader said nothing", () => { - // The case the old message could only hedge about: the binding is present, node-pty - // destroyed the loader error, and the only evidence left is what node-gyp configured. - const text = message({ - survey: { ...INSTALLED, builtNodeAbi: '127' } - }) - expect(text).toContain('built for Node ABI 127, this host runs ABI 115') - }) - - it('separates an architecture mismatch from an ABI mismatch', () => { - expect(diagnose({ loaderError: 'invalid ELF header' }).reason).toBe('arch_mismatch') - expect(message({ survey: { ...INSTALLED, builtArch: 'arm64' } })).toContain( - 'built for arm64, this host runs x64' - ) - expect( - message({ - loaderError: - "dlopen(/opt/pty.node, 0x0001): tried: '/opt/pty.node' (mach-o file, but is an " + - "incompatible architecture (have 'arm64', need 'x86_64'))" - }) - ).toContain('built for arm64, this host needs x86_64') - }) - - it('separates a missing shared library from a libc floor break', () => { - // Different remedies: install a package, versus rebuild against an older toolchain. - const verdict = diagnose({ - loaderError: 'libstdc++.so.6: cannot open shared object file: No such file or directory' - }) - expect(verdict.reason).toBe('shared_library_missing') - const text = formatNodePtyUnavailableMessage(verdict) - expect(text).toContain('libstdc++.so.6 is not installed on this host') - expect(text).toContain('Install that library') - }) - - it('offers the build-tools remedy only when it probed the toolchain and found it missing', () => { - const missing = diagnose({ - survey: NOTHING_INSTALLED, - toolchain: toolchain(['python3']) - }) - expect(missing.reason).toBe('toolchain_missing') - const text = formatNodePtyUnavailableMessage(missing) - expect(text).toContain('make and a C++ compiler are not installed') - expect(text).toContain(`checked ${SEARCHED.join(', ')} under ${MODULE_DIR}`) - expect(text).toContain('sudo apt-get install -y build-essential python3') - - // Toolchain present and nothing compiled: the install failed for another reason, and - // "install make/g++/python3" would send the user chasing tools they already have. - const present = diagnose({ - survey: NOTHING_INSTALLED, - toolchain: toolchain(['make', 'g++', 'python3']) - }) - expect(present.reason).toBe('dependency_missing') - expect(formatNodePtyUnavailableMessage(present)).not.toContain('apt-get') - expect(formatNodePtyUnavailableMessage(present)).toContain( - 'build tools needed to compile it are present' - ) - }) - - it('never claims the build tools are present when no toolchain probe answered', () => { - // docs/reference/ssh-execution-boundary.md: a probe that timed out established nothing. - const linuxUnchecked = message({ survey: NOTHING_INSTALLED, toolchain: null }) - expect(linuxUnchecked).not.toContain('are present') - expect(linuxUnchecked).toContain('could not be checked') - expect(linuxUnchecked).toContain('Reconnect to reinstall') - - // Off Linux the probe never runs: node-pty ships prebuilds, so tools are beside the point. - const macos = message({ - host: { ...UBUNTU_2004, platform: 'darwin', libc: 'none', glibcVersion: null }, - survey: NOTHING_INSTALLED, - toolchain: null - }) - expect(macos).not.toContain('build tools') - expect(macos).toContain('Reconnect to reinstall') - }) - - it('names an absent node-pty directory as not installed and still offers the build tools (#20386)', () => { - // The no-toolchain deploy removes node-pty outright, so there is no directory to search. - const text = message({ - survey: { ...NOTHING_INSTALLED, installed: false, searched: [] }, - toolchain: toolchain(['python3']) - }) - expect(text).toContain(`node-pty is not installed at ${MODULE_DIR}`) - expect(text).toContain('sudo apt-get install -y build-essential python3') - expect(text).not.toContain('reconnect to retry') - }) - - it('does not blame the build tools for an absent directory on a host that has them', () => { - // The node-pty-less reinstall also leaves no directory when it fails for a non-toolchain - // reason (ENOSPC, registry unreachable). Naming tools the host already has is the - // confidently-wrong diagnosis #20386's fix must not introduce. - const verdict = diagnose({ - survey: { ...NOTHING_INSTALLED, installed: false, searched: [] }, - toolchain: toolchain(['make', 'g++', 'python3']) - }) - expect(verdict.reason).toBe('dependency_missing') - const text = formatNodePtyUnavailableMessage(verdict) - expect(text).toContain(`node-pty is not installed at ${MODULE_DIR}`) - expect(text).toContain('build tools needed to compile it are present') - expect(text).not.toContain('apt-get') - expect(text).not.toContain('not installed.') - }) - - it('reports a binding that killed the probe as a crash rather than a miss', () => { - const text = message({ probeSignal: 'SIGSEGV' }) - expect(text).toContain('SIGSEGV') - expect(text).toContain('incompatible with this host rather than missing') - }) - - it('quotes the loader verbatim when nothing recognizes it', () => { - const raw = 'dlopen(/opt/pty.node): unexpected relocation kind 0x9f' - const verdict = diagnose({ loaderError: raw }) - expect(verdict).toMatchObject({ status: 'blocked', reason: 'load_failed', rawError: raw }) - const text = formatNodePtyUnavailableMessage(verdict) - expect(text).toContain(`Loader error: ${raw}`) - expect(text).toContain('file an issue') - }) - - it('reports a probe that never answered as unverifiable and diagnoses nothing', () => { - // docs/reference/ssh-execution-boundary.md: loss of contact is not a verdict. - const verdict = diagnose({ - unverifiableBecause: 'the node-pty load probe did not finish in time' - }) - expect(verdict.status).toBe('unverifiable') - const text = formatNodePtyUnavailableMessage(verdict) - expect(text).toContain('could not establish why') - expect(text).toContain('not evidence node-pty is broken') - expect(text).not.toContain('Reconnect to rebuild') - expect(text).not.toContain('apt-get') - }) - - it('marks rebuildable faults repairable and everything else not', () => { - // The relay's node-pty is compiled ON the remote, so a binding that no longer matches - // the machine is fixed by recompiling there. A missing compiler or a missing library - // is not: the rebuild would need the very thing that is absent. - const repairable = (overrides: Partial) => - toTerminalUnavailableCause(diagnose(overrides)).repairable - - expect(repairable({ survey: { ...INSTALLED, builtNodeAbi: '127' } })).toBe(true) - expect(repairable({ survey: { ...INSTALLED, builtArch: 'arm64' } })).toBe(true) - expect(repairable({ loaderError: "version `GLIBC_2.34' not found" })).toBe(true) - expect(repairable({ probeSignal: 'SIGSEGV' })).toBe(true) - expect( - repairable({ survey: NOTHING_INSTALLED, toolchain: toolchain(['make', 'g++', 'python3']) }) - ).toBe(true) - - expect(repairable({ survey: NOTHING_INSTALLED, toolchain: toolchain(['python3']) })).toBe(false) - expect(repairable({ loaderError: 'libstdc++.so.6: cannot open shared object file' })).toBe( - false - ) - // Nothing was established, so nothing may be rewritten on the host (#14830). - expect(repairable({ unverifiableBecause: 'probe timed out' })).toBe(false) - }) - - it('publishes a cause that survives its own wire schema', () => { - const cause = toTerminalUnavailableCause( - diagnose({ loaderError: "version `GLIBC_2.34' not found" }) - ) - expect(parseTerminalUnavailableCause(cause)).toEqual(cause) - expect(mayRepairFromCause(cause)).toBe(true) - expect(cause.host).toMatchObject({ arch: 'x64', nodeAbi: '115', glibcVersion: '2.31' }) - - // A peer claiming repairable on an unverifiable status must not be believed. - expect(mayRepairFromCause({ ...cause, status: 'unverifiable' })).toBe(false) - expect(parseTerminalUnavailableCause({ ...cause, host: undefined })).toBeNull() - // A reason this client has never heard of must not discard the whole cause; the - // relay may name faults added after the client shipped. - expect(parseTerminalUnavailableCause({ ...cause, reason: 'invented_later' })).not.toBeNull() - }) - - it('puts the host on every message so a bug report needs no follow-up question', () => { - for (const overrides of [ - {}, - { loaderError: 'invalid ELF header' }, - { unverifiableBecause: 'probe timed out' } - ]) { - expect(message(overrides)).toContain( - 'linux/x64, glibc 2.31, Node v20.11.0 (ABI 115), prebuild slot linux-x64-glibc' - ) - } - }) -}) diff --git a/src/relay/node-pty-unavailable-diagnosis.ts b/src/relay/node-pty-unavailable-diagnosis.ts deleted file mode 100644 index b8d8fdbdda5..00000000000 --- a/src/relay/node-pty-unavailable-diagnosis.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * Why the remote host cannot spawn terminals, in terms the user can act on and check. - * - * The relay used to answer this with one hedged paragraph — "install build tools, or - * else reconnect, or else check your Node version" — because the only thing it looked at - * was that `require('node-pty')` threw. That paragraph names three different remedies for - * four different faults and lets the user verify none of them. - * - * node-pty's own loader is why the raw cause went missing: it walks build/Release, - * build/Debug and prebuilds/-, then rethrows only the LAST error. So a - * `pty.node` the dynamic loader refused arrives as `Cannot find module '../prebuilds/…'`, - * and the GLIBC/ABI/arch sentence that actually says what is wrong is discarded before - * the relay ever sees it. Recovering it needs a separate dlopen of the file the loader - * would have opened — see node-pty-binding-survey.ts. - * - * Everything here is pure so every verdict is testable from a host that is none of the - * hosts that break. `unverifiable` is a first-class outcome: a probe that did not answer - * is not a diagnosis (docs/reference/ssh-execution-boundary.md). - */ -import { GLIBC_FLOOR, nativeSlotName, type NativeHostAbi } from '../main/orcad/native-host-abi' -import { - classifyNodePtyLoaderMessage, - isFlattenedNodePtyLoaderMessage -} from '../main/orcad/node-pty-loader-diagnosis' -import { - toolchainInstallHintLines, - type BuildToolchainStatus -} from '../main/ssh/build-toolchain-diagnosis' -import type { RuntimeTerminalUnavailableReason } from '../shared/runtime-types' -import type { TerminalUnavailableCause } from '../shared/terminal-unavailable-cause' - -/** What is actually on disk where node-pty's loader looks, and what it was built for. */ -export type NodePtyBindingSurvey = { - /** The node-pty install the relay would load from. */ - moduleDir: string - /** False when `moduleDir` itself is absent — the no-toolchain deploy skips node-pty entirely. */ - installed: boolean - /** The compiled binding the loader would open, or null when no directory holds one. */ - bindingPath: string | null - /** Directories checked, so "nothing is installed" is a statement with evidence. */ - searched: string[] - /** `node_module_version` from node-gyp's build/config.gypi, when it is readable. */ - builtNodeAbi: string | null - /** `target_arch` from node-gyp's build/config.gypi, when it is readable. */ - builtArch: string | null -} - -export type NodePtyUnavailableHost = NativeHostAbi & { nodeVersion: string } - -export type NodePtyUnavailableDiagnosis = { - /** `blocked` — proved. `unverifiable` — nothing answered, which is not evidence. */ - status: 'blocked' | 'unverifiable' - reason: RuntimeTerminalUnavailableReason - host: NodePtyUnavailableHost - /** Short phrase naming the values found. */ - detail: string - /** The loader's own words, kept verbatim so an unclassified verdict is still reportable. */ - rawError: string | null - survey: NodePtyBindingSurvey | null - toolchain: BuildToolchainStatus | null -} - -export type NodePtyDiagnosisInput = { - /** What the recovered dlopen said, when one ran. Preferred over `requireError`. */ - loaderError?: string | null - /** What `require('node-pty')`/`pty.spawn` threw. Usually flattened by node-pty. */ - requireError?: string | null - /** A load probe that was killed rather than answering. */ - probeSignal?: NodeJS.Signals | null - /** Set when the load probe never answered at all; forces `unverifiable`. */ - unverifiableBecause?: string | null - host: NodePtyUnavailableHost - survey: NodePtyBindingSurvey | null - toolchain?: BuildToolchainStatus | null -} - -/** Reasons a loader message can establish on its own, and which nothing else outranks. */ -const LOADER_NAMED_FAULTS: ReadonlySet = new Set([ - 'abi_mismatch', - 'arch_mismatch', - 'libc_floor', - 'shared_library_missing' -]) - -export function diagnoseNodePtyUnavailable( - input: NodePtyDiagnosisInput -): NodePtyUnavailableDiagnosis { - const { host, survey } = input - const toolchain = input.toolchain ?? null - // Why the require error is only a fallback: node-pty flattens the real cause away, so - // its text is evidence of "did not load", never of why. - const usableRequireError = - input.requireError && !isFlattenedNodePtyLoaderMessage(input.requireError) - ? input.requireError - : null - // Capped because a macOS dlopen error lists every path it tried; the message quotes this - // verbatim when nothing classifies it, and a toast is not a log file. - const rawError = truncate(input.loaderError ?? usableRequireError ?? input.requireError ?? null) - const base = { host, rawError, survey, toolchain } as const - - if (input.unverifiableBecause) { - return { - ...base, - status: 'unverifiable', - reason: 'unknown', - detail: input.unverifiableBecause - } - } - // Before anything the loader said: a binary that aborts inside the loader never reaches - // a catch and often prints nothing, so the signal is the only evidence there is. - if (input.probeSignal) { - return { - ...base, - status: 'blocked', - reason: 'load_crashed', - detail: `loading the binding killed the probe with ${input.probeSignal}` - } - } - - const classifiable = input.loaderError ?? usableRequireError - const classified = classifiable ? classifyNodePtyLoaderMessage(classifiable) : null - // Only a loader message that named the fault outranks the build record. `load_failed` - // and `dependency_missing` do not: the first named nothing, and the second is what - // node-pty says about a binding it never reached. - if (classified && LOADER_NAMED_FAULTS.has(classified.reason)) { - return { ...base, status: 'blocked', ...classified } - } - - // The loader said nothing usable. The binding's own build record still can: node-gyp - // records the ABI and arch it configured for, and either differing from this runtime is - // a fault the user can check without reproducing the load. - if (survey?.bindingPath) { - if (survey.builtNodeAbi && survey.builtNodeAbi !== host.nodeAbi) { - return { - ...base, - status: 'blocked', - reason: 'abi_mismatch', - detail: `built for Node ABI ${survey.builtNodeAbi}, this host runs ABI ${host.nodeAbi}` - } - } - if (survey.builtArch && survey.builtArch !== host.arch) { - return { - ...base, - status: 'blocked', - reason: 'arch_mismatch', - detail: `built for ${survey.builtArch}, this host runs ${host.arch}` - } - } - return { - ...base, - status: 'blocked', - reason: classified?.reason ?? 'load_failed', - detail: classified?.detail ?? 'the binding is present but the loader refused it' - } - } - - if (!survey) { - return { - ...base, - status: 'unverifiable', - reason: 'unknown', - detail: "the relay could not read node-pty's install directory" - } - } - // Nothing compiled anywhere. On Linux that is either a compile that never ran for want - // of a toolchain, or an install that failed for some other reason — different remedies. - if (toolchain?.toolchainMissing) { - return { - ...base, - status: 'blocked', - reason: 'toolchain_missing', - detail: `no compiled binding exists and ${missingToolSummary(toolchain)} missing` - } - } - return { - ...base, - status: 'blocked', - reason: 'dependency_missing', - detail: 'no compiled node-pty binding exists on this host' - } -} - -const RAW_ERROR_MAX = 600 - -function truncate(message: string | null): string | null { - if (message === null || message.length <= RAW_ERROR_MAX) { - return message - } - return `${message.slice(0, RAW_ERROR_MAX)}…` -} - -function missingToolSummary(toolchain: BuildToolchainStatus): string { - const present = new Set(toolchain.present) - const missing: string[] = [] - if (!present.has('make')) { - missing.push('make') - } - if (!present.has('g++') && !present.has('c++') && !present.has('clang++')) { - missing.push('a C++ compiler') - } - if (!present.has('python3') && !present.has('python')) { - missing.push('python3') - } - if (missing.length <= 1) { - return `${missing[0] ?? 'the build tools'} is` - } - return `${missing.slice(0, -1).join(', ')} and ${missing.at(-1)} are` -} - -/** - * Faults a rebuild on the host actually fixes. - * - * The relay's node-pty is compiled ON the remote by `npm install`, so a binding that is - * absent, built for another Node ABI, built for another architecture, or linked against a - * newer libc than the host provides is all one thing: the compiled artifact no longer - * matches the machine, and recompiling here produces one that does. That is different - * from the packaged desktop app, where the binary is built elsewhere and the glibc floor - * in docs/reference/linux-glibc-compatibility.md is the binding constraint. - * - * Excluded on purpose: `toolchain_missing` (no compiler to rebuild with) and - * `shared_library_missing` (the compile would need the same absent library). - */ -const REBUILD_FIXES: ReadonlySet = new Set([ - 'abi_mismatch', - 'arch_mismatch', - 'libc_floor', - 'load_crashed', - 'dependency_missing' -]) - -/** - * The machine-readable cause, for a client that can act instead of printing. - * - * `repairable` requires a proved status AND a toolchain that is not known-missing: a - * rebuild the host cannot perform is not a repair, it is a wasted `npm install` — which - * is the shape of #14830. - */ -export function toTerminalUnavailableCause( - diagnosis: NodePtyUnavailableDiagnosis -): TerminalUnavailableCause { - const { host } = diagnosis - return { - status: diagnosis.status, - reason: diagnosis.reason, - detail: diagnosis.detail.slice(0, 400), - repairable: - diagnosis.status === 'blocked' && - REBUILD_FIXES.has(diagnosis.reason) && - diagnosis.toolchain?.toolchainMissing !== true, - host: { - platform: host.platform, - arch: host.arch, - libc: host.libc, - ...(host.glibcVersion ? { glibcVersion: host.glibcVersion } : {}), - nodeAbi: host.nodeAbi, - nodeVersion: host.nodeVersion - }, - ...(diagnosis.rawError ? { rawError: diagnosis.rawError.slice(0, 1000) } : {}) - } -} - -/** `linux/x64, glibc 2.31, Node v20.11.0 (ABI 115), prebuild slot linux-x64-glibc`. */ -function formatNodePtyHostLine(host: NodePtyUnavailableHost): string { - const libc = - host.libc === 'none' ? null : `${host.libc}${host.glibcVersion ? ` ${host.glibcVersion}` : ''}` - return [ - `${host.platform}/${host.arch}`, - libc, - `Node ${host.nodeVersion} (ABI ${host.nodeAbi})`, - `prebuild slot ${nativeSlotName(host)}` - ] - .filter((part): part is string => part !== null) - .join(', ') -} - -/** - * One remedy per fault, each naming a value the user can go and check. - * - * `unverifiable` deliberately prescribes nothing: the relay proved only that it could not - * establish a cause, and dressing that up as a diagnosis is the bug this replaces. - */ -export function formatNodePtyUnavailableMessage(diagnosis: NodePtyUnavailableDiagnosis): string { - const { host } = diagnosis - // Unverifiable deliberately prescribes nothing beyond a retry: nothing was established, - // and dressing that up as a diagnosis is the bug this replaces. - const opening = - diagnosis.status === 'unverifiable' - ? `Remote terminals are unavailable, and the relay could not establish why: ${diagnosis.detail}. ` + - `That is not evidence node-pty is broken — reconnect to retry.` - : `Remote terminals are unavailable: ${remedyFor(diagnosis)}` - const lines = [opening, `Host: ${formatNodePtyHostLine(host)}.`] - // Quoted only where nothing else named the fault: elsewhere the remedy already carries - // the numbers, and a dlopen dump would bury them. - const quoteRaw = - diagnosis.status === 'unverifiable' || - diagnosis.reason === 'load_failed' || - diagnosis.reason === 'unknown' - if (diagnosis.rawError && quoteRaw) { - lines.push(`Loader error: ${diagnosis.rawError}`) - } - return lines.join('\n') -} - -function remedyFor(diagnosis: NodePtyUnavailableDiagnosis): string { - const { host, survey, toolchain } = diagnosis - switch (diagnosis.reason) { - case 'toolchain_missing': - return ( - `node-pty ships no prebuilt binary for Linux and this host has no compiled one ` + - `(${searchedPhrase(survey)}), because ${toolchain ? missingToolSummary(toolchain) : 'the build tools are'} not installed. ` + - `Install them on the remote host, then reconnect:\n` + - `${(toolchain ? toolchainInstallHintLines(toolchain) : []).join('\n')}` - ) - case 'dependency_missing': - return ( - `node-pty has no compiled binary on this host (${searchedPhrase(survey)}). ` + - `${toolchainPresenceSentence(host, toolchain)}Reconnect to reinstall the relay's native modules.` - ) - case 'abi_mismatch': - return ( - `the installed node-pty binding was built for a different Node ABI than the remote's ` + - `Node — ${diagnosis.detail}. Reconnect to rebuild node-pty against ${host.nodeVersion}, ` + - `or run the relay on the Node version the binding was built for.` - ) - case 'arch_mismatch': - return ( - `the installed node-pty binding does not match this host's CPU architecture — ` + - `${diagnosis.detail}. Reconnect to rebuild node-pty on the remote host; a binding ` + - `copied from a machine of another architecture can never load here.` - ) - case 'libc_floor': - return ( - `${diagnosis.detail}, which this host's C library does not provide ` + - `(${host.glibcVersion ? `glibc ${host.glibcVersion}` : 'this host reports no glibc version'}). ` + - `The binding was compiled on a newer system than this one. Reconnect to rebuild ` + - `node-pty here; Orca's own Linux floor is glibc ${GLIBC_FLOOR}.` - ) - case 'shared_library_missing': - return ( - `node-pty's native binding cannot be opened because ${diagnosis.detail}. ` + - `Install that library on the remote host, then reconnect.` - ) - case 'load_crashed': - return ( - `${diagnosis.detail}, which means the binding is incompatible with this host rather ` + - `than missing. Reconnect to rebuild the relay's native modules.` - ) - case 'load_failed': - case 'spawn_helper_missing': - case 'unknown': - return ( - `this host refused to load node-pty's native binding and the cause was not recognized. ` + - `Reconnect to rebuild the relay's native modules; if that does not help, please file an ` + - `issue quoting the loader error below.` - ) - } -} - -// Claims "present" only off a probe that answered; the probe runs on Linux only, since -// node-pty ships prebuilds elsewhere and a reinstall there needs no compiler. -function toolchainPresenceSentence( - host: NodePtyUnavailableHost, - toolchain: BuildToolchainStatus | null -): string { - if (toolchain) { - return 'The C/C++ build tools needed to compile it are present. ' - } - if (host.platform === 'linux') { - return 'Whether the C/C++ build tools needed to compile it are installed could not be checked. ' - } - return '' -} - -function searchedPhrase(survey: NodePtyBindingSurvey | null): string { - if (survey && !survey.installed) { - return `node-pty is not installed at ${survey.moduleDir}` - } - return survey && survey.searched.length > 0 - ? `checked ${survey.searched.join(', ')} under ${survey.moduleDir}` - : 'nothing was found where node-pty looks' -} diff --git a/src/relay/omp-fresh-launch-environment.test.ts b/src/relay/omp-fresh-launch-environment.test.ts deleted file mode 100644 index a3ad51b502c..00000000000 --- a/src/relay/omp-fresh-launch-environment.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { runProcess } from '@orca/process-host' -import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' -import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import type * as NodeOs from 'node:os' -import { delimiter, join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { PtyHandler } from './pty-handler' -import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' -import { PluginOverlayManager } from '../wsl-guest/plugin-overlay' -import { withFreshOmpLaunch } from '../shared/omp-fresh-launch' - -const state = vi.hoisted(() => ({ home: '' })) -vi.mock('node:os', async (original) => ({ - ...(await original()), - homedir: () => state.home -})) -afterEach(() => { - vi.restoreAllMocks() - if (state.home) { - rmSync(state.home, { recursive: true, force: true }) - } -}) - -it('prepares the execution host OMP config and status extension for a guarded launch', async () => { - state.home = mkdtempSync(join(tmpdir(), 'orca-relay-fresh-')) - const dispatcher = new RelayDispatcher(() => {}) - const handler = new PtyHandler(dispatcher) - const augment = vi.spyOn(handler, 'addEnvAugmenter') - const source = vi.spyOn(PluginOverlayManager.prototype, 'hasPiSource').mockReturnValue(true) - const original = PluginOverlayManager.prototype.materializePi - vi.spyOn(PluginOverlayManager.prototype, 'materializePi').mockImplementation(function ( - this: PluginOverlayManager, - ...args - ) { - this.setSources({ ompExtensionSource: '// Orca managed test status extension' }) - return original.apply(this, args) - }) - const runtime = new RelayAgentHookRuntime( - dispatcher, - handler, - join(state.home, 'relay.sock'), - join(state.home, 'hooks') - ) - await runtime.start() - try { - const environment = await augment.mock.calls[1][0]({ - id: 'fresh-pane', - shell: '/bin/bash', - env: { HOME: state.home }, - command: withFreshOmpLaunch('omp', 'posix') - }) - expect(readFileSync(environment.ORCA_OMP_FRESH_CONFIG, 'utf8')).toBe('autoResume: false\n') - expect(existsSync(environment.ORCA_OMP_STATUS_EXTENSION)).toBe(true) - expect(environment.ORCA_OMP_SOURCE_AGENT_DIR).toBe(join(state.home, '.omp', 'agent')) - expect(environment.PI_CODING_AGENT_DIR).toBeUndefined() - expect(environment.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() - if (process.platform !== 'win32' && existsSync('/bin/bash')) { - writeFileSync( - join(state.home, 'omp'), - '#!/bin/sh\nprintf "%s\\n" "$@" "$ORCA_OMP_PREFILL"\nexit 17\n', - { mode: 0o755 } - ) - const result = await runProcess({ - program: '/bin/bash', - args: [ - '--noprofile', - '--norc', - '-c', - `${getPosixOmpShellWrapper()}\n${withFreshOmpLaunch('omp', 'posix')}` - ], - cwd: state.home, - env: { - ...process.env, - ...environment, - PATH: state.home + delimiter + process.env.PATH, - ORCA_OMP_PREFILL: 'remote draft' - } - }) - expect(result.code).toBe(17) - expect(result.stdout.trim().split('\n')).toEqual([ - '--extension', - environment.ORCA_OMP_STATUS_EXTENSION, - '--config', - environment.ORCA_OMP_FRESH_CONFIG, - 'remote draft' - ]) - } - - source.mockReturnValue(false) - expect( - await augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' }) - ).toEqual({}) - } finally { - runtime.stop() - dispatcher.dispose() - } -}) - -it('fails the config guard safely when the relay-owned directory cannot be created', () => { - state.home = mkdtempSync(join(tmpdir(), 'orca-relay-fresh-fail-')) - const blocked = join(state.home, 'file') - writeFileSync(blocked, '') - const manager = new PluginOverlayManager({ homeDir: blocked }) - expect(manager.materializeOmpFreshConfig()).toBe('') -}) diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts deleted file mode 100644 index 8fc9c5f4799..00000000000 --- a/src/relay/opencode-hook-selection.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import './mock-descendant-sweep' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../shared/agent-hook-relay' - -const mocks = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) -vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe -})) -vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ - probeOpenCodeLaunchCapabilities: async () => null -})) -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) -vi.mock('../wsl-guest/agent-hook-server', () => ({ - RelayAgentHookServer: class { - start = async () => {} - stop = () => {} - buildPtyEnv = () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) - clearPaneState = () => {} - } -})) -let root: string -let harness: ReturnType -let runtime: RelayAgentHookRuntime -let custom: string -const plugin = (dir: string, agent: string) => join(dir, 'plugins', `orca-${agent}-status.js`) - -beforeEach(async () => { - root = mkdtempSync(join(tmpdir(), 'orca-relay-disabled-')) - custom = join(root, 'custom') - mkdirSync(custom) - writeFileSync(join(custom, 'opencode.json'), '{"model":"fixture"}') - vi.stubEnv('HOME', root) - vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) - for (const key of [ - 'OPENCODE_CONFIG_DIR', - 'ORCA_OPENCODE_CONFIG_DIR', - 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - 'ORCA_OPENCODE_AGENT', - 'ZDOTDIR' - ]) { - vi.stubEnv(key, undefined) - } - harness = beginPtyHandlerTest(mocks) - runtime = new RelayAgentHookRuntime( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness implements the request registration used by the runtime; PTY and disk paths are real. - harness.dispatcher as unknown as RelayDispatcher, - harness.handler, - join(root, 'relay.sock') - ) - await runtime.start() -}) -afterEach(async () => { - runtime.stop() - await endPtyHandlerTest(harness.handler, harness.originalPlatform) - vi.unstubAllEnvs() - rmSync(root, { recursive: true, force: true }) -}) -async function install(v1: string, v2: string): Promise { - await harness.dispatcher.callRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { - opencodePluginSource: v1, - opencode2PluginSource: v2 - }) -} -async function spawn(params: Record = {}): Promise> { - await harness.dispatcher.callRequest('pty.spawn', { - cwd: root, - shell: '/bin/bash', - cols: 80, - rows: 24, - command: 'echo fixture', - ...params - }) - return mocks.mockPtySpawn.mock.calls.at(-1)?.[2].env -} - -describe('relay OpenCode source selection on real fixture files', () => { - it.each(['home', 'xdg', 'shell'] as const)( - 'consumer config root follows the remote %s environment', - async (kind) => { - await install('// remote plugin', '') - const home = join(root, 'remote-home') - const xdg = join(root, 'remote-xdg') - const consumer = kind === 'home' ? join(home, '.config', 'opencode') : join(xdg, 'opencode') - mkdirSync(home, { recursive: true }) - mkdirSync(consumer, { recursive: true }) - writeFileSync(join(consumer, 'opencode.json'), '{"model":"remote-model"}') - if (kind === 'shell') { - writeFileSync(join(home, '.zshrc'), `export XDG_CONFIG_HOME='${xdg}'\n`) - } - const env = await spawn({ - cwd: home, - launchAgent: 'opencode', - env: { - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: kind === 'xdg' ? xdg : '', - SHELL: kind === 'shell' ? '/bin/zsh' : '/bin/sh' - } - }) - expect(readFileSync(plugin(consumer, 'opencode'), 'utf8')).toBe('// remote plugin') - expect(existsSync(plugin(join(root, 'xdg', 'opencode'), 'opencode'))).toBe(false) - expect(readFileSync(join(consumer, 'opencode.json'), 'utf8')).toBe('{"model":"remote-model"}') - expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() - expect(env.HOME).toBe(home) - expect(env.XDG_CONFIG_HOME).toBe(kind === 'xdg' ? xdg : '') - } - ) - - it('leaves a standalone relay without supplied sources unconfigured', async () => { - const env = await spawn() - expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() - expect(existsSync(join(root, 'xdg', 'opencode'))).toBe(false) - }) - it.each([ - { v1: '// v1', v2: '// v2', selected: 'opencode' }, - { v1: '', v2: '// v2', selected: 'opencode2' }, - { v1: '// v1', v2: '', selected: 'opencode' }, - { v1: '', v2: '', selected: undefined } - ])('ordinary terminal with sources $v1 / $v2', async ({ v1, v2, selected }) => { - await install(v1, v2) - const env = await spawn() - expect(env.ORCA_OPENCODE_AGENT).toBe(selected) - for (const agent of ['opencode', 'opencode2']) { - expect(existsSync(plugin(join(root, 'xdg', 'opencode'), agent))).toBe(agent === selected) - } - }) - it.each(['opencode', 'opencode2'] as const)( - 'does not substitute another plugin for disabled explicit %s', - async (agent) => { - await install(agent === 'opencode' ? '' : '// v1', agent === 'opencode2' ? '' : '// v2') - for (const params of [{ command: `${agent} --session fixture` }, { launchAgent: agent }]) { - const env = await spawn(params) - expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() - expect(existsSync(join(root, 'xdg', 'opencode'))).toBe(false) - } - } - ) - it('updates cached sources on one runtime and leaves old plugin files intact', async () => { - await install('// v1', '// v2') - await spawn() - const original = plugin(join(root, 'xdg', 'opencode'), 'opencode') - writeFileSync(original, '// sentinel') - await install('', '// v2') - expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode2') - expect(readFileSync(original, 'utf8')).toBe('// sentinel') - await install('// refreshed v1', '// v2') - expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode') - expect(readFileSync(original, 'utf8')).toBe('// refreshed v1') - }) - // Why: a running OpenCode 2 service reloads a changed plugin file, so connecting must upgrade it. - it('refreshes an existing canonical plugin on install without creating a new one', async () => { - const dir = join(root, 'xdg', 'opencode') - mkdirSync(join(dir, 'plugins'), { recursive: true }) - writeFileSync(plugin(dir, 'opencode2'), '// old v2') - await install('// v1', '// v2') - expect(readFileSync(plugin(dir, 'opencode2'), 'utf8')).toBe('// v2') - const tuiEntry = join(dir, 'plugins', 'orca-opencode2-status-tui', 'tui.js') - expect(readFileSync(tuiEntry, 'utf8')).toBe('// v2') - expect(existsSync(plugin(dir, 'opencode'))).toBe(false) - }) - it('restores the real custom source when all OpenCode sources are revoked', async () => { - await install('// v1', '// v2') - const first = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) - const path = plugin(first.OPENCODE_CONFIG_DIR, 'opencode') - expect(readFileSync(path, 'utf8')).toBe('// v1') - await install('', '') - const env = await spawn({ env: first }) - expect(env.OPENCODE_CONFIG_DIR).toBe(custom) - expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() - expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() - expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() - expect(readFileSync(path, 'utf8')).toBe('// v1') - expect(env.ORCA_AGENT_HOOK_PORT).toBe('12345') - }) -}) - -it.each([true, false])( - 'preserves explicit config over inherited relay markers with hooks %s', - async (enabled) => { - await install(enabled ? '// v1' : '', '') - const stale = join(root, 'stale-source') - mkdirSync(stale) - writeFileSync(join(stale, 'opencode.json'), '{"model":"stale"}') - vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', join(root, 'old-overlay')) - vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', stale) - const env = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) - expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( - '{"model":"fixture"}' - ) - expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) - expect(existsSync(plugin(stale, 'opencode'))).toBe(false) - } -) - -it.each([true, false])( - 'restores legacy source-only overlay markers with hooks %s', - async (enabled) => { - await install('// v1', '') - const parent = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) - delete parent.ORCA_OPENCODE_CONFIG_DIR - if (!enabled) { - await install('', '') - } - const env = await spawn({ env: parent }) - expect(env.OPENCODE_CONFIG_DIR).toEqual(enabled ? expect.any(String) : custom) - expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( - '{"model":"fixture"}' - ) - expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) - } -) diff --git a/src/relay/opencode-plugin-selection.ts b/src/relay/opencode-plugin-selection.ts deleted file mode 100644 index 38c7934cfd6..00000000000 --- a/src/relay/opencode-plugin-selection.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - getOpenCodeCliCapabilities, - type OpenCodeCliCapabilities -} from '../shared/opencode-cli-version' -import { addWslEnvKeys } from '../shared/wsl-env' - -export function restoreOpenCodeCapabilities(value: unknown): OpenCodeCliCapabilities | undefined { - if ( - !value || - typeof value !== 'object' || - !('version' in value) || - typeof value.version !== 'string' - ) { - return undefined - } - const capabilities = getOpenCodeCliCapabilities(value.version) - return capabilities.pluginApi === 'unknown' ? undefined : capabilities -} - -export function applyOpenCodePluginSelection( - env: Record, - envToDelete: string[], - capabilities: OpenCodeCliCapabilities | null | undefined, - wsl: boolean -): void { - delete env.ORCA_OPENCODE_PLUGIN_API - if (!capabilities || capabilities.pluginApi === 'unknown') { - return - } - env.ORCA_OPENCODE_PLUGIN_API = capabilities.pluginApi - for (let index = envToDelete.length - 1; index >= 0; index -= 1) { - if (envToDelete[index] === 'ORCA_OPENCODE_PLUGIN_API') { - envToDelete.splice(index, 1) - } - } - if (wsl) { - addWslEnvKeys(env, ['ORCA_OPENCODE_PLUGIN_API']) - } -} diff --git a/src/relay/plugin-host-call-handler.ts b/src/relay/plugin-host-call-handler.ts deleted file mode 100644 index e4000608f5b..00000000000 --- a/src/relay/plugin-host-call-handler.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' -import { - admitPluginPanelCall, - createPluginPanelCallAdmission, - type PluginPanelCallAdmission -} from '../shared/plugins/plugin-panel-call-admission' -import { - executePluginHostCallRequest, - isPluginHostCallRequest, - type ResolvePluginHostCallPolicy -} from '../main/plugins/plugin-host-call-adapter' - -export const RELAY_PLUGIN_PANEL_HOST_CALL_METHOD = 'plugins.hostCall.panel' -export const RELAY_PLUGIN_WORKER_HOST_CALL_METHOD = 'plugins.hostCall.worker' - -export type RelayPluginHostCallDispatcher = { - onRequest(method: string, handler: MethodHandler): void -} - -export type ResolveRelayPluginHostCallIdentity = ( - context: RequestContext -) => string | null | Promise - -/** Relay provisioning is deliberately out of scope here. Its connection- - * keyed resolver owns plugin identity, consent, services, and audit authority. */ -export function registerRelayPluginHostCallHandlers( - dispatcher: RelayPluginHostCallDispatcher, - resolveIdentity: ResolveRelayPluginHostCallIdentity, - resolvePolicy: ResolvePluginHostCallPolicy, - options: { panelAdmission?: PluginPanelCallAdmission } = {} -): void { - const panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission() - const register = (registeredMethod: string, viaPanel: boolean): void => { - dispatcher.onRequest(registeredMethod, async (params, context) => { - let pluginKey: string | null - try { - pluginKey = await resolveIdentity(context) - } catch { - pluginKey = null - } - if (!pluginKey) { - return { - ok: false, - code: 'unavailable', - error: 'plugin host authority is not available' - } - } - if (viaPanel) { - const admissionRefusal = admitPluginPanelCall(panelAdmission, pluginKey, params) - if (admissionRefusal) { - return admissionRefusal - } - } - if (!isPluginHostCallRequest(params)) { - return { ok: false, code: 'invalid_request', error: 'malformed plugin host call request' } - } - return executePluginHostCallRequest({ - pluginKey, - request: params, - viaPanel, - resolvePolicy - }) - }) - } - // Why: transport authority is fixed by the registered RPC method; callers - // cannot promote a panel call to the wider worker method set in params. - register(RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, true) - register(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, false) -} diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts deleted file mode 100644 index 45a389b7c94..00000000000 --- a/src/relay/port-scan-handler.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' - -const { readFileMock, readdirMock, readlinkMock } = vi.hoisted(() => ({ - readFileMock: vi.fn(), - readdirMock: vi.fn(), - readlinkMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, - readdir: readdirMock, - readlink: readlinkMock -})) - -import { parseHexAddress, PortScanHandler } from './port-scan-handler' -import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' - -// The scanner skips any pid matching the relay process or its parent, so a fixture pid -// range that covers the vitest worker's own pid silently drops the row and the assertion -// sees no ports. Shift the whole range past a colliding pid so the walk stays hermetic. -const MAX_FIXTURE_PIDS = 1_000 - -const PID_BASE = (() => { - let base = 1_000 - while ([process.pid, process.ppid].some((pid) => pid >= base && pid < base + MAX_FIXTURE_PIDS)) { - base += MAX_FIXTURE_PIDS - } - return base -})() - -const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') - -beforeEach(() => { - vi.clearAllMocks() - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) -}) - -afterEach(() => { - if (originalPlatformDescriptor) { - Object.defineProperty(process, 'platform', originalPlatformDescriptor) - } -}) - -function capturePortDetectHandler(): MethodHandler { - let handler: MethodHandler | undefined - new PortScanHandler({ - onRequest: (method, nextHandler) => { - expect(method).toBe('ports.detect') - handler = nextHandler - } - }) - if (!handler) { - throw new Error('ports.detect handler was not registered') - } - return handler -} - -function requestContext(signal?: AbortSignal): RequestContext { - return { clientId: 1, isStale: () => signal?.aborted ?? false, signal } -} - -function createDeferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve = (_value: T): void => { - throw new Error('deferred promise was not initialized') - } - const promise = new Promise((nextResolve) => { - resolve = nextResolve - }) - return { promise, resolve } -} - -type FixtureListener = { port: number; inode: number } - -const DEFAULT_LISTENER: FixtureListener = { port: 3000, inode: 11_111 } - -function tcpRow(index: number, { port, inode }: FixtureListener): string { - const hexPort = port.toString(16).toUpperCase().padStart(4, '0') - return `${index}: 0100007F:${hexPort} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 ${inode}` -} - -function mockLinuxProcScan({ - pidCount, - fdCount, - firstReadlink, - listeners = [DEFAULT_LISTENER], - inodesByPidOffset, - cmdlineByPidOffset -}: { - pidCount: number - fdCount: number - firstReadlink?: Promise - // Listening rows in /proc/net/tcp. Several rows are what makes an early exit keyed on - // `result.size === inodes.size` distinguishable from one keyed on `result.size > 0`. - listeners?: readonly FixtureListener[] - // Socket inode per fd index, keyed by the pid's offset from PID_BASE. Offsets left out hold no - // listening socket at all; fd indexes past the end of a list link to a non-socket path. - inodesByPidOffset?: ReadonlyMap - cmdlineByPidOffset?: ReadonlyMap -}): void { - const tcpHeader = - 'sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode' - readFileMock.mockImplementation(async (path: string) => { - if (path === '/proc/net/tcp') { - return `${tcpHeader}\n${listeners.map((listener, index) => tcpRow(index, listener)).join('\n')}\n` - } - if (path === '/proc/net/tcp6') { - return `${tcpHeader}\n` - } - const cmdlineMatch = path.match(/^\/proc\/(\d+)\/cmdline$/) - if (cmdlineMatch) { - return ( - cmdlineByPidOffset?.get(Number(cmdlineMatch[1]) - PID_BASE) ?? '/usr/bin/node\0server.js' - ) - } - throw new Error(`unexpected readFile: ${path}`) - }) - - const pids = Array.from({ length: pidCount }, (_, index) => String(PID_BASE + index)) - const fds = Array.from({ length: fdCount }, (_, index) => String(index)) - readdirMock.mockImplementation(async (path: string) => { - if (path === '/proc') { - return pids - } - if (path.endsWith('/fd')) { - return fds - } - throw new Error(`unexpected readdir: ${path}`) - }) - - let first = true - readlinkMock.mockImplementation((path: string) => { - if (first && firstReadlink) { - first = false - return firstReadlink - } - first = false - if (!inodesByPidOffset) { - return Promise.resolve(`socket:[${DEFAULT_LISTENER.inode}]`) - } - const match = path.match(/^\/proc\/(\d+)\/fd\/(\d+)$/) - if (!match) { - throw new Error(`unexpected readlink: ${path}`) - } - const inode = inodesByPidOffset.get(Number(match[1]) - PID_BASE)?.[Number(match[2])] - return Promise.resolve(inode === undefined ? '/dev/null' : `socket:[${inode}]`) - }) -} - -describe('PortScanHandler Linux walk bounds', () => { - it('stops walking procfs once every listening socket has an owner', async () => { - // Why: this scan repeats for the life of the session, and its unit cost was O(all host - // processes x all fds) regardless of how few sockets it was resolving. On a busy remote the - // process count only climbs, so the scan got permanently more expensive -- the shape behind - // "SSH degrades the longer Orca stays open". One listener means one readlink, not 100,000. - mockLinuxProcScan({ pidCount: 1_000, fdCount: 100 }) - - await capturePortDetectHandler()({}, requestContext()) - - expect(readlinkMock).toHaveBeenCalledTimes(1) - }) - - it('still walks the whole table when a socket has no reachable owner', async () => { - // The inverse: an unattributable inode (another user's process) must not make the scan give up - // early on sockets it could still attribute. - mockLinuxProcScan({ pidCount: 3, fdCount: 2 }) - readlinkMock.mockImplementation(() => Promise.resolve('socket:[99999]')) - - await capturePortDetectHandler()({}, requestContext()) - - expect(readlinkMock).toHaveBeenCalledTimes(6) - }) - - it('resolves an owner for every listening socket before it exits', async () => { - // The exit condition has to be "every inode is attributed", not "some inode is". With one - // fixture row those are the same assertion, which is how an exit-after-the-first-listener bug - // would slip through: ports 3001 and 3002 would come back ownerless. - mockLinuxProcScan({ - pidCount: 500, - fdCount: 1, - listeners: [ - { port: 3000, inode: 11_111 }, - { port: 3001, inode: 22_222 }, - { port: 3002, inode: 33_333 } - ], - inodesByPidOffset: new Map([ - [0, [11_111]], - [1, [22_222]], - [2, [33_333]] - ]) - }) - - await expect(capturePortDetectHandler()({}, requestContext())).resolves.toEqual({ - platform: 'linux', - ports: [ - { host: '127.0.0.1', port: 3000, pid: PID_BASE, processName: 'node' }, - { host: '127.0.0.1', port: 3001, pid: PID_BASE + 1, processName: 'node' }, - { host: '127.0.0.1', port: 3002, pid: PID_BASE + 2, processName: 'node' } - ] - }) - // Three owners found means three readlinks: it stops at the third pid, not the five hundredth. - expect(readlinkMock).toHaveBeenCalledTimes(3) - }) - - it('keeps walking past an attributed socket to reach a later owner', async () => { - // A partially attributed table is the sharpest case: the first pid resolves one inode, and the - // second inode is only reachable at the end of the walk. - mockLinuxProcScan({ - pidCount: 4, - fdCount: 1, - listeners: [ - { port: 3000, inode: 11_111 }, - { port: 3001, inode: 22_222 } - ], - inodesByPidOffset: new Map([ - [0, [11_111]], - [3, [22_222]] - ]) - }) - - await expect(capturePortDetectHandler()({}, requestContext())).resolves.toEqual({ - platform: 'linux', - ports: [ - { host: '127.0.0.1', port: 3000, pid: PID_BASE, processName: 'node' }, - { host: '127.0.0.1', port: 3001, pid: PID_BASE + 3, processName: 'node' } - ] - }) - expect(readlinkMock).toHaveBeenCalledTimes(4) - }) -}) - -describe('PortScanHandler shared listening inode attribution', () => { - it('attributes a shared inode to the first holder the walk reaches', async () => { - // An nginx master and its workers (or a Node cluster) share one listening inode. The walk used - // to overwrite the entry for every later holder, so the last pid in readdir order won; exiting - // as soon as the inode is attributed makes the first one win instead. That is the better - // answer -- the master owns the socket -- but it is a visible change to the name in the ports - // UI, so pin it here rather than let it drift. - mockLinuxProcScan({ - pidCount: 3, - fdCount: 1, - inodesByPidOffset: new Map([ - [0, [DEFAULT_LISTENER.inode]], - [1, [DEFAULT_LISTENER.inode]], - [2, [DEFAULT_LISTENER.inode]] - ]), - cmdlineByPidOffset: new Map([ - [0, '/usr/sbin/nginx\0master process'], - [1, '/usr/sbin/nginx-worker\0worker process'], - [2, '/usr/sbin/nginx-worker\0worker process'] - ]) - }) - - await expect(capturePortDetectHandler()({}, requestContext())).resolves.toEqual({ - platform: 'linux', - ports: [{ host: '127.0.0.1', port: 3000, pid: PID_BASE, processName: 'nginx' }] - }) - expect(readlinkMock).toHaveBeenCalledTimes(1) - }) -}) - -describe('PortScanHandler Linux cancellation', () => { - it('does not touch procfs for an already-cancelled request', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - capturePortDetectHandler()({}, requestContext(controller.signal)) - ).rejects.toMatchObject({ name: 'AbortError' }) - - expect(readFileMock).not.toHaveBeenCalled() - expect(readdirMock).not.toHaveBeenCalled() - expect(readlinkMock).not.toHaveBeenCalled() - }) - - it('stops a large pid and fd walk at the filesystem operation already in flight', async () => { - const firstReadlink = createDeferred() - mockLinuxProcScan({ pidCount: 1_000, fdCount: 100, firstReadlink: firstReadlink.promise }) - const controller = new AbortController() - const scan = capturePortDetectHandler()({}, requestContext(controller.signal)) - - await vi.waitFor(() => expect(readlinkMock).toHaveBeenCalledTimes(1)) - controller.abort() - firstReadlink.resolve('socket:[11111]') - - await expect(scan).rejects.toMatchObject({ name: 'AbortError' }) - expect(readdirMock).toHaveBeenCalledTimes(2) - expect(readdirMock).toHaveBeenNthCalledWith(1, '/proc') - expect(readdirMock).toHaveBeenNthCalledWith(2, `/proc/${PID_BASE}/fd`) - expect(readlinkMock).toHaveBeenCalledTimes(1) - expect(readlinkMock).toHaveBeenCalledWith(`/proc/${PID_BASE}/fd/0`) - }) - - it('preserves detected port results when the request stays live', async () => { - mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) - - await expect( - capturePortDetectHandler()({}, requestContext(new AbortController().signal)) - ).resolves.toEqual({ - ports: [{ host: '127.0.0.1', port: 3000, pid: PID_BASE, processName: 'node' }], - platform: 'linux' - }) - }) - - it('fails instead of answering "no listeners" when the socket table is unreadable', async () => { - mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) - const scanTable = readFileMock.getMockImplementation() - readFileMock.mockImplementation(async (path: string) => { - if (path === '/proc/net/tcp') { - throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) - } - return scanTable?.(path) - }) - - await expect(capturePortDetectHandler()({}, requestContext())).rejects.toThrow( - /Could not read \/proc\/net\/tcp/ - ) - }) - - it('reads a host with IPv6 off, where tcp6 is absent', async () => { - mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) - const scanTable = readFileMock.getMockImplementation() - readFileMock.mockImplementation(async (path: string) => { - if (path === '/proc/net/tcp6') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - } - return scanTable?.(path) - }) - - await expect(capturePortDetectHandler()({}, requestContext())).resolves.toMatchObject({ - ports: [{ port: 3000 }] - }) - }) -}) - -describe('parseHexAddress', () => { - it('parses IPv4 localhost (127.0.0.1)', () => { - // 127.0.0.1 in little-endian hex: 0100007F - const result = parseHexAddress('0100007F:0BB8') - expect(result).toEqual({ host: '127.0.0.1', port: 3000 }) - }) - - it('parses IPv4 all-interfaces (0.0.0.0)', () => { - const result = parseHexAddress('00000000:1F90') - expect(result).toEqual({ host: '0.0.0.0', port: 8080 }) - }) - - it('parses a non-localhost IPv4 address', () => { - // 192.168.1.100 in little-endian: 6401A8C0 - const result = parseHexAddress('6401A8C0:1388') - expect(result).toEqual({ host: '192.168.1.100', port: 5000 }) - }) - - it('parses IPv6 all-zeros (::)', () => { - const result = parseHexAddress('00000000000000000000000000000000:1F90') - expect(result).toEqual({ host: '::', port: 8080 }) - }) - - it('parses IPv6 loopback (::1)', () => { - const result = parseHexAddress('00000000000000000000000001000000:0BB8') - expect(result).toEqual({ host: '::1', port: 3000 }) - }) - - it('returns null for port 0', () => { - const result = parseHexAddress('0100007F:0000') - expect(result).toBeNull() - }) - - it('returns null for malformed input', () => { - expect(parseHexAddress('invalid')).toBeNull() - expect(parseHexAddress('')).toBeNull() - expect(parseHexAddress('::::')).toBeNull() - }) -}) - -describe('parseWindowsPowerShellPortRows', () => { - it('parses PowerShell JSON arrays', () => { - expect( - parseWindowsPowerShellPortRows( - JSON.stringify([ - { host: '127.0.0.1', port: 5173, pid: 1234, processName: 'node' }, - { host: '0.0.0.0', port: 8080, pid: 5678, processName: 'dotnet' } - ]) - ) - ).toEqual([ - { host: '127.0.0.1', port: 5173, pid: 1234, processName: 'node' }, - { host: '0.0.0.0', port: 8080, pid: 5678, processName: 'dotnet' } - ]) - }) - - it('parses single-object PowerShell JSON', () => { - expect( - parseWindowsPowerShellPortRows( - JSON.stringify({ host: '::1', port: '3000', pid: '4321', processName: 'node' }) - ) - ).toEqual([{ host: '::1', port: 3000, pid: 4321, processName: 'node' }]) - }) - - it('ignores malformed rows', () => { - expect( - parseWindowsPowerShellPortRows( - JSON.stringify([ - { host: '127.0.0.1', port: 5173, pid: 1234 }, - { host: '127.0.0.1', port: 'nan', pid: 1234 }, - { port: 8080, pid: 5678 } - ]) - ) - ).toEqual([{ host: '127.0.0.1', port: 5173, pid: 1234 }]) - }) -}) - -describe('parseWindowsNetstatOutput', () => { - it('parses Windows netstat listening rows', () => { - const output = [ - ' Proto Local Address Foreign Address State PID', - ' TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 1234', - ' TCP 127.0.0.1:9229 0.0.0.0:0 ESTABLISHED 1234', - ' TCP [::1]:3000 [::]:0 LISTENING 5678' - ].join('\r\n') - - expect(parseWindowsNetstatOutput(output)).toEqual([ - { host: '0.0.0.0', port: 5173, pid: 1234 }, - { host: '::1', port: 3000, pid: 5678 } - ]) - }) - - it('parses Windows netstat rows without whitespace regex splitting', () => { - const splitSpy = vi.spyOn(String.prototype, 'split') - - expect( - parseWindowsNetstatOutput( - ' TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 4242' - ) - ).toEqual([{ host: '127.0.0.1', port: 3000, pid: 4242 }]) - - const usedWhitespaceFieldSplit = splitSpy.mock.calls.some( - ([separator]) => separator instanceof RegExp && separator.source.includes('\\s+') - ) - splitSpy.mockRestore() - expect(usedWhitespaceFieldSplit).toBe(false) - }) -}) diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts deleted file mode 100644 index 29fdca9a19f..00000000000 --- a/src/relay/port-scan-handler.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { readFile, readdir, readlink } from 'node:fs/promises' -import { getProcessOutputFields } from '../shared/process-output-field-scanner' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { scanWindowsListeningPorts } from './windows-port-scan' - -// Keep in sync with src/shared/ssh-types.ts — DetectedPort -export type DetectedPort = { - port: number - host: string - pid?: number - processName?: string -} - -const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) - -const MAX_DETECTED_PORTS = 50 - -export class PortScanHandler { - constructor(dispatcher: Pick) { - dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => { - if (process.platform === 'linux') { - return { - ports: await this.scanLinuxListeningPorts(context.signal), - platform: process.platform - } - } - if (process.platform === 'win32') { - return { - ports: await scanWindowsListeningPorts(context.signal), - platform: process.platform - } - } - return { - ports: [], - platform: process.platform - } - }) - } - - private async scanLinuxListeningPorts(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - const [tcp4, tcp6] = await Promise.all([ - this.readProcNet('/proc/net/tcp', signal, false), - this.readProcNet('/proc/net/tcp6', signal, true) - ]) - signal?.throwIfAborted() - - const listeningSockets = [...tcp4, ...tcp6] - if (listeningSockets.length === 0) { - return [] - } - - const inodeSet = new Set(listeningSockets.map((s) => s.inode)) - const inodeToPid = await this.mapInodesToPids(inodeSet, signal) - - const seen = new Set() - const results: DetectedPort[] = [] - const relayPid = process.pid - const relayParentPid = process.ppid - - for (const socket of listeningSockets) { - signal?.throwIfAborted() - const key = `${socket.host}:${socket.port}` - if (seen.has(key)) { - continue - } - seen.add(key) - - if (SYSTEM_PORTS_TO_EXCLUDE.has(socket.port)) { - continue - } - - const pid = inodeToPid.get(socket.inode) - if (pid === relayPid || pid === relayParentPid) { - continue - } - - const processName = pid != null ? await this.getProcessName(pid, signal) : undefined - - if (processName === 'sshd') { - continue - } - - results.push({ - port: socket.port, - host: socket.host, - pid: pid ?? undefined, - processName - }) - } - - // Why: sort before capping so the visible set is deterministic (lowest - // port numbers first) regardless of /proc enumeration order. - results.sort((a, b) => a.port - b.port) - return results.slice(0, MAX_DETECTED_PORTS) - } - - private async readProcNet( - path: string, - signal: AbortSignal | undefined, - absentMeansNone: boolean - ): Promise<{ port: number; host: string; inode: number }[]> { - signal?.throwIfAborted() - let content: string - try { - content = await readFile(path, 'utf-8') - } catch (error) { - signal?.throwIfAborted() - // Why: tcp6 is absent when IPv6 is off; any other failure means the listeners were never - // read, which must not be reported as "none listening". - if (absentMeansNone && error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return [] - } - throw new Error(`Could not read ${path} to list listening ports.`) - } - signal?.throwIfAborted() - - const lines = content.split('\n') - const results: { port: number; host: string; inode: number }[] = [] - - for (let i = 1; i < lines.length; i++) { - const fields = getProcessOutputFields(lines[i], 10) - if (fields.length < 10) { - continue - } - - // State field (index 3): 0A = TCP_LISTEN - if (fields[3] !== '0A') { - continue - } - - const localAddress = fields[1] - const parsed = parseHexAddress(localAddress) - if (!parsed) { - continue - } - - const inode = Number.parseInt(fields[9], 10) - if (Number.isNaN(inode) || inode === 0) { - continue - } - - results.push({ port: parsed.port, host: parsed.host, inode }) - } - - return results - } - - private async mapInodesToPids( - inodes: Set, - signal?: AbortSignal - ): Promise> { - signal?.throwIfAborted() - const result = new Map() - if (inodes.size === 0) { - return result - } - - let pids: string[] - try { - pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) - } catch { - signal?.throwIfAborted() - return result - } - signal?.throwIfAborted() - - for (const pidStr of pids) { - signal?.throwIfAborted() - // Why: every remaining pid costs a readdir plus one readlink per fd, and this scan repeats for - // the life of the session. Without this the walk was O(all host processes x all fds) even once - // every listener was already attributed, so its cost grew with the remote's process count and - // never came back down — the shape behind "SSH gets slower the longer Orca stays open". - if (result.size === inodes.size) { - return result - } - const fdDir = `/proc/${pidStr}/fd` - let fds: string[] - try { - fds = await readdir(fdDir) - } catch { - signal?.throwIfAborted() - continue - } - signal?.throwIfAborted() - - const pid = Number.parseInt(pidStr, 10) - - for (const fd of fds) { - signal?.throwIfAborted() - let link: string - try { - link = await readlink(`${fdDir}/${fd}`) - } catch { - signal?.throwIfAborted() - continue - } - signal?.throwIfAborted() - - const match = link.match(/^socket:\[(\d+)\]$/) - if (!match) { - continue - } - - const inode = Number.parseInt(match[1], 10) - if (inodes.has(inode)) { - result.set(inode, pid) - if (result.size === inodes.size) { - return result - } - } - } - } - - return result - } - - private async getProcessName(pid: number, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - try { - const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8') - signal?.throwIfAborted() - if (!cmdline) { - return undefined - } - - const exe = cmdline.split('\0')[0] - if (!exe) { - return undefined - } - - const parts = exe.split('/') - return parts.at(-1) - } catch { - signal?.throwIfAborted() - return undefined - } - } -} - -// Why: /proc/net/tcp encodes addresses as hex pairs in host-byte-order. -// IPv4: 8 hex chars for address + ':' + 4 hex chars for port. -// IPv6: 32 hex chars for address + ':' + 4 hex chars for port. -export function parseHexAddress(hexAddr: string): { host: string; port: number } | null { - const parts = hexAddr.split(':') - if (parts.length !== 2) { - return null - } - - const port = Number.parseInt(parts[1], 16) - if (Number.isNaN(port) || port === 0) { - return null - } - - const addrHex = parts[0] - - if (addrHex.length === 8) { - const b1 = Number.parseInt(addrHex.substring(6, 8), 16) - const b2 = Number.parseInt(addrHex.substring(4, 6), 16) - const b3 = Number.parseInt(addrHex.substring(2, 4), 16) - const b4 = Number.parseInt(addrHex.substring(0, 2), 16) - const host = `${b1}.${b2}.${b3}.${b4}` - return { host, port } - } - - if (addrHex.length === 32) { - if (addrHex === '00000000000000000000000000000000') { - return { host: '::', port } - } - if (addrHex === '00000000000000000000000001000000') { - return { host: '::1', port } - } - return { host: formatIPv6(addrHex), port } - } - - return null -} - -function formatIPv6(hex: string): string { - const groups: string[] = [] - for (let i = 0; i < 32; i += 8) { - const chunk = hex.substring(i, i + 8) - const reversed = - chunk.substring(6, 8) + chunk.substring(4, 6) + chunk.substring(2, 4) + chunk.substring(0, 2) - groups.push(reversed.substring(0, 4)) - groups.push(reversed.substring(4, 8)) - } - return groups.map((g) => g.replace(/^0+/, '') || '0').join(':') -} diff --git a/src/relay/pty-child-process-inspection.ts b/src/relay/pty-child-process-inspection.ts deleted file mode 100644 index 6d246817053..00000000000 --- a/src/relay/pty-child-process-inspection.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Whether anything is running under a pane's shell. - * - * Split out of `pty-shell-utils` because it is a distinct question from "what is in front" and - * carries its own platform reasoning, its own cost budget, and the verdict vocabulary from - * docs/reference/ssh-execution-boundary.md. - */ -import { queryWindowsPaneProcessInventory } from '../main/providers/windows-foreground-process-rows' -import { getProcessTableIndex } from '../shared/process-table-index' -import { - getFreshProcessTableSnapshot, - getProcessTableSnapshot -} from '../shared/process-table-snapshot-reader' -import type { PtyChildProcessVerdict } from '../shared/terminal-process-inspection' -import { isProcessAlive } from './pty-shell-utils' - -/** - * Check whether a process has child processes. - * - * Why the shared snapshot and not `pgrep -P`: this answers one field of - * `pty.inspectProcess`, which every tracked pane polls on a 750ms/2000ms - * cadence, and the fork was neither cached nor coalesced. procps-ng opens six - * procfs files per process to resolve a ppid — including a `/proc//ctty` - * that never exists on Linux — so one call cost O(host process count) syscalls, - * ~4k opens per pgrep on a 690-process host, at up to 8 forks/sec (#13537). - * `getForegroundProcessName` in the same RPC already captured the TTL-cached - * `ps` table, whose index carries the parent/child map, so the answer is free. - * - * `fresh` opts out of that TTL. A poll can read a 500ms-old table because its - * next tick corrects it, but a close or cleanup decision acts on the answer - * once and destructively — a child that started inside the TTL would be killed - * with no confirmation. `pgrep` scanned per call, so anything that decides - * has to keep scanning per call. - */ -export async function inspectPtyChildProcesses( - pid: number, - options?: { fresh?: boolean } -): Promise { - if (process.platform === 'win32') { - // Windows has no `ps`, but it does have a process table, and the pane walk over it already - // exists for the foreground reader. Answering `false` from nothing was the older shape: a - // hardcoded negative is indistinguishable from a measurement, and every close guard reads it - // as "nothing is running here". - // - // Deliberately the TTL-cached table even when `fresh` is asked for: on a relay without the - // native binding this falls back to the CIM scan, whose own 1.36s runtime is longer than the - // 500ms TTL a fresh read would be refreshing, so a "fresh" answer is not meaningfully fresher - // while N sequential ones are an N x 1.36s stall. - const inventory = await queryWindowsPaneProcessInventory(pid) - if (inventory) { - return inventory.candidates.length > 0 ? 'children' : 'no-children' - } - // A null inventory is an unreadable table OR a snapshot that never showed the root, and - // neither of those looked at the pane. The one answer available without the table is a root - // the kernel says is gone: nothing runs under a shell that does not exist. - return isProcessAlive(pid) ? 'unverifiable' : 'no-children' - } - try { - const rows = options?.fresh - ? await getFreshProcessTableSnapshot() - : await getProcessTableSnapshot() - return (getProcessTableIndex(rows).childrenByPpid.get(pid)?.length ?? 0) > 0 - ? 'children' - : 'no-children' - } catch { - return 'unverifiable' - } -} - -/** - * The boolean the wire has always carried. `unverifiable` keeps spelling itself `false` here on - * purpose: this value reaches clients too old to know the third answer, and it is read both as - * "busy, do not close" and as "the agent has taken over, safe to type into", so no single mapping - * of `unverifiable` is safe for both. Callers that can act on the distinction read the verdict. - */ -export async function processHasChildren( - pid: number, - options?: { fresh?: boolean } -): Promise { - return (await inspectPtyChildProcesses(pid, options)) === 'children' -} diff --git a/src/relay/pty-handler-agent-launch-token.test.ts b/src/relay/pty-handler-agent-launch-token.test.ts deleted file mode 100644 index 1ad05f5e643..00000000000 --- a/src/relay/pty-handler-agent-launch-token.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { tmpdir } from 'node:os' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { beginPtyHandlerTest, endPtyHandlerTest, testPtyId } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PANE = 'tab-a:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -describe('execution-host launch token authority', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - afterEach(async () => endPtyHandlerTest(handler, originalPlatform)) - const spawn = (env: Record = {}, extra = {}) => - dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - cwd: tmpdir(), - env: { ORCA_PANE_KEY: PANE, ORCA_WORKTREE_ID: 'folder:workspace', ...env }, - ...extra - }) - - it.each(['linux', 'darwin', 'win32'])( - 'retains the final successfully spawned environment on %s', - async (platform) => { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - handler.addEnvAugmenter(() => ({ ORCA_AGENT_LAUNCH_TOKEN: 'augmented-live' })) - await spawn({ ORCA_AGENT_LAUNCH_TOKEN: 'renderer-before-augmentation' }) - expect(mockPtySpawn.mock.calls[0][2].env.ORCA_AGENT_LAUNCH_TOKEN).toBe('augmented-live') - expect(handler.getAgentLaunchToken(PANE)).toBe('augmented-live') - } - ) - - it('keeps deleted and inherited launch identities unverifiable', async () => { - vi.stubEnv('ORCA_AGENT_LAUNCH_TOKEN', 'inherited-stale') - await spawn() - expect(handler.getAgentLaunchToken(PANE)).toBeUndefined() - await spawn( - { - ORCA_PANE_KEY: 'tab-b:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - ORCA_AGENT_LAUNCH_TOKEN: 'deleted' - }, - { envToDelete: ['ORCA_AGENT_LAUNCH_TOKEN'] } - ) - expect( - handler.getAgentLaunchToken('tab-b:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') - ).toBeUndefined() - vi.unstubAllEnvs() - }) - - it('never chooses an arbitrary root when two current PTYs share a pane', async () => { - await spawn({ ORCA_AGENT_LAUNCH_TOKEN: 'first' }) - await spawn({ ORCA_AGENT_LAUNCH_TOKEN: 'second' }) - expect(handler.getAgentLaunchToken(PANE)).toBeUndefined() - expect(handler.getTmuxManagedPty(PANE)).toBeNull() - }) - - it('retains no authority from a failed physical spawn', async () => { - mockPtySpawn.mockImplementationOnce(() => { - throw new Error('spawn failed') - }) - await expect(spawn({ ORCA_AGENT_LAUNCH_TOKEN: 'never-started' })).rejects.toThrow( - 'spawn failed' - ) - expect(handler.getAgentLaunchToken(PANE)).toBeUndefined() - }) - - it('retirement hides authority and accepted attach restores only the existing token', async () => { - await spawn({ ORCA_AGENT_LAUNCH_TOKEN: 'actual-live' }) - await dispatcher.callRequest('pty.shutdown', { id: testPtyId(1) }) - expect(handler.isPaneSurfaceRetired(PANE)).toBe(true) - expect(handler.getAgentLaunchToken(PANE)).toBeUndefined() - await dispatcher.callRequest('pty.attach', { id: testPtyId(1), paneKey: PANE }) - expect(handler.getAgentLaunchToken(PANE)).toBe('actual-live') - }) - - it('does not trust serialized launch tokens during revive', async () => { - const state = JSON.stringify([ - { - id: 'pty-legacy', - pid: process.pid, - cols: 80, - rows: 24, - cwd: tmpdir(), - paneKey: PANE, - worktreeId: 'folder:workspace', - agentLaunchToken: 'untrusted-serialized' - } - ]) - await dispatcher.callRequest('pty.revive', { state }) - expect(mockPtySpawn).toHaveBeenCalledOnce() - expect(mockPtySpawn.mock.calls[0][2].env.ORCA_AGENT_LAUNCH_TOKEN).toBeUndefined() - expect(handler.getAgentLaunchToken(PANE)).toBeUndefined() - }) -}) diff --git a/src/relay/pty-handler-agent-workspace-trust.test.ts b/src/relay/pty-handler-agent-workspace-trust.test.ts deleted file mode 100644 index 3acdda6ab2e..00000000000 --- a/src/relay/pty-handler-agent-workspace-trust.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe, mockApplyTrust } = - vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockApplyTrust: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } - })) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -vi.mock('./agent-workspace-trust-spawn', () => ({ - applyRelayAgentWorkspaceTrust: mockApplyTrust -})) - -import type { PtyHandler } from './pty-handler' -import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -describe('relay pty.spawn agent workspace trust', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let root: string - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - mockApplyTrust.mockReset() - root = mkdtempSync(join(tmpdir(), 'orca-relay-trust-spawn-')) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - rmSync(root, { recursive: true, force: true }) - }) - - it("writes the launch's trust with its final env before the agent's process starts", async () => { - let finishTrust = (): void => {} - mockApplyTrust.mockReturnValue( - new Promise((resolve) => { - finishTrust = resolve - }) - ) - const request = { workspacePath: root } - - const spawned = dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - cwd: root, - launchAgent: 'codex', - env: { CODEX_HOME: '/remote/codex-home' }, - agentWorkspaceTrust: request - }) - await vi.advanceTimersByTimeAsync(0) - - expect(mockApplyTrust).toHaveBeenCalledWith( - request, - 'codex', - expect.objectContaining({ CODEX_HOME: '/remote/codex-home' }), - { wslShell: false } - ) - expect(mockPtySpawn).not.toHaveBeenCalled() - finishTrust() - await spawned - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/relay/pty-handler-attach-replay.test.ts b/src/relay/pty-handler-attach-replay.test.ts deleted file mode 100644 index 571290e59cf..00000000000 --- a/src/relay/pty-handler-attach-replay.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as ptyShellUtils from './pty-shell-utils' -import { - PTY_ATTACH_PROVEN_EXITED_MARKER, - isProvenExitedPtyAttachRefusal -} from '../shared/pty-attach-absence-evidence' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import { attachIdentityMismatches, type PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createPtyRequestHelpers, - createTestPtyHandler, - testPtyId, - endPtyHandlerTest -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - const { spawnPty, attachPty } = createPtyRequestHelpers(() => dispatcher) - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('reports not-found and reaps a reattach whose backing shell is dead', async () => { - // Why: a lingering managed entry whose child died without an onExit would - // otherwise attach-succeed with an empty replay and strand the pane on a - // black shell. A provably-dead pid must surface as not-found so the SSH - // provider maps it to SSH_SESSION_EXPIRED and the pane respawns fresh. - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - const exits: { id: string; paneKey?: string }[] = [] - handler.setExitListener((evt) => exits.push(evt)) - - await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dead:0' } }) - expect(handler.activePtyCount).toBe(1) - expect(onExitCb).toBeDefined() - - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) - try { - await expect( - dispatcher.callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) - ).rejects.toThrow(`PTY "${PTY_1}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) - } finally { - aliveSpy.mockRestore() - } - - // The stale entry is reaped: cache-eviction observers fire and the map slot - // is freed so a later attach also cleanly reports not-found. - expect(exits).toEqual([{ id: PTY_1, paneKey: 'tab-dead:0' }]) - expect(handler.activePtyCount).toBe(0) - const unknownId = await dispatcher - .callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) - .then( - () => new Error('expected the attach to be refused'), - (error: Error) => error - ) - - // The second refusal is the shape a restarted relay gives for every id the previous one minted: - // same words, no liveness check behind them. Only the probed one may be read as a death - // (docs/reference/ssh-execution-boundary.md). - expect(unknownId.message).toContain(`PTY "${PTY_1}" not found`) - expect(isProvenExitedPtyAttachRefusal(unknownId)).toBe(false) - }) - - it('settles concurrent immediate shutdown when attach proves the shell exited', async () => { - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', {}) - - let shutdown: Promise | undefined - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockImplementation(() => { - shutdown = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - return false - }) - try { - await expect(dispatcher.callRequest('pty.attach', { id: PTY_1 })).rejects.toThrow( - `PTY "${PTY_1}" not found` - ) - await expect(shutdown).resolves.toBeUndefined() - } finally { - aliveSpy.mockRestore() - } - - expect(mockKill).toHaveBeenCalledWith('SIGKILL') - expect(handler.activePtyCount).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('replays for a reattach whose backing shell is still alive', async () => { - // Guard the other direction: a live pid must never be reaped, so a quiet - // but live session still returns its buffered replay on reattach. - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - const spawn = await spawnPty() - dataCallback?.('prompt$ ') - - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - try { - const result = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'prompt$ ' }) - } finally { - aliveSpy.mockRestore() - } - expect(handler.activePtyCount).toBe(1) - }) - - it('returns attach replay instead of notifying when replay notification is suppressed', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - const spawn = await spawnPty() - dataCallback!('buffered output') - - const result = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - - expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'buffered output' }) - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.replay', expect.anything()) - vi.advanceTimersByTime(8) - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) - }) - - it('suppresses legacy replay after the V1 owner is already active', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - const spawn = await spawnPty() - dataCallback!('buffered output') - handler.setSourcePublication({ - activate: vi.fn(() => 'existing'), - accepts: vi.fn(() => true), - publish: vi.fn(() => true), - dispose: vi.fn() - } as never) - - const result = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - - expect(result).toEqual({ incarnationId: spawn.incarnationId }) - }) - - it('requires restore when the V1 pending-send recovery fence expires', async () => { - const spawn = await spawnPty() - const waitForPendingSend = vi.fn().mockResolvedValue(false) - const activate = vi - .fn() - .mockReturnValue({ status: 'restoreRequired', reason: 'checkpointUnavailable' }) - handler.setSourcePublication({ - activate, - accepts: vi.fn(() => true), - waitForPendingSend, - dispose: vi.fn() - } as never) - - const result = await dispatcher.callRequest( - 'pty.attach', - { - id: spawn.id, - sourceRecovery: { - status: 'checkpoint', - deliveryToken: 'old-token', - ptyIncarnation: spawn.incarnationId, - clientGeneration: 1, - ownerGeneration: 1, - acceptedSourceEndSu: 0 - } - }, - { - clientId: 2, - isStale: () => false, - onResponseSettled: vi.fn() - } as never - ) - - expect(waitForPendingSend).toHaveBeenCalledWith(spawn.id) - expect(activate).toHaveBeenCalledWith( - spawn.id, - spawn.incarnationId, - expect.anything(), - Object.freeze({ status: 'checkpointUnavailable' }) - ) - expect(result).toEqual({ - incarnationId: spawn.incarnationId, - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } - }) - }) - - it('notifies replay on normal attach', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - const spawn = await spawnPty() - dataCallback!('buffered output') - dispatcher.notify.mockClear() - - const result = await attachPty({ id: PTY_1 }) - - expect(result).toEqual({ incarnationId: spawn.incarnationId }) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.replay', { - id: PTY_1, - data: 'buffered output' - }) - vi.advanceTimersByTime(8) - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) - }) - - it('uses attach identity metadata without exporting it to the shell env', async () => { - const oldPaneKey = process.env.ORCA_PANE_KEY - const oldTabId = process.env.ORCA_TAB_ID - delete process.env.ORCA_PANE_KEY - delete process.env.ORCA_TAB_ID - let spawn!: { id: string; incarnationId: string } - try { - spawn = await spawnPty({ - env: { FOO: 'bar' }, - paneKey: 'tab-a:leaf-a', - tabId: 'tab-a' - }) - } finally { - if (oldPaneKey === undefined) { - delete process.env.ORCA_PANE_KEY - } else { - process.env.ORCA_PANE_KEY = oldPaneKey - } - if (oldTabId === undefined) { - delete process.env.ORCA_TAB_ID - } else { - process.env.ORCA_TAB_ID = oldTabId - } - } - - const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(spawnOptions.env.ORCA_PANE_KEY).toBeUndefined() - expect(spawnOptions.env.ORCA_TAB_ID).toBeUndefined() - - await expect( - attachPty({ - id: PTY_1, - expectedPaneKey: 'tab-b:leaf-b', - expectedTabId: 'tab-b' - }) - ).rejects.toThrow(`PTY "${PTY_1}" not found`) - - await expect( - attachPty({ - id: PTY_1, - expectedPaneKey: 'tab-a:leaf-a', - expectedTabId: 'tab-a' - }) - ).resolves.toEqual({ incarnationId: spawn.incarnationId }) - }) - - it('notifies on PTY exit and removes from map', async () => { - let exitCallback: ((info: { exitCode: number }) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { - exitCallback = cb - }) - }) - - const spawn = await spawnPty() - expect(handler.activePtyCount).toBe(1) - - exitCallback!({ exitCode: 0 }) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { - id: PTY_1, - code: 0, - incarnationId: spawn.incarnationId - }) - expect(handler.activePtyCount).toBe(0) - }) - - it('retains PTY exit until the ordinary writer admits it', async () => { - await handler.dispose({ waitForPhysicalExit: false }) - let capacityListener: (() => void) | undefined - const tryNotifyPtyExit = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) - Object.assign(dispatcher, { - onLegacyPtyCapacity: vi.fn((listener: () => void) => { - capacityListener = listener - return vi.fn() - }), - tryNotifyPtyData: vi.fn(() => true), - tryNotifyPtyExit, - legacyRetentionBelowLowWater: true - }) - handler = createTestPtyHandler(dispatcher) - let exitCallback: ((info: { exitCode: number }) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn((callback: (info: { exitCode: number }) => void) => { - exitCallback = callback - }) - }) - - const spawn = await spawnPty() - exitCallback?.({ exitCode: 0 }) - expect(tryNotifyPtyExit).toHaveBeenCalledOnce() - - capacityListener?.() - expect(tryNotifyPtyExit).toHaveBeenLastCalledWith({ - id: PTY_1, - code: 0, - incarnationId: spawn.incarnationId - }) - expect(tryNotifyPtyExit).toHaveBeenCalledTimes(2) - }) - - it('flushes pending PTY output before notifying exit', async () => { - let dataCallback: ((data: string) => void) | undefined - let exitCallback: ((info: { exitCode: number }) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { - exitCallback = cb - }) - }) - - const spawn = await spawnPty() - dataCallback!('final output') - exitCallback!({ exitCode: 0 }) - - expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', { - id: PTY_1, - data: 'final output' - }) - expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.exit', { - id: PTY_1, - code: 0, - incarnationId: spawn.incarnationId - }) - }) - - it('throws for attach on nonexistent PTY', async () => { - await expect(dispatcher.callRequest('pty.attach', { id: 'pty-999' })).rejects.toThrow( - 'PTY "pty-999" not found' - ) - }) - - it('attach preserves buffer so repeated attaches return the same data plus new output', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - const spawn = await spawnPty() - dataCallback!('initial output') - - const r1 = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - expect(r1).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output' }) - - dataCallback!(' more') - - const r2 = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - expect(r2).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output more' }) - }) - - it('second app restart still replays full buffer', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - const spawn = await spawnPty() - - dataCallback!('$ while true; do date; done\r\n') - dataCallback!('Mon Apr 28\r\n') - - const firstAttach = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - expect(firstAttach.incarnationId).toBe(spawn.incarnationId) - - dataCallback!('Tue Apr 29\r\n') - - const secondAttach = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - expect(secondAttach.incarnationId).toBe(spawn.incarnationId) - - dataCallback!('Wed Apr 30\r\n') - - const result = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - expect(result).toEqual({ - incarnationId: spawn.incarnationId, - replay: '$ while true; do date; done\r\nMon Apr 28\r\nTue Apr 29\r\nWed Apr 30\r\n' - }) - }) -}) - -describe('attachIdentityMismatches', () => { - it('rejects a tabId collision when only tab identity is known', () => { - expect(attachIdentityMismatches({ tabId: 'tab-a' }, { tabId: 'tab-b' })).toBe(true) - }) - - it('stays permissive when the caller supplies no identity', () => { - expect(attachIdentityMismatches({}, { paneKey: 'tab-a:0', tabId: 'tab-a' })).toBe(false) - }) - - it('stays permissive when the managed PTY predates identity capture', () => { - expect(attachIdentityMismatches({ paneKey: 'tab-a:0', tabId: 'tab-a' }, {})).toBe(false) - }) - - it('does not reject on tabId when paneKey matches (split panes share a tab)', () => { - expect( - attachIdentityMismatches({ paneKey: 'tab-a:1' }, { paneKey: 'tab-a:1', tabId: 'tab-a' }) - ).toBe(false) - }) -}) diff --git a/src/relay/pty-handler-dispose-lifecycle.test.ts b/src/relay/pty-handler-dispose-lifecycle.test.ts deleted file mode 100644 index 631834ab1b7..00000000000 --- a/src/relay/pty-handler-dispose-lifecycle.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import { IMMEDIATE_PTY_EXIT_TIMEOUT_MS, type PtyHandler } from './pty-handler' -import { beginPtyHandlerTest, endPtyHandlerTest, testPtyId } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) -const PTY_2 = testPtyId(2) - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('invokes the exit listener with the spawn-time paneKey', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - const exits: { id: string; paneKey?: string }[] = [] - handler.setExitListener((evt) => exits.push(evt)) - - await dispatcher.callRequest('pty.spawn', { - env: { ORCA_PANE_KEY: 'tab-2:1' } - }) - expect(onExitCb).toBeDefined() - onExitCb!({ exitCode: 0 }) - - expect(exits).toEqual([{ id: PTY_1, paneKey: 'tab-2:1' }]) - }) - - it('keeps immediate shutdown pending until onExit and invokes the exit listener once', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - const exits: { id: string; paneKey?: string }[] = [] - handler.setExitListener((evt) => exits.push(evt)) - - await dispatcher.callRequest('pty.spawn', { - env: { ORCA_PANE_KEY: 'tab-shutdown:0' } - }) - let settled = false - const shutdown = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - void shutdown.then(() => { - settled = true - }) - await Promise.resolve() - - expect(settled).toBe(false) - expect(exits).toEqual([]) - expect(handler.activePtyCount).toBe(1) - onExitCb!({ exitCode: 0 }) - await shutdown - - expect(mockKill).toHaveBeenCalledWith('SIGKILL') - expect(exits).toEqual([{ id: PTY_1, paneKey: 'tab-shutdown:0' }]) - expect(handler.activePtyCount).toBe(0) - }) - - it('physically stops only matching worktree PTYs before relay deletion', async () => { - let firstExit: ((evt: { exitCode: number }) => void) | undefined - let secondExit: ((evt: { exitCode: number }) => void) | undefined - const firstKill = vi.fn() - const secondKill = vi.fn() - mockPtySpawn - .mockReturnValueOnce({ - ...mockPtyInstance, - kill: firstKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - firstExit = cb - }) - }) - .mockReturnValueOnce({ - ...mockPtyInstance, - kill: secondKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - secondExit = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', { - cwd: '/repo', - env: { ORCA_WORKTREE_ID: 'repo-id::/repo' } - }) - await dispatcher.callRequest('pty.spawn', { - cwd: '/sibling', - env: { ORCA_WORKTREE_ID: 'repo-id::/sibling' } - }) - - let settled = false - const shutdown = handler.shutdownForWorktreePath('/repo').finally(() => { - settled = true - }) - await Promise.resolve() - expect(firstKill).toHaveBeenCalledWith('SIGKILL') - expect(secondKill).not.toHaveBeenCalled() - expect(settled).toBe(false) - expect(handler.activePtyCount).toBe(2) - - firstExit?.({ exitCode: 137 }) - await shutdown - expect(secondExit).toBeDefined() - expect(handler.activePtyCount).toBe(1) - }) - - it('rejects timed-out immediate shutdown while retaining the physical owner', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', {}) - const shutdown = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - const rejected = expect(shutdown).rejects.toThrow('Timed out waiting for PTY process exit') - await vi.advanceTimersByTimeAsync(IMMEDIATE_PTY_EXIT_TIMEOUT_MS) - await rejected - - expect(mockKill).toHaveBeenCalledTimes(1) - expect(handler.activePtyCount).toBe(1) - const retry = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - expect(mockKill).toHaveBeenCalledTimes(1) - onExitCb!({ exitCode: 137 }) - await retry - expect(handler.activePtyCount).toBe(0) - }) - - it('fences late creation and drains an admitted spawn before the disposal snapshot', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - const admittedSpawn = dispatcher.callRequest('pty.spawn', {}) - const dispose = handler.dispose() - await admittedSpawn - - expect(mockKill).toHaveBeenCalledWith('SIGKILL') - expect(handler.activePtyCount).toBe(1) - await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( - 'PTY handler is shutting down' - ) - const aliveSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await expect( - dispatcher.callRequest('pty.revive', { - state: JSON.stringify([ - { id: 'pty-late', pid: process.pid, cols: 80, rows: 24, cwd: '/tmp' } - ]) - }) - ).rejects.toThrow('PTY handler is shutting down') - } finally { - aliveSpy.mockRestore() - } - - onExitCb!({ exitCode: 137 }) - await dispose - expect(handler.activePtyCount).toBe(0) - expect(handler.dispose()).toBe(dispose) - }) - - it('retries a rejected force kill during dispose and waits for physical exit', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - let forceAttempts = 0 - const mockKill = vi.fn((signal: string) => { - if (signal === 'SIGKILL' && forceAttempts++ === 0) { - throw new Error('transient dispose kill failure') - } - }) - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', {}) - const dispose = handler.dispose() - await Promise.resolve() - - expect(mockKill.mock.calls).toEqual([['SIGKILL']]) - expect(handler.activePtyCount).toBe(1) - await vi.advanceTimersByTimeAsync(250) - expect(mockKill.mock.calls).toEqual([['SIGKILL'], ['SIGKILL']]) - expect(handler.activePtyCount).toBe(1) - - onExitCb!({ exitCode: 137 }) - await dispose - expect(handler.activePtyCount).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('takes ownership when dispose overlaps a queued graceful force-kill retry', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - let forceAttempts = 0 - const mockKill = vi.fn((signal: string) => { - if (signal === 'SIGKILL' && forceAttempts++ < 2) { - throw new Error('transient overlapping kill failure') - } - }) - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: false }) - vi.advanceTimersByTime(5000) - expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - expect(vi.getTimerCount()).toBe(1) - - const dispose = handler.dispose() - await Promise.resolve() - expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']]) - expect(vi.getTimerCount()).toBe(1) - await vi.advanceTimersByTimeAsync(250) - expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL'], ['SIGKILL']]) - expect(handler.activePtyCount).toBe(1) - - onExitCb!({ exitCode: 137 }) - await dispose - expect(handler.activePtyCount).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => { - const mockKill = vi.fn() - const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCallbacks.push(cb) - }) - }) - const exits: { id: string; paneKey?: string }[] = [] - handler.setExitListener((evt) => exits.push(evt)) - - await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:0' } }) - await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:1' } }) - expect(handler.activePtyCount).toBe(2) - - const dispose = handler.dispose() - await Promise.resolve() - // Why: dispose uses SIGKILL (not SIGTERM) because the relay process is - // exiting. A SIGTERM-ignoring remote shell (editor with unsaved buffers, - // wedged process, uninterruptible sleep) would survive SIGTERM + immediate - // destroy() as an orphan on the remote host. SIGKILL is not ignorable. - expect(mockKill).toHaveBeenCalledWith('SIGKILL') - expect(handler.activePtyCount).toBe(2) - for (const onExit of onExitCallbacks) { - onExit({ exitCode: 137 }) - } - await dispose - expect(exits).toEqual([ - { id: PTY_1, paneKey: 'tab-dispose:0' }, - { id: PTY_2, paneKey: 'tab-dispose:1' } - ]) - expect(handler.activePtyCount).toBe(0) - }) -}) diff --git a/src/relay/pty-handler-grace-timer.test.ts b/src/relay/pty-handler-grace-timer.test.ts deleted file mode 100644 index ed552e71551..00000000000 --- a/src/relay/pty-handler-grace-timer.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('allows callers to shorten a grace timer for empty startup relays', () => { - const onExpire = vi.fn() - handler.startGraceTimer(onExpire, 100) - - expect(handler.graceTimerActive).toBe(true) - vi.advanceTimersByTime(99) - expect(onExpire).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - expect(onExpire).toHaveBeenCalledTimes(1) - }) - - it('does not expire an unlimited grace timer', () => { - const onExpire = vi.fn() - handler.startGraceTimer(onExpire, 100) - - expect(handler.graceTimerActive).toBe(true) - handler.startGraceTimer(onExpire, 0) - - expect(handler.graceTimerActive).toBe(false) - vi.advanceTimersByTime(100) - expect(onExpire).not.toHaveBeenCalled() - }) - - it('uses the configured grace time for future disconnect timers', () => { - const onExpire = vi.fn() - - handler.setGraceTimeMs(250) - handler.startGraceTimer(onExpire) - - vi.advanceTimersByTime(249) - expect(onExpire).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - expect(onExpire).toHaveBeenCalledTimes(1) - }) - - it('default grace timer does not expire', () => { - const onExpire = vi.fn() - - handler.startGraceTimer(onExpire) - - expect(handler.graceTimerActive).toBe(false) - vi.advanceTimersByTime(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000) - expect(onExpire).not.toHaveBeenCalled() - }) - - it('configured grace timer waits full period even when no PTYs exist', () => { - const onExpire = vi.fn() - const boundedGraceMs = DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - handler.setGraceTimeMs(boundedGraceMs) - handler.startGraceTimer(onExpire) - expect(onExpire).not.toHaveBeenCalled() - vi.advanceTimersByTime(boundedGraceMs - 1) - expect(onExpire).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - expect(onExpire).toHaveBeenCalledTimes(1) - }) - - it('grace timer fires after configured delay when PTYs exist', async () => { - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', {}) - - const onExpire = vi.fn() - const boundedGraceMs = DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - handler.setGraceTimeMs(boundedGraceMs) - handler.startGraceTimer(onExpire) - expect(onExpire).not.toHaveBeenCalled() - - vi.advanceTimersByTime(boundedGraceMs - 1) - expect(onExpire).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - expect(onExpire).toHaveBeenCalledTimes(1) - }) - - it('cancelGraceTimer prevents expiration', async () => { - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', {}) - - const onExpire = vi.fn() - const boundedGraceMs = DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - handler.setGraceTimeMs(boundedGraceMs) - handler.startGraceTimer(onExpire) - - vi.advanceTimersByTime(60_000) - handler.cancelGraceTimer() - - vi.advanceTimersByTime(boundedGraceMs) - expect(onExpire).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/pty-handler-immediate-descendants.test.ts b/src/relay/pty-handler-immediate-descendants.test.ts deleted file mode 100644 index 1362052834a..00000000000 --- a/src/relay/pty-handler-immediate-descendants.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' -import type { PtyHandler } from './pty-handler' -import type { RelayPtySourcePublication } from './relay-pty-source-publication' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe, sweep } = vi.hoisted( - () => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - sweep: - vi.fn< - (pid: number, killRoot: () => void, deps?: { ownsRoot?: () => boolean }) => Promise - >(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } - }) -) -vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) -vi.mock('../main/pty-descendant-termination', () => ({ killWithDescendantSweep: sweep })) -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: (_pid: number, kill: () => void) => kill() -})) -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -const ensure = { - claim: { - digestVersion: 1, - keyId: 'claim-key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'omp' - }, - surface: { - worktreeId: 'repo::/tmp/worktree', - tabId: '11111111-1111-4111-8111-111111111111', - leafId: '22222222-2222-4222-8222-222222222222', - terminalHandle: 'term_omp' - } -} - -describe('relay immediate descendant cleanup', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let exit: ((event: { exitCode: number }) => void) | undefined - let release: (() => void) | undefined - let kill: ReturnType - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - exit = undefined - release = undefined - kill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill, - onExit: (callback: (event: { exitCode: number }) => void) => { - exit = callback - } - }) - sweep.mockReset() - sweep.mockImplementation( - (_pid, killRoot) => - new Promise((resolve, reject) => { - release = () => { - try { - killRoot() - resolve() - } catch (error) { - reject(error) - } - } - }) - ) - }) - afterEach(async () => { - release?.() - exit?.({ exitCode: 137 }) - await endPtyHandlerTest(handler, originalPlatform) - }) - - async function spawn(params: Record = {}) { - const result = await dispatcher.callRequest('pty.spawn', params) - if ( - !result || - typeof result !== 'object' || - !('id' in result) || - typeof result.id !== 'string' - ) { - throw new Error('missing PTY id') - } - return result.id - } - const close = (id: string) => dispatcher.callRequest('pty.shutdown', { id, immediate: true }) - - it('sweeps a typed agent before force-kill and joins close through physical exit', async () => { - const id = await spawn() - const first = close(id) - const second = close(id) - expect(sweep).toHaveBeenCalledTimes(1) - expect(kill).not.toHaveBeenCalled() - await expect(dispatcher.callRequest('pty.attach', { id })).rejects.toThrow('terminating') - release?.() - await vi.waitFor(() => expect(kill).toHaveBeenCalledWith('SIGKILL')) - expect(handler.activePtyCount).toBe(1) - exit?.({ exitCode: 137 }) - await Promise.all([first, second]) - expect(handler.activePtyCount).toBe(0) - expect(kill).toHaveBeenCalledTimes(1) - }) - - it('does not signal a root that exits while its snapshot is pending', async () => { - const id = await spawn() - const closing = close(id) - const ownsRoot = sweep.mock.calls[0]?.[2]?.ownsRoot - expect(ownsRoot?.()).toBe(true) - exit?.({ exitCode: 0 }) - expect(ownsRoot?.()).toBe(false) - release?.() - await closing - expect(kill).not.toHaveBeenCalled() - }) - - it('retains the agent claim instead of adopting or duplicating a closing owner', async () => { - const id = await spawn({ agentSessionEnsure: ensure }) - const closing = close(id) - await expect(spawn({ agentSessionEnsure: ensure })).rejects.toThrow('terminating') - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - release?.() - await vi.waitFor(() => expect(kill).toHaveBeenCalledTimes(1)) - exit?.({ exitCode: 137 }) - await closing - }) - - it('does not replay a completed create operation while its owner is closing', async () => { - const params = { - agentSessionEnsure: ensure, - agentSessionCreateOperationId: 'ccccccccccccccccccccccccccccccccccccccccccc' - } - const id = await spawn(params) - const closing = close(id) - await expect(spawn(params)).rejects.toThrow('terminating') - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - release?.() - await vi.waitFor(() => expect(kill).toHaveBeenCalledTimes(1)) - exit?.({ exitCode: 137 }) - await closing - }) - - it('allows retry after a failed root signal without releasing the live PTY', async () => { - const id = await spawn() - kill.mockImplementationOnce(() => { - throw new Error('signal refused') - }) - const rejected = expect(close(id)).rejects.toThrow('signal refused') - release?.() - await rejected - expect(handler.activePtyCount).toBe(1) - const retry = close(id) - expect(sweep).toHaveBeenCalledTimes(2) - release?.() - await vi.waitFor(() => expect(kill).toHaveBeenCalledTimes(2)) - exit?.({ exitCode: 137 }) - await retry - }) - - it('keeps the Windows force-kill path and fences attachment until physical exit', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const id = await spawn() - const closing = close(id) - expect(sweep).not.toHaveBeenCalled() - expect(kill).toHaveBeenCalledWith() - await expect(dispatcher.callRequest('pty.attach', { id })).rejects.toThrow('terminating') - exit?.({ exitCode: 137 }) - await closing - }) - - it('keeps graceful shell shutdown off the descendant sweep', async () => { - const id = await spawn() - await dispatcher.callRequest('pty.shutdown', { id, immediate: false }) - expect(sweep).not.toHaveBeenCalled() - expect(kill).toHaveBeenCalledWith('SIGTERM') - }) - it('refuses attach after close completes during source checkpoint wait', async () => { - const id = await spawn() - let finishSource!: (ready: boolean) => void - const sourceWait = new Promise((resolve) => { - finishSource = resolve - }) - const activate = vi.fn(() => false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This stub supplies every publication method exercised by the handler. - handler.setSourcePublication({ - accepts: () => false, - exitPublicationSettled: () => true, - sealAndPublishExit: () => false, - publish: () => false, - onCreditAvailable: () => {}, - receivingActivation: () => undefined, - waitForPendingSend: () => sourceWait, - activate, - getDebugSnapshot: () => ({}), - dispose: () => {} - } as unknown as RelayPtySourcePublication) - const attaching = dispatcher.callRequest('pty.attach', { - id, - sourceRecovery: { - status: 'checkpoint', - deliveryToken: 'token', - ptyIncarnation: 'incarnation', - clientGeneration: 1, - ownerGeneration: 1, - acceptedSourceEndSu: 0 - } - }) - const closing = close(id) - release?.() - await vi.waitFor(() => expect(kill).toHaveBeenCalledTimes(1)) - exit?.({ exitCode: 137 }) - await closing - expect(handler.activePtyCount).toBe(0) - finishSource(true) - await expect(attaching).rejects.toThrow() - expect(activate).not.toHaveBeenCalled() - }) - - it('retains claim if close starts before initial claim liveness validation', async () => { - let closing: Promise | undefined - let closeId = '' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This stub supplies every publication method exercised by the handler. - handler.setSourcePublication({ - accepts: () => false, - exitPublicationSettled: () => true, - sealAndPublishExit: () => false, - publish: () => false, - onCreditAvailable: () => {}, - receivingActivation: () => undefined, - waitForPendingSend: async () => true, - activate: (id: string) => { - if (!closeId) { - closeId = id - queueMicrotask(() => { - closing = close(id) - void closing.catch(() => {}) - }) - } - return false - }, - getDebugSnapshot: () => ({}), - dispose: () => {} - } as unknown as RelayPtySourcePublication) - await expect(spawn({ agentSessionEnsure: ensure })).rejects.toThrow('terminating') - expect(handler.activePtyCount).toBe(1) - const firstExit = exit - const retried = spawn({ agentSessionEnsure: ensure }) - const outcome = await retried.then( - () => 'created', - () => 'rejected' - ) - const spawnCount = mockPtySpawn.mock.calls.length - release?.() - firstExit?.({ exitCode: 137 }) - await closing - expect(outcome).toBe('rejected') - expect(spawnCount).toBe(1) - }) -}) diff --git a/src/relay/pty-handler-inventory-process-evidence.test.ts b/src/relay/pty-handler-inventory-process-evidence.test.ts deleted file mode 100644 index 0ab15977f0b..00000000000 --- a/src/relay/pty-handler-inventory-process-evidence.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import './mock-descendant-sweep' -// Regression guard for the SHIPPED inventory path. `pty.listProcesses` resolves -// every managed pane's title from one batched host capture; a per-pane tree walk -// would restore the O(panes x rows) scan on the relay's single event-loop thread, -// and a batched result that cannot name the foreground process must fall back to -// node-pty's own name rather than relabelling a live pane "shell". -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' - -const { - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe, - mockGetStrictProcessTableSnapshot, - mockGetStrictProcessTableSnapshotWithAge -} = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockGetStrictProcessTableSnapshot: vi.fn(), - mockGetStrictProcessTableSnapshotWithAge: vi.fn(), - mockPtyInstance: { - pid: process.pid, - process: 'zsh', - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -vi.mock('../shared/process-table-snapshot-reader', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - getStrictProcessTableSnapshot: mockGetStrictProcessTableSnapshot, - getStrictProcessTableSnapshotWithAge: mockGetStrictProcessTableSnapshotWithAge - } -}) - -import type * as processTableSnapshotModule from '../shared/process-table-snapshot-reader' -import type { ProcessTableRow } from '../shared/process-table-snapshot' - -type ProcessTableSnapshotModule = typeof processTableSnapshotModule -import * as ptyShellUtils from './pty-shell-utils' -import type { PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createPtyRequestHelpers, - endPtyHandlerTest -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -type ProcessSummary = { id: string; title: string } - -/** A shell root plus its foreground children, as `ps` reports them. */ -function paneRows(rootPid: number, commands: string[]): ProcessTableRow[] { - const foregroundPgid = rootPid + 1 - return [ - { - pid: rootPid, - ppid: 1, - pgid: rootPid, - tpgid: foregroundPgid, - stat: 'Ss', - command: '/bin/zsh' - }, - ...commands.map((command, index) => ({ - pid: foregroundPgid + index, - ppid: index === 0 ? rootPid : foregroundPgid + index - 1, - pgid: foregroundPgid, - tpgid: foregroundPgid, - stat: 'S+', - command - })) - ] -} - -/** Counts element reads so a per-pane rescan of the table cannot pass unseen. */ -function countingRows(rows: ProcessTableRow[]): { - rows: readonly ProcessTableRow[] - reads: () => number -} { - let reads = 0 - const proxy = new Proxy(rows, { - get(target, key, receiver) { - if (typeof key === 'string' && /^\d+$/.test(key)) { - reads += 1 - } - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. - return Reflect.get(target, key, receiver) - } - }) - return { rows: proxy, reads: () => reads } -} - -describe('PtyHandler inventory foreground evidence', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - const { spawnPty } = createPtyRequestHelpers(() => dispatcher) - - async function spawnPane(pid: number, processName: string): Promise { - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - pid, - process: processName, - onData: vi.fn(), - onExit: vi.fn(), - kill: vi.fn() - }) - return (await spawnPty()).id - } - - async function listProcesses(): Promise { - return (await dispatcher.callRequest('pty.listProcesses', {})) as ProcessSummary[] - } - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - mockGetStrictProcessTableSnapshot.mockReset() - mockGetStrictProcessTableSnapshotWithAge.mockReset() - mockGetStrictProcessTableSnapshotWithAge.mockImplementation(async () => ({ - rows: await mockGetStrictProcessTableSnapshot(), - capturedAgeMs: 0 - })) - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('names each pane from the batched capture', async () => { - const rows = [...paneRows(1000, ['node /opt/codex']), ...paneRows(2000, ['vim notes.txt'])] - mockGetStrictProcessTableSnapshot.mockResolvedValue(rows) - await spawnPane(1000, 'zsh') - await spawnPane(2000, 'vim') - - expect((await listProcesses()).map((entry) => entry.title)).toEqual(['codex', 'vim']) - }) - - it('keeps the node-pty name when the capture cannot disambiguate a wrapper', async () => { - // Two same-group `node` children (dev server + worker): the batch refuses to - // guess, and the pane must stay "node" rather than being relabelled a shell. - mockGetStrictProcessTableSnapshot.mockResolvedValue( - paneRows(3000, ['node /srv/app/server.js', 'node /srv/app/worker.js']) - ) - await spawnPane(3000, 'node') - - expect((await listProcesses())[0].title).toBe('node') - }) - - // The cost that matters is per-CAPTURE, not per-pane: the defect this guards against is a - // full-table walk for every pane, which is what an O(PTY x rows) inventory looked like. Two - // linear passes build the two indexes the resolver reads — parent/child correlation, and which - // process groups occupy each controlling terminal — and neither grows with the pane count. - const CAPTURE_PASSES = 2 - - it.each([1, 8])( - 'walks the host table a fixed number of times for %s panes', - async (paneCount) => { - const table = Array.from({ length: paneCount }, (_, index) => - paneRows(10_000 + index * 10, ['node /opt/codex']) - ).flat() - const { rows, reads } = countingRows(table) - mockGetStrictProcessTableSnapshot.mockResolvedValue(rows) - for (let index = 0; index < paneCount; index += 1) { - await spawnPane(10_000 + index * 10, 'zsh') - } - - const listed = await listProcesses() - - expect(listed).toHaveLength(paneCount) - expect(listed.every((entry) => entry.title === 'codex')).toBe(true) - expect(mockGetStrictProcessTableSnapshot).toHaveBeenCalledTimes(1) - // Linear in the capture — NOT one full-table walk per pane, which would be - // `table.length * paneCount` here. - expect(reads()).toBe(table.length * CAPTURE_PASSES) - } - ) - - it('returns fenced inspect evidence and echoes the PTY incarnation', async () => { - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - pid: 4000, - process: 'zsh', - onData: vi.fn(), - onExit: vi.fn(), - kill: vi.fn() - }) - const spawned = await spawnPty({ cols: 80, rows: 24 }) - mockGetStrictProcessTableSnapshot.mockResolvedValue([ - { - pid: 4000, - ppid: 1, - pgid: 4000, - tpgid: 4001, - tty: '/dev/pts/9', - startTime: 'anchor-start', - stat: 'Ss', - command: '/bin/zsh' - }, - { - pid: 4001, - ppid: 4000, - pgid: 4001, - tpgid: 4001, - tty: '/dev/pts/9', - startTime: 'candidate-start', - stat: 'S+', - command: 'node /opt/codex' - } - ]) - const inspection = await dispatcher.callRequest('pty.inspectProcess', { - id: spawned.id, - expectedIncarnationId: spawned.incarnationId - }) - - expect(inspection).toMatchObject({ - foregroundProcess: 'codex', - foregroundProcessEvidence: { - verdict: 'live', - ptyId: spawned.id, - ptyIncarnationId: spawned.incarnationId, - fence: { - platform: 'posix', - shellPid: 4000, - shellStartTime: 'anchor-start', - tty: '/dev/pts/9', - foregroundPgid: 4001, - process: { pid: 4001, startTime: 'candidate-start' } - } - } - }) - expect(mockGetStrictProcessTableSnapshot).toHaveBeenCalledOnce() - }) - - it('skips process capture for the no-evidence inventory projection', async () => { - await spawnPane(5000, 'zsh') - mockGetStrictProcessTableSnapshot.mockReset() - - const result = await dispatcher.callRequest('pty.listProcesses', { - includeForegroundProcessEvidence: false - }) - - expect(result).toHaveLength(1) - expect(mockGetStrictProcessTableSnapshot).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/pty-handler-opencode-revive.test.ts b/src/relay/pty-handler-opencode-revive.test.ts deleted file mode 100644 index 1c73f8be1fe..00000000000 --- a/src/relay/pty-handler-opencode-revive.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import './mock-descendant-sweep' -import { tmpdir } from 'node:os' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { getOpenCodeCliCapabilities } from '../shared/opencode-cli-version' -import { applyOpenCodePluginSelection } from './opencode-plugin-selection' -import type { PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createMockDispatcher, - createTestPtyHandler, - endPtyHandlerTest, - testPtyId, - type MockDispatcher -} from './pty-handler-test-harness' - -const mocks = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - probe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe -})) -vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ - probeOpenCodeLaunchCapabilities: mocks.probe -})) - -let dispatcher: MockDispatcher -let handler: PtyHandler -let originalPlatform: PropertyDescriptor | undefined - -beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks)) - mocks.probe.mockReset() -}) - -afterEach(async () => endPtyHandlerTest(handler, originalPlatform)) - -function spawnedEnvironment(): Record { - const options: unknown = mocks.mockPtySpawn.mock.calls.at(-1)?.[2] - if ( - !options || - typeof options !== 'object' || - !('env' in options) || - !options.env || - typeof options.env !== 'object' - ) { - throw new Error('Expected a PTY spawn environment') - } - return Object.fromEntries(Object.entries(options.env)) -} - -async function serialize(): Promise { - const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] }) - if (typeof state !== 'string') { - throw new Error('Expected serialized PTY state') - } - return state -} - -async function restartHandler(): Promise { - await handler.dispose({ waitForPhysicalExit: false }) - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - mocks.mockPtySpawn.mockClear() -} - -it.each(['1.18.32', '2.0.16'])( - 'preserves host-selected plugin exports through two relay revives on %s', - async (version) => { - const capabilities = getOpenCodeCliCapabilities(version) - mocks.probe.mockResolvedValue(capabilities) - await dispatcher.callRequest('pty.spawn', { - cwd: tmpdir(), - command: 'opencode --standalone', - launchAgent: 'opencode', - env: { ORCA_PANE_KEY: 'tab-oc:leaf', ORCA_OPENCODE_PLUGIN_API: 'untrusted' }, - envToDelete: ['ORCA_OPENCODE_PLUGIN_API', 'ORCA_OPENCODE_PLUGIN_API'] - }) - expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) - const state = await serialize() - expect(JSON.parse(state)).toMatchObject([ - { openCodeCapabilities: capabilities, envToDelete: [] } - ]) - - await restartHandler() - await dispatcher.callRequest('pty.revive', { state }) - expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) - const second = await serialize() - await restartHandler() - await dispatcher.callRequest('pty.revive', { state: second }) - expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) - expect(mocks.probe).toHaveBeenCalledOnce() - } -) - -it.each([ - undefined, - { version: 'garbage', pluginApi: 'v1' }, - { version: '3.0.0', pluginApi: 'v1' }, - { version: 2, pluginApi: 'v1' } -])('keeps legacy or unverifiable serialized selection unset: %j', async (openCodeCapabilities) => { - handler.addEnvAugmenter(() => ({ ORCA_OPENCODE_PLUGIN_API: 'v1' })) - await dispatcher.callRequest('pty.revive', { - state: JSON.stringify([ - { - id: testPtyId(1), - pid: process.pid, - cols: 80, - rows: 24, - cwd: tmpdir(), - ...(openCodeCapabilities ? { openCodeCapabilities } : {}) - } - ]) - }) - expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBeUndefined() - expect(mocks.probe).not.toHaveBeenCalled() -}) - -it('carries a selected API into WSL without dropping other forwarded keys', () => { - const env = { WSLENV: 'XDG_DATA_HOME/p', ORCA_OPENCODE_PLUGIN_API: 'untrusted' } - const envToDelete = ['ORCA_OPENCODE_PLUGIN_API', 'OTHER_ENV'] - applyOpenCodePluginSelection(env, envToDelete, getOpenCodeCliCapabilities('1.18.32'), true) - expect(env).toMatchObject({ - ORCA_OPENCODE_PLUGIN_API: 'v1', - WSLENV: 'XDG_DATA_HOME/p:ORCA_OPENCODE_PLUGIN_API' - }) - expect(envToDelete).toEqual(['OTHER_ENV']) -}) diff --git a/src/relay/pty-handler-output-drain-differential.test.ts b/src/relay/pty-handler-output-drain-differential.test.ts deleted file mode 100644 index 3afd9a37d81..00000000000 --- a/src/relay/pty-handler-output-drain-differential.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' - -const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) - -import { PtyHandler } from './pty-handler' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -// Mirrors the relay drain constants; kept local so a constant change fails this oracle loudly. -const CHUNK_CHARS = 16 * 1024 -const MAX_WRITES = 2 -const BATCH_INTERVAL_MS = 8 -const DRAIN_CONTINUE_MS = 1 - -type PendingOutput = { data: string; rawLength?: number; seq?: number } -type DataEvent = { id: string; data: string; seq?: number; rawLength?: number } - -/** - * The pre-optimization implementation, verbatim in behavior: snapshot the whole pending map each - * tick, then consume up to MAX_WRITES from that frozen list. The bounded-prefix capture must match - * it event-for-event — including which entry leads each tick. - */ -function legacyFlushTick(pendingById: Map): { - events: DataEvent[] - writes: number - reschedules: boolean -} { - const events: DataEvent[] = [] - let writes = 0 - for (const [id, pending] of Array.from(pendingById.entries())) { - if (writes >= MAX_WRITES) { - break - } - pendingById.delete(id) - const chunk = pending.data.slice(0, CHUNK_CHARS) - const remaining = pending.data.slice(CHUNK_CHARS) - if (remaining) { - pendingById.set(id, { - data: remaining, - ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), - seq: pending.seq - }) - } - events.push({ - id, - data: chunk, - ...(pending.seq === undefined - ? {} - : { seq: pending.seq - (pending.data.length - chunk.length) }), - ...(pending.rawLength === undefined ? {} : { rawLength: chunk.length }) - }) - writes += 1 - } - return { events, writes, reschedules: pendingById.size > 0 && writes > 0 } -} - -function legacyTimeline(initial: Map): DataEvent[] { - const pendingById = new Map(initial) - const timeline: DataEvent[] = [] - for (let tick = 0; tick < 500 && pendingById.size > 0; tick++) { - const result = legacyFlushTick(pendingById) - timeline.push(...result.events) - if (!result.reschedules) { - break - } - } - return timeline -} - -// xorshift32 — deterministic across platforms, no Math.random. -function createRandom(seed: number): () => number { - let state = seed >>> 0 || 1 - return () => { - state ^= state << 13 - state ^= state >>> 17 - state ^= state << 5 - state >>>= 0 - return state / 0x1_0000_0000 - } -} - -describe('relay PTY output drain — differential vs the pre-optimization snapshot loop', () => { - let dispatcher: { - notify: ReturnType - callRequest: (method: string, params?: Record) => Promise - _notifications: { method: string; params?: Record }[] - } - let handler: PtyHandler - let dataCallbacks: Map void> - let exitCallbacks: Map void> - let onNotifyData: ((frame: { id: string; data: string }) => void) | null - - beforeEach(() => { - vi.useFakeTimers() - mockPtySpawn.mockReset() - dataCallbacks = new Map() - exitCallbacks = new Map() - onNotifyData = null - - const requestHandlers = new Map) => Promise>() - const notifications: { method: string; params?: Record }[] = [] - let reentering = false - dispatcher = { - notify: vi.fn((method: string, params?: Record) => { - notifications.push({ method, params }) - if (method !== 'pty.data' || !onNotifyData || reentering) { - return - } - // Why: the relay sink is a synchronous write; this hook models a sink that re-enters - // PTY ingress before the drain returns. - reentering = true - try { - onNotifyData(params as unknown as { id: string; data: string }) - } finally { - reentering = false - } - }), - callRequest: async (method: string, params: Record = {}) => { - const h = requestHandlers.get(method) - if (!h) { - throw new Error(`No handler for ${method}`) - } - return h(params) - }, - _notifications: notifications - } - const full = { - onRequest: vi.fn((method: string, h: (params: Record) => Promise) => - requestHandlers.set(method, h) - ), - onNotification: vi.fn(), - notify: dispatcher.notify - } - handler = new PtyHandler(full as unknown as RelayDispatcher) - }) - - afterEach(async () => { - onNotifyData = null - const cleanup = handler.dispose({ waitForPhysicalExit: false }) - await vi.runAllTimersAsync() - await cleanup.catch(() => {}) - vi.useRealTimers() - }) - - async function spawnPtys(count: number): Promise { - const ids: string[] = [] - for (let i = 0; i < count; i++) { - let dataCallback!: (data: string) => void - let exitCallback!: (event: { exitCode: number }) => void - mockPtySpawn.mockReturnValueOnce({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn((cb: (event: { exitCode: number }) => void) => { - exitCallback = cb - }) - }) - const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } - dataCallbacks.set(spawned.id, dataCallback) - exitCallbacks.set(spawned.id, exitCallback) - ids.push(spawned.id) - } - return ids - } - - function recordedDataEvents(): DataEvent[] { - return dispatcher._notifications - .filter((n) => n.method === 'pty.data') - .map((n) => n.params as unknown as DataEvent) - } - - it('emits the same pty.data timeline as the snapshot loop for multi-PTY multi-chunk drains', async () => { - const sessionCount = 4 - const ids = await spawnPtys(sessionCount) - - const payloads = new Map() - const expectedPending = new Map() - ids.forEach((id, index) => { - // Vary chunk counts so PTYs finish on different ticks. - const data = `${String.fromCharCode(97 + index)}`.repeat(CHUNK_CHARS * (index + 1) + index) - payloads.set(id, data) - expectedPending.set(id, { data }) - }) - - for (const id of ids) { - dataCallbacks.get(id)!(payloads.get(id)!) - } - - await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) - for (let tick = 0; tick < 200; tick++) { - await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) - } - - expect(recordedDataEvents()).toEqual(legacyTimeline(expectedPending)) - }) - - it('matches the snapshot loop across randomized session counts and payload sizes', async () => { - const random = createRandom(0x5eed) - const sessionCount = 5 - const ids = await spawnPtys(sessionCount) - - const expectedPending = new Map() - for (const id of ids) { - const chunks = 1 + Math.floor(random() * 3) - const extra = Math.floor(random() * 64) - const data = 'z'.repeat(CHUNK_CHARS * chunks + extra) - expectedPending.set(id, { data }) - dataCallbacks.get(id)!(data) - } - - await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) - for (let tick = 0; tick < 300; tick++) { - await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) - } - - expect(recordedDataEvents()).toEqual(legacyTimeline(expectedPending)) - }) - - it('keeps one write per tick when a single PTY drains — pacing a lazy iterator walk breaks', async () => { - const [id] = await spawnPtys(1) - const first = 'x'.repeat(CHUNK_CHARS) - dataCallbacks.get(id)!(`${first}tail`) - - await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) - // Iterating the live map lazily would pick the re-queued remainder up again in this same tick. - expect(recordedDataEvents()).toEqual([{ id, data: first }]) - - await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) - expect(recordedDataEvents()).toEqual([ - { id, data: first }, - { id, data: 'tail' } - ]) - }) - - it('writes at most two PTYs per tick and rotates the rest to the next tick', async () => { - const ids = await spawnPtys(3) - for (const id of ids) { - dataCallbacks.get(id)!('a'.repeat(CHUNK_CHARS + 4)) - } - - await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) - // First tick writes the two head PTYs only. - expect(recordedDataEvents().map((event) => event.id)).toEqual([ids[0], ids[1]]) - - await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) - // Third PTY leads the next tick; the two re-queued remainders sit behind it. - expect(recordedDataEvents().map((event) => event.id)).toEqual([ids[0], ids[1], ids[2], ids[0]]) - }) - - it('freezes the tick batch before the first send, matching the snapshot loop under re-entrancy', async () => { - const ids = await spawnPtys(2) - dataCallbacks.get(ids[0])!('first') - dataCallbacks.get(ids[1])!('second') - - // Append to the second PTY while the first is being sent. Capturing the batch up front freezes - // the same values the whole-map snapshot froze, so this tick's output is unchanged. - onNotifyData = (frame) => { - if (frame.id === ids[0]) { - dataCallbacks.get(ids[1])!('-appended') - } - } - - await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) - expect(recordedDataEvents()).toEqual([ - { id: ids[0], data: 'first' }, - { id: ids[1], data: 'second' } - ]) - - onNotifyData = null - await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) - await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) - // Verbatim pre-change behavior, quirk included: the frozen entry is sent and then deleted, so - // bytes appended to it mid-tick are dropped. Reachable only from a sink that re-enters PTY - // ingress synchronously; the relay's real sinks are stdout/socket writes that cannot. Asserted - // here so the optimization is pinned to "no behavior change" rather than a silent improvement. - expect(recordedDataEvents()).toEqual([ - { id: ids[0], data: 'first' }, - { id: ids[1], data: 'second' } - ]) - }) - - it('flushes pending bytes before pty.exit', async () => { - // PTY exit routes through flushPtyOutput (the whole-entry path), not the chunked drain. - const [id] = await spawnPtys(1) - dataCallbacks.get(id)!('tail bytes') - exitCallbacks.get(id)!({ exitCode: 0 }) - - const methods = dispatcher._notifications.map((n) => n.method) - // Ordering invariant: buffered output must land before the exit frame. - expect(methods.indexOf('pty.data')).toBeGreaterThanOrEqual(0) - expect(methods.indexOf('pty.data')).toBeLessThan(methods.indexOf('pty.exit')) - - const dataFrames = dispatcher._notifications.filter((n) => n.method === 'pty.data') - expect(dataFrames).toHaveLength(1) - expect(dataFrames[0].params).toEqual({ id, data: 'tail bytes' }) - }) -}) diff --git a/src/relay/pty-handler-output-streaming.test.ts b/src/relay/pty-handler-output-streaming.test.ts deleted file mode 100644 index 2d13b5d9b8e..00000000000 --- a/src/relay/pty-handler-output-streaming.test.ts +++ /dev/null @@ -1,796 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' -import { _resetPtyOwnerHostColorsForTest } from '../shared/pty-owner-color-query-colors' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import { PtyHandler } from './pty-handler' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame } from '../wsl-guest/protocol' -import type { RelayPtySourcePublication } from './relay-pty-source-publication' -import { - beginPtyHandlerTest, - createTestPtyHandler, - endPtyHandlerTest, - testPtyId -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) - -type PendingFlowState = { - pendingOutputByPty: Map - pendingProducerBytesByPty: Map -} - -function pendingFlowState(handler: PtyHandler): PendingFlowState { - return handler as unknown as PendingFlowState -} - -function chargedPendingBytes(data: string): number { - return Math.max(Buffer.byteLength(data, 'utf8'), 2 * data.length) + 128 -} - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('pauses native output at the producer hard water and resumes after retained writes settle', async () => { - let onData: ((data: string) => void) | undefined - const pause = vi.fn() - const resume = vi.fn() - mockPtySpawn.mockReturnValueOnce({ - ...mockPtyInstance, - pause, - resume, - onData: vi.fn((callback: (data: string) => void) => { - onData = callback - }) - }) - const writeCallbacks: (() => void)[] = [] - let writableLength = 0 - const boundedDispatcher = new RelayDispatcher( - (data, settle) => { - writableLength += data.length - writeCallbacks.push(() => { - writableLength -= data.length - settle({ ok: true }) - }) - return true - }, - { - supportsWriteCallback: true, - writableLength: () => writableLength, - writableHighWaterMark: () => 4 * 1024 * 1024 - } - ) - const boundedHandler = new PtyHandler(boundedDispatcher, undefined, 'bounded-test-mint-epoch') - try { - boundedDispatcher.feed( - encodeJsonRpcFrame({ jsonrpc: '2.0', id: 1, method: 'pty.spawn', params: {} }, 1, 0) - ) - await vi.advanceTimersByTimeAsync(0) - expect(onData).toBeTypeOf('function') - - onData?.('x'.repeat(1536 * 1024)) - expect(pause).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(300) - - expect(writeCallbacks.length).toBeGreaterThan(50) - expect(resume).not.toHaveBeenCalled() - for (const settle of writeCallbacks.splice(0)) { - settle() - } - await vi.advanceTimersByTimeAsync(0) - expect(resume).toHaveBeenCalledTimes(1) - } finally { - await boundedHandler.dispose({ waitForPhysicalExit: false }).catch(() => {}) - boundedDispatcher.dispose() - } - }) - - it('forwards data from PTY to dispatcher notifications', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - expect(dataCallback).toBeDefined() - - dataCallback!('hello world') - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: 'hello world' }) - }) - - it('publishes Freebuff host status without charging synthetic OSC bytes to the PTY', async () => { - let onData: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((callback: (data: string) => void) => { - onData = callback - }) - }) - await dispatcher.callRequest('pty.spawn', { launchAgent: 'freebuff', cols: 120, rows: 40 }) - const raw = readFileSync( - join(import.meta.dirname, '../main/runtime/__fixtures__/freebuff-trust.txt'), - 'utf8' - ) - onData!(raw) - vi.advanceTimersByTime(8) - // The capture opens with Freebuff's OSC 10/11 probes, which the relay answers and strips. - expect(dispatcher.notify).toHaveBeenCalledWith( - 'pty.data', - expect.objectContaining({ - id: PTY_1, - data: expect.stringContaining('"state":"blocked"'), - seq: raw.length, - transformed: true - }) - ) - dispatcher.notify.mockClear() - onData!('more output') - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: 'more output' }) - }) - - it('consumes capable startup queries before relay replay and fanout', async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', { - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 - } - }) - - const query = '\x1b]10;?\x07' - dataCallback!(query) - dataCallback!('prompt') - vi.advanceTimersByTime(8) - - expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\') - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '', - rawLength: query.length, - seq: query.length, - transformed: true - }) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: 'prompt' }) - await expect( - dispatcher.callRequest('pty.attach', { - id: PTY_1, - suppressReplayNotification: true - }) - ).resolves.toEqual({ replay: 'prompt', incarnationId: expect.any(String) }) - }) - - it('does not carry transformed raw length into the next plain pending entry', async () => { - await handler.dispose({ waitForPhysicalExit: false }) - const admitted: Record[] = [] - let hasCapacity = false - const tryNotifyPtyData = vi.fn((params: Record) => { - if (hasCapacity) { - admitted.push(params) - } - return hasCapacity - }) - Object.assign(dispatcher, { - onLegacyPtyCapacity: vi.fn(() => vi.fn()), - tryNotifyPtyData, - tryNotifyPtyExit: vi.fn(() => true), - legacyRetentionBelowLowWater: true - }) - handler = createTestPtyHandler(dispatcher) - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((callback: (data: string) => void) => { - dataCallback = callback - }), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', { - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 - } - }) - - const query = '\x1b]10;?\x07' - dataCallback?.(query) - dataCallback?.('fresh') - hasCapacity = true - await vi.runAllTimersAsync() - - expect(tryNotifyPtyData).toHaveBeenCalledTimes(3) - expect(admitted).toEqual([ - { - id: PTY_1, - data: '', - rawLength: query.length, - seq: query.length, - transformed: true - }, - { id: PTY_1, data: 'fresh' } - ]) - }) - - describe('legacy flush retry with a memoized source chunk', () => { - let capacityListener: (() => void) | undefined - let hasCapacity: boolean - let maxChars: number - let admitted: Record[] - let dataCallback: ((data: string) => void) | undefined - - async function setupRetryHarness(spawnParams: Record = {}): Promise { - await handler.dispose({ waitForPhysicalExit: false }) - capacityListener = undefined - hasCapacity = false - admitted = [] - Object.assign(dispatcher, { - onLegacyPtyCapacity: vi.fn((listener: () => void) => { - capacityListener = listener - return vi.fn() - }), - tryNotifyPtyData: vi.fn((params: Record) => { - if (hasCapacity) { - admitted.push(params) - } - return hasCapacity - }), - tryNotifyPtyExit: vi.fn(() => true), - legacyRetentionBelowLowWater: true, - // Clamped like the real dispatcher: never more than min(data.length, limit). - maxLegacyPtyDataChars: vi.fn((_params: unknown, data: string, limit?: number) => - Math.min(maxChars, data.length, limit ?? data.length) - ) - }) - handler = createTestPtyHandler(dispatcher) - dataCallback = undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((callback: (data: string) => void) => { - dataCallback = callback - }), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', spawnParams) - expect(dataCallback).toBeDefined() - } - - it('resends the memo verbatim and keeps the coalesced tail when capacity grows', async () => { - maxChars = 5_000 - await setupRetryHarness() - dataCallback!('a'.repeat(20_000)) - await vi.advanceTimersByTimeAsync(8) - // Why before restoring capacity: the failed batch made zero writes so no flush is - // rescheduled; this enqueue re-arms the timer. - dataCallback!('b'.repeat(10)) - hasCapacity = true - maxChars = 16_384 - await vi.runAllTimersAsync() - - expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000) + 'b'.repeat(10)) - expect((admitted[0].data as string).length).toBe(5_000) - // Why: remainder frames must not leak transformed/rawLength/seq keys. - expect(admitted[1]).toStrictEqual({ id: PTY_1, data: expect.any(String) }) - }) - - it('keeps a tail appended after a failed flush under constant capacity', async () => { - maxChars = 16_384 - await setupRetryHarness() - dataCallback!('a'.repeat(5_000)) - await vi.advanceTimersByTimeAsync(8) - dataCallback!('b'.repeat(10)) - hasCapacity = true - await vi.runAllTimersAsync() - - expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(5_000) + 'b'.repeat(10)) - expect(admitted[1].data).toBe('b'.repeat(10)) - }) - - it('does not duplicate memoized chars when capacity shrinks before the retry', async () => { - maxChars = 16_384 - await setupRetryHarness() - dataCallback!('a'.repeat(20_000)) - await vi.advanceTimersByTimeAsync(8) - maxChars = 5_000 - hasCapacity = true - // Why: nothing else re-arms the flush timer after a zero-write batch. - capacityListener!() - await vi.runAllTimersAsync() - - expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000)) - expect((admitted[0].data as string).length).toBe(16_384) - expect((admitted[1].data as string).length).toBe(3_616) - }) - - it('retains a coalesced transformed raw advance behind a memoized source-only chunk', async () => { - maxChars = 16_384 - await setupRetryHarness({ - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 - } - }) - // First answered query: direct publish fails, entry queued without a memo. - dataCallback!('\x1b]10;?\x07') - // Capacity event while still blocked: the flush fails and memoizes the source-only chunk. - capacityListener!() - await vi.advanceTimersByTimeAsync(0) - // Second answered query coalesces into the memoized transformed head. - dataCallback!('\x1b]11;?\x07') - hasCapacity = true - capacityListener!() - await vi.runAllTimersAsync() - - expect(admitted).toEqual([ - { id: PTY_1, data: '', rawLength: 7, seq: 7, transformed: true }, - { id: PTY_1, data: '', rawLength: 7, seq: 14, transformed: true } - ]) - }) - }) - - it('ignores an intent from another ingress version and answers from the default theme', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - try { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', { - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 - } - }) - - const query = '\x1b]10;?\x07' - dataCallback!(query) - vi.advanceTimersByTime(8) - - expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:ffff/ffff/ffff\x1b\\') - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', { id: PTY_1, data: query }) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('answers a color query at a native Windows SSH relay owner', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', { shellOverride: 'powershell.exe' }) - - dataCallback!('\x1b]10;?\x07') - vi.advanceTimersByTime(8) - - expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:ffff/ffff/ffff\x1b\\') - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '', - rawLength: '\x1b]10;?\x07'.length, - seq: '\x1b]10;?\x07'.length, - transformed: true - }) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('answers a POSIX SSH relay query from the latest relay-wide colours', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - try { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', { shellOverride: '/bin/bash' }) - const query = '\x1b]11;?\x07' - - dispatcher.callNotification('pty.setColorQueryReplyColors', { - colors: { foreground: '#000000', background: '#123456' } - }) - dataCallback!(query) - // A malformed push keeps the previous colours rather than blanking them. - dispatcher.callNotification('pty.setColorQueryReplyColors', { colors: { background: 7 } }) - dataCallback!(query) - vi.advanceTimersByTime(8) - - expect(term.write.mock.calls).toEqual([ - ['\x1b]11;rgb:1212/3434/5656\x1b\\'], - ['\x1b]11;rgb:1212/3434/5656\x1b\\'] - ]) - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', { id: PTY_1, data: query }) - } finally { - _resetPtyOwnerHostColorsForTest() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('answers a Windows SSH relay query for a WSL shell at the owner too', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', { - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' - }) - - dataCallback!('\x1b]11;?\x07') - vi.advanceTimersByTime(8) - - expect(term.write).toHaveBeenCalledWith('\x1b]11;rgb:2828/2c2c/3434\x1b\\') - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b]11;?\x07' - }) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('coalesces background PTY output before notifying the client', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - dataCallback!('hello ') - dataCallback!('world') - - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: 'hello world' - }) - }) - - it('tracks exact pending producer bytes through coalescing and sliced drains', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((callback: (data: string) => void) => { - dataCallback = callback - }), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', {}) - - const first = '界'.repeat(16_380) - const second = `${'界'.repeat(10)}tail` - dataCallback!(first) - dataCallback!(second) - - const flow = pendingFlowState(handler) - expect(flow.pendingProducerBytesByPty.get(PTY_1)).toBe(chargedPendingBytes(first + second)) - - vi.advanceTimersByTime(8) - const remaining = `${'界'.repeat(6)}tail` - expect(flow.pendingOutputByPty.get(PTY_1)?.[0]?.data).toBe(remaining) - expect(flow.pendingProducerBytesByPty.get(PTY_1)).toBe(chargedPendingBytes(remaining)) - - vi.advanceTimersByTime(1) - expect(flow.pendingOutputByPty.has(PTY_1)).toBe(false) - expect(flow.pendingProducerBytesByPty.has(PTY_1)).toBe(false) - }) - - it('drops the pending producer counter with attach replay and disposal', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((callback: (data: string) => void) => { - dataCallback = callback - }), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', {}) - - dataCallback!('replayed output') - const flow = pendingFlowState(handler) - expect(flow.pendingProducerBytesByPty.has(PTY_1)).toBe(true) - await dispatcher.callRequest('pty.attach', { - id: PTY_1, - suppressReplayNotification: true - }) - expect(flow.pendingOutputByPty.has(PTY_1)).toBe(false) - expect(flow.pendingProducerBytesByPty.has(PTY_1)).toBe(false) - - Object.assign(dispatcher, { tryNotifyPtyData: vi.fn(() => false) }) - dataCallback!('blocked output') - expect(flow.pendingProducerBytesByPty.has(PTY_1)).toBe(true) - await handler.dispose({ waitForPhysicalExit: false }) - expect(flow.pendingOutputByPty.size).toBe(0) - expect(flow.pendingProducerBytesByPty.size).toBe(0) - }) - - it('tracks each negotiated source entry without rescanning the queue', async () => { - let sourceDataCallback: ((data: string) => void) | undefined - const pause = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - pause, - onData: vi.fn((callback: (data: string) => void) => { - sourceDataCallback = callback - }), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', {}) - - const publish = vi.fn(() => false) - handler.setSourcePublication({ - accepts: () => true, - publish, - onCreditAvailable: () => {}, - exitPublicationSettled: () => false, - sealAndPublishExit: () => false, - publishExitAfterRetire: () => null, - waitForPendingSend: async () => true, - activate: () => false, - receivingActivation: () => undefined, - getDebugSnapshot: () => ({}), - dispose: () => {} - } as unknown as RelayPtySourcePublication) - - const entryCount = 2_000 - for (let index = 0; index < entryCount; index += 1) { - sourceDataCallback!('x') - } - - const flow = pendingFlowState(handler) - expect(flow.pendingProducerBytesByPty.get(PTY_1)).toBe(entryCount * chargedPendingBytes('x')) - expect(pause).toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(8) - expect(publish).toHaveBeenCalledTimes(1) - expect(flow.pendingProducerBytesByPty.get(PTY_1)).toBe(entryCount * chargedPendingBytes('x')) - - publish.mockReturnValue(true) - handler.handleSourcePublicationCapacity(PTY_1) - await vi.runAllTimersAsync() - - expect(flow.pendingOutputByPty.has(PTY_1)).toBe(false) - expect(flow.pendingProducerBytesByPty.has(PTY_1)).toBe(false) - }) - - it('sends recent-input redraw output immediately', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - dispatcher.callNotification('pty.data', { id: PTY_1, data: 'a' }) - dispatcher.notify.mockClear() - - dataCallback!('\x1b[20;2Hredraw') - - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b[20;2Hredraw' - }) - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledTimes(1) - }) - - it('drains large relay PTY output in bounded slices', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - const firstChunk = 'x'.repeat(16 * 1024) - dataCallback!(`${firstChunk}tail`) - - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledTimes(1) - expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', { - id: PTY_1, - data: firstChunk - }) - - vi.advanceTimersByTime(1) - expect(dispatcher.notify).toHaveBeenCalledTimes(2) - expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.data', { - id: PTY_1, - data: 'tail' - }) - }) - - it('does not split a bounded slice inside an open DEC 2026 frame', async () => { - let dataCallback: ((data: string) => void) | undefined - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - // A frame that closes just before the 16KB boundary, then a second frame - // that straddles it. Cutting at the raw boundary would strand the second - // frame's \x1b[?2026l, and xterm paints nothing while the latch is open. - const open = '\x1b[?2026h' - const close = '\x1b[?2026l' - const firstFrame = `${open}${'x'.repeat(16 * 1024 - 2 * open.length - close.length)}${close}` - const secondFrame = `${open}${'y'.repeat(64)}${close}` - dataCallback!(`${firstFrame}${secondFrame}`) - - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledTimes(1) - expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', { - id: PTY_1, - data: firstFrame - }) - - vi.advanceTimersByTime(1) - expect(dispatcher.notify).toHaveBeenCalledTimes(2) - expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.data', { - id: PTY_1, - data: secondFrame - }) - }) - - it('writes data to PTY via pty.data notification', async () => { - const mockWrite = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - write: mockWrite, - onData: vi.fn(), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - dispatcher.callNotification('pty.data', { id: PTY_1, data: 'ls\n' }) - expect(mockWrite).toHaveBeenCalledWith('ls\n') - }) - - it('resizes PTY via pty.resize notification', async () => { - const mockResize = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - resize: mockResize, - onData: vi.fn(), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - dispatcher.callNotification('pty.resize', { id: PTY_1, cols: 120, rows: 40 }) - expect(mockResize).toHaveBeenCalledWith(120, 40) - }) - - it('reports the PTY grid actually applied by node-pty', async () => { - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - cols: 132, - rows: 43, - onData: vi.fn(), - onExit: vi.fn() - }) - - const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } - - await expect(dispatcher.callRequest('pty.getSize', { id: spawned.id })).resolves.toEqual({ - cols: 132, - rows: 43 - }) - await expect(dispatcher.callRequest('pty.getSize', { id: 'missing' })).resolves.toBeNull() - }) -}) diff --git a/src/relay/pty-handler-owner-inventory-clone-budget.test.ts b/src/relay/pty-handler-owner-inventory-clone-budget.test.ts deleted file mode 100644 index e76f955e4c0..00000000000 --- a/src/relay/pty-handler-owner-inventory-clone-budget.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -import './mock-descendant-sweep' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as CryptoModule from 'node:crypto' -import type * as SnapshotModule from '../shared/process-table-snapshot-reader' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - encodeJsonRpcFrame, - parseJsonRpcMessage, - type JsonRpcResponse -} from '../wsl-guest/protocol' -import { PtyHandler } from './pty-handler' -import { ClaimedAgentPtyOwnerRegistry } from '../shared/claimed-agent-pty-owner' -import { - isAgentSessionClaimedSpawnResult, - isAgentSessionOwnerBinding, - type AgentSessionOwnerBinding -} from '../shared/agent-session-host-authority' -import * as ptyShell from './pty-shell-utils' - -const mocks = vi.hoisted(() => ({ spawn: vi.fn(), snapshots: vi.fn(), uuid: 0 })) -vi.mock('node:crypto', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - randomUUID: () => `00000000-0000-4000-8000-${String(++mocks.uuid).padStart(12, '0')}` - } -}) -vi.mock('node-pty', () => ({ spawn: mocks.spawn })) -vi.mock('../shared/process-table-snapshot-reader', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, getStrictProcessTableSnapshotWithAge: mocks.snapshots } -}) -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -type InventoryRow = { - id: string - incarnationId: string - agentSessionOwners?: AgentSessionOwnerBinding[] -} - -function inventoryRows(value: unknown): InventoryRow[] { - if (!Array.isArray(value)) { - throw new Error('Inventory result must be an array') - } - return value.map((row: unknown) => { - if ( - typeof row !== 'object' || - row === null || - !('id' in row) || - typeof row.id !== 'string' || - !('incarnationId' in row) || - typeof row.incarnationId !== 'string' - ) { - throw new Error('Inventory row identity is missing') - } - const owners = 'agentSessionOwners' in row ? row.agentSessionOwners : undefined - if ( - owners !== undefined && - (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) - ) { - throw new Error('Inventory owners are malformed') - } - return { - id: row.id, - incarnationId: row.incarnationId, - ...(owners ? { agentSessionOwners: owners } : {}) - } - }) -} - -describe('relay owner inventory clone budget', () => { - let dispatcher: RelayDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let requestId = 0 - const frames: Buffer[] = [] - - async function response(method: string, params: Record = {}) { - const id = ++requestId - const before = frames.length - dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) - for (let turn = 0; turn < 50 && frames.length === before; turn++) { - await Promise.resolve() - } - expect(frames).toHaveLength(before + 1) - const frame = frames[before] - const message = parseJsonRpcMessage(frame.subarray(13)) - return { message, frame } - } - - async function request(method: string, params: Record = {}) { - const { message, frame } = await response(method, params) - const id = requestId - if (!('id' in message) || message.id !== id || 'method' in message || !('result' in message)) { - throw new Error(`Unexpected RPC response: ${JSON.stringify(message)}`) - } - return { result: message.result, frame } - } - - beforeEach(() => { - originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - vi.useFakeTimers() - vi.spyOn(Date, 'now').mockReturnValue(1_000_000) - vi.spyOn(ptyShell, 'isProcessAlive').mockReturnValue(true) - mocks.snapshots.mockReset().mockResolvedValue({ rows: [], capturedAgeMs: 0 }) - mocks.uuid = 0 - mocks.spawn.mockReset().mockImplementation(() => ({ - pid: process.pid, - process: 'zsh', - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - })) - requestId = 0 - frames.length = 0 - dispatcher = new RelayDispatcher((frame) => { - frames.push(Buffer.from(frame)) - return true - }) - handler = new PtyHandler(dispatcher, undefined, 'owner-budget') - }) - - afterEach(async () => { - await handler.dispose({ waitForPhysicalExit: false }) - dispatcher.dispose() - vi.restoreAllMocks() - vi.useRealTimers() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - }) - - it.each([false, true, undefined])( - 'samples the complete frame clock before cloning, evidence=%s', - async (includeForegroundProcessEvidence) => { - const spawned = await request('pty.spawn', { - cwd: process.cwd(), - env: { ORCA_PANE_KEY: 'clock-pane' }, - agentSessionEnsure: { - claim: { - digestVersion: 1, - keyId: 'clock-key', - identityDigest: 'a'.repeat(43), - worktreeScopeDigest: 'b'.repeat(43), - agent: 'codex' - }, - surface: { - worktreeId: 'clock-folder', - tabId: '11111111-1111-4111-8111-111111111111', - leafId: '22222222-2222-4222-8222-222222222222', - terminalHandle: 'term_clock' - } - } - }) - const identity = inventoryRows([spawned.result])[0] - if ( - typeof spawned.result !== 'object' || - spawned.result === null || - !('agentSessionEnsure' in spawned.result) || - !isAgentSessionClaimedSpawnResult(spawned.result.agentSessionEnsure) - ) { - throw new Error('Claimed spawn receipt is missing') - } - let now = 1_000_017 - vi.spyOn(Date, 'now').mockImplementation(() => now) - const originalList = ClaimedAgentPtyOwnerRegistry.prototype.listForPty - vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty').mockImplementation(function ( - this: ClaimedAgentPtyOwnerRegistry, - id: string - ) { - const owners = originalList.call(this, id) - now += 20 - return owners - }) - for (let iteration = 0; iteration < 2; iteration++) { - now = 1_000_017 + iteration * 100 - const receipt = await request( - 'pty.listProcesses', - includeForegroundProcessEvidence === undefined ? {} : { includeForegroundProcessEvidence } - ) - const full = [ - { - id: identity.id, - incarnationId: identity.incarnationId, - cwd: process.cwd(), - title: 'zsh', - hostAgeMs: 17 + iteration * 100, - paneBound: true, - ...(includeForegroundProcessEvidence !== false - ? { - foregroundProcessEvidence: { - authorityGeneration: 'owner-budget', - observationEpoch: iteration + 1, - capturedAgeMs: 0, - verdict: 'unverifiable', - reason: 'root_missing' - } - } - : {}), - agentSessionOwners: [spawned.result.agentSessionEnsure.owner] - } - ] - expect(receipt.result).toEqual(full) - expect( - receipt.frame.equals( - encodeJsonRpcFrame( - { jsonrpc: '2.0', id: requestId, result: full }, - requestId, - requestId - ) - ) - ).toBe(true) - } - } - ) - - it('publishes the clock error before attempting an owner clone', async () => { - await request('pty.spawn', { cwd: process.cwd() }) - const list = vi - .spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') - .mockImplementation(() => { - throw new Error('owner-clone-failed') - }) - // Isolate the age read after the decoder, receive and inventory clock stamps. - const clock = vi - .spyOn(Date, 'now') - .mockReturnValue(1_000_000) - .mockReturnValueOnce(1_000_000) - .mockReturnValueOnce(1_000_000) - .mockReturnValueOnce(1_000_000) - .mockImplementationOnce(() => { - throw new Error('host-clock-failed') - }) - try { - const receipt = await response('pty.listProcesses', { - includeForegroundProcessEvidence: false - }) - const expected: JsonRpcResponse = { - jsonrpc: '2.0', - id: requestId, - error: { code: -32000, message: 'host-clock-failed' } - } - expect(receipt.message).toEqual(expected) - expect(receipt.frame.equals(encodeJsonRpcFrame(expected, requestId, requestId))).toBe(true) - expect(list).not.toHaveBeenCalled() - } finally { - clock.mockReturnValue(1_000_000) - } - }) - - it.each([false, true, undefined])( - 'keeps complete RPC frames and fresh owners, evidence=%s', - async (includeForegroundProcessEvidence) => { - const withEvidence = includeForegroundProcessEvidence !== false - const expected: Record[] = [] - const ensure = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'ensure') - let firstOwner: AgentSessionOwnerBinding | null = null - const ownedCount = 8 - const plainCount = 3 - for (let index = 0; index < ownedCount + plainCount; index++) { - const claim = { - digestVersion: 1, - keyId: `key${index}`, - identityDigest: 'a'.repeat(43), - worktreeScopeDigest: 'b'.repeat(43), - agent: 'codex' - } - const surface = { - worktreeId: `folder${index}`, - tabId: '11111111-1111-4111-8111-111111111111', - leafId: '22222222-2222-4222-8222-222222222222', - terminalHandle: `term_owner${index}` - } - const spawned = await request('pty.spawn', { - cwd: process.cwd(), - env: { ORCA_PANE_KEY: `pane${index}` }, - ...(index < ownedCount ? { agentSessionEnsure: { claim, surface } } : {}) - }) - const identity = inventoryRows([spawned.result])[0] - let owner: AgentSessionOwnerBinding | null = null - if (index < ownedCount) { - if ( - typeof spawned.result !== 'object' || - spawned.result === null || - !('agentSessionEnsure' in spawned.result) || - !isAgentSessionClaimedSpawnResult(spawned.result.agentSessionEnsure) - ) { - throw new Error('Claimed spawn receipt is missing') - } - owner = spawned.result.agentSessionEnsure.owner - firstOwner ??= owner - } - expected.push({ - id: identity.id, - incarnationId: identity.incarnationId, - cwd: process.cwd(), - title: 'zsh', - hostAgeMs: 0, - paneBound: true, - ...(owner ? { agentSessionOwners: [owner] } : {}) - }) - } - const registry = ensure.mock.contexts[0] - if (!(registry instanceof ClaimedAgentPtyOwnerRegistry) || !firstOwner) { - throw new Error('Actual claimed registry was not used') - } - const secondOwner: AgentSessionOwnerBinding = { - ...firstOwner, - claim: { ...firstOwner.claim, keyId: 'second-owner' }, - surface: { ...firstOwner.surface, terminalHandle: 'term_second' }, - generation: 'second-generation' - } - registry.register(secondOwner) - expected[0].agentSessionOwners = [firstOwner, secondOwner] - const list = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') - let previous: InventoryRow[] | null = null - let previousClone: AgentSessionOwnerBinding[] | null = null - for (let iteration = 0; iteration < 10; iteration++) { - const firstCloneIndex = list.mock.results.length - const response = await request( - 'pty.listProcesses', - includeForegroundProcessEvidence === undefined ? {} : { includeForegroundProcessEvidence } - ) - const full = expected.map(({ agentSessionOwners, ...row }) => ({ - ...row, - ...(withEvidence - ? { - foregroundProcessEvidence: { - authorityGeneration: 'owner-budget', - observationEpoch: iteration + 1, - capturedAgeMs: 0, - verdict: 'unverifiable', - reason: 'root_missing' - } - } - : {}), - ...(agentSessionOwners ? { agentSessionOwners } : {}) - })) - expect(response.result).toEqual(full) - const expectedFrame = encodeJsonRpcFrame( - { jsonrpc: '2.0', id: requestId, result: full }, - requestId, - requestId - ) - expect(response.frame.equals(expectedFrame)).toBe(true) - const rows = inventoryRows(response.result) - const firstClone: unknown = list.mock.results[firstCloneIndex].value - if (!Array.isArray(firstClone) || !firstClone.every(isAgentSessionOwnerBinding)) { - throw new Error('Actual registry clone is malformed') - } - if (previousClone) { - expect(firstClone).not.toBe(previousClone) - expect(firstClone[0]).not.toBe(previousClone[0]) - expect(firstClone[0].claim).not.toBe(previousClone[0].claim) - expect(firstClone[0].surface).not.toBe(previousClone[0].surface) - } - if (previous) { - expect(rows[0].agentSessionOwners).not.toBe(previous[0].agentSessionOwners) - expect(rows[0].agentSessionOwners?.[0]).not.toBe(previous[0].agentSessionOwners?.[0]) - expect(rows[0].agentSessionOwners?.[0].claim).not.toBe( - previous[0].agentSessionOwners?.[0].claim - ) - expect(rows[0].agentSessionOwners?.[0].surface).not.toBe( - previous[0].agentSessionOwners?.[0].surface - ) - } - const owner = rows[0].agentSessionOwners?.[0] - if (!owner) { - throw new Error('Expected first owner') - } - if (iteration === 4) { - registry.release(secondOwner.ptyId, secondOwner.generation) - expected[0].agentSessionOwners = [firstOwner] - } - owner.claim.keyId = 'mutated-client-copy' - owner.surface.terminalHandle = 'term_mutated' - firstClone[0].claim.keyId = 'mutated-published-clone' - firstClone[0].surface.terminalHandle = 'term_mutated_clone' - previousClone = firstClone - previous = rows - } - expect(mocks.snapshots).toHaveBeenCalledTimes(withEvidence ? 10 : 0) - const copiedOwners = list.mock.results.reduce((count, result) => { - const owners: unknown = result.value - if (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) { - throw new Error('Actual registry clone is malformed') - } - return count + owners.length - }, 0) - expect(copiedOwners).toBe(85) - expect(list).toHaveBeenCalledTimes((ownedCount + plainCount) * 10) - } - ) -}) diff --git a/src/relay/pty-handler-pi-owner-env.test.ts b/src/relay/pty-handler-pi-owner-env.test.ts deleted file mode 100644 index ec9bddd6538..00000000000 --- a/src/relay/pty-handler-pi-owner-env.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import './mock-descendant-sweep' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - beginPtyHandlerTest, - createMockDispatcher, - createTestPtyHandler, - endPtyHandlerTest, - testPtyId -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' -import type { PtyHandler } from './pty-handler' - -const mocks = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) -vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe -})) -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -const ownerEnv = { - ORCA_PI_STATUS_OWNED: '1234', - ORCA_PRIME_AGENT_STATUS_OWNED: '1234', - ORCA_PI_TITLE_MARKER_OWNED: '1234' -} -const terminalEnv = { - ORCA_PANE_KEY: 'tab:11111111-1111-4111-8111-111111111111', - ORCA_TAB_ID: 'tab', - ORCA_WORKTREE_ID: 'folder:workspace', - ORCA_AGENT_LAUNCH_TOKEN: 'new-launch', - ORCA_AGENT_HOOK_TOKEN: 'receiver-token', - KEEP_ME: 'terminal-value' -} - -describe('independent SSH terminal Pi ownership', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks)) - }) - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - vi.unstubAllEnvs() - }) - - it.each(['host', 'request', 'augmenter'] as const)( - 'drops the %s owner while keeping the new pane identity and hook coordinates', - async (source) => { - if (source === 'host') { - for (const [key, value] of Object.entries(ownerEnv)) { - vi.stubEnv(key, value) - } - } - if (source === 'augmenter') { - handler.addEnvAugmenter(() => ({ ...ownerEnv })) - } - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { ...terminalEnv, ...(source === 'request' ? ownerEnv : {}) } - }) - const env = mocks.mockPtySpawn.mock.calls.at(-1)?.[2].env - for (const key of Object.keys(ownerEnv)) { - expect(env).not.toHaveProperty(key) - } - expect(env).toMatchObject(terminalEnv) - } - ) - - it('also isolates a revived terminal from the relay process owner', async () => { - for (const [key, value] of Object.entries(ownerEnv)) { - vi.stubEnv(key, value) - } - await dispatcher.callRequest('pty.spawn', { env: terminalEnv }) - const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] }) - await handler.dispose({ waitForPhysicalExit: false }) - mocks.mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - await dispatcher.callRequest('pty.revive', { state }) - - expect(mocks.mockPtySpawn).toHaveBeenCalledOnce() - const env = mocks.mockPtySpawn.mock.calls.at(-1)?.[2].env - for (const key of Object.keys(ownerEnv)) { - expect(env).not.toHaveProperty(key) - } - expect(env.ORCA_PANE_KEY).toBe(terminalEnv.ORCA_PANE_KEY) - }) -}) diff --git a/src/relay/pty-handler-resize-stale-pty.test.ts b/src/relay/pty-handler-resize-stale-pty.test.ts deleted file mode 100644 index d3478bc6825..00000000000 --- a/src/relay/pty-handler-resize-stale-pty.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import './mock-descendant-sweep' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import * as ptyShellUtils from './pty-shell-utils' -import { beginPtyHandlerTest, endPtyHandlerTest, testPtyId } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) -const STALE_PID = 424_242 - -/** - * node-pty's native `pty.resize` error when the ioctl reaches a closed master. - * - * Orca's node-pty patch retires `_fd` when it gives up the master, so a patched - * handle answers a late resize with a no-op. That leaves this handler two cases - * it still has to contain: the tick between libuv closing the fd and node-pty's - * own handler observing it, and a relay host, which installs node-pty from npm - * and has no such guard. - */ -function ebadfResize(): never { - throw new Error('ioctl(2) failed, EBADF') -} - -describe('PtyHandler.resize against a stale PTY handle', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let resize: ReturnType - - beforeEach(async () => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - resize = vi.fn() - // A shell that exited without node-pty producing `onExit`: the record is - // still in the pool and undisposed, but the master behind it is gone. - mockPtySpawn.mockReturnValue({ ...mockPtyInstance, pid: STALE_PID, resize }) - await dispatcher.callRequest('pty.spawn', {}) - expect(handler.activePtyCount).toBe(1) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('retires an entry whose pid the host proves is gone, instead of issuing the ioctl', () => { - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) - resize.mockImplementation(ebadfResize) - - expect(() => - dispatcher.callNotification('pty.resize', { id: PTY_1, cols: 120, rows: 40 }) - ).not.toThrow() - - expect(resize).not.toHaveBeenCalled() - // The record must leave the pool: while it stays, the relay keeps - // advertising a dead shell and `activePtyCount` never reaches zero, so a - // relay configured with an unlimited grace never reaches its idle exit. - expect(handler.activePtyCount).toBe(0) - }) - - it('contains an ioctl failure without re-classifying liveness, and keeps the record', () => { - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - resize.mockImplementation(ebadfResize) - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - - expect(() => - dispatcher.callNotification('pty.resize', { id: PTY_1, cols: 120, rows: 40 }) - ).not.toThrow() - // Repeats must stay contained too — this is the notification the client - // re-sends on every reconnect and every window resize. - expect(() => - dispatcher.callNotification('pty.resize', { id: PTY_1, cols: 90, rows: 30 }) - ).not.toThrow() - - // Loss of an fd observed the handle, not the host that owns the pid, so it is - // `unverifiable` and the claim is retained. Only the probe above retires. - expect(handler.activePtyCount).toBe(1) - expect(stderr.mock.calls.map(([line]) => String(line)).join('')).toContain( - 'ioctl(2) failed, EBADF' - ) - }) - - it('retires the entry when the pid goes absent between the pre-probe and the ioctl', () => { - // The race the catch-block re-probe exists for, and the one a constant - // liveness mock cannot express: alive when the pre-probe asks, gone by the - // time the ioctl fails. libuv closes the master synchronously inside - // `uv_close`, so this window opens before any JS guard can be set — and on - // a relay host, where node-pty comes from npm, there is no JS guard at all. - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValueOnce(true).mockReturnValueOnce(false) - resize.mockImplementation(ebadfResize) - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - - expect(() => - dispatcher.callNotification('pty.resize', { id: PTY_1, cols: 120, rows: 40 }) - ).not.toThrow() - - expect(resize).toHaveBeenCalledTimes(1) - expect(handler.activePtyCount).toBe(0) - // Proven `exited` retires silently; only live-or-unverifiable is reported. - expect(stderr).not.toHaveBeenCalled() - }) - - it('publishes the exit so the client stops holding a pane on a retired session', () => { - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) - - dispatcher.callNotification('pty.resize', { id: PTY_1, cols: 120, rows: 40 }) - - // Retiring the record without this leaves the pane mounted against a - // session the relay has already forgotten: the next attach answers - // `PTY "" not found` and nothing before it explained why. - expect(dispatcher._notifications).toContainEqual({ - method: 'pty.exit', - params: { id: PTY_1, code: -1, incarnationId: expect.any(String) } - }) - }) - - it('does not republish an exit node-pty already reported', async () => { - // The listing sweep also reaps entries the natural `onExit` left behind; a - // second `pty.exit` would hand the client a duplicate carrying -1 in place - // of the real status. That sweep retires off `managed.disposed`, which is - // bookkeeping rather than liveness, so it never publishes a verdict at all. - const onExit = mockPtyInstance.onExit.mock.calls.at(-1)?.[0] as (e: { - exitCode: number - }) => void - onExit({ exitCode: 7 }) - await vi.runAllTimersAsync() - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) - - await dispatcher.callRequest('pty.listProcesses', {}) - - const exits = dispatcher._notifications.filter( - (notification) => notification.method === 'pty.exit' - ) - expect(exits).toHaveLength(1) - expect(exits[0]?.params).toMatchObject({ id: PTY_1, code: 7 }) - }) - - it('still resizes a live PTY, with the clamped geometry', () => { - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - - dispatcher.callNotification('pty.resize', { id: PTY_1, cols: 4_000, rows: 40 }) - - expect(resize).toHaveBeenCalledWith(500, 40) - expect(handler.activePtyCount).toBe(1) - }) -}) diff --git a/src/relay/pty-handler-retired-pane-surface.test.ts b/src/relay/pty-handler-retired-pane-surface.test.ts deleted file mode 100644 index c8f50ebf13a..00000000000 --- a/src/relay/pty-handler-retired-pane-surface.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import * as ptyShellUtils from './pty-shell-utils' -import type { PtyHandler } from './pty-handler' -import { - IMMEDIATE_PTY_EXIT_TIMEOUT_MS, - SHUTDOWN_REAP_MAX_SWEEPS, - SHUTDOWN_REAP_VERIFY_DELAY_MS -} from './pty-handler' -import { - beginPtyHandlerTest, - createPtyRequestHelpers, - endPtyHandlerTest -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PANE_KEY = 'tab-agent:22222222-2222-4222-8222-222222222222' - -const AGENT_SESSION_ENSURE = { - claim: { - digestVersion: 1, - keyId: 'claim-key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'claude' - }, - surface: { - worktreeId: 'repo::/tmp/worktree', - tabId: '11111111-1111-4111-8111-111111111111', - leafId: '22222222-2222-4222-8222-222222222222', - terminalHandle: 'term_claimed' - } -} - -type ProcessSummary = { - id: string - agentSessionOwners?: unknown[] -} - -describe('PtyHandler retires a closed pane surface', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - const { spawnPty } = createPtyRequestHelpers(() => dispatcher) - - async function spawnAgentPane( - params: Record = {} - ): Promise<{ id: string; incarnationId: string; term: typeof mockPtyInstance }> { - const term = { ...mockPtyInstance, kill: vi.fn(), onData: vi.fn(), onExit: vi.fn() } - mockPtySpawn.mockReturnValue(term) - const spawned = await spawnPty({ - env: { ORCA_PANE_KEY: PANE_KEY }, - agentSessionEnsure: AGENT_SESSION_ENSURE, - ...params - }) - return { id: spawned.id, incarnationId: spawned.incarnationId, term } - } - - async function listProcesses(): Promise { - return (await dispatcher.callRequest('pty.listProcesses', {})) as ProcessSummary[] - } - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('marks the pane surface retired as soon as its tab is closed', async () => { - const retired: { id: string; paneKey: string }[] = [] - handler.setSurfaceRetiredListener((event) => retired.push(event)) - const { id } = await spawnAgentPane() - - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(false) - - await dispatcher.callRequest('pty.shutdown', { id }) - - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true) - expect(retired).toEqual([{ id, paneKey: PANE_KEY }]) - }) - - it('refuses shutdown for a different PTY incarnation without retiring or killing it', async () => { - const retired: { id: string; paneKey: string }[] = [] - handler.setSurfaceRetiredListener((event) => retired.push(event)) - const { id, term } = await spawnAgentPane() - - await expect( - dispatcher.callRequest('pty.shutdown', { - id, - expectedIncarnationId: 'different-incarnation' - }) - ).rejects.toThrow('PTY incarnation mismatch') - - expect(term.kill).not.toHaveBeenCalled() - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(false) - expect(retired).toEqual([]) - expect((await listProcesses()).map((session) => session.id)).toEqual([id]) - }) - - it('shuts down when the expected PTY incarnation matches', async () => { - const { id, incarnationId, term } = await spawnAgentPane() - - await dispatcher.callRequest('pty.shutdown', { id, expectedIncarnationId: incarnationId }) - - expect(term.kill).toHaveBeenCalledWith('SIGTERM') - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true) - }) - - it('retires the surface even when the shell survives the kill request', async () => { - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - const { id } = await spawnAgentPane() - - await dispatcher.callRequest('pty.shutdown', { id }) - await vi.advanceTimersByTimeAsync( - SHUTDOWN_REAP_VERIFY_DELAY_MS * (SHUTDOWN_REAP_MAX_SWEEPS + 2) - ) - - // The process could not be verified gone, so the claim is deliberately kept — releasing it is - // what would let a reopened project fork a second agent onto the same transcript. - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true) - const sessions = await listProcesses() - expect(sessions.map((session) => session.id)).toEqual([id]) - }) - - it('re-issues the force kill instead of letting a detached shell outlive its tab', async () => { - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - const { id, term } = await spawnAgentPane() - - await dispatcher.callRequest('pty.shutdown', { id }) - // The graceful path's own armed fallback fires first; count only what the sweep adds. - await vi.advanceTimersByTimeAsync(SHUTDOWN_REAP_VERIFY_DELAY_MS - 1) - const killsBeforeSweep = term.kill.mock.calls.filter(([signal]) => signal === 'SIGKILL').length - - await vi.advanceTimersByTimeAsync( - SHUTDOWN_REAP_VERIFY_DELAY_MS * (SHUTDOWN_REAP_MAX_SWEEPS + 2) - ) - - const killsAfterSweep = term.kill.mock.calls.filter(([signal]) => signal === 'SIGKILL').length - expect(killsAfterSweep - killsBeforeSweep).toBe(SHUTDOWN_REAP_MAX_SWEEPS) - }) - - it('never re-closes a ConPTY handle on Windows, where the first kill is already final', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - const { id, term } = await spawnAgentPane() - - // The immediate path is the one that reaches ConPTY's kill without setting gracefulKillSent. - const shutdown = dispatcher.callRequest('pty.shutdown', { id, immediate: true }) - const rejected = expect(shutdown).rejects.toThrow('Timed out waiting for PTY process exit') - await vi.advanceTimersByTimeAsync(IMMEDIATE_PTY_EXIT_TIMEOUT_MS) - await rejected - await vi.advanceTimersByTimeAsync( - SHUTDOWN_REAP_VERIFY_DELAY_MS * (SHUTDOWN_REAP_MAX_SWEEPS + 2) - ) - - expect(term.kill.mock.calls).toHaveLength(1) - }) - - it('retires the relay session once the sweep proves the process is gone', async () => { - const exits: { id: string; paneKey?: string }[] = [] - handler.setExitListener((event) => exits.push(event)) - const alive = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - const { id } = await spawnAgentPane() - - expect((await listProcesses())[0]?.agentSessionOwners).toHaveLength(1) - - await dispatcher.callRequest('pty.shutdown', { id }) - alive.mockReturnValue(false) - await vi.advanceTimersByTimeAsync(SHUTDOWN_REAP_VERIFY_DELAY_MS + 1) - - // The sweep itself must retire it — node-pty produced no exit, and nothing here polls a listing. - expect(exits).toEqual([{ id, paneKey: PANE_KEY }]) - expect(await listProcesses()).toEqual([]) - }) - - it('stops advertising an agent session whose process died without a node-pty exit', async () => { - const alive = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - await spawnAgentPane() - - expect((await listProcesses())[0]?.agentSessionOwners).toHaveLength(1) - - // No shutdown, no onExit — the shell just went away. The relay has to look. - alive.mockReturnValue(false) - - expect(await listProcesses()).toEqual([]) - }) - - it('restores the surface when a new PTY binds the same pane key', async () => { - const { id } = await spawnAgentPane() - await dispatcher.callRequest('pty.shutdown', { id }) - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true) - - const term = { ...mockPtyInstance, kill: vi.fn(), onData: vi.fn(), onExit: vi.fn() } - mockPtySpawn.mockReturnValue(term) - await spawnPty({ env: { ORCA_PANE_KEY: PANE_KEY } }) - - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(false) - }) - - it('restores the surface when a client reattaches to a shut-down PTY that survived', async () => { - vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) - const { id } = await spawnAgentPane() - await dispatcher.callRequest('pty.shutdown', { id }) - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true) - - await dispatcher.callRequest('pty.attach', { id }) - - expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(false) - }) - - it('shutting down a pane with no pane key retires nothing', async () => { - const retired: { id: string; paneKey: string }[] = [] - handler.setSurfaceRetiredListener((event) => retired.push(event)) - const term = { ...mockPtyInstance, kill: vi.fn(), onData: vi.fn(), onExit: vi.fn() } - mockPtySpawn.mockReturnValue(term) - const spawned = await spawnPty({}) - - await dispatcher.callRequest('pty.shutdown', { id: spawned.id }) - - expect(retired).toEqual([]) - }) -}) diff --git a/src/relay/pty-handler-revive.test.ts b/src/relay/pty-handler-revive.test.ts deleted file mode 100644 index 88155e38242..00000000000 --- a/src/relay/pty-handler-revive.test.ts +++ /dev/null @@ -1,744 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { existsSync, rmSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' -import { hashWorktreeId } from '../main/terminal-history-id' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import { MAX_RELAY_PTY_SESSIONS, type PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createMockDispatcher, - createTestPtyHandler, - testPtyId, - endPtyHandlerTest -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) - -// Why a real directory: revive drops an entry whose serialized cwd is gone from this host, so a -// fixture path that never existed would be skipped before the behaviour under test runs. -const LIVE_CWD = tmpdir() - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('revive restores pane identity env alongside hook-server coordinates', async () => { - await dispatcher.callRequest('pty.spawn', { - cols: 90, - rows: 30, - cwd: '/tmp', - env: { - ORCA_PANE_KEY: 'tab-5:1', - ORCA_TAB_ID: 'tab-5', - ORCA_WORKTREE_ID: 'wt-5' - } - }) - const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - - await handler.dispose({ waitForPhysicalExit: false }) - mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - handler.addEnvAugmenter(() => ({ - ORCA_AGENT_HOOK_PORT: '12345', - ORCA_AGENT_HOOK_TOKEN: 'abc-uuid' - })) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - // Why seeded: a revived pane must not inherit a feature selection from the - // relay process's own environment. - const oldShellFeatures = process.env.ORCA_SHELL_FEATURES - process.env.ORCA_SHELL_FEATURES = 'ready,identity,markers,overlay' - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - if (oldShellFeatures === undefined) { - delete process.env.ORCA_SHELL_FEATURES - } else { - process.env.ORCA_SHELL_FEATURES = oldShellFeatures - } - killSpy.mockRestore() - } - - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(callArgs.env.ORCA_PANE_KEY).toBe('tab-5:1') - expect(callArgs.env.ORCA_TAB_ID).toBe('tab-5') - expect(callArgs.env.ORCA_WORKTREE_ID).toBe('wt-5') - expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345') - expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') - expect(callArgs.env.TERM).toBe('xterm-256color') - expect(callArgs.env.TERM_PROGRAM).toBe('Orca') - expect(callArgs.env.ORCA_SHELL_FEATURES).not.toContain('ready') - expect(callArgs.env.ORCA_SHELL_FEATURES).not.toContain('identity') - }) - - it('fences both revived worktree identity and cwd with rollback', async () => { - const finishSiblingAdmission = vi.fn() - const beginWorktreePtySpawn = vi.fn((operationPath: string) => { - if (operationPath === '/repo/removing/nested') { - throw new Error('Remote worktree deletion already in progress') - } - return finishSiblingAdmission - }) - handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) - const state = JSON.stringify([ - { - id: 'pty-7', - pid: process.pid, - cols: 80, - rows: 24, - cwd: '/repo/removing/nested', - worktreeId: 'repo-id::/repo/sibling' - } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( - 'Remote worktree deletion already in progress' - ) - } finally { - killSpy.mockRestore() - } - - expect(beginWorktreePtySpawn.mock.calls.map(([operationPath]) => operationPath)).toEqual([ - '/repo/sibling', - '/repo/removing/nested' - ]) - expect(finishSiblingAdmission).toHaveBeenCalledOnce() - expect(mockPtySpawn).not.toHaveBeenCalled() - }) - - it('applies the physical PTY cap to untrusted revive state', async () => { - const state = JSON.stringify( - Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, (_, index) => ({ - id: `pty-${index + 1}`, - pid: process.pid, - cols: 80, - rows: 24, - cwd: LIVE_CWD, - worktreeId: 'repo-id::/repo' - })) - ) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( - 'Maximum number of PTY sessions reached (50)' - ) - } finally { - killSpy.mockRestore() - } - - expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS) - expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) - }) - - it('deduplicates concurrent revive requests for the same physical PTY id', async () => { - const state = JSON.stringify([ - { - id: 'pty-7', - pid: process.pid, - cols: 80, - rows: 24, - cwd: LIVE_CWD, - worktreeId: 'repo-id::/repo' - } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await Promise.all([ - dispatcher.callRequest('pty.revive', { state }), - dispatcher.callRequest('pty.revive', { state }) - ]) - } finally { - killSpy.mockRestore() - } - - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - expect(handler.activePtyCount).toBe(1) - }) - - it('revive preserves the credential guard chosen for an SSH agent terminal', async () => { - await dispatcher.callRequest('pty.spawn', { - command: 'claude' - }) - const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(true) - - handler.dispose() - mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record - expect(revivedEnv.GIT_TERMINAL_PROMPT).toBe('0') - expect(revivedEnv.GCM_INTERACTIVE).toBe('never') - expect(Object.values(revivedEnv)).toContain('credential.interactive') - expect(Object.values(revivedEnv)).toContain('credential.guiPrompt') - }) - - it('revive treats legacy relay state as an ordinary unguarded terminal', async () => { - const savedTerminalPrompt = process.env.GIT_TERMINAL_PROMPT - const savedGcmInteractive = process.env.GCM_INTERACTIVE - delete process.env.GIT_TERMINAL_PROMPT - delete process.env.GCM_INTERACTIVE - const state = JSON.stringify([ - { - id: 'pty-legacy', - pid: process.pid, - cols: 80, - rows: 24, - cwd: process.cwd() - } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - - try { - await dispatcher.callRequest('pty.revive', { state }) - const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record - expect(revivedEnv.GIT_TERMINAL_PROMPT).toBeUndefined() - expect(revivedEnv.GCM_INTERACTIVE).toBeUndefined() - } finally { - killSpy.mockRestore() - if (savedTerminalPrompt === undefined) { - delete process.env.GIT_TERMINAL_PROMPT - } else { - process.env.GIT_TERMINAL_PROMPT = savedTerminalPrompt - } - if (savedGcmInteractive === undefined) { - delete process.env.GCM_INTERACTIVE - } else { - process.env.GCM_INTERACTIVE = savedGcmInteractive - } - } - }) - - it('normalizes an explicit empty TERM and preserves sanitized env deletions on revive', async () => { - await dispatcher.callRequest('pty.spawn', { - env: { TERM: '' }, - envToDelete: ['ORCA_STALE_TEST_ENV', '', 42] - }) - - const initialEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(initialEnv.name).toBe('xterm-256color') - expect(initialEnv.env.TERM).toBe('xterm-256color') - - const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - const [serialized] = JSON.parse(state) as { - explicitTerm?: string - envToDelete?: string[] - }[] - expect(serialized.explicitTerm).toBeUndefined() - expect(serialized.envToDelete).toEqual(['ORCA_STALE_TEST_ENV']) - - await handler.dispose({ waitForPhysicalExit: false }) - mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - handler.addEnvAugmenter(() => ({ - ORCA_STALE_TEST_ENV: '/tmp/revived-stale' - })) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const revivedEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(revivedEnv.name).toBe('xterm-256color') - expect(revivedEnv.env.TERM).toBe('xterm-256color') - expect(revivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined() - }) - - it('drops legacy empty explicit TERM metadata after revive', async () => { - const state = JSON.stringify([ - { - id: 'pty-8', - pid: process.pid, - cols: 80, - rows: 24, - cwd: process.cwd(), - explicitTerm: '', - envToDelete: ['ORCA_STALE_TEST_ENV'] - } - ]) - handler.addEnvAugmenter(() => ({ - ORCA_STALE_TEST_ENV: '/tmp/legacy-empty-stale' - })) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const revivedEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(revivedEnv.name).toBe('xterm-256color') - expect(revivedEnv.env.TERM).toBe('xterm-256color') - expect(revivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined() - - const serializedState = (await dispatcher.callRequest('pty.serialize', { - ids: ['pty-8'] - })) as string - const [serialized] = JSON.parse(serializedState) as { - explicitTerm?: string - envToDelete?: string[] - }[] - expect(serialized.explicitTerm).toBeUndefined() - expect(serialized.envToDelete).toEqual(['ORCA_STALE_TEST_ENV']) - }) - - it('preserves explicit TERM and env deletions through repeated revive cycles', async () => { - await dispatcher.callRequest('pty.spawn', { - env: { TERM: 'screen-256color' }, - envToDelete: ['ORCA_STALE_TEST_ENV'] - }) - let state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await handler.dispose({ waitForPhysicalExit: false }) - mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - handler.addEnvAugmenter(() => ({ - ORCA_STALE_TEST_ENV: '/tmp/first-revive' - })) - await dispatcher.callRequest('pty.revive', { state }) - - const firstRevivedEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(firstRevivedEnv.name).toBe('screen-256color') - expect(firstRevivedEnv.env.TERM).toBe('screen-256color') - expect(firstRevivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined() - state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - expect(JSON.parse(state)).toMatchObject([ - { - explicitTerm: 'screen-256color', - envToDelete: ['ORCA_STALE_TEST_ENV'] - } - ]) - - await handler.dispose({ waitForPhysicalExit: false }) - mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - handler.addEnvAugmenter(() => ({ - ORCA_STALE_TEST_ENV: '/tmp/second-revive' - })) - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const secondRevivedEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(secondRevivedEnv.name).toBe('screen-256color') - expect(secondRevivedEnv.env.TERM).toBe('screen-256color') - expect(secondRevivedEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined() - }) - - it('revives legacy serialized entries with default TERM and no env deletions', async () => { - handler.addEnvAugmenter(() => ({ - ORCA_STALE_TEST_ENV: '/tmp/legacy-stale' - })) - const state = JSON.stringify([ - { - id: 'pty-7', - pid: process.pid, - cols: 80, - rows: 24, - cwd: process.cwd() - } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const revivedEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(revivedEnv.env.TERM).toBe('xterm-256color') - expect(revivedEnv.env.ORCA_STALE_TEST_ENV).toBe('/tmp/legacy-stale') - }) - - it('revive preserves attach identity metadata without exporting hook identity env', async () => { - const oldPaneKey = process.env.ORCA_PANE_KEY - const oldTabId = process.env.ORCA_TAB_ID - delete process.env.ORCA_PANE_KEY - delete process.env.ORCA_TAB_ID - try { - await dispatcher.callRequest('pty.spawn', { - cols: 90, - rows: 30, - cwd: '/tmp', - env: { FOO: 'bar' }, - paneKey: 'tab-5:leaf-5', - tabId: 'tab-5' - }) - } finally { - if (oldPaneKey === undefined) { - delete process.env.ORCA_PANE_KEY - } else { - process.env.ORCA_PANE_KEY = oldPaneKey - } - if (oldTabId === undefined) { - delete process.env.ORCA_TAB_ID - } else { - process.env.ORCA_TAB_ID = oldTabId - } - } - const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - - await handler.dispose({ waitForPhysicalExit: false }) - mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - delete process.env.ORCA_PANE_KEY - delete process.env.ORCA_TAB_ID - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - if (oldPaneKey === undefined) { - delete process.env.ORCA_PANE_KEY - } else { - process.env.ORCA_PANE_KEY = oldPaneKey - } - if (oldTabId === undefined) { - delete process.env.ORCA_TAB_ID - } else { - process.env.ORCA_TAB_ID = oldTabId - } - } - - const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(callArgs.env.ORCA_PANE_KEY).toBeUndefined() - expect(callArgs.env.ORCA_TAB_ID).toBeUndefined() - - await expect( - dispatcher.callRequest('pty.attach', { - id: PTY_1, - expectedPaneKey: 'tab-other:leaf', - expectedTabId: 'tab-other' - }) - ).rejects.toThrow(`PTY "${PTY_1}" not found`) - }) - - // Why: `cwd` is the last serialized field revive took on trust. It proves the directory existed - // when the client wrote it down, not that it exists now -- node-pty answers a removed one by - // _exit(1)-ing the child on POSIX and by throwing on Windows, and the throw escapes the loop. - it('skips a pane whose serialized cwd is gone from this host, keeping the batch', async () => { - const removedCwd = join(tmpdir(), `orca-revive-removed-${process.pid}`) - rmSync(removedCwd, { force: true, recursive: true }) - const state = JSON.stringify([ - { id: 'pty-20', pid: process.pid, cols: 80, rows: 24, cwd: removedCwd }, - { id: 'pty-21', pid: process.pid, cols: 80, rows: 24, cwd: LIVE_CWD } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - // The later entry still revived, and no other directory stood in for the first. - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - expect((mockPtySpawn.mock.calls[0][2] as { cwd: string }).cwd).toBe(LIVE_CWD) - const live = (await dispatcher.callRequest('pty.serialize', { - ids: ['pty-20', 'pty-21'] - })) as string - expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-21']) - }) - - // Why: the pid gate is the one place revive turns an observation into "this pane is - // finished". `kill(pid, 0)` answers EPERM when the process exists under another uid, and - // the same ESRCH-only rule `reapPtyProvenExited` applies has to hold here - // (docs/reference/ssh-execution-boundary.md). - it('keeps a pane whose pid refuses the probe and drops only a proven-gone one', async () => { - const state = JSON.stringify([ - { id: 'pty-30', pid: 424242, cols: 80, rows: 24, cwd: LIVE_CWD }, - { id: 'pty-31', pid: 434343, cols: 80, rows: 24, cwd: LIVE_CWD } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { - if (pid === 424242) { - throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) - } - if (pid === 434343) { - throw Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }) - } - return true - }) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: callRequest is typed unknown; pty.serialize answers with the JSON state string this file parses everywhere. - const live = (await dispatcher.callRequest('pty.serialize', { - ids: ['pty-30', 'pty-31'] - })) as string - expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-30']) - }) - - describe('a Windows relay reviving a WSL pane', () => { - const worktreeId = 'r::/remote/wsl-worktree' - const historyFile = join( - homedir(), - '.orca-remote', - 'terminal-history', - `${hashWorktreeId(worktreeId)}-bash_history` - ) - let previousPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - previousPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - }) - - afterEach(() => { - if (previousPlatform) { - Object.defineProperty(process, 'platform', previousPlatform) - } - rmSync(historyFile, { force: true }) - }) - - /** Spawn a WSL pane, serialize it, then revive it into a fresh handler. */ - async function reviveWslPane(): Promise { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu', - worktreeId, - historyIsolationEnabled: true - }) - const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - - await handler.dispose({ waitForPhysicalExit: false }) - mockPtySpawn.mockClear() - rmSync(historyFile, { force: true }) - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - } - - // The #15236 gap: revive called resolveDefaultShell() and ignored the - // entry's override, so a restarted relay silently handed the user a - // PowerShell pane where a WSL one had been. - it('relaunches wsl.exe in the pane\u2019s own distro instead of the host default shell', async () => { - await reviveWslPane() - - const [shell, args] = mockPtySpawn.mock.calls[0] as [string, string[]] - expect(shell).toBe('wsl.exe') - expect(args).toEqual(['-d', 'Ubuntu']) - }) - - it('scopes HISTFILE past the wsl.exe wrapper and carries it over WSLENV', async () => { - await reviveWslPane() - - const spawnEnv = mockPtySpawn.mock.calls[0][2]?.env as Record - expect(spawnEnv.HISTFILE?.endsWith(`${hashWorktreeId(worktreeId)}-bash_history`)).toBe(true) - expect(spawnEnv.WSLENV?.split(':')).toContain('HISTFILE') - // Deletion is unchanged because the file still lives on the relay host. - expect(existsSync(historyFile)).toBe(true) - }) - - // Why: a revived pane is serialized again on the next restart, so dropping - // the override there is the same defect one reconnect later. - it('keeps the override across a second serialize/revive round trip', async () => { - await reviveWslPane() - - const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - - expect(JSON.parse(state)[0]).toMatchObject({ - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' - }) - }) - - // Why: this field is replayed from state the relay hands a client and takes - // back unvalidated, and it lands in argv. - it('drops an over-long distro name rather than passing it to wsl.exe', async () => { - const state = JSON.stringify([ - { - id: 'pty-11', - pid: process.pid, - cols: 80, - rows: 24, - cwd: LIVE_CWD, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'U'.repeat(257) - } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const [shell, args] = mockPtySpawn.mock.calls[0] as [string, string[]] - expect(shell).toBe('wsl.exe') - expect(args).toEqual([]) - }) - - // Why: the override's whole point is that the pane keeps its own shell, so a - // shell that no longer exists must cost that pane and nothing else. - it('skips a pane whose overridden shell can no longer spawn, keeping the batch', async () => { - const state = JSON.stringify([ - { - id: 'pty-12', - pid: process.pid, - cols: 80, - rows: 24, - cwd: LIVE_CWD, - shellOverride: 'wsl.exe' - }, - { id: 'pty-13', pid: process.pid, cols: 80, rows: 24, cwd: LIVE_CWD } - ]) - mockPtySpawn.mockImplementationOnce(() => { - throw new Error('spawn wsl.exe ENOENT') - }) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - // The second entry still revived, and no other shell stood in for the first. - expect(mockPtySpawn).toHaveBeenCalledTimes(2) - const live = (await dispatcher.callRequest('pty.serialize', { - ids: ['pty-12', 'pty-13'] - })) as string - expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-13']) - }) - - // Why: relayHostDirectoryExists stats the relay's own filesystem, and a wsl.exe pane's cwd - // lives in the guest -- the same host boundary requireRelaySpawnCwd already honours. - it('revives a WSL pane whose cwd is a guest path this host cannot stat', async () => { - const state = JSON.stringify([ - { - id: 'pty-14', - pid: process.pid, - cols: 80, - rows: 24, - cwd: '/home/dev/guest-only-worktree', - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' - } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - expect(mockPtySpawn).toHaveBeenCalledTimes(1) - expect((mockPtySpawn.mock.calls[0][2] as { cwd: string }).cwd).toBe( - '/home/dev/guest-only-worktree' - ) - }) - - it('degrades one entry with an unsupported override without failing the batch', async () => { - const state = JSON.stringify([ - { - id: 'pty-9', - pid: process.pid, - cols: 80, - rows: 24, - cwd: LIVE_CWD, - shellOverride: 'nc.exe' - }, - { id: 'pty-10', pid: process.pid, cols: 80, rows: 24, cwd: LIVE_CWD } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - expect(mockPtySpawn).toHaveBeenCalledTimes(2) - expect(mockPtySpawn.mock.calls.map(([shell]) => shell)).not.toContain('nc.exe') - }) - }) -}) diff --git a/src/relay/pty-handler-shell-recovery.test.ts b/src/relay/pty-handler-shell-recovery.test.ts deleted file mode 100644 index 43580756b60..00000000000 --- a/src/relay/pty-handler-shell-recovery.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import './mock-descendant-sweep' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PROCESS_BOUNDARY_GROUND } from '../shared/terminal-mode-reset-profiles' -import { RelayDispatcher, type RelayClientSessionIdentity } from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { PtyHandler } from './pty-handler' -import { TEST_PTY_ID_MINT_EPOCH } from './pty-handler-test-harness' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const { mockPtySpawn, mockConfirmShellForeground } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockConfirmShellForeground: vi.fn() -})) - -vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) -vi.mock('../main/daemon/pty-subprocess/pty-shell-foreground-confirmation', () => ({ - confirmPtyShellForeground: mockConfirmShellForeground -})) - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -// A command that pushed kitty keyboard flags and died without popping them. -const DYING_COMMAND = '\x1b]133;C\x07\x1b[>1u' -const COMMAND_DONE = '\x1b]133;D;130\x07' -const PROMPT = '$ ' - -type Frame = { - method?: string - id?: number - params?: Record - result?: Record -} - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -function decode(buffer: Buffer): Frame | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) -} - -describe.each([ - ['source-credit delivery', true], - ['legacy delivery', false] -])('PtyHandler shell recovery over %s', (_, sourceCredit) => { - let dispatcher: RelayDispatcher - let handler: PtyHandler - let writes: Buffer[] - let emitData: (data: string) => void - let emitExit: (event: { exitCode: number }) => void - let ptyId: string - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(async () => { - vi.useFakeTimers() - originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - writes = [] - mockConfirmShellForeground.mockReset() - mockPtySpawn.mockReset() - mockPtySpawn.mockReturnValue({ - pid: process.pid, - onData: vi.fn((callback: (data: string) => void) => (emitData = callback)), - onExit: vi.fn((callback: (event: { exitCode: number }) => void) => (emitExit = callback)), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - destroy: vi.fn() - }) - dispatcher = new RelayDispatcher( - (data, settle) => { - writes.push(Buffer.from(data)) - queueMicrotask(() => settle({ ok: true })) - return true - }, - { supportsWriteCallback: true, writableHighWaterMark: () => 0 }, - endpointIdentity - ) - handler = new PtyHandler(dispatcher, undefined, TEST_PTY_ID_MINT_EPOCH) - if (sourceCredit) { - let publication: RelayPtySourcePublication | undefined - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication?.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => - handler.handleSourcePublicationCapacity(id) - ) - handler.setSourcePublication(publication) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 256 * 1024 } } - }) - ) - await vi.advanceTimersByTimeAsync(0) - } - dispatcher.feed(requestFrame(2, 'pty.spawn', {})) - await vi.advanceTimersByTimeAsync(0) - ptyId = String(response(2)?.id) - }) - - afterEach(async () => { - await handler.dispose({ waitForPhysicalExit: false }).catch(() => {}) - dispatcher.dispose() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - vi.useRealTimers() - }) - - function response(id: number): Record | undefined { - return writes.map(decode).find((frame) => frame?.id === id)?.result - } - - function published(): string { - const frames = writes.map(decode).filter((frame) => frame?.method === 'pty.data') - if (sourceCredit) { - expect(frames.every((frame) => frame?.params?.sourceLengthSu !== undefined)).toBe(true) - } - return frames.map((frame) => frame?.params?.data).join('') - } - - async function replay(): Promise { - dispatcher.feed( - requestFrame(3, 'pty.attach', { - id: ptyId, - requireReplay: true, - suppressReplayNotification: true - }) - ) - await vi.advanceTimersByTimeAsync(0) - return String(response(3)?.replay ?? '') - } - - async function stream(...chunks: string[]): Promise { - for (const chunk of chunks) { - emitData(chunk) - } - await vi.advanceTimersByTimeAsync(50) - } - - it('grounds a dead command before the prompt, live and in replay alike', async () => { - mockConfirmShellForeground.mockResolvedValue(true) - await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) - - const expected = `${DYING_COMMAND}${COMMAND_DONE}${PROCESS_BOUNDARY_GROUND}${PROMPT}` - expect(mockConfirmShellForeground).toHaveBeenCalledOnce() - expect(published()).toBe(expected) - expect(await replay()).toBe(expected) - }) - - it('leaves the bytes unchanged when the shell does not own the foreground', async () => { - mockConfirmShellForeground.mockResolvedValue(false) - await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) - - const expected = `${DYING_COMMAND}${COMMAND_DONE}${PROMPT}` - expect(published()).toBe(expected) - expect(await replay()).toBe(expected) - }) - - it('delivers a dying app’s oversized final frame and the ground behind it', async () => { - mockConfirmShellForeground.mockResolvedValue(true) - const finalFrame = 'x'.repeat(20 * 1024) - await stream(DYING_COMMAND, `${finalFrame}${COMMAND_DONE}${PROMPT}`) - - const expected = `${DYING_COMMAND}${finalFrame}${COMMAND_DONE}${PROCESS_BOUNDARY_GROUND}${PROMPT}` - expect(published()).toBe(expected) - expect(await replay()).toBe(expected) - }) - - it('flushes the held prompt when the shell exits mid-proof', async () => { - mockConfirmShellForeground.mockReturnValue(new Promise(() => {})) - await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) - expect(published()).not.toContain(PROMPT) - - emitExit({ exitCode: 0 }) - await vi.advanceTimersByTimeAsync(50) - - expect(published()).toBe(`${DYING_COMMAND}${COMMAND_DONE}${PROMPT}`) - }) - - it('grounds replay, not the live stream, on Reset Terminal after an unhooked crash', async () => { - // Armed with no command end: nothing the host barrier could ground at. - const unhookedCrash = '\x1b[>1u\x1b[?1000h' - await stream(unhookedCrash, PROMPT) - - dispatcher.feed(requestFrame(4, 'pty.resetInputModes', { id: ptyId })) - await vi.advanceTimersByTimeAsync(50) - - // The client grounds its own view; a zero-raw span would not cross the credit window. - expect(published()).toBe(`${unhookedCrash}${PROMPT}`) - expect(await replay()).toBe(`${unhookedCrash}${PROMPT}${PROCESS_BOUNDARY_GROUND}`) - }) -}) diff --git a/src/relay/pty-handler-shell-resolution.test.ts b/src/relay/pty-handler-shell-resolution.test.ts deleted file mode 100644 index 679c2babab7..00000000000 --- a/src/relay/pty-handler-shell-resolution.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as gitBash from '../main/git-bash' -import * as ptyShellUtils from './pty-shell-utils' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('uses an explicit shell override and falls back to the default shell otherwise', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - const resolveDefaultShellSpy = vi - .spyOn(ptyShellUtils, 'resolveDefaultShell') - .mockReturnValue('/default-shell') - try { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - shellOverride: 'powershell.exe' - }) - expect(mockPtySpawn).toHaveBeenCalledWith( - 'powershell.exe', - expect.any(Array), - expect.any(Object) - ) - - mockPtySpawn.mockClear() - - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - expect(mockPtySpawn).toHaveBeenCalledWith( - '/default-shell', - expect.any(Array), - expect.any(Object) - ) - } finally { - resolveDefaultShellSpy.mockRestore() - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('ignores Windows shell overrides on non-Windows relay hosts', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'linux' - }) - const resolveDefaultShellSpy = vi - .spyOn(ptyShellUtils, 'resolveDefaultShell') - .mockReturnValue('/default-shell') - try { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - shellOverride: 'powershell.exe' - }) - - expect(mockPtySpawn).toHaveBeenCalledWith( - '/default-shell', - expect.any(Array), - expect.any(Object) - ) - } finally { - resolveDefaultShellSpy.mockRestore() - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('rejects unsupported shell overrides on Windows relay hosts', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - try { - await expect( - dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - shellOverride: 'notepad.exe' - }) - ).rejects.toThrow('Unsupported Windows shell override') - expect(mockPtySpawn).not.toHaveBeenCalled() - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - // Why: both spellings classify as a POSIX startup family, so the relay must not be the one host - // that hard-fails a setting the local and daemon PTYs accept. - it.each(['bash', 'bash.exe'])( - 'accepts the %s shell override and routes it through Git Bash resolution', - async (shellOverride) => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - const resolveGitBashSpy = vi - .spyOn(gitBash, 'resolveWindowsGitBashShellPath') - .mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe') - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride }) - - expect(resolveGitBashSpy).toHaveBeenCalledWith(shellOverride) - expect(mockPtySpawn).toHaveBeenCalledWith( - 'C:\\Program Files\\Git\\bin\\bash.exe', - expect.any(Array), - expect.any(Object) - ) - } finally { - resolveGitBashSpy.mockRestore() - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - } - ) - - it('falls back to the literal bash override when Git Bash is not installed', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - const resolveGitBashSpy = vi - .spyOn(gitBash, 'resolveWindowsGitBashShellPath') - .mockReturnValue(null) - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride: 'bash' }) - - expect(mockPtySpawn).toHaveBeenCalledWith('bash', expect.any(Array), expect.any(Object)) - } finally { - resolveGitBashSpy.mockRestore() - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - const resolveGitBashSpy = vi - .spyOn(gitBash, 'resolveWindowsGitBashShellPath') - .mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe') - try { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - shellOverride: 'git-bash' - }) - - expect(resolveGitBashSpy).toHaveBeenCalledWith('git-bash') - expect(mockPtySpawn).toHaveBeenCalledWith( - 'C:\\Program Files\\Git\\bin\\bash.exe', - expect.any(Array), - expect.any(Object) - ) - } finally { - resolveGitBashSpy.mockRestore() - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('passes the selected WSL distro to relay launches on Windows', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - try { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu-24.04' - }) - - expect(mockPtySpawn).toHaveBeenCalledWith( - 'wsl.exe', - ['-d', 'Ubuntu-24.04'], - expect.any(Object) - ) - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) -}) diff --git a/src/relay/pty-handler-shutdown-signals.test.ts b/src/relay/pty-handler-shutdown-signals.test.ts deleted file mode 100644 index 5bc3373599c..00000000000 --- a/src/relay/pty-handler-shutdown-signals.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createPtyRequestHelpers, - endPtyHandlerTest, - testPtyId -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - const { spawnPty } = createPtyRequestHelpers(() => dispatcher) - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('terminates spawned PTY when request becomes stale before response', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const killSpy = vi.fn() - const term = { - ...mockPtyInstance, - kill: killSpy, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - } - mockPtySpawn.mockReturnValue(term) - - await dispatcher.callRequest( - 'pty.spawn', - {}, - { - isStale: () => mockPtySpawn.mock.calls.length > 0 - } - ) - - // Why: assert via the captured spy reference rather than term.kill because - // disposeManagedPty() neutralizes managed.pty.kill (replaces it with a - // no-op) on POSIX to close the UnixTerminal.destroy() → socket-close → - // SIGHUP-to-recycled-pid race. After the 5s timer fires, term.kill is the - // neutralized function, not the original spy. killSpy retains call history. - expect(killSpy).toHaveBeenCalledWith('SIGTERM') - vi.advanceTimersByTime(5000) - expect(killSpy).toHaveBeenCalledWith('SIGKILL') - expect(handler.activePtyCount).toBe(1) - - onExitCb?.({ exitCode: 137 }) - expect(handler.activePtyCount).toBe(0) - }) - - it('accepts SIGWINCH for restored TUI repaint', async () => { - await dispatcher.callRequest('pty.spawn', {}) - - await dispatcher.callRequest('pty.sendSignal', { id: PTY_1, signal: 'SIGWINCH' }) - - const term = mockPtySpawn.mock.results[0].value - expect(term.kill).toHaveBeenCalledWith('SIGWINCH') - }) - - it('kills PTY on shutdown with SIGTERM by default', async () => { - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn() - }) - - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: false }) - expect(mockKill).toHaveBeenCalledWith('SIGTERM') - }) - - // Why: node-pty's Windows agent throws "Signals not supported on windows." - // for any signal argument. killPtyProcess drops the signal on win32 — cover - // every call site so a future regression cannot reintroduce signal args. - describe('kills PTY without a signal on Windows', () => { - async function withWindowsPlatform(fn: () => Promise): Promise { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - await fn() - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - } - - function mockKillablePty(): ReturnType { - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn() - }) - return mockKill - } - - function expectBareKills(mockKill: ReturnType, times: number): void { - expect(mockKill).toHaveBeenCalledTimes(times) - expect(mockKill.mock.calls.every((args) => args.length === 0)).toBe(true) - } - - it('on graceful shutdown', async () => { - await withWindowsPlatform(async () => { - const mockKill = mockKillablePty() - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: false }) - expectBareKills(mockKill, 1) - }) - }) - - it('on immediate shutdown', async () => { - await withWindowsPlatform(async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - await dispatcher.callRequest('pty.spawn', {}) - const shutdown = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - onExitCb!({ exitCode: 137 }) - await shutdown - expectBareKills(mockKill, 1) - }) - }) - - it('does not double-kill when immediate cleanup joins graceful shutdown', async () => { - await withWindowsPlatform(async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - const destroy = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - destroy, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: false }) - const immediate = dispatcher.callRequest('pty.shutdown', { - id: PTY_1, - immediate: true - }) - - expectBareKills(mockKill, 1) - onExitCb!({ exitCode: 137 }) - await immediate - expectBareKills(mockKill, 1) - expect(destroy).not.toHaveBeenCalled() - }) - }) - - it('does not retry stale-spawn cleanup after the Windows kill deadline', async () => { - await withWindowsPlatform(async () => { - const mockKill = mockKillablePty() - await dispatcher.callRequest( - 'pty.spawn', - {}, - { - isStale: () => mockPtySpawn.mock.calls.length > 0 - } - ) - expectBareKills(mockKill, 1) - vi.advanceTimersByTime(5000) - expectBareKills(mockKill, 1) - }) - }) - - it('does not retry graceful shutdown after the Windows kill deadline', async () => { - await withWindowsPlatform(async () => { - const mockKill = mockKillablePty() - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: false }) - expectBareKills(mockKill, 1) - vi.advanceTimersByTime(5000) - expectBareKills(mockKill, 1) - }) - }) - - it('on dispose', async () => { - await withWindowsPlatform(async () => { - const mockKill = vi.fn() - const destroy = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - destroy, - onData: vi.fn(), - onExit: vi.fn() - }) - await dispatcher.callRequest('pty.spawn', {}) - await handler.dispose({ waitForPhysicalExit: false }) - expectBareKills(mockKill, 1) - expect(destroy).not.toHaveBeenCalled() - }) - }) - }) - - it('flushes pending PTY output before immediate shutdown cleanup', async () => { - let dataCallback: ((data: string) => void) | undefined - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', {}) - dataCallback!('last words') - const shutdown = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - onExitCb!({ exitCode: 137 }) - await shutdown - - expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', { - id: PTY_1, - data: 'last words' - }) - expect(mockKill).toHaveBeenCalledWith('SIGKILL') - }) - - it('notifies pty.exit when graceful shutdown falls back to SIGKILL', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - const exits: { id: string; paneKey?: string }[] = [] - handler.setExitListener((evt) => exits.push(evt)) - - const spawn = await spawnPty({ env: { ORCA_PANE_KEY: 'tab-fallback:0' } }) - await dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: false }) - vi.advanceTimersByTime(5000) - - expect(handler.activePtyCount).toBe(1) - expect(exits).toEqual([]) - expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.exit', expect.anything()) - onExitCb!({ exitCode: 137 }) - - expect(mockKill).toHaveBeenCalledWith('SIGTERM') - expect(mockKill).toHaveBeenCalledWith('SIGKILL') - expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { - id: PTY_1, - code: 137, - incarnationId: spawn.incarnationId - }) - expect(exits).toEqual([{ id: PTY_1, paneKey: 'tab-fallback:0' }]) - expect(handler.activePtyCount).toBe(0) - }) - - it('retries a rejected graceful SIGKILL fallback while retaining ownership', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - let forceAttempts = 0 - const mockKill = vi.fn((signal: string) => { - if (signal === 'SIGKILL' && forceAttempts++ === 0) { - throw new Error('transient kill failure') - } - }) - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: false }) - vi.advanceTimersByTime(5000) - - expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - expect(handler.activePtyCount).toBe(1) - expect(vi.getTimerCount()).toBe(1) - - vi.runOnlyPendingTimers() - expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']]) - expect(handler.activePtyCount).toBe(1) - - onExitCb!({ exitCode: 137 }) - expect(handler.activePtyCount).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('kills PTY on shutdown with SIGKILL when immediate', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const mockKill = vi.fn() - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', {}) - const shutdown = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - onExitCb!({ exitCode: 137 }) - await shutdown - expect(mockKill).toHaveBeenCalledWith('SIGKILL') - }) -}) diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts deleted file mode 100644 index d283ea4e5b0..00000000000 --- a/src/relay/pty-handler-source-publication.test.ts +++ /dev/null @@ -1,822 +0,0 @@ -import './mock-descendant-sweep' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { PtyHandler } from './pty-handler' -import { TEST_PTY_ID_MINT_EPOCH, testPtyId } from './pty-handler-test-harness' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const { mockPtySpawn } = vi.hoisted(() => ({ mockPtySpawn: vi.fn() })) - -const PTY_1 = testPtyId(1) - -vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -type Notification = { method: string; params: Record } - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -function notification(buffer: Buffer): Notification | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - const message = JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) - return typeof message.method === 'string' && message.id === undefined ? message : null -} - -function responseResult(buffer: Buffer, id: number): Record | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - const message = JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) - return message.id === id ? (message.result ?? null) : null -} - -describe('PtyHandler negotiated source publication', () => { - let dispatcher: RelayDispatcher - let handler: PtyHandler - let publication: RelayPtySourcePublication - let dataCallback: ((data: string) => void) | undefined - let originalPlatform: PropertyDescriptor | undefined - let writes: Buffer[] - let heldResponseId: number | null - let heldResponseSettlements: ((result: SinkWriteSettlement) => void)[] - let adapter: SshPtyConsumerSessionAdapter - let pausePty: ReturnType - let exitCallback: ((event: { exitCode: number }) => void) | undefined - let destroyPty: ReturnType - let holdDataSettlements: boolean - let heldDataSettlements: ((result: SinkWriteSettlement) => void)[] - let holdExitSettlements: boolean - let heldExitSettlements: ((result: SinkWriteSettlement) => void)[] - let highWaterMark: number | undefined - - beforeEach(async () => { - vi.useFakeTimers() - originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - writes = [] - heldResponseId = null - heldResponseSettlements = [] - dataCallback = undefined - exitCallback = undefined - holdDataSettlements = false - heldDataSettlements = [] - holdExitSettlements = false - heldExitSettlements = [] - highWaterMark = undefined - pausePty = vi.fn() - destroyPty = vi.fn() - mockPtySpawn.mockReset() - mockPtySpawn.mockReturnValue({ - pid: process.pid, - onData: vi.fn((callback: (data: string) => void) => { - dataCallback = callback - }), - onExit: vi.fn((callback: (event: { exitCode: number }) => void) => { - exitCallback = callback - }), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: pausePty, - resume: vi.fn(), - destroy: destroyPty - }) - dispatcher = new RelayDispatcher( - (data, settle) => { - writes.push(Buffer.from(data)) - if (heldResponseId !== null && responseResult(data, heldResponseId)) { - heldResponseSettlements.push(settle) - return true - } - const frame = notification(data) - if (holdDataSettlements && frame?.method === 'pty.data') { - heldDataSettlements.push(settle) - return true - } - if (frame?.method === 'pty.exit') { - if (holdExitSettlements) { - heldExitSettlements.push(settle) - return true - } - // Why: real sockets never settle inside write(); a synchronous settle would re-enter - // the legacy capacity path before the pending exit is retired. - queueMicrotask(() => settle({ ok: true })) - return true - } - settle({ ok: true }) - return true - }, - { - supportsWriteCallback: true, - writableHighWaterMark: () => highWaterMark ?? 0 - }, - endpointIdentity - ) - handler = new PtyHandler(dispatcher, undefined, TEST_PTY_ID_MINT_EPOCH) - adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => - handler.handleSourcePublicationCapacity(id) - ) - handler.setSourcePublication(publication) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 1024 } } - }) - ) - await vi.advanceTimersByTimeAsync(0) - }) - - afterEach(async () => { - await handler.dispose({ waitForPhysicalExit: false }).catch(() => {}) - dispatcher.dispose() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - vi.useRealTimers() - }) - - async function spawn(params: Record): Promise { - dispatcher.feed(requestFrame(2, 'pty.spawn', params)) - await vi.advanceTimersByTimeAsync(0) - expect(dataCallback).toBeTypeOf('function') - } - - function sourceDataFrames(): Notification[] { - return writes - .map(notification) - .filter((frame): frame is Notification => frame?.method === 'pty.data') - } - - function exitFrames(): Notification[] { - return writes - .map(notification) - .filter((frame): frame is Notification => frame?.method === 'pty.exit') - } - - async function cancelSourceDelivery(spawnResult: Record): Promise { - const activation = spawnResult.sourceActivation as Record - dispatcher.feed( - requestFrame(9, 'pty.cancelDelivery', { - id: spawnResult.id, - clientGeneration: activation.clientGeneration, - ownerGeneration: activation.ownerGeneration, - deliveryToken: activation.deliveryToken - }) - ) - await vi.advanceTimersByTimeAsync(0) - } - - function stubPublication(overrides: Partial>): RelayPtySourcePublication { - return { - accepts: () => true, - exitPublicationSettled: () => false, - sealAndPublishExit: () => false, - publish: () => false, - onCreditAvailable: () => {}, - receivingActivation: () => undefined, - waitForPendingSend: async () => true, - activate: () => false, - getDebugSnapshot: () => ({}), - dispose: () => {}, - ...overrides - } as unknown as RelayPtySourcePublication - } - - it('publishes a single legacy exit after the owner cancels its delivery', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - dataCallback!('prompt') - await vi.advanceTimersByTimeAsync(8) - await cancelSourceDelivery(spawnResult) - expect(publication.accepts(String(spawnResult.id))).toBe(false) - - expect(() => exitCallback!({ exitCode: 0 })).not.toThrow() - - expect(exitFrames()).toHaveLength(1) - expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 0 }) - expect(destroyPty).toHaveBeenCalledOnce() - - handler.handleSourcePublicationCapacity(String(spawnResult.id)) - await vi.advanceTimersByTimeAsync(8) - expect(exitFrames()).toHaveLength(1) - }) - - async function attachSubscriber( - holdDataSettlement?: (settle: (result: SinkWriteSettlement) => void) => boolean - ): Promise { - const subscriberWrites: Buffer[] = [] - const clientId = dispatcher.attachClient( - (data, settle) => { - subscriberWrites.push(Buffer.from(data)) - const frame = notification(data) - if (frame?.method === 'pty.data' && holdDataSettlement?.(settle)) { - return true - } - if (frame?.method === 'pty.exit') { - // Why: real sockets never settle inside write(); see the primary sink above. - queueMicrotask(() => settle({ ok: true })) - return true - } - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - clientId, - requestFrame(20, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'legacy-subscriber', - requestedRole: 'subscriber' - }) - ) - await vi.advanceTimersByTimeAsync(0) - return subscriberWrites - } - - function subscriberExitFrames(subscriberWrites: Buffer[]): Notification[] { - return subscriberWrites - .map(notification) - .filter((frame): frame is Notification => frame?.method === 'pty.exit') - } - - it('never re-delivers the exit to subscribers when a cancel retires the record', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const subscriberWrites = await attachSubscriber() - dataCallback!('prompt') - await vi.advanceTimersByTimeAsync(8) - - // Why: the owner's producer lane is saturated when the PTY exits, so only the legacy - // projection lands; the record then still holds the undelivered owner exit. - highWaterMark = 64 - expect(() => exitCallback!({ exitCode: 0 })).not.toThrow() - await vi.advanceTimersByTimeAsync(8) - expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1) - - highWaterMark = undefined - await cancelSourceDelivery(spawnResult) - await vi.advanceTimersByTimeAsync(8) - - expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1) - }) - - it('drains the pending exit once the retired record published it', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - dataCallback!('prompt') - await vi.advanceTimersByTimeAsync(8) - holdExitSettlements = true - - exitCallback!({ exitCode: 0 }) - await vi.advanceTimersByTimeAsync(0) - expect(heldExitSettlements).toHaveLength(1) - await cancelSourceDelivery(spawnResult) - // Why: the in-flight frame failing on a canceled delivery is the only path that reaches - // the retiring branch with an unsettled exit publication. - heldExitSettlements[0]({ ok: false, error: new Error('socket write failed') }) - await vi.advanceTimersByTimeAsync(8) - expect(exitFrames()).toHaveLength(2) - - handler.handleSourcePublicationCapacity(String(spawnResult.id)) - await vi.advanceTimersByTimeAsync(8) - - expect(exitFrames()).toHaveLength(2) - }) - - it('drains buffered legacy output before the exit when capacity returns', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - await cancelSourceDelivery(spawnResult) - const subscriberWrites = await attachSubscriber((settle) => { - if (!holdDataSettlements) { - return false - } - heldDataSettlements.push(settle) - return true - }) - holdDataSettlements = true - - dataCallback!('first') - await vi.advanceTimersByTimeAsync(8) - expect(heldDataSettlements).toHaveLength(1) - // Why: shrinking the frame capacity makes the next chunk unpublishable, so it stays queued. - highWaterMark = 64 - dataCallback!('second') - await vi.advanceTimersByTimeAsync(8) - - expect(() => exitCallback!({ exitCode: 3 })).not.toThrow() - expect(exitFrames()).toHaveLength(0) - - highWaterMark = undefined - holdDataSettlements = false - heldDataSettlements[0]({ ok: true }) - await vi.advanceTimersByTimeAsync(8) - - const frames = subscriberWrites - .map(notification) - .filter( - (frame): frame is Notification => - frame?.method === 'pty.data' || frame?.method === 'pty.exit' - ) - expect(frames.map((frame) => frame.method)).toEqual(['pty.data', 'pty.data', 'pty.exit']) - expect(frames.map((frame) => frame.params.data)).toEqual(['first', 'second', undefined]) - expect(frames.at(-1)!.params).toMatchObject({ id: spawnResult.id, code: 3 }) - }) - - it('contains a source publication fault and still delivers the legacy exit', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - handler.setSourcePublication( - stubPublication({ - sealAndPublishExit: () => { - throw new Error('ledger delivery vanished') - } - }) - ) - - expect(() => exitCallback!({ exitCode: 7 })).not.toThrow() - - expect(exitFrames()).toHaveLength(1) - expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 7 }) - expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( - '[pty-handler] pty source exit publication failed' - ) - } finally { - stderr.mockRestore() - } - }) - - it('retires the delivery after the owner exit publication throws', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const id = String(spawnResult.id) - const subscriberWrites = await attachSubscriber() - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - const publishOwnerExit = vi - .spyOn(dispatcher, 'tryNotifyPtyExitToClient') - .mockImplementationOnce(() => { - throw new Error('owner write failed') - }) - try { - expect(() => exitCallback!({ exitCode: 8 })).not.toThrow() - await vi.advanceTimersByTimeAsync(0) - - expect(exitFrames()).toHaveLength(1) - expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1) - expect(publication.accepts(id)).toBe(false) - expect(adapter.getDebugSnapshot().deliveryTokens).toBe(0) - - handler.handleSourcePublicationCapacity(id) - await vi.advanceTimersByTimeAsync(0) - expect(exitFrames()).toHaveLength(1) - expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1) - } finally { - publishOwnerExit.mockRestore() - stderr.mockRestore() - } - }) - - it('retires the delivery when committed owner exit settlement fails', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const id = String(spawnResult.id) - const subscriberWrites = await attachSubscriber() - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - const settleOwnerExit = vi.spyOn(adapter, 'settleExitPublication').mockImplementation(() => { - throw new Error('exit settlement failed') - }) - try { - exitCallback!({ exitCode: 9 }) - await vi.advanceTimersByTimeAsync(0) - - expect(exitFrames()).toHaveLength(1) - expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1) - expect(publication.accepts(id)).toBe(false) - expect(adapter.getDebugSnapshot().deliveryTokens).toBe(0) - - handler.handleSourcePublicationCapacity(id) - await vi.advanceTimersByTimeAsync(0) - expect(exitFrames()).toHaveLength(1) - expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1) - } finally { - settleOwnerExit.mockRestore() - stderr.mockRestore() - } - }) - - it('lets a retired record re-target its own exit instead of broadcasting a duplicate', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const subscriberWrites = await attachSubscriber() - const publishExitAfterRetire = vi.fn(() => true) - handler.setSourcePublication(stubPublication({ accepts: () => false, publishExitAfterRetire })) - - expect(() => exitCallback!({ exitCode: 4 })).not.toThrow() - - expect(publishExitAfterRetire).toHaveBeenCalledWith( - expect.objectContaining({ id: spawnResult.id, code: 4 }) - ) - expect(exitFrames()).toHaveLength(0) - expect(subscriberExitFrames(subscriberWrites)).toHaveLength(0) - - handler.handleSourcePublicationCapacity(String(spawnResult.id)) - await vi.advanceTimersByTimeAsync(8) - expect(publishExitAfterRetire).toHaveBeenCalledOnce() - }) - - it('broadcasts the legacy exit when the retired record never projected one', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - handler.setSourcePublication( - stubPublication({ accepts: () => false, publishExitAfterRetire: () => null }) - ) - - exitCallback!({ exitCode: 5 }) - - expect(exitFrames()).toHaveLength(1) - expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 5 }) - }) - - it('contains a retired-record publication fault and falls back to the legacy exit', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - handler.setSourcePublication( - stubPublication({ - accepts: () => false, - publishExitAfterRetire: () => { - throw new Error('retired owner lookup failed') - } - }) - ) - - expect(() => exitCallback!({ exitCode: 6 })).not.toThrow() - - expect(exitFrames()).toHaveLength(1) - expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 6 }) - expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( - '[pty-handler] retired pty exit publication failed' - ) - } finally { - stderr.mockRestore() - } - }) - - it('completes the exit from the settled state without re-sealing the delivery', async () => { - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const sealAndPublishExit = vi.fn(() => true) - handler.setSourcePublication( - stubPublication({ exitPublicationSettled: () => true, sealAndPublishExit }) - ) - - exitCallback!({ exitCode: 0 }) - handler.handleSourcePublicationCapacity(String(spawnResult.id)) - - expect(sealAndPublishExit).not.toHaveBeenCalled() - expect(exitFrames()).toHaveLength(0) - }) - - it('fences the first source frame behind immutable spawn and attach activation metadata', async () => { - heldResponseId = 2 - await spawn({}) - const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const sourceActivation = spawnResult.sourceActivation as Record - - expect(sourceActivation).toMatchObject({ - status: 'pending', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: spawnResult.incarnationId, - deliveryToken: expect.any(String), - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }) - dataCallback!('prompt') - await vi.advanceTimersByTimeAsync(8) - expect(sourceDataFrames()).toHaveLength(0) - - heldResponseSettlements[0]({ ok: true }) - const firstSource = sourceDataFrames()[0] - expect(firstSource.params).toMatchObject({ - id: spawnResult.id, - data: 'prompt', - clientGeneration: sourceActivation.clientGeneration, - ownerGeneration: sourceActivation.ownerGeneration, - ptyIncarnation: sourceActivation.ptyIncarnation, - deliveryToken: sourceActivation.deliveryToken, - sourceEndSu: 6 - }) - - dispatcher.feed(requestFrame(3, 'pty.attach', { id: spawnResult.id })) - await vi.advanceTimersByTimeAsync(0) - const attachResult = writes.map((buffer) => responseResult(buffer, 3)).find(Boolean)! - expect(attachResult.sourceActivation).toEqual(sourceActivation) - }) - - it('settles a consumed POSIX startup query before publishing its prompt', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - await spawn({ - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, - startupIngress: { - colors: { foreground: '#2e3434', background: '#ffffff' }, - deadlineMs: 5_000 - } - }) - const query = '\x1b]10;?\x07' - - dataCallback!(query) - dataCallback!('prompt') - await vi.advanceTimersByTimeAsync(9) - - expect(sourceDataFrames().map((frame) => frame.params)).toEqual([ - expect.objectContaining({ - data: '', - rawLength: query.length, - transformed: true, - sourceLengthSu: query.length, - sourceEndSu: query.length - }), - expect.objectContaining({ - data: 'prompt', - rawLength: 6, - sourceLengthSu: 6, - sourceEndSu: query.length + 6 - }) - ]) - expect(publication.getDebugSnapshot()).toMatchObject({ sendCommitted: 2 }) - }) - - it('settles a suppressed ConPTY query before publishing its prompt', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - await spawn({ shellOverride: 'powershell.exe' }) - const query = '\x1b]10;?\x07' - - dataCallback!(query) - dataCallback!('PS> ') - await vi.advanceTimersByTimeAsync(9) - - expect(sourceDataFrames().map((frame) => frame.params)).toEqual([ - expect.objectContaining({ - data: '', - rawLength: query.length, - transformed: true, - sourceLengthSu: query.length, - sourceEndSu: query.length - }), - expect.objectContaining({ - data: 'PS> ', - rawLength: 4, - sourceLengthSu: 4, - sourceEndSu: query.length + 4 - }) - ]) - expect(publication.getDebugSnapshot()).toMatchObject({ sendCommitted: 2 }) - }) - - it('activates a fresh source consumer when an operation response is lost and retried', async () => { - const operationId = 'a'.repeat(43) - const grant = writes.map((buffer) => responseResult(buffer, 1)).find(Boolean)! - heldResponseId = 2 - dispatcher.feed( - requestFrame(2, 'pty.spawn', { - agentSessionCreateOperationId: operationId - }) - ) - await vi.advanceTimersByTimeAsync(0) - expect(heldResponseSettlements).toHaveLength(1) - expect( - writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)?.sourceActivation - ).toEqual(expect.objectContaining({ deliveryToken: expect.any(String) })) - - dispatcher.invalidateClient() - expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 0 }) - - const replacementWrites: Buffer[] = [] - const replacementClientId = dispatcher.attachClient( - (data, settle) => { - replacementWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - replacementClientId, - requestFrame(3, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: grant.ownerGeneration, - ownerLease: grant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 1024 } } - }) - ) - await vi.advanceTimersByTimeAsync(0) - dispatcher.feedClient( - replacementClientId, - requestFrame(4, 'pty.spawn', { - agentSessionCreateOperationId: operationId - }) - ) - await vi.advanceTimersByTimeAsync(0) - - dataCallback!('prompt') - await vi.advanceTimersByTimeAsync(8) - - expect(mockPtySpawn).toHaveBeenCalledOnce() - expect( - responseResult( - replacementWrites.find((buffer) => responseResult(buffer, 4))!, - 4 - ) - ).toMatchObject({ - id: PTY_1, - incarnationId: expect.any(String), - sourceActivation: expect.objectContaining({ deliveryToken: expect.any(String) }) - }) - expect( - replacementWrites.map(notification).find((frame) => frame?.method === 'pty.data')?.params - ).toMatchObject({ - id: PTY_1, - data: 'prompt', - sourceLengthSu: 6, - sourceEndSu: 6 - }) - expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1 }) - }) - - it('republishes the identical memoized span after a failed projection', async () => { - await spawn({}) - const appendSpy = vi.spyOn(adapter, 'appendSource') - const projectSpy = vi - .spyOn(dispatcher, 'projectPtyDataToMatchingClients') - .mockReturnValueOnce(false) - // Why: a larger capacity result on the retry must not move the memoized span boundary. - const maxCharsSpy = vi.spyOn(dispatcher, 'maxLegacyPtyDataChars') - maxCharsSpy.mockReturnValueOnce(4).mockReturnValue(8) - - dataCallback!('abcdefgh') - await vi.advanceTimersByTimeAsync(8) - expect(projectSpy).toHaveBeenCalledTimes(1) - - handler.handleSourcePublicationCapacity(PTY_1) - await vi.advanceTimersByTimeAsync(8) - - // Retried projection carries the byte-identical span params. - expect(projectSpy.mock.calls[1][1]).toEqual(projectSpy.mock.calls[0][1]) - const spans = appendSpy.mock.calls.map(([, input]) => input) - expect(spans.filter((span) => span.data === 'abcd')).toHaveLength(1) - expect(spans.map((span) => span.data).join('')).toBe('abcdefgh') - expect(spans.map((span) => [span.displayStart, span.displayEnd])).toEqual([ - [0, 4], - [4, 8] - ]) - }) - - it('keeps the native PTY and V1 owner live when one subscriber saturates', async () => { - await spawn({}) - const detached: number[] = [] - const healthyWrites: Buffer[] = [] - let saturateSubscriber = false - dispatcher.onClientDetached((clientId) => detached.push(clientId)) - const saturatedId = dispatcher.attachClient( - (_data, settle) => { - if (saturateSubscriber) { - return false - } - settle({ ok: true }) - return true - }, - { - supportsWriteCallback: true, - writableLength: () => 16 * 1024, - writableHighWaterMark: () => 4 * 1024 * 1024 - }, - endpointIdentity - ) - const healthyId = dispatcher.attachClient( - (data, settle) => { - healthyWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - saturatedId, - requestFrame(20, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'saturated-subscriber', - requestedRole: 'subscriber' - }) - ) - dispatcher.feedClient( - healthyId, - requestFrame(21, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'healthy-subscriber', - requestedRole: 'subscriber' - }) - ) - await vi.advanceTimersByTimeAsync(0) - saturateSubscriber = true - const payload = 's'.repeat(16 * 1024) - let admitted = 0 - while ( - dispatcher.tryNotifyPtyDataToClient(saturatedId, { id: 'saturated', data: payload }, () => {}) - ) { - admitted++ - } - - dataCallback!(payload) - await vi.advanceTimersByTimeAsync(8) - - expect(admitted).toBeGreaterThan(100) - expect(admitted).toBeLessThan(140) - expect(detached).toEqual([saturatedId]) - expect(detached).not.toContain(healthyId) - expect( - healthyWrites.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - expect(sourceDataFrames()).toHaveLength(1) - expect(publication.getDebugSnapshot()).toMatchObject({ sendCommitted: 1 }) - expect(pausePty).not.toHaveBeenCalled() - }) - - // The reported defect, in the shape measured against the live relay: the client attaches twice - // for the same PTY. The first registers a source delivery under the primary client's id; the - // second finds `current.clientId === context.clientId`, answers 'existing', and returns NO - // replay. Live, every pane on reconnect logged path=existing-delivery and painted nothing. - // - // A reattach always lands in a NEW terminal — a reconnect bumps tab.generation, which is the - // pane's React key, so TerminalPane remounts and the old xterm is disposed with its buffer. - describe('a second attach for the same client', () => { - async function attach(id: number, params: Record) { - writes = [] - dispatcher.feed(requestFrame(id, 'pty.attach', { id: PTY_1, ...params })) - await vi.advanceTimersByTimeAsync(0) - return writes.map((buffer) => responseResult(buffer, id)).find(Boolean) - } - - beforeEach(async () => { - await spawn({}) - dataCallback!('scrollback-that-must-survive') - await vi.advanceTimersByTimeAsync(10) - await attach(10, { suppressReplayNotification: true }) - }) - - it('replays the scrollback when the client says it needs it', async () => { - const second = await attach(11, { - suppressReplayNotification: true, - requireReplay: true - }) - - expect(second, 'second attach returned no response').toBeTruthy() - expect(second!.replay).toContain('scrollback-that-must-survive') - }) - - // The early return is right for a duplicate attach from a client that IS still receiving the - // stream; only a client that has thrown its terminal away should ask. Pinned so the fix stays - // opt-in and cannot start double-rendering for callers that never asked. - it('still sends nothing when the client does not ask', async () => { - const second = await attach(12, { suppressReplayNotification: true }) - - expect(second, 'second attach returned no response').toBeTruthy() - expect(second!.replay).toBeUndefined() - }) - }) -}) diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts deleted file mode 100644 index 038d944e329..00000000000 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ /dev/null @@ -1,801 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import * as ptyChildProcessInspection from './pty-child-process-inspection' -import * as ptyShellUtils from './pty-shell-utils' -import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader' -import * as runProcessModule from '@orca/process-host' -import * as nodePtyBindingSurvey from './node-pty-binding-survey' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import { MAX_RELAY_PTY_SESSIONS, PtyHandler } from './pty-handler' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - beginPtyHandlerTest, - createMockDispatcher, - createPtyRequestHelpers, - createTestPtyHandler, - testPtyId, - endPtyHandlerTest -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - const { spawnPty, attachPty } = createPtyRequestHelpers(() => dispatcher) - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('registers all expected handlers', () => { - const methods = Array.from(dispatcher._requestHandlers.keys()) - expect(methods).toContain('pty.spawn') - expect(methods).toContain('pty.attach') - expect(methods).toContain('pty.shutdown') - expect(methods).toContain('pty.sendSignal') - expect(methods).toContain('pty.getCwd') - expect(methods).toContain('pty.getInitialCwd') - expect(methods).toContain('pty.clearBuffer') - expect(methods).toContain('pty.hasChildProcesses') - expect(methods).toContain('pty.getForegroundProcess') - expect(methods).toContain('pty.inspectProcess') - expect(methods).toContain('pty.listProcesses') - expect(methods).toContain('pty.getDefaultShell') - - const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) - expect(notifMethods).toContain('pty.data') - expect(notifMethods).toContain('pty.resize') - // Why not `toContain('pty.ackData')`: PtyHandler must NOT own that method. It used to - // register a no-op here, which survived only because the consumer session adapter was - // constructed later and overwrote it (STA-4571). - expect(notifMethods).not.toContain('pty.ackData') - }) - - it('rescans the process table for a close decision but not for a poll', async () => { - const hasChildren = vi.mocked(ptyChildProcessInspection.processHasChildren) - const snapshot = vi - .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') - .mockResolvedValue({ - rows: [ - { - pid: mockPtyInstance.pid, - ppid: 1, - pgid: mockPtyInstance.pid, - tpgid: mockPtyInstance.pid, - stat: 'S+', - tty: '/dev/pts/1', - startTime: '1', - command: 'bash' - } - ], - capturedAgeMs: 0 - }) - const { id } = (await spawnPty({ cols: 80, rows: 24 })) as { id: string } - hasChildren.mockClear() - - await dispatcher.callRequest('pty.inspectProcess', { id }) - // The poll shares the TTL-cached process table used for the foreground lookup, - // so it does not fork a separate child-process probe. - expect(snapshot).toHaveBeenCalledOnce() - expect(hasChildren).not.toHaveBeenCalled() - - await dispatcher.callRequest('pty.hasChildProcesses', { id }) - // This RPC only ever gates a destructive decision (window close, workspace - // cleanup), so it has to see a child started inside the 500ms window. - expect(hasChildren).toHaveBeenLastCalledWith(mockPtyInstance.pid, { fresh: true }) - }) - - it('does not re-enter the shared capture after the evidence read gave up on it', async () => { - // The budget is worthless if the compatibility fields answer by joining the very capture the - // evidence read just abandoned: `inspectPtyChildProcesses` and `getForegroundProcessName` - // read the same TTL-shared table with no budget of their own, so on a slow host this call - // would still block for the whole capture -- once, then once per managed PTY in the listing. - const snapshot = vi - .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') - .mockRejectedValue(new Error('process table unreadable: capture_over_budget')) - const hasChildren = vi.spyOn(ptyChildProcessInspection, 'inspectPtyChildProcesses') - const foregroundName = vi.spyOn(ptyShellUtils, 'getForegroundProcessName') - - const { id } = (await spawnPty({ cols: 80, rows: 24 })) as { id: string } - hasChildren.mockClear() - foregroundName.mockClear() - - const inspection = (await dispatcher.callRequest('pty.inspectProcess', { id })) as { - hasChildProcesses: boolean - childProcessEvidence?: string - foregroundProcessEvidence?: { verdict: string; reason?: string } - } - - expect(snapshot).toHaveBeenCalled() - expect(hasChildren).not.toHaveBeenCalled() - // The verdict the gates already handle, reached promptly instead of late. - expect(inspection.foregroundProcessEvidence?.verdict).toBe('unverifiable') - expect(inspection.foregroundProcessEvidence?.reason).toBe('process_table_unreadable') - // The honest verdict rather than a fabricated negative, reached without the wait. The - // compatibility boolean still spells `unverifiable` as `false` for older clients. - expect(inspection.childProcessEvidence).toBe('unverifiable') - expect(inspection.hasChildProcesses).toBe(false) - - const listing = (await dispatcher.callRequest('pty.listProcesses', {})) as { - id: string - title: string - }[] - - expect(foregroundName).not.toHaveBeenCalled() - expect(listing.find((entry) => entry.id === id)?.title).toBeTruthy() - }) - - it('rejects strict process inspection for a missing relay PTY', async () => { - await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'missing' })).rejects.toThrow( - 'terminal_gone' - ) - }) - - it('spawns a PTY and returns an id', async () => { - const result = await spawnPty({ cols: 80, rows: 24 }) - // shellReadyArmed rides every spawn reply, false included: absent has to keep - // meaning "host predates the field", not "host did not arm". - expect(result).toEqual({ - id: testPtyId(1), - incarnationId: expect.any(String), - shellReadyArmed: false - }) - expect(mockPtySpawn).toHaveBeenCalled() - expect(handler.activePtyCount).toBe(1) - }) - - it('replays an operation-owned spawn after its first response becomes stale', async () => { - const operationId = 'a'.repeat(43) - - await dispatcher.callRequest( - 'pty.spawn', - { cols: 80, rows: 24, agentSessionCreateOperationId: operationId }, - { isStale: () => mockPtySpawn.mock.calls.length > 0 } - ) - const replayed = await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - agentSessionCreateOperationId: operationId - }) - - expect(replayed).toEqual({ - id: testPtyId(1), - incarnationId: expect.any(String), - shellReadyArmed: false - }) - expect(mockPtySpawn).toHaveBeenCalledOnce() - expect(mockPtyInstance.kill).not.toHaveBeenCalled() - expect(handler.activePtyCount).toBe(1) - }) - - it('starts a create however many create records it holds, and still replays an earlier one', async () => { - // Why: a 4,096-record limit refused a user's create for unrelated traffic. - const request = (operationId: string) => ({ - cols: 80, - rows: 24, - agentSessionCreateOperationId: operationId - }) - await dispatcher.callRequest('pty.spawn', request('a'.repeat(43))) - const records = handler['agentSessionCreateOperations'] - const held = records.get('a'.repeat(43))! - for (let index = records.size; index < 4_096; index += 1) { - records.set(`held-${index}`, held) - } - - await expect(dispatcher.callRequest('pty.spawn', request('b'.repeat(43)))).resolves.toEqual({ - id: testPtyId(2), - incarnationId: expect.any(String), - shellReadyArmed: false - }) - await expect(dispatcher.callRequest('pty.spawn', request('a'.repeat(43)))).resolves.toEqual({ - id: testPtyId(1), - incarnationId: expect.any(String), - shellReadyArmed: false - }) - expect(records.size).toBe(4_097) - expect(mockPtySpawn).toHaveBeenCalledTimes(2) - }) - - it('retains an operation fence when publication fails after native spawn', async () => { - const operationId = 'f'.repeat(43) - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(() => { - throw new Error('listener publication failed') - }) - }) - const request = { - cols: 80, - rows: 24, - agentSessionCreateOperationId: operationId - } - - await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow( - 'listener publication failed' - ) - await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow( - 'listener publication failed' - ) - expect(mockPtySpawn).toHaveBeenCalledOnce() - expect(handler.activePtyCount).toBe(1) - }) - - it('releases a canceled operation before native spawn after module preflight', async () => { - let finishModuleLoad!: (value: { spawn: typeof mockPtySpawn }) => void - const moduleLoad = new Promise<{ spawn: typeof mockPtySpawn }>((resolve) => { - finishModuleLoad = resolve - }) - const internals = handler as unknown as { - loadPty(): Promise<{ spawn: typeof mockPtySpawn } | null> - } - const loadPty = vi.spyOn(internals, 'loadPty').mockReturnValueOnce(moduleLoad) - const abort = new AbortController() - const operationId = 'c'.repeat(43) - const request = { cols: 80, rows: 24, agentSessionCreateOperationId: operationId } - const spawning = dispatcher.callRequest('pty.spawn', request, { - isStale: () => abort.signal.aborted, - signal: abort.signal - }) - - abort.abort() - finishModuleLoad({ spawn: mockPtySpawn }) - await expect(spawning).rejects.toThrow('client_disconnected') - expect(mockPtySpawn).not.toHaveBeenCalled() - - loadPty.mockResolvedValue({ spawn: mockPtySpawn }) - await expect(dispatcher.callRequest('pty.spawn', request)).resolves.toMatchObject({ - id: testPtyId(2) - }) - expect(mockPtySpawn).toHaveBeenCalledOnce() - }) - - it('rejects malformed create operation ids before spawning', async () => { - await expect( - dispatcher.callRequest('pty.spawn', { agentSessionCreateOperationId: 'not-valid' }) - ).rejects.toThrow('agent_session_operation_invalid') - expect(mockPtySpawn).not.toHaveBeenCalled() - }) - - it('adopts only the exact claimed owner generation on relay retry', async () => { - const agentSessionEnsure = { - claim: { - digestVersion: 1, - keyId: 'claim-key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'codex' - }, - surface: { - worktreeId: 'repo::/tmp/worktree', - tabId: '11111111-1111-4111-8111-111111111111', - leafId: '22222222-2222-4222-8222-222222222222', - terminalHandle: 'term_claimed' - } - } - - const first = (await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - agentSessionEnsure - })) as Record - const second = (await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - agentSessionEnsure - })) as Record - - expect(first).toMatchObject({ - id: testPtyId(1), - agentSessionEnsure: { disposition: 'created' } - }) - expect(second).toMatchObject({ - id: testPtyId(1), - agentSessionEnsure: { disposition: 'adopted' } - }) - expect(second.agentSessionEnsure).toMatchObject({ - owner: (first.agentSessionEnsure as { owner: unknown }).owner - }) - expect(mockPtySpawn).toHaveBeenCalledOnce() - }) - - it('normalizes a missing native binding as degraded node-pty availability', async () => { - mockPtySpawn.mockImplementationOnce(() => { - throw new Error( - 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' - ) - }) - - await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( - 'Remote terminals are unavailable' - ) - expect(handler.activePtyCount).toBe(0) - }) - - const THROWN_LOAD_ERROR = - 'Failed to load native module: pty.node, checked: build/Release, prebuilds/linux-x64' - - function failSpawnWithToolchainProbe( - toolchainProbeStdout: string - ): Promise<(Error & { data?: unknown }) | null> { - const realRunProcess = runProcessModule.runProcess - vi.spyOn(runProcessModule, 'runProcess').mockImplementation((spec) => - spec.program === '/bin/sh' - ? Promise.resolve({ - stdout: toolchainProbeStdout, - stderr: '', - code: 0, - signal: null, - timedOut: false - }) - : realRunProcess(spec) - ) - mockPtySpawn.mockImplementationOnce(() => { - throw new Error(THROWN_LOAD_ERROR) - }) - return dispatcher.callRequest('pty.spawn', {}).then( - () => null, - (error: Error & { data?: unknown }) => error - ) - } - - it('diagnoses a relay installed without node-pty instead of asking for a reconnect (#20386)', async () => { - // Relies on no node-pty beside the relay source — what the no-toolchain deploy leaves. Absence - // is observed on the owning host, so it is a diagnosis (docs/reference/ssh-execution-boundary.md). - expect(typeof process.resourcesPath, 'packaged node-pty lookup must be off').not.toBe('string') - expect( - existsSync(join(__dirname, 'node_modules', 'node-pty')), - 'a node-pty beside src/relay would turn this into the installed-but-unbuilt case' - ).toBe(false) - // The checkout's own node_modules holds a node-pty an SSH host's relay dir never has. - vi.spyOn(nodePtyBindingSurvey, 'resolveNodePtyInstallDir').mockReturnValue(null) - - const rejection = await failSpawnWithToolchainProbe('HAVE python3\nPKG apt-get\n') - const message = rejection?.message ?? '' - - // #17830: the load error the relay was handed still travels with the rejection. - // No compiler means no rebuild, so the client must not auto-reconnect on this one. - expect(rejection?.data).toMatchObject({ - reason: 'toolchain_missing', - repairable: false, - rawError: THROWN_LOAD_ERROR - }) - expect(message).not.toContain('could not establish why') - expect(message).toContain('node-pty is not installed at') - expect(message).toContain('sudo apt-get install -y build-essential python3') - // Every message still names the host, for the bug report. - expect(message).toMatch(/Host: linux\/\w+, .*Node v[\d.]+ \(ABI \d+\)/) - }) - - it('diagnoses the node-pty an ancestor node_modules supplied, not the absent one beside the relay', async () => { - const ancestorInstall = join(mkdtempSync(join(tmpdir(), 'orca-node-pty-')), 'node-pty') - mkdirSync(ancestorInstall) - vi.spyOn(nodePtyBindingSurvey, 'resolveNodePtyInstallDir').mockReturnValue(ancestorInstall) - - try { - const message = - (await failSpawnWithToolchainProbe('HAVE make\nHAVE g++\nHAVE python3\nPKG apt-get\n')) - ?.message ?? '' - - expect(message).not.toContain('node-pty is not installed at') - expect(message).toContain(`under ${ancestorInstall}`) - } finally { - rmSync(dirname(ancestorInstall), { recursive: true, force: true }) - } - }) - - it('preserves unrelated node-pty spawn failures', async () => { - mockPtySpawn.mockImplementationOnce(() => { - throw new Error('File not found: missing-shell.exe') - }) - - await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( - 'File not found: missing-shell.exe' - ) - }) - - it('atomically caps concurrent PTY spawn admission', async () => { - const results = await Promise.allSettled( - Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () => - dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - ) - ) - - expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength( - MAX_RELAY_PTY_SESSIONS - ) - expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1) - expect(results.find(({ status }) => status === 'rejected')).toMatchObject({ - reason: expect.objectContaining({ message: 'Maximum number of PTY sessions reached (50)' }) - }) - expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS) - expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) - }) - - it('spawns a PTY without post-Node-18 array copy methods', async () => { - const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed') - Reflect.deleteProperty(Array.prototype, 'toReversed') - try { - await expect(dispatcher.callRequest('pty.spawn', {})).resolves.toMatchObject({ - id: testPtyId(1) - }) - expect(handler.activePtyCount).toBe(1) - } finally { - if (descriptor) { - Object.defineProperty(Array.prototype, 'toReversed', descriptor) - } - } - }) - - it('increments PTY ids on each spawn', async () => { - const r1 = await dispatcher.callRequest('pty.spawn', {}) - const r2 = await dispatcher.callRequest('pty.spawn', {}) - expect((r1 as { id: string }).id).toBe(testPtyId(1)) - expect((r2 as { id: string }).id).toBe(testPtyId(2)) - }) - - it('does not remint a PTY id across handler lifetimes', async () => { - const first = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } - await handler.dispose({ waitForPhysicalExit: false }) - dispatcher = createMockDispatcher() - handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) - - const second = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } - - expect(first.id).toMatch(/^pty2:[^:]+:1$/) - expect(second.id).toMatch(/^pty2:[^:]+:1$/) - expect(second.id).not.toBe(first.id) - }) - - it('escapes the mint epoch so it cannot forge the id separators', async () => { - await handler.dispose({ waitForPhysicalExit: false }) - dispatcher = createMockDispatcher() - // Why: the epoch is constructor-supplied. Encoding is what keeps a minted id - // exactly three fields, so it can never be read as another epoch/sequence and - // can never smuggle the `@@` that app-side SSH id routing splits on. - handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, 'a:b@@c:9') - - const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } - - const [prefix, epoch, sequence, ...extra] = spawned.id.split(':') - expect(extra).toEqual([]) - expect(prefix).toBe('pty2') - expect(decodeURIComponent(epoch)).toBe('a:b@@c:9') - expect(sequence).toBe('1') - expect(spawned.id).not.toContain('@@') - }) - - it('revives a legacy id and advances the legacy sequence', async () => { - const state = JSON.stringify([ - { id: 'pty-7', pid: process.pid, cols: 80, rows: 24, cwd: process.cwd() } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const serialized = (await dispatcher.callRequest('pty.serialize', { - ids: ['pty-7'] - })) as string - const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } - - expect(JSON.parse(serialized)).toMatchObject([{ id: 'pty-7' }]) - expect(spawned.id).toBe(testPtyId(8)) - }) - - it('does not advance its sequence from a revived foreign mint epoch', async () => { - const foreignId = 'pty2:previous-mint-epoch:40' - const state = JSON.stringify([ - { id: foreignId, pid: process.pid, cols: 80, rows: 24, cwd: process.cwd() } - ]) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const serialized = (await dispatcher.callRequest('pty.serialize', { - ids: [foreignId] - })) as string - const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } - - expect(JSON.parse(serialized)).toMatchObject([{ id: foreignId }]) - expect(spawned.id).toBe(testPtyId(1)) - }) - - it('admits default-cwd spawns through the worktree removal coordinator', async () => { - const finishCreation = vi.fn() - const beginWorktreePtySpawn = vi.fn((_operationPath: string) => finishCreation) - handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) - - await dispatcher.callRequest('pty.spawn', {}) - - expect(beginWorktreePtySpawn).toHaveBeenCalledWith(expect.any(String)) - expect(beginWorktreePtySpawn.mock.calls[0][0]).not.toBe('') - expect(finishCreation).toHaveBeenCalledTimes(1) - }) - - // requireRelaySpawnCwd strips the `::workspace:` instance suffix to get the real folder - // path, so a fence keyed on the unstripped id would guard a directory no spawn ever uses -- - // exactly what routing both through one resolver is supposed to make impossible. - it('fences a folder-workspace instance id on the directory the spawn will use', async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), 'orca-relay-fence-')) - try { - const finishCreation = vi.fn() - const beginWorktreePtySpawn = vi.fn((_operationPath: string) => finishCreation) - handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) - - await dispatcher.callRequest('pty.spawn', { - worktreeId: `repo-1::${workspaceRoot}::workspace:b1706d92-9d05-4932-8360-01e00b54305a` - }) - - const fencedPaths = beginWorktreePtySpawn.mock.calls.map((call) => call[0]) - expect(fencedPaths).toContain(workspaceRoot) - expect(fencedPaths.some((fenced) => fenced.includes('::workspace:'))).toBe(false) - } finally { - rmSync(workspaceRoot, { recursive: true, force: true }) - } - }) - - it('fences both sibling worktree identity and removing cwd with rollback', async () => { - const finishSiblingAdmission = vi.fn() - const beginWorktreePtySpawn = vi.fn((operationPath: string) => { - if (operationPath === '/repo/removing/nested') { - throw new Error('Remote worktree deletion already in progress') - } - return finishSiblingAdmission - }) - handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) - - await expect( - dispatcher.callRequest('pty.spawn', { - cwd: '/repo/removing/nested', - env: { ORCA_WORKTREE_ID: 'repo-id::/repo/sibling' } - }) - ).rejects.toThrow('Remote worktree deletion already in progress') - - expect(beginWorktreePtySpawn.mock.calls.map(([operationPath]) => operationPath)).toEqual([ - '/repo/sibling', - '/repo/removing/nested' - ]) - expect(finishSiblingAdmission).toHaveBeenCalledOnce() - expect(mockPtySpawn).not.toHaveBeenCalled() - }) - - describe('onPtyPoolEmpty', () => { - it('fires once when natural exit drains the last PTY, never while one remains', async () => { - const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCallbacks.push(cb) - }) - }) - const poolEmpty = vi.fn() - handler.onPtyPoolEmpty(poolEmpty) - - await spawnPty() - await spawnPty() - expect(onExitCallbacks).toHaveLength(2) - - onExitCallbacks[0]({ exitCode: 0 }) - expect(handler.activePtyCount).toBe(1) - expect(poolEmpty).not.toHaveBeenCalled() - - onExitCallbacks[1]({ exitCode: 0 }) - expect(handler.activePtyCount).toBe(0) - expect(poolEmpty).toHaveBeenCalledTimes(1) - }) - - it('fires when the dead-shell reap inside attach removes the last PTY', async () => { - mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) - const poolEmpty = vi.fn() - handler.onPtyPoolEmpty(poolEmpty) - await spawnPty() - - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) - try { - await expect( - attachPty({ id: testPtyId(1), suppressReplayNotification: true }) - ).rejects.toThrow(`PTY "${testPtyId(1)}" not found`) - } finally { - aliveSpy.mockRestore() - } - - expect(handler.activePtyCount).toBe(0) - expect(poolEmpty).toHaveBeenCalledTimes(1) - }) - - it('fires when dispose-for-shutdown removes the last PTY', async () => { - mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) - const poolEmpty = vi.fn() - handler.onPtyPoolEmpty(poolEmpty) - await spawnPty() - await spawnPty() - - await handler.dispose({ waitForPhysicalExit: false }) - - expect(handler.activePtyCount).toBe(0) - expect(poolEmpty).toHaveBeenCalledTimes(1) - }) - - it('fires when a spawn fails before the PTY ever reaches the pool', async () => { - // Why: removePty can only announce a PTY it stored, so a creation that dies mid-flight - // would otherwise leave the relay believing it is still non-idle forever. - mockPtySpawn.mockImplementation(() => { - throw new Error('posix_spawnp failed') - }) - const poolEmpty = vi.fn() - handler.onPtyPoolEmpty(poolEmpty) - - await expect(spawnPty()).rejects.toThrow() - - expect(handler.activePtyCount).toBe(0) - expect(handler.pendingPtyCreationCount).toBe(0) - expect(poolEmpty).toHaveBeenCalledTimes(1) - }) - - it('stays silent when a failing creation settles while another is still admitted', async () => { - let spawnCall = 0 - mockPtySpawn.mockImplementation(() => { - spawnCall += 1 - if (spawnCall === 1) { - throw new Error('posix_spawnp failed') - } - return { ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() } - }) - const poolEmpty = vi.fn() - handler.onPtyPoolEmpty(poolEmpty) - - // Both admissions land before either creation resolves, so the failing one must not - // announce an empty pool while the surviving one still owns a shell. - const failing = spawnPty() - const succeeding = spawnPty() - expect(handler.pendingPtyCreationCount).toBe(2) - - await expect(failing).rejects.toThrow() - await succeeding - - expect(handler.activePtyCount).toBe(1) - expect(poolEmpty).not.toHaveBeenCalled() - }) - - it('stops notifying after the returned unsubscribe runs', async () => { - const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCallbacks.push(cb) - }) - }) - const poolEmpty = vi.fn() - const unsubscribe = handler.onPtyPoolEmpty(poolEmpty) - - await spawnPty() - onExitCallbacks[0]({ exitCode: 0 }) - expect(poolEmpty).toHaveBeenCalledTimes(1) - - unsubscribe() - await spawnPty() - onExitCallbacks[1]({ exitCode: 0 }) - expect(handler.activePtyCount).toBe(0) - expect(poolEmpty).toHaveBeenCalledTimes(1) - }) - }) - - describe('onPtyPoolActive', () => { - it('fires at admission, while the pool is still empty', async () => { - mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) - const poolCounts: number[] = [] - handler.onPtyPoolActive(() => { - poolCounts.push(handler.activePtyCount) - }) - - const pending = spawnPty() - // The relay must learn it is non-idle here, not after the creation resolves. - expect(poolCounts).toEqual([0]) - - await pending - expect(poolCounts).toEqual([0, 1]) - }) - - it('fires for a revived PTY whose creation was admitted before the pool was empty', async () => { - await spawnPty({ cols: 80, rows: 24, cwd: '/tmp' }) - const state = (await dispatcher.callRequest('pty.serialize', { - ids: [testPtyId(1)] - })) as string - await handler.dispose({ waitForPhysicalExit: false }) - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) - const poolActive = vi.fn() - handler.onPtyPoolActive(poolActive) - - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - expect(handler.activePtyCount).toBe(1) - expect(poolActive).toHaveBeenCalled() - }) - - it('stops notifying after the returned unsubscribe runs', async () => { - mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) - const poolActive = vi.fn() - const unsubscribe = handler.onPtyPoolActive(poolActive) - - await spawnPty() - const callsWhileSubscribed = poolActive.mock.calls.length - expect(callsWhileSubscribed).toBeGreaterThan(0) - - unsubscribe() - await spawnPty() - expect(poolActive).toHaveBeenCalledTimes(callsWhileSubscribed) - }) - }) - - it('counts a spawn admitted but not yet pooled as a pending creation', async () => { - mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) - expect(handler.pendingPtyCreationCount).toBe(0) - - const pending = spawnPty() - // The shell is already owned even though activePtyCount still reads zero. - expect(handler.activePtyCount).toBe(0) - expect(handler.pendingPtyCreationCount).toBe(1) - - await pending - expect(handler.activePtyCount).toBe(1) - expect(handler.pendingPtyCreationCount).toBe(0) - }) -}) diff --git a/src/relay/pty-handler-spawn-cwd.test.ts b/src/relay/pty-handler-spawn-cwd.test.ts deleted file mode 100644 index fd2c64849e4..00000000000 --- a/src/relay/pty-handler-spawn-cwd.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -function spawnCwd(callIndex = 0): string { - return (mockPtySpawn.mock.calls[callIndex][2] as { cwd: string }).cwd -} - -describe('relay pty spawn cwd (#15296)', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let root: string - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - root = mkdtempSync(join(tmpdir(), 'orca-relay-cwd-')) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - rmSync(root, { recursive: true, force: true }) - }) - - it('spawns a folder workspace in ORCA_WORKSPACE_ROOT instead of the host default', async () => { - // Why: `folder:` carries no path, so the worktree-id split yields nothing and the - // configured root — delivered in the same env — was silently replaced by $HOME. - const workspaceRoot = join(root, 'workspace') - mkdirSync(workspaceRoot) - const workspaceId = 'folder:b1706d92-9d05-4932-8360-01e00b54305a' - - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { - ORCA_WORKSPACE_ID: workspaceId, - ORCA_WORKTREE_ID: workspaceId, - ORCA_WORKSPACE_ROOT: workspaceRoot - } - }) - - expect(spawnCwd()).toBe(workspaceRoot) - expect(spawnCwd()).not.toBe(homedir()) - }) - - it('refuses to launch an agent when the named workspace root is not on this host', async () => { - const workspaceId = 'folder:b1706d92-9d05-4932-8360-01e00b54305a' - - await expect( - dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - launchAgent: 'claude', - env: { - ORCA_WORKSPACE_ID: workspaceId, - ORCA_WORKTREE_ID: workspaceId, - ORCA_WORKSPACE_ROOT: join(root, 'gone') - } - }) - ).rejects.toThrow(/Cannot determine the working directory/) - expect(mockPtySpawn).not.toHaveBeenCalled() - }) - - it('refuses to launch an agent for a folder workspace that carries no root at all', async () => { - await expect( - dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - launchAgent: 'claude', - env: { ORCA_WORKTREE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a' } - }) - ).rejects.toThrow(/Cannot determine the working directory/) - expect(mockPtySpawn).not.toHaveBeenCalled() - }) - - it('falls back to the worktree path carried by the worktree id', async () => { - const worktreePath = join(root, 'checkout') - mkdirSync(worktreePath) - - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - worktreeId: `repo-1::${worktreePath}` - }) - - expect(spawnCwd()).toBe(worktreePath) - }) - - it('keeps an explicitly requested cwd verbatim', async () => { - const workspaceRoot = join(root, 'workspace') - const requested = join(root, 'workspace', 'sub') - mkdirSync(requested, { recursive: true }) - - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - cwd: requested, - env: { ORCA_WORKTREE_ID: 'folder:abc', ORCA_WORKSPACE_ROOT: workspaceRoot } - }) - - expect(spawnCwd()).toBe(requested) - }) - - it('still uses the host default for a terminal that names no workspace', async () => { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - - expect(spawnCwd()).toBe(process.env.HOME || homedir()) - }) - - it('does not refuse a plain shell whose workspace root is missing', async () => { - const missing = join(root, 'gone') - - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { ORCA_WORKTREE_ID: 'folder:abc', ORCA_WORKSPACE_ROOT: missing } - }) - - expect(spawnCwd()).toBe(process.env.HOME || homedir()) - }) -}) - -describe('relay pty spawn cwd when the relay is not the execution host', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let root: string - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - root = mkdtempSync(join(tmpdir(), 'orca-relay-wsl-cwd-')) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - rmSync(root, { recursive: true, force: true }) - }) - - // The relay supports WSL shells, and relayHostDirectoryExists stats the relay's own filesystem. - // A guest path never stats on a Windows relay, so refusing there would fail an agent launch the - // launch wrapper would have cd'd into fine -- the same host pair the worktree branch already - // treats as a miss rather than a refusal. - it('does not refuse an agent whose folder-workspace root lives in the WSL guest', async () => { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - launchAgent: 'claude', - shellOverride: 'wsl.exe', - env: { - ORCA_WORKSPACE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', - ORCA_WORKTREE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', - ORCA_WORKSPACE_ROOT: '/home/u/guest-only-project' - } - }) - - expect(mockPtySpawn).toHaveBeenCalled() - }) - - it('still refuses when the same spawn runs on the relay filesystem itself', async () => { - await expect( - dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - launchAgent: 'claude', - shellOverride: 'powershell.exe', - env: { - ORCA_WORKSPACE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', - ORCA_WORKTREE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', - ORCA_WORKSPACE_ROOT: join(root, 'gone') - } - }) - ).rejects.toThrow(/Cannot determine the working directory/) - expect(mockPtySpawn).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts deleted file mode 100644 index 659d9fbc627..00000000000 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ /dev/null @@ -1,916 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' -import { - resolveSetupAgentSequenceLaunchCommand, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV -} from '../shared/setup-agent-sequencing' -import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' -import { fishHistorySessionName, relayFishHistorySessionName } from '../main/fish-history-session' -import { hashWorktreeId } from '../main/terminal-history-id' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createMockDispatcher, - createTestPtyHandler, - testPtyId, - endPtyHandlerTest -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) -const PTY_2 = testPtyId(2) - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it("does not forward Orca's own NODE_ENV into the spawned shell", async () => { - // Why: NODE_ENV in the relay host process is a build-mode flag, not the - // user's; leaking it breaks `next build` and Vitest in the terminal. - const previous = process.env.NODE_ENV - process.env.NODE_ENV = 'development' - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - } finally { - if (previous === undefined) { - delete process.env.NODE_ENV - } else { - process.env.NODE_ENV = previous - } - } - - const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(spawnOptions.env.NODE_ENV).toBeUndefined() - const expectedEnv = { PATH: process.env.PATH ?? '' } - stripLegacyTerminalShimEnv(expectedEnv, process.platform) - expect(spawnOptions.env.PATH).toBe(expectedEnv.PATH) - }) - - describe('half-activated conda env (#14195)', () => { - const CONDA_KEYS = [ - 'CONDA_SHLVL', - 'CONDA_PREFIX', - 'CONDA_DEFAULT_ENV', - 'CONDA_PROMPT_MODIFIER', - 'CONDA_EXE' - ] as const - - const withRelayCondaEnv = async ( - relayEnv: Partial>, - spawnParams: Record - ): Promise> => { - const saved = Object.fromEntries(CONDA_KEYS.map((key) => [key, process.env[key]])) - try { - for (const key of CONDA_KEYS) { - delete process.env[key] - } - Object.assign(process.env, relayEnv) - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, ...spawnParams }) - } finally { - for (const key of CONDA_KEYS) { - const value = saved[key] - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - } - return (mockPtySpawn.mock.calls.at(-1)![2] as { env: Record }).env - } - - it('drops the sentinel the remote relay inherited without a prefix', async () => { - // Why the relay owns this: the execution host composes its own env, so a - // remote relay launched from a half-activated login shell would otherwise - // hand the broken pair to every SSH terminal. - const env = await withRelayCondaEnv( - { - CONDA_SHLVL: '1', - CONDA_DEFAULT_ENV: 'base', - CONDA_PROMPT_MODIFIER: '(base) ', - CONDA_EXE: '/opt/miniconda3/bin/conda' - }, - {} - ) - - expect(env.CONDA_SHLVL).toBeUndefined() - expect(env.CONDA_DEFAULT_ENV).toBeUndefined() - expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined() - expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda') - }) - - it('keeps activation state a client explicitly repaired', async () => { - const env = await withRelayCondaEnv( - { CONDA_SHLVL: '1', CONDA_DEFAULT_ENV: 'base' }, - { env: { CONDA_PREFIX: '/opt/miniconda3' } } - ) - - expect(env.CONDA_PREFIX).toBe('/opt/miniconda3') - expect(env.CONDA_SHLVL).toBe('1') - expect(env.CONDA_DEFAULT_ENV).toBe('base') - }) - - it('drops the sentinel when the client deletes CONDA_PREFIX', async () => { - // Why: the relay's other inherited-env scrubbers run BEFORE envToDelete, - // so anchoring the coherence pass beside them would re-create the crash. - const env = await withRelayCondaEnv( - { CONDA_SHLVL: '1', CONDA_PREFIX: '/opt/miniconda3', CONDA_DEFAULT_ENV: 'base' }, - { envToDelete: ['CONDA_PREFIX'] } - ) - - expect(env.CONDA_PREFIX).toBeUndefined() - expect(env.CONDA_SHLVL).toBeUndefined() - expect(env.CONDA_DEFAULT_ENV).toBeUndefined() - }) - }) - - it('does not inherit legacy attribution state from the relay process', async () => { - const keys = ['ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_ATTRIBUTION_SHIM_DIR', 'PATH'] as const - const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) - process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1' - process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix' - process.env.PATH = '/tmp/orca-terminal-attribution/posix:/usr/bin' - - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - const spawnedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as { - env: Record - } - expect(spawnedEnv.env.PATH).toBe('/usr/bin') - expect(spawnedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() - expect(spawnedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() - - const state = (await dispatcher.callRequest('pty.serialize', { - ids: [PTY_1] - })) as string - await handler.dispose({ waitForPhysicalExit: false }) - mockPtySpawn.mockClear() - dispatcher = createMockDispatcher() - handler = createTestPtyHandler(dispatcher) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) - try { - await dispatcher.callRequest('pty.revive', { state }) - } finally { - killSpy.mockRestore() - } - - const revivedEnv = mockPtySpawn.mock.calls.at(-1)?.[2] as { - env: Record - } - expect(revivedEnv.env.PATH).toBe('/usr/bin') - expect(revivedEnv.env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() - expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() - } finally { - for (const [key, value] of Object.entries(saved)) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - } - }) - - it('keeps a renderer-supplied NODE_ENV for the spawned shell', async () => { - // Why: only the ambient value is stripped; an explicit request still wins. - const previous = process.env.NODE_ENV - process.env.NODE_ENV = 'development' - try { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { NODE_ENV: 'production' } - }) - } finally { - if (previous === undefined) { - delete process.env.NODE_ENV - } else { - process.env.NODE_ENV = previous - } - } - - const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(spawnOptions.env.NODE_ENV).toBe('production') - }) - - describe('history isolation off', () => { - // Why isolation OFF: injectRelayFishHistoryEnv runs only for a fish pane with - // isolation on, but fish EXPORTS fish_history, so a relay launched from an Orca - // fish pane inherits one on EVERY path — and it names someone else's worktree - // (a desktop-minted name names a directory that does not exist here at all). - it.each([ - [ - 'a relay-minted session', - relayFishHistorySessionName(hashWorktreeId('r::/other')), - undefined - ], - ['a desktop-minted session', fishHistorySessionName(hashWorktreeId('r::/other')), undefined], - ['a user value', 'mine', 'mine'] - ])('%s inherited from the relay process env', async (_kind, inherited, expected) => { - const previous = process.env.fish_history - process.env.fish_history = inherited - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - } finally { - if (previous === undefined) { - delete process.env.fish_history - } else { - process.env.fish_history = previous - } - } - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.fish_history).toBe(expected) - }) - - it.each([ - [ - 'a relay-minted path', - `${process.env.HOME ?? ''}/.orca-remote/terminal-history/aabbccddeeff0011-zsh_history`, - undefined - ], - [ - 'a desktop-minted path', - '/fake/userData/terminal-history/aabbccddeeff0011/zsh_history', - undefined - ], - ['a user value', '/home/me/.zsh_history', '/home/me/.zsh_history'] - ])( - '%s inherited as HISTFILE from the relay process env', - async (_kind, inherited, expected) => { - const previous = process.env.HISTFILE - process.env.HISTFILE = inherited - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - } finally { - if (previous === undefined) { - delete process.env.HISTFILE - } else { - process.env.HISTFILE = previous - } - } - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.HISTFILE).toBe(expected) - } - ) - - // Why unconditionally, not only with isolation on: injectRelayHistoryEnv is - // what normally mints (and first clears) ORCA_HISTFILE, and it runs only - // with isolation on. An inherited one — the relay can be launched from an - // Orca pane — would otherwise reach the remote wrapper on the disabled and - // revive paths, re-exporting another worktree's history path (#11146) and - // wrapping a zsh pane nothing asked to wrap. - it.each([ - [ - 'a relay-minted path', - `${process.env.HOME ?? ''}/.orca-remote/terminal-history/aabbccddeeff0011-zsh_history` - ], - ['a desktop-minted path', '/fake/userData/terminal-history/aabbccddeeff0011/zsh_history'], - ['a user value', '/home/me/.zsh_history'] - ])( - 'drops %s inherited as ORCA_HISTFILE from the relay process env', - async (_kind, inherited) => { - const previous = process.env.ORCA_HISTFILE - process.env.ORCA_HISTFILE = inherited - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - } finally { - if (previous === undefined) { - delete process.env.ORCA_HISTFILE - } else { - process.env.ORCA_HISTFILE = previous - } - } - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.ORCA_HISTFILE).toBeUndefined() - } - ) - - it.each(['process', 'client'])( - 'drops an ORCA_CODEX_LAUNCH_PREFLIGHT from the relay %s env', - async (source) => { - const inherited = '/opt/orca/bin/orca' - const previous = process.env.ORCA_CODEX_LAUNCH_PREFLIGHT - if (source === 'process') { - process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = inherited - } - try { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - ...(source === 'client' ? { env: { ORCA_CODEX_LAUNCH_PREFLIGHT: inherited } } : {}) - }) - } finally { - if (previous === undefined) { - delete process.env.ORCA_CODEX_LAUNCH_PREFLIGHT - } else { - process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = previous - } - } - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() - } - ) - - it('keeps the Codex server opt-out the client asked for', async () => { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { ORCA_CODEX_ISOLATE: '0' } - }) - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.ORCA_CODEX_ISOLATE).toBe('0') - }) - - it('drops an ORCA_HISTFILE handed over in the client env', async () => { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { ORCA_HISTFILE: '/fake/userData/terminal-history/aabbccddeeff0011/zsh_history' } - }) - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.ORCA_HISTFILE).toBeUndefined() - }) - - it('drops a desktop-minted session handed over in the client env', async () => { - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { fish_history: fishHistorySessionName(hashWorktreeId('r::/other')) } - }) - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.fish_history).toBeUndefined() - }) - }) - - describe('history isolation for a Windows relay launching WSL', () => { - const wslWorktreeId = 'r::/remote/wsl-worktree' - const wslHistoryFile = join( - homedir(), - '.orca-remote', - 'terminal-history', - `${hashWorktreeId(wslWorktreeId)}-bash_history` - ) - let previousPlatform: PropertyDescriptor | undefined - - beforeEach(() => { - previousPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - }) - - afterEach(() => { - if (previousPlatform) { - Object.defineProperty(process, 'platform', previousPlatform) - } - rmSync(wslHistoryFile, { force: true }) - }) - - const spawnWslPane = (): Promise => - dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - shellOverride: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu', - worktreeId: wslWorktreeId, - historyIsolationEnabled: true - }) - - it('scopes HISTFILE past the wsl.exe wrapper and carries it over WSLENV', async () => { - await spawnWslPane() - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.HISTFILE?.endsWith(`${hashWorktreeId(wslWorktreeId)}-bash_history`)).toBe( - true - ) - expect(spawnEnv.WSLENV?.split(':')).toContain('HISTFILE') - }) - - // The injected file lives on the relay host, so the existing host-side - // unlink is the deletion counterpart — no distro-scoped root is involved. - it('deletes the injected file through the ordinary relay history deletion', async () => { - await spawnWslPane() - expect(existsSync(wslHistoryFile)).toBe(true) - - await dispatcher.callRequest('pty.deleteWorktreeHistory', { worktreeId: wslWorktreeId }) - - expect(existsSync(wslHistoryFile)).toBe(false) - }) - - // Guest fish keeps its history inside the distro, where relay deletion cannot - // reach it, so wsl.exe panes intentionally stay on shared fish history. - it('does not mint a fish session for a WSL pane', async () => { - await spawnWslPane() - - const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record - expect(spawnEnv.fish_history).toBeUndefined() - }) - }) - - it('guards SSH agent terminals after merging the relay inherited Git config', async () => { - const gitConfigKeys = [ - 'GIT_CONFIG_COUNT', - 'GIT_CONFIG_KEY_0', - 'GIT_CONFIG_VALUE_0', - 'GIT_CONFIG_KEY_1', - 'GIT_CONFIG_VALUE_1', - 'GIT_CONFIG_KEY_2', - 'GIT_CONFIG_VALUE_2' - ] as const - const saved = Object.fromEntries(gitConfigKeys.map((key) => [key, process.env[key]])) - process.env.GIT_CONFIG_COUNT = '3' - process.env.GIT_CONFIG_KEY_0 = 'core.quotePath' - process.env.GIT_CONFIG_VALUE_0 = 'false' - process.env.GIT_CONFIG_KEY_1 = 'base.one' - process.env.GIT_CONFIG_VALUE_1 = 'one' - process.env.GIT_CONFIG_KEY_2 = 'base.two' - process.env.GIT_CONFIG_VALUE_2 = 'two' - - try { - await dispatcher.callRequest('pty.spawn', { - command: 'claude', - env: { - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'http.proxy', - GIT_CONFIG_VALUE_0: 'http://proxy.invalid' - } - }) - - const spawnEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0') - expect(spawnEnv.GCM_INTERACTIVE).toBe('never') - expect(spawnEnv.GIT_CONFIG_COUNT).toBe('3') - expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('http.proxy') - expect(spawnEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive') - expect(spawnEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt') - expect(spawnEnv.GIT_CONFIG_KEY_3).toBeUndefined() - } finally { - for (const key of gitConfigKeys) { - if (saved[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = saved[key] - } - } - } - }) - - it('guards a trusted SSH agent when its command uses a custom wrapper', async () => { - await dispatcher.callRequest('pty.spawn', { - command: 'cd /repo && custom-agent-wrapper', - launchAgent: 'claude' - }) - - const spawnEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0') - expect(spawnEnv.GCM_INTERACTIVE).toBe('never') - expect(Object.values(spawnEnv)).toContain('credential.interactive') - expect(Object.values(spawnEnv)).toContain('credential.guiPrompt') - }) - - it('leaves an ordinary Windows SSH user terminal unchanged', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - try { - await dispatcher.callRequest('pty.spawn', { - env: { - GIT_TERMINAL_PROMPT: '1', - GCM_INTERACTIVE: 'auto', - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'core.quotePath', - GIT_CONFIG_VALUE_0: 'false' - } - }) - const userEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record - expect(userEnv.GIT_TERMINAL_PROMPT).toBe('1') - expect(userEnv.GCM_INTERACTIVE).toBe('auto') - expect(userEnv.GIT_CONFIG_COUNT).toBe('1') - expect(userEnv.GIT_CONFIG_KEY_0).toBe('core.quotePath') - expect(userEnv.GIT_CONFIG_KEY_1).toBeUndefined() - const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string - expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(false) - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - - it('keeps the image protocol hint consistent after renderer and augmenter overrides', async () => { - handler.addEnvAugmenter(() => ({ ORCA_IMAGE_PROTOCOL: 'sixel' })) - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { ORCA_IMAGE_PROTOCOL: 'none' } - }) - expect(mockPtySpawn.mock.calls[0][2].env.ORCA_IMAGE_PROTOCOL).toBe('kitty') - }) - - it('waits for execution-host environment resolution before spawning', async () => { - const entered = Promise.withResolvers() - const resolved = Promise.withResolvers>() - handler.addEnvAugmenter(() => { - entered.resolve() - return resolved.promise - }) - const spawning = dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - await entered.promise - expect(mockPtySpawn).not.toHaveBeenCalled() - resolved.resolve({ PI_CONFIG_DIR: '.evaluated-profile' }) - await spawning - expect(mockPtySpawn.mock.calls[0]?.[2]?.env.PI_CONFIG_DIR).toBe('.evaluated-profile') - }) - - it('does not spawn when canceled during environment resolution', async () => { - const entered = Promise.withResolvers() - const resolved = Promise.withResolvers>() - handler.addEnvAugmenter(() => { - entered.resolve() - return resolved.promise - }) - const abort = new AbortController() - const spawning = dispatcher.callRequest( - 'pty.spawn', - { cols: 80, rows: 24 }, - { - signal: abort.signal, - isStale: () => abort.signal.aborted - } - ) - const rejected = expect(spawning).rejects.toThrow('client_disconnected') - await entered.promise - abort.abort() - resolved.resolve({ PI_CONFIG_DIR: '.evaluated-profile' }) - await rejected - expect(mockPtySpawn).not.toHaveBeenCalled() - expect(handler.activePtyCount).toBe(0) - }) - - it('disposes a creation already awaiting its execution environment', async () => { - const entered = Promise.withResolvers() - const resolved = Promise.withResolvers>() - handler.addEnvAugmenter(() => { - entered.resolve() - return resolved.promise - }) - const spawning = dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - await entered.promise - const disposal = handler.dispose({ waitForPhysicalExit: false }) - expect(mockPtySpawn).not.toHaveBeenCalled() - resolved.resolve({ PI_CONFIG_DIR: '.evaluated-profile' }) - await spawning - await disposal - expect(mockPtyInstance.kill).toHaveBeenCalled() - expect(handler.activePtyCount).toBe(0) - }) - it('applies env augmenters after process.env and renderer-supplied env (augmenter wins on key conflict)', async () => { - handler.addEnvAugmenter(() => ({ - ORCA_AGENT_HOOK_PORT: '12345', - ORCA_AGENT_HOOK_TOKEN: 'abc-uuid', - // Why: also override a key the renderer supplied below so the test pins - // the documented "augmenter wins on key conflict" invariant — see the - // doc-comment on addEnvAugmenter in pty-handler.ts. - ORCA_PANE_KEY: 'augmenter-wins' - })) - - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { ORCA_PANE_KEY: 'tab-1:0', ORCA_TAB_ID: 'tab-1' } - }) - - expect(mockPtySpawn).toHaveBeenCalled() - const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345') - expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') - // Augmenter override beats the renderer-supplied value: - expect(callArgs.env.ORCA_PANE_KEY).toBe('augmenter-wins') - // Renderer-supplied keys not in augmenter map flow through: - expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1') - }) - - it('mirrors pane identity onto its scrub-safe aliases for a remote spawn', async () => { - // Why the relay and not just the shared helper: a remote pane's env is built here, and an - // agent whose harness drops KEY/TOKEN names (DSH) has nothing to attribute its hooks to. - await dispatcher.callRequest('pty.spawn', { - cols: 80, - rows: 24, - env: { ORCA_PANE_KEY: 'tab-1:0', ORCA_AGENT_LAUNCH_TOKEN: 't' } - }) - - const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(callArgs.env.ORCA_AGENT_PANE).toBe('tab-1:0') - expect(callArgs.env.ORCA_AGENT_LAUNCH).toBe('t') - expect(callArgs.env.ORCA_PANE_KEY).toBe('tab-1:0') - }) - - it("drops an alias the relay's own env carries when the spawn claims no identity", async () => { - // Why: the relay is itself startable from an Orca pane, so inheriting either name would - // attribute this pane's hooks to whichever row started the relay. - const previous = { - pane: process.env.ORCA_PANE_KEY, - launch: process.env.ORCA_AGENT_LAUNCH_TOKEN, - paneAlias: process.env.ORCA_AGENT_PANE - } - process.env.ORCA_PANE_KEY = 'relays-own-pane' - process.env.ORCA_AGENT_LAUNCH_TOKEN = 'relays-own-launch' - process.env.ORCA_AGENT_PANE = 'stale-alias' - try { - await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - } finally { - for (const [key, value] of [ - ['ORCA_PANE_KEY', previous.pane], - ['ORCA_AGENT_LAUNCH_TOKEN', previous.launch], - ['ORCA_AGENT_PANE', previous.paneAlias] - ] as const) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - } - - const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(callArgs.env.ORCA_PANE_KEY).toBeUndefined() - expect(callArgs.env.ORCA_AGENT_PANE).toBeUndefined() - expect(callArgs.env.ORCA_AGENT_LAUNCH).toBeUndefined() - }) - - it('passes PTY and explicit launch identity to env augmenters', async () => { - const seenContexts: { - id: string - paneKey?: string - launchAgent?: string - env: Record - }[] = [] - handler.addEnvAugmenter((ctx) => { - seenContexts.push(ctx) - return { - OVERLAY_ID: ctx.paneKey ?? ctx.id - } - }) - - await dispatcher.callRequest('pty.spawn', { - env: { ORCA_PANE_KEY: 'tab-context:0' }, - launchAgent: 'pi' - }) - await dispatcher.callRequest('pty.spawn', {}) - - const firstEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } - const secondEnv = mockPtySpawn.mock.calls[1][2] as { env: Record } - expect(seenContexts[0]).toMatchObject({ - id: PTY_1, - paneKey: 'tab-context:0', - launchAgent: 'pi', - env: { ORCA_PANE_KEY: 'tab-context:0' } - }) - expect(seenContexts[1]).toMatchObject({ id: PTY_2, paneKey: undefined }) - expect(firstEnv.env.OVERLAY_ID).toBe('tab-context:0') - expect(secondEnv.env.OVERLAY_ID).toBe(PTY_2) - }) - - it('passes process and renderer env to env augmenters before augmenter overrides are applied', async () => { - const oldProcessValue = process.env.OPENCODE_CONFIG_DIR - process.env.OPENCODE_CONFIG_DIR = '/remote/default-opencode' - try { - handler.addEnvAugmenter((ctx) => ({ - SEEN_OPENCODE_CONFIG_DIR: ctx.env.OPENCODE_CONFIG_DIR, - SEEN_PI_CODING_AGENT_DIR: ctx.env.PI_CODING_AGENT_DIR - })) - - await dispatcher.callRequest('pty.spawn', { - env: { - OPENCODE_CONFIG_DIR: '/remote/renderer-opencode', - PI_CODING_AGENT_DIR: '/remote/pi' - } - }) - } finally { - if (oldProcessValue === undefined) { - delete process.env.OPENCODE_CONFIG_DIR - } else { - process.env.OPENCODE_CONFIG_DIR = oldProcessValue - } - } - - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(spawnEnv.name).toBe('xterm-256color') - expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode') - expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi') - }) - - it('applies identity defaults, then deletions, while preserving explicit TERM', async () => { - handler.addEnvAugmenter(() => ({ - TERM: 'augmenter-term', - TERM_PROGRAM: 'augmenter-terminal', - ORCA_STALE_TEST_ENV: '/tmp/augmenter-stale' - })) - - await dispatcher.callRequest('pty.spawn', { - env: { - TERM: 'screen-256color', - TERM_PROGRAM: 'renderer-terminal', - ORCA_STALE_TEST_ENV: '/tmp/renderer-stale' - }, - envToDelete: ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV'] - }) - - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(spawnEnv.name).toBe('screen-256color') - expect(spawnEnv.env.TERM).toBe('screen-256color') - expect(spawnEnv.env.COLORTERM).toBe('truecolor') - expect(spawnEnv.env.FORCE_HYPERLINK).toBe('1') - expect(spawnEnv.env.TERM_PROGRAM).toBeUndefined() - expect(spawnEnv.env.ORCA_STALE_TEST_ENV).toBeUndefined() - }) - - it('replaces an ambient TERM=dumb when no explicit TERM is supplied', async () => { - const previousTerm = process.env.TERM - process.env.TERM = 'dumb' - try { - await dispatcher.callRequest('pty.spawn', {}) - } finally { - if (previousTerm === undefined) { - delete process.env.TERM - } else { - process.env.TERM = previousTerm - } - } - - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(spawnEnv.name).toBe('xterm-256color') - expect(spawnEnv.env.TERM).toBe('xterm-256color') - expect(spawnEnv.env.TERM_PROGRAM).toBe('Orca') - expect(spawnEnv.env.ORCA_IMAGE_PROTOCOL).toBe('kitty') - }) - - it('expands variables in PATH before spawning a Windows relay shell', async () => { - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - try { - await dispatcher.callRequest('pty.spawn', { - env: { - ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local', - PATH: '%orca_path_root%\\agy\\bin;C:\\Windows' - } - }) - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - } - - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(spawnEnv.env.PATH).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows') - }) - - it('uses the safe terminal default when TERM is deleted without a custom value', async () => { - await dispatcher.callRequest('pty.spawn', { - envToDelete: ['TERM'] - }) - - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { - name: string - env: Record - } - expect(spawnEnv.name).toBe('xterm-256color') - expect(spawnEnv.env.TERM).toBe('xterm-256color') - }) - - it('lets relay env augmenters resolve the original sequenced startup command hint', async () => { - handler.addEnvAugmenter((ctx) => ({ - SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? '' - })) - - await dispatcher.callRequest('pty.spawn', { - command: 'powershell wait-wrapper', - env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' } - }) - - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } - expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume') - }) - - it.skipIf(process.platform === 'win32')( - 'wraps bash spawns to restore overlay env after remote startup files', - async () => { - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const oldOrcaPi = process.env.ORCA_PI_CODING_AGENT_DIR - const homeDir = mkdtempSync(join(tmpdir(), 'relay-pty-shell-launch-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - delete process.env.ORCA_PI_CODING_AGENT_DIR - try { - if (!existsSync('/bin/bash')) { - return - } - - handler.addEnvAugmenter(() => ({ - OPENCODE_CONFIG_DIR: '/remote/overlay/opencode', - ORCA_OPENCODE_CONFIG_DIR: '/remote/overlay/opencode', - ORCA_OMP_STATUS_EXTENSION: '/remote/.omp/agent/extensions/orca-agent-status.ts' - })) - - await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir } }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - if (oldOrcaPi === undefined) { - delete process.env.ORCA_PI_CODING_AGENT_DIR - } else { - process.env.ORCA_PI_CODING_AGENT_DIR = oldOrcaPi - } - } - - const shellArgs = mockPtySpawn.mock.calls[0][1] - const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } - const rcfile = join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile') - - expect(shellArgs).toEqual(['--rcfile', rcfile]) - expect(spawnOptions.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/remote/overlay/opencode') - expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() - expect(readFileSync(rcfile, 'utf8')).toContain( - 'export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' - ) - expect(readFileSync(rcfile, 'utf8')).not.toContain('ORCA_PI_CODING_AGENT_DIR') - expect(readFileSync(rcfile, 'utf8')).toContain('command omp --extension') - - rmSync(homeDir, { recursive: true, force: true }) - } - ) -}) diff --git a/src/relay/pty-handler-startup-command-delivery.test.ts b/src/relay/pty-handler-startup-command-delivery.test.ts deleted file mode 100644 index a8d812ed89a..00000000000 --- a/src/relay/pty-handler-startup-command-delivery.test.ts +++ /dev/null @@ -1,744 +0,0 @@ -import './mock-descendant-sweep' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../shared/setup-agent-sequencing' -import * as ptyShellUtils from './pty-shell-utils' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - // Why: attach now proves the backing pid is alive before replaying, so the - // default managed PTY must report a live pid. Reuse the test runner's own - // pid — always alive — so unrelated attach tests are not seen as dead. - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createPtyRequestHelpers, - endPtyHandlerTest, - testPtyId -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const PTY_1 = testPtyId(1) - -describe('PtyHandler', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - - const { spawnPty, attachPty } = createPtyRequestHelpers(() => dispatcher) - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => { - await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' }) - - vi.advanceTimersByTime(50) - - const term = mockPtySpawn.mock.results[0]?.value - expect(term.write).not.toHaveBeenCalled() - }) - - it('submits provider-delivered spawn commands to the relay shell', async () => { - await dispatcher.callRequest('pty.spawn', { - command: 'echo provider-owned', - commandDelivery: 'provider' - }) - - vi.advanceTimersByTime(49) - const term = mockPtySpawn.mock.results[0]?.value - expect(handler.retainedStartupCommandCount).toBe(1) - expect(handler.retainedStartupCommandBytes).toBe('echo provider-owned'.length) - expect(term.write).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - expect(term.write).toHaveBeenCalledWith('echo provider-owned\r') - expect(handler.retainedStartupCommandCount).toBe(0) - }) - - it.skipIf(process.platform === 'win32')( - 'emits shell-ready markers for renderer-delivered startup commands', - async () => { - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-shell-ready-spawn-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'echo renderer-owned', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as - | { env?: Record } - | undefined - expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') - expect(handler.retainedStartupCommandCount).toBe(1) - expect(handler.retainedStartupCommandBytes).toBe(0) - vi.advanceTimersByTime(15_000) - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it.skipIf(process.platform === 'win32')( - 'emits shell-ready markers for plain Codex on a line-editor shell', - async () => { - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-plain-codex-spawn-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - try { - // No prefill flag and no shell-ready hint: the host decides from its own - // shell, because the client cannot see it (#18767). - const reply = await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'codex' - }) - expect(reply).toMatchObject({ shellReadyArmed: true }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as - | { env?: Record } - | undefined - expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') - vi.advanceTimersByTime(15_000) - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it.skipIf(process.platform === 'win32')( - 'leaves plain Codex unwaited on a shell that emits the marker before its reader', - async () => { - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-plain-codex-fish-spawn-')) - - process.env.HOME = homeDir - try { - const reply = await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir, SHELL: '/usr/bin/fish' }, - command: 'codex' - }) - // Why the reply carries it: the client cannot see this shell, and without - // the verdict it waits the full fallback for a marker fish never emits. - expect(reply).toMatchObject({ shellReadyArmed: false }) - } finally { - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as - | { env?: Record } - | undefined - expect(spawnOptions?.env?.ORCA_SHELL_FEATURES ?? '').not.toContain('ready') - } - ) - - it.skipIf(process.platform === 'win32')( - 'emits shell-ready markers for renderer-delivered Codex native prefill commands', - async () => { - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-codex-prefill-spawn-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: "codex --prefill 'linked issue context'" - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as - | { env?: Record } - | undefined - expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') - expect(handler.retainedStartupCommandCount).toBe(1) - } - ) - - it.skipIf(process.platform === 'win32')( - 'enables shell-ready marker env for provider-delivered startup commands', - async () => { - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'echo provider-owned', - commandDelivery: 'provider', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as - | { env?: Record } - | undefined - expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') - } - ) - - it.skipIf(process.platform === 'win32')( - 'uses the sequenced startup command hint for provider shell-ready detection', - async () => { - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - try { - await dispatcher.callRequest('pty.spawn', { - env: { - HOME: homeDir, - [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" - }, - command: 'bash -lc wait-for-setup-wrapper', - commandDelivery: 'provider' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as - | { env?: Record } - | undefined - expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toContain('ready') - } - ) - - it.skipIf(process.platform === 'win32')( - 'waits for the shell-ready marker before provider-delivered startup commands', - async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'echo after-ready', - commandDelivery: 'provider', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - vi.advanceTimersByTime(1499) - expect(term.write).not.toHaveBeenCalled() - - dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ') - vi.advanceTimersByTime(49) - expect(term.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - - expect(term.write).toHaveBeenCalledWith('echo after-ready\r') - expect(handler.retainedStartupCommandCount).toBe(0) - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: 'user@remote $ ' - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'recovers provider delivery when startup exec replaces the relay wrapper', - async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-exec-spawn-')) - const oldShell = process.env.SHELL - process.env.SHELL = '/bin/bash' - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'echo after-exec', - commandDelivery: 'provider', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - rmSync(homeDir, { recursive: true, force: true }) - } - - dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `) - await vi.advanceTimersByTimeAsync(8) - - const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as { - onPromptReady: () => void - } - expect( - mockCreateShellPromptReadinessProbe.mock.results[0]?.value.notifyOutput - ).toHaveBeenCalledWith('\x1b[?2004hremote $ ') - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b[?2004hremote $ ' - }) - promptOptions.onPromptReady() - await vi.advanceTimersByTimeAsync(50) - - expect(term.write).toHaveBeenCalledWith('echo after-exec\r') - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it.skipIf(process.platform === 'win32')( - 'signals renderer delivery when startup exec replaces the relay wrapper', - async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-exec-spawn-')) - const oldShell = process.env.SHELL - process.env.SHELL = '/bin/bash' - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'echo after-exec', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - rmSync(homeDir, { recursive: true, force: true }) - } - - dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b[?2004hremote $ `) - await vi.advanceTimersByTimeAsync(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b[?2004hremote $ ' - }) - - const promptOptions = mockCreateShellPromptReadinessProbe.mock.calls[0]?.[0] as { - onPromptReady: () => void - } - promptOptions.onPromptReady() - await vi.advanceTimersByTimeAsync(8) - - expect(term.write).not.toHaveBeenCalled() - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b]777;orca-shell-ready\x07' - }) - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it.skipIf(process.platform === 'win32')( - 'forwards the supported ready marker to renderer delivery', - async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-ready-spawn-')) - const oldShell = process.env.SHELL - process.env.SHELL = '/bin/bash' - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'echo after-ready', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - rmSync(homeDir, { recursive: true, force: true }) - } - - dataCallback?.( - `\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready\x07remote $ ` - ) - await vi.advanceTimersByTimeAsync(8) - - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b]777;orca-shell-ready\x07remote $ ' - }) - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it.skipIf(process.platform === 'win32')( - 'releases split renderer readiness through one completed path', - async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - const homeDir = mkdtempSync(join(tmpdir(), 'relay-renderer-split-ready-spawn-')) - const oldShell = process.env.SHELL - process.env.SHELL = '/bin/bash' - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'echo after-ready', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - rmSync(homeDir, { recursive: true, force: true }) - } - - dataCallback?.(`\x1b]777;orca-shell-start:${process.pid}\x07\x1b]777;orca-shell-ready`) - dataCallback?.('\x07remote $ ') - await vi.advanceTimersByTimeAsync(8) - - const probe = mockCreateShellPromptReadinessProbe.mock.results[0]?.value - expect(probe.notifyOutput).not.toHaveBeenCalled() - expect(probe.dispose).toHaveBeenCalledOnce() - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b]777;orca-shell-ready\x07remote $ ' - }) - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it.skipIf(process.platform === 'win32')( - 'does not retain renderer readiness state for unsupported shells', - async () => { - const oldShell = process.env.SHELL - process.env.SHELL = '/bin/sh' - try { - await dispatcher.callRequest('pty.spawn', { - command: 'x'.repeat(256 * 1024), - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - } - - const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as - | { env?: Record } - | undefined - expect(spawnOptions?.env?.ORCA_SHELL_FEATURES).toBe('') - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it.skipIf(process.platform === 'win32')( - 'flushes held shell-ready marker bytes when provider delivery falls back', - async () => { - let dataCallback: ((data: string) => void) | undefined - const term = { - ...mockPtyInstance, - onData: vi.fn((cb: (data: string) => void) => { - dataCallback = cb - }), - onExit: vi.fn() - } - mockPtySpawn.mockReturnValue(term) - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-')) - - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - let spawn!: { id: string; incarnationId: string } - try { - spawn = await spawnPty({ - env: { HOME: homeDir }, - command: 'echo fallback', - commandDelivery: 'provider', - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - - dataCallback?.('\x1b]777;orca-shell-ready') - vi.advanceTimersByTime(1500) - - expect(term.write).toHaveBeenCalledWith('echo fallback\r') - vi.advanceTimersByTime(8) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { - id: PTY_1, - data: '\x1b]777;orca-shell-ready' - }) - - const result = await attachPty({ - id: PTY_1, - suppressReplayNotification: true - }) - expect(result).toEqual({ - incarnationId: spawn.incarnationId, - replay: '\x1b]777;orca-shell-ready' - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'releases renderer readiness state when attach reaps a dead shell', - async () => { - const oldShell = process.env.SHELL - const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-dead-shell-ready-spawn-')) - process.env.SHELL = '/bin/bash' - process.env.HOME = homeDir - try { - await dispatcher.callRequest('pty.spawn', { - env: { HOME: homeDir }, - command: 'x'.repeat(256 * 1024), - startupCommandDelivery: 'shell-ready' - }) - } finally { - if (oldShell === undefined) { - delete process.env.SHELL - } else { - process.env.SHELL = oldShell - } - if (oldHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = oldHome - } - rmSync(homeDir, { recursive: true, force: true }) - } - expect(handler.retainedStartupCommandCount).toBe(1) - - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) - try { - await expect(dispatcher.callRequest('pty.attach', { id: PTY_1 })).rejects.toThrow( - `PTY "${PTY_1}" not found` - ) - } finally { - aliveSpy.mockRestore() - } - - expect(handler.retainedStartupCommandCount).toBe(0) - vi.advanceTimersByTime(15_000) - expect(handler.retainedStartupCommandCount).toBe(0) - } - ) - - it('does not submit provider-delivered commands for stale spawn responses', async () => { - const killSpy = vi.fn() - const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } - mockPtySpawn.mockReturnValue(term) - - await dispatcher.callRequest( - 'pty.spawn', - { command: 'echo stale', commandDelivery: 'provider' }, - { isStale: () => mockPtySpawn.mock.calls.length > 0 } - ) - - vi.advanceTimersByTime(50) - expect(term.write).not.toHaveBeenCalled() - expect(handler.retainedStartupCommandCount).toBe(0) - expect(killSpy).toHaveBeenCalledWith('SIGTERM') - }) - - it('releases pending provider-delivered commands on shutdown before delivery', async () => { - let onExitCb: ((evt: { exitCode: number }) => void) | undefined - const killSpy = vi.fn() - const term = { - ...mockPtyInstance, - kill: killSpy, - onData: vi.fn(), - onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { - onExitCb = cb - }) - } - mockPtySpawn.mockReturnValue(term) - - await dispatcher.callRequest('pty.spawn', { - command: 'echo stop-before-run', - commandDelivery: 'provider' - }) - expect(handler.retainedStartupCommandCount).toBe(1) - - const shutdown = dispatcher.callRequest('pty.shutdown', { id: PTY_1, immediate: true }) - onExitCb!({ exitCode: 137 }) - await shutdown - vi.advanceTimersByTime(50) - - expect(handler.retainedStartupCommandCount).toBe(0) - expect(term.write).not.toHaveBeenCalled() - expect(killSpy).toHaveBeenCalledWith('SIGKILL') - }) -}) diff --git a/src/relay/pty-handler-startup-command-staging.test.ts b/src/relay/pty-handler-startup-command-staging.test.ts deleted file mode 100644 index 672aaf4d9e5..00000000000 --- a/src/relay/pty-handler-startup-command-staging.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import './mock-descendant-sweep' -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ - spawn: mockPtySpawn -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import type { PtyHandler } from './pty-handler' -import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -const describePosix = process.platform === 'win32' ? describe.skip : describe - -describePosix('relay startup command staging', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let stagingDir: string - - beforeEach(() => { - stagingDir = mkdtempSync(join(tmpdir(), 'orca-relay-staging-')) - vi.stubEnv('TMPDIR', stagingDir) - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - vi.unstubAllEnvs() - rmSync(stagingDir, { recursive: true, force: true }) - }) - - async function spawn(command: string): Promise { - return await dispatcher.callRequest('pty.spawn', { - command, - commandDelivery: 'provider', - env: { SHELL: '/bin/zsh' } - }) - } - - it('types a short provider-delivered command as is', async () => { - await spawn('echo short') - await vi.advanceTimersByTimeAsync(50) - expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith('echo short\r') - }) - - it('stages a long provider-delivered command and types only the sourcing line', async () => { - const command = `claude '${'x'.repeat(600)}'` - await spawn(command) - const [script] = readdirSync(stagingDir) - const scriptPath = join(stagingDir, script) - expect(readFileSync(scriptPath, 'utf8').split('\n')[1]).toBe(command) - await vi.advanceTimersByTimeAsync(50) - expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith(`. '${scriptPath}'\r`) - }) - - it('deletes a script the shell never sourced when the PTY exits', async () => { - await spawn(`claude '${'x'.repeat(600)}'`) - const scriptPath = join(stagingDir, readdirSync(stagingDir)[0]) - mockPtyInstance.onExit.mock.calls.at(-1)?.[0]?.({ exitCode: 0 }) - expect(existsSync(scriptPath)).toBe(false) - }) - - it('stages nothing for a renderer-delivered command it only holds as a hint', async () => { - await dispatcher.callRequest('pty.spawn', { - command: `claude '${'x'.repeat(600)}'`, - env: { SHELL: '/bin/zsh' } - }) - expect(readdirSync(stagingDir)).toEqual([]) - }) - - it('prints a notice in the terminal when it types a line it could not stage', async () => { - vi.stubEnv('TMPDIR', join(stagingDir, 'missing')) - const command = `claude '${'x'.repeat(600)}'` - await spawn(command) - await vi.advanceTimersByTimeAsync(50) - expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith(`${command}\r`) - const output = dispatcher._notifications - .filter((notification) => notification.method === 'pty.data') - .map((notification) => String(notification.params?.data)) - .join('') - expect(output).toContain('[orca] Could not stage the launch command (ENOENT') - }) -}) diff --git a/src/relay/pty-handler-test-harness.ts b/src/relay/pty-handler-test-harness.ts deleted file mode 100644 index b9259a721a6..00000000000 --- a/src/relay/pty-handler-test-harness.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { vi } from 'vitest' -import type { Mock } from 'vitest' -import * as ptyChildProcessInspection from './pty-child-process-inspection' -import { PtyHandler } from './pty-handler' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -export const TEST_PTY_ID_MINT_EPOCH = 'test-mint-epoch' - -// Why encoded: production escapes the epoch at the mint site. A test epoch with a -// reserved character would otherwise diverge silently across ~40 assertions. -export function testPtyId(sequence: number): string { - return `pty2:${encodeURIComponent(TEST_PTY_ID_MINT_EPOCH)}:${sequence}` -} - -export function createTestPtyHandler(dispatcher: MockDispatcher): PtyHandler { - return new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, TEST_PTY_ID_MINT_EPOCH) -} - -export type TestRequestContext = { - isStale: () => boolean - signal?: AbortSignal -} - -export function createMockDispatcher() { - const requestHandlers = new Map< - string, - (params: Record, context?: TestRequestContext) => Promise - >() - const notificationHandlers = new Map) => void>() - const notifications: { method: string; params?: Record }[] = [] - - const dispatcher = { - onRequest: vi.fn( - ( - method: string, - handler: (params: Record, context?: TestRequestContext) => Promise - ) => { - requestHandlers.set(method, handler) - } - ), - onNotification: vi.fn((method: string, handler: (params: Record) => void) => { - notificationHandlers.set(method, handler) - }), - notify: vi.fn((method: string, params?: Record) => { - notifications.push({ method, params }) - }), - // Helpers for tests - _requestHandlers: requestHandlers, - _notificationHandlers: notificationHandlers, - _notifications: notifications, - async callRequest( - method: string, - params: Record = {}, - context?: TestRequestContext - ) { - const handler = requestHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params, context) - }, - callNotification(method: string, params: Record = {}) { - const handler = notificationHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - handler(params) - } - } - - return dispatcher -} - -export type MockDispatcher = ReturnType - -type MockPtyInstance = { - pid: number - onData: Mock - onExit: Mock - write: Mock - resize: Mock - kill: Mock - clear: Mock - pause: Mock - resume: Mock -} - -export type PtyHandlerTestMocks = { - mockPtySpawn: Mock - mockPtyInstance: MockPtyInstance - mockCreateShellPromptReadinessProbe: Mock -} - -/** Mirrors the shared beforeEach: pin the platform, reset node-pty mocks, build handler + dispatcher. */ -export function beginPtyHandlerTest(mocks: PtyHandlerTestMocks): { - dispatcher: MockDispatcher - handler: PtyHandler - originalPlatform: PropertyDescriptor | undefined -} { - const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = mocks - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - vi.useFakeTimers() - mockPtySpawn.mockReset() - mockPtyInstance.onData.mockReset() - mockPtyInstance.onExit.mockReset() - mockPtyInstance.write.mockReset() - mockPtyInstance.resize.mockReset() - mockPtyInstance.kill.mockReset() - mockPtyInstance.clear.mockReset() - mockPtyInstance.pause.mockReset() - mockPtyInstance.resume.mockReset() - mockCreateShellPromptReadinessProbe.mockReset() - mockCreateShellPromptReadinessProbe.mockReturnValue({ - notifyOutput: vi.fn(), - dispose: vi.fn() - }) - vi.spyOn(ptyChildProcessInspection, 'processHasChildren').mockResolvedValue(false) - - mockPtySpawn.mockReturnValue({ ...mockPtyInstance }) - - const dispatcher = createMockDispatcher() - const handler = createTestPtyHandler(dispatcher) - return { dispatcher, handler, originalPlatform } -} - -export async function endPtyHandlerTest( - handler: PtyHandler, - originalPlatform: PropertyDescriptor | undefined -): Promise { - const cleanup = handler.dispose({ waitForPhysicalExit: false }) - await vi.runAllTimersAsync() - await cleanup.catch(() => {}) - vi.useRealTimers() - vi.restoreAllMocks() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -} - -/** Reads the dispatcher lazily so tests that swap in a fresh dispatcher keep working. */ -export function createPtyRequestHelpers(getDispatcher: () => MockDispatcher) { - async function spawnPty( - params: Record = {} - ): Promise<{ id: string; incarnationId: string }> { - return (await getDispatcher().callRequest('pty.spawn', params)) as { - id: string - incarnationId: string - } - } - - async function attachPty( - params: Record - ): Promise<{ incarnationId: string; replay?: string }> { - return (await getDispatcher().callRequest('pty.attach', params)) as { - incarnationId: string - replay?: string - } - } - - return { spawnPty, attachPty } -} diff --git a/src/relay/pty-handler-windows-child-process-evidence.test.ts b/src/relay/pty-handler-windows-child-process-evidence.test.ts deleted file mode 100644 index 75f7382df8d..00000000000 --- a/src/relay/pty-handler-windows-child-process-evidence.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import './mock-descendant-sweep' -// Regression guard for the Windows SSH child-process answer. The relay used to return a hardcoded -// `false` here, which every close guard reads as "nothing is running in this pane" -- so a Windows -// SSH pane running a build closed with no prompt. The answer now comes from the process table, and -// the one thing it may never do again is fabricate a negative. -// -// The second contract is cost. `pty.inspectProcess` is the polled path (750ms/2000ms per tracked -// pane) and a relay host has no `@vscode/windows-process-tree`, so its table read falls back to a -// 1.36s CIM scan. Polling that would reinstate the fork storm the shared table exists to prevent, -// so only a caller whose answer decides something asks for the scan. -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' - -const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ - mockPtySpawn: vi.fn(), - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtyInstance: { - pid: process.pid, - process: 'xterm-256color', - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -})) - -vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe -})) - -import * as ptyChildProcessInspection from './pty-child-process-inspection' -import type { PtyHandler } from './pty-handler' -import { - beginPtyHandlerTest, - createPtyRequestHelpers, - endPtyHandlerTest -} from './pty-handler-test-harness' -import type { MockDispatcher } from './pty-handler-test-harness' - -type Inspection = { - foregroundProcess: string | null - hasChildProcesses: boolean - childProcessEvidence?: string -} - -describe('PtyHandler Windows child-process evidence', () => { - let dispatcher: MockDispatcher - let handler: PtyHandler - let originalPlatform: PropertyDescriptor | undefined - let inspectChildren: ReturnType - - const { spawnPty } = createPtyRequestHelpers(() => dispatcher) - - /** Spawn under the harness's POSIX platform, then answer as the Windows relay would. */ - async function spawnThenBecomeWindows(): Promise { - const { id } = await spawnPty() - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - return id - } - - async function inspect(params: Record): Promise { - return (await dispatcher.callRequest('pty.inspectProcess', params)) as Inspection - } - - beforeEach(() => { - ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ - mockPtySpawn, - mockPtyInstance, - mockCreateShellPromptReadinessProbe - })) - inspectChildren = vi - .spyOn(ptyChildProcessInspection, 'inspectPtyChildProcesses') - .mockResolvedValue('no-children') - }) - - afterEach(async () => { - await endPtyHandlerTest(handler, originalPlatform) - }) - - it('publishes what the host observed when the caller pays for the scan', async () => { - const id = await spawnThenBecomeWindows() - inspectChildren.mockResolvedValue('children') - - const result = await inspect({ id, scanChildProcesses: true }) - - expect(inspectChildren).toHaveBeenCalledWith(mockPtyInstance.pid) - expect(result.childProcessEvidence).toBe('children') - expect(result.hasChildProcesses).toBe(true) - }) - - it('reports an observed-empty pane as no-children, not merely false', async () => { - const id = await spawnThenBecomeWindows() - inspectChildren.mockResolvedValue('no-children') - - const result = await inspect({ id, scanChildProcesses: true }) - - // Asserted alongside the value so the case fails if the answer stops coming from a real read. - expect(inspectChildren).toHaveBeenCalledWith(mockPtyInstance.pid) - expect(result.childProcessEvidence).toBe('no-children') - expect(result.hasChildProcesses).toBe(false) - }) - - it('keeps the compatibility boolean false when the host could not observe the pane', async () => { - const id = await spawnThenBecomeWindows() - inspectChildren.mockResolvedValue('unverifiable') - - const result = await inspect({ id, scanChildProcesses: true }) - - expect(result.childProcessEvidence).toBe('unverifiable') - // Clients too old to read the verdict also read `true` as "an agent took the PTY, safe to - // type into it", so `unverifiable` must not be promoted to `true` on the shared boolean. - expect(result.hasChildProcesses).toBe(false) - }) - - it('never reads the process table for a poll, and says so instead of guessing', async () => { - const id = await spawnThenBecomeWindows() - - const result = await inspect({ id }) - - expect(inspectChildren).not.toHaveBeenCalled() - expect(result.childProcessEvidence).toBe('unverifiable') - expect(result.hasChildProcesses).toBe(false) - }) -}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts deleted file mode 100644 index 0e6fc386877..00000000000 --- a/src/relay/pty-handler.ts +++ /dev/null @@ -1,3578 +0,0 @@ -import type { TmuxManagedPty } from '../shared/tmux-agent-hook-owner' -/* oxlint-disable max-lines */ -import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchronized-output-scan' -import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-account-environment' -import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' -import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' -import type { ClaudeTerminalEvidence } from '../shared/claude-terminal-interrupt' -import { FreebuffStatusProjection } from './freebuff-status-projection' -import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' -import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' -import { - applyOpenCodePluginSelection, - restoreOpenCodeCapabilities -} from './opencode-plugin-selection' -import { resolveCommandPathForRelay } from '../wsl-guest/preflight-handler' -import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' -import type { IPty } from 'node-pty' -import { killWithDescendantSweep } from '../main/pty-descendant-termination' -import type * as NodePty from 'node-pty' -import { existsSync } from 'node:fs' -import { basename, join } from 'node:path' -import { randomUUID } from 'node:crypto' -import { resolveWindowsGitBashShellPath } from '../main/git-bash' -import { isSupportedWindowsShellOverride } from '../shared/windows-terminal-shell' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { - resolveDefaultShell, - resolveProcessCwd, - getForegroundProcessName, - isProcessAlive, - listShellProfiles -} from './pty-shell-utils' -import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' -import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' -import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' -import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' -import { addWslEnvKeys } from '../shared/wsl-env' -import { - OPENCODE_STARTUP_PROMPT_SHA256_ENV, - OPENCODE_STARTUP_PROMPT_NONCE_ENV, - OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, - OPENCODE_STARTUP_PROMPT_BODY_ENV, - OPENCODE_STARTUP_PROMPT_SHELL_ENV -} from '../shared/opencode-startup-prompt' -import { - ORCA_IMAGE_PROTOCOL_ENV, - ORCA_IMAGE_PROTOCOL_VALUE -} from '../shared/terminal-image-protocol' -import { SHELL_STARTUP_FEATURE_ENV } from '../main/shell-startup-features' -import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' -import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' -import { buildStartupCommandSubmission } from '../shared/startup-command-submission' -import { - discardStagedStartupCommand, - stageStartupCommand, - startupStagingFailureNotice, - type StartupCommandStaging -} from '../shared/startup-command-staging' -import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' -import { - isPathInsideOrEqual, - normalizeRuntimePathForComparison -} from '../shared/cross-platform-path' -import { splitWorktreeIdForFilesystem } from '../shared/worktree/id' -import { - formatUnresolvedRelaySpawnCwdMessage, - relayHostDirectoryExists, - resolveRelaySpawnCwd, - type RelaySpawnCwdResolution -} from './pty-spawn-cwd' -import { PhysicalExitTracker } from '../shared/physical-exit-tracker' -import { PTY_ATTACH_PROVEN_EXITED_MARKER } from '../shared/pty-attach-absence-evidence' -import { SHELL_READY_MARKER_PREFIX } from '../main/shell-ready-marker-scanner' -import { - createShellStartupOutputScanState, - drainShellStartupOutputScanState, - scanShellStartupOutput, - type ShellStartupOutputScanState -} from '../main/shell-startup-output-scanner' -import { - createShellPromptReadinessProbe, - type ShellPromptReadinessProbe -} from '../main/shell-prompt-readiness-probe' -import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' -import { - gitCredentialPromptGuardEnv, - mergeGitConfigEnvProtocol -} from '../shared/git-credential-prompt-env' -import { isTuiAgent } from '../shared/tui-agent-config' -import type { TuiAgent } from '../shared/tui-agent' -import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' -import type { PtyChildProcessVerdict } from '../shared/terminal-process-inspection' -import { terminatePtyJob } from '../main/windows/windows-pty-job' -import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' -import { stripPiProcessOwnerEnv } from '../main/pty/pi-process-owner-env' -import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' -import { dropIncoherentCondaActivationEnv } from '../main/pty/conda-activation-env' -import { dropInheritedOrcaFishHistory } from '../main/fish-history-session' -import { dropInheritedOrcaHistFile } from '../main/worktree-history-file-path' -import { - PTY_STARTUP_INGRESS_VERSION, - PtyStartupIngress, - parsePtyStartupIngressIntent, - type PtyIngressEmission -} from '../shared/pty-startup-ingress' -import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' -import { setPtyOwnerHostColors } from '../shared/pty-owner-color-query-colors' -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { TerminalShellRecoveryBarrier } from '../main/daemon/terminal-shell-recovery-barrier' -import { confirmPtyShellForeground } from '../main/daemon/pty-subprocess/pty-shell-foreground-confirmation' -import { - resolveAgentForegroundProcessesBatch, - resolveRemoteForegroundEvidence, - toForegroundProcessEvidence, - type BatchedForegroundProcessResult -} from '../main/providers/agent-foreground-process' -import type { ProcessTableRow } from '../shared/process-table-snapshot' -import { getStrictProcessTableSnapshotWithAge } from '../shared/process-table-snapshot-reader' -import type { - ForegroundProcessEvidence, - RemoteForegroundEvidence -} from '../shared/foreground-process-evidence' -import { expandWindowsPathEnvironmentVariables } from '../shared/windows-environment-expansion' -import { pruneRetiredPtyIncarnations } from '../shared/retired-pty-incarnations' -import { - agentSessionOwnerBindingsEqual, - ClaimedAgentPtyOwnerRegistry -} from '../shared/claimed-agent-pty-owner' -import type { RelayPtySourceOutput } from './relay-pty-source-output' -import { signalPosixPtyForegroundGroup } from '../main/pty/posix-pty-foreground-group' -import { readPtsName } from '../main/pty/node-pty-pts-name' -import type { RelayPtySourcePublication } from './relay-pty-source-publication' -import type { - PtySourceRecoveryRequest, - PtySourceRecoveryResult -} from '../shared/pty-source-recovery-contract' -import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' -import { - AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION, - AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, - isAgentSessionExecutionClaim, - isAgentSessionSurfaceBinding, - type AgentSessionOwnerBinding -} from '../shared/agent-session-host-authority' -import { readPtySlavePath } from '../shared/pty-slave-line-discipline-echo' -import { chargedPtyRetainedStringBytes } from '../shared/pty-retained-string-memory' -import { - deleteRelayFishHistory, - deleteRelayHistory, - injectRelayFishHistoryEnv, - injectRelayHistoryEnv -} from './terminal-history' -import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' -import { - collectNodePtyUnavailableDiagnosis, - resolveNodePtyInstallDir -} from './node-pty-binding-survey' -import { describeRelayRuntime } from './relay-runtime-identity' -import { relayConptyDllSpawnOptions } from './relay-windows-conpty' -import { - formatNodePtyUnavailableMessage, - toTerminalUnavailableCause -} from './node-pty-unavailable-diagnosis' -import { TERMINAL_UNAVAILABLE_RPC_ERROR_CODE } from '../shared/terminal-unavailable-cause' - -/** - * The shell a spawn will actually launch, resolved the same way `spawnAfterAdmission` resolves it. - * - * Non-throwing on an unsupported override: that override fails the spawn later regardless, and this - * is only asked in order to decide whose filesystem the cwd lives on. - */ -function resolveRelaySpawnShell( - params: Record, - env: Record | undefined -): string { - const shellOverride = typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' - const requestedEnvShell = - process.platform !== 'win32' && typeof env?.SHELL === 'string' ? env.SHELL.trim() : '' - return resolveRevivedShellOverride(shellOverride) || requestedEnvShell || resolveDefaultShell() -} - -/** - * Spawn cwd, or a refusal. Both `spawnOnce` (admission fence) and `spawnAfterAdmission` (the native - * spawn) resolve through here so the fence can never be keyed on a directory the spawn won't use. - */ -function requireRelaySpawnCwd( - params: Record, - env: Record | undefined -): string { - const resolution: RelaySpawnCwdResolution = resolveRelaySpawnCwd({ - requestedCwd: params.cwd, - worktreeId: typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID, - env, - launchAgent: isTuiAgent(params.launchAgent) ? params.launchAgent : undefined, - // A WSL shell executes in a guest, so the relay's own statSync is not the right question. - executesOnRelayFilesystem: !isRelayWslShell(resolveRelaySpawnShell(params, env)) - }) - if (resolution.kind === 'unresolved') { - throw new Error(formatUnresolvedRelaySpawnCwdMessage(resolution.workspaceId)) - } - return resolution.cwd -} - -function isMissingNodePtyNativeBinding(error: unknown): boolean { - return error instanceof Error && isFlattenedNodePtyLoaderMessage(error.message) -} - -function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | undefined { - if (typeof value !== 'object' || value === null) { - return undefined - } - const input = value as Record - if (input.status === 'checkpointUnavailable') { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - if ( - input.status !== 'checkpoint' || - typeof input.deliveryToken !== 'string' || - input.deliveryToken.length === 0 || - typeof input.ptyIncarnation !== 'string' || - input.ptyIncarnation.length === 0 || - !Number.isSafeInteger(input.clientGeneration) || - Number(input.clientGeneration) <= 0 || - !Number.isSafeInteger(input.ownerGeneration) || - Number(input.ownerGeneration) <= 0 || - !Number.isSafeInteger(input.acceptedSourceEndSu) || - Number(input.acceptedSourceEndSu) < 0 - ) { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - return Object.freeze({ - status: 'checkpoint', - deliveryToken: input.deliveryToken, - ptyIncarnation: input.ptyIncarnation, - clientGeneration: Number(input.clientGeneration), - ownerGeneration: Number(input.ownerGeneration), - acceptedSourceEndSu: Number(input.acceptedSourceEndSu) - }) -} - -type ManagedPty = { - openCodeCapabilities?: OpenCodeCliCapabilities - freebuffStatus?: FreebuffStatusProjection - id: string - incarnationId: string - pty: IPty - initialCwd: string - /** Why a chunk deque: rebuilding a rolling 100KB string per PTY chunk copied the - * whole window on every write once saturated. Readers are attach/adopt/revive only. */ - buffered: RecentPtyOutputBuffer - /** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */ - killTimer?: ReturnType - /** Timer for the post-shutdown reap sweep: re-probes liveness after a kill request instead of - * assuming the request landed. */ - reapTimer?: ReturnType - /** True once disposeManagedPty has run; blocks double-dispose and makes post-dispose calls fail "not found" not silently. */ - disposed?: boolean - /** True once external cleanup observers have been notified. */ - exitListenerNotified?: boolean - /** Renderer-supplied paneKey (ORCA_PANE_KEY); captured so exit observers can evict per-pane cache state. */ - paneKey?: string - tabId?: string - /** Attach-only identity metadata (RPC). Separate from paneKey/tabId, which also drive shell env/revive hooks. */ - attachIdentity?: PtyIdentity - worktreeId?: string - terminalHandle?: string - explicitTerm?: string - shellPath?: string - /** The raw client-requested shell override, kept so revive can re-resolve it on this host. */ - shellOverride?: string - /** Requested WSL distro; only meaningful when the override launched wsl.exe. */ - wslDistro?: string - shellCwd?: string - shellPathEnv?: string - agentLaunchToken?: string - envToDelete: string[] - gitCredentialPromptGuarded: boolean - historyIsolationEnabled?: boolean - startupCommand?: ManagedStartupCommand - /** Kept past delivery: the typed line may never run if the shell dies first. */ - stagedStartupCommand?: StartupCommandStaging - /** Whether this host armed the shell-ready marker for a renderer-delivered startup command. - * Kept off `startupCommand`, which is dropped once delivered; the client reads it from the - * spawn reply to skip waiting for a marker that will never come (fish, sh, Windows). */ - shellReadyArmed?: boolean - physicalExit?: PhysicalExitTracker - immediateClose?: Promise - forceKillSent?: boolean - gracefulKillSent?: boolean - startupIngress?: PtyStartupIngress - recoveryBarrier?: TerminalShellRecoveryBarrier - startupIngressIntent?: ReturnType - ownerBackend: PtyOwnerBackend - agentSessionOwners?: AgentSessionOwnerBinding[] - /** Host clock, host-relative only: published as an age so no client has to trust our wall clock. */ - createdAt: number - /** The authenticated consumer identity that asked this host to create this PTY, read from the - * live grant rather than from a spawn parameter. Absent whenever the host could not attest one - * (no consumer session, or a revive replaying state some other client serialized), and absence - * must never be read as "nobody owns it". */ - ownerClientInstanceId?: string -} - -type RelayAgentSessionCreateResult = { - openCodeCapabilities?: OpenCodeCliCapabilities - id: string - incarnationId: string - replay?: string - agentSessionEnsure?: unknown - sourceActivation?: PtySourceReceivingActivation - shellReadyArmed?: boolean -} - -const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/ -const AGENT_SESSION_CREATE_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000 - -type PendingPtyOutput = RelayPtySourceOutput & { - data: string - interactive?: boolean - sourceChunk?: RelayPtySourceOutput - /** Cached producer-retention charge; kept off the wire and refreshed on data mutations. */ - producerChargeBytes?: number -} - -type ManagedStartupCommand = { - command: string | null - providerDelivery: boolean - delivered: boolean - waitForShellReady: boolean - outputScanState: ShellStartupOutputScanState | null - shellPid: number | null - promptProbe: ShellPromptReadinessProbe | null - timer: ReturnType | null -} - -// Why: Windows ConPTY rejects signals; forward them only on POSIX. -function killPtyProcess(pty: IPty, signal: string): void { - if (process.platform === 'win32') { - pty.kill() - return - } - if (signal === 'SIGKILL') { - forceKillPosixPtyProcessGroups(pty.pid, () => pty.kill(signal)) - return - } - pty.kill(signal) -} - -function finishPtyCreationOperations(operations: readonly (() => void)[]): void { - // Why: the relay still targets Node 18, which lacks Array.prototype.toReversed. - for (let index = operations.length - 1; index >= 0; index--) { - operations[index]() - } -} - -function disposeManagedPty(managed: ManagedPty): void { - if (managed.disposed) { - return - } - managed.disposed = true - discardStagedStartupCommand(managed.stagedStartupCommand) - // Why: clear the SIGKILL fallback timer so it can't fire pty.kill on an already-disposed instance. - if (managed.killTimer) { - clearTimeout(managed.killTimer) - managed.killTimer = undefined - } - if (managed.reapTimer) { - clearTimeout(managed.reapTimer) - managed.reapTimer = undefined - } - // Why: neutralize pty.kill before destroy() so UnixTerminal's async 'close' SIGHUP can't hit a recycled pid. - // Windows exempt: its destroy() IS a kill() (via _deferNoArgs), so neutralizing leaks the ConPTY agent. - if (process.platform !== 'win32') { - ;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {} - } else if (managed.gracefulKillSent || managed.forceKillSent) { - // Why: WindowsTerminal.destroy() calls kill(); a prior bare kill already closed ConPTY, so skip to avoid double-close. - return - } - try { - ;(managed.pty as unknown as { destroy?: () => void }).destroy?.() - } catch { - /* swallow */ - } -} -const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 -export const IMMEDIATE_PTY_EXIT_TIMEOUT_MS = 8_000 -/** Longer than the 5s armed SIGKILL fallback, so the first sweep observes the post-kill state. */ -export const SHUTDOWN_REAP_VERIFY_DELAY_MS = 6_000 -export const SHUTDOWN_REAP_MAX_SWEEPS = 3 -export const MAX_RELAY_PTY_SESSIONS = 50 -export const REPLAY_BUFFER_MAX = 100 * 1024 -const PTY_OUTPUT_BATCH_INTERVAL_MS = 8 -const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1 -const PTY_OUTPUT_FLUSH_CHUNK_CHARS = 16 * 1024 -const PTY_OUTPUT_FLUSH_MAX_WRITES = 2 -const PTY_OUTPUT_PRODUCER_HIGH_BYTES = 128 * 1024 -const PTY_OUTPUT_PRODUCER_LOW_BYTES = 64 * 1024 -const INTERACTIVE_OUTPUT_WINDOW_MS = 100 -const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 -const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS -const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 -const STARTUP_COMMAND_WRITE_DELAY_MS = 50 -const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 -const RENDERER_SHELL_READY_RETENTION_MS = 15_000 -const PTY_FORCE_KILL_RETRY_DELAY_MS = 250 -const PTY_FORCE_KILL_MAX_ATTEMPTS = 2 -const ALLOWED_SIGNALS = new Set([ - 'SIGINT', - 'SIGTERM', - 'SIGHUP', - 'SIGKILL', - 'SIGTSTP', - 'SIGCONT', - 'SIGWINCH', - 'SIGUSR1', - 'SIGUSR2' -]) - -function resolvePtyShellOverride(shellOverride: string): string { - if (!shellOverride) { - return '' - } - if (process.platform !== 'win32') { - return '' - } - if (!isSupportedWindowsShellOverride(shellOverride)) { - throw new Error(`Unsupported Windows shell override: ${shellOverride}`) - } - return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride -} - -/** - * Longest WSL distro name revive will carry into `wsl.exe -d `. - * - * Why bounded here and not at spawn: spawn's value is a live RPC parameter, - * while this one is replayed from state the relay hands a client and takes back - * unvalidated. It reaches an argv, not a shell, so an over-long name costs a - * failed spawn rather than anything worse -- but revive's whole job here is to - * re-apply fresh-spawn bounds, and an unbounded value had no business in it. - * Real distro names are registry keys, far below this. - */ -const MAX_REVIVED_WSL_DISTRO_LENGTH = 256 - -/** - * Same bounds as a fresh spawn, but one unusable entry may not fail the whole - * revive batch — an override this host no longer allows degrades that single - * pane to the default shell instead of throwing away every other pane's state. - */ -function resolveRevivedShellOverride(shellOverride: string): string { - try { - return resolvePtyShellOverride(shellOverride) - } catch { - return '' - } -} - -type PtyProcessSummary = { - id: string - incarnationId: string - cwd: string - title: string - worktreeId?: string - terminalHandle?: string - foregroundProcessEvidence?: ForegroundProcessEvidence - agentSessionOwners?: AgentSessionOwnerBinding[] - /** Age on the HOST's clock. Published instead of a creation timestamp so a client with a skewed - * clock cannot compute a negative or enormous age and act on it. */ - hostAgeMs?: number - /** True when this PTY was spawned for an Orca pane (`ORCA_PANE_KEY`). False means a bare relay - * shell. Absent from a host that predates the field — which is neither. */ - paneBound?: boolean - /** See {@link ManagedPty.ownerClientInstanceId}. Omitted when this host cannot attest one. */ - ownerClientInstanceId?: string -} - -type SerializedPtyEntry = { - openCodeCapabilities?: OpenCodeCliCapabilities - id: string - pid: number - cols: number - rows: number - cwd: string - paneKey?: string - tabId?: string - attachIdentity?: PtyIdentity - worktreeId?: string - terminalHandle?: string - explicitTerm?: string - envToDelete?: string[] - /** Optional for state serialized by relays predating the credential guard. */ - gitCredentialPromptGuarded?: boolean - /** Optional for state serialized by relays predating scoped history. */ - historyIsolationEnabled?: boolean - /** - * The shell override this pane was spawned with, re-resolved (and re-bounded) - * on revive. Optional: state from a relay predating it revives the host - * default shell, which is what those relays did anyway. - */ - shellOverride?: string - terminalWindowsWslDistro?: string - agentSessionOwners?: AgentSessionOwnerBinding[] -} - -function sanitizeEnvToDelete(value: unknown): string[] { - return Array.isArray(value) - ? value - .filter((key): key is string => typeof key === 'string' && key.length > 0) - .slice(0, 1_024) - : [] -} - -export type PtyExitListener = (event: { id: string; paneKey?: string }) => void - -/** Notified when a client retires a pane's surface (`pty.shutdown`). Deliberately separate from - * {@link PtyExitListener}: retirement says the tab is gone, never that the process exited. */ -export type PtySurfaceRetiredListener = (event: { id: string; paneKey: string }) => void - -type PtyIdentity = { paneKey?: string; tabId?: string } - -/** - * True when a reattach's expected pane identity contradicts the target PTY's own. - * Rejects legacy cross-relay-generation id collisions (a reset relay reused `pty-N`). - * Only compares fields present on both sides; absent identity stays permissive. - */ -export function attachIdentityMismatches(expected: PtyIdentity, managed: PtyIdentity): boolean { - return Boolean( - (expected.paneKey && managed.paneKey && expected.paneKey !== managed.paneKey) || - (expected.tabId && managed.tabId && expected.tabId !== managed.tabId) - ) -} -/** Returns env to merge into the PTY's spawn env. Receives spawn context so augmenters can derive per-PTY identity from paneKey. - * `command` is the renderer-chosen agent launch command (`pi`, `omp`, …); undefined for CLI-launched bare shells. */ -export type PtyEnvAugmenter = (ctx: { - id: string - paneKey?: string - shell: string - env: Record - command?: string - launchAgent?: TuiAgent -}) => Record | Promise> - -export type RelayPtyWorktreeRemovalCoordinator = { - beginWorktreePtySpawn(operationPath: string): () => void -} - -export class PtyHandler { - private ptys = new Map() - private readonly ptyIdMintEpoch: string - private foregroundEvidenceEpoch = 0 - private nextId = 1 - private dispatcher: RelayDispatcher - private graceTimeMs: number - private graceTimer: ReturnType | null = null - private outputFlushTimer: ReturnType | null = null - private pendingOutputByPty = new Map() - private pendingProducerBytesByPty = new Map() - private pendingExitByPty = new Map() - private retiredIncarnations = new Map< - string, - { id: string; code: number; incarnationId: string; expiresAt: number } - >() - private pausedOutputPtys = new Set() - private consumerPausedOutputPtys = new Set() - private removeLegacyCapacityListener: (() => void) | null = null - private sourcePublication: RelayPtySourcePublication | null = null - private consumerIdentityResolver: ((clientId: number) => string | null) | null = null - private lastInputAtByPty = new Map() - private interactiveOutputCharsByPty = new Map() - private pendingSpawnCount = 0 - private pendingReviveIds = new Set() - private creationFenced = false - private pendingCreationDrainResolvers = new Set<() => void>() - private worktreeRemovalCoordinator: RelayPtyWorktreeRemovalCoordinator | null = null - private disposePromise: Promise | null = null - private ptyModule: typeof NodePty | null = null - private ptyModuleLoadPromise: Promise | null = null - private reloadPtyModuleFromDisk = false - /** The last thing `require('node-pty')` threw, kept because it is the only cause anyone has. */ - private lastPtyLoadError: unknown = null - // Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup. - private exitListener: PtyExitListener | null = null - private surfaceRetiredListener: PtySurfaceRetiredListener | null = null - private readonly retiredPaneSurfaces = new RetiredPaneSurfaceRegistry() - private ptyPoolEmptyListener: (() => void) | null = null - private ptyPoolActiveListener: (() => void) | null = null - // Why: augment environment on every spawn so PTYs receive current hook coordinates. - private envAugmenters: PtyEnvAugmenter[] = [] - private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() - private readonly agentSessionCreateOperations = new Map< - string, - Promise - >() - - constructor( - dispatcher: RelayDispatcher, - graceTimeMs = DEFAULT_GRACE_TIME_MS, - ptyIdMintEpoch: string = randomUUID() - ) { - this.dispatcher = dispatcher - this.graceTimeMs = graceTimeMs - this.ptyIdMintEpoch = ptyIdMintEpoch - this.registerHandlers() - this.removeLegacyCapacityListener = - this.dispatcher.onLegacyPtyCapacity?.(() => this.handleLegacyCapacity()) ?? null - } - - setConsumerDeliveryPaused(id: string, paused: boolean): void { - if (paused) { - this.consumerPausedOutputPtys.add(id) - this.pausePtyOutput(id) - return - } - this.consumerPausedOutputPtys.delete(id) - this.maybeResumePtyOutput(id) - } - - setSourcePublication(publication: RelayPtySourcePublication): void { - this.sourcePublication = publication - } - - /** Supplies the authenticated client identity behind a transport connection, so a spawn can be - * attributed to the consumer session that requested it. */ - setConsumerIdentityResolver(resolve: ((clientId: number) => string | null) | null): void { - this.consumerIdentityResolver = resolve - } - - handleSourceCreditAvailable(id: string): void { - this.sourcePublication?.onCreditAvailable(id) - } - - handleSourcePublicationCapacity(id: string): void { - if (this.pendingOutputByPty.has(id)) { - this.scheduleOutputFlush(0) - } - this.maybeResumePtyOutput(id) - this.publishPendingExit(id) - } - - private async loadPty(): Promise { - if (this.ptyModule) { - return this.ptyModule - } - if (this.ptyModuleLoadPromise) { - return this.ptyModuleLoadPromise - } - this.ptyModuleLoadPromise = this.loadPtyUncached() - try { - return await this.ptyModuleLoadPromise - } finally { - this.ptyModuleLoadPromise = null - } - } - - private async loadPtyUncached(): Promise { - if (!this.reloadPtyModuleFromDisk) { - try { - this.ptyModule = await import('node-pty') - return this.ptyModule - } catch (error) { - // Why keep it: this is the only place the load error exists. Discarding it here is - // what left the relay able to say "unavailable" and never why. - this.lastPtyLoadError = error - this.reloadPtyModuleFromDisk = true - } - } - // Why: tie module resolution to the deployed bundle dir, not cwd. - const moduleEntry = join(this.relayNodePtyDir(), 'lib', 'index.js') - if (!existsSync(moduleEntry)) { - this.lastPtyLoadError = this.lastPtyLoadError ?? new Error(`no node-pty at ${moduleEntry}`) - return null - } - try { - this.ptyModule = require(moduleEntry) as typeof NodePty - return this.ptyModule - } catch (error) { - this.lastPtyLoadError = error - return null - } - } - - private conptyDllSpawnOptions(): { useConptyDll: true } | Record { - return relayConptyDllSpawnOptions(this.relayNodePtyDir(), describeRelayRuntime().kind) - } - - /** Where the relay's own node-pty lives — the deployed bundle dir, never cwd. */ - private relayNodePtyDir(): string { - // Packaged relays live under Resources/relay while runtime dependencies are - // copied to the sibling Resources/node_modules directory. Development - // bundles keep node_modules beside the relay output, so retain that path as - // the fallback. - const packagedRoot = typeof process.resourcesPath === 'string' ? process.resourcesPath : '' - const packagedDir = packagedRoot ? join(packagedRoot, 'node_modules', 'node-pty') : '' - const localDir = join(__dirname, 'node_modules', 'node-pty') - return packagedDir && existsSync(packagedDir) ? packagedDir : localDir - } - - /** - * The rejection for a spawn that cannot happen: prose for a human, and the structured - * cause for a client that can repair the host instead of printing a paragraph. - * - * Runs the survey and out-of-process load probe only here, on the failure path, so a - * healthy relay never pays for them. - */ - private async nodePtyUnavailableError(spawnError?: unknown): Promise { - // Why: diagnose the install the bare import loaded; the bundle's own dir is only the fallback. - const nodePtyDir = resolveNodePtyInstallDir(__dirname) ?? this.relayNodePtyDir() - const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir, - error: spawnError ?? this.lastPtyLoadError - }) - return Object.assign(new Error(formatNodePtyUnavailableMessage(diagnosis)), { - code: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, - data: toTerminalUnavailableCause(diagnosis) - }) - } - - private invalidatePtyModuleAfterBindingFailure(): void { - this.ptyModule = null - this.reloadPtyModuleFromDisk = true - const moduleRoot = this.relayNodePtyDir() - for (const cachedPath of Object.keys(require.cache)) { - if (isPathInsideOrEqual(moduleRoot, cachedPath)) { - delete require.cache[cachedPath] - } - } - } - - // Why: this value never reaches the grace *timer* — startGraceTimer's only caller always passes an - // explicit timeoutMs — but relay.startGrace reads it back through configuredGraceTimeMs to pick the - // grace branch, so a host-sent change does affect shutdown behavior. Callers and the host-sleep - // consequence: docs/reference/relay-grace-time-reconfiguration.md. - setGraceTimeMs(graceTimeMs: number): void { - this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) - } - - setWorktreeRemovalCoordinator(coordinator: RelayPtyWorktreeRemovalCoordinator | null): void { - this.worktreeRemovalCoordinator = coordinator - } - - async shutdownForWorktreePath(rootPath: string): Promise { - const matchingIds = [...this.ptys.values()] - .filter((managed) => { - const ownedPath = managed.worktreeId - ? splitWorktreeIdForFilesystem(managed.worktreeId)?.worktreePath - : undefined - return ( - (ownedPath !== undefined && isPathInsideOrEqual(rootPath, ownedPath)) || - isPathInsideOrEqual(rootPath, managed.initialCwd) - ) - }) - .map((managed) => managed.id) - await Promise.all(matchingIds.map((id) => this.shutdown({ id, immediate: true }))) - } - - get configuredGraceTimeMs(): number { - return this.graceTimeMs - } - - private agentPresenceTrigger: ((paneKey: string) => void) | null = null - - setAgentPresenceTrigger(listener: ((paneKey: string) => void) | null): void { - this.agentPresenceTrigger = listener - } - - private claudeTerminalEvidenceListener: - | ((paneKey: string, evidence: ClaudeTerminalEvidence) => void) - | null = null - - setClaudeTerminalEvidenceListener( - listener: ((paneKey: string, evidence: ClaudeTerminalEvidence) => void) | null - ): void { - this.claudeTerminalEvidenceListener = listener - } - - /** Subscribe to PTY-exit events (relay-hook server uses this to evict per-paneKey caches). */ - setExitListener(listener: PtyExitListener | null): void { - this.exitListener = listener - } - - /** Subscribe to pane-surface retirement (relay-hook server uses this to drop the pane's cached - * agent status the moment the tab goes away, rather than waiting for a process exit that a - * surviving shell may never produce). */ - setSurfaceRetiredListener(listener: PtySurfaceRetiredListener | null): void { - this.surfaceRetiredListener = listener - } - - /** True when the client has told this host the pane's tab is gone and no PTY has re-bound the - * paneKey since. Nothing this pane emits can belong to a surface any client still owns. */ - isPaneSurfaceRetired(paneKey: string): boolean { - return this.retiredPaneSurfaces.isRetired(paneKey) - } - - getTmuxManagedPty(paneKey: string): TmuxManagedPty | null { - if (process.platform === 'win32' || this.isPaneSurfaceRetired(paneKey)) { - return null - } - const root = this.getCurrentManagedPty(paneKey) - if (!root?.worktreeId || !root.pty.pid) { - return null - } - return { - pid: root.pty.pid, - incarnation: root.incarnationId, - scope: { - executionHostId: 'local', - wslDistro: null, - workspaceId: root.worktreeId, - workspaceKind: root.worktreeId.startsWith('folder:') ? 'folder' : 'git-worktree' - } - } - } - - getAgentLaunchToken(paneKey: string): string | undefined { - return this.isPaneSurfaceRetired(paneKey) - ? undefined - : this.getCurrentManagedPty(paneKey)?.agentLaunchToken - } - - private getCurrentManagedPty(paneKey: string): ManagedPty | undefined { - const candidates = [...this.ptys.values()].filter( - (pty) => !pty.disposed && (pty.paneKey ?? pty.attachIdentity?.paneKey) === paneKey - ) - return candidates.length === 1 ? candidates[0] : undefined - } - - /** Notified when the last PTY leaves the pool, so the relay can re-arm its idle grace. */ - onPtyPoolEmpty(listener: () => void): () => void { - this.ptyPoolEmptyListener = listener - return () => { - if (this.ptyPoolEmptyListener === listener) { - this.ptyPoolEmptyListener = null - } - } - } - - /** - * Notified when a PTY creation is admitted or a PTY joins the pool. - * - * Why: the relay arms its idle cap on an empty pool, and a spawn/revive that lands after that - * decision must disarm it — creation is asynchronous, so the pool-empty edge alone can't see it. - */ - onPtyPoolActive(listener: () => void): () => void { - this.ptyPoolActiveListener = listener - return () => { - if (this.ptyPoolActiveListener === listener) { - this.ptyPoolActiveListener = null - } - } - } - - private notifyPoolListener(listener: (() => void) | null, label: string): void { - if (!listener) { - return - } - try { - listener() - } catch (err) { - process.stderr.write( - `[pty-handler] ${label} listener threw: ${err instanceof Error ? err.message : String(err)}\n` - ) - } - } - - // Why: the sole removal path, so the three exit routes can't drift on who announces an empty pool. - private removePty(id: string): void { - this.ptys.delete(id) - if (this.ptys.size > 0) { - return - } - this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty') - } - - /** Register an env augmenter merged into every spawn env *after* process.env and renderer env. - * Used by the relay-hook server to inject ORCA_AGENT_HOOK_* coords: evaluated per spawn (not captured once), so a late or restarted hook-server bind still reaches the next PTY. */ - addEnvAugmenter(augmenter: PtyEnvAugmenter): () => void { - this.envAugmenters.push(augmenter) - return () => { - const idx = this.envAugmenters.indexOf(augmenter) - if (idx !== -1) { - this.envAugmenters.splice(idx, 1) - } - } - } - - /** Build augmented spawn env; augmenter values win over process.env/renderer env. Shared by spawn()/revive() so precedence can't drift. */ - private async buildSpawnEnv( - rendererEnv: Record | undefined, - ctx: { - id: string - paneKey?: string - shell: string - command?: string - launchAgent?: TuiAgent - }, - envToDelete: readonly string[] = [] - ): Promise> { - const inheritedEnv = stripInheritedBuildModeEnv(process.env) - restoreManagedDataAccountEnvironment(inheritedEnv) - const explicitEnv = { ...rendererEnv } - restoreManagedDataAccountEnvironment(explicitEnv, false) - const mergedEnv = mergeGitConfigEnvProtocol( - { - ...inheritedEnv, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - TERM_PROGRAM_VERSION: - rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev', - FORCE_HYPERLINK: '1' - }, - explicitEnv - ) - const baseEnv: Record = Object.fromEntries( - Object.entries(mergedEnv).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - ) - const augmented: Record = {} - for (const augmenter of this.envAugmenters) { - try { - Object.assign(augmented, await augmenter({ ...ctx, env: baseEnv })) - } catch (err) { - process.stderr.write( - `[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n` - ) - } - } - const result: Record = Object.fromEntries( - Object.entries(mergeGitConfigEnvProtocol(baseEnv, augmented)).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - ) - result[ORCA_IMAGE_PROTOCOL_ENV] = ORCA_IMAGE_PROTOCOL_VALUE - // Why: an older client may not ask a newly upgraded relay to delete inherited shim state. - stripLegacyTerminalShimEnv(result, process.platform) - stripPiProcessOwnerEnv(result) - // Why unconditionally here, not in injectRelayFishHistoryEnv: that runs only for a - // fish pane with isolation on, yet an Orca-minted `fish_history` (fish EXPORTS it, - // so the relay inherits one when launched from an Orca fish pane) must never scope - // any pane to someone else's worktree. Matches the desktop, which drops it on both - // branches (STA-4682). - dropInheritedOrcaFishHistory(result) - // Why here as well as in injectRelayHistoryEnv: that runs only with isolation - // on, yet an inherited Orca HISTFILE must not scope a pane to someone else's - // worktree on the disabled and revive paths either. - dropInheritedOrcaHistFile(result) - // Why unconditionally: ORCA_HISTFILE is Orca-owned and minted below by - // injectRelayHistoryEnv, which also runs only with isolation on. An - // inherited one (the relay can be launched from an Orca pane) would - // otherwise reach the wrapper on the disabled and revive paths, scoping the - // pane to another worktree's history file — and wrapping a zsh pane that - // nothing asked to wrap, since `history` is selected on its presence. - delete result.ORCA_HISTFILE - // Why: the codex wrapper runs this path as hook prep, and a relay pane never gets one of its own. - delete result.ORCA_CODEX_LAUNCH_PREFLIGHT - // Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values. - for (const key of envToDelete) { - delete result[key] - } - if (!envToDelete.includes('TERM') && rendererEnv && Object.hasOwn(rendererEnv, 'TERM')) { - result.TERM = rendererEnv.TERM - } - // Why: node-pty defaults missing/empty TERM per-platform; normalize so POSIX and Windows children agree. - if (!result.TERM) { - result.TERM = 'xterm-256color' - } - // Why: the relay's own process env can carry pane identity (it is itself startable from - // an Orca pane), and unlike the local and daemon builders this one never dropped it. A - // spawn that specified no identity would then inherit someone else's, and every agent's - // hook would report against that pane. Drop it before mirroring, so an alias can only - // ever carry identity this spawn actually asked for. - for (const key of ['ORCA_PANE_KEY', 'ORCA_AGENT_LAUNCH_TOKEN'] as const) { - if (!rendererEnv || !Object.hasOwn(rendererEnv, key)) { - delete result[key] - } - } - // Why here and not only in the local/daemon builders: a remote pane's env is built HERE, - // and the client forwards only the canonical pane-identity names. An agent whose harness - // scrubs those names (DSH drops any env var whose name contains KEY or TOKEN) would find - // nothing to attribute its hooks to, so remote status would silently never appear even - // with the remote hook installed. - applyScrubSafeAgentEnvAliases(result) - // Why last, not beside the scrubbers above: the relay runs those BEFORE envToDelete, - // so an envToDelete of CONDA_PREFIX would otherwise re-create the broken pair. - dropIncoherentCondaActivationEnv(result, process.platform) - expandWindowsPathEnvironmentVariables(result) - return result - } - - private clearStartupCommandTimer(managed: ManagedPty): void { - if (managed.startupCommand?.timer) { - clearTimeout(managed.startupCommand.timer) - managed.startupCommand.timer = null - } - } - - private appendReplayBuffer(managed: ManagedPty, data: string): void { - if (data.length === 0) { - return - } - managed.buffered.append(data) - } - - private releaseStartupCommand(managed: ManagedPty): void { - this.clearStartupCommandTimer(managed) - managed.startupCommand?.promptProbe?.dispose() - managed.startupCommand = undefined - } - - private drainStartupScanBytes(startup: ManagedStartupCommand): string { - if (!startup.outputScanState) { - return '' - } - const heldBytes = drainShellStartupOutputScanState(startup.outputScanState) - startup.outputScanState = null - return heldBytes - } - - private scheduleStartupCommandResolution(managed: ManagedPty, delayMs: number): void { - const startup = managed.startupCommand - if (!startup || startup.delivered || managed.disposed) { - return - } - this.clearStartupCommandTimer(managed) - startup.timer = setTimeout(() => { - startup.timer = null - if (startup.providerDelivery) { - this.deliverStartupCommand(managed) - } else { - this.signalRendererShellReady(managed) - } - }, delayMs) - } - - private deliverStartupCommand(managed: ManagedPty): void { - const startup = managed.startupCommand - if (!startup?.providerDelivery || !startup.command || startup.delivered || managed.disposed) { - return - } - startup.delivered = true - this.clearStartupCommandTimer(managed) - startup.promptProbe?.dispose() - const heldBytes = this.drainStartupScanBytes(startup) - if (heldBytes) { - managed.startupIngress?.accept(heldBytes) - } - // Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed. - const payload = buildStartupCommandSubmission( - managed.stagedStartupCommand?.command ?? startup.command, - { bracketedPasteSafe: startup.waitForShellReady } - ) - managed.startupCommand = undefined - managed.pty.write(payload) - } - - private signalRendererShellReady(managed: ManagedPty): void { - const startup = managed.startupCommand - if (!startup || startup.providerDelivery || startup.delivered || managed.disposed) { - return - } - startup.delivered = true - this.clearStartupCommandTimer(managed) - startup.promptProbe?.dispose() - managed.startupIngress?.accept(this.drainStartupScanBytes(startup)) - managed.startupIngress?.accept(`${SHELL_READY_MARKER_PREFIX}\x07`) - managed.startupCommand = undefined - } - - /** Wire onData/onExit listeners for a managed PTY and store it. */ - private wireAndStore(managed: ManagedPty): void { - managed.physicalExit = new PhysicalExitTracker() - this.ptys.set(managed.id, managed) - // Why: a PTY joining the pool under this paneKey means the surface exists again (reopened pane - // or revive), so a prior retirement no longer describes anything and must not mute its hooks. - const boundPaneKey = managed.paneKey ?? managed.attachIdentity?.paneKey - if (boundPaneKey) { - this.retiredPaneSurfaces.restore(boundPaneKey) - } - // Why: a second announce covers any store whose admission window has already closed. - this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') - const emitIngressData = (emission: PtyIngressEmission): void => { - const rawLength = emission.rawEndSeq - emission.rawStartSeq - const data = managed.freebuffStatus?.project(emission.data) ?? emission.data - this.appendReplayBuffer(managed, data) - this.enqueuePtyOutput( - managed.id, - data, - emission.transformed || rawLength !== data.length - ? { rawLength, seq: emission.rawEndSeq, transformed: true } - : {} - ) - } - const isDead = (): boolean => managed.disposed === true - const recoveryBarrier = new TerminalShellRecoveryBarrier({ - confirmShellForeground: () => - confirmPtyShellForeground({ process: managed.pty, shellPath: managed.shellPath, isDead }), - release: emitIngressData, - isAlive: () => !isDead() - }) - managed.recoveryBarrier = recoveryBarrier - managed.startupIngress = new PtyStartupIngress({ - ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), - ownerBackend: managed.ownerBackend, - write: (data) => managed.pty.write(data), - onEmission: (emission) => recoveryBarrier.accept(emission) - }) - const startup = managed.startupCommand - if (startup?.waitForShellReady) { - startup.promptProbe = createShellPromptReadinessProbe({ - slavePath: readPtySlavePath(managed.pty), - shellPath: managed.shellPath, - shellCwd: managed.shellCwd, - shellPathEnv: managed.shellPathEnv, - getShellPid: () => startup.shellPid, - onPromptReady: () => { - if (startup.providerDelivery) { - this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS) - } else { - this.signalRendererShellReady(managed) - } - } - }) - } - const recheckAgentPresence = (): void => { - if (managed.paneKey) { - this.agentPresenceTrigger?.(managed.paneKey) - } - } - let lastTitleGateKey: string | null = null - const presenceTriggers = createTerminalTitleTracker({ - onTitle: (normalizedTitle, rawTitle, meta) => { - if (managed.paneKey && !meta?.staleWorkingTitleClear) { - this.claudeTerminalEvidenceListener?.(managed.paneKey, { kind: 'title', title: rawTitle }) - } - // Why: spinner frames arrive several times a second; only a real title change re-checks. - const gateKey = getDecorativeTitleGateKey(rawTitle, normalizedTitle) - if (gateKey !== lastTitleGateKey && !meta?.staleWorkingTitleClear) { - recheckAgentPresence() - } - lastTitleGateKey = gateKey - }, - onCommandFinished: recheckAgentPresence - }) - managed.pty.onData((data: string) => { - presenceTriggers.handleChunk(data) - const startup = managed.startupCommand - if (startup?.waitForShellReady && startup.outputScanState && !startup.delivered) { - const scanned = scanShellStartupOutput(startup.outputScanState, data) - data = scanned.output - if (scanned.shellPid) { - startup.shellPid = scanned.shellPid - } - if (scanned.ready) { - if (startup.providerDelivery) { - this.scheduleStartupCommandResolution(managed, STARTUP_COMMAND_WRITE_DELAY_MS) - } else { - this.signalRendererShellReady(managed) - } - } - } - managed.startupIngress?.accept(data) - if (startup && !startup.delivered && data.length > 0) { - startup.promptProbe?.notifyOutput(data) - } - }) - managed.pty.onExit(({ exitCode }: { exitCode: number }) => { - presenceTriggers.dispose() - if (managed.paneKey) { - this.claudeTerminalEvidenceListener?.(managed.paneKey, { kind: 'reset' }) - } - managed.physicalExit?.markExited() - if (managed.disposed) { - return - } - // Why: neutralize pty.kill synchronously so node-pty's 'close' SIGHUP can't hit a recycled pid on POSIX. - if (process.platform !== 'win32') { - ;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - // Why: clear the SIGKILL fallback timer on clean exit so it doesn't fire later. - if (managed.killTimer) { - clearTimeout(managed.killTimer) - managed.killTimer = undefined - } - this.clearStartupCommandTimer(managed) - this.releaseRelayIngress(managed) - this.pausedOutputPtys.delete(managed.id) - this.consumerPausedOutputPtys.delete(managed.id) - this.flushPtyOutput(managed.id) - this.pendingExitByPty.set(managed.id, { - id: managed.id, - code: exitCode, - incarnationId: managed.incarnationId - }) - this.retiredIncarnations.set(managed.id, { - id: managed.id, - code: exitCode, - incarnationId: managed.incarnationId, - expiresAt: Date.now() + 5_000 - }) - pruneRetiredPtyIncarnations(this.retiredIncarnations) - this.publishPendingExit(managed.id) - this.notifyExitListener(managed) - this.agentSessionOwners.release(managed.id) - this.removePty(managed.id) - this.clearPtyInputState(managed.id) - // Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md). - disposeManagedPty(managed) - }) - } - - private releaseRelayIngress(managed: ManagedPty): void { - const startupCommand = managed.startupCommand - if (startupCommand) { - this.clearStartupCommandTimer(managed) - startupCommand.promptProbe?.dispose() - managed.startupIngress?.accept(this.drainStartupScanBytes(startupCommand)) - managed.startupCommand = undefined - } - managed.startupIngress?.drainAndClose() - // Why after drainAndClose: drained ingress bytes re-enter the barrier; a - // teardown mid-proof must still deliver the held prompt before exit. - managed.recoveryBarrier?.flushPending() - managed.recoveryBarrier?.dispose() - managed.freebuffStatus?.dispose() - } - - private notifyExitListener(managed: ManagedPty): void { - if (managed.exitListenerNotified) { - return - } - managed.exitListenerNotified = true - // Why: notify exactly once — both physical exit and whole-relay disposal reach here. - if (this.exitListener) { - try { - this.exitListener({ id: managed.id, paneKey: managed.paneKey }) - } catch (err) { - process.stderr.write( - `[pty-handler] exit listener threw: ${err instanceof Error ? err.message : String(err)}\n` - ) - } - } - } - - private registerHandlers(): void { - this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context)) - this.dispatcher.onRequest('pty.attach', (p, context) => this.attach(p, context)) - this.dispatcher.onRequest('pty.shutdown', (p, context) => this.shutdown(p, context)) - this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) - this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) - this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) - this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) - this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) - this.dispatcher.onRequest('pty.resetInputModes', (p) => this.resetInputModes(p)) - this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) - this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) - this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p)) - this.dispatcher.onRequest('pty.getCapabilities', async () => ({ - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, - agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, - agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION, - // Additive capability: clients may request the no-process-table inventory - // projection and consume fenced inspect evidence on this host. - foregroundProcessEvidenceVersion: 1 - })) - this.dispatcher.onRequest('pty.listProcesses', (params) => this.listProcesses(params)) - this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell()) - this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) - this.dispatcher.onRequest('pty.revive', (p) => this.revive(p)) - this.dispatcher.onRequest('pty.getProfiles', async () => listShellProfiles()) - this.dispatcher.onRequest('pty.deleteWorktreeHistory', async (p) => { - if (typeof p.worktreeId === 'string') { - deleteRelayFishHistory(p.worktreeId) - deleteRelayHistory(p.worktreeId) - } - return { ok: true } - }) - this.dispatcher.onRequest('pty.closeStartupQueryAuthority', (p) => - this.closeStartupQueryAuthority(p) - ) - - this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) - this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) - // A notification, so a client newer than this relay is ignored rather than refused. - this.dispatcher.onNotification('pty.setColorQueryReplyColors', (p) => - setPtyOwnerHostColors(p.colors) - ) - } - - private isLikelyInteractiveRedraw(data: string): boolean { - if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { - return true - } - return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') - } - - private async closeStartupQueryAuthority( - params: Record - ): Promise<{ appliedSeq: number }> { - const id = params.id as string - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - throw new Error(`PTY "${id}" not found`) - } - return { appliedSeq: managed.startupIngress?.closeQueryAuthority() ?? 0 } - } - - private shouldSendInteractiveOutputNow(id: string, data: string): boolean { - const lastInputAt = this.lastInputAtByPty.get(id) - const now = performance.now() - if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { - this.interactiveOutputCharsByPty.delete(id) - return false - } - if (!this.isLikelyInteractiveRedraw(data)) { - this.interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - const usedChars = this.interactiveOutputCharsByPty.get(id) ?? 0 - if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { - this.interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - this.interactiveOutputCharsByPty.set(id, usedChars + data.length) - return true - } - - private enqueuePtyOutput( - id: string, - data: string, - meta: { rawLength?: number; transformed?: boolean; seq?: number } = {} - ): void { - const queue = this.pendingOutputByPty.get(id) ?? [] - if (this.sourcePublication?.accepts(id)) { - const pending = this.initializePendingProducerCharge({ data, ...meta }) - queue.push(pending) - this.pendingOutputByPty.set(id, queue) - this.addPendingProducerBytes(id, pending) - if (queue.length === 1 && this.shouldSendInteractiveOutputNow(id, data)) { - queue[0].interactive = true - if (this.flushPtyOutput(id)) { - return - } - } - if (this.pendingProducerBytes(id) >= PTY_OUTPUT_PRODUCER_HIGH_BYTES) { - this.pausePtyOutput(id) - } - this.scheduleOutputFlush(PTY_OUTPUT_BATCH_INTERVAL_MS) - return - } - const existing = queue.at(-1) - if (meta.transformed === true) { - if (queue.length === 0) { - const transformed = this.initializePendingProducerCharge({ data, ...meta }) - if (this.publishPtyOutput(id, transformed, false)) { - return - } - queue.push(transformed) - // Registering after direct publish preserves legacy overwrite semantics for re-entrant ingress. - this.replacePendingOutputQueue(id, queue, this.pendingProducerChargeForEntry(transformed)) - } else if (existing?.transformed) { - const previousCharge = this.pendingProducerChargeForEntry(existing) - existing.data += data - existing.rawLength = (existing.rawLength ?? 0) + (meta.rawLength ?? data.length) - existing.seq = meta.seq - this.refreshPendingProducerCharge(id, existing, previousCharge) - } else { - const transformed = this.initializePendingProducerCharge({ data, ...meta }) - queue.push(transformed) - this.addPendingProducerBytes(id, transformed) - } - this.pendingOutputByPty.set(id, queue) - this.pausePtyOutput(id) - return - } - const pending: PendingPtyOutput = existing && !existing.transformed ? existing : { data: '' } - const previousCharge = - existing && !existing.transformed ? this.pendingProducerChargeForEntry(pending) : 0 - const previousLength = pending.data.length - pending.data += data - if (pending.rawLength !== undefined || meta.rawLength !== undefined) { - pending.rawLength = (pending.rawLength ?? previousLength) + (meta.rawLength ?? data.length) - } - if (meta.seq !== undefined) { - pending.seq = meta.seq - } - if (!existing || existing.transformed) { - this.initializePendingProducerCharge(pending) - queue.push(pending) - this.addPendingProducerBytes(id, pending) - } else { - this.refreshPendingProducerCharge(id, pending, previousCharge) - } - this.pendingOutputByPty.set(id, queue) - if (queue.length === 1 && this.shouldSendInteractiveOutputNow(id, pending.data)) { - pending.interactive = true - if (this.flushPtyOutput(id)) { - return - } - } - if (this.pendingProducerBytes(id) >= PTY_OUTPUT_PRODUCER_HIGH_BYTES) { - this.pausePtyOutput(id) - } - this.scheduleOutputFlush(PTY_OUTPUT_BATCH_INTERVAL_MS) - } - - private scheduleOutputFlush(delayMs: number): void { - if (this.outputFlushTimer !== null) { - return - } - this.outputFlushTimer = setTimeout(() => this.flushPendingOutput(), delayMs) - } - - private flushPendingOutput(): void { - this.outputFlushTimer = null - // Why batch before the first send: a re-entrant sink must read the values a whole-map snapshot - // would have frozen. Why the raw iterator: `for...of` would consume one entry past the limit. - const pendingEntries = this.pendingOutputByPty[Symbol.iterator]() - const batch: [string, PendingPtyOutput[], number][] = [] - while (batch.length < PTY_OUTPUT_FLUSH_MAX_WRITES) { - const next = pendingEntries.next() - if (next.done === true) { - break - } - const [id, queue] = next.value - batch.push([ - id, - queue.map((pending) => ({ ...pending })), - this.pendingProducerBytesByPty.get(id) ?? 0 - ]) - } - let writes = 0 - for (const [id, queue, chargedBytes] of batch) { - this.deletePendingOutput(id) - if (this.flushPtyOutput(id, queue, chargedBytes)) { - writes++ - } - } - if (this.pendingOutputByPty.size > 0 && writes > 0) { - // Why: yield between slices of a large chunk so client input and control frames can interleave. - this.scheduleOutputFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) - } - } - - private flushPtyOutput( - id: string, - capturedQueue?: PendingPtyOutput[], - capturedProducerBytes?: number - ): boolean { - const queue = capturedQueue ?? this.pendingOutputByPty.get(id) - const pending = queue?.[0] - if (!queue || !pending) { - this.publishPendingExit(id) - return true - } - const queueWasCaptured = capturedQueue !== undefined - const capturedQueueBytes = queueWasCaptured - ? (capturedProducerBytes ?? this.pendingProducerChargeForEntry(pending)) - : (this.pendingProducerBytesByPty.get(id) ?? this.pendingProducerChargeForEntry(pending)) - const desiredChars = pending.transformed - ? pending.data.length - : Math.min(pending.data.length, PTY_OUTPUT_FLUSH_CHUNK_CHARS) - const sourceOnlyEmission = - pending.transformed === true && pending.data.length === 0 && (pending.rawLength ?? 0) > 0 - const paramsWithoutData = { - id, - ...(pending.seq === undefined ? {} : { seq: pending.seq }), - ...(pending.rawLength === undefined ? {} : { rawLength: pending.rawLength }), - ...(pending.transformed ? { transformed: true } : {}) - } - // Why: a failed publish may already have reserved this exact span (source-ledger append, - // partial legacy fan-out), so a retry must resend it verbatim and slice the remainder at - // the memo boundary — capacity and coalesced data can both have changed since. The capacity - // search is skipped on retry: its result is discarded, and publish re-checks capacity. - let chunkChars = - pending.transformed || pending.sourceChunk - ? desiredChars - : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? - desiredChars) - // Why before the surrogate guard: splitting inside an open DEC 2026 frame - // strands the closing \x1b[?2026l in the remainder, and xterm stops repainting - // until it arrives or its 1000ms timeout fires. The surrogate guard keeps the - // final say so a frame boundary can never sever a pair. - if (!pending.transformed && !pending.sourceChunk && chunkChars > 0) { - const frameAligned = resolveSynchronizedOutputSafeSplit(pending.data, chunkChars) - // Why the floor of 2: the surrogate guard below can decrement by one, and - // a chunkChars of 0 takes the pause-and-retry path. Never let frame - // alignment walk a healthy slice into that. - if (frameAligned >= 2) { - chunkChars = frameAligned - } - } - if ( - chunkChars > 0 && - chunkChars < pending.data.length && - pending.data.charCodeAt(chunkChars - 1) >= 0xd800 && - pending.data.charCodeAt(chunkChars - 1) <= 0xdbff - ) { - chunkChars-- - } - if ( - (!sourceOnlyEmission && chunkChars <= 0) || - (pending.transformed && chunkChars !== pending.data.length) - ) { - this.restorePendingOutputAfterFlush(id, queue, capturedQueueBytes, queueWasCaptured) - this.pausePtyOutput(id) - return false - } - const chunk = pending.sourceChunk?.data ?? pending.data.slice(0, chunkChars) - const remaining = pending.data.slice(chunk.length) - const chunkRawLength = pending.transformed - ? pending.rawLength - : pending.rawLength === undefined - ? undefined - : chunk.length - const chunkSeq = - pending.seq === undefined ? undefined : pending.seq - (pending.data.length - chunk.length) - const sourceChunk = - pending.sourceChunk ?? - ({ - data: chunk, - ...(chunkSeq === undefined ? {} : { seq: chunkSeq }), - ...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }), - ...(pending.transformed ? { transformed: true } : {}) - } satisfies RelayPtySourceOutput) - pending.sourceChunk = sourceChunk - const published = this.publishPtyOutput(id, sourceChunk, pending.interactive === true) - if (!published) { - this.restorePendingOutputAfterFlush(id, queue, capturedQueueBytes, queueWasCaptured) - this.pausePtyOutput(id) - return false - } - const queueStillTracked = !queueWasCaptured && this.pendingOutputByPty.get(id) === queue - const queueChargeAfterPublish = queueStillTracked - ? (this.pendingProducerBytesByPty.get(id) ?? capturedQueueBytes) - : capturedQueueBytes - const pendingChargeAfterPublish = this.pendingProducerChargeForEntry(pending) - // rawLength fallback is defensive only: transformed memos always carry rawLength (ingress meta). - const publishedRawLength = sourceChunk.rawLength ?? sourceChunk.data.length - const remainingRawLength = pending.transformed - ? (pending.rawLength ?? 0) - publishedRawLength - : remaining.length - if (remaining || (pending.transformed && remainingRawLength > 0)) { - const remainder = this.initializePendingProducerCharge({ - data: remaining, - ...(pending.transformed ? { transformed: true } : {}), - ...(pending.rawLength === undefined ? {} : { rawLength: remainingRawLength }), - seq: pending.seq - }) - queue[0] = remainder - const nextQueueBytes = - queueChargeAfterPublish - - pendingChargeAfterPublish + - this.pendingProducerChargeForEntry(remainder) - this.replacePendingOutputQueue(id, queue, nextQueueBytes) - } else { - queue.shift() - const nextQueueBytes = queueChargeAfterPublish - pendingChargeAfterPublish - if (queue.length === 0) { - this.deletePendingOutput(id) - this.publishPendingExit(id) - } else { - this.replacePendingOutputQueue(id, queue, nextQueueBytes) - } - } - this.maybeResumePtyOutput(id) - this.clearOutputFlushTimerIfIdle() - return true - } - - private clearOutputFlushTimerIfIdle(): void { - if (this.pendingOutputByPty.size > 0 || this.outputFlushTimer === null) { - return - } - clearTimeout(this.outputFlushTimer) - this.outputFlushTimer = null - } - - private clearPtyFlowState(id: string): void { - this.deletePendingOutput(id) - this.pendingExitByPty.delete(id) - this.pausedOutputPtys.delete(id) - this.consumerPausedOutputPtys.delete(id) - this.clearPtyInputState(id) - this.clearOutputFlushTimerIfIdle() - } - - private clearPtyInputState(id: string): void { - this.lastInputAtByPty.delete(id) - this.interactiveOutputCharsByPty.delete(id) - } - - private publishPtyOutput( - id: string, - output: RelayPtySourceOutput, - interactive: boolean - ): boolean { - if (this.sourcePublication?.accepts(id)) { - return this.sourcePublication.publish(id, output, interactive) - } - if (this.dispatcher.tryNotifyPtyData) { - return this.dispatcher.tryNotifyPtyData( - { - id, - data: output.data, - ...(output.seq === undefined ? {} : { seq: output.seq }), - ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}) - }, - { interactive } - ) - } - this.dispatcher.notify('pty.data', { - id, - data: output.data, - ...(output.seq === undefined ? {} : { seq: output.seq }), - ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}) - }) - return true - } - - private publishPendingExit(id: string): void { - if (this.pendingOutputByPty.has(id)) { - return - } - const exit = this.pendingExitByPty.get(id) - if (!exit) { - return - } - if (this.sourcePublication?.accepts(id)) { - try { - // Why: after the exit settlement, re-entering sealAndPublishExit would pump a closed - // ledger delivery; the settled state alone decides completion. - if (this.sourcePublication.exitPublicationSettled(id)) { - this.pendingExitByPty.delete(id) - return - } - if (!this.sourcePublication.sealAndPublishExit(exit)) { - return - } - if ( - this.sourcePublication.accepts(id) && - !this.sourcePublication.exitPublicationSettled(id) - ) { - return - } - this.pendingExitByPty.delete(id) - return - } catch (err) { - // Why: a source-publication fault must never escape onExit — it reaches - // uncaughtException and kills the whole relay daemon. Fall back to the legacy exit. - process.stderr.write( - `[pty-handler] pty source exit publication failed for ${id}: ${ - err instanceof Error ? (err.stack ?? err.message) : String(err) - }\n` - ) - } - } - // Why: a retired record can already have projected this exit to the legacy subscribers, and - // the broadcast below would hand them a second copy. - let retiredExitPublished: boolean | null | undefined - try { - retiredExitPublished = this.sourcePublication?.publishExitAfterRetire?.(exit) - } catch (err) { - process.stderr.write( - `[pty-handler] retired pty exit publication failed for ${id}: ${ - err instanceof Error ? (err.stack ?? err.message) : String(err) - }\n` - ) - } - const published = - retiredExitPublished ?? - (this.dispatcher.tryNotifyPtyExit - ? this.dispatcher.tryNotifyPtyExit(exit) - : (this.dispatcher.notify('pty.exit', exit), true)) - if (!published) { - return - } - this.pendingExitByPty.delete(id) - } - - private pendingProducerCharge(data: string): number { - return chargedPtyRetainedStringBytes(data) - } - - private initializePendingProducerCharge(pending: PendingPtyOutput): PendingPtyOutput { - pending.producerChargeBytes = this.pendingProducerCharge(pending.data) - return pending - } - - private pendingProducerChargeForEntry(pending: PendingPtyOutput): number { - if (pending.producerChargeBytes === undefined) { - pending.producerChargeBytes = this.pendingProducerCharge(pending.data) - } - return pending.producerChargeBytes - } - - private addPendingProducerBytes(id: string, pending: PendingPtyOutput): void { - const charge = this.pendingProducerChargeForEntry(pending) - this.pendingProducerBytesByPty.set(id, (this.pendingProducerBytesByPty.get(id) ?? 0) + charge) - } - - private refreshPendingProducerCharge( - id: string, - pending: PendingPtyOutput, - previousCharge: number - ): void { - const nextCharge = this.pendingProducerCharge(pending.data) - pending.producerChargeBytes = nextCharge - const currentTotal = this.pendingProducerBytesByPty.get(id) - if (currentTotal === undefined) { - return - } - this.pendingProducerBytesByPty.set(id, currentTotal + nextCharge - previousCharge) - } - - private deletePendingOutput(id: string): void { - this.pendingOutputByPty.delete(id) - this.pendingProducerBytesByPty.delete(id) - } - - private replacePendingOutputQueue( - id: string, - queue: PendingPtyOutput[], - chargedBytes: number - ): void { - if (queue.length === 0) { - this.deletePendingOutput(id) - return - } - this.pendingOutputByPty.set(id, queue) - this.pendingProducerBytesByPty.set(id, chargedBytes) - } - - private restorePendingOutputAfterFlush( - id: string, - queue: PendingPtyOutput[], - capturedBytes: number, - wasCaptured: boolean - ): void { - if (wasCaptured || this.pendingOutputByPty.get(id) !== queue) { - this.replacePendingOutputQueue(id, queue, capturedBytes) - return - } - // A live queue remains tracked through a failed send; ingress may have coalesced into it while - // the sink was called, so keep the incrementally maintained total instead of replacing it. - this.pendingOutputByPty.set(id, queue) - } - - private pendingProducerBytes(id: string): number { - return this.pendingProducerBytesByPty.get(id) ?? 0 - } - - private pausePtyOutput(id: string): void { - if (this.pausedOutputPtys.has(id)) { - return - } - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - return - } - this.pausedOutputPtys.add(id) - managed.pty.pause() - } - - private maybeResumePtyOutput(id: string): void { - if ( - !this.pausedOutputPtys.has(id) || - this.consumerPausedOutputPtys.has(id) || - this.pendingProducerBytes(id) > PTY_OUTPUT_PRODUCER_LOW_BYTES || - this.dispatcher.legacyRetentionBelowLowWater === false - ) { - return - } - const managed = this.ptys.get(id) - this.pausedOutputPtys.delete(id) - if (managed && !managed.disposed) { - managed.pty.resume() - } - } - - private handleLegacyCapacity(): void { - if (this.pendingOutputByPty.size > 0) { - this.scheduleOutputFlush(0) - } - for (const id of Array.from(this.pendingExitByPty.keys())) { - this.publishPendingExit(id) - } - for (const id of Array.from(this.pausedOutputPtys)) { - this.maybeResumePtyOutput(id) - } - } - - private beginPtyCreation(operationPaths: readonly (string | undefined)[]): () => void { - if (this.creationFenced) { - throw new Error('PTY handler is shutting down') - } - const distinctPaths = new Map() - for (const operationPath of operationPaths) { - if (operationPath) { - distinctPaths.set(normalizeRuntimePathForComparison(operationPath), operationPath) - } - } - const finishRemovalOperations: (() => void)[] = [] - try { - if (this.worktreeRemovalCoordinator) { - for (const operationPath of distinctPaths.values()) { - finishRemovalOperations.push( - this.worktreeRemovalCoordinator.beginWorktreePtySpawn(operationPath) - ) - } - } - if (this.ptys.size + this.pendingSpawnCount >= MAX_RELAY_PTY_SESSIONS) { - throw new Error('Maximum number of PTY sessions reached (50)') - } - } catch (error) { - // Why: a later rejection must release every earlier admission before propagating. - finishPtyCreationOperations(finishRemovalOperations) - throw error - } - this.pendingSpawnCount++ - // Why: announce at admission, not at store — the relay must stop treating itself as idle before - // the creation parks on its first await, or an armed idle timer kills the shell it produces. - this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') - let finished = false - return () => { - if (finished) { - return - } - finished = true - this.pendingSpawnCount-- - if (this.pendingSpawnCount === 0) { - for (const resolve of this.pendingCreationDrainResolvers) { - resolve() - } - this.pendingCreationDrainResolvers.clear() - } - finishPtyCreationOperations(finishRemovalOperations) - // Why: a creation that failed before wireAndStore still leaves the pool empty, and removePty - // can't announce a PTY that was never stored — without this the relay never re-arms its idle cap. - if (this.ptys.size === 0 && this.pendingSpawnCount === 0) { - this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty') - } - } - } - - private waitForPendingPtyCreations(): Promise { - if (this.pendingSpawnCount === 0) { - return Promise.resolve() - } - return new Promise((resolve) => { - this.pendingCreationDrainResolvers.add(resolve) - }) - } - - private async spawn( - params: Record, - context?: RequestContext - ): Promise { - const operationId = params.agentSessionCreateOperationId - if (operationId === undefined) { - return await this.spawnOnce(params, context) - } - if ( - typeof operationId !== 'string' || - !AGENT_SESSION_CREATE_OPERATION_ID_PATTERN.test(operationId) - ) { - throw new Error('agent_session_operation_invalid') - } - const existing = this.agentSessionCreateOperations.get(operationId) - if (existing) { - const result = await existing - this.assertPtyNotClosing(this.ptys.get(result.id)) - this.sourcePublication?.activate(result.id, result.incarnationId, context) - const sourceActivation = - context && this.sourcePublication?.receivingActivation?.(result.id, context.clientId) - const { sourceActivation: _staleActivation, ...stableResult } = result - return { ...stableResult, ...(sourceActivation ? { sourceActivation } : {}) } - } - const operation = this.spawnOnce(params, context) - this.agentSessionCreateOperations.set(operationId, operation) - try { - const result = await operation - this.expireAgentSessionCreateOperation(operationId, operation) - return result - } catch (error) { - const outcomeUnknown = - typeof error === 'object' && - error !== null && - 'agentSessionOperationOutcome' in error && - error.agentSessionOperationOutcome === 'unknown' - if (outcomeUnknown) { - // Why: the native PTY may be live; replay the same failure instead of spawning again. - this.expireAgentSessionCreateOperation(operationId, operation) - } else if (this.agentSessionCreateOperations.get(operationId) === operation) { - this.agentSessionCreateOperations.delete(operationId) - } - throw error - } - } - - private expireAgentSessionCreateOperation( - operationId: string, - operation: Promise - ): void { - const timer = setTimeout(() => { - if (this.agentSessionCreateOperations.get(operationId) === operation) { - this.agentSessionCreateOperations.delete(operationId) - } - }, AGENT_SESSION_CREATE_OPERATION_RETENTION_MS) - timer.unref?.() - } - - private async spawnOnce( - params: Record, - context?: RequestContext - ): Promise { - const env = params.env as Record | undefined - const worktreeId = - typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID - // Must be the filesystem split, matching requireRelaySpawnCwd: a `::workspace:` id would - // otherwise fence a directory the spawn never enters. - const worktreePath = worktreeId - ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - : undefined - const cwd = requireRelaySpawnCwd(params, env) - const finishCreation = this.beginPtyCreation([worktreePath, cwd]) - let physicalSpawnCommitted = false - const markPhysicalSpawnCommitted = (): void => { - physicalSpawnCommitted = true - } - try { - const ensure = params.agentSessionEnsure as { claim?: unknown; surface?: unknown } | undefined - if (!ensure) { - return await this.spawnAfterAdmission(params, context, markPhysicalSpawnCommitted) - } - if ( - !isAgentSessionExecutionClaim(ensure.claim) || - !isAgentSessionSurfaceBinding(ensure.surface) - ) { - throw new Error('agent_session_identity_required') - } - const claim = ensure.claim - const surface = ensure.surface - const result = await this.agentSessionOwners.ensure({ - claim, - surface, - spawn: async ({ generation }) => { - const created = await this.spawnAfterAdmission( - params, - context, - markPhysicalSpawnCommitted - ) - const managed = this.ptys.get(created.id) - if (managed) { - managed.agentSessionOwners = [ - { - claim, - generation, - phase: 'live', - ptyId: created.id, - surface - } - ] - } - return { ptyId: created.id } - }, - isLive: (owner) => { - const managed = this.ptys.get(owner.ptyId) - return Boolean( - managed && - !managed.disposed && - (!managed.pty.pid || isProcessAlive(managed.pty.pid)) && - managed.agentSessionOwners?.some((candidate) => - agentSessionOwnerBindingsEqual(candidate, owner) - ) - ) - } - }) - const managed = this.ptys.get(result.owner.ptyId) - if (!managed || managed.disposed) { - this.agentSessionOwners.release(result.owner.ptyId, result.owner.generation) - throw new Error('agent_session_exited_during_start') - } - this.assertPtyNotClosing(managed) - managed.agentSessionOwners = this.agentSessionOwners.listForPty(managed.id) - const adoptedReplay = result.disposition === 'adopted' ? managed.buffered.read() : '' - this.sourcePublication?.activate(managed.id, managed.incarnationId, context) - const sourceActivation = - context && this.sourcePublication?.receivingActivation?.(managed.id, context.clientId) - return { - id: managed.id, - incarnationId: managed.incarnationId, - agentSessionEnsure: result, - ...(result.disposition === 'created' && managed.openCodeCapabilities - ? { openCodeCapabilities: managed.openCodeCapabilities } - : {}), - ...(sourceActivation ? { sourceActivation } : {}), - ...(adoptedReplay ? { replay: adoptedReplay } : {}), - ...(managed.shellReadyArmed !== undefined - ? { shellReadyArmed: managed.shellReadyArmed } - : {}) - } - } catch (error) { - if (!physicalSpawnCommitted) { - throw error - } - const message = error instanceof Error ? error.message : String(error) - throw Object.assign(new Error(message), { - agentSessionOperationOutcome: 'unknown' as const - }) - } finally { - finishCreation() - } - } - - private async spawnAfterAdmission( - params: Record, - context?: RequestContext, - onPhysicalSpawnCommitted?: () => void - ): Promise<{ - id: string - incarnationId: string - sourceActivation?: PtySourceReceivingActivation - shellReadyArmed?: boolean - openCodeCapabilities?: OpenCodeCliCapabilities - }> { - const pty = await this.loadPty() - if (!pty) { - throw await this.nodePtyUnavailableError() - } - - const cols = (params.cols as number) || 80 - const rows = (params.rows as number) || 24 - const env = params.env as Record | undefined - const cwd = requireRelaySpawnCwd(params, env) - const envToDelete = sanitizeEnvToDelete(params.envToDelete) - const explicitTerm = - !envToDelete.includes('TERM') && - env && - Object.hasOwn(env, 'TERM') && - typeof env.TERM === 'string' && - env.TERM.length > 0 - ? env.TERM - : undefined - const shellOverride = - typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' - const resolvedShellOverride = resolvePtyShellOverride(shellOverride) - const requestedEnvShell = - process.platform !== 'win32' && typeof env?.SHELL === 'string' ? env.SHELL.trim() : '' - const shell = resolvedShellOverride || requestedEnvShell || resolveDefaultShell() - let id: string - do { - id = `pty2:${encodeURIComponent(this.ptyIdMintEpoch)}:${this.nextId++}` - } while (this.ptys.has(id) || this.pendingReviveIds.has(id)) - - // Why: augmenter values override renderer env so remote paths and hook coords win over local userData. - const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined - // Why: kept so a restarted runtime can re-adopt this PTY under its original handle (survives revive). - const terminalHandle = - typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined - const command = typeof params.command === 'string' ? params.command : undefined - const launchAgent = isTuiAgent(params.launchAgent) ? params.launchAgent : undefined - const terminalWindowsWslDistro = - typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null - const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' - const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined - const spawnEnv = await this.buildSpawnEnv( - env, - { id, paneKey, shell, command, launchAgent }, - envToDelete - ) - delete spawnEnv.ORCA_OPENCODE_PLUGIN_API - // Relay input streams lack driving-input provenance, so native intent is unavailable. - for (const key of [ - OPENCODE_STARTUP_PROMPT_SHA256_ENV, - OPENCODE_STARTUP_PROMPT_NONCE_ENV, - OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, - OPENCODE_STARTUP_PROMPT_BODY_ENV, - OPENCODE_STARTUP_PROMPT_SHELL_ENV - ]) { - delete spawnEnv[key] - } - const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ - command, - agent: launchAgent, - env: spawnEnv, - cwd, - hostIdentity: `relay:${process.platform}`, - resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), - ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) - }) - applyOpenCodePluginSelection( - spawnEnv, - envToDelete, - openCodeCapabilities, - isRelayWslShell(shell) - ) - await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { - wslShell: isRelayWslShell(shell) - }) - const worktreeId = - typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID - const historyIsolationEnabled = params.historyIsolationEnabled === true - // Deliberately not reached by wsl.exe: a guest fish writes its history file - // inside the distro, where relay deletion cannot reach it (STA-4682). - if (historyIsolationEnabled && worktreeId && basename(shell).toLowerCase().startsWith('fish')) { - injectRelayFishHistoryEnv(spawnEnv, worktreeId) - } - const wslShell = isRelayWslShell(shell) - if (wslShell) { - // WSLENV is the only channel that carries a host env var into the guest. - addWslEnvKeys(spawnEnv, [ORCA_IMAGE_PROTOCOL_ENV]) - } - if (historyIsolationEnabled && worktreeId) { - const historyRoot = injectRelayHistoryEnv(spawnEnv, worktreeId, shell, { wsl: wslShell }) - if (wslShell && historyRoot) { - addWslEnvKeys(spawnEnv, ['HISTFILE']) - } - } - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) - // Why: SSH PTYs bypass main's host-env builder, so apply the guard after the relay merges its authoritative env. - const gitCredentialPromptGuarded = applyTerminalGitCredentialPromptGuard(spawnEnv, { - launchCommand: launchCommandHint, - isUnattended: launchAgent !== undefined, - platform: process.platform - }) - // Why the shell is part of the decision here and not on the client: the client - // cannot see which shell this host runs, and plain Codex must still wait where - // the marker rides the line editor rather than double-echoing an early write. - const shouldEmitShellReadyMarker = - launchCommandHint !== undefined && - shouldUseShellReadyStartupDelivery({ - command: launchCommandHint, - startupCommandDelivery: - params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined, - shellPath: shell - }) - const managedStartupCommand = shouldProviderDeliverCommand ? command : launchCommandHint - // Why: both renderer- and provider-delivered startup commands use this marker; the delivering side strips it from output. - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { - terminalWindowsWslDistro, - emitReadyMarker: shouldEmitShellReadyMarker, - emitStartupIdentity: shouldEmitShellReadyMarker - }) - const rendererShellReadySupported = - !shouldProviderDeliverCommand && shellLaunch.supportsReadyMarker - - if (context?.signal?.aborted || context?.isStale()) { - // Why: cancellation remains side-effect-free until the exact native spawn seam. - throw new Error('client_disconnected') - } - - // Why: SSH exec channels give the relay a minimal environment without - // .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH - // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). - // When overlays are injected, the launch wrapper keeps those paths after - // user startup files re-export their defaults. - const ptyEnv: Record = { - ...spawnEnv, - [SHELL_STARTUP_FEATURE_ENV]: '', - ...shellLaunch.env - } - let term: IPty - try { - term = pty.spawn(shell, shellLaunch.args, { - // Why: node-pty overwrites env.TERM with `name`; pass caller-selected TERM so it isn't lost. - name: spawnEnv.TERM ?? 'xterm-256color', - cols, - rows, - cwd, - // Why the empty default: relay shells inherit process.env, and the launch - // config is the only thing allowed to name features for this shell. - env: ptyEnv, - ...this.conptyDllSpawnOptions() - }) - } catch (error) { - // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. - if (isMissingNodePtyNativeBinding(error)) { - this.invalidatePtyModuleAfterBindingFailure() - throw await this.nodePtyUnavailableError(error) - } - throw error - } - onPhysicalSpawnCommitted?.() - - // Why: capture paneKey so the exit listener can evict per-pane caches without a separate ptyId→paneKey map. - const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined - const attachIdentity = { - paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, - tabId: typeof params.tabId === 'string' ? params.tabId : tabId - } - const startupIngressIntent = - params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION - ? parsePtyStartupIngressIntent(params.startupIngress) - : undefined - const ownerClientInstanceId = - context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) - const managed: ManagedPty = { - ...(openCodeCapabilities ? { openCodeCapabilities } : {}), - ...(launchAgent === 'freebuff' - ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } - : {}), - id, - incarnationId: randomUUID(), - pty: term, - initialCwd: cwd, - createdAt: Date.now(), - ...(ownerClientInstanceId ? { ownerClientInstanceId } : {}), - buffered: new RecentPtyOutputBuffer({ - preserveChunkBoundaries: false, - limit: REPLAY_BUFFER_MAX - }), - paneKey, - tabId, - ...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}), - worktreeId, - ...(explicitTerm !== undefined ? { explicitTerm } : {}), - envToDelete, - gitCredentialPromptGuarded, - ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), - shellPath: shell, - // Why the resolved one gates it: on a POSIX relay an override is rejected - // outright, and storing one revive would only reject again is noise. - ...(resolvedShellOverride ? { shellOverride } : {}), - ...(terminalWindowsWslDistro ? { wslDistro: terminalWindowsWslDistro } : {}), - shellCwd: cwd, - shellPathEnv: spawnEnv.PATH, - agentLaunchToken: ptyEnv.ORCA_AGENT_LAUNCH_TOKEN?.trim() || undefined, - ownerBackend: resolvePtyOwnerBackend({ - platform: process.platform, - shellPath: shell, - wslDistro: terminalWindowsWslDistro - }), - ...(startupIngressIntent ? { startupIngressIntent } : {}), - ...(terminalHandle ? { terminalHandle } : {}), - shellReadyArmed: rendererShellReadySupported, - ...(managedStartupCommand && (shouldProviderDeliverCommand || rendererShellReadySupported) - ? { - startupCommand: { - command: shouldProviderDeliverCommand ? managedStartupCommand : null, - providerDelivery: shouldProviderDeliverCommand, - delivered: false, - waitForShellReady: shellLaunch.supportsReadyMarker, - outputScanState: shellLaunch.supportsReadyMarker - ? createShellStartupOutputScanState() - : null, - shellPid: null, - promptProbe: null, - timer: null - } - } - : {}) - } - if (managed.startupCommand?.providerDelivery && managed.startupCommand.command) { - managed.stagedStartupCommand = stageStartupCommand({ - command: managed.startupCommand.command, - shellPath: shell, - orcaBuiltLine: launchAgent !== undefined - }) - if (managed.stagedStartupCommand.failure) { - process.stderr.write( - `[pty-handler] Could not stage startup command for ${id}; typing it in full: ${managed.stagedStartupCommand.failure}\n` - ) - } - } - this.retiredIncarnations.delete(id) - this.sourcePublication?.activate(id, managed.incarnationId, context) - const sourceActivation = - context && this.sourcePublication?.receivingActivation?.(id, context.clientId) - this.wireAndStore(managed) - const stagingNotice = - managed.stagedStartupCommand && startupStagingFailureNotice(managed.stagedStartupCommand) - if (stagingNotice) { - managed.startupIngress?.accept(stagingNotice) - } - if (context?.isStale() && !params.agentSessionEnsure && !params.agentSessionCreateOperationId) { - // Why: if the client reconnected while pty.spawn was in flight, the - // response is discarded and no renderer can own this PTY. Shut it down - // immediately so it does not linger as an unreachable remote shell. - this.releaseStartupCommand(managed) - this.requestGracefulKill(managed, 'terminate stale') - } else if (managed.startupCommand) { - this.scheduleStartupCommandResolution( - managed, - managed.startupCommand.providerDelivery - ? managed.startupCommand.waitForShellReady - ? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS - : STARTUP_COMMAND_WRITE_DELAY_MS - : RENDERER_SHELL_READY_RETENTION_MS - ) - } - return { - id, - incarnationId: managed.incarnationId, - ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported, - ...(openCodeCapabilities ? { openCodeCapabilities } : {}) - } - } - - private async attach( - params: Record, - context?: RequestContext - ): Promise<{ - incarnationId: string - replay?: string - sourceRecovery?: PtySourceRecoveryResult - sourceActivation?: PtySourceReceivingActivation - }> { - const id = params.id as string - const managed = this.ptys.get(id) - // Why: after dispose, pty.kill is a POSIX no-op; treat disposed as not-found so failures aren't silent. - if (!managed || managed.disposed) { - throw new Error(`PTY "${id}" not found`) - } - - this.assertPtyNotClosing(managed) - - // Why: verify liveness because shells can exit without node-pty onExit. - if (this.reapPtyProvenExited(managed)) { - // Why the marker: this is the ONLY not-found answer backed by a liveness check. The unmarked - // one above is also thrown for an id this session map never had — every id minted before a - // relay restart — so a client that cannot tell them apart certifies deaths it never observed - // (docs/reference/ssh-execution-boundary.md). - throw new Error(`PTY "${id}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) - } - - // Why: legacy `pty-N` ids repeated across relay generations; reject conflicting identities. - const mismatch = attachIdentityMismatches( - { - paneKey: typeof params.expectedPaneKey === 'string' ? params.expectedPaneKey : undefined, - tabId: typeof params.expectedTabId === 'string' ? params.expectedTabId : undefined - }, - managed.attachIdentity ?? { paneKey: managed.paneKey, tabId: managed.tabId } - ) - if (mismatch) { - throw new Error(`PTY "${id}" not found (identity mismatch)`) - } - - // Why: an accepted attach is a client surface for this pane, so a prior retirement no longer - // describes anything — without this a reattach to a shut-down-but-surviving PTY would keep the - // pane's agent hooks muted for the rest of the daemon's life. - const attachedPaneKey = managed.paneKey ?? managed.attachIdentity?.paneKey - if (attachedPaneKey) { - this.retiredPaneSurfaces.restore(attachedPaneKey) - } - - managed.startupIngress?.snapshotBarrier() - let sourceRecovery = parseSourceRecoveryRequest(params.sourceRecovery) - if ( - sourceRecovery?.status === 'checkpoint' && - this.sourcePublication && - !(await this.sourcePublication.waitForPendingSend(id)) - ) { - sourceRecovery = Object.freeze({ status: 'checkpointUnavailable' }) - } - if (this.ptys.get(id) !== managed || managed.disposed) { - throw new Error(`PTY "${id}" not found`) - } - this.assertPtyNotClosing(managed) - const activation = this.sourcePublication?.activate( - id, - managed.incarnationId, - context, - sourceRecovery - ) - const sourceActivation = - context && this.sourcePublication?.receivingActivation?.(id, context.clientId) - if (typeof activation === 'object') { - return { - incarnationId: managed.incarnationId, - sourceRecovery: activation, - ...(sourceActivation ? { sourceActivation } : {}) - } - } - // `existing` means a delivery is already open for this client, so it is already receiving live - // output and does not need its screen re-sent. That is true for a duplicate attach — and false - // for the case this skipped: an SSH reconnect. The client keeps its id there (the dispatcher - // refuses to detach the primary, and setWrite revives that same id), so the delivery outlives - // the dead transport, while the renderer has thrown its terminal away — a reconnect bumps - // tab.generation, which is the React key, so the pane remounts with a brand-new empty xterm and - // nothing captures the old buffer. Answering "you already have it" leaves the pane blank - // forever, until new output happens to arrive. - // - // So the client says which it is. Falling through rather than returning the replay inline is - // deliberate: the path below already drops the pending batched bytes that are also in the - // buffer, which is what stops the live delivery double-rendering them. - if ( - activation === 'existing' && - this.sourcePublication?.accepts(id) && - params.requireReplay !== true - ) { - return { - incarnationId: managed.incarnationId, - ...(sourceActivation ? { sourceActivation } : {}) - } - } - - // Why: return replay during spawn before renderer handlers register. - // Why: retain replay buffers so later restarts receive full history. - const replay = managed.buffered.read() - if (replay) { - // Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice. - this.deletePendingOutput(id) - this.clearOutputFlushTimerIfIdle() - this.maybeResumePtyOutput(id) - if (params.suppressReplayNotification) { - return { - incarnationId: managed.incarnationId, - replay, - ...(sourceActivation ? { sourceActivation } : {}) - } - } - this.dispatcher.notify('pty.replay', { id, data: replay }) - } - return { - incarnationId: managed.incarnationId, - ...(sourceActivation ? { sourceActivation } : {}) - } - } - - private writeData(params: Record): void { - const id = params.id as string - const data = params.data as string - if (typeof data !== 'string') { - return - } - const managed = this.ptys.get(id) - if (managed && !managed.disposed) { - this.lastInputAtByPty.set(id, performance.now()) - this.interactiveOutputCharsByPty.set(id, 0) - // Relay PTYs need the local provider's cooked-echo containment (#13137). - // DA1/CPR stay immediate unless an echo-risk reply is already held (#13892, #15559). - if (managed.startupIngress?.answerLiveQueryReply(data)) { - return - } - managed.pty.write(data) - if (managed.paneKey) { - this.claudeTerminalEvidenceListener?.(managed.paneKey, { kind: 'input', data }) - } - } - } - - private resize(params: Record): void { - const id = params.id as string - const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80))) - const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24))) - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - return - } - // Why probe (same probe attach() and listProcesses() run): a shell that - // exited without node-pty's `onExit` leaves an undisposed entry behind, and - // while it stays the relay keeps advertising a dead shell and keeps holding - // `activePtyCount` above zero, which is what stops a relay with - // `relayGracePeriodSeconds: 0` from ever reaching its idle-no-ptys exit - // (#12423). This is retirement, not ioctl safety: only ESRCH from the host - // that owns the pid is evidence of `exited`. - if (this.reapPtyProvenExited(managed)) { - return - } - // The patched node-pty retires `_fd` in the same block that gives up the - // master (config/patches/node-pty@1.1.0.patch), which makes a resize past - // that point a no-op rather than a TIOCSWINSZ aimed at a reused descriptor. - // That covers only part of the window and does not cover this process at - // all: libuv closes the fd synchronously inside `uv_close`, before the JS - // `'close'` that runs `_close()`, and a relay host installs node-pty from - // npm, where the patch is not applied. So the catch below stays. - try { - managed.pty.resize(cols, rows) - managed.freebuffStatus?.resize(cols, rows) - } catch (err) { - // A failed ioctl observed the handle, not the host's process table, so on - // its own it is `unverifiable`. Re-probe: a now-absent pid retires the - // entry, anything else keeps it and is contained here rather than - // escaping as a parse error on every later resize. - if (this.reapPtyProvenExited(managed)) { - return - } - process.stderr.write( - `[pty-handler] resize failed for PTY ${id} whose process is still live or unverifiable: ${err instanceof Error ? err.message : String(err)}\n` - ) - } - } - - private async getSize( - params: Record - ): Promise<{ cols: number; rows: number } | null> { - const managed = this.ptys.get(params.id as string) - if (!managed || managed.disposed) { - return null - } - return { cols: managed.pty.cols, rows: managed.pty.rows } - } - - private async shutdown(params: Record, context?: RequestContext): Promise { - const id = params.id as string - const immediate = params.immediate as boolean - const expectedIncarnationId = params.expectedIncarnationId - if ( - expectedIncarnationId !== undefined && - (typeof expectedIncarnationId !== 'string' || expectedIncarnationId.length === 0) - ) { - throw new Error('Invalid expectedIncarnationId') - } - const expectedOwnerClientInstanceId = params.expectedOwnerClientInstanceId - if ( - expectedOwnerClientInstanceId !== undefined && - (typeof expectedOwnerClientInstanceId !== 'string' || - expectedOwnerClientInstanceId.length === 0) - ) { - throw new Error('Invalid expectedOwnerClientInstanceId') - } - const managed = this.ptys.get(id) - if (!managed) { - return - } - if (expectedIncarnationId !== undefined && expectedIncarnationId !== managed.incarnationId) { - throw new Error(`PTY incarnation mismatch for ${id}`) - } - if (expectedOwnerClientInstanceId !== undefined) { - this.assertShutdownOwnership(id, managed, expectedOwnerClientInstanceId, context) - } - // Why: `pty.shutdown` is the only authoritative statement this host ever gets that a tab is - // gone. Record it before the kill request, because the kill is the part that can fail: an agent - // that survives teardown otherwise keeps posting hooks the relay forwards as a live agent pane - // with no tab, and that advertisement is what drives a second `--resume` onto its transcript. - // Why gated on an actual retirement: the teardown below is fire-and-forget — one SIGTERM, one - // armed SIGKILL, and nobody ever looks again. Re-probing is what stops a retired pane's shell - // outliving its tab; a PTY with no pane surface has nothing to outlive, so its long-standing - // teardown contract is left exactly as it was. - if (this.retirePaneSurface(managed)) { - this.armShutdownReapSweep(managed, SHUTDOWN_REAP_MAX_SWEEPS) - } - - if (immediate) { - this.releaseStartupCommand(managed) - this.flushPtyOutput(id) - await this.closeImmediately(managed) - } else { - this.releaseStartupCommand(managed) - this.requestGracefulKill(managed, 'force-kill') - } - } - - private assertPtyNotClosing(managed: ManagedPty | undefined): void { - if (managed?.immediateClose) { - throw new Error(`PTY "${managed.id}" is terminating`) - } - } - - private async closeImmediately(managed: ManagedPty): Promise { - if (managed.immediateClose) { - return managed.immediateClose - } - const ownsRoot = (): boolean => this.ptys.get(managed.id) === managed && !managed.disposed - const close = async (): Promise => { - if (process.platform === 'win32') { - this.requestForceKill(managed) - } else { - await killWithDescendantSweep( - managed.pty.pid, - () => { - if (ownsRoot()) { - this.requestForceKill(managed) - } - }, - { ownsRoot, terminateOwnedTree: () => terminatePtyJob(managed.pty) } - ) - } - await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) - } - const pending = close() - managed.immediateClose = pending - try { - await pending - } finally { - if (managed.immediateClose === pending) { - managed.immediateClose = undefined - } - } - } - - /** Re-decide, on the host, whether the caller may destroy this PTY. - * - * `pty.shutdown` is irreversible and its siblings `pty.spawn`/`pty.attach` already take a - * request context; without this the whole ownership rule lived on the client, on the one call - * that cannot be taken back. Both halves are checked here because either alone is an echo: the - * connection must still authenticate as that consumer identity (so a claim cannot be asserted), - * and this host must have recorded that same identity as the PTY's creator at spawn (so the - * caller cannot reach a PTY it never made). - * - * Only callers that opt in are checked. An ordinary pane teardown does not pass the field, and - * must not: a revived PTY carries no attested owner at all, and a host predating the attestation - * would refuse stops it is obliged to honour. */ - private assertShutdownOwnership( - id: string, - managed: ManagedPty, - expectedOwnerClientInstanceId: string, - context: RequestContext | undefined - ): void { - const requester = - context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) - if (requester !== expectedOwnerClientInstanceId) { - throw new Error(`PTY "${id}" stop refused: requester is not the attested owner`) - } - if (managed.ownerClientInstanceId !== expectedOwnerClientInstanceId) { - throw new Error(`PTY "${id}" stop refused: this host attested no such owner`) - } - } - - /** Record that this pane's client surface is gone, and tell the hook server so the pane's cached - * agent status stops being replayed to reconnecting clients. Returns false when there is no pane - * surface to retire. */ - private retirePaneSurface(managed: ManagedPty): boolean { - const paneKey = managed.paneKey ?? managed.attachIdentity?.paneKey - if (!paneKey) { - return false - } - this.retiredPaneSurfaces.retire(paneKey) - const listener = this.surfaceRetiredListener - if (!listener) { - return true - } - try { - listener({ id: managed.id, paneKey }) - } catch (err) { - process.stderr.write( - `[pty-handler] surface-retired listener threw: ${err instanceof Error ? err.message : String(err)}\n` - ) - } - return true - } - - /** - * After a retirement, verify the host actually reaped the shell rather than trusting the kill - * request. Two outcomes matter and both are bookkeeping the relay previously never did: - * - * - the pid is gone but node-pty never produced `onExit` — retire the record here, so the entry - * and its agent-session owners stop being published by `pty.listProcesses`; - * - the pid is still alive — re-issue the force kill instead of leaving a detached shell (and the - * agent inside it) outliving its tab for the life of the daemon. - * - * Bounded: after {@link SHUTDOWN_REAP_MAX_SWEEPS} the owner claim is deliberately *retained*. - * Releasing a claim we cannot prove dead is what lets a reopened project spawn a second agent - * over one transcript; keeping it makes the next `pty.spawn` adopt this PTY instead. - */ - private armShutdownReapSweep(managed: ManagedPty, attemptsRemaining: number): void { - if (managed.reapTimer) { - return - } - const timer = setTimeout(() => { - managed.reapTimer = undefined - if (this.ptys.get(managed.id) !== managed || managed.disposed) { - return - } - if (this.reapPtyProvenExited(managed)) { - return - } - if (attemptsRemaining <= 0) { - process.stderr.write( - `[pty-handler] retired pane PTY ${managed.id} still alive after force kill; ownership retained as unverifiable\n` - ) - return - } - // Why POSIX-only: a SIGKILL that returned success is not proof of death there — the group - // probe can degrade to a root-pid kill, leaving the agent running under a shell nobody is - // watching. On Windows ConPTY's kill is already force-final and closing its handle twice is - // the hazard disposeManagedPty guards against, so the probe above is the whole sweep. - if (process.platform !== 'win32') { - managed.forceKillSent = false - try { - this.requestForceKill(managed) - } catch { - /* Re-probed on the next sweep; a transient failure must not end the escalation. */ - } - } - this.armShutdownReapSweep(managed, attemptsRemaining - 1) - }, SHUTDOWN_REAP_VERIFY_DELAY_MS) - timer.unref?.() - managed.reapTimer = timer - } - - /** - * Retire every record for a PTY whose process is proven gone. Shared by the attach probe, the - * listing probe and the post-shutdown sweep so the three cannot drift on what "gone" retires. - * - * `evidence` is not decoration: `exited` publishes a verdict to the client, and only ESRCH from - * the host that owns the pid earns it. The disposed-record sweep retires off our own - * bookkeeping, which says we tore the record down — not that the shell died — so it stays - * silent (docs/reference/ssh-execution-boundary.md). - */ - private reapExitedPty(managed: ManagedPty, evidence: 'exited' | 'record-torn-down'): void { - managed.physicalExit?.markExited() - this.releaseRelayIngress(managed) - this.flushPtyOutput(managed.id) - if (evidence === 'exited') { - this.publishReapedExit(managed) - } - this.notifyExitListener(managed) - this.agentSessionOwners.release(managed.id) - this.retiredIncarnations.set(managed.id, { - id: managed.id, - code: 0, - incarnationId: managed.incarnationId, - expiresAt: Date.now() + 5_000 - }) - pruneRetiredPtyIncarnations(this.retiredIncarnations) - disposeManagedPty(managed) - this.removePty(managed.id) - this.clearPtyFlowState(managed.id) - } - - /** - * A reap is an exit the client has to hear about. `notifyExitListener` is - * relay-internal, so a retirement that stops there leaves the pane mounted - * against a session the relay has already forgotten — the next attach answers - * `PTY "" not found` and nothing before it said why. `resize` made that - * user-triggered. - * - * `-1` is this wire's "gone, status unrecoverable": the pid is proven absent - * (ESRCH from the host that owns it) but nothing waited on the shell, so no - * status exists. `ssh-relay-session` already publishes the same code for a - * dropped lease. Reached only from the proven-exited path — a client that acts - * on this retires the pane, so nothing weaker than ESRCH may reach it. - */ - private publishReapedExit(managed: ManagedPty): void { - // Why the guard: node-pty's own `onExit` already queued and published this - // pty's real exit code before reaching here, and the sweep also reaps - // entries that path left behind. - if (managed.exitListenerNotified || this.pendingExitByPty.has(managed.id)) { - return - } - this.pendingExitByPty.set(managed.id, { - id: managed.id, - code: -1, - incarnationId: managed.incarnationId - }) - this.publishPendingExit(managed.id) - } - - /** - * Retire this entry when the host proves its pid is gone; report whether it was. - * - * `managed.disposed` is bookkeeping, not liveness: it says we tore the record - * down, not that the shell died. A shell can exit without node-pty producing - * `onExit`, which leaves a non-disposed entry holding a handle whose master fd - * is already closed. Only `isProcessAlive` (ESRCH, from the host that owns the - * process) is positive evidence of absence; every other outcome is - * `unverifiable` and keeps its record and owner claim - * (docs/reference/ssh-execution-boundary.md). - */ - private reapPtyProvenExited(managed: ManagedPty): boolean { - if (!managed.pty.pid || isProcessAlive(managed.pty.pid)) { - return false - } - this.reapExitedPty(managed, 'exited') - return true - } - - private async sendSignal(params: Record): Promise { - const id = params.id as string - const signal = params.signal as string - if (!ALLOWED_SIGNALS.has(signal)) { - throw new Error(`Signal not allowed: ${signal}`) - } - const managed = this.ptys.get(id) - // Why: dispose neutralizes pty.kill on POSIX; treat disposed as not-found so signals don't silently no-op. - if (!managed || managed.disposed) { - throw new Error(`PTY "${id}" not found`) - } - // Why only SIGWINCH: a real resize reaches the tty's foreground process group, - // and node-pty's kill targets the root pid, which the shell setpgid's away from. - // Host-local behavior only — no wire change, so an older client simply gets a - // SIGWINCH that now lands. Destructive signals keep node-pty's own path. - if (signal === 'SIGWINCH') { - signalPosixPtyForegroundGroup(managed.pty.pid, readPtsName(managed.pty), signal, () => { - managed.pty.kill(signal) - }) - return - } - managed.pty.kill(signal) - } - - private waitForPhysicalExit(managed: ManagedPty, timeoutMs: number): Promise { - const physicalExit = managed.physicalExit - if (!physicalExit) { - return Promise.reject(new Error(`PTY "${managed.id}" exit tracking unavailable`)) - } - return physicalExit.waitForExit( - timeoutMs, - () => new Error(`Timed out waiting for PTY process exit: ${managed.id}`) - ) - } - - private requestGracefulKill( - managed: ManagedPty, - fallbackAction: 'terminate stale' | 'force-kill' - ): void { - if (managed.gracefulKillSent) { - return - } - managed.gracefulKillSent = true - if (process.platform === 'win32') { - // Why: ConPTY's bare kill is already force-final; block any later close of the handle. - managed.forceKillSent = true - } - try { - killPtyProcess(managed.pty, 'SIGTERM') - } catch (error) { - managed.gracefulKillSent = false - managed.forceKillSent = false - throw error - } - if (process.platform === 'win32') { - return - } - // Why: POSIX children may ignore SIGTERM; arm a bounded SIGKILL fallback. - this.armForceKillFallback(managed, fallbackAction, 5000, PTY_FORCE_KILL_MAX_ATTEMPTS) - } - - private armForceKillFallback( - managed: ManagedPty, - fallbackAction: 'terminate stale' | 'force-kill', - delayMs: number, - attemptsRemaining: number - ): void { - managed.killTimer = setTimeout(() => { - managed.killTimer = undefined - const still = this.ptys.get(managed.id) - if (!still || still.disposed) { - return - } - try { - this.requestForceKill(still) - } catch (error) { - process.stderr.write( - `[pty-handler] failed to ${fallbackAction} PTY ${managed.id}: ${error instanceof Error ? error.message : String(error)}\n` - ) - // Why: a transient SIGKILL failure must not strand an unreachable remote shell. - if (attemptsRemaining > 1 && this.ptys.get(still.id) === still && !still.disposed) { - this.armForceKillFallback( - still, - fallbackAction, - PTY_FORCE_KILL_RETRY_DELAY_MS, - attemptsRemaining - 1 - ) - } - } - }, delayMs) - } - - private requestForceKill(managed: ManagedPty): void { - if (managed.forceKillSent || (process.platform === 'win32' && managed.gracefulKillSent)) { - return - } - managed.forceKillSent = true - try { - killPtyProcess(managed.pty, 'SIGKILL') - } catch (error) { - managed.forceKillSent = false - throw error - } - } - - private async getCwd(params: Record): Promise { - const id = params.id as string - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - throw new Error(`PTY "${id}" not found`) - } - return resolveProcessCwd(managed.pty.pid, managed.initialCwd) - } - - private async getInitialCwd(params: Record): Promise { - const id = params.id as string - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - throw new Error(`PTY "${id}" not found`) - } - return managed.initialCwd - } - - private async clearBuffer(params: Record): Promise { - const id = params.id as string - const managed = this.ptys.get(id) - if (managed && !managed.disposed) { - managed.startupIngress?.snapshotBarrier() - managed.pty.clear() - } - } - - // Why the replay buffer and not the stream: a zero-raw span never crosses the - // credit window, and the client grounds its own view; reattach replays this. - private async resetInputModes(params: Record): Promise { - const managed = this.ptys.get(params.id as string) - if (managed?.recoveryBarrier && !managed.disposed) { - this.appendReplayBuffer(managed, managed.recoveryBarrier.groundInputModes()) - } - } - - private async hasChildProcesses(params: Record): Promise { - const id = params.id as string - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - return false - } - // Fresh, not TTL-cached: this RPC exists to gate destructive decisions (the - // window-close confirmation, workspace cleanup's idle evidence), which act - // on the answer once. `pty.inspectProcess` below stays on the shared - // snapshot because it is the polled path, where a scan per pane per tick is - // the fork storm the cache removed. - return await processHasChildren(managed.pty.pid, { fresh: true }) - } - - private async getForegroundProcess(params: Record): Promise { - const id = params.id as string - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - return null - } - return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) - } - - private async inspectProcess(params: Record): Promise<{ - foregroundProcess: string | null - hasChildProcesses: boolean - childProcessEvidence?: PtyChildProcessVerdict - foregroundProcessEvidence?: RemoteForegroundEvidence - }> { - pruneRetiredPtyIncarnations(this.retiredIncarnations) - const id = params.id as string - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { - const tombstone = this.retiredIncarnations.get(id) - if ( - tombstone && - tombstone.expiresAt > Date.now() && - typeof params.expectedIncarnationId === 'string' && - params.expectedIncarnationId === tombstone.incarnationId - ) { - return { - foregroundProcess: null, - hasChildProcesses: false, - foregroundProcessEvidence: { - authorityGeneration: this.ptyIdMintEpoch, - observationEpoch: ++this.foregroundEvidenceEpoch, - capturedAgeMs: 0, - ptyId: id, - ptyIncarnationId: tombstone.incarnationId, - verdict: 'exited', - reason: `pty_exit_${tombstone.code}` - } - } - } - throw new Error('terminal_gone') - } - const expectedIncarnationId = params.expectedIncarnationId - if ( - expectedIncarnationId !== undefined && - (typeof expectedIncarnationId !== 'string' || - expectedIncarnationId.length === 0 || - expectedIncarnationId !== managed.incarnationId) - ) { - return { - foregroundProcess: null, - hasChildProcesses: false, - foregroundProcessEvidence: { - authorityGeneration: this.ptyIdMintEpoch, - observationEpoch: ++this.foregroundEvidenceEpoch, - capturedAgeMs: 0, - ptyId: id, - ptyIncarnationId: managed.incarnationId, - verdict: 'unverifiable', - reason: 'incarnation_mismatch' - } - } - } - let rows: readonly ProcessTableRow[] | null = null - // Set only when the budgeted evidence read gave up, so the compatibility fields below do not - // turn around and ask the same unreadable table again with no budget at all. - let tableUnavailable = false - let evidence: RemoteForegroundEvidence | undefined - if (process.platform === 'win32') { - // Why SSH-to-Windows is always unverifiable: POSIX has a real foreground primitive - // (the controlling terminal's foreground process group, tpgid/pgid), so the host can - // read which process is in front. Windows has no equivalent. Local Windows approximates - // it by reading the native process table and walking descendants of the PTY root pid - // (windows-foreground-process-rows.ts), but the relay has neither piece: it does not - // import windows-process-table, its getForegroundProcessName is POSIX-shaped - // (/proc, pgrep, lsof), and relay hosts run stock node-pty, so no ConPTY job/console - // association is available. Returning a descendant name without a creation-time and - // session fence would be a guess. Lifting this requires teaching the relay the Windows - // process table plus a measured creation-time/session fence - a separate change. - evidence = { - authorityGeneration: this.ptyIdMintEpoch, - observationEpoch: ++this.foregroundEvidenceEpoch, - capturedAgeMs: 0, - ptyId: id, - ptyIncarnationId: managed.incarnationId, - verdict: 'unverifiable', - reason: 'windows_ssh_foreground_unavailable' - } - } else { - try { - const snapshot = await getStrictProcessTableSnapshotWithAge() - rows = snapshot.rows - evidence = resolveRemoteForegroundEvidence( - { rootPid: managed.pty.pid, fallbackProcess: managed.pty.process || null }, - { - ptyId: id, - ptyIncarnationId: managed.incarnationId, - authorityGeneration: this.ptyIdMintEpoch, - observationEpoch: ++this.foregroundEvidenceEpoch, - capturedAgeMs: snapshot.capturedAgeMs, - platform: process.platform - }, - rows - ) - } catch { - tableUnavailable = true - evidence = { - authorityGeneration: this.ptyIdMintEpoch, - observationEpoch: ++this.foregroundEvidenceEpoch, - capturedAgeMs: 0, - ptyId: id, - ptyIncarnationId: managed.incarnationId, - verdict: 'unverifiable', - reason: 'process_table_unreadable' - } - } - } - // Preserve the compatibility field for older clients. New remote identity - // consumers ignore it unless the fenced evidence member is also accepted. - const foregroundProcess = - evidence?.verdict === 'live' - ? (evidence.processName ?? managed.pty.process) || null - : managed.pty.process || null - // Derive child liveness from the same capture; do not fork a second process-table probe for - // each field/pane in an event burst. - // - // Why Windows is gated on the caller asking: this is the one field whose Windows answer costs - // a process-table read, and `inspectProcess` is the polled path (750ms/2000ms per tracked - // pane). A relay host has no `@vscode/windows-process-tree`, so the read falls back to the - // 1.36s CIM scan, and polling that would reinstate exactly the fork storm the shared table - // exists to prevent (#15209, #15036). Close and cleanup decisions ask for the scan by name; - // a poll gets the honest `unverifiable` instead of a fabricated negative. - // Why `tableUnavailable` first: it means the budgeted evidence read already gave up. Without - // this arm `inspectPtyChildProcesses` re-enters `getProcessTableSnapshot()` and joins the very - // capture this call just abandoned, blocking for all of it and spending the whole latency the - // budget exists to avoid. The destructive `pty.hasChildProcesses` RPC keeps its fresh probe. - const childProcessEvidence: PtyChildProcessVerdict = rows - ? rows.some((row) => row.ppid === managed.pty.pid) - ? 'children' - : 'no-children' - : tableUnavailable - ? 'unverifiable' - : process.platform === 'win32' && params.scanChildProcesses !== true - ? 'unverifiable' - : await inspectPtyChildProcesses(managed.pty.pid) - return { - foregroundProcess, - // `unverifiable` keeps spelling itself `false` on the compatibility field, which is what - // every client too old to read the verdict receives. - hasChildProcesses: childProcessEvidence === 'children', - childProcessEvidence, - ...(evidence ? { foregroundProcessEvidence: evidence } : {}) - } - } - - private async listProcesses(params: Record = {}): Promise { - const results: PtyProcessSummary[] = [] - // Why (SSH-v3 P2 — the host is the authoritative liveness source, so it has to look): this - // listing is what publishes `agentSessionOwners`, i.e. "there is a live agent session here you - // can adopt". A shell can exit without node-pty's onExit, and an unverified entry advertised - // that session forever. Snapshot the map because reaping mutates it. - const managedEntries = Array.from(this.ptys) - // R1 seed evidence is additive and POSIX-only. Windows authorities retain - // the existing title/liveness path until the measured relay adapter lands. - // Desktop callers omit this additive field and retain the shipped list - // shape/cost; automatic inventory callers pass false explicitly to skip - // process-table work on the host. - const includeForegroundProcessEvidence = params.includeForegroundProcessEvidence !== false - let evidenceRows: readonly ProcessTableRow[] | null = null - // Same reason as `inspectProcess`: once the budgeted read has given up, the per-PTY title - // fallback below must not re-enter the same capture without a budget -- and here it would do - // so once per managed PTY. - let evidenceTableUnavailable = false - let evidenceResults: BatchedForegroundProcessResult[] = [] - const evidenceEpoch = ++this.foregroundEvidenceEpoch - // Worst-case capture time for the snapshot below, not the instant its await settled: the - // reader may serve a TTL-cached table. The WithAge reader returns the real age, so the - // stamp is exact rather than assuming the full staleness window. - let evidenceCapturedAtMs = Date.now() - if ( - includeForegroundProcessEvidence && - process.platform !== 'win32' && - managedEntries.length > 0 - ) { - try { - const evidenceSnapshot = await getStrictProcessTableSnapshotWithAge() - evidenceRows = evidenceSnapshot.rows - evidenceCapturedAtMs = Date.now() - evidenceSnapshot.capturedAgeMs - evidenceResults = await resolveAgentForegroundProcessesBatch( - managedEntries.map(([, managed]) => ({ - rootPid: managed.pty.pid, - fallbackProcess: managed.pty.process || null - })), - { rows: evidenceRows } - ) - } catch { - // An unreadable capture is represented as unverifiable evidence below; - // existing inventory fields remain available for old clients. - evidenceTableUnavailable = true - } - } - for (const [entryIndex, [id, managed]] of managedEntries.entries()) { - if (managed.disposed) { - this.reapExitedPty(managed, 'record-torn-down') - continue - } - if (this.reapPtyProvenExited(managed)) { - continue - } - // Reuse batched correlation; per-PTY tree scans recreate O(PTY × rows) work. - const title = - (evidenceRows - ? (evidenceResults[entryIndex]?.processName ?? managed.pty.process ?? null) - : includeForegroundProcessEvidence && !evidenceTableUnavailable - ? await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) - : managed.pty.process || null) || 'shell' - const foregroundProcessEvidence = - includeForegroundProcessEvidence && process.platform !== 'win32' - ? toForegroundProcessEvidence( - evidenceResults[entryIndex] ?? { - available: false, - processName: managed.pty.process || null, - reason: 'table_unreadable' - }, - { - authorityGeneration: this.ptyIdMintEpoch, - observationEpoch: evidenceEpoch, - capturedAgeMs: Math.max(0, Date.now() - evidenceCapturedAtMs) - } - ) - : undefined - const hostAgeMs = Math.max(0, Date.now() - managed.createdAt) - const agentSessionOwners = this.agentSessionOwners.listForPty(id) - results.push({ - id, - incarnationId: managed.incarnationId, - cwd: managed.initialCwd, - title, - hostAgeMs, - paneBound: Boolean(managed.paneKey ?? managed.attachIdentity?.paneKey), - ...(managed.ownerClientInstanceId - ? { ownerClientInstanceId: managed.ownerClientInstanceId } - : {}), - ...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}), - ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}), - ...(foregroundProcessEvidence ? { foregroundProcessEvidence } : {}), - ...(agentSessionOwners.length ? { agentSessionOwners } : {}) - }) - } - return results - } - - private async serialize(params: Record): Promise { - const ids = params.ids as string[] - const entries: SerializedPtyEntry[] = [] - for (const id of ids) { - const managed = this.ptys.get(id) - if (!managed) { - continue - } - const { pid, cols, rows } = managed.pty - entries.push({ - id, - pid, - cols, - rows, - cwd: managed.initialCwd, - paneKey: managed.paneKey, - tabId: managed.tabId, - attachIdentity: managed.attachIdentity, - worktreeId: managed.worktreeId, - ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), - envToDelete: managed.envToDelete, - ...(managed.openCodeCapabilities - ? { openCodeCapabilities: managed.openCodeCapabilities } - : {}), - gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, - ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), - // Why serialized: revive re-spawns the shell, and without these a WSL - // pane came back as the host default shell in another distro's history. - ...(managed.shellOverride ? { shellOverride: managed.shellOverride } : {}), - ...(managed.wslDistro ? { terminalWindowsWslDistro: managed.wslDistro } : {}), - ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) - }) - } - return JSON.stringify(entries) - } - - private async revive(params: Record): Promise { - const state = params.state as string - const entries = JSON.parse(state) as SerializedPtyEntry[] - - for (const entry of entries) { - if (this.ptys.has(entry.id) || this.pendingReviveIds.has(entry.id)) { - continue - } - // Only re-attach if the host proves the original process is still there. `isProcessAlive` - // is ESRCH-only for the same reason `reapPtyProvenExited` is: a refusal this host cannot - // resolve is unverifiable, not absence (docs/reference/ssh-execution-boundary.md). - if (!Number.isInteger(entry.pid) || entry.pid <= 0 || !isProcessAlive(entry.pid)) { - continue - } - const ownedPath = entry.worktreeId - ? splitWorktreeIdForFilesystem(entry.worktreeId)?.worktreePath - : undefined - const finishCreation = this.beginPtyCreation([ownedPath, entry.cwd]) - this.pendingReviveIds.add(entry.id) - try { - await this.reviveEntry(entry) - } finally { - this.pendingReviveIds.delete(entry.id) - finishCreation() - } - } - } - - private async reviveEntry(entry: SerializedPtyEntry): Promise { - const ptyMod = await this.loadPty() - if (!ptyMod) { - return - } - // Why: pane identity comes from the serialized entry (not env) since hook scripts exit without ORCA_PANE_KEY. - const revivedEnv: Record = {} - if (entry.paneKey) { - revivedEnv.ORCA_PANE_KEY = entry.paneKey - } - if (entry.tabId) { - revivedEnv.ORCA_TAB_ID = entry.tabId - } - if (entry.worktreeId) { - revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId - } - if (entry.terminalHandle) { - revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle - } - const explicitTerm = - typeof entry.explicitTerm === 'string' && entry.explicitTerm.length > 0 - ? entry.explicitTerm - : undefined - if (explicitTerm !== undefined) { - revivedEnv.TERM = explicitTerm - } - // Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds. - const envToDelete = sanitizeEnvToDelete(entry.envToDelete) - const shellOverride = typeof entry.shellOverride === 'string' ? entry.shellOverride.trim() : '' - const resolvedShellOverride = resolveRevivedShellOverride(shellOverride) - const shell = resolvedShellOverride || resolveDefaultShell() - // Mirrors spawn: the entry's override is what gets re-launched, so a WSL - // pane needs the same guest-visible HISTFILE and the same WSLENV carrier. - const wslShell = isRelayWslShell(shell) - // Why cwd is re-checked: it is the one serialized field revive still took on trust, and it only - // proves the directory existed when the client wrote it down. A worktree removed since leaves - // node-pty to _exit(1) the child on POSIX (a pane revived already dead) and to throw on Windows, - // which escapes the loop and costs every later entry its state. Same call as the shell override - // below: drop this one pane rather than substitute a directory it was never pointed at. Skipped - // for a WSL shell, whose cwd lives in a guest that never stats on this host. - if (!wslShell && !relayHostDirectoryExists(entry.cwd)) { - return - } - const terminalWindowsWslDistro = - typeof entry.terminalWindowsWslDistro === 'string' && - entry.terminalWindowsWslDistro.length <= MAX_REVIVED_WSL_DISTRO_LENGTH - ? entry.terminalWindowsWslDistro - : null - const historyIsolationEnabled = entry.historyIsolationEnabled === true - const spawnEnv = await this.buildSpawnEnv( - revivedEnv, - { id: entry.id, paneKey: entry.paneKey, shell }, - envToDelete - ) - const openCodeCapabilities = restoreOpenCodeCapabilities(entry.openCodeCapabilities) - applyOpenCodePluginSelection(spawnEnv, envToDelete, openCodeCapabilities, wslShell) - if ( - historyIsolationEnabled && - entry.worktreeId && - basename(shell).toLowerCase().startsWith('fish') - ) { - injectRelayFishHistoryEnv(spawnEnv, entry.worktreeId) - } - if (wslShell) { - addWslEnvKeys(spawnEnv, [ORCA_IMAGE_PROTOCOL_ENV]) - } - if (historyIsolationEnabled && entry.worktreeId) { - const historyRoot = injectRelayHistoryEnv(spawnEnv, entry.worktreeId, shell, { - wsl: wslShell - }) - if (wslShell && historyRoot) { - addWslEnvKeys(spawnEnv, ['HISTFILE']) - } - } - // Why: revive lacks the original launch command, so reuse the fresh-spawn guard decision (legacy defaults to unguarded). - const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true - if (gitCredentialPromptGuarded) { - Object.assign(spawnEnv, gitCredentialPromptGuardEnv(spawnEnv, process.platform)) - } - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { - terminalWindowsWslDistro - }) - const ptyEnv: Record = { - ...spawnEnv, - [SHELL_STARTUP_FEATURE_ENV]: '', - ...shellLaunch.env - } - let term: IPty - try { - term = ptyMod.spawn(shell, shellLaunch.args, { - name: spawnEnv.TERM ?? 'xterm-256color', - cols: entry.cols, - rows: entry.rows, - cwd: entry.cwd, - // Why: no provider-delivered command is waiting for a ready marker. - env: ptyEnv, - ...this.conptyDllSpawnOptions() - }) - } catch (error) { - // Why skip rather than retry the host default shell: the stored override - // names a shell that existed at serialize time and may not now (an - // uninstalled WSL takes wsl.exe with it), and substituting a different - // shell is the defect this override exists to fix -- its args and its - // history env are built for the shell that is gone. Dropping one pane is - // the honest outcome; letting the throw escape the revive loop would cost - // every later entry its state too. The worktree-removal fence throws - // before this, outside reviveEntry, so it stays a hard failure. - if (!resolvedShellOverride) { - throw error - } - return - } - this.wireAndStore({ - id: entry.id, - incarnationId: randomUUID(), - pty: term, - initialCwd: entry.cwd, - createdAt: Date.now(), - // Deliberately no ownerClientInstanceId: revive replays state a client serialized, which is - // not this host observing who asked for the shell. Unattested means never swept. - - buffered: new RecentPtyOutputBuffer({ - preserveChunkBoundaries: false, - limit: REPLAY_BUFFER_MAX - }), - paneKey: entry.paneKey, - agentLaunchToken: ptyEnv.ORCA_AGENT_LAUNCH_TOKEN?.trim() || undefined, - tabId: entry.tabId, - attachIdentity: entry.attachIdentity, - worktreeId: entry.worktreeId, - ...(explicitTerm !== undefined ? { explicitTerm } : {}), - envToDelete, - gitCredentialPromptGuarded, - ...(openCodeCapabilities ? { openCodeCapabilities } : {}), - ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), - shellPath: shell, - // Why re-stored: a revived pane can be serialized again, and losing the - // override on the second round trip is the same bug one restart later. - ...(resolvedShellOverride ? { shellOverride } : {}), - ...(terminalWindowsWslDistro ? { wslDistro: terminalWindowsWslDistro } : {}), - ownerBackend: resolvePtyOwnerBackend({ - platform: process.platform, - shellPath: shell, - wslDistro: terminalWindowsWslDistro - }), - ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) - }) - - const match = entry.id.match(/^pty-(\d+)$/) - if (match) { - this.nextId = Math.max(this.nextId, Number.parseInt(match[1], 10) + 1) - } - } - - startGraceTimer(onExpire: () => void, timeoutMs = this.graceTimeMs): void { - this.cancelGraceTimer() - if (timeoutMs === 0) { - return - } - // Why: connected relays keep the configured grace so live PTYs survive restarts/reconnects. - this.graceTimer = setTimeout(() => { - onExpire() - }, timeoutMs) - } - - cancelGraceTimer(): void { - if (this.graceTimer) { - clearTimeout(this.graceTimer) - this.graceTimer = null - } - } - - /** - * Reap every owned PTY synchronously, for the fatal-exit path only. - * - * Runs to completion across all PTYs: one shell that refuses to die must not - * strand the rest. The first failure is rethrown so the caller can record it -- - * a reap that failed on a remote host is otherwise invisible. - */ - forceKillAllPtyProcesses(): void { - let firstError: unknown - let hasError = false - for (const managed of this.ptys.values()) { - try { - // Why mark rather than skip: the job already took the whole tree, and the - // flag is what suppresses the redundant signal -- here in requestForceKill, - // and in any dispose that still runs after this. - if (process.platform === 'win32' && terminatePtyJob(managed.pty) === 'terminated') { - managed.forceKillSent = true - } - this.requestForceKill(managed) - } catch (error) { - if (!hasError) { - firstError = error - hasError = true - } - } - } - if (hasError) { - throw firstError - } - } - - dispose(options: { waitForPhysicalExit?: boolean } = {}): Promise { - // Why: fence synchronously before the first await so a spawn/revive can't slip past disposal and escape exit. - this.creationFenced = true - if (this.disposePromise) { - return this.disposePromise - } - this.removeLegacyCapacityListener?.() - this.removeLegacyCapacityListener = null - this.agentSessionCreateOperations.clear() - const disposePromise = this.disposePtys(options.waitForPhysicalExit !== false) - this.disposePromise = disposePromise - void disposePromise.catch(() => { - // Why: clear on rejected kill so a later shutdown can retry instead of joining a rejected promise. - if (this.disposePromise === disposePromise) { - this.disposePromise = null - } - }) - return disposePromise - } - - private async disposePtys(waitForPhysicalExit: boolean): Promise { - this.cancelGraceTimer() - await this.waitForPendingPtyCreations() - for (const managed of this.ptys.values()) { - this.releaseRelayIngress(managed) - this.flushPtyOutput(managed.id) - } - if (this.outputFlushTimer !== null) { - clearTimeout(this.outputFlushTimer) - this.outputFlushTimer = null - } - this.pendingOutputByPty.clear() - this.pendingProducerBytesByPty.clear() - this.pendingExitByPty.clear() - this.pausedOutputPtys.clear() - this.consumerPausedOutputPtys.clear() - this.lastInputAtByPty.clear() - this.interactiveOutputCharsByPty.clear() - this.sourcePublication?.dispose() - this.sourcePublication = null - const results = await Promise.allSettled( - [...this.ptys.values()].map((managed) => - this.disposePtyForRelayShutdown(managed, waitForPhysicalExit) - ) - ) - const rejected = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (rejected) { - throw rejected.reason - } - } - - private async disposePtyForRelayShutdown( - managed: ManagedPty, - waitForPhysicalExit: boolean - ): Promise { - if (managed.killTimer) { - clearTimeout(managed.killTimer) - managed.killTimer = undefined - } - this.clearStartupCommandTimer(managed) - this.releaseRelayIngress(managed) - // Why: retain the native owner until SIGKILL is accepted (one bounded retry) or onExit proves it gone. - await this.requestForceKillForRelayShutdown(managed) - if (waitForPhysicalExit && this.ptys.get(managed.id) === managed && !managed.disposed) { - try { - await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) - } catch { - // An accepted SIGKILL is the final boundary when an uninterruptible child can't report exit. - } - } - if (this.ptys.get(managed.id) === managed && !managed.disposed) { - this.notifyExitListener(managed) - this.agentSessionOwners.release(managed.id) - disposeManagedPty(managed) - this.removePty(managed.id) - this.clearPtyFlowState(managed.id) - } - } - - private async requestForceKillForRelayShutdown(managed: ManagedPty): Promise { - let lastError: unknown - for (let attempt = 0; attempt < PTY_FORCE_KILL_MAX_ATTEMPTS; attempt++) { - if (this.ptys.get(managed.id) !== managed || managed.disposed) { - return - } - try { - this.requestForceKill(managed) - return - } catch (error) { - lastError = error - } - if (attempt + 1 < PTY_FORCE_KILL_MAX_ATTEMPTS) { - const tracker = managed.physicalExit - if (!tracker) { - throw lastError - } - try { - await tracker.waitForExit( - PTY_FORCE_KILL_RETRY_DELAY_MS, - () => new Error(`Retrying force-kill for PTY ${managed.id}`) - ) - return - } catch { - // The bounded waiter detached; retry the still-owned native handle. - } - } - } - throw lastError - } - - get activePtyCount(): number { - return this.ptys.size - } - - /** Spawns admitted but not yet in the pool — each already owns a shell the relay must not treat as idle. */ - get pendingPtyCreationCount(): number { - return this.pendingSpawnCount - } - - get retainedStartupCommandCount(): number { - let count = 0 - for (const managed of this.ptys.values()) { - if (managed.startupCommand) { - count += 1 - } - } - return count - } - - get retainedStartupCommandBytes(): number { - let bytes = 0 - for (const managed of this.ptys.values()) { - bytes += managed.startupCommand?.command?.length ?? 0 - } - return bytes - } - - get graceTimerActive(): boolean { - return this.graceTimer !== null - } -} diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts deleted file mode 100644 index 92ae2437f76..00000000000 --- a/src/relay/pty-shell-launch.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { spawnSync } from 'node:child_process' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { buildSshPtySpawnEnv } from '../main/providers/ssh-pty-spawn-env' -import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' - -const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 -const itWithBash = hasBash ? it : it.skip - -function runInteractiveBashRcfile( - rcfile: string, - homeDir: string, - input = 'true\nfalse\nexit 0\n' -): string { - const result = spawnSync( - 'bash', - ['-lc', 'bash --noprofile --rcfile "$1" -i 2>&1', 'bash', rcfile], - { - input, - encoding: 'utf8', - env: { - ...process.env, - HOME: homeDir, - TERM: process.env.TERM || 'xterm' - }, - timeout: 5000 - } - ) - - expect(result.error).toBeUndefined() - expect(result.status).toBe(0) - return result.stdout -} - -function expectBashOsc133Lifecycle(output: string): void { - const oscA = '\x1b]133;A\x07' - const oscC = '\x1b]133;C\x07' - const oscD = '\x1b]133;D;' - const firstPromptMarker = output.indexOf(oscA) - const lifecyclePattern = new RegExp( - `${String.fromCharCode(27)}]133;(?:A|C|D;[0-9]+)${String.fromCharCode(7)}`, - 'g' - ) - - expect(firstPromptMarker).toBeGreaterThanOrEqual(0) - expect(output.slice(0, firstPromptMarker)).not.toContain(oscC) - expect(output.slice(0, firstPromptMarker)).not.toContain(oscD) - expect(output.match(lifecyclePattern)).toEqual([ - oscA, - oscC, - `${oscD}0\x07`, - oscA, - oscC, - `${oscD}1\x07`, - oscA, - oscC - ]) -} - -describe('isRelayWslShell', () => { - it.each(['wsl.exe', 'WSL.EXE', 'C:\\Windows\\System32\\wsl.exe', 'wsl'])( - 'recognizes %s on a Windows relay', - (shell) => { - expect(isRelayWslShell(shell, 'win32')).toBe(true) - } - ) - - it.each([ - ['C:\\Program Files\\Git\\bin\\bash.exe', 'win32' as const], - ['/bin/bash', 'win32' as const], - // A POSIX remote reaches no distro, so a like-named binary is an ordinary shell. - ['wsl.exe', 'linux' as const] - ])('does not treat %s on %s as a WSL launch', (shell, platform) => { - expect(isRelayWslShell(shell, platform)).toBe(false) - }) -}) - -describe('getRelayShellLaunchConfig', () => { - let homeDir: string - - beforeEach(() => { - homeDir = mkdtempSync(join(tmpdir(), 'relay-shell-launch-')) - }) - - afterEach(() => { - rmSync(homeDir, { recursive: true, force: true }) - }) - - it.skipIf(process.platform === 'win32')( - 'preserves a user ZDOTDIR exported from .zshenv for later startup files', - () => { - const config = getRelayShellLaunchConfig('/bin/zsh', { - HOME: homeDir, - ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay' - }) - const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') - - expect(config.args).toEqual(['-l']) - expect(config.env.ZDOTDIR).toBe(zshRoot) - const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') - // Why no ORCA_USER_ZDOTDIR: the relay used to republish the inherited - // ZDOTDIR under that name so its three later wrapper files could prefer it - // over the spawn-time value. There are no later wrapper files, and a - // ZDOTDIR the user's own .zshenv exports simply stands. - expect(zshenv).not.toContain('ORCA_USER_ZDOTDIR') - expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') - expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') - for (const name of ['.zprofile', '.zshrc', '.zlogin']) { - expect(existsSync(join(zshRoot, name))).toBe(false) - } - // Why .zshenv: the final restore is the last step of the one epilogue. - } - ) - - it('does not pass POSIX login flags to Windows shells', () => { - expect( - getRelayShellLaunchConfig('C:\\Windows\\System32\\cmd.exe', { HOME: homeDir }, 'win32') - ).toEqual({ - args: [], - env: {}, - supportsReadyMarker: false - }) - expect( - getRelayShellLaunchConfig( - 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', - { HOME: homeDir }, - 'win32' - ) - ).toEqual({ - args: ['-NoLogo'], - env: {}, - supportsReadyMarker: false - }) - }) - - it.skipIf(process.platform === 'win32')( - 'wraps an ordinary SSH pane, as every remote pane with a CLI bridge already was', - () => { - // Why the real builder: the relay's wrapping rule is only meaningful - // against the env main actually sends. Every relay session with a CLI - // bridge sets ORCA_REMOTE_CLI_BIN_DIR, which was already enough to wrap. - const env = buildSshPtySpawnEnv({ - env: { HOME: homeDir, PATH: '/usr/bin:/bin' }, - remoteCliBridgeEnv: { - binDir: '/home/remote/.orca-relay/bin', - relayDir: '/home/remote/.orca-relay', - nodePath: '/home/remote/.orca-relay/node', - sockPath: '/home/remote/.orca-relay/relay.sock' - } - }) - env.ORCA_HISTFILE = join(homeDir, 'orca-history', 'zsh_history') - - const config = getRelayShellLaunchConfig('/bin/zsh', env) - - expect(config.env.ZDOTDIR).toBe(join(homeDir, '.orca-relay', 'shell-ready', 'zsh')) - expect(config.env.ORCA_SHELL_FEATURES).toBe('overlay,history,markers') - } - ) - - it.skipIf(process.platform === 'win32')( - 'repairs worktree history on a remote pane whose host reports no CLI bridge', - () => { - // Why this env shape: a host too old to report its platform leaves - // remoteCliBridgeEnv null, so the pane carries no overlay key at all. - // It is the one pane class the relay was NOT already wrapping, and - // leaving it unwrapped silently loses its worktree history. - const env = buildSshPtySpawnEnv({ env: { HOME: homeDir, PATH: '/usr/bin:/bin' } }) - env.ORCA_HISTFILE = join(homeDir, 'orca-history', 'zsh_history') - - const config = getRelayShellLaunchConfig('/bin/zsh', env) - - expect(config.env.ZDOTDIR).toBe(join(homeDir, '.orca-relay', 'shell-ready', 'zsh')) - expect(config.env.ORCA_SHELL_FEATURES).toBe('history') - } - ) - - it.skipIf(process.platform === 'win32')( - 'keeps a remote zsh with nothing Orca-owned on the plain login path', - () => { - const env = buildSshPtySpawnEnv({ env: { HOME: homeDir, PATH: '/usr/bin:/bin' } }) - - expect(getRelayShellLaunchConfig('/bin/zsh', env)).toEqual({ - args: ['-l'], - env: {}, - supportsReadyMarker: false - }) - } - ) - - it('keeps PowerShell Core on POSIX remotes as a login shell', () => { - expect(getRelayShellLaunchConfig('/usr/bin/pwsh', { HOME: homeDir }, 'linux')).toEqual({ - args: ['-l'], - env: {}, - supportsReadyMarker: false - }) - }) - - it.skipIf(process.platform === 'win32')('rewrites stale persistent wrapper files', () => { - const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') - mkdirSync(zshRoot, { recursive: true }) - writeFileSync(join(zshRoot, '.zshenv'), '# stale relay wrapper\n') - - getRelayShellLaunchConfig('/bin/zsh', { - HOME: homeDir, - ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay' - }) - - expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"' - ) - }) - - it.skipIf(process.platform === 'win32')( - 'wraps zsh when MiMo home must survive shell startup', - () => { - const config = getRelayShellLaunchConfig('/bin/zsh', { - HOME: homeDir, - ORCA_MIMOCODE_HOME: '/tmp/orca-mimocode-overlay' - }) - const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') - // Why .zshenv: the overlay restores live in the one epilogue defined there. - const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') - - expect(config.args).toEqual(['-l']) - expect(config.env.ZDOTDIR).toBe(zshRoot) - expect(config.env.ORCA_SHELL_FEATURES).toBe('overlay,history,markers') - expect(zshenv).toContain( - '[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"' - ) - } - ) - - it.skipIf(process.platform === 'win32')( - 'wraps bash even without overlay env for OSC 133 lifecycle markers', - () => { - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) - const rcfile = join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile') - const bashRc = readFileSync(rcfile, 'utf8') - - expect(config.args).toEqual(['--rcfile', rcfile]) - // Why the empty allowlist: bash keeps its unconditional OSC 133 hooks, and - // an explicit empty value also overrides anything the relay inherited. - expect(config.env).toEqual({ ORCA_SHELL_FEATURES: '' }) - expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') - expect(bashRc).toContain('printf "\\033]133;C\\007"') - } - ) - - it.skipIf(process.platform === 'win32')( - 'enables the shell-ready marker for requested zsh startup delivery', - () => { - const config = getRelayShellLaunchConfig('/bin/zsh', { HOME: homeDir }, 'linux', { - emitReadyMarker: true - }) - const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') - const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') - - expect(config.args).toEqual(['-l']) - expect(config.env.ZDOTDIR).toBe(zshRoot) - expect(config.supportsReadyMarker).toBe(true) - expect(config.env.ORCA_SHELL_FEATURES).toContain('ready') - expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - // Why: the channel is destroyed before the user's own config is sourced. - expect(zshenv).toContain('builtin unset ORCA_SHELL_FEATURES') - expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') - expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') - } - ) - - it.skipIf(process.platform === 'win32')( - 'enables the shell-ready marker for requested bash startup delivery', - () => { - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }, 'linux', { - emitReadyMarker: true - }) - const bashRc = readFileSync(config.args[1] as string, 'utf8') - - expect(config.supportsReadyMarker).toBe(true) - expect(config.env.ORCA_SHELL_FEATURES).toContain('ready') - expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(bashRc).toContain('builtin unset ORCA_SHELL_FEATURES') - expect(bashRc).toContain('__orca_append_prompt_command "__orca_prompt_mark"') - expect(bashRc).toContain('printf "\\033]777;orca-shell-ready\\007"') - } - ) - - it.skipIf(process.platform === 'win32')( - 'wraps zsh when only startup identity emission is requested', - () => { - const config = getRelayShellLaunchConfig('/bin/zsh', { HOME: homeDir }, 'linux', { - emitStartupIdentity: true - }) - - expect(config.env.ZDOTDIR).toBe(join(homeDir, '.orca-relay', 'shell-ready', 'zsh')) - expect(config.env.ORCA_SHELL_FEATURES).toContain('identity') - // Why the negative half: identity emission alone must not arm the - // readiness handshake, or the delivering side waits for a marker that - // this shell was never told to print. - expect(config.env.ORCA_SHELL_FEATURES).not.toContain('ready') - expect(config.supportsReadyMarker).toBe(false) - } - ) - - itWithBash('runs the relay bash wrapper without fake C/D markers before the first prompt', () => { - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) - const bashRc = readFileSync(config.args[1] as string, 'utf8') - const output = runInteractiveBashRcfile(config.args[1] as string, homeDir) - - expect(bashRc).toContain('[[ -z "${__orca_in_command:-}" ]] || return 0') - expectBashOsc133Lifecycle(output) - }) - - itWithBash('emits lifecycle for foreground text ending like an internal hook', () => { - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) - const input = 'echo user:__orca_osc133_prompt_done\nfalse\nexit 0\n' - const output = runInteractiveBashRcfile(config.args[1] as string, homeDir, input) - - expect(output).toContain('user:__orca_osc133_prompt_done') - expectBashOsc133Lifecycle(output) - }) - - itWithBash('preserves relay bash prompt hooks and DEBUG traps without fake markers', () => { - writeFileSync( - join(homeDir, '.bash_profile'), - [ - 'PROMPT_COMMAND=\'AFTER_FIRST_PROMPT=1; printf "PROMPT_HOOK\\n"\'', - 'trap \'if [[ -n "${AFTER_FIRST_PROMPT:-}" ]]; then\n printf "USER_DEBUG_AFTER:<%s>\\n" "$BASH_COMMAND"\nfi\' DEBUG' - ].join('\n') - ) - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) - const output = runInteractiveBashRcfile(config.args[1] as string, homeDir) - - expect(output).toContain('PROMPT_HOOK') - expect(output).toContain('USER_DEBUG_AFTER') - expect(output).toContain('USER_DEBUG_AFTER:') - expect(output).not.toContain('USER_DEBUG_AFTER:<(( __orca_exit_code == 0 ))>') - expect(output).not.toContain('USER_DEBUG_AFTER:<__orca_restore_prompt_status') - expectBashOsc133Lifecycle(output) - }) - - itWithBash('forwards a DEBUG trap replaced with relay functrace', () => { - writeFileSync( - join(homeDir, '.bash_profile'), - [ - 'set -T', - 'trap \'printf "OLD_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG', - 'PROMPT_COMMAND=\'printf "PROMPT_HOOK\\n"\'' - ].join('\n') - ) - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) - const input = 'trap \'printf "NEW_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG\nfalse\nexit 0\n' - const output = runInteractiveBashRcfile(config.args[1] as string, homeDir, input) - - expect(output.split('OLD_DEBUG:')).toHaveLength(2) - expect(output.split('NEW_DEBUG:')).toHaveLength(3) - expectBashOsc133Lifecycle(output) - }) - - itWithBash('normalizes relay bash array PROMPT_COMMAND hooks', () => { - writeFileSync( - join(homeDir, '.bash_profile'), - 'PROMPT_COMMAND=(\'printf "PROMPT_ARRAY_A\\n"\' \'printf "PROMPT_ARRAY_B\\n"; \')\n' - ) - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) - const output = runInteractiveBashRcfile(config.args[1] as string, homeDir) - - expect(output.split('PROMPT_ARRAY_A')).toHaveLength(4) - expect(output.split('PROMPT_ARRAY_B')).toHaveLength(4) - expectBashOsc133Lifecycle(output) - }) - - // Why: RHEL-family /etc/bashrc prepends "history -a; " to PROMPT_COMMAND - // outside its BASHRCSOURCED guard (repeated across re-sources), so the value - // Orca inherits ends in a ";"+whitespace separator. Prepend/append must not - // splice an empty command (";;") that breaks the prompt with a syntax error. - itWithBash('normalizes an inherited PROMPT_COMMAND ending in a separator', () => { - writeFileSync( - join(homeDir, '.bash_profile'), - 'PROMPT_COMMAND=\'AFTER_SEP_PROMPT=1; printf "PROMPT_SEP\\n"; \'\n' - ) - const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) - const output = runInteractiveBashRcfile(config.args[1] as string, homeDir) - - expect(output).not.toContain('syntax error') - expect(output).toContain('PROMPT_SEP') - expectBashOsc133Lifecycle(output) - }) -}) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts deleted file mode 100644 index f617903056b..00000000000 --- a/src/relay/pty-shell-launch.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { - encodeShellStartupFeatures, - selectShellStartupFeatures, - SHELL_STARTUP_FEATURE_ENV, - type ShellStartupFeature -} from '../main/shell-startup-features' -import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' -import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' -const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' -const POSIX_LOGIN_ARGS = ['-l'] - -export type RelayShellLaunchConfig = { - args: string[] - env: Record - supportsReadyMarker: boolean -} - -function shellBasename(shellPath: string): string { - return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' -} - -/** The outer exe of a WSL launch; the shell the user actually types into lives - * inside the distro, so history/env handling must look past this name. */ -export function isRelayWslShell( - shellPath: string, - platform: NodeJS.Platform = process.platform -): boolean { - if (platform !== 'win32') { - return false - } - const name = shellBasename(shellPath) - return name === 'wsl.exe' || name === 'wsl' -} - -function windowsShellArgs( - shellName: string, - options: { terminalWindowsWslDistro?: string | null } = {} -): string[] | null { - if (shellName === 'powershell.exe' || shellName === 'powershell') { - return ['-NoLogo'] - } - if (shellName === 'pwsh.exe' || shellName === 'pwsh') { - return ['-NoLogo'] - } - if (shellName === 'cmd.exe' || shellName === 'cmd') { - return [] - } - if (shellName === 'wsl.exe' || shellName === 'wsl') { - const distro = options.terminalWindowsWslDistro?.trim() - return distro ? ['-d', distro] : [] - } - return null -} - -function getWrapperRoot(env: Record): string { - return join(env.HOME || process.env.HOME || homedir(), RELAY_SHELL_READY_DIR) -} - -export function getRelayShellLaunchConfig( - shellPath: string, - env: Record, - platform: NodeJS.Platform = process.platform, - options: { - emitReadyMarker?: boolean - emitStartupIdentity?: boolean - terminalWindowsWslDistro?: string | null - } = {} -): RelayShellLaunchConfig { - const shellName = shellBasename(shellPath) - const unwrapped: RelayShellLaunchConfig = { - args: POSIX_LOGIN_ARGS, - env: {}, - supportsReadyMarker: false - } - if (platform === 'win32') { - // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must - // only apply when the relay itself is running on native Windows. - return { - args: - windowsShellArgs(shellName, { - terminalWindowsWslDistro: options.terminalWindowsWslDistro - }) ?? [], - env: {}, - supportsReadyMarker: false - } - } - - if (shellName !== 'zsh' && shellName !== 'bash') { - return unwrapped - } - - // Why both map to the same flag: the relay only wraps for a startup command - // when that command's delivery asked for the readiness handshake. - const startupCommandRequested = - options.emitReadyMarker === true || options.emitStartupIdentity === true - const features = selectShellStartupFeatures({ - shellPath: shellName, - env, - hasStartupCommand: startupCommandRequested, - waitsForShellReady: options.emitReadyMarker === true, - emitsStartupIdentity: options.emitStartupIdentity === true - }) - // Why bash is always wrapped: its rcfile carries the OSC 133 command-lifecycle - // hooks unconditionally today, and dropping them would strand agent rows on - // "working". zsh keeps the plain startup fast path when nothing needs it — - // the same `features.length` rule the local and daemon transports use. - if (shellName !== 'bash' && features.length === 0) { - return unwrapped - } - - const root = getWrapperRoot(env) - let wrappersReady = false - try { - wrappersReady = ensureOverlayRestoreWrappers(root) - } catch { - // Why swallow: a remote HOME can be read-only or root-owned (EACCES), and - // that must not stop the pane from opening at all. - wrappersReady = false - } - if (!wrappersReady) { - // Why plain login shell: ZDOTDIR pointed at an incomplete wrapper dir makes - // zsh skip the user's whole config. Losing Orca's features is recoverable. - return unwrapped - } - - const featureEnv = { - [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) - } - const supportsReadyMarker = features.includes('ready') - - if (shellName === 'zsh') { - return { - args: POSIX_LOGIN_ARGS, - env: { - ...inheritedZdotdirEnv(resolveInheritedZdotdir(env)), - ZDOTDIR: join(root, 'zsh'), - ...featureEnv - }, - supportsReadyMarker - } - } - - return { - args: ['--rcfile', join(root, 'bash', 'rcfile')], - env: featureEnv, - supportsReadyMarker - } -} - -export type { ShellStartupFeature } diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts deleted file mode 100644 index 82ad3a7fb85..00000000000 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { readFileSync, statSync } from 'node:fs' -import { join } from 'node:path' -import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' -import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' -import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' -import { - BASH_FEATURE_CHANNEL_BLOCK, - BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, - SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - BASH_HISTFILE_RESTORE_BLOCK, - ZSH_WRAPPER_DIR_MARKER_CONTENT, - ZSH_WRAPPER_DIR_MARKER_FILE -} from '../main/shell-templates' -import { writeShellWrapperFiles } from '../main/shell-wrapper-file-writer' -import { buildZshStartupHook, type ZshStartupHookSpec } from '../main/zsh-startup-wrapper-builder' - -/** Writes the zsh/bash overlay wrapper files a relay-spawned shell sources. - * Split from pty-shell-launch.ts so the launch-config decisions stay readable - * next to each other rather than buried under ~150 lines of shell templates. */ - -const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' - -// Why the relay no longer needs its own ZDOTDIR shape: it used to republish the -// inherited value as ORCA_USER_ZDOTDIR so the later wrapper files could prefer -// it over the spawn-time ORCA_ORIG_ZDOTDIR. There are no later wrapper files -// now, and ZDOTDIR itself carries the answer, so the relay and desktop bodies -// are one template again. -function getRelayZshWrapperSpec(): ZshStartupHookSpec { - return { - headerLabel: 'Orca relay zsh overlay wrapper', - readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, - osc133CommandMarkers: false, - startupCommandDelivery: false, - overlayRestoreComment: - '# Why: remote startup files can re-export user defaults after relay spawn.', - restores: { - managedWslCli: false, - agentTeamsPath: false, - remoteCliBinDir: true, - codexHome: false - } - } -} - -/** True when every overlay wrapper file is present and non-empty afterwards. */ -export function ensureOverlayRestoreWrappers(root: string): boolean { - const zshDir = join(root, 'zsh') - const bashDir = join(root, 'bash') - - const zshenv = buildZshStartupHook(getRelayZshWrapperSpec()) - const bashRc = `# Orca relay bash overlay wrapper -${BASH_FEATURE_CHANNEL_BLOCK} -${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} -# Why a plain variable: the channel is consumed and destroyed in these first -# lines, so nothing this shell later spawns can see or inherit the selection. -__orca_ready_marker="" -__orca_has_feature ready && __orca_ready_marker=1 -unset _orca_shell_features -unset -f __orca_has_feature -[[ -f /etc/profile ]] && source /etc/profile -if [[ -f "$HOME/.bash_profile" ]]; then - source "$HOME/.bash_profile" -elif [[ -f "$HOME/.bash_login" ]]; then - source "$HOME/.bash_login" -elif [[ -f "$HOME/.profile" ]]; then - source "$HOME/.profile" -fi -# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as -# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds -# 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 -# Why: remote startup files can re-export user defaults after relay spawn. -[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" -[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac -${ORCA_CLI_POSIX_PATH_RESTORE} -${getPosixOmpShellWrapper()} -${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} -# Why: SSH bash sessions need the same command lifecycle markers as local -# bash so agent rows stop showing "working" when the foreground command exits. -__orca_initializing_wrapper=1 -__orca_osc133_precmd() { - local exit_code=$? - __orca_in_prompt_command=1 - if [[ -n "\${__orca_in_command:-}" ]]; then - printf "\\033]133;D;%s\\007" "$exit_code" - unset __orca_in_command - fi - printf "\\033]133;A\\007" - return "$exit_code" -} -__orca_osc133_prompt_done() { - unset __orca_in_prompt_command; __orca_adopt_outer_debug_trap - trap '__orca_osc133_preexec' DEBUG -} -__orca_osc133_preexec() { - if [[ -n "\${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then - unset __orca_initial_prompt - __orca_in_legacy_prompt_wrapper=1 - return 0 - fi - if [[ -n "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}\${__orca_initial_prompt:-}\${__orca_in_prompt_dispatch:-}\${__orca_in_legacy_prompt_wrapper:-}\${__orca_in_prompt_command:-}" ]]; then - [[ -z "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}" ]] || return 0 - if [[ -n "\${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then - unset __orca_initial_prompt; return 0 - fi - if [[ -n "\${__orca_in_prompt_dispatch:-}" ]]; then - [[ -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 - if [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then - case "$BASH_COMMAND" in - '(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; - esac - fi - elif [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then - return 0 - fi - [[ -z "\${__orca_in_legacy_prompt_wrapper:-}" || -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 - if [[ -n "\${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then - return 0 - fi - fi - case "\${FUNCNAME[1]:-}" in __orca_osc133_*|__orca_prompt_mark|__orca_restore_prompt_status) return 0 ;; esac - case "$BASH_COMMAND" in __orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; esac - __orca_run_user_debug_trap - [[ -z "\${__orca_in_prompt_command:-}" ]] || return 0 - [[ -z "\${__orca_in_command:-}" ]] || return 0 - printf "\\033]133;C\\007" - __orca_in_command=1 -} -${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK} -__orca_prepend_prompt_command "__orca_osc133_precmd" -# Why: SSH startup commands are renderer-delivered; emit the same internal -# readiness marker as local shells only when that delivery mode asks for it. -if [[ -n "$__orca_ready_marker" ]]; then - __orca_prompt_mark() { - printf "${SHELL_READY_MARKER_ESCAPED}" - } - __orca_append_prompt_command "__orca_prompt_mark" -fi -__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture' -__orca_append_prompt_command "__orca_osc133_prompt_done" -__orca_had_functrace="" -[[ -o functrace ]] && __orca_had_functrace=1 -set +T -__orca_debug_trap_spec="$(trap -p DEBUG)" -[[ -z "$__orca_had_functrace" ]] || set -T -if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then - __orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }" - __orca_debug_trap_command="\${__orca_debug_trap_command% DEBUG}" - eval "__orca_user_debug_trap=$__orca_debug_trap_command" -fi -unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace -unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command -unset __orca_prompt_command_normalized -# Why: arm DEBUG after wrapper setup so the relay rcfile itself does not emit -# fake command-start/end markers before the first prompt. -__orca_initial_prompt=1 -trap '__orca_osc133_preexec' DEBUG -unset __orca_initializing_wrapper -` - - // Only .zshenv: see local-pty-shell-ready-wrapper-generation.ts. - const files = [ - [join(zshDir, '.zshenv'), zshenv], - [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], - [join(bashDir, 'rcfile'), bashRc] - ] as const - - // Why: relay wrapper files persist under ~/.orca-relay across app upgrades. - // Existence alone is not enough; stale wrappers would miss later fixes such - // as preserving post-.zshenv ZDOTDIR. - const stale = files.filter(([path, content]) => readFileOrNull(path) !== content) - if (stale.length > 0 && !writeShellWrapperFiles(stale, '[relay/shell-overlay]')) { - return false - } - return files.every(([path]) => isNonEmptyFile(path)) -} - -function readFileOrNull(path: string): string | null { - try { - return readFileSync(path, 'utf8') - } catch { - return null - } -} - -function isNonEmptyFile(path: string): boolean { - try { - return statSync(path).size > 0 - } catch { - return false - } -} diff --git a/src/relay/pty-shell-utils-process-cycles.test.ts b/src/relay/pty-shell-utils-process-cycles.test.ts deleted file mode 100644 index aa074651ced..00000000000 --- a/src/relay/pty-shell-utils-process-cycles.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getProcessTableIndex } from '../shared/process-table-index' -import type { ProcessTableRow } from '../shared/process-table-snapshot' -import { getProcessTableSnapshot } from '../shared/process-table-snapshot-reader' -import { getForegroundProcessName } from './pty-shell-utils' - -vi.mock(import('../shared/process-table-snapshot-reader'), async (importOriginal) => ({ - ...(await importOriginal()), - getProcessTableSnapshot: vi.fn() -})) - -function row(pid: number, ppid: number, command = 'bash'): ProcessTableRow { - return { pid, ppid, stat: 'S+', command } -} - -describe('relay foreground process snapshot cycles', () => { - let platform: PropertyDescriptor | undefined - - beforeEach(() => { - platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - }) - - afterEach(() => { - vi.restoreAllMocks() - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - }) - - it.each([ - ['a self-parented root', [row(100, 100), row(101, 100, 'node /usr/bin/codex')]], - ['a two-process cycle', [row(100, 101), row(101, 100, 'node /usr/bin/codex')]], - [ - 'duplicate rows', - [row(100, 1), row(101, 100, 'node /usr/bin/codex'), row(101, 100, 'node /usr/bin/codex')] - ], - ['an ordinary tree', [row(100, 1), row(101, 100, 'node /usr/bin/codex')]] - ])('resolves the agent once for %s', async (_name, rows) => { - vi.mocked(getProcessTableSnapshot).mockResolvedValue(rows) - const children = getProcessTableIndex(rows).childrenByPpid - const readChildren = children.get.bind(children) - let reads = 0 - vi.spyOn(children, 'get').mockImplementation((pid) => { - // Bound the regression itself so removing the guard cannot OOM the test worker. - if (++reads > 20) { - throw new Error('process snapshot traversal did not terminate') - } - return readChildren(pid) - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex') - expect(reads).toBeLessThanOrEqual(rows.length) - }) -}) diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts deleted file mode 100644 index 6a8d3a76006..00000000000 --- a/src/relay/pty-shell-utils.test.ts +++ /dev/null @@ -1,615 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { execFileMock, execFileSyncMock, getAllProcessesMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - getAllProcessesMock: vi.fn(), - execFileSyncMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - execFile: execFileMock, - execFileSync: execFileSyncMock -})) - -import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows' -import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table' -import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot-reader' -import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' -import { - getForegroundProcessName, - isProcessAlive, - resolveDefaultCwd, - resolveWindowsDefaultShell -} from './pty-shell-utils' - -function mockExecFile( - implementation: (command: string, args: string[]) => { stdout: string; stderr?: string } | Error -): void { - execFileMock.mockImplementation( - (command: string, args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - const result = implementation(command, args) - if (result instanceof Error) { - callback(result, { stdout: '', stderr: '' }) - return - } - callback(null, { stdout: result.stdout, stderr: result.stderr ?? '' }) - } - ) -} - -/** - * Feed the native Windows snapshot. A real snapshot always contains the - * querying process, and the reader rejects a table without it. - */ -/** A native reader that answers, but with no snapshot -- an unreadable table, not an empty one. */ -function mockUnreadableWindowsProcessTable(): void { - __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, - getAllProcesses: (cb: (value: undefined) => void) => cb(undefined) - })) -} - -function mockWindowsProcessTable( - rows: { pid: number; ppid: number; name: string; commandLine?: string }[] -): void { - __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, - getAllProcesses: (cb: (value: typeof rows | undefined) => void) => - cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows]) - })) - getAllProcessesMock.mockClear() -} - -async function withProcessPlatform( - platform: NodeJS.Platform, - run: () => T | Promise -): Promise { - const descriptor = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - try { - return await run() - } finally { - if (descriptor) { - Object.defineProperty(process, 'platform', descriptor) - } - } -} - -beforeEach(() => { - vi.resetModules() - execFileMock.mockReset() - execFileSyncMock.mockReset() - resetProcessTableSnapshotForTests() - resetWindowsProcessRowsSnapshotForTests() - __setWindowsProcessTreeLoaderForTests() -}) - -describe('isProcessAlive', () => { - it('reports the test runner process as alive', () => { - expect(isProcessAlive(process.pid)).toBe(true) - }) - - it('reports a process as dead ONLY on ESRCH', () => { - // Why: attach() reaps a lingering managed PTY based on this, so a false - // positive would kill a live remote shell. Only "no such process" counts. - const spy = vi.spyOn(process, 'kill').mockImplementation(() => { - const err = new Error('no such process') as NodeJS.ErrnoException - err.code = 'ESRCH' - throw err - }) - try { - expect(isProcessAlive(2147483646)).toBe(false) - } finally { - spy.mockRestore() - } - }) - - it('treats an unsignalable process (EPERM) as alive', () => { - const spy = vi.spyOn(process, 'kill').mockImplementation(() => { - const err = new Error('operation not permitted') as NodeJS.ErrnoException - err.code = 'EPERM' - throw err - }) - try { - expect(isProcessAlive(1)).toBe(true) - } finally { - spy.mockRestore() - } - }) -}) - -describe('resolveWindowsDefaultShell', () => { - it('uses an existing SHELL override when one is provided', () => { - expect( - resolveWindowsDefaultShell( - { - SHELL: 'C:\\Tools\\pwsh.exe', - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === 'C:\\Tools\\pwsh.exe', - () => { - throw new Error('DefaultShell should not be read when SHELL wins') - } - ) - ).toBe('C:\\Tools\\pwsh.exe') - }) - - it('uses an existing OpenSSH DefaultShell path', () => { - const powershell7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' - - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell7, - () => powershell7 - ) - ).toBe(powershell7) - }) - - it('preserves the fallback chain for an invalid OpenSSH DefaultShell', () => { - const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' - - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell, - () => 'C:\\missing\\pwsh.exe' - ) - ).toBe(powershell) - }) - - it('honors a deliberate OpenSSH PowerShell 5.1 DefaultShell value', () => { - const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' - - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell, - () => powershell - ) - ).toBe(powershell) - }) - - it('prefers inbox PowerShell before ComSpec for an interactive Windows PTY', () => { - const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' - - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe', - () => '' - ) - ).toBe(powershell) - }) - - it('falls back to ComSpec when PowerShell cannot be found by path', () => { - expect( - resolveWindowsDefaultShell( - { - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe' - }, - (path) => path === 'C:\\Windows\\System32\\cmd.exe', - () => '' - ) - ).toBe('C:\\Windows\\System32\\cmd.exe') - }) -}) - -describe('resolveDefaultCwd', () => { - it('uses USERPROFILE for Windows PTYs without an explicit cwd', () => { - expect( - resolveDefaultCwd( - { - USERPROFILE: 'C:\\Users\\alice', - HOME: '/not/a/windows/cwd' - }, - 'win32', - 'C:\\Users\\fallback' - ) - ).toBe('C:\\Users\\alice') - }) - - it('falls back to HOMEDRIVE plus HOMEPATH on Windows when USERPROFILE is missing', () => { - expect( - resolveDefaultCwd( - { - HOMEDRIVE: 'D:', - HOMEPATH: '\\Users\\bob' - }, - 'win32', - 'C:\\Users\\fallback' - ) - ).toBe('D:\\Users\\bob') - }) - - it('keeps POSIX HOME fallback behavior', () => { - expect(resolveDefaultCwd({ HOME: '/home/alice' }, 'linux', '/fallback')).toBe('/home/alice') - }) -}) - -describe('getForegroundProcessName', () => { - it('keeps a non-agent foreground name when the process table shows no agent', async () => { - await withProcessPlatform('darwin', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { stdout: ['100 99 Ss zsh -l', '101 100 S+ vim notes.md'].join('\n') } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'vim')).resolves.toBe('vim') - }) - }) - - it('resolves a macOS p_comm basename to the agent that owns the foreground', async () => { - // Why: node-pty reports the native Claude binary as its version directory (`2.1.258`); - // answering with that name downgrades agent prompts to unframed chunks (STA-4577). - await withProcessPlatform('darwin', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 99 Ss zsh -l', '101 100 S+ claude --model haiku'].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, '2.1.258')).resolves.toBe('claude') - }) - }) - - it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join( - '\n' - ) - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex') - }) - }) - - it('recognizes Windows SSH relay shell-rooted agent descendants', async () => { - await withProcessPlatform('win32', async () => { - mockWindowsProcessTable([ - { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd' - } - ]) - - await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex') - }) - }) - - it('recognizes SSH relay wrapped agents when no foreground marker is available', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: [ - '100 99 Ss bash -l', - '101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs' - ].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini') - }) - }) - - it('does not guess when SSH relay wrapper descendants are ambiguous', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: [ - '100 99 Ss bash -l', - '101 100 S node /home/dev/project/server.js', - '102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js' - ].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node') - }) - }) - - it('does not report a stopped SSH relay agent when another process has foreground', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: [ - '100 99 Ss bash -l', - '101 100 T node /home/dev/.local/bin/codex', - '102 100 S+ vim notes.txt' - ].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node') - }) - }) - - // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). - it('reports the outer omp wrapper over the wrapped pi child from a shell fallback', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('omp') - }) - }) - - it('rescans for the omp wrapper when node-pty reports the wrapped pi as foreground', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') - }) - }) - - it('returns an outer omp fallback without a process-table scan', async () => { - mockExecFile(() => new Error('unexpected process-table scan')) - - await expect(getForegroundProcessName(100, 'omp')).resolves.toBe('omp') - expect(execFileMock).not.toHaveBeenCalled() - }) - - it('rescans a Windows pi fallback for its outer omp wrapper', async () => { - await withProcessPlatform('win32', async () => { - mockWindowsProcessTable([ - { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, - { pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp.exe' }, - { pid: 102, ppid: 101, name: 'pi.exe', commandLine: 'pi.exe' } - ]) - - await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') - }) - }) - - it('keeps a pi fallback as pi when no omp wrapper is in the tree', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { stdout: ['100 99 Ss bash -l', '101 100 S+ pi'].join('\n') } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('pi') - }) - }) - - it('normalizes a wrapper fallback the process table cannot confirm', async () => { - // Why: the table scan must answer null, not the raw node-pty name, so the - // ladder still publishes the RECOGNIZED (normalized) identity. - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { stdout: ['100 99 Ss bash -l', '101 100 S+ vim notes.txt'].join('\n') } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, '/opt/homebrew/bin/pi')).resolves.toBe('pi') - }) - }) - - it('resolves a duplicated root pid to the FIRST capture row', async () => { - // Preserve rows.find() semantics if a malformed table repeats a pid: an argv - // newline makes `ps` print a continuation line that can parse as a spurious - // row, so the real root (row one) must keep owning the pane's foreground. - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 1 Ss bash', '100 1 Ss+ bash', '101 100 S node /opt/codex'].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('codex') - }) - }) - - it('falls back to the root process command when descendant inspection fails', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return new Error('ps table unavailable') - } - return { stdout: 'bash\n' } - }) - - await expect(getForegroundProcessName(100)).resolves.toBe('bash') - }) -}) - -describe('processHasChildren', () => { - // Why these assert on argv, not just the answer: the defect in #13537 was the - // cost of the answer. `pgrep -P` forks per pane per poll and opens six procfs - // files per host process to resolve one ppid, so the contract worth pinning is - // "no fork of its own, and share the foreground lookup's cached table". - const PS_TABLE = ['100 1 Ss bash', '101 100 S+ node /opt/codex', '200 1 Ss zsh'].join('\n') - - it('answers from the shared process table without forking pgrep', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { stdout: PS_TABLE } - } - return new Error('unexpected command') - }) - - await expect(processHasChildren(100)).resolves.toBe(true) - await expect(processHasChildren(200)).resolves.toBe(false) - - expect(execFileMock.mock.calls.map((call) => call[0])).not.toContain('pgrep') - }) - }) - - it('shares one process-table capture across a burst of panes', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { stdout: PS_TABLE } - } - return new Error('unexpected command') - }) - - const answers = await Promise.all([ - processHasChildren(100), - processHasChildren(100), - processHasChildren(200), - getForegroundProcessName(100, 'bash') - ]) - - expect(answers).toEqual([true, true, false, 'codex']) - expect(execFileMock).toHaveBeenCalledTimes(1) - }) - }) - - it('rescans for a close decision rather than serving a table from inside the TTL', async () => { - await withProcessPlatform('linux', async () => { - let table = PS_TABLE - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { stdout: table } - } - return new Error('unexpected command') - }) - - // The poll answers from the cache, which is the whole point of the memo. - await expect(processHasChildren(200)).resolves.toBe(false) - table = [PS_TABLE, '201 200 S+ npm run build'].join('\n') - await expect(processHasChildren(200)).resolves.toBe(false) - expect(execFileMock).toHaveBeenCalledTimes(1) - - // A close or cleanup acts on the answer once and destructively, so a - // child started inside the 500ms window has to be visible to it. - await expect(processHasChildren(200, { fresh: true })).resolves.toBe(true) - expect(execFileMock).toHaveBeenCalledTimes(2) - }) - }) - - it('reports an unreadable POSIX table as unverifiable, and still spells it false on the wire', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile(() => new Error('ps table unavailable')) - - await expect(inspectPtyChildProcesses(100)).resolves.toBe('unverifiable') - await expect(processHasChildren(100)).resolves.toBe(false) - }) - }) -}) - -describe('inspectPtyChildProcesses on Windows', () => { - // Why this describe exists: the relay used to `return false` here unconditionally, and a - // hardcoded negative is indistinguishable from a measurement. Every close guard reads it as - // "nothing is running here", so a Windows SSH pane running a build closed with no prompt. - it('walks the process table rather than answering from nothing', async () => { - await withProcessPlatform('win32', async () => { - mockWindowsProcessTable([ - { pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' }, - { pid: 101, ppid: 100, name: 'PING.EXE', commandLine: 'ping -n 40 127.0.0.1' } - ]) - - await expect(inspectPtyChildProcesses(100)).resolves.toBe('children') - await expect(processHasChildren(100)).resolves.toBe(true) - }) - }) - - it('finds a grandchild the shell backgrounded, not just direct children', async () => { - await withProcessPlatform('win32', async () => { - mockWindowsProcessTable([ - { pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' }, - { pid: 101, ppid: 100, name: 'node.exe', commandLine: 'node build.js' }, - { pid: 102, ppid: 101, name: 'tsc.exe', commandLine: 'tsc --watch' } - ]) - - await expect(inspectPtyChildProcesses(102)).resolves.toBe('no-children') - await expect(inspectPtyChildProcesses(101)).resolves.toBe('children') - }) - }) - - it('separates an observed-empty shell from a table it could not read', async () => { - await withProcessPlatform('win32', async () => { - mockWindowsProcessTable([{ pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' }]) - await expect(inspectPtyChildProcesses(100)).resolves.toBe('no-children') - - resetWindowsProcessRowsSnapshotForTests() - mockUnreadableWindowsProcessTable() - const alive = vi.spyOn(process, 'kill').mockReturnValue(true as never) - try { - await expect(inspectPtyChildProcesses(100)).resolves.toBe('unverifiable') - // The compatibility boolean keeps spelling unverifiable `false`: it reaches clients that - // cannot read the verdict, and they read `true` as "an agent took the PTY, safe to type". - await expect(processHasChildren(100)).resolves.toBe(false) - } finally { - alive.mockRestore() - } - }) - }) - - it('does not read a missing shell as unverifiable when the kernel says it is gone', async () => { - await withProcessPlatform('win32', async () => { - // The root is absent from the snapshot, which on its own cannot distinguish a filtered - // table from an exited shell. Only ESRCH settles it. - mockWindowsProcessTable([{ pid: 900, ppid: 1, name: 'explorer.exe' }]) - const gone = vi.spyOn(process, 'kill').mockImplementation(() => { - const error = new Error('no such process') as NodeJS.ErrnoException - error.code = 'ESRCH' - throw error - }) - try { - await expect(inspectPtyChildProcesses(100)).resolves.toBe('no-children') - } finally { - gone.mockRestore() - } - }) - }) -}) diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts deleted file mode 100644 index 93244c7c30b..00000000000 --- a/src/relay/pty-shell-utils.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { execFile as execFileCb } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { win32 as pathWin32 } from 'node:path' -import { promisify } from 'node:util' -import { - isAgentForegroundWrapperProcess, - isExpectedAgentProcess, - recognizeAgentProcess -} from '../shared/agent-process-recognition' -import { getFirstCommandToken } from '../shared/command-token-scanner' -import { getProcessTableIndex, type ProcessTableIndex } from '../shared/process-table-index' -import { PS_MAX_BUFFER_BYTES, type ProcessTableRow } from '../shared/process-table-snapshot' -import { getProcessTableSnapshot } from '../shared/process-table-snapshot-reader' -import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection' -import { - resolveOuterWrapperForegroundProcess, - shouldInspectOuterWrapperForegroundProcess -} from '../shared/foreground-wrapper-agent' -import { - resolveWindowsAgentForegroundProcess, - shouldInspectWindowsAgentForeground -} from '../main/providers/windows-agent-foreground-process' -import { readOpenSshDefaultShell } from '../shared/openssh-default-shell' - -const execFile = promisify(execFileCb) - -export function resolveWindowsDefaultShell( - env: NodeJS.ProcessEnv = process.env, - existsPath: (path: string) => boolean = existsSync, - readDefaultShell: () => string = readOpenSshDefaultShell -): string { - const envShell = env.SHELL - if (envShell && existsPath(envShell)) { - return envShell - } - - const configuredShell = readDefaultShell() - if (configuredShell && existsPath(configuredShell)) { - return configuredShell - } - - const systemRoot = env.SystemRoot || env.WINDIR || env.windir || 'C:\\Windows' - const windowsPowerShell = pathWin32.join( - systemRoot, - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe' - ) - if (existsPath(windowsPowerShell)) { - return windowsPowerShell - } - - const comspec = env.ComSpec || env.COMSPEC - if (comspec && existsPath(comspec)) { - return comspec - } - - return comspec || 'powershell.exe' -} - -/** - * Resolve the default shell for PTY spawning. - * Prefers $SHELL, then common fallbacks. - */ -export function resolveDefaultShell(): string { - if (process.platform === 'win32') { - return resolveWindowsDefaultShell() - } - - const envShell = process.env.SHELL - if (envShell && existsSync(envShell)) { - return envShell - } - - for (const candidate of ['/bin/bash', '/bin/zsh', '/bin/sh']) { - if (existsSync(candidate)) { - return candidate - } - } - return '/bin/sh' -} - -export function resolveDefaultCwd( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, - homeDir = homedir() -): string { - if (platform === 'win32') { - const driveHome = env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined - return env.USERPROFILE || env.HOME || driveHome || homeDir || `${env.SystemDrive || 'C:'}\\` - } - - return env.HOME || homeDir || '/' -} - -/** - * Resolve the current working directory of a process by pid. - * Tries /proc on Linux and lsof on macOS before falling back to `fallbackCwd`. - */ -export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promise { - // Try to read /proc/{pid}/cwd on Linux. Skip an existsSync gate — the - // check+read pair races a concurrent exit anyway, and the catch already - // falls through to lsof. - try { - const { readlinkSync } = await import('node:fs') - return readlinkSync(`/proc/${pid}/cwd`) - } catch { - // Fall through - } - - // Fallback: use lsof on macOS - // Why: `-d cwd` restricts output to the cwd file descriptor only. Without it, - // lsof returns ALL open files (sockets, log files, TTYs) and the first `n`-line - // could be any of them — not the actual working directory. - try { - // Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them - // and emits cwd records for every process on the system, so the n-line - // scan below picks up the first unrelated process (often pid ~391 with - // cwd `/`) and returns `/` regardless of the target pid's real cwd. - const { stdout: output } = await execFile( - 'lsof', - ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], - { - encoding: 'utf-8', - timeout: 3000 - } - ) - const lines = output.split('\n') - for (const line of lines) { - if (line.startsWith('n') && line.includes('/')) { - // Why: lsof -d cwd is authoritative — don't second-guess it with - // existsSync. A concurrent rmdir would race the check and cause us - // to drop the correct answer; node-pty handles a missing cwd on - // spawn anyway. - return line.slice(1) - } - } - } catch { - // Fall through - } - - return fallbackCwd -} - -// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no -// such process") proves the pid is gone; EPERM means it exists but is -// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so -// a liveness check can only ever declare a *provably* dead process dead. -export function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (err) { - return (err as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - -function collectDescendants( - index: ProcessTableIndex, - rootPid: number -): (ProcessTableRow & { depth: number })[] { - const descendants: (ProcessTableRow & { depth: number })[] = [] - const seen = new Set([rootPid]) - const stack = (index.childrenByPpid.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - // Process snapshots can contain duplicate PIDs or cycles during reparenting. - if (seen.has(row.pid)) { - continue - } - seen.add(row.pid) - descendants.push({ ...row, depth }) - for (const child of index.childrenByPpid.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} - -async function getRecognizedForegroundDescendant( - pid: number, - fallbackProcess?: string | null -): Promise { - try { - const rows = await getProcessTableSnapshot() - return getForegroundProcessNameFromProcessTable(rows, pid, fallbackProcess) - } catch { - // Fall through to node-pty's process name or the root command name. - } - return null -} - -// Why: returns null (never the fallback) so `getForegroundProcessName` keeps -// owning the fallback ladder — its wrapper branch answers with the RECOGNIZED -// process name, which is normalized where node-pty's raw name is not. -function getForegroundProcessNameFromProcessTable( - rows: ProcessTableRow[], - pid: number, - fallbackProcess?: string | null -): string | null { - // Why: one memoized index per capture, so N panes sharing the TTL-cached - // snapshot no longer each rebuild the parent/child map over every row. - const index = getProcessTableIndex(rows) - const root = index.byPid.get(pid) - const candidates = collectDescendants(index, pid) - // Why: SSH relays do not have the daemon's async wrapper cache. Inspect the - // remote process tree so node/python agent entrypoints become real agents. - const foregroundIsKnown = - root?.stat.includes('+') === true || - candidates.some((candidate) => candidate.stat.includes('+')) - const foregroundCandidates = foregroundIsKnown - ? candidates.filter((candidate) => candidate.stat.includes('+')) - : candidates - const inspectionCandidates = - fallbackProcess && isAgentForegroundWrapperProcess(fallbackProcess) - ? foregroundCandidates.filter((candidate) => - isExpectedAgentProcess(getFirstCommandToken(candidate.command), fallbackProcess) - ) - : foregroundCandidates - if ( - fallbackProcess && - isAgentForegroundWrapperProcess(fallbackProcess) && - inspectionCandidates.length !== 1 - ) { - return null - } - const ancestryCandidates = root ? [{ ...root, depth: 0 }, ...candidates] : candidates - const selected = selectForegroundProcessCandidate(inspectionCandidates, ancestryCandidates) - if (selected) { - // Why: return the outer wrapper (omp) rather than a deeper recognized helper - // in the same process lineage. - return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates) - } - return null -} - -/** - * Get the foreground process name of a given pid (via ps). - */ -export async function getForegroundProcessName( - pid: number, - fallbackProcess?: string | null -): Promise { - if (fallbackProcess) { - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if (fallbackRecognition) { - // Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous - // fallback so authoritative OMP reads keep the zero-subprocess fast path. - if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) { - if (process.platform === 'win32') { - return ( - (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? - fallbackRecognition.processName - ) - } - return ( - (await getRecognizedForegroundDescendant(pid, fallbackProcess)) ?? - fallbackRecognition.processName - ) - } - return fallbackRecognition.processName - } - if (process.platform === 'win32') { - if (!shouldInspectWindowsAgentForeground(fallbackProcess)) { - return fallbackProcess - } - return ( - (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess - ) - } - } - // Why: an unrecognized name is not proof of a non-agent foreground -- macOS p_comm truncates - // to the executable basename, which for the native Claude install is its version directory - // (`2.1.258`). The TTL-cached table read resolves the real command line; a foreground that - // is genuinely not an agent still answers with its own name below. - const recognized = await getRecognizedForegroundDescendant(pid, fallbackProcess) - if (recognized) { - return recognized - } - if (fallbackProcess) { - return fallbackProcess - } - try { - const { stdout } = await execFile('ps', ['-o', 'comm=', '-p', String(pid)], { - encoding: 'utf-8', - timeout: 3000, - maxBuffer: PS_MAX_BUFFER_BYTES - }) - return stdout.trim() || null - } catch { - return null - } -} - -export function listShellProfiles(): { name: string; path: string }[] { - const profiles: { name: string; path: string }[] = [] - const seen = new Set() - - try { - const content = readFileSync('/etc/shells', 'utf-8') - for (const line of content.split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) { - continue - } - if (!existsSync(trimmed)) { - continue - } - if (seen.has(trimmed)) { - continue - } - seen.add(trimmed) - - const name = trimmed.split('/').pop() || trimmed - profiles.push({ name, path: trimmed }) - } - } catch { - // /etc/shells may not exist on all systems; fall back to known shells - for (const candidate of ['/bin/bash', '/bin/zsh', '/bin/sh']) { - if (existsSync(candidate) && !seen.has(candidate)) { - seen.add(candidate) - const name = candidate.split('/').pop()! - profiles.push({ name, path: candidate }) - } - } - } - - return profiles -} diff --git a/src/relay/pty-source-cancellation-index.ts b/src/relay/pty-source-cancellation-index.ts deleted file mode 100644 index 740b7fba50c..00000000000 --- a/src/relay/pty-source-cancellation-index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { PtySourceDeliveryCancellation } from '../shared/pty-source-credit-contract' -import type { PtyConsumerSessionGrant } from '../shared/pty-consumer-session' - -export const RECENT_PTY_SOURCE_CANCELLATION_LIMIT = 256 - -export class RecentPtySourceCancellationIndex { - private readonly proofs = new Map() - - remember(proof: PtySourceDeliveryCancellation): void { - this.proofs.set(proof.deliveryToken, proof) - while (this.proofs.size > RECENT_PTY_SOURCE_CANCELLATION_LIMIT) { - this.proofs.delete(this.proofs.keys().next().value!) - } - } - - get(deliveryToken: string): PtySourceDeliveryCancellation | undefined { - return this.proofs.get(deliveryToken) - } - - owned( - deliveryToken: string, - params: Record, - grant: Readonly | null - ): PtySourceDeliveryCancellation | undefined { - const proof = this.proofs.get(deliveryToken) - return grant?.capabilities?.outputFlowControl && - proof?.clientGeneration === grant.clientGeneration && - params.id === proof.id && - Number(params.clientGeneration) === proof.clientGeneration && - Number(params.ownerGeneration) === proof.ownerGeneration - ? proof - : undefined - } - - clear(): void { - this.proofs.clear() - } -} - -export function ptySourceCancellationResult( - proof: Readonly<{ sentEndSu: number; creditedEndSu: number }> -) { - return Object.freeze({ - canceled: true as const, - sentEndSu: proof.sentEndSu, - creditedEndSu: proof.creditedEndSu - }) -} diff --git a/src/relay/pty-source-credit-availability-notice.ts b/src/relay/pty-source-credit-availability-notice.ts deleted file mode 100644 index 392bab9c775..00000000000 --- a/src/relay/pty-source-credit-availability-notice.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Guarded fan-out of "this delivery released credit" to the publication layer. - */ -export function notifyPtySourceCreditAvailable( - notify: ((id: string) => void) | undefined, - id: string -): void { - try { - notify?.(id) - } catch (err) { - // Why: this fires from request handlers, detach paths, and a bare grace setTimeout; a - // downstream throw in the timer would reach uncaughtException and kill the daemon. - process.stderr.write( - `[pty-source-credit] credit-available notification failed for ${id}: ${ - err instanceof Error ? (err.stack ?? err.message) : String(err) - }\n` - ) - } -} diff --git a/src/relay/pty-source-credit-ledger.test.ts b/src/relay/pty-source-credit-ledger.test.ts deleted file mode 100644 index 57c00d1adec..00000000000 --- a/src/relay/pty-source-credit-ledger.test.ts +++ /dev/null @@ -1,807 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ptySourceDeliveryKey, - type PtySourceDeliveryIdentity -} from '../shared/pty-source-credit-contract' -import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' -import { CLOSED_DELIVERY_TOMBSTONE_LIMIT, type DeliveryRecord } from './pty-source-credit-record' - -function getDeliveryRecord( - ledger: RelayPtySourceCreditLedger, - owner: PtySourceDeliveryIdentity -): DeliveryRecord { - const internals = ledger as unknown as { deliveries: Map } - const record = internals.deliveries.get(ptySourceDeliveryKey(owner)) - if (!record) { - throw new Error('test delivery record missing') - } - return record -} - -function identity( - deliveryToken = 'token-1', - overrides: Partial = {} -): PtySourceDeliveryIdentity { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken, - ...overrides - } -} - -function append( - ledger: RelayPtySourceCreditLedger, - owner: PtySourceDeliveryIdentity, - data: string, - spanId = `span-${data}` -): void { - const start = ledger.snapshot(owner).receivedEndSu - ledger.append(owner, { - spanId, - data, - displayStart: start, - displayEnd: start + data.length, - splittable: true, - transform: { - transformed: false, - rawLengthSu: data.length, - scalarSafe: true - } - }) -} - -function drainOne( - ledger: RelayPtySourceCreditLedger, - owner: PtySourceDeliveryIdentity, - maxSourceSu = 16 * 1024 -) { - const reservation = ledger.reserveNextSend(owner, maxSourceSu) - if (reservation) { - ledger.commitSend(reservation) - } - return reservation -} - -// Why: retention totals are maintained incrementally, so recomputing from the live records is -// the only independent check that no mutation path skipped a counter update. -function expectRetentionMatchesRecords(ledger: RelayPtySourceCreditLedger): void { - const internals = ledger as unknown as { deliveries: Map } - const expected = { sourceSu: 0, dataBytes: 0, spans: 0 } - for (const record of internals.deliveries.values()) { - expected.sourceSu += record.receivedEndSu - record.creditedEndSu - expected.dataBytes += record.retainedDataBytes - expected.spans += record.spans.length - } - expect(ledger.retentionSnapshot()).toEqual(expected) -} - -describe('RelayPtySourceCreditLedger', () => { - it('keeps send-span lookup near-linear across a retained-frame burst', () => { - const spanCount = 1_024 - const ledger = new RelayPtySourceCreditLedger({ - maxRetainedSourceSu: spanCount * 2, - maxAggregateRetainedSourceSu: spanCount * 2, - maxRetainedDataBytes: spanCount * 1_024, - maxAggregateRetainedDataBytes: spanCount * 1_024, - maxRetainedSpans: spanCount, - maxAggregateRetainedSpans: spanCount - }) - const owner = identity() - ledger.open(owner, spanCount * 2) - for (let index = 0; index < spanCount; index += 1) { - append(ledger, owner, 'x', `span-${index}`) - } - - const record = getDeliveryRecord(ledger, owner) - let indexedReads = 0 - const spans = record.spans - record.spans = new Proxy(spans, { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) { - indexedReads += 1 - } - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward. - return Reflect.get(target, property, receiver) - } - }) - - let sends = 0 - while (true) { - const reservation = ledger.reserveNextSend(owner, 1) - if (!reservation) { - break - } - ledger.commitSend(reservation) - sends += 1 - } - - expect(sends).toBe(spanCount) - // The final span remains the cursor until another source append arrives. - expect(record.sendSpanIndex).toBe(spanCount - 1) - // Linear in spans; the removed Array.find rescanned the sent prefix (524,800 predicate visits). - expect(indexedReads).toBeLessThan(spanCount * 3) - }) - - it('keeps the uncovered-cursor diagnostic when a retained span has a gap', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity('token-gap') - ledger.open(owner, 8) - append(ledger, owner, 'ab', 'span-gap') - const record = getDeliveryRecord(ledger, owner) - record.spans = [ - Object.freeze({ - ...record.spans[0], - sourceStartSu: 2, - sourceEndSu: 4, - transform: Object.freeze({ ...record.spans[0].transform, rawLengthSu: 2 }) - }) - ] - - expect(() => ledger.reserveNextSend(owner, 2)).toThrow( - 'PTY source delivery cursor is not covered by the retained ledger' - ) - }) - - it('keeps the cursor correct across ACK reclaim, rollback, rotation, and close', () => { - const ledger = new RelayPtySourceCreditLedger() - const oldOwner = identity() - const replacement = identity('token-replacement', { - clientGeneration: 4, - ownerGeneration: 5 - }) - ledger.open(oldOwner, 16) - append(ledger, oldOwner, 'ab', 'span-a') - append(ledger, oldOwner, 'cd', 'span-b') - append(ledger, oldOwner, 'ef', 'span-c') - - const first = ledger.reserveNextSend(oldOwner, 2)! - ledger.commitSend(first) - expect(getDeliveryRecord(ledger, oldOwner).sendSpanIndex).toBe(0) - ledger.acknowledge(oldOwner, { - id: oldOwner.id, - clientGeneration: oldOwner.clientGeneration, - ownerGeneration: oldOwner.ownerGeneration, - deliveryToken: oldOwner.deliveryToken, - creditedEndSu: 2 - }) - expect(getDeliveryRecord(ledger, oldOwner).sendSpanIndex).toBe(0) - - const attempted = ledger.reserveNextSend(oldOwner, 1)! - expect(attempted.span.data).toBe('c') - ledger.rollbackSend(attempted) - const retried = ledger.reserveNextSend(oldOwner, 1)! - expect(retried.span.data).toBe('c') - ledger.commitSend(retried) - - ledger.commitSend(ledger.reserveNextSend(oldOwner, 2)!) - const rotation = ledger.rotate(oldOwner, replacement, 2, 16) - expect(rotation.recovery.map((span) => span.data).join('')).toBe('cdef') - expect(getDeliveryRecord(ledger, replacement).sendSpanIndex).toBe(0) - // Rotation is the only path that removes and re-adds a record in one call. - expectRetentionMatchesRecords(ledger) - ledger.commitSend(ledger.reserveNextSend(replacement, 16)!) - ledger.commitSend(ledger.reserveNextSend(replacement, 16)!) - ledger.seal(replacement) - ledger.settleExitPublication(replacement, { ok: true }) - ledger.acknowledge(replacement, { - id: replacement.id, - clientGeneration: replacement.clientGeneration, - ownerGeneration: replacement.ownerGeneration, - deliveryToken: replacement.deliveryToken, - creditedEndSu: 6 - }) - expect(ledger.snapshotIfKnown(replacement)?.state).toBe('closed') - - expectRetentionMatchesRecords(ledger) - - const canceled = identity('token-canceled') - ledger.open(canceled, 8) - append(ledger, canceled, 'x', 'span-cancel') - expectRetentionMatchesRecords(ledger) - ledger.cancel(canceled, 'test-close') - expect(ledger.snapshotIfKnown(canceled)?.state).toBe('closed') - expectRetentionMatchesRecords(ledger) - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 0, dataBytes: 0, spans: 0 }) - }) - - it('rebases the send cursor when ACK reclaim removes spans at or past it', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity('token-reclaim') - ledger.open(owner, 16) - append(ledger, owner, 'ab', 'span-a') - append(ledger, owner, 'cd', 'span-b') - append(ledger, owner, 'ef', 'span-c') - ledger.commitSend(ledger.reserveNextSend(owner, 2)!) - ledger.commitSend(ledger.reserveNextSend(owner, 2)!) - expect(getDeliveryRecord(ledger, owner).sendSpanIndex).toBe(1) - - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: 4 - }) - - // Reclaim dropped both spans the cursor had passed, so it must rebase onto the new head. - const record = getDeliveryRecord(ledger, owner) - expect(record.spans.map((span) => span.data)).toEqual(['ef']) - expect(record.sendSpanIndex).toBe(0) - expect(ledger.reserveNextSend(owner, 2)!.span.data).toBe('ef') - }) - - it('reclaims every credited boundary across a long sequential ACK drain', () => { - const boundaryCount = 1_024 - const spanCount = boundaryCount - 1 - const ledger = new RelayPtySourceCreditLedger({ - maxRetainedSpans: boundaryCount, - maxAggregateRetainedSpans: boundaryCount - }) - const owner = identity() - ledger.open(owner, boundaryCount) - for (let index = 0; index < spanCount; index += 1) { - append(ledger, owner, 'x', `span-${index}`) - const reservation = ledger.reserveNextSend(owner, 1) - expect(reservation).not.toBeNull() - ledger.commitSend(reservation!) - } - - const record = getDeliveryRecord(ledger, owner) - expect([...record.sentBoundaries]).toEqual( - Array.from({ length: boundaryCount }, (_, index) => index) - ) - - for (let creditedEndSu = 1; creditedEndSu <= spanCount; creditedEndSu += 1) { - expect( - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu - }) - ).toBe('advanced') - // Every boundary below the credit must be gone, so the oldest live one is the credit itself. - const [oldestLive] = record.sentBoundaries - expect(oldestLive).toBe(creditedEndSu) - // Boundary cleanup and span reclamation must stay in step on every ACK, not just the last. - expect(ledger.retentionSnapshot().spans).toBe(spanCount - creditedEndSu) - } - - expect([...record.sentBoundaries]).toEqual([spanCount]) - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 0, dataBytes: 0, spans: 0 }) - }) - - it('deletes every boundary skipped by a jump-ahead cumulative ACK', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 16) - append(ledger, owner, 'abcdefgh') - for (let index = 0; index < 8; index += 1) { - ledger.commitSend(ledger.reserveNextSend(owner, 1)!) - } - const record = getDeliveryRecord(ledger, owner) - expect([...record.sentBoundaries]).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) - - expect( - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: 8 - }) - ).toBe('advanced') - expect([...record.sentBoundaries]).toEqual([8]) - }) - - it('never exceeds a token source window across generated send/ACK sequences', () => { - for (let seed = 1; seed <= 40; seed++) { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity(`token-${seed}`) - const windowSu = 7 + (seed % 17) - ledger.open(owner, windowSu) - for (let part = 0; part < 20; part += 1) { - append(ledger, owner, 'x'.repeat(5), `span-${seed}-${part}`) - } - - for (let turn = 0; turn < 100; turn++) { - const reservation = drainOne(ledger, owner, 1 + ((seed * 13 + turn * 7) % 19)) - const snapshot = ledger.snapshot(owner) - expect(snapshot.sentEndSu - snapshot.creditedEndSu).toBeLessThanOrEqual(windowSu) - const record = getDeliveryRecord(ledger, owner) - const containingIndex = record.spans.findIndex( - (span) => - span.sourceStartSu <= snapshot.sentEndSu && span.sourceEndSu > snapshot.sentEndSu - ) - // Cursor may lag (advancement is lazy) but must never overshoot the containing span. - if (containingIndex !== -1) { - expect(record.sendSpanIndex).toBeLessThanOrEqual(containingIndex) - } - if (snapshot.sentEndSu > snapshot.creditedEndSu && (turn + seed) % 3 === 0) { - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: snapshot.sentEndSu - }) - } - expectRetentionMatchesRecords(ledger) - if (!reservation && snapshot.creditedEndSu === 100) { - break - } - } - } - }) - - it('slices splittable source without splitting a surrogate pair', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 3) - append(ledger, owner, `a😀b`) - - expect(drainOne(ledger, owner, 2)?.span.data).toBe('a') - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: owner.deliveryToken, - creditedEndSu: 1 - }) - expect(drainOne(ledger, owner, 2)?.span.data).toBe('😀') - }) - - it('never emits the leading half of a surrogate pair', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 1) - append(ledger, owner, `😀`) - - expect(() => drainOne(ledger, owner, 1)).toThrow('surrogate pair') - expect(ledger.snapshot(owner)).toMatchObject({ sentEndSu: 0, creditedEndSu: 0 }) - }) - - it('holds an indivisible transform that does not fit the remaining window', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 4, 2) - ledger.append(owner, { - spanId: 'transform', - data: 'Z', - displayStart: 0, - displayEnd: 1, - splittable: false, - transform: { transformed: true, rawLengthSu: 5, scalarSafe: true } - }) - - expect(ledger.reserveNextSend(owner)).toBeNull() - expect(ledger.snapshot(owner)).toMatchObject({ sentEndSu: 2, creditedEndSu: 2 }) - }) - - it('rejects wrong owners, stale tokens, and over-credit without clamping', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 16) - append(ledger, owner, 'abcd') - drainOne(ledger, owner) - - expect(() => - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: 99, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: 1 - }) - ).toThrow('does not own') - expect(() => - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: 5 - }) - ).toThrow('exceeds sent') - expect(() => ledger.snapshot(identity('stale'))).toThrow('stale') - }) - - it('rejects cumulative ACKs inside a committed frame boundary', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 16) - append(ledger, owner, 'abcd') - drainOne(ledger, owner) - - expect(() => - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: owner.deliveryToken, - creditedEndSu: 2 - }) - ).toThrow('boundary') - expect(ledger.snapshot(owner).creditedEndSu).toBe(0) - }) - - it('reserves an exact early ACK until the matching send settles', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 4) - append(ledger, owner, 'data') - const pending = ledger.reserveNextSend(owner)! - - expect( - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: 4 - }) - ).toBe('reserved') - expect(ledger.snapshot(owner)).toMatchObject({ sentEndSu: 0, creditedEndSu: 0 }) - - ledger.commitSend(pending) - expect(ledger.snapshot(owner)).toMatchObject({ sentEndSu: 4, creditedEndSu: 4 }) - expect([...getDeliveryRecord(ledger, owner).sentBoundaries]).toEqual([4]) - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 0, dataBytes: 0, spans: 0 }) - }) - - it('retains reserved credit across an exact same-token send retry', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 4) - append(ledger, owner, 'data') - const failed = ledger.reserveNextSend(owner)! - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: 4 - }) - - ledger.rollbackSend(failed) - expect( - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: owner.clientGeneration, - ownerGeneration: owner.ownerGeneration, - deliveryToken: owner.deliveryToken, - creditedEndSu: 4 - }) - ).toBe('reserved') - expect(ledger.reserveNextSend(owner, 3)).toBeNull() - const retry = ledger.reserveNextSend(owner)! - ledger.commitSend(retry) - - expect(ledger.snapshot(owner)).toMatchObject({ sentEndSu: 4, creditedEndSu: 4 }) - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 0, dataBytes: 0, spans: 0 }) - }) - - it('keeps sealed-unsettled state until exit and suffix ACK are both published', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 8) - append(ledger, owner, 'tail') - drainOne(ledger, owner) - ledger.seal(owner) - ledger.settleExitPublication(owner, { ok: true }) - - expect(ledger.snapshot(owner).state).toBe('sealed-unsettled') - ledger.acknowledge(owner, { - id: owner.id, - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: owner.deliveryToken, - creditedEndSu: 4 - }) - expect(ledger.snapshot(owner).state).toBe('closed') - }) - - it('cannot publish exit while a preceding source span is unsent or reserved', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 8) - append(ledger, owner, 'tail') - ledger.seal(owner) - - expect(() => ledger.settleExitPublication(owner, { ok: true })).toThrow('preceding') - }) - - it('rotates tokens with exact recovery and rejects the stale delivery', () => { - const ledger = new RelayPtySourceCreditLedger() - const oldOwner = identity() - const replacement = identity('token-2', { - clientGeneration: 4, - ownerGeneration: 5 - }) - ledger.open(oldOwner, 16) - append(ledger, oldOwner, 'abcdefgh') - drainOne(ledger, oldOwner, 3) - ledger.acknowledge(oldOwner, { - id: oldOwner.id, - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: oldOwner.deliveryToken, - creditedEndSu: 3 - }) - drainOne(ledger, oldOwner, 2) - - const rotation = ledger.rotate(oldOwner, replacement, 5, 16) - expect(rotation.recovery.map((span) => span.data).join('')).toBe('fgh') - expect(rotation.cancellation).toMatchObject({ - remainingStartSu: 3, - remainingEndSu: 5, - replacementDeliveryToken: 'token-2' - }) - expect(() => - ledger.acknowledge(oldOwner, { - id: oldOwner.id, - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: oldOwner.deliveryToken, - creditedEndSu: 8 - }) - ).toThrow() - expect(() => - ledger.open(identity('token-3', { clientGeneration: 6, ownerGeneration: 7 }), 16) - ).toThrow('upstream owner') - }) - - it('rejects non-boundary and pending-send recovery with zero mutation', () => { - const ledger = new RelayPtySourceCreditLedger() - const oldOwner = identity() - const replacement = identity('token-2', { - clientGeneration: 4, - ownerGeneration: 5 - }) - ledger.open(oldOwner, 16) - append(ledger, oldOwner, 'abcdefgh') - drainOne(ledger, oldOwner, 4) - const before = ledger.snapshot(oldOwner) - - expect(() => ledger.rotate(oldOwner, replacement, 3, 16)).toThrow('checkpoint') - expect(ledger.snapshot(oldOwner)).toEqual(before) - - const pending = ledger.reserveNextSend(oldOwner, 2)! - expect(() => ledger.rotate(oldOwner, replacement, 4, 16)).toThrow('checkpoint') - expect(ledger.snapshot(oldOwner)).toEqual(before) - ledger.rollbackSend(pending) - expect(ledger.snapshot(oldOwner)).toEqual(before) - }) - - it('rejects a recovery checkpoint beyond source accepted by the sink', () => { - const ledger = new RelayPtySourceCreditLedger() - const oldOwner = identity() - const replacement = identity('token-2', { - clientGeneration: 4, - ownerGeneration: 5 - }) - ledger.open(oldOwner, 16) - append(ledger, oldOwner, 'abcdefgh') - drainOne(ledger, oldOwner, 4) - - expect(() => ledger.rotate(oldOwner, replacement, 8, 16)).toThrow('checkpoint') - expect(ledger.snapshot(oldOwner)).toMatchObject({ - state: 'active', - receivedEndSu: 8, - sentEndSu: 4 - }) - }) - - it('accepts an exact receiver checkpoint for a rolled-back send attempt', () => { - const ledger = new RelayPtySourceCreditLedger() - const oldOwner = identity() - const replacement = identity('token-2', { - clientGeneration: 4, - ownerGeneration: 5 - }) - ledger.open(oldOwner, 16) - append(ledger, oldOwner, 'abcdefgh') - const attempted = ledger.reserveNextSend(oldOwner, 4)! - ledger.rollbackSend(attempted) - - const rotation = ledger.rotate(oldOwner, replacement, 4, 16) - - expect(rotation.recovery.map((span) => span.data).join('')).toBe('efgh') - expect(rotation.cancellation).toMatchObject({ sentEndSu: 4, creditedEndSu: 0 }) - expect(ledger.snapshot(replacement)).toMatchObject({ - receivedEndSu: 8, - sentEndSu: 4, - creditedEndSu: 4 - }) - }) - - it('generation-closes every retained token exactly once', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 8) - append(ledger, owner, 'data') - - expect(ledger.closeGeneration(1)).toBe(1) - expect(ledger.closeGeneration(1)).toBe(0) - expect(ledger.snapshot(owner)).toMatchObject({ state: 'closed', generationClosed: true }) - expect(ledger.retainedSourceSu()).toBe(0) - }) - - it('enforces an aggregate retained-source budget across PTYs', () => { - const ledger = new RelayPtySourceCreditLedger({ - maxRetainedSourceSu: 8, - maxAggregateRetainedSourceSu: 6 - }) - const first = identity('token-a') - const second = identity('token-b', { - id: 'pty-2', - ptyIncarnation: 'incarnation-2' - }) - ledger.open(first, 8) - ledger.open(second, 8) - append(ledger, first, 'abcd') - - expect(() => append(ledger, second, 'xyz')).toThrow('Aggregate') - expect(ledger.retainedSourceSu()).toBe(4) - }) - - it('bounds exact UTF-8 retained bytes for transformed spans', () => { - const ledger = new RelayPtySourceCreditLedger({ - maxRetainedDataBytes: 7, - maxAggregateRetainedDataBytes: 7 - }) - const owner = identity() - ledger.open(owner, 8) - - expect(() => - ledger.append(owner, { - spanId: 'expanded', - data: '😀😀', - displayStart: 0, - displayEnd: 4, - splittable: false, - transform: { transformed: true, rawLengthSu: 1, scalarSafe: false } - }) - ).toThrow('encoded-data') - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 0, dataBytes: 0, spans: 0 }) - }) - - it('charges UTF-16 storage and one record per retained source frame', () => { - const ledger = new RelayPtySourceCreditLedger({ - maxRetainedDataBytes: 391, - maxAggregateRetainedDataBytes: 391 - }) - const owner = identity() - ledger.open(owner, 8) - append(ledger, owner, '\u0000'.repeat(64), 'utf16-heavy') - - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 64, dataBytes: 256, spans: 1 }) - expect(() => append(ledger, owner, 'a'.repeat(8), 'fragment')).toThrow('retained encoded-data') - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 64, dataBytes: 256, spans: 1 }) - }) - - it('enforces per-PTY and aggregate source, charged-byte, and frame caps across fragments', () => { - const limits = { - maxRetainedSourceSu: 6, - maxAggregateRetainedSourceSu: 9, - maxRetainedDataBytes: 500, - maxAggregateRetainedDataBytes: 700, - maxRetainedSpans: 2, - maxAggregateRetainedSpans: 3 - } - const first = identity('token-a') - const second = identity('token-b', { - id: 'pty-2', - ptyIncarnation: 'incarnation-2' - }) - const ledger = new RelayPtySourceCreditLedger(limits) - ledger.open(first, 8) - ledger.open(second, 8) - append(ledger, first, 'aa', 'first-a') - append(ledger, first, 'bb', 'first-b') - append(ledger, second, 'cc', 'second-a') - - expect(ledger.retentionSnapshot()).toEqual({ - sourceSu: 6, - dataBytes: 396, - spans: 3 - }) - expect(() => append(ledger, first, 'c', 'first-frame-cap')).toThrow('retained-span') - expect(() => append(ledger, second, 'dddd', 'aggregate-source-cap')).toThrow('Aggregate') - - const byteLimited = new RelayPtySourceCreditLedger({ - ...limits, - maxRetainedSourceSu: 200, - maxAggregateRetainedSourceSu: 300, - maxAggregateRetainedSpans: 10, - maxRetainedSpans: 10, - maxAggregateRetainedDataBytes: 650 - }) - byteLimited.open(first, 8) - byteLimited.open(second, 8) - append(byteLimited, first, '\u0000'.repeat(64), 'first-utf16') - append(byteLimited, second, '\u0000'.repeat(64), 'second-utf16') - expect(() => append(byteLimited, second, '\u0000'.repeat(8), 'aggregate-byte-cap')).toThrow( - 'Aggregate' - ) - }) - - it('bounds retained frame metadata independently of source units', () => { - const ledger = new RelayPtySourceCreditLedger({ - maxRetainedSpans: 2, - maxAggregateRetainedSpans: 2 - }) - const owner = identity() - ledger.open(owner, 8) - append(ledger, owner, 'a', 'span-a') - append(ledger, owner, 'b', 'span-b') - - expect(() => append(ledger, owner, 'c', 'span-c')).toThrow('retained-span') - expect(ledger.retentionSnapshot()).toEqual({ sourceSu: 2, dataBytes: 260, spans: 2 }) - }) - - it('bounds closed token tombstones', () => { - const ledger = new RelayPtySourceCreditLedger() - const owners = Array.from({ length: 300 }, (_, index) => - identity(`token-${index}`, { - id: `pty-${index}`, - ptyIncarnation: `incarnation-${index}` - }) - ) - for (const owner of owners) { - ledger.open(owner, 8) - ledger.cancel(owner, 'test') - } - - expect(() => ledger.snapshot(owners[0])).toThrow('stale') - expect(ledger.snapshot(owners.at(-1)!)).toMatchObject({ state: 'closed' }) - }) - - it('probes live, closed, cancelled and evicted deliveries without throwing', () => { - const ledger = new RelayPtySourceCreditLedger() - const live = identity('token-live', { id: 'pty-live' }) - ledger.open(live, 8) - expect(ledger.snapshotIfKnown(live)).toMatchObject({ state: 'active', exitPublished: false }) - - const completed = identity('token-completed', { id: 'pty-completed' }) - ledger.open(completed, 8) - ledger.seal(completed) - ledger.settleExitPublication(completed, { ok: true }) - expect(ledger.snapshotIfKnown(completed)).toMatchObject({ - state: 'closed', - exitPublished: true - }) - - const canceled = identity('token-canceled', { id: 'pty-canceled' }) - ledger.open(canceled, 8) - ledger.cancel(canceled, 'client-request') - expect(ledger.snapshotIfKnown(canceled)).toMatchObject({ - state: 'closed', - exitPublished: false - }) - - expect(ledger.snapshotIfKnown(identity('token-unknown'))).toBeNull() - expect(() => ledger.snapshot(identity('token-unknown'))).toThrow('stale') - - for (let index = 0; index < CLOSED_DELIVERY_TOMBSTONE_LIMIT; index++) { - const evicting = identity(`token-evicting-${index}`, { id: `pty-evicting-${index}` }) - ledger.open(evicting, 8) - ledger.cancel(evicting, 'test') - } - expect(ledger.snapshotIfKnown(canceled)).toBeNull() - }) - - it('rejects reopening a recently closed one-use token', () => { - const ledger = new RelayPtySourceCreditLedger() - const owner = identity() - ledger.open(owner, 8) - ledger.cancel(owner, 'test') - - expect(() => ledger.open(owner, 8)).toThrow('already used') - }) -}) diff --git a/src/relay/pty-source-credit-ledger.ts b/src/relay/pty-source-credit-ledger.ts deleted file mode 100644 index db6bd628f41..00000000000 --- a/src/relay/pty-source-credit-ledger.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { - ptySourceDeliveryKey, - ptySourceSpanIsSplittable, - samePtySourceDelivery, - type PtySourceCreditAck, - type PtySourceDeliveryCancellation, - type PtySourceDeliveryIdentity, - type PtySourceDeliverySnapshot, - type PtySourceSpan -} from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPositiveSafeInteger, - assertPtySourceIdentity -} from '../shared/pty-source-credit-validation' -import { - resolvePtySourceCreditLimits, - type PtySourceCreditLedgerOptions, - type PtySourceCreditLimits -} from './pty-source-credit-limits' -import { - CLOSED_DELIVERY_TOMBSTONE_LIMIT, - closeDeliveryGeneration, - createAppendedSourceSpan, - createDeliveryCancellation, - createDeliveryRecord, - createReplacementDeliveryRecord, - findPtySourceSpanForSend, - matchingDeliverySnapshot, - MAX_SOURCE_SPAN_DATA_BYTES, - ptyOwnerKey, - sliceForSend, - snapshotDeliveryRecord, - type DeliveryRecord, - type PtySourceAppendInput, - type PtySourceSendReservation -} from './pty-source-credit-record' -import { - acknowledgeDeliveryRecord, - commitPtySourceSend, - reservedPtySourceSendLength, - rollbackPtySourceSend, - type PtySourceAckResult -} from './pty-source-credit-settlement' -import { chargedPtyRetainedStringBytes } from '../shared/pty-retained-string-memory' -import { PtySourceCreditRetention } from './pty-source-credit-retention' - -export type { PtySourceSendReservation } from './pty-source-credit-record' -export type { PtySourceCreditLedgerOptions } from './pty-source-credit-limits' - -export class RelayPtySourceCreditLedger { - private readonly deliveries = new Map() - private readonly upstreamOwnerByPty = new Map() - private readonly closedSnapshots = new Map() - private readonly limits: PtySourceCreditLimits - private readonly retention = new PtySourceCreditRetention() - private nextReservationId = 1 - - constructor(options: PtySourceCreditLedgerOptions = {}) { - this.limits = resolvePtySourceCreditLimits(options) - } - - open( - identity: PtySourceDeliveryIdentity, - windowSu: number, - checkpointSourceEndSu = 0 - ): PtySourceDeliverySnapshot { - assertPtySourceIdentity(identity) - assertPositiveSafeInteger(windowSu, 'windowSu') - assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu') - const key = ptySourceDeliveryKey(identity) - if (this.deliveries.has(key) || this.closedSnapshots.has(key)) { - throw new Error('PTY source delivery token was already used') - } - const ownerKey = ptyOwnerKey(identity) - if (this.upstreamOwnerByPty.has(ownerKey)) { - throw new Error('PTY source delivery already has an upstream owner') - } - const record = createDeliveryRecord(identity, windowSu, checkpointSourceEndSu) - this.deliveries.set(key, record) - this.upstreamOwnerByPty.set(ownerKey, key) - return snapshotDeliveryRecord(record) - } - - append(identity: PtySourceDeliveryIdentity, input: PtySourceAppendInput): PtySourceSpan { - const record = this.requireActive(identity) - const sourceEndSu = record.receivedEndSu + input.transform.rawLengthSu - const encodedDataBytes = Buffer.byteLength(input.data, 'utf8') - const retainedDataBytes = chargedPtyRetainedStringBytes(input.data) - if (encodedDataBytes > MAX_SOURCE_SPAN_DATA_BYTES) { - throw new Error('PTY source span encoded-data budget exceeded') - } - if (sourceEndSu - record.creditedEndSu > this.limits.maxRetainedSourceSu) { - throw new Error('PTY source retained-range budget exceeded') - } - if ( - this.retainedSourceSu() + input.transform.rawLengthSu > - this.limits.maxAggregateRetainedSourceSu - ) { - throw new Error('Aggregate PTY source retained-range budget exceeded') - } - if (record.retainedDataBytes + retainedDataBytes > this.limits.maxRetainedDataBytes) { - throw new Error('PTY source retained encoded-data budget exceeded') - } - if (this.retainedDataBytes() + retainedDataBytes > this.limits.maxAggregateRetainedDataBytes) { - throw new Error('Aggregate PTY source retained encoded-data budget exceeded') - } - if (record.spans.length + 1 > this.limits.maxRetainedSpans) { - throw new Error('PTY source retained-span budget exceeded') - } - if (this.retainedSpans() + 1 > this.limits.maxAggregateRetainedSpans) { - throw new Error('Aggregate PTY source retained-span budget exceeded') - } - const span = createAppendedSourceSpan(record, input) - record.spans.push(span) - record.retainedDataBytes += retainedDataBytes - record.receivedEndSu = sourceEndSu - this.retention.addSpan(input.transform.rawLengthSu, retainedDataBytes) - return span - } - - reserveNextSend( - identity: PtySourceDeliveryIdentity, - maxSourceSu = 16 * 1024 - ): PtySourceSendReservation | null { - assertPositiveSafeInteger(maxSourceSu, 'maxSourceSu') - const record = this.requireDelivery(identity) - if (record.state !== 'active' && record.state !== 'sealed-unsettled') { - return null - } - if (record.pendingSend) { - throw new Error('PTY source delivery already has a pending send reservation') - } - const remainingWindowSu = record.windowSu - (record.sentEndSu - record.creditedEndSu) - if (remainingWindowSu <= 0 || record.sentEndSu >= record.receivedEndSu) { - return null - } - const containing = findPtySourceSpanForSend(record) - if (!containing || containing.sourceStartSu > record.sentEndSu) { - throw new Error('PTY source delivery cursor is not covered by the retained ledger') - } - const reservedLengthSu = reservedPtySourceSendLength(record, remainingWindowSu, maxSourceSu) - if (reservedLengthSu === null) { - return null - } - const available = reservedLengthSu ?? Math.min(remainingWindowSu, maxSourceSu) - if ( - (!ptySourceSpanIsSplittable(containing) || containing.transform.transformed) && - containing.sourceEndSu - record.sentEndSu > available - ) { - return null - } - const span = sliceForSend(containing, record.sentEndSu, available) - const reservation = Object.freeze({ - reservationId: `pty-source-send:${this.nextReservationId++}`, - identity: record.identity, - span - }) - record.pendingSend = reservation - record.attemptedEndSu = span.sourceEndSu - return reservation - } - - commitSend(reservation: PtySourceSendReservation): void { - const record = this.requireDelivery(reservation.identity) - const settled = this.retention.trackMutation(record, () => - commitPtySourceSend(record, reservation) - ) - if (settled) { - this.maybeClose(record) - } - } - - rollbackSend(reservation: PtySourceSendReservation): void { - const record = this.requireDelivery(reservation.identity) - rollbackPtySourceSend(record, reservation) - } - - acknowledge(identity: PtySourceDeliveryIdentity, ack: PtySourceCreditAck): PtySourceAckResult { - const record = this.requireDelivery(identity) - const result = this.retention.trackMutation(record, () => - acknowledgeDeliveryRecord(record, ack) - ) - if (result === 'advanced') { - this.maybeClose(record) - } - return result - } - - seal(identity: PtySourceDeliveryIdentity): void { - const record = this.requireActive(identity) - record.state = 'sealed-unsettled' - } - - settleExitPublication( - identity: PtySourceDeliveryIdentity, - result: { ok: true } | { ok: false; error: Error } - ): void { - const record = this.requireDelivery(identity) - if (record.state !== 'sealed-unsettled') { - throw new Error('PTY source delivery is not sealed') - } - if (!result.ok) { - return - } - if (record.pendingSend || record.sentEndSu !== record.receivedEndSu) { - throw new Error('PTY source exit cannot publish ahead of preceding source data') - } - record.exitPublished = true - this.maybeClose(record) - } - - cancel( - identity: PtySourceDeliveryIdentity, - reason: string, - replacementDeliveryToken?: string - ): PtySourceDeliveryCancellation { - const record = this.requireDelivery(identity) - record.state = 'closing' - const proof = createDeliveryCancellation(record, reason, replacementDeliveryToken) - this.closeRecord(record) - return proof - } - - closeGeneration(providerGeneration: number): number { - assertPositiveSafeInteger(providerGeneration, 'providerGeneration') - return closeDeliveryGeneration(this.deliveries.values(), providerGeneration, (record) => - this.closeRecord(record) - ) - } - - rotate( - oldIdentity: PtySourceDeliveryIdentity, - newIdentity: PtySourceDeliveryIdentity, - acceptedSourceEndSu: number, - windowSu: number - ): Readonly<{ cancellation: PtySourceDeliveryCancellation; recovery: readonly PtySourceSpan[] }> { - const old = this.requireDelivery(oldIdentity) - const replacementKey = ptySourceDeliveryKey(newIdentity) - if (this.deliveries.has(replacementKey) || this.closedSnapshots.has(replacementKey)) { - throw new Error('PTY source replacement token was already used') - } - const replacement = createReplacementDeliveryRecord( - old, - newIdentity, - acceptedSourceEndSu, - windowSu - ) - this.upstreamOwnerByPty.delete(ptyOwnerKey(old.identity)) - this.deliveries.set(replacementKey, replacement) - // Retention mirrors the delivery map, so count the replacement as it enters, not after cancel. - this.retention.addRecord(replacement) - this.upstreamOwnerByPty.set(ptyOwnerKey(replacement.identity), replacementKey) - const cancellation = this.cancel(oldIdentity, 'superseded', newIdentity.deliveryToken) - return Object.freeze({ cancellation, recovery: Object.freeze(replacement.spans.slice()) }) - } - - // Why: callers that can legitimately race a close probe instead of throwing; an evicted - // tombstone reads as null and must be treated as closed. - snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { - return matchingDeliverySnapshot(this.deliveries, this.closedSnapshots, identity) - } - - snapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { - const snapshot = this.snapshotIfKnown(identity) - if (!snapshot) { - throw new Error('Unknown or stale PTY source delivery') - } - return snapshot - } - - retainedSourceSu = (): number => this.retention.retainedSourceSu() - - retainedDataBytes = (): number => this.retention.retainedDataBytes() - - retainedSpans = (): number => this.retention.retainedSpans() - - retentionSnapshot = () => this.retention.snapshot() - - private requireActive(identity: PtySourceDeliveryIdentity): DeliveryRecord { - const record = this.requireDelivery(identity) - if (record.state !== 'active') { - throw new Error('PTY source delivery does not admit new source') - } - return record - } - - private requireDelivery(identity: PtySourceDeliveryIdentity): DeliveryRecord { - const record = this.deliveries.get(ptySourceDeliveryKey(identity)) - if (!record || !samePtySourceDelivery(record.identity, identity)) { - throw new Error('Unknown or stale PTY source delivery') - } - return record - } - - private maybeClose(record: DeliveryRecord): void { - if ( - record.state === 'sealed-unsettled' && - record.exitPublished && - record.creditedEndSu === record.receivedEndSu - ) { - this.closeRecord(record) - } - } - - private closeRecord(record: DeliveryRecord): void { - if (record.state === 'closed') { - return - } - this.retention.removeRecord(record) - record.state = 'closed' - record.pendingSend = null - record.spans = [] - record.retainedDataBytes = 0 - record.sendSpanIndex = 0 - const key = ptySourceDeliveryKey(record.identity) - const ownerKey = ptyOwnerKey(record.identity) - if (this.upstreamOwnerByPty.get(ownerKey) === key) { - this.upstreamOwnerByPty.delete(ownerKey) - } - this.deliveries.delete(key) - this.closedSnapshots.set(key, snapshotDeliveryRecord(record)) - while (this.closedSnapshots.size > CLOSED_DELIVERY_TOMBSTONE_LIMIT) { - this.closedSnapshots.delete(this.closedSnapshots.keys().next().value!) - } - } -} diff --git a/src/relay/pty-source-credit-limits.ts b/src/relay/pty-source-credit-limits.ts deleted file mode 100644 index 4a271c3d2fa..00000000000 --- a/src/relay/pty-source-credit-limits.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { assertPositiveSafeInteger } from '../shared/pty-source-credit-validation' -import { - DEFAULT_AGGREGATE_RETAINED_DATA_BYTES, - DEFAULT_AGGREGATE_RETAINED_SPANS, - DEFAULT_AGGREGATE_RETAINED_SOURCE_SU, - DEFAULT_RETAINED_DATA_BYTES, - DEFAULT_RETAINED_SPANS, - DEFAULT_RETAINED_SOURCE_SU -} from './pty-source-credit-record' - -export type PtySourceCreditLedgerOptions = { - maxRetainedSourceSu?: number - maxAggregateRetainedSourceSu?: number - maxRetainedDataBytes?: number - maxAggregateRetainedDataBytes?: number - maxRetainedSpans?: number - maxAggregateRetainedSpans?: number -} - -export type PtySourceCreditLimits = Required - -export function resolvePtySourceCreditLimits( - options: PtySourceCreditLedgerOptions -): PtySourceCreditLimits { - const limits = { - maxRetainedSourceSu: options.maxRetainedSourceSu ?? DEFAULT_RETAINED_SOURCE_SU, - maxAggregateRetainedSourceSu: - options.maxAggregateRetainedSourceSu ?? DEFAULT_AGGREGATE_RETAINED_SOURCE_SU, - maxRetainedDataBytes: options.maxRetainedDataBytes ?? DEFAULT_RETAINED_DATA_BYTES, - maxAggregateRetainedDataBytes: - options.maxAggregateRetainedDataBytes ?? DEFAULT_AGGREGATE_RETAINED_DATA_BYTES, - maxRetainedSpans: options.maxRetainedSpans ?? DEFAULT_RETAINED_SPANS, - maxAggregateRetainedSpans: options.maxAggregateRetainedSpans ?? DEFAULT_AGGREGATE_RETAINED_SPANS - } - for (const [name, value] of Object.entries(limits)) { - assertPositiveSafeInteger(value, name) - } - return Object.freeze(limits) -} diff --git a/src/relay/pty-source-credit-record.ts b/src/relay/pty-source-credit-record.ts deleted file mode 100644 index e44b845642e..00000000000 --- a/src/relay/pty-source-credit-record.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { - PtySourceDeliveryIdentity, - PtySourceDeliveryCancellation, - PtySourceDeliverySnapshot, - PtySourceSpan, - PtySourceTransform -} from '../shared/pty-source-credit-contract' -import { - ptySourceDeliveryKey, - ptySourceSpanIsSplittable, - samePtySourceDelivery -} from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPositiveSafeInteger, - assertPtySourceIdentity, - assertPtySourceSpan -} from '../shared/pty-source-credit-validation' -import { chargedPtyRetainedStringBytes } from '../shared/pty-retained-string-memory' -import { PtySourceSentBoundaries } from './pty-source-sent-boundaries' - -export const DEFAULT_RETAINED_SOURCE_SU = 512 * 1024 -export const DEFAULT_AGGREGATE_RETAINED_SOURCE_SU = 48 * 1024 * 1024 -export const DEFAULT_RETAINED_DATA_BYTES = 2 * 1024 * 1024 -export const DEFAULT_AGGREGATE_RETAINED_DATA_BYTES = 64 * 1024 * 1024 -export const DEFAULT_RETAINED_SPANS = 1_024 -export const DEFAULT_AGGREGATE_RETAINED_SPANS = 64 * 1_024 -export const MAX_SOURCE_SPAN_DATA_BYTES = 1024 * 1024 -export const CLOSED_DELIVERY_TOMBSTONE_LIMIT = 256 - -export type PtySourceSendReservation = Readonly<{ - reservationId: string - identity: PtySourceDeliveryIdentity - span: PtySourceSpan -}> - -export type DeliveryRecord = { - identity: PtySourceDeliveryIdentity - state: 'active' | 'sealed-unsettled' | 'closing' | 'closed' - windowSu: number - receivedEndSu: number - sentEndSu: number - creditedEndSu: number - retainedDataBytes: number - spans: PtySourceSpan[] - /** First retained span that may contain the next unsent source unit. */ - sendSpanIndex: number - sentBoundaries: PtySourceSentBoundaries - pendingSend: PtySourceSendReservation | null - reservedAckEndSu: number | null - attemptedEndSu: number | null - exitPublished: boolean - generationClosed: boolean -} - -export function ptyOwnerKey(identity: PtySourceDeliveryIdentity): string { - return `${identity.providerGeneration}\0${identity.id}\0${identity.ptyIncarnation}` -} - -export function createDeliveryRecord( - identity: PtySourceDeliveryIdentity, - windowSu: number, - checkpointSourceEndSu: number -): DeliveryRecord { - return { - identity: Object.freeze({ ...identity }), - state: 'active', - windowSu, - receivedEndSu: checkpointSourceEndSu, - sentEndSu: checkpointSourceEndSu, - creditedEndSu: checkpointSourceEndSu, - retainedDataBytes: 0, - spans: [], - sendSpanIndex: 0, - sentBoundaries: new PtySourceSentBoundaries(checkpointSourceEndSu), - pendingSend: null, - reservedAckEndSu: null, - attemptedEndSu: null, - exitPublished: false, - generationClosed: false - } -} - -export type PtySourceAppendInput = Readonly<{ - spanId: string - data: string - displayStart: number - displayEnd: number - splittable: boolean - transform: PtySourceTransform -}> - -export function createAppendedSourceSpan( - record: DeliveryRecord, - input: PtySourceAppendInput -): PtySourceSpan { - const span = Object.freeze({ - ...record.identity, - ...input, - transform: Object.freeze({ ...input.transform }), - sourceStartSu: record.receivedEndSu, - sourceEndSu: record.receivedEndSu + input.transform.rawLengthSu - }) - assertPtySourceSpan(span) - return span -} - -export function sliceAtSourceStart(span: PtySourceSpan, sourceStartSu: number): PtySourceSpan { - if (sourceStartSu === span.sourceStartSu) { - return span - } - if (!ptySourceSpanIsSplittable(span) || span.transform.transformed) { - throw new Error('Indivisible PTY source span cannot be split for recovery') - } - const offset = sourceStartSu - span.sourceStartSu - return Object.freeze({ - ...span, - spanId: `${span.spanId}:suffix:${sourceStartSu}`, - sourceStartSu, - displayStart: span.displayStart + offset, - data: span.data.slice(offset), - transform: Object.freeze({ - ...span.transform, - rawLengthSu: span.sourceEndSu - sourceStartSu - }) - }) -} - -export function sliceForSend( - span: PtySourceSpan, - sourceStartSu: number, - maxSourceSu: number -): PtySourceSpan { - const remaining = sliceAtSourceStart(span, sourceStartSu) - const sourceLengthSu = remaining.sourceEndSu - remaining.sourceStartSu - if (sourceLengthSu <= maxSourceSu) { - return remaining - } - if (!ptySourceSpanIsSplittable(remaining) || remaining.transform.transformed) { - throw new Error('Indivisible PTY source span does not fit the available source window') - } - let endOffset = maxSourceSu - const trailing = remaining.data.charCodeAt(endOffset - 1) - const following = remaining.data.charCodeAt(endOffset) - if ( - endOffset > 0 && - trailing >= 0xd800 && - trailing <= 0xdbff && - following >= 0xdc00 && - following <= 0xdfff - ) { - endOffset-- - } - if (endOffset <= 0) { - throw new Error('Available source window would split a surrogate pair') - } - return Object.freeze({ - ...remaining, - spanId: `${remaining.spanId}:slice:${remaining.sourceStartSu + endOffset}`, - sourceEndSu: remaining.sourceStartSu + endOffset, - displayEnd: remaining.displayStart + endOffset, - data: remaining.data.slice(0, endOffset), - transform: Object.freeze({ ...remaining.transform, rawLengthSu: endOffset }) - }) -} - -export function findPtySourceSpanForSend(record: DeliveryRecord): PtySourceSpan | undefined { - let span = record.spans[record.sendSpanIndex] - while (span && span.sourceEndSu <= record.sentEndSu) { - record.sendSpanIndex += 1 - span = record.spans[record.sendSpanIndex] - } - return span -} - -export function snapshotDeliveryRecord(record: DeliveryRecord): PtySourceDeliverySnapshot { - return Object.freeze({ - ...record.identity, - state: record.state, - windowSu: record.windowSu, - receivedEndSu: record.receivedEndSu, - sentEndSu: record.sentEndSu, - creditedEndSu: record.creditedEndSu, - exitPublished: record.exitPublished, - generationClosed: record.generationClosed - }) -} - -// Why: identity equality is re-checked because a delivery key outlives the token that made it. -export function matchingDeliverySnapshot( - deliveries: ReadonlyMap, - closedSnapshots: ReadonlyMap, - identity: PtySourceDeliveryIdentity -): PtySourceDeliverySnapshot | null { - const key = ptySourceDeliveryKey(identity) - const active = deliveries.get(key) - if (active && samePtySourceDelivery(active.identity, identity)) { - return snapshotDeliveryRecord(active) - } - const closed = closedSnapshots.get(key) - return closed && samePtySourceDelivery(closed, identity) ? closed : null -} - -export function createReplacementDeliveryRecord( - old: DeliveryRecord, - newIdentity: PtySourceDeliveryIdentity, - acceptedSourceEndSu: number, - windowSu: number -): DeliveryRecord { - assertPtySourceIdentity(newIdentity) - assertPositiveSafeInteger(windowSu, 'windowSu') - assertNonNegativeSafeInteger(acceptedSourceEndSu, 'acceptedSourceEndSu') - const committedCheckpoint = - acceptedSourceEndSu <= old.sentEndSu && old.sentBoundaries.has(acceptedSourceEndSu) - const attemptedCheckpoint = acceptedSourceEndSu === old.attemptedEndSu - if ( - acceptedSourceEndSu < old.creditedEndSu || - (!committedCheckpoint && !attemptedCheckpoint) || - old.pendingSend || - newIdentity.id !== old.identity.id || - newIdentity.ptyIncarnation !== old.identity.ptyIncarnation || - newIdentity.providerGeneration !== old.identity.providerGeneration - ) { - throw new Error('PTY source recovery checkpoint does not exactly cover the retained delivery') - } - if (acceptedSourceEndSu > old.sentEndSu) { - old.sentEndSu = acceptedSourceEndSu - old.sentBoundaries.add(acceptedSourceEndSu) - } - old.attemptedEndSu = null - const replacement = createDeliveryRecord(newIdentity, windowSu, acceptedSourceEndSu) - replacement.state = old.state === 'sealed-unsettled' ? 'sealed-unsettled' : 'active' - replacement.receivedEndSu = old.receivedEndSu - replacement.spans = old.spans - .filter((span) => span.sourceEndSu > acceptedSourceEndSu) - .map((span) => sliceAtSourceStart(span, Math.max(span.sourceStartSu, acceptedSourceEndSu))) - .map((span) => Object.freeze({ ...span, ...replacement.identity })) - replacement.sendSpanIndex = 0 - replacement.retainedDataBytes = replacement.spans.reduce( - (bytes, span) => bytes + chargedPtyRetainedStringBytes(span.data), - 0 - ) - return replacement -} - -export function createDeliveryCancellation( - record: DeliveryRecord, - reason: string, - replacementDeliveryToken?: string -): PtySourceDeliveryCancellation { - return Object.freeze({ - ...record.identity, - reason, - sentEndSu: record.sentEndSu, - creditedEndSu: record.creditedEndSu, - remainingStartSu: record.creditedEndSu, - remainingEndSu: record.sentEndSu, - ...(replacementDeliveryToken ? { replacementDeliveryToken } : {}) - }) -} - -export function closeDeliveryGeneration( - records: Iterable, - providerGeneration: number, - close: (record: DeliveryRecord) => void -): number { - let closed = 0 - for (const record of Array.from(records)) { - if (record.identity.providerGeneration !== providerGeneration || record.state === 'closed') { - continue - } - record.generationClosed = true - close(record) - closed++ - } - return closed -} diff --git a/src/relay/pty-source-credit-retention.ts b/src/relay/pty-source-credit-retention.ts deleted file mode 100644 index 742a660e374..00000000000 --- a/src/relay/pty-source-credit-retention.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { DeliveryRecord } from './pty-source-credit-record' - -export type PtySourceCreditRetentionSnapshot = Readonly<{ - sourceSu: number - dataBytes: number - spans: number -}> - -type RecordRetention = PtySourceCreditRetentionSnapshot - -export class PtySourceCreditRetention { - private sourceSuTotal = 0 - private dataBytesTotal = 0 - private spansTotal = 0 - - addSpan(sourceSu: number, dataBytes: number): void { - this.sourceSuTotal += sourceSu - this.dataBytesTotal += dataBytes - this.spansTotal += 1 - } - - addRecord(record: DeliveryRecord): void { - this.apply(recordRetention(record), 1) - } - - removeRecord(record: DeliveryRecord): void { - this.apply(recordRetention(record), -1) - } - - trackMutation(record: DeliveryRecord, mutate: () => T): T { - const before = recordRetention(record) - try { - return mutate() - } finally { - const after = recordRetention(record) - this.sourceSuTotal += after.sourceSu - before.sourceSu - this.dataBytesTotal += after.dataBytes - before.dataBytes - this.spansTotal += after.spans - before.spans - } - } - - retainedSourceSu = (): number => this.sourceSuTotal - - retainedDataBytes = (): number => this.dataBytesTotal - - retainedSpans = (): number => this.spansTotal - - snapshot(): PtySourceCreditRetentionSnapshot { - return Object.freeze({ - sourceSu: this.sourceSuTotal, - dataBytes: this.dataBytesTotal, - spans: this.spansTotal - }) - } - - private apply(retention: RecordRetention, direction: 1 | -1): void { - this.sourceSuTotal += direction * retention.sourceSu - this.dataBytesTotal += direction * retention.dataBytes - this.spansTotal += direction * retention.spans - } -} - -function recordRetention(record: DeliveryRecord): RecordRetention { - return { - sourceSu: record.receivedEndSu - record.creditedEndSu, - dataBytes: record.retainedDataBytes, - spans: record.spans.length - } -} diff --git a/src/relay/pty-source-credit-scheduler.test.ts b/src/relay/pty-source-credit-scheduler.test.ts deleted file mode 100644 index 1e1c2ab76e6..00000000000 --- a/src/relay/pty-source-credit-scheduler.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' -import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' -import { RelayPtySourceCreditScheduler } from './pty-source-credit-scheduler' - -function identity(index: number): PtySourceDeliveryIdentity { - return { - id: `pty-${index}`, - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: `incarnation-${index}`, - deliveryToken: `token-${index}` - } -} - -describe('RelayPtySourceCreditScheduler', () => { - it('round-robins fifty continuously active PTYs without exceeding turn budgets', () => { - const ledger = new RelayPtySourceCreditLedger() - const scheduler = new RelayPtySourceCreditScheduler(ledger) - const identities = Array.from({ length: 50 }, (_, index) => identity(index)) - for (const owner of identities) { - ledger.open(owner, 1024) - ledger.append(owner, { - spanId: `span-${owner.id}`, - data: 'x'.repeat(100), - displayStart: 0, - displayEnd: 100, - splittable: true, - transform: { transformed: false, rawLengthSu: 100, scalarSafe: true } - }) - scheduler.enqueue(owner) - } - - const visited: string[] = [] - for (let turn = 0; turn < 25; turn++) { - const reservations = scheduler.takeTurn() - expect(reservations.length).toBeLessThanOrEqual(2) - expect( - reservations.reduce( - (total, reservation) => - total + reservation.span.sourceEndSu - reservation.span.sourceStartSu, - 0 - ) - ).toBeLessThanOrEqual(32 * 1024) - for (const reservation of reservations) { - visited.push(reservation.identity.id) - ledger.commitSend(reservation) - } - } - - expect(new Set(visited)).toEqual(new Set(identities.map((owner) => owner.id))) - }) - - it('does not duplicate a turn after remove and re-enqueue', () => { - const ledger = new RelayPtySourceCreditLedger() - const scheduler = new RelayPtySourceCreditScheduler(ledger) - const owner = identity(1) - ledger.open(owner, 8) - ledger.append(owner, { - spanId: 'span-1', - data: 'ab', - displayStart: 0, - displayEnd: 2, - splittable: true, - transform: { transformed: false, rawLengthSu: 2, scalarSafe: true } - }) - scheduler.enqueue(owner) - scheduler.remove(owner) - scheduler.enqueue(owner) - - expect(scheduler.takeTurn(2, 2)).toHaveLength(1) - }) - - it('isolates one rejecting delivery without losing reservations or blocking peers', () => { - const ledger = new RelayPtySourceCreditLedger() - const scheduler = new RelayPtySourceCreditScheduler(ledger) - const owners = [identity(1), identity(2), identity(3)] - for (const owner of owners) { - ledger.open(owner, 8) - ledger.append(owner, { - spanId: `span-${owner.id}`, - data: 'ab', - displayStart: 0, - displayEnd: 2, - splittable: true, - transform: { transformed: false, rawLengthSu: 2, scalarSafe: true } - }) - scheduler.enqueue(owner) - } - const reserveNextSend = ledger.reserveNextSend.bind(ledger) - const reserve = vi.spyOn(ledger, 'reserveNextSend').mockImplementation((owner, maxSourceSu) => { - if (owner === owners[1]) { - throw new Error('delivery ledger corrupted') - } - return reserveNextSend(owner, maxSourceSu) - }) - - const reservations = scheduler.takeTurn(2, 4) - - expect(reserve).toHaveBeenCalledTimes(3) - expect(reservations.map(({ identity: owner }) => owner.id)).toEqual(['pty-1', 'pty-3']) - for (const reservation of reservations) { - ledger.commitSend(reservation) - } - scheduler.takeTurn(2, 4) - expect(reserve).toHaveBeenCalledTimes(5) - expect(reserve.mock.calls.map(([owner]) => owner.id)).toEqual([ - 'pty-1', - 'pty-2', - 'pty-3', - 'pty-1', - 'pty-3' - ]) - }) -}) diff --git a/src/relay/pty-source-credit-scheduler.ts b/src/relay/pty-source-credit-scheduler.ts deleted file mode 100644 index 792daec0ff4..00000000000 --- a/src/relay/pty-source-credit-scheduler.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - ptySourceDeliveryKey, - type PtySourceDeliveryIdentity -} from '../shared/pty-source-credit-contract' -import type { - RelayPtySourceCreditLedger, - PtySourceSendReservation -} from './pty-source-credit-ledger' - -export const PTY_SOURCE_SCHEDULER_MAX_FRAMES = 2 -export const PTY_SOURCE_SCHEDULER_MAX_SU = 32 * 1024 - -export class RelayPtySourceCreditScheduler { - private readonly identities = new Map() - private readonly readyKeys: string[] = [] - private readonly queuedKeys = new Set() - - constructor(private readonly ledger: RelayPtySourceCreditLedger) {} - - enqueue(identity: PtySourceDeliveryIdentity): void { - const key = ptySourceDeliveryKey(identity) - this.identities.set(key, identity) - if (!this.queuedKeys.has(key)) { - this.queuedKeys.add(key) - this.readyKeys.push(key) - } - } - - remove(identity: PtySourceDeliveryIdentity): void { - const key = ptySourceDeliveryKey(identity) - this.identities.delete(key) - this.queuedKeys.delete(key) - for (let index = this.readyKeys.length - 1; index >= 0; index--) { - if (this.readyKeys[index] === key) { - this.readyKeys.splice(index, 1) - } - } - } - - takeTurn( - maxFrames = PTY_SOURCE_SCHEDULER_MAX_FRAMES, - maxSourceSu = PTY_SOURCE_SCHEDULER_MAX_SU - ): PtySourceSendReservation[] { - const reservations: PtySourceSendReservation[] = [] - let remainingKeys = this.readyKeys.length - let admittedSu = 0 - while (remainingKeys-- > 0 && reservations.length < maxFrames && admittedSu < maxSourceSu) { - const key = this.readyKeys.shift()! - this.queuedKeys.delete(key) - const identity = this.identities.get(key) - if (!identity) { - continue - } - let reservation: PtySourceSendReservation | null - try { - reservation = this.ledger.reserveNextSend(identity, maxSourceSu - admittedSu) - } catch { - this.identities.delete(key) - continue - } - this.enqueue(identity) - if (!reservation) { - continue - } - reservations.push(reservation) - admittedSu += reservation.span.sourceEndSu - reservation.span.sourceStartSu - } - return reservations - } -} diff --git a/src/relay/pty-source-credit-settlement.ts b/src/relay/pty-source-credit-settlement.ts deleted file mode 100644 index 5a27782c714..00000000000 --- a/src/relay/pty-source-credit-settlement.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { PtySourceCreditAck } from '../shared/pty-source-credit-contract' -import { assertPtySourceAck } from '../shared/pty-source-credit-validation' -import { chargedPtyRetainedStringBytes } from '../shared/pty-retained-string-memory' -import type { DeliveryRecord, PtySourceSendReservation } from './pty-source-credit-record' - -export type PtySourceAckResult = 'advanced' | 'reserved' | 'duplicate' | 'regression' - -export function reservedPtySourceSendLength( - record: DeliveryRecord, - remainingWindowSu: number, - maxSourceSu: number -): number | null | undefined { - if (record.reservedAckEndSu === null) { - return undefined - } - const lengthSu = record.reservedAckEndSu - record.sentEndSu - return lengthSu > 0 && lengthSu <= remainingWindowSu && lengthSu <= maxSourceSu ? lengthSu : null -} - -function reclaimCreditedSpans(record: DeliveryRecord): void { - let removed = 0 - while (record.spans[0]?.sourceEndSu <= record.creditedEndSu) { - record.retainedDataBytes -= chargedPtyRetainedStringBytes(record.spans.shift()!.data) - removed += 1 - } - if (removed > 0) { - record.sendSpanIndex = Math.max(0, record.sendSpanIndex - removed) - } -} - -function advanceCredit(record: DeliveryRecord, creditedEndSu: number): void { - record.creditedEndSu = creditedEndSu - record.sentBoundaries.dropBelow(creditedEndSu) - reclaimCreditedSpans(record) -} - -export function acknowledgeDeliveryRecord( - record: DeliveryRecord, - ack: PtySourceCreditAck -): PtySourceAckResult { - assertPtySourceAck(ack) - if (record.state === 'closed' || record.state === 'closing') { - throw new Error('PTY source ACK targets a closed delivery') - } - if ( - ack.id !== record.identity.id || - ack.clientGeneration !== record.identity.clientGeneration || - ack.ownerGeneration !== record.identity.ownerGeneration || - ack.deliveryToken !== record.identity.deliveryToken - ) { - throw new Error('PTY source ACK does not own this delivery') - } - const reservedBoundarySu = record.pendingSend?.span.sourceEndSu ?? record.reservedAckEndSu - const eligibleEndSu = reservedBoundarySu ?? record.sentEndSu - if (ack.creditedEndSu > eligibleEndSu) { - throw new Error('PTY source ACK exceeds sent source credit') - } - if (ack.creditedEndSu === record.creditedEndSu) { - return 'duplicate' - } - if (ack.creditedEndSu < record.creditedEndSu) { - return 'regression' - } - if (ack.creditedEndSu > record.sentEndSu) { - if (ack.creditedEndSu !== reservedBoundarySu) { - throw new Error('PTY source ACK does not match a reserved send boundary') - } - record.reservedAckEndSu = ack.creditedEndSu - return 'reserved' - } - if (!record.sentBoundaries.has(ack.creditedEndSu)) { - throw new Error('PTY source ACK does not match a committed send boundary') - } - advanceCredit(record, ack.creditedEndSu) - return 'advanced' -} - -export function settleReservedPtySourceAck(record: DeliveryRecord): boolean { - const creditedEndSu = record.reservedAckEndSu - record.reservedAckEndSu = null - if (creditedEndSu === null) { - return false - } - if (creditedEndSu !== record.sentEndSu || !record.sentBoundaries.has(creditedEndSu)) { - throw new Error('PTY source reserved ACK does not match the settled send boundary') - } - advanceCredit(record, creditedEndSu) - return true -} - -export function commitPtySourceSend( - record: DeliveryRecord, - reservation: PtySourceSendReservation -): boolean { - if (record.pendingSend !== reservation) { - throw new Error('PTY source send reservation is stale') - } - // sentBoundaries lookups and cleanup both assume ascending order, so inserts must ascend. - if (reservation.span.sourceEndSu <= record.sentEndSu) { - throw new Error('PTY source send reservation regresses the sent boundary') - } - record.pendingSend = null - record.sentEndSu = reservation.span.sourceEndSu - record.sentBoundaries.add(record.sentEndSu) - record.attemptedEndSu = null - return settleReservedPtySourceAck(record) -} - -export function rollbackPtySourceSend( - record: DeliveryRecord, - reservation: PtySourceSendReservation -): void { - if (record.pendingSend !== reservation) { - return - } - record.pendingSend = null -} diff --git a/src/relay/pty-source-delivery-ownership.ts b/src/relay/pty-source-delivery-ownership.ts deleted file mode 100644 index b7f1306e72a..00000000000 --- a/src/relay/pty-source-delivery-ownership.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' -import type { PtyConsumerSessionGrant } from '../shared/pty-consumer-session' - -export function ownedPtySourceDelivery( - identity: PtySourceDeliveryIdentity | undefined, - grant: Readonly, - id: string -): PtySourceDeliveryIdentity | null { - if ( - !identity || - identity.id !== id || - identity.clientGeneration !== grant.clientGeneration || - identity.ownerGeneration !== grant.ownerGeneration - ) { - return null - } - return identity -} diff --git a/src/relay/pty-source-sent-boundaries.test.ts b/src/relay/pty-source-sent-boundaries.test.ts deleted file mode 100644 index 367501f9c28..00000000000 --- a/src/relay/pty-source-sent-boundaries.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PtySourceSentBoundaries } from './pty-source-sent-boundaries' - -describe('PtySourceSentBoundaries', () => { - it('keeps the checkpoint boundary and every later insert', () => { - const boundaries = new PtySourceSentBoundaries(4) - boundaries.add(9) - boundaries.add(11) - expect([...boundaries]).toEqual([4, 9, 11]) - expect(boundaries.has(4)).toBe(true) - expect(boundaries.has(9)).toBe(true) - expect(boundaries.has(11)).toBe(true) - expect(boundaries.has(10)).toBe(false) - expect(boundaries.has(12)).toBe(false) - }) - - it('rejects a boundary that does not ascend past the highest insert', () => { - const boundaries = new PtySourceSentBoundaries(4) - boundaries.add(9) - expect(() => boundaries.add(9)).toThrow('does not ascend') - expect(() => boundaries.add(5)).toThrow('does not ascend') - expect([...boundaries]).toEqual([4, 9]) - }) - - it('drops every boundary below the credit and keeps the credit itself', () => { - const boundaries = new PtySourceSentBoundaries(0) - for (const boundary of [1, 2, 3, 4, 5]) { - boundaries.add(boundary) - } - boundaries.dropBelow(3) - expect([...boundaries]).toEqual([3, 4, 5]) - expect(boundaries.has(2)).toBe(false) - expect(boundaries.has(3)).toBe(true) - }) - - it('stays correct across the compaction that follows a long interleaved drain', () => { - const boundaries = new PtySourceSentBoundaries(0) - for (let boundary = 1; boundary <= 4_096; boundary += 1) { - boundaries.add(boundary) - boundaries.dropBelow(boundary - 1) - expect(boundaries.has(boundary - 1)).toBe(true) - expect(boundaries.has(boundary - 2)).toBe(false) - } - expect([...boundaries]).toEqual([4_095, 4_096]) - }) -}) diff --git a/src/relay/pty-source-sent-boundaries.ts b/src/relay/pty-source-sent-boundaries.ts deleted file mode 100644 index de1ecf238f0..00000000000 --- a/src/relay/pty-source-sent-boundaries.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Committed send boundaries in ascending order. Cleanup advances a monotone cursor rather than -// iterating a Set, because repeated Set iteration rescans V8 delete tombstones and stays quadratic -// across an ACK drain even when the loop breaks at the first live element. -export class PtySourceSentBoundaries { - private entries: number[] - private liveStart = 0 - - constructor(checkpointBoundary: number) { - this.entries = [checkpointBoundary] - } - - // has binary-searches and dropBelow scans forward, so a non-ascending insert corrupts both. - add(boundary: number): void { - const highest = this.entries.at(-1) - if (highest !== undefined && boundary <= highest) { - throw new Error('PTY source sent boundary does not ascend') - } - this.entries.push(boundary) - } - - has(boundary: number): boolean { - let low = this.liveStart - let high = this.entries.length - 1 - while (low <= high) { - const middle = (low + high) >>> 1 - const value = this.entries[middle]! - if (value === boundary) { - return true - } - if (value < boundary) { - low = middle + 1 - } else { - high = middle - 1 - } - } - return false - } - - dropBelow(boundary: number): void { - while (this.liveStart < this.entries.length && this.entries[this.liveStart]! < boundary) { - this.liveStart += 1 - } - // Compact only once the dropped prefix outgrows the live tail so the copy cost stays amortized. - if (this.liveStart > 0 && this.liveStart * 2 >= this.entries.length) { - this.entries = this.entries.slice(this.liveStart) - this.liveStart = 0 - } - } - - *[Symbol.iterator](): IterableIterator { - for (let index = this.liveStart; index < this.entries.length; index += 1) { - yield this.entries[index]! - } - } -} diff --git a/src/relay/pty-spawn-cwd.ts b/src/relay/pty-spawn-cwd.ts deleted file mode 100644 index 17d380c2102..00000000000 --- a/src/relay/pty-spawn-cwd.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { statSync } from 'node:fs' -import { parseWorkspaceKey } from '../shared/workspace-scope' -import { splitWorktreeIdForFilesystem } from '../shared/worktree/id' -import { resolveDefaultCwd } from './pty-shell-utils' - -export type RelaySpawnCwdResolution = - | { kind: 'requested' | 'worktree' | 'workspace-root' | 'host-default'; cwd: string } - | { kind: 'unresolved'; workspaceId: string } - -export function relayHostDirectoryExists(path: string): boolean { - try { - return statSync(path).isDirectory() - } catch { - return false - } -} - -export function formatUnresolvedRelaySpawnCwdMessage(workspaceId: string): string { - return `Cannot determine the working directory for workspace ${workspaceId} on this host. Refusing to start an agent in a fallback directory.` -} - -function trimmedString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined -} - -/** - * Resolve the cwd a relay PTY must spawn in. - * - * Why the workspace-root hop: a folder workspace's id is `folder:` and carries no path, so - * the worktree-id split yields nothing and the host default silently won (#15296). The client - * already delivers the configured root as `ORCA_WORKSPACE_ROOT`; read it before falling back. - * - * Existence is checked on the relay host only when the relay is itself the execution host. A - * worktree path absent here (a Windows relay launching into WSL) stays a miss, not a refusal — the - * launch wrapper owns that cd. A declared-but-absent folder root is different: we know exactly which - * directory was meant, so substituting `$HOME` for an agent is the damage this refuses to do. - * - * That refusal is only sound when the relay's own filesystem is the one the spawn will use. A WSL - * shell hands execution to a guest, and a guest path never stats on the Windows relay, so - * `executesOnRelayFilesystem: false` demotes the refusal back to a miss — the same treatment the - * worktree branch already gives that host pair. - */ -export function resolveRelaySpawnCwd(args: { - requestedCwd?: unknown - worktreeId?: string - env?: Record - launchAgent?: unknown - directoryExists?: (path: string) => boolean - hostDefaultCwd?: () => string - /** Defaults to true: absent better knowledge, the relay is the execution host. */ - executesOnRelayFilesystem?: boolean -}): RelaySpawnCwdResolution { - const directoryExists = args.directoryExists ?? relayHostDirectoryExists - const requested = trimmedString(args.requestedCwd) - if (requested) { - return { kind: 'requested', cwd: requested } - } - - const workspaceId = trimmedString(args.worktreeId) ?? trimmedString(args.env?.ORCA_WORKSPACE_ID) - const scope = workspaceId ? parseWorkspaceKey(workspaceId) : null - const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId - const worktreePath = - scope?.type === 'folder' || !worktreeId - ? undefined - : splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - if (worktreePath && directoryExists(worktreePath)) { - return { kind: 'worktree', cwd: worktreePath } - } - - const workspaceRoot = trimmedString(args.env?.ORCA_WORKSPACE_ROOT) - if (workspaceRoot && directoryExists(workspaceRoot)) { - return { kind: 'workspace-root', cwd: workspaceRoot } - } - - // A folder workspace named a root we could not resolve. For an agent that is - // "cannot determine", not permission to pick one. - if ( - args.launchAgent !== undefined && - args.executesOnRelayFilesystem !== false && - (workspaceRoot !== undefined || scope?.type === 'folder') - ) { - return { kind: 'unresolved', workspaceId: workspaceId ?? 'unknown' } - } - - return { kind: 'host-default', cwd: (args.hostDefaultCwd ?? resolveDefaultCwd)() } -} diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts deleted file mode 100644 index 69d8097a743..00000000000 --- a/src/relay/relay-agent-hook-runtime.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { AGENT_HOOK_INFER_INTERRUPT_METHOD } from '../shared/agent-hook-interrupt-reconciliation' -import { homedir } from 'node:os' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' -import { RelayAgentHookServer } from '../wsl-guest/agent-hook-server' -import { endpointDirForRelaySocket } from '../wsl-guest/agent-hook-endpoint-coordinates' -import { PluginOverlayManager } from '../wsl-guest/plugin-overlay' -import { installOpenCodePluginInCanonicalConfig } from '../wsl-guest/opencode-canonical-config' -import { - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD -} from '../shared/agent-hook-relay' -import { publishAgentHookEnvelope } from '../wsl-guest/agent-hook-envelope-publication' -import { assertPluginSourceUnderByteCap } from '../wsl-guest/plugin-source-limit' -import { - resolveOpenCodeSourceConfigDir, - resolvePiSourceAgentDir, - resolveOmpConfigDirName -} from '../wsl-guest/plugin-overlay-env' -import { - detectExplicitPiAgentKindFromCommand, - isPiCompatibleAgentType -} from '../shared/pi-agent-kind' -import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' -import { selectOpenCodeHookAgent } from '../shared/opencode-launch-command' -import { relayLogLine } from './relay-diagnostic-log' -import { restoreOrStripOverlayEnv } from '../shared/agent-overlay-env' -import { registerManagedHookInstaller } from './managed-hook-installer' -import { readSessionShellStartupEnvVar } from '../main/pty/shell-startup-env' - -export class RelayAgentHookRuntime { - private readonly hookServer: RelayAgentHookServer - private readonly pluginOverlay = new PluginOverlayManager() - - constructor( - private readonly dispatcher: RelayDispatcher, - private readonly ptyHandler: PtyHandler, - sockPath: string, - endpointDir?: string - ) { - this.hookServer = new RelayAgentHookServer({ - endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), - forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope), - forwardUnavailable: (envelope) => publishAgentHookEnvelope(dispatcher, envelope), - // Why: the PTY handler is the only component that knows which panes still have a client - // surface, so it — not the client — decides whether a hook post describes a live pane. - isPaneSurfaceRetired: (paneKey) => ptyHandler.isPaneSurfaceRetired(paneKey), - getAgentLaunchToken: (paneKey) => ptyHandler.getAgentLaunchToken(paneKey), - getTmuxManagedPty: async (paneKey) => ptyHandler.getTmuxManagedPty(paneKey) - }) - } - - async start(): Promise { - try { - await this.hookServer.start({ publishEndpoint: false }) - } catch (error) { - relayLogLine( - `[relay] agent-hook server failed to start: ${error instanceof Error ? error.message : String(error)}` - ) - } - this.registerPtyEnvironment() - this.registerHandlers() - } - - publishEndpointFile(): void { - this.hookServer.publishEndpointFile() - } - - stop(): void { - this.hookServer.stop() - } - - private registerPtyEnvironment(): void { - this.ptyHandler.setClaudeTerminalEvidenceListener((paneKey, evidence) => { - this.hookServer.claudeTerminalInterrupts.observe(paneKey, evidence) - }) - this.ptyHandler.setAgentPresenceTrigger((paneKey) => { - void this.hookServer.checkAgentPresence(paneKey) - }) - this.ptyHandler.addEnvAugmenter(() => this.hookServer.buildPtyEnv()) - this.ptyHandler.addEnvAugmenter((context) => this.buildPluginEnvironment(context)) - this.ptyHandler.setExitListener(({ paneKey, id }) => { - if (paneKey) { - this.hookServer.clearPaneState(paneKey) - } - this.pluginOverlay.clearOverlay(paneKey ?? id) - }) - // Why: the exit listener above only fires on proof of process death, which a shell that - // survives teardown never produces. Drop the pane's cached status the moment its tab goes, so a - // reconnecting client cannot be handed a replay of an agent nobody owns. - this.ptyHandler.setSurfaceRetiredListener(({ paneKey }) => { - this.hookServer.clearPaneState(paneKey) - }) - } - - private async buildPluginEnvironment( - context: Parameters[0] - ): Promise> { - const env: Record = {} - const overlayId = context.paneKey ?? context.id - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) - const opencodeAgent = selectOpenCodeHookAgent(context.launchAgent, launchCommandHint, (agent) => - this.pluginOverlay.hasOpenCodeSource(agent) - ) - restoreOrStripOverlayEnv( - context.env, - { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }, - {} - ) - delete context.env.ORCA_OPENCODE_AGENT - if (opencodeAgent) { - env.ORCA_OPENCODE_AGENT = opencodeAgent - const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) - const inheritedRelayOverlay = sourceDir - ? this.pluginOverlay.isRelayOverlayPath(sourceDir) - : false - if (sourceDir && !inheritedRelayOverlay) { - const dir = this.pluginOverlay.materializeOpenCode(overlayId, sourceDir, opencodeAgent) - if (dir) { - env.OPENCODE_CONFIG_DIR = dir - env.ORCA_OPENCODE_CONFIG_DIR = dir - env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir - } - } else { - this.pluginOverlay.installOpenCodePlugin(opencodeAgent, { - ...context.env, - XDG_CONFIG_HOME: - readSessionShellStartupEnvVar('XDG_CONFIG_HOME', context.env, context.shell) ?? - context.env.XDG_CONFIG_HOME - }) - } - } - const explicitKind = isPiCompatibleAgentType(context.launchAgent) - ? context.launchAgent - : context.launchAgent === undefined - ? detectExplicitPiAgentKindFromCommand(launchCommandHint) - : null - const kind = explicitKind ?? 'pi' - const hasLaunchCommand = - typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - if (kind === 'omp' || !hasLaunchCommand) { - env.ORCA_OMP_FRESH_CONFIG = this.pluginOverlay.materializeOmpFreshConfig() - } - if (!this.pluginOverlay.hasPiSource()) { - return env - } - if (kind === 'pi') { - const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'pi') - const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { - materializeDefaultHome: explicitKind === 'pi' - }) - if (result?.sourceAgentDir) { - env.ORCA_PI_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - if (kind === 'omp' || !hasLaunchCommand) { - const sourceDir = - kind === 'omp' - ? resolvePiSourceAgentDir(context.env, context.shell, 'omp') - : context.env.ORCA_OMP_SOURCE_AGENT_DIR - const configDirName = await resolveOmpConfigDirName(context.env, context.shell) - if (configDirName !== undefined) { - env.PI_CONFIG_DIR = configDirName - } - const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'omp', { - materializeDefaultHome: explicitKind === 'omp', - configDirName - }) - if (result?.statusExtensionPath) { - env.ORCA_OMP_STATUS_EXTENSION = result.statusExtensionPath - } - if (result?.sourceAgentDir) { - env.ORCA_OMP_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - if (kind === 'prime-agent') { - const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'prime-agent') - const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'prime-agent', { - materializeDefaultHome: explicitKind === 'prime-agent' - }) - if (result?.sourceAgentDir) { - env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - return env - } - - private registerHandlers(): void { - this.dispatcher.onRequest(AGENT_HOOK_INFER_INTERRUPT_METHOD, async (params) => ({ - applied: this.hookServer.inferInterrupt(params) - })) - this.dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ - replayed: this.hookServer.replayCachedPayloadsForPanes() - })) - registerManagedHookInstaller(this.dispatcher) - this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { - const startupPrompt = params.opencodeStartupPromptSource - const opencode = params.opencodePluginSource - const opencode2 = params.opencode2PluginSource - const pi = params.piExtensionSource - const omp = params.ompExtensionSource - const primeAgent = params.primeAgentExtensionSource - assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) - assertPluginSourceUnderByteCap('opencodePluginSource', opencode) - assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) - assertPluginSourceUnderByteCap('piExtensionSource', pi) - assertPluginSourceUnderByteCap('ompExtensionSource', omp) - assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) - this.pluginOverlay.setSources({ - opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, - opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, - opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, - piExtensionSource: typeof pi === 'string' ? pi : undefined, - ompExtensionSource: typeof omp === 'string' ? omp : undefined, - primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined - }) - // Why: a running OpenCode 2 service reloads a changed plugin file, so an Orca upgrade - // reaches it on connect instead of at the next pane spawn. Never creates an install. - for (const [agent, source] of [ - ['opencode', opencode], - ['opencode2', opencode2] - ] as const) { - if (typeof source === 'string' && source) { - installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) - } - } - const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( - process.env - ) - return { - installed: { - opencodeStartupPrompt: startupPromptInstalled, - opencode: this.pluginOverlay.hasOpenCodeSource(), - opencode2: this.pluginOverlay.hasOpenCode2Source(), - pi: this.pluginOverlay.hasPiSource('pi'), - omp: this.pluginOverlay.hasPiSource('omp'), - primeAgent: this.pluginOverlay.hasPiSource('prime-agent') - } - } - }) - } -} diff --git a/src/relay/relay-agent-process-lifetime.test.ts b/src/relay/relay-agent-process-lifetime.test.ts deleted file mode 100644 index a94d124a892..00000000000 --- a/src/relay/relay-agent-process-lifetime.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { spawnProcess } from '@orca/process-host' -import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime' - -describe.skipIf(process.platform === 'win32')('RelayAgentProcessLifetime', () => { - it('finishes on the child exit when a background process keeps its output pipes open', async () => { - const lifetime = new RelayAgentProcessLifetime(5_000, 50) - // The wrapper exits at once; `sleep` inherits stdout/stderr and holds them for 5s. - const child = spawnProcess({ program: '/bin/sh', args: ['-c', 'sleep 5 & exit 0'] }) - let closed = false - child.once('close', () => { - closed = true - }) - lifetime.track(child) - await expect(lifetime.dispose()).resolves.toBeUndefined() - expect(closed).toBe(true) - }) -}) diff --git a/src/relay/relay-agent-process-lifetime.ts b/src/relay/relay-agent-process-lifetime.ts deleted file mode 100644 index e03c888db88..00000000000 --- a/src/relay/relay-agent-process-lifetime.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { terminateRelaySubprocessTree } from './subprocess-tree-termination' - -type Child = Parameters[0] - -// Why 10s: the same physical-exit deadline the relay's watcher children get after a kill. -export const RELAY_AGENT_CLOSE_DEADLINE_MS = 10_000 -// Why: output still buffered when the child exits drains well inside this; anything later comes -// from a background process that inherited the pipes, which the exec does not own. -export const RELAY_AGENT_EXIT_PIPE_DRAIN_MS = 1_000 - -/** Request timeout/cancellation is not evidence that its host child has closed. */ -export class RelayAgentProcessLifetime { - private fenced = false - private readonly children = new Map>() - // Why: a retry re-checks close instead of re-killing a tree whose pid may already be reused. - private readonly signalled = new WeakSet() - private disposal: Promise | null = null - - constructor( - private readonly closeDeadlineMs = RELAY_AGENT_CLOSE_DEADLINE_MS, - private readonly exitPipeDrainMs = RELAY_AGENT_EXIT_PIPE_DRAIN_MS - ) {} - - assertAdmission(): void { - if (this.fenced) { - throw new Error('relay_agent_execution_shutdown_fenced') - } - } - - /** Never throws: a child that slipped past a fence is killed, but still tracked to its close. */ - track(child: Child): void { - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolveClosed!: () => void - const closed = new Promise((resolve) => { - resolveClosed = resolve - }) - this.children.set(child, closed) - // A late error after request timeout must not remove physical-close tracking or crash the host. - const onError = () => {} - child.on('error', onError) - // Exit is the physical-exit proof; a descendant holding the pipes must not keep 'close' away, - // so after a bounded drain the relay lets go of them, which emits 'close' for every listener. - let drain: ReturnType | undefined - child.once('exit', () => { - drain = setTimeout(() => { - for (const stream of [child.stdin, child.stdout, child.stderr]) { - stream?.destroy() - } - }, this.exitPipeDrainMs) - drain.unref?.() - }) - child.once('close', () => { - clearTimeout(drain) - child.off('error', onError) - this.children.delete(child) - resolveClosed() - }) - if (this.fenced) { - this.signalled.add(child) - terminateRelaySubprocessTree(child) - } - } - - reopen(): void { - this.fenced = false - } - - dispose(): Promise { - this.fenced = true - if (this.disposal) { - return this.disposal - } - const pending = [...this.children.entries()] - for (const [child] of pending) { - if (!this.signalled.has(child)) { - this.signalled.add(child) - terminateRelaySubprocessTree(child) - } - } - const disposal = this.waitForClose(pending.map(([, closed]) => closed)).finally(() => { - this.disposal = null - }) - this.disposal = disposal - return disposal - } - - private waitForClose(closes: Promise[]): Promise { - if (closes.length === 0) { - return Promise.resolve() - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('relay_agent_execution_shutdown_incomplete')) - }, this.closeDeadlineMs) - timer.unref?.() - void Promise.all(closes).then(() => { - clearTimeout(timer) - resolve() - }) - }) - } -} diff --git a/src/relay/relay-bundled-ripgrep.test.ts b/src/relay/relay-bundled-ripgrep.test.ts deleted file mode 100644 index 93c4e56490f..00000000000 --- a/src/relay/relay-bundled-ripgrep.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { EventEmitter } from 'node:events' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) -vi.mock('./relay-diagnostic-log', () => ({ relayLogLine: vi.fn() })) - -import { - configureRelayBundledRipgrep, - resolveRelayRipgrepCommand, - retryRipgrepOnPathAfterLaunchFailure -} from './relay-bundled-ripgrep' -import { listFilesWithRg } from './fs-handler-list-files' -import { searchWithRg } from './fs-handler-utils' - -type MockChild = ChildProcess & { stdout: EventEmitter; stderr: EventEmitter } - -function createProcess(pid: number | undefined): MockChild { - const child = Object.assign(new EventEmitter(), { - stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }), - stderr: new EventEmitter(), - kill: vi.fn(), - exitCode: null, - signalCode: null - }) - Object.defineProperty(child, 'pid', { configurable: true, value: pid }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test double exposes only the ChildProcess members these spawners use. - return child as unknown as MockChild -} - -function failToLaunch(child: MockChild, code: string): void { - setImmediate(() => child.emit('error', Object.assign(new Error(code), { code }))) -} - -function succeedWith(child: MockChild, stdout: string): void { - setImmediate(() => { - child.stdout.emit('data', stdout) - child.emit('close', 0, null) - }) -} - -describe('relay bundled ripgrep', () => { - let dir: string - let bundled: string - - beforeEach(() => { - spawnMock.mockReset() - dir = mkdtempSync(join(tmpdir(), 'relay-rg-')) - bundled = join(dir, 'rg') - writeFileSync(bundled, '') - configureRelayBundledRipgrep(bundled) - }) - - afterEach(() => { - configureRelayBundledRipgrep(undefined) - rmSync(dir, { recursive: true, force: true }) - }) - - it('prefers the bundled binary and uses PATH rg when none is configured or present', () => { - expect(resolveRelayRipgrepCommand()).toBe(bundled) - configureRelayBundledRipgrep(join(dir, 'not-uploaded-yet', 'rg')) - expect(resolveRelayRipgrepCommand()).toBe('rg') - configureRelayBundledRipgrep(undefined) - expect(resolveRelayRipgrepCommand()).toBe('rg') - }) - - it('does not blame the binary when the spawn cwd is what is missing', async () => { - await expect( - retryRipgrepOnPathAfterLaunchFailure(bundled, join(dir, 'missing-cwd')) - ).resolves.toBe(false) - expect(resolveRelayRipgrepCommand()).toBe(bundled) - await expect(retryRipgrepOnPathAfterLaunchFailure('rg', dir)).resolves.toBe(false) - }) - - it('backs off the bundled binary after a launch failure, then tries it again', async () => { - vi.useFakeTimers() - try { - await expect(retryRipgrepOnPathAfterLaunchFailure(bundled, dir)).resolves.toBe(true) - expect(resolveRelayRipgrepCommand()).toBe('rg') - vi.advanceTimersByTime(60_001) - expect(resolveRelayRipgrepCommand()).toBe(bundled) - } finally { - vi.useRealTimers() - } - }) - - it('does not back off on fd or process pressure', async () => { - const error = Object.assign(new Error('EMFILE'), { code: 'EMFILE' }) - await expect(retryRipgrepOnPathAfterLaunchFailure(bundled, dir, error)).resolves.toBe(false) - expect(resolveRelayRipgrepCommand()).toBe(bundled) - }) - - it('retries a file listing on PATH rg after the bundled binary fails to launch', async () => { - const commands: string[] = [] - spawnMock.mockImplementation((command: string) => { - commands.push(command) - if (command === bundled) { - const child = createProcess(undefined) - failToLaunch(child, 'EACCES') - return child - } - const child = createProcess(42) - succeedWith(child, 'src/index.ts\0') - return child - }) - - await expect(listFilesWithRg(dir, [], { maxResults: 10 })).resolves.toEqual(['src/index.ts']) - expect(commands[0]).toBe(bundled) - expect(commands.slice(1).every((command) => command === 'rg')).toBe(true) - expect(resolveRelayRipgrepCommand()).toBe('rg') - }) - - it('retries a text search on PATH rg with the relay command env and a hidden window', async () => { - const hit = JSON.stringify({ - type: 'match', - data: { - path: { text: join(dir, 'a.ts') }, - lines: { text: 'needle\n' }, - line_number: 1, - submatches: [{ start: 0, end: 6 }] - } - }) - spawnMock.mockImplementation((command: string) => { - if (command === bundled) { - const child = createProcess(undefined) - failToLaunch(child, 'ENOENT') - return child - } - const child = createProcess(42) - succeedWith(child, `${hit}\n`) - return child - }) - - const result = await searchWithRg(dir, 'needle', { maxResults: 10 }) - - expect(result.totalMatches).toBe(1) - expect(spawnMock.mock.calls.map(([command]) => command)).toEqual([bundled, 'rg']) - for (const [, , options] of spawnMock.mock.calls) { - expect(options.windowsHide).toBe(true) - expect(options.env.PATH ?? options.env.Path).toContain('.cargo') - } - expect(resolveRelayRipgrepCommand()).toBe('rg') - }) -}) diff --git a/src/relay/relay-bundled-ripgrep.ts b/src/relay/relay-bundled-ripgrep.ts deleted file mode 100644 index 54cb5e562ca..00000000000 --- a/src/relay/relay-bundled-ripgrep.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Which `rg` the relay spawns. The SSH deploy uploads Orca's own ripgrep to a version-keyed cache - * and passes its path via `--ripgrep-path`; PATH `rg` (and then the git/readdir fallbacks) stays - * the answer whenever that binary is absent or cannot launch on this host. - */ -import { existsSync, statSync } from 'node:fs' -import { win32 } from 'node:path' -import { - isRipgrepSpawnCwdUsable, - isTransientRipgrepSpawnError -} from '../shared/ripgrep-process-availability' -import { relayLogLine } from './relay-diagnostic-log' -import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' - -export const PATH_RIPGREP_COMMAND = 'rg' - -/** - * Absolute path to a PATH `rg` on Windows, or null when there is none. - * - * Why Windows only: CreateProcessW searches the spawn cwd -- the user's repo -- before PATH, so a - * bare `rg` there runs a planted `rg.exe` from a cloned repository. POSIX `execvp` never consults - * the cwd, so a bare name stays correct and free there. - */ -export function isDriveRootedWindowsPath(dir: string): boolean { - // Why not path.isAbsolute: on Windows it accepts `\tools` and `/tools`, which are rooted but - // carry no drive, so they resolve against whatever drive the process is on. The probe would - // then validate `C:\tools\rg.exe` while the spawn, running with the user's repo as cwd, - // executes `D:\tools\rg.exe` -- the same cwd-dependence this lookup exists to remove. - const { root } = win32.parse(dir) - return /^[A-Za-z]:[\\/]/.test(root) || root.startsWith('\\\\') -} - -// Why only rg.exe, though libuv honours %PATHEXT%: `.bat`/`.cmd` shims are not spawnable without -// `shell: true`, and every ripgrep installer (winget, choco, scoop, cargo) lays down rg.exe. -function resolveWindowsPathRipgrep(path: string): string | null { - for (const entry of path.split(';')) { - const dir = entry.replace(/^"|"$/g, '').trim() - if (!dir || !isDriveRootedWindowsPath(dir)) { - continue - } - const candidate = win32.join(dir, 'rg.exe') - try { - if (statSync(candidate).isFile()) { - return candidate - } - } catch { - /* next entry */ - } - } - return null -} - -let windowsPathRipgrep: { path: string; command: string | null; expiresAt: number } | undefined -const WINDOWS_PATH_MISS_RETRY_MS = 60_000 - -let bundledRipgrepPath: string | null = null -// Why a back-off, not forever: Windows AV often locks a just-installed rg.exe for its first spawns. -const BUNDLED_RIPGREP_RETRY_MS = 60_000 -let bundledRipgrepUnusableUntil = 0 - -export function configureRelayBundledRipgrep(path: string | undefined): void { - bundledRipgrepPath = path ? path : null - bundledRipgrepUnusableUntil = 0 -} - -/** - * The host's PATH ripgrep, safe to spawn directly, or null when it has none. On Windows that is an - * absolute path, because a bare name would resolve against the spawn cwd first. - */ -export function pathRipgrepCommand(): string | null { - if (process.platform !== 'win32') { - return PATH_RIPGREP_COMMAND - } - const env = buildRelayCommandEnv() - const path = env.PATH ?? env.Path ?? '' - const now = Date.now() - // Cache the effective PATH; retry misses so an install can become visible without restarting. - if ( - !windowsPathRipgrep || - windowsPathRipgrep.path !== path || - now >= windowsPathRipgrep.expiresAt - ) { - const command = resolveWindowsPathRipgrep(path) - windowsPathRipgrep = { - path, - command, - expiresAt: command === null ? now + WINDOWS_PATH_MISS_RETRY_MS : Infinity - } - } - return windowsPathRipgrep.command -} - -/** Null means this host has no usable rg, so the caller falls back to git/readdir. */ -export function resolveRelayRipgrepCommand(): string | null { - // Why check existence per spawn: the deploy uploads rg after the relay starts, so it can appear mid-session. - if ( - bundledRipgrepPath && - Date.now() >= bundledRipgrepUnusableUntil && - existsSync(bundledRipgrepPath) - ) { - return bundledRipgrepPath - } - return pathRipgrepCommand() -} - -export function resetRelayRipgrepPathCacheForTests(): void { - windowsPathRipgrep = undefined -} - -/** - * Called when `command` failed to launch. Resolves true when it was the bundled binary and the - * caller should retry once on PATH `rg`; spawns skip the bundled binary for a back-off window. - */ -export async function retryRipgrepOnPathAfterLaunchFailure( - command: string, - cwd: string, - error?: unknown -): Promise { - if (command === PATH_RIPGREP_COMMAND || command !== bundledRipgrepPath) { - return false - } - // Why: fd/process pressure says nothing about the binary, so it must not disable it for the relay's life. - if (isTransientRipgrepSpawnError(error)) { - return false - } - // Why: spawn reports a missing cwd as ENOENT too; that says nothing about the binary. - if (!(await isRipgrepSpawnCwdUsable(cwd))) { - return false - } - // Why only while the file exists: a removed binary is re-checked per spawn anyway. - if (existsSync(command) && Date.now() >= bundledRipgrepUnusableUntil) { - bundledRipgrepUnusableUntil = Date.now() + BUNDLED_RIPGREP_RETRY_MS - relayLogLine(`[relay] Bundled ripgrep at ${command} failed to launch; using rg from PATH`) - } - return true -} diff --git a/src/relay/relay-client-resync-marker.ts b/src/relay/relay-client-resync-marker.ts deleted file mode 100644 index 5fe6361c09c..00000000000 --- a/src/relay/relay-client-resync-marker.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -type RetainedMarker = { params: Record; estimatedBytes: number } - -type MarkerState = { - method: string - // Why: one outstanding marker per (client, key) keeps sustained backpressure bounded. - inFlight: Set - // Rejected markers, retained per client so they can be republished when the control lane frees up. - pending: Map> - capacityUnsubscribes: Map void> -} - -/** - * Publishes a small "your view is stale, re-read it" notification on the control lane after a - * producer-lane payload was refused. Shared by every producer whose oversized frame would otherwise - * desync a client silently; the marker is coalesced per key and retried on capacity, never dropped. - */ -export type RelayClientResyncMarkerPublisher = { - emit(clientId: number, markerKey: string, params: Record): void - forgetClient(clientId: number): void -} - -export function createRelayClientResyncMarkerPublisher( - dispatcher: RelayDispatcher, - method: string -): RelayClientResyncMarkerPublisher { - const state: MarkerState = { - method, - inFlight: new Set(), - pending: new Map(), - capacityUnsubscribes: new Map() - } - // In-flight keys need no sweep here: closing a client settles every queued and written frame first. - dispatcher.onClientDetached((clientId) => { - // Not every detach retires the id: invalidateClient() detaches the primary without removing it and - // setWrite() revives it, so dropping the markers here would desync the state the reconnect restores. - if (dispatcher.isClientAttached(clientId)) { - return - } - forgetClientMarkers(state, clientId) - }) - return { - emit(clientId, markerKey, params) { - const retained = state.pending.get(clientId)?.get(markerKey) - if (retained) { - // Latest generation wins: a retained marker that has not been sent yet must not replay a - // projection the producer has already moved past. - retained.params = params - retained.estimatedBytes = dispatcher.notificationFrameBytes(method, params) - return - } - // Per key, never per client alone: an outstanding marker for one subject must not suppress another's resync. - if (state.inFlight.has(markerId(clientId, markerKey))) { - return - } - publishMarker(dispatcher, state, clientId, markerKey, params) - }, - forgetClient(clientId) { - forgetClientMarkers(state, clientId) - } - } -} - -function markerId(clientId: number, markerKey: string): string { - return `${clientId} ${markerKey}` -} - -function forgetClientMarkers(state: MarkerState, clientId: number): void { - // Unsubscribe first so no re-entrant flush can observe a half-cleared client. - state.capacityUnsubscribes.get(clientId)?.() - state.capacityUnsubscribes.delete(clientId) - state.pending.delete(clientId) -} - -// Why: the control lane — on the producer lane the marker would hit the same full queue that just -// rejected the payload and be dropped, silently desyncing the client. -function publishMarker( - dispatcher: RelayDispatcher, - state: MarkerState, - clientId: number, - markerKey: string, - params: Record, - estimatedBytes?: number -): void { - const key = markerId(clientId, markerKey) - const frameBytes = estimatedBytes ?? dispatcher.notificationFrameBytes(state.method, params) - state.inFlight.add(key) - let settled = false - const accepted = dispatcher.tryNotifyClient( - clientId, - state.method, - params, - (result) => { - // Settles on write, drop, or client close, so the slot can never leak. - settled = true - state.inFlight.delete(key) - if (result.ok) { - return - } - // A frame the sink never wrote leaves the client just as desynced as a rejected one — setWrite - // fails every queued and in-flight frame this way. Retain unconditionally: a real detach clears - // it through onClientDetached, which fires after this settlement. - retainMarker(dispatcher, state, clientId, markerKey, params, frameBytes) - }, - { controlOverflow: 'reject' } - ) - if (accepted || settled) { - return - } - // Admission rejection has no settlement callback: retain the marker instead of desyncing the client. - state.inFlight.delete(key) - retainMarker(dispatcher, state, clientId, markerKey, params, frameBytes) -} - -function retainMarker( - dispatcher: RelayDispatcher, - state: MarkerState, - clientId: number, - markerKey: string, - params: Record, - estimatedBytes: number -): void { - if (!state.capacityUnsubscribes.has(clientId)) { - const unsubscribe = dispatcher.onClientCapacity(clientId, () => - flushPendingMarkers(dispatcher, state, clientId) - ) - if (!unsubscribe) { - // The client went away between admission and arming, so there is nothing left to resync. - return - } - state.capacityUnsubscribes.set(clientId, unsubscribe) - } - const retained = state.pending.get(clientId) - if (retained) { - retained.set(markerKey, { params, estimatedBytes }) - return - } - state.pending.set(clientId, new Map([[markerKey, { params, estimatedBytes }]])) -} - -function flushPendingMarkers( - dispatcher: RelayDispatcher, - state: MarkerState, - clientId: number -): void { - const retained = state.pending.get(clientId) - if (!retained) { - return - } - for (const [markerKey, marker] of Array.from(retained)) { - // Capacity fires on every lane; retry only frames the control queue can admit now. - if (!dispatcher.canAdmitControlFrame(clientId, marker.estimatedBytes)) { - continue - } - // Drop before republishing so a synchronous settlement cannot see the marker as still pending — - // and skip keys a re-entrant flush already took, which would otherwise send the marker twice. - if (!retained.delete(markerKey)) { - continue - } - publishMarker(dispatcher, state, clientId, markerKey, marker.params, marker.estimatedBytes) - } - // Identity check: a re-entrant flush may have retired this set and armed a fresh one to keep. - if (retained.size > 0 || state.pending.get(clientId) !== retained) { - return - } - forgetClientMarkers(state, clientId) -} diff --git a/src/relay/relay-connect-channel.ts b/src/relay/relay-connect-channel.ts deleted file mode 100644 index 6536597339f..00000000000 --- a/src/relay/relay-connect-channel.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createConnection } from 'node:net' -import { DispatcherClientWriter } from '../wsl-guest/dispatcher-client-writer' -import { RELAY_SENTINEL } from '../wsl-guest/protocol' -import { readLaunchVersion, runConnectHandshake } from './relay-handshake' - -const CONNECT_TIMEOUT_MS = 5_000 - -// Why: bridges a new SSH channel to the relay process that owns the live PTYs. -export function runRelayConnectChannel(sockPath: string, endpointCredential?: string): void { - const myVersion = readLaunchVersion() - const sock = createConnection({ path: sockPath }) - const stdoutWriter = new DispatcherClientWriter( - (data, onSettled) => - process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }), - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (callback) => { - process.stdout.once('drain', callback) - return () => process.stdout.off('drain', callback) - } - }, - () => { - sock.destroy() - process.exit(1) - } - ) - - const connectTimeout = setTimeout(() => { - process.stderr.write(`[relay-connect] Connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) - sock.destroy() - process.exit(1) - }, CONNECT_TIMEOUT_MS) - - sock.on('connect', () => { - clearTimeout(connectTimeout) - runConnectHandshake( - sock, - myVersion, - { - onAccepted: (leftover: Buffer) => { - stdoutWriter.enqueue('control', () => Buffer.from(RELAY_SENTINEL), RELAY_SENTINEL.length) - if (leftover.length > 0) { - stdoutWriter.enqueue('control', () => leftover, leftover.length) - } - process.stdin.pipe(sock) - sock.on('data', (data: Buffer) => { - sock.pause() - let offset = 0 - const writeNext = (): void => { - if (offset >= data.length) { - sock.resume() - return - } - const bytes = Math.min(stdoutWriter.producerFrameCapacity, data.length - offset) - if (bytes <= 0) { - stdoutWriter.close(new Error('Relay stdout has no producer capacity')) - return - } - const chunk = data.subarray(offset, offset + bytes) - if ( - !stdoutWriter.enqueue( - 'ordinary', - () => chunk, - chunk.length, - (result) => { - if (!result.ok) { - return - } - offset += bytes - writeNext() - } - ) - ) { - stdoutWriter.close(new Error('Relay stdout bridge capacity exceeded')) - } - } - writeNext() - }) - } - }, - endpointCredential - ) - }) - - // Why: Node swallows EPIPE on stdout, so close the bridge and enter relay grace promptly. - process.stdout.on('error', () => { - stdoutWriter.close(new Error('Relay stdout closed')) - }) - - sock.on('error', (error) => { - clearTimeout(connectTimeout) - process.stderr.write(`[relay-connect] Socket error: ${error.message}\n`) - process.exit(1) - }) - - sock.on('close', async () => { - await stdoutWriter.waitForIdle() - process.exit(0) - }) -} diff --git a/src/relay/relay-daemon-fatal-reap.test.ts b/src/relay/relay-daemon-fatal-reap.test.ts deleted file mode 100644 index 25b67ba962c..00000000000 --- a/src/relay/relay-daemon-fatal-reap.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import './mock-descendant-sweep' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { PtyHandler } from './pty-handler' - -const daemonMocks = vi.hoisted(() => ({ - dispatcher: null as unknown, - runtimePtyHandler: null as unknown, - mockCreateShellPromptReadinessProbe: vi.fn(), - mockPtySpawn: vi.fn(), - socketCleanup: vi.fn(), - relayLogLine: vi.fn(), - forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) -})) - -vi.mock('node-pty', () => ({ spawn: daemonMocks.mockPtySpawn })) - -vi.mock('../main/shell-prompt-readiness-probe', () => ({ - createShellPromptReadinessProbe: daemonMocks.mockCreateShellPromptReadinessProbe -})) - -vi.mock('../main/pty/posix-pty-process-groups', () => ({ - forceKillPosixPtyProcessGroups: daemonMocks.forceKillPosixPtyProcessGroups -})) - -vi.mock('./relay-diagnostic-log', () => ({ relayLogLine: daemonMocks.relayLogLine })) -vi.mock('./relay-handshake', () => ({ readLaunchVersion: vi.fn(() => 'test-version') })) - -vi.mock('./relay-primary-channel', () => ({ - RelayPrimaryChannel: class { - readonly dispatcher = daemonMocks.dispatcher - readonly isAlive = true - detachInput(): void {} - startOutputFailureHandling(): void {} - startInput(): void {} - writeSentinel(): void {} - detachPrimaryClient(): void {} - } -})) - -vi.mock('./relay-runtime-services', async () => { - const { PtyHandler: ActualPtyHandler } = (await vi.importActual('./pty-handler')) as { - PtyHandler: new (dispatcher: never) => PtyHandler - } - return { - RelayRuntimeServices: class { - readonly ptyHandler = new ActualPtyHandler(daemonMocks.dispatcher as never) - readonly ptyConsumerSessionAdapter = { getDebugSnapshot: vi.fn() } - readonly ptySourcePublication = { getDebugSnapshot: vi.fn() } - constructor() { - daemonMocks.runtimePtyHandler = this.ptyHandler - } - async disposeOwnedProcesses(): Promise {} - async disposeExitOnlyServices(): Promise {} - disposeHandlers(): void {} - } - } -}) - -vi.mock('./relay-agent-hook-runtime', () => ({ - RelayAgentHookRuntime: class { - async start(): Promise {} - publishEndpointFile(): void {} - stop(): void {} - } -})) - -vi.mock('./relay-socket-ownership', () => ({ - RelaySocketOwnership: class { - readonly owned = false - readonly server = null - cleanup(): void { - daemonMocks.socketCleanup() - } - closeAndCleanup(): void {} - } -})) - -vi.mock('./relay-reconnect-listener', () => ({ - RelayReconnectListener: class { - readonly clientCount = 0 - readonly hasAcceptedClient = false - readonly acceptedConnections = 0 - async start(): Promise {} - setEndpointCredential(): void {} - } -})) - -vi.mock('./relay-grace-lifecycle', () => ({ - RelayGraceLifecycle: class { - readonly deadlineAt = null - readonly reason = null - cancel(): void {} - start(): void {} - installProcessLifecycle(): void {} - } -})) - -import { createMockDispatcher } from './pty-handler-test-harness' -import { runRelayDaemon } from './relay-daemon' -import { __setConptyJobNativeForTests } from '../main/windows/windows-pty-job' - -describe('relay daemon fatal PTY reap', () => { - let originalPlatform: PropertyDescriptor | undefined - let originalUncaughtListeners: Set - let originalRejectionListeners: Set - - beforeEach(() => { - originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - originalUncaughtListeners = new Set(process.listeners('uncaughtException')) - originalRejectionListeners = new Set(process.listeners('unhandledRejection')) - daemonMocks.dispatcher = createMockDispatcher() - daemonMocks.runtimePtyHandler = null - daemonMocks.mockPtySpawn.mockReset() - daemonMocks.mockCreateShellPromptReadinessProbe.mockReset() - daemonMocks.mockCreateShellPromptReadinessProbe.mockReturnValue({ - notifyOutput: vi.fn(), - dispose: vi.fn() - }) - daemonMocks.socketCleanup.mockReset() - daemonMocks.relayLogLine.mockReset() - daemonMocks.forceKillPosixPtyProcessGroups.mockClear() - }) - - function usePlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - } - - afterEach(async () => { - for (const listener of process.listeners('uncaughtException')) { - if (!originalUncaughtListeners.has(listener)) { - process.removeListener('uncaughtException', listener) - } - } - for (const listener of process.listeners('unhandledRejection')) { - if (!originalRejectionListeners.has(listener)) { - process.removeListener('unhandledRejection', listener) - } - } - const handler = daemonMocks.runtimePtyHandler as PtyHandler | null - await handler?.dispose({ waitForPhysicalExit: false }).catch(() => {}) - __setConptyJobNativeForTests() - vi.restoreAllMocks() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - }) - - it('synchronously reaps every Windows PTY job before fatal exit', async () => { - usePlatform('win32') - const firstKill = vi.fn(() => { - throw new Error('ConPTY close failed') - }) - const secondKill = vi.fn() - daemonMocks.mockPtySpawn - .mockReturnValueOnce(createMockPty(11, 101, firstKill)) - .mockReturnValueOnce(createMockPty(22, 202, secondKill)) - const terminateJob = vi.fn((id: number) => id === 22) - __setConptyJobNativeForTests(() => ({ - terminateJob, - listJobProcessIds: vi.fn(), - assignCurrentProcessToJob: vi.fn() - })) - const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) - - await runRelayDaemon({ - graceTimeMs: 0, - connectMode: false, - detached: false, - cliMode: false, - sockPath: 'relay-test-socket' - }) - const dispatcher = daemonMocks.dispatcher as ReturnType - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.spawn', {}) - const fatalListener = process - .listeners('uncaughtException') - .find((listener) => !originalUncaughtListeners.has(listener)) - - expect(fatalListener).toBeDefined() - fatalListener!(new Error('relay crashed'), 'uncaughtException') - - expect(terminateJob.mock.calls).toEqual([ - [11, 101], - [22, 202] - ]) - expect(firstKill).toHaveBeenCalledOnce() - expect(firstKill.mock.calls[0]).toEqual([]) - expect(secondKill).not.toHaveBeenCalled() - expect(daemonMocks.socketCleanup).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(1) - }) - - async function bootDaemonWithTwoPtys( - firstKill: ReturnType, - secondKill: ReturnType - ) { - daemonMocks.mockPtySpawn - .mockReturnValueOnce(createMockPty(11, 101, firstKill)) - .mockReturnValueOnce(createMockPty(22, 202, secondKill)) - const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) - await runRelayDaemon({ - graceTimeMs: 0, - connectMode: false, - detached: false, - cliMode: false, - sockPath: 'relay-test-socket' - }) - const dispatcher = daemonMocks.dispatcher as ReturnType - await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.spawn', {}) - const fatalListener = process - .listeners('uncaughtException') - .find((listener) => !originalUncaughtListeners.has(listener)) - expect(fatalListener).toBeDefined() - return { exit, crash: () => fatalListener!(new Error('relay crashed'), 'uncaughtException') } - } - - it('reaps every POSIX PTY process group before fatal exit', async () => { - usePlatform('linux') - const firstKill = vi.fn() - const secondKill = vi.fn() - - const { exit, crash } = await bootDaemonWithTwoPtys(firstKill, secondKill) - crash() - - // Why the group, not the pid: a shell setpgid's its children away from the root. - expect(daemonMocks.forceKillPosixPtyProcessGroups.mock.calls.map(([pid]) => pid)).toEqual([ - 101, 202 - ]) - expect(firstKill.mock.calls).toEqual([['SIGKILL']]) - expect(secondKill.mock.calls).toEqual([['SIGKILL']]) - expect(daemonMocks.socketCleanup).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(1) - }) - - it('reaps past a failing PTY and records the failure instead of exiting silently', async () => { - usePlatform('linux') - const firstKill = vi.fn(() => { - throw new Error('SIGKILL refused') - }) - const secondKill = vi.fn() - - const { exit, crash } = await bootDaemonWithTwoPtys(firstKill, secondKill) - crash() - - expect(secondKill.mock.calls).toEqual([['SIGKILL']]) - expect(daemonMocks.relayLogLine.mock.calls.map(([line]) => String(line)).join('\n')).toContain( - '[relay] Fatal PTY reap failed: SIGKILL refused' - ) - expect(daemonMocks.socketCleanup).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(1) - }) -}) - -function createMockPty(id: number, pid: number, kill: ReturnType) { - return { - _pty: id, - pid, - process: 'cmd.exe', - onData: vi.fn(), - onExit: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - kill, - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - } -} diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts deleted file mode 100644 index 31eb57b9f98..00000000000 --- a/src/relay/relay-daemon.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { installRelayLogRotation } from './rotating-log-writer' -import { readLaunchVersion } from './relay-handshake' -import type { RelayLaunchOptions } from './relay-launch-options' -import { RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, RELAY_IDLE_GRACE_MS } from './relay-launch-options' -import { relayLogLine } from './relay-diagnostic-log' -import { RelayPrimaryChannel } from './relay-primary-channel' -import { RelayRuntimeServices } from './relay-runtime-services' -import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' -import { RelaySocketOwnership } from './relay-socket-ownership' -import { RelayReconnectListener } from './relay-reconnect-listener' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' -import { - publishRelayEndpointCredential, - restrictWindowsRelayEndpointCredential -} from './relay-endpoint-credential-publication' -import { SKILL_RELAY_CAPABILITIES } from './skill-install-handler' -import { publishRelayPid } from './relay-pid-publication' -import { errorMessage } from '../shared/error-message' - -export async function runRelayDaemon(options: RelayLaunchOptions): Promise { - if (options.detached && options.logFile) { - installRelayLogRotation(options.logFile) - } - - const socketOwnership = new RelaySocketOwnership(options.sockPath) - let fatalPtyHandler: RelayRuntimeServices['ptyHandler'] | null = null - process.on('uncaughtException', (error) => { - relayLogLine(`[relay] Uncaught exception: ${error.message}\n${error.stack}`) - try { - fatalPtyHandler?.forceKillAllPtyProcesses() - } catch (reapError) { - // Why log rather than swallow: exit must still win, but this line is the only - // forensic trace a crashed remote daemon leaves behind for an orphaned shell. - relayLogLine(`[relay] Fatal PTY reap failed: ${errorMessage(reapError)}`) - } - socketOwnership.cleanup() - process.exit(1) - }) - process.on('unhandledRejection', (reason) => { - relayLogLine(`[relay] Unhandled rejection: ${String(reason)}`) - }) - - const primaryChannel = new RelayPrimaryChannel() - const launchVersion = readLaunchVersion() - const runtime = new RelayRuntimeServices( - primaryChannel.dispatcher, - options.graceTimeMs, - launchVersion - ) - fatalPtyHandler = runtime.ptyHandler - let reconnectListener: RelayReconnectListener | null = null - const agentHooks = new RelayAgentHookRuntime( - primaryChannel.dispatcher, - runtime.ptyHandler, - options.sockPath, - options.endpointDir - ) - const lifecycle = new RelayGraceLifecycle({ - dispatcher: primaryChannel.dispatcher, - ptyHandler: runtime.ptyHandler, - detached: options.detached, - emptyDetachedStartupGraceMs: RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, - idleRelayGraceMs: RELAY_IDLE_GRACE_MS, - readSocketClientCount: () => reconnectListener?.clientCount ?? 0, - hasAcceptedSocketClient: () => reconnectListener?.hasAcceptedClient ?? false, - ownsSocketPath: () => socketOwnership.owned, - disposeOwnedProcesses: () => runtime.disposeOwnedProcesses(), - reopenOwnedProcesses: () => runtime.reopenOwnedProcesses(), - disposeExitOnlyServices: () => runtime.disposeExitOnlyServices(), - disposeRuntime: () => { - primaryChannel.dispatcher.dispose() - runtime.disposeHandlers() - agentHooks.stop() - socketOwnership.closeAndCleanup() - } - }) - - await agentHooks.start() - reconnectListener = new RelayReconnectListener( - primaryChannel.dispatcher, - socketOwnership, - launchVersion, - options.credentialFile, - { - detachPrimaryInput: () => primaryChannel.detachInput(), - cancelGrace: (reason) => lifecycle.cancel(reason), - onLastClientClosed: () => { - if (!primaryChannel.isAlive) { - lifecycle.start('socket client closed') - } - } - } - ) - const startedAt = Date.now() - registerRelayStatus( - primaryChannel, - runtime, - reconnectListener, - socketOwnership, - lifecycle, - options, - startedAt - ) - - try { - // Why this order: the bind is the only proof of endpoint ownership. A start that loses it - // exits inside start() and never reaches the credential file, so racing starters cannot - // rotate the secret a surviving daemon enforces. - await reconnectListener.start() - publishRelayPid() - reconnectListener.setEndpointCredential(publishRelayEndpointCredential(options.credentialFile)) - agentHooks.publishEndpointFile() - } catch (error) { - relayLogLine(`[relay] Startup failed: ${errorMessage(error)}`) - process.exit(1) - return - } - if (options.credentialFile) { - void restrictWindowsRelayEndpointCredential(options.credentialFile) - } - - primaryChannel.startOutputFailureHandling() - if (options.detached) { - lifecycle.start('detached startup') - } else { - primaryChannel.startInput({ - onData: () => lifecycle.cancel('stdin data'), - onDisconnect: (reason) => { - if ((reconnectListener?.clientCount ?? 0) === 0) { - lifecycle.start(reason) - } - } - }) - } - lifecycle.installProcessLifecycle() - primaryChannel.writeSentinel() - if (options.detached) { - primaryChannel.detachPrimaryClient() - } -} - -function registerRelayStatus( - primaryChannel: RelayPrimaryChannel, - runtime: RelayRuntimeServices, - reconnectListener: RelayReconnectListener, - socketOwnership: RelaySocketOwnership, - lifecycle: RelayGraceLifecycle, - options: RelayLaunchOptions, - startedAt: number -): void { - primaryChannel.dispatcher.onRequest('relay.status', async () => ({ - capabilities: SKILL_RELAY_CAPABILITIES, - pid: process.pid, - uptimeMs: Date.now() - startedAt, - detached: options.detached, - stdoutAlive: primaryChannel.isAlive, - memory: process.memoryUsage(), - ptys: { active: runtime.ptyHandler.activePtyCount }, - ptySourceCredit: { - enabled: true, - session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), - publication: runtime.ptySourcePublication.getDebugSnapshot() - }, - socket: { - path: options.sockPath, - owned: socketOwnership.owned, - listening: socketOwnership.server?.listening ?? false, - clients: reconnectListener.clientCount, - acceptedConnections: reconnectListener.acceptedConnections - }, - grace: { - active: runtime.ptyHandler.graceTimerActive, - deadlineAt: lifecycle.deadlineAt, - reason: lifecycle.reason - } - })) -} diff --git a/src/relay/relay-diagnostic-log.test.ts b/src/relay/relay-diagnostic-log.test.ts deleted file mode 100644 index 38adbcb7669..00000000000 --- a/src/relay/relay-diagnostic-log.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { relayLogLine } from './relay-diagnostic-log' - -describe('relayLogLine', () => { - it('prefixes each log line with an ISO timestamp', () => { - const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - try { - relayLogLine('[relay] Grace started (stdin ended)') - - expect(writeSpy).toHaveBeenCalledTimes(1) - const line = writeSpy.mock.calls[0][0] as string - // Why: relay.log correlation depends on a grep-stable - // " " shape (#7773). - expect(line).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[relay\] Grace started \(stdin ended\)\n$/ - ) - } finally { - writeSpy.mockRestore() - } - }) -}) diff --git a/src/relay/relay-diagnostic-log.ts b/src/relay/relay-diagnostic-log.ts deleted file mode 100644 index a42106101d9..00000000000 --- a/src/relay/relay-diagnostic-log.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Why: the relay daemon's stderr is redirected to relay.log on the remote -// host. Without timestamps, reconnect flaps in that log cannot be correlated -// with user activity or sleep/wake windows during diagnosis (#7773). Keep the -// format grep-stable: ISO timestamp, single space, then the original line. -export function relayLogLine(message: string): void { - process.stderr.write(`${new Date().toISOString()} ${message}\n`) -} diff --git a/src/relay/relay-endpoint-credential-publication.test.ts b/src/relay/relay-endpoint-credential-publication.test.ts deleted file mode 100644 index 9c725d4aa3c..00000000000 --- a/src/relay/relay-endpoint-credential-publication.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' -import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { build } from 'esbuild' -import { JSONC_PARSER_ESM_ALIAS } from '../../config/build-plugins/jsonc-parser-esm' -import { spawnRelay, type RelayProcess } from './subprocess-test-utils' -import { - readAdoptableRelayEndpointCredential, - writeRelayEndpointCredentialFile -} from './relay-endpoint-credential-publication' -import { EXIT_CODE_CREDENTIAL_MISMATCH } from './relay-handshake' - -const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') -let bundleDir: string -let relayEntry: string - -beforeAll(async () => { - bundleDir = mkdtempSync(path.join(tmpdir(), 'relay-cred-bundle-')) - relayEntry = path.join(bundleDir, 'relay.js') - await build({ - entryPoints: [RELAY_TS_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: relayEntry, - external: ['node-pty', '@parcel/watcher', 'electron'], - alias: JSONC_PARSER_ESM_ALIAS, - sourcemap: false - }) -}, 30_000) - -afterAll(async () => { - await rm(bundleDir, { recursive: true, force: true }).catch(() => {}) -}) - -const CREDENTIAL_PATTERN = /^[A-Za-z0-9_-]{32,256}$/ - -function captureStderr(proc: RelayProcess): () => string { - let text = '' - proc.proc.stderr!.on('data', (chunk: Buffer) => { - text += chunk.toString('utf8') - }) - return () => text -} - -describe.skipIf(process.platform === 'win32')('relay endpoint credential publication', () => { - let tmpDir: string - let sockPath: string - let credentialFile: string - const live: RelayProcess[] = [] - - function startDaemon(): RelayProcess { - const daemon = spawnRelay(relayEntry, [ - '--detached', - '--grace-time', - '10', - '--sock-path', - sockPath, - '--endpoint-dir', - path.join(tmpDir, 'agent-hooks'), - '--credential-file', - credentialFile - ]) - live.push(daemon) - return daemon - } - - function connect(file = credentialFile): RelayProcess { - const bridge = spawnRelay(relayEntry, [ - '--connect', - '--sock-path', - sockPath, - '--credential-file', - file - ]) - live.push(bridge) - return bridge - } - - afterEach(async () => { - for (const proc of live.splice(0)) { - if (proc.proc.exitCode === null) { - proc.proc.kill('SIGKILL') - await proc.waitForExit().catch(() => {}) - } - } - await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) - }) - - function freshDir(prefix: string): void { - tmpDir = mkdtempSync(path.join(tmpdir(), prefix)) - sockPath = path.join(tmpDir, 'relay.sock') - credentialFile = `${sockPath}.credential` - } - - it('mints an owner-only credential only after it owns the socket', async () => { - freshDir('relay-cred-mint-') - const daemon = startDaemon() - expect(existsSync(credentialFile)).toBe(false) - await daemon.sentinelReceived - expect(readFileSync(credentialFile, 'utf8')).toMatch(CREDENTIAL_PATTERN) - expect(statSync(credentialFile).mode & 0o777).toBe(0o600) - expect(existsSync(sockPath)).toBe(true) - - const bridge = connect() - await bridge.sentinelReceived - const resp = await bridge.waitForResponse(bridge.send('relay.status')) - expect(resp.error).toBeUndefined() - }, 15_000) - - it('adopts a credential an older client pre-wrote instead of rotating it', async () => { - freshDir('relay-cred-adopt-') - const preWritten = 'b'.repeat(40) - writeFileSync(credentialFile, `${preWritten}\n`, { mode: 0o600 }) - const daemon = startDaemon() - await daemon.sentinelReceived - expect(readFileSync(credentialFile, 'utf8').trim()).toBe(preWritten) - - const bridge = connect() - await bridge.sentinelReceived - const resp = await bridge.waitForResponse(bridge.send('relay.status')) - expect(resp.error).toBeUndefined() - }, 15_000) - - it('replaces a pre-written credential that is not owner-only instead of adopting it', async () => { - freshDir('relay-cred-reject-') - const foreign = 'f'.repeat(40) - writeFileSync(credentialFile, `${foreign}\n`, { mode: 0o644 }) - const daemon = startDaemon() - await daemon.sentinelReceived - const published = readFileSync(credentialFile, 'utf8').trim() - expect(published).not.toBe(foreign) - expect(published).toMatch(CREDENTIAL_PATTERN) - expect(statSync(credentialFile).mode & 0o777).toBe(0o600) - - const bridge = connect() - await bridge.sentinelReceived - const resp = await bridge.waitForResponse(bridge.send('relay.status')) - expect(resp.error).toBeUndefined() - }, 15_000) - - it('refuses a credential that is not the one it published, with a typed bridge exit', async () => { - freshDir('relay-cred-refuse-') - const daemon = startDaemon() - const daemonStderr = captureStderr(daemon) - await daemon.sentinelReceived - const original = readFileSync(credentialFile, 'utf8').trim() - - const staleFile = path.join(tmpDir, 'stale.credential') - writeFileSync(staleFile, 'd'.repeat(48), { mode: 0o600 }) - const bridge = connect(staleFile) - const bridgeStderr = captureStderr(bridge) - const code = await bridge.waitForExit(8000) - expect(code).toBe(EXIT_CODE_CREDENTIAL_MISMATCH) - expect(bridgeStderr()).toContain('Endpoint credential refused by daemon') - expect(daemonStderr()).toContain('Endpoint credential mismatch') - expect(readFileSync(credentialFile, 'utf8').trim()).toBe(original) - - // The daemon still serves the credential it owns. - const good = connect() - await good.sentinelReceived - const resp = await good.waitForResponse(good.send('relay.status')) - expect(resp.error).toBeUndefined() - - // Fixed for the process lifetime: rewriting the file does not move the daemon's credential. - writeRelayEndpointCredentialFile(credentialFile, 'e'.repeat(48)) - const rotated = connect() - expect(await rotated.waitForExit(8000)).toBe(EXIT_CODE_CREDENTIAL_MISMATCH) - writeRelayEndpointCredentialFile(credentialFile, original) - const restored = connect() - await restored.sentinelReceived - }, 20_000) -}) - -describe.skipIf(process.platform === 'win32')('readAdoptableRelayEndpointCredential', () => { - let dir: string - afterEach(async () => { - await rm(dir, { recursive: true, force: true }).catch(() => {}) - }) - - it('adopts only a well-formed, owner-only file owned by this uid', () => { - dir = mkdtempSync(path.join(tmpdir(), 'relay-cred-read-')) - const file = path.join(dir, 'relay.sock.credential') - const value = 'h'.repeat(32) - writeFileSync(file, `${value}\n`, { mode: 0o600 }) - expect(readAdoptableRelayEndpointCredential(file)).toBe(value) - chmodSync(file, 0o644) - expect(readAdoptableRelayEndpointCredential(file)).toBeUndefined() - chmodSync(file, 0o600) - writeFileSync(file, 'short', { mode: 0o600 }) - expect(readAdoptableRelayEndpointCredential(file)).toBeUndefined() - expect(readAdoptableRelayEndpointCredential(path.join(dir, 'missing'))).toBeUndefined() - }) -}) diff --git a/src/relay/relay-endpoint-credential-publication.ts b/src/relay/relay-endpoint-credential-publication.ts deleted file mode 100644 index 914c9f4e998..00000000000 --- a/src/relay/relay-endpoint-credential-publication.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * The relay daemon owns its endpoint credential. - * - * The client used to mint the credential before launching a daemon. A launch that then lost the - * socket bind to a still-running relay had already rotated the file, so every later --connect - * authenticated against a secret the surviving daemon never held, and that daemon sat forever - * refusing clients while holding its PTYs. Only a process whose listen() succeeded can prove it - * owns the endpoint, and that proof is atomic with the bind — so publication happens here, after - * the bind, in the daemon. A losing starter exits before reaching this file and touches nothing. - */ -import { randomBytes } from 'node:crypto' -import { chmodSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' -import { runProcess } from '@orca/process-host' -import { relayLogLine } from './relay-diagnostic-log' - -const ENDPOINT_CREDENTIAL_PATTERN = /^[A-Za-z0-9_-]{32,256}$/ - -export function isValidRelayEndpointCredential(value: string): boolean { - return ENDPOINT_CREDENTIAL_PATTERN.test(value) -} - -export function mintRelayEndpointCredential(): string { - return randomBytes(32).toString('base64url') -} - -/** - * A file this daemon may trust as its credential: owner-only mode and owned by this uid on - * POSIX. Anyone who can produce such a file inside our relay dir already runs as us. Windows - * relies on the profile dir's inherited ACL plus the icacls tightening below. - */ -function readOwnerOnlyRelayEndpointCredential(credentialFile: string): string | undefined { - try { - if (process.platform !== 'win32') { - const stat = statSync(credentialFile) - if ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()) { - return undefined - } - } - const value = readFileSync(credentialFile, 'utf8').trim() - return isValidRelayEndpointCredential(value) ? value : undefined - } catch { - return undefined - } -} - -/** - * Read a credential the file already holds, or undefined when there is none to adopt. - * - * Why adopt rather than always mint: clients older than this daemon still pre-write the file - * before launching, and their --connect reads it back. Overwriting it would lock them out. - * A file that fails the owner-only rule is not adopted; it is replaced by a fresh mint. - */ -export function readAdoptableRelayEndpointCredential(credentialFile: string): string | undefined { - return readOwnerOnlyRelayEndpointCredential(credentialFile) -} - -/** Atomic, owner-only publication: temp file created exclusively, then renamed over the path. */ -export function writeRelayEndpointCredentialFile(credentialFile: string, credential: string): void { - const tempFile = `${credentialFile}.${process.pid}.${randomBytes(8).toString('hex')}.tmp` - try { - writeFileSync(tempFile, credential, { flag: 'wx', mode: 0o600 }) - renameSync(tempFile, credentialFile) - } catch (error) { - try { - unlinkSync(tempFile) - } catch { - /* the temp file was never created, or the rename already consumed it */ - } - throw error - } - if (process.platform !== 'win32') { - chmodSync(credentialFile, 0o600) - } -} - -/** - * Establish the credential this daemon will enforce. Call only after the socket bind succeeded. - * Returns the credential, or undefined when the launch requested none. - */ -export function publishRelayEndpointCredential( - credentialFile: string | undefined -): string | undefined { - if (!credentialFile) { - return undefined - } - const adopted = readAdoptableRelayEndpointCredential(credentialFile) - if (adopted !== undefined) { - return adopted - } - const minted = mintRelayEndpointCredential() - writeRelayEndpointCredentialFile(credentialFile, minted) - relayLogLine(`[relay] Endpoint credential published: ${credentialFile}`) - return minted -} - -// Why best-effort and after publication: the file's inherited ACL is already user-scoped under -// the profile dir; this tightens it to match the POSIX 0600 contract without gating readiness. -export async function restrictWindowsRelayEndpointCredential( - credentialFile: string -): Promise { - if (process.platform !== 'win32' || !process.env.USERNAME) { - return - } - try { - await runProcess({ - program: `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe`, - args: [credentialFile, '/inheritance:r', '/grant:r', `${process.env.USERNAME}:(R,W)`], - timeoutMs: 10_000 - }) - } catch (error) { - relayLogLine( - `[relay] Could not restrict endpoint credential ACL: ${error instanceof Error ? error.message : String(error)}` - ) - } -} diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts deleted file mode 100644 index fcf3850157a..00000000000 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ /dev/null @@ -1,478 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' -import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor' -import type { WatcherProcessSubscribeOptions } from '../main/ipc/parcel-watcher-process-protocol' -import type { - WatcherProcessCallback, - WatcherProcessHooks, - WatcherProcessSubscription -} from '../main/ipc/parcel-watcher-process-subscription' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' -import { createRelayWatcherProcessPool } from './relay-watcher-process-pool' - -type InstalledWatch = { - rootPath: string - callback: WatcherProcessCallback - hooks: WatcherProcessHooks - unsubscribe: ReturnType Promise>> -} - -type InstalledSupervisorWatch = Omit & { dir: string } - -function stubRelayWatcherSupervisors() { - const installed = new Map() - vi.spyOn(WatcherProcessSupervisor.prototype, 'subscribe').mockImplementation(function ( - this: WatcherProcessSupervisor, - dir, - callback, - _options, - hooks = {} - ) { - const watches = installed.get(this) ?? [] - const unsubscribe = vi.fn(async () => undefined) - watches.push({ dir, callback, hooks, unsubscribe }) - installed.set(this, watches) - return Promise.resolve({ unsubscribe }) - }) - const dispose = vi - .spyOn(WatcherProcessSupervisor.prototype, 'dispose') - .mockImplementation(() => undefined) - return { installed, dispose } -} - -class FakeWatcherPool { - readonly installed: InstalledWatch[] = [] - readonly dispose = vi.fn() - readonly forgetRoot = vi.fn() - readonly disposeAndWait = vi.fn(async () => {}) - readonly reopen = vi.fn() - - async subscribe( - rootPath: string, - callback: WatcherProcessCallback, - _options: WatcherProcessSubscribeOptions, - hooks: WatcherProcessHooks - ): Promise { - const unsubscribe = vi.fn(async () => undefined) - this.installed.push({ rootPath, callback, hooks, unsubscribe }) - return { unsubscribe } - } -} - -function createDispatcher() { - const detached = new Set<(clientId: number) => void>() - // Batches ride the producer lane and markers the control lane, so record both in one ordered log. - const fsChanged: Record[] = [] - const record = (_clientId: number, method: string, params?: Record): void => { - if (method === 'fs.changed' && params) { - fsChanged.push(params) - } - } - return { - fsChanged, - notify: vi.fn(), - notifyClient: vi.fn(record), - tryNotifyClient: vi.fn( - ( - clientId: number, - method: string, - params?: Record, - onSettled?: (result: { ok: true } | { ok: false; error: Error }) => void - ) => { - record(clientId, method, params) - // A healthy sink settles as the frame is written, which releases the emitter's outstanding-marker slot. - onSettled?.({ ok: true }) - return true - } - ), - activeClientIds: vi.fn(() => [1]), - producerEnvelopeBudget: vi.fn(() => Number.MAX_SAFE_INTEGER), - publishProducerNotification: vi.fn( - (clientId: number, method: string, params?: Record) => { - record(clientId, method, params) - return true - } - ), - onClientDetached: vi.fn((listener: (clientId: number) => void) => { - detached.add(listener) - return () => detached.delete(listener) - }), - // Detach here always retires the id, so the emitter's marker cleanup runs. - isClientAttached: vi.fn(() => false), - notificationFrameBytes: vi.fn(() => 64) - } -} - -function watchFailedCalls(dispatcher: ReturnType): unknown[][] { - return dispatcher.notifyClient.mock.calls.filter(([, method]) => method === 'fs.watchFailed') -} - -function context(clientId: number): RequestContext { - return { clientId, isStale: () => false } -} - -describe('RelayFilesystemWatchRegistry', () => { - let dispatcher: ReturnType - let pool: FakeWatcherPool - let registry: RelayFilesystemWatchRegistry - - beforeEach(() => { - dispatcher = createDispatcher() - pool = new FakeWatcherPool() - registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool) - }) - - it('emits overflow around child replacement and resumes ordered event delivery', async () => { - await registry.watch('/repo', context(1)) - const first = pool.installed[0] - - first.callback(null, [{ type: 'create', path: '/repo/before.txt' }]) - first.hooks.onInterruption?.() - first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }]) - - expect(dispatcher.fsChanged).toEqual([ - { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }, - { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, - { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] } - ]) - }) - - it.each([ - ['native watcher failure', new Error('native subscription stopped')], - [ - 'watcher process failure', - new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') - ] - ])('recovers after a %s without dropping shared clients', async (_kind, error) => { - await registry.watch('/repo', context(1)) - await registry.watch('/repo', context(2)) - const first = pool.installed[0] - first.hooks.onTerminalError?.(error) - await Promise.resolve() - - expect(pool.installed).toHaveLength(2) - first.callback(null, [{ type: 'create', path: '/repo/late.txt' }]) - pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }]) - expect(dispatcher.fsChanged).toEqual([ - { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, - { events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }] } - ]) - - registry.unwatch('/repo', context(1)) - expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled() - registry.unwatch('/repo', context(2)) - expect(pool.installed[1].unsubscribe).toHaveBeenCalledTimes(1) - }) - - it('notifies each owning client when bounded recovery terminates a live watch', async () => { - await registry.watch('/repo', context(1), 101) - await registry.watch('/repo', context(2), 202) - const first = pool.installed[0] - vi.spyOn(pool, 'subscribe').mockRejectedValueOnce(new Error('quarantine recovery failed')) - - first.hooks.onTerminalError?.( - new WatcherProcessFailure( - 'file watcher process crashed repeatedly', - 'supervisor', - 'supervisor_crash_fuse' - ) - ) - - await vi.waitFor(() => expect(watchFailedCalls(dispatcher)).toHaveLength(2)) - expect(watchFailedCalls(dispatcher)).toEqual([ - [ - 1, - 'fs.watchFailed', - { rootPath: '/repo', watchId: 101, message: 'quarantine recovery failed' } - ], - [ - 2, - 'fs.watchFailed', - { rootPath: '/repo', watchId: 202, message: 'quarantine recovery failed' } - ] - ]) - }) - - it('aborts a pending crawl only after the last same-root client leaves', async () => { - let sharedSignal: AbortSignal | undefined - let rejectSubscribe: ((error: Error) => void) | undefined - vi.spyOn(pool, 'subscribe').mockImplementation( - (_rootPath, _callback, _options, hooks): Promise => - new Promise((_resolve, reject) => { - sharedSignal = hooks.signal - rejectSubscribe = reject - hooks.signal?.addEventListener( - 'abort', - () => - reject( - new WatcherProcessFailure( - 'file watcher subscription aborted', - 'subscription', - 'subscribe_aborted' - ) - ), - { once: true } - ) - }) - ) - const firstAbort = new AbortController() - const secondAbort = new AbortController() - const first = registry.watch('/repo', { - ...context(1), - signal: firstAbort.signal - }) - const second = registry.watch('/repo', { - ...context(2), - signal: secondAbort.signal - }) - - firstAbort.abort() - await first - expect(sharedSignal?.aborted).toBe(false) - - secondAbort.abort() - await second - expect(sharedSignal?.aborted).toBe(true) - expect(rejectSubscribe).toBeDefined() - }) - - it('propagates unexpected non-Error setup failures', async () => { - vi.spyOn(pool, 'subscribe').mockRejectedValueOnce('native setup failed') - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - try { - await expect(registry.watch('/repo', context(1))).rejects.toBe('native setup failed') - } finally { - stderr.mockRestore() - } - }) - - it('rejects acknowledged teardown while another client still owns the watch', async () => { - await registry.watch('/repo', context(1)) - await registry.watch('/repo', context(2)) - const unsubscribe = pool.installed[0].unsubscribe - - await expect(registry.unwatchAndWait('/repo', context(1))).rejects.toThrow( - 'still watched by another client' - ) - expect(unsubscribe).not.toHaveBeenCalled() - - registry.unwatch('/repo', context(1)) - expect(unsubscribe).not.toHaveBeenCalled() - registry.unwatch('/repo', context(2)) - expect(unsubscribe).toHaveBeenCalledTimes(1) - }) - - it('notifies old clients and fences new ones while removal owns the root', async () => { - await registry.watch('/repo', context(1), 101) - let failRemoval: (error: Error) => void = () => undefined - const removal = registry.runWithRemovalFence( - '/repo', - () => - new Promise((_resolve, reject) => { - failRemoval = reject - }) - ) - await vi.waitFor(() => expect(pool.installed[0].unsubscribe).toHaveBeenCalledTimes(1)) - expect(dispatcher.notifyClient).toHaveBeenCalledWith(1, 'fs.watchFailed', { - rootPath: '/repo', - watchId: 101, - message: 'Remote worktree is being removed' - }) - - await expect(registry.watch('/repo', context(2))).rejects.toThrow( - 'deletion already in progress' - ) - failRemoval(new Error('Git removal failed')) - await expect(removal).rejects.toThrow('Git removal failed') - - await expect(registry.watch('/repo', context(2))).resolves.toBeUndefined() - }) - - it('waits admitted PTY creation, rejects late creation, then tears down before removal', async () => { - const finishPtyCreation = registry.beginWorktreePtySpawn('/repo/nested') - const order: string[] = [] - registry.setWorktreePtyTeardown(async (rootPath) => { - order.push(`pty:${rootPath}`) - }) - - const removal = registry.runWithRemovalFence('/repo', async () => { - order.push('remove') - }) - await Promise.resolve() - expect(order).toEqual([]) - expect(() => registry.beginWorktreePtySpawn('/repo/late')).toThrow( - 'deletion already in progress' - ) - - finishPtyCreation() - await removal - expect(order).toEqual(['pty:/repo', 'remove']) - }) - - it.each([ - ['drive spelling', 'C:\\Repo', 'c:/repo/'], - ['UNC spelling', '\\\\Server\\Share\\Repo', '//server/share/repo/'] - ])('shares one Windows root across equivalent %s', async (_label, firstPath, secondPath) => { - await registry.watch(firstPath, context(1)) - await registry.watch(secondPath, context(2)) - - expect(pool.installed).toHaveLength(1) - expect(pool.installed[0].rootPath).toBe(firstPath) - await expect(registry.unwatchAndWait(secondPath, context(1))).rejects.toThrow( - 'still watched by another client' - ) - - registry.unwatch(secondPath, context(1)) - await registry.unwatchAndWait(firstPath, context(2)) - - expect(pool.installed[0].unsubscribe).toHaveBeenCalledTimes(1) - expect(pool.forgetRoot).toHaveBeenCalledWith(firstPath) - }) - - it('keeps literal POSIX backslash roots physically distinct', async () => { - await registry.watch('/srv/team\\repo', context(1)) - await registry.watch('/srv/team/repo', context(2)) - - expect(pool.installed.map(({ rootPath }) => rootPath)).toEqual([ - '/srv/team\\repo', - '/srv/team/repo' - ]) - - await registry.unwatchAndWait('/srv/team\\repo', context(1)) - await registry.unwatchAndWait('/srv/team/repo', context(2)) - }) - - it('retains pending-setup teardown failure until the child physically exits', async () => { - let resolveSubscribe: (subscription: WatcherProcessSubscription) => void = () => undefined - let resolvePhysicalExit: () => void = () => undefined - const physicalExit = new Promise((resolve) => { - resolvePhysicalExit = resolve - }) - const terminationError = new WatcherProcessFailure( - 'file watcher process did not exit after termination deadline', - 'supervisor', - 'process_unavailable', - physicalExit - ) - const unsubscribe = vi.fn().mockRejectedValue(terminationError) - vi.spyOn(pool, 'subscribe').mockImplementation( - () => - new Promise((resolve) => { - resolveSubscribe = resolve - }) - ) - - const watch = registry.watch('/repo', context(1)) - const close = registry.unwatchAndWait('/repo', context(1)) - resolveSubscribe({ unsubscribe }) - - await expect(watch).rejects.toBe(terminationError) - await expect(close).rejects.toBe(terminationError) - await expect(registry.unwatchAndWait('/repo', context(1))).rejects.toBe(terminationError) - expect(pool.forgetRoot).not.toHaveBeenCalled() - - resolvePhysicalExit() - await physicalExit - await Promise.resolve() - - await expect(registry.unwatchAndWait('/repo', context(1))).resolves.toBeUndefined() - expect(pool.forgetRoot).toHaveBeenCalledWith('/repo') - }) -}) - -describe('createRelayWatcherProcessPool', () => { - afterEach(() => vi.restoreAllMocks()) - - it('uses one healthy watcher process per SSH host for each standard repo', async () => { - const { installed } = stubRelayWatcherSupervisors() - const firstHostPool = createRelayWatcherProcessPool() - const secondHostPool = createRelayWatcherProcessPool() - const repoBase = join(tmpdir(), 'ssh-repo') - const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')] - try { - for (const root of roots) { - await firstHostPool.subscribe(root, vi.fn(), {}, {}) - await secondHostPool.subscribe(root, vi.fn(), {}, {}) - } - - expect( - Array.from(installed.values()).map((watches) => watches.map(({ dir }) => dir)) - ).toEqual([roots, roots]) - } finally { - firstHostPool.dispose() - secondHostPool.dispose() - } - }) - - it('quarantines and recovers each standard-repo root after the shared child fails', async () => { - const { installed, dispose } = stubRelayWatcherSupervisors() - const pool = createRelayWatcherProcessPool() - const dispatcher = createDispatcher() - const registry = new RelayFilesystemWatchRegistry( - dispatcher as unknown as RelayDispatcher, - pool - ) - const repoBase = join(tmpdir(), 'ssh-repo-recovery') - const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')] - try { - for (const root of roots) { - await registry.watch(root, context(1)) - } - const healthyWatches = Array.from(installed.values())[0] - const failure = new WatcherProcessFailure( - 'file watcher process crashed repeatedly', - 'supervisor', - 'supervisor_crash_fuse' - ) - - for (const watch of healthyWatches) { - watch.hooks.onTerminalError?.(failure) - } - await Promise.resolve() - - const recoveredWatches = Array.from(installed.values()).slice(1) - expect(dispose).toHaveBeenCalledTimes(1) - expect(recoveredWatches.map((watches) => watches.map(({ dir }) => dir))).toEqual( - roots.map((root) => [root]) - ) - expect(dispatcher.fsChanged.slice(0, roots.length)).toEqual( - roots.map((root) => ({ events: [{ kind: 'overflow', absolutePath: root }] })) - ) - - for (const watches of recoveredWatches) { - const [{ callback, dir }] = watches - callback(null, [{ type: 'update', path: join(dir, 'recovered.txt') }]) - } - expect(dispatcher.fsChanged.slice(roots.length)).toEqual( - roots.map((root) => ({ - events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }] - })) - ) - } finally { - registry.dispose() - } - }) - - it('fails closed instead of loading the native watcher in the relay process', async () => { - const previousVitest = process.env.VITEST - process.env.VITEST = 'true' - const pool = createRelayWatcherProcessPool( - join(tmpdir(), `missing-relay-watcher-${process.pid}.js`) - ) - try { - await expect(pool.subscribe('/repo', vi.fn(), {}, {})).rejects.toMatchObject({ - code: 'entry_missing' - }) - } finally { - pool.dispose() - if (previousVitest === undefined) { - delete process.env.VITEST - } else { - process.env.VITEST = previousVitest - } - } - }) -}) diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts deleted file mode 100644 index 3ec6fb90760..00000000000 --- a/src/relay/relay-filesystem-watch-registry.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' -import { - WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOptions -} from '../main/ipc/filesystem-watcher-ignore' -import { - createRelayWatcherProcessPool, - type RelayWatcherProcessPool -} from './relay-watcher-process-pool' -import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' -import { awaitRelayWatcherSetupForClient, startInitialRelayWatch } from './relay-watcher-setup-wait' -import { - RelayWatcherTeardownTracker, - type RelayWatcherTeardownState -} from './relay-watcher-teardown-tracker' -import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' -import { RelayWatchRootCapacityGate } from './relay-watch-root-capacity-gate' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' -import { - trackRelayWatcherSetup, - type RelayWatcherPendingSetup -} from './relay-watcher-setup-tracking' -import { RelayWatcherRemovalFence } from './relay-watcher-removal-fence' -import { - pruneStaleRelayWatchClients, - releaseStaleRelayWatches -} from './relay-watcher-stale-client-release' -import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import { joinRelayWatcherPendingSetup } from './relay-watcher-pending-setup-join' -import { createRelayWatcherState } from './relay-watcher-state' -import { closeRelayWatchesAndWait, disposeRelayWatchesAndWait } from './relay-watcher-shutdown' - -const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) - -export class RelayFilesystemWatchRegistry { - private readonly watches = new Map() - private readonly pendingSetups = new Map() - private readonly capacityGate = new RelayWatchRootCapacityGate( - this.watches, - this.pendingSetups, - () => this.teardownTracker - ) - private readonly teardownTracker: RelayWatcherTeardownTracker - private readonly removalFence: RelayWatcherRemovalFence - private disposed = false - - constructor( - private readonly dispatcher: RelayDispatcher, - private readonly watcherPool: RelayWatcherProcessPool = createRelayWatcherProcessPool() - ) { - this.teardownTracker = new RelayWatcherTeardownTracker((rootPath) => - this.watcherPool.forgetRoot(rootPath) - ) - this.removalFence = new RelayWatcherRemovalFence( - this.watches, - this.pendingSetups, - this.teardownTracker, - this.dispatcher - ) - this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId)) - } - - watch(rootPath: string, context?: RequestContext, watchId?: number): Promise { - if (this.disposed) { - return Promise.reject(new Error('relay_watcher_shutdown_fenced')) - } - const rootKey = normalizeRuntimePathForComparison(rootPath) - if (this.removalFence.isActive(rootKey)) { - return Promise.reject(new Error('Remote worktree deletion already in progress')) - } - const existing = this.watches.get(rootKey) - if (existing) { - const hasLiveClient = [...existing.clients.values()].some((isStale) => !isStale()) - if (hasLiveClient) { - const clientId = context?.clientId ?? 0 - existing.clients.set(clientId, context?.isStale ?? (() => false)) - if (watchId !== undefined) { - existing.clientWatchIds.set(clientId, watchId) - } - return awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) - } - void this.closeWatch(existing).catch(() => {}) - } - const pendingSetup = this.pendingSetups.get(rootKey) - if (pendingSetup) { - const retry = (): Promise => this.watch(rootPath, context, watchId) - return joinRelayWatcherPendingSetup(pendingSetup, context?.signal, retry) - } - const setup = this.installWatch(rootKey, rootPath, context, watchId) - return trackRelayWatcherSetup(this.pendingSetups, rootKey, setup) - } - - private async installWatch( - rootKey: string, - rootPath: string, - context?: RequestContext, - watchId?: number - ): Promise { - const staleTeardown = releaseStaleRelayWatches(this.watches.values(), (state) => - this.closeWatch(state) - ) - if (staleTeardown) { - await staleTeardown - } - const rootTeardown = this.teardownTracker.join(rootKey) - if (rootTeardown) { - await rootTeardown - } - const capacityRelease = this.capacityGate.release(rootKey, context?.signal) - if (capacityRelease) { - await capacityRelease - } - if (this.disposed) { - throw new Error('relay_watcher_shutdown_fenced') - } - const clientId = context?.clientId ?? 0 - const isStale = context?.isStale ?? (() => false) - const existing = this.watches.get(rootKey) - if (existing) { - existing.clients.set(clientId, isStale) - if (watchId !== undefined) { - existing.clientWatchIds.set(clientId, watchId) - } - await awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) - return - } - - this.capacityGate.assert(rootKey) - - const state = createRelayWatcherState(rootKey, rootPath, clientId, isStale, watchId) - this.watches.set(rootKey, state) - state.setupWaiters = new PromiseSettlementWaiters(this.startInitialWatch(state)) - await awaitRelayWatcherSetupForClient(state, context, () => - this.releaseWatchClient(state, clientId) - ) - } - - unwatch(rootPath: string, context?: RequestContext): void { - const rootKey = normalizeRuntimePathForComparison(rootPath) - const state = this.watches.get(rootKey) - if (state) { - this.releaseWatchClient(state, context?.clientId ?? 0) - return - } - const setup = this.pendingSetups.get(rootKey) - if (setup) { - // Why: notification teardown must not disappear before async setup publishes ownership. - void setup.promise.then(() => this.unwatch(rootPath, context)).catch(() => {}) - } - } - - async unwatchAndWait(rootPath: string, context?: RequestContext): Promise { - const rootKey = normalizeRuntimePathForComparison(rootPath) - let state = this.watches.get(rootKey) - if (!state) { - await this.pendingSetups.get(rootKey)?.promise.catch(() => undefined) - state = this.watches.get(rootKey) - } - if (!state) { - const failed = this.teardownTracker.failedState(rootKey) - if (failed) { - await this.closeWatch(failed) - return - } - await this.teardownTracker.join(rootKey) - return - } - const clientId = context?.clientId ?? 0 - pruneStaleRelayWatchClients(state, clientId) - if ([...state.clients.keys()].some((registeredClientId) => registeredClientId !== clientId)) { - // Why: destructive cleanup cannot acknowledge while another client owns the handle. - throw new Error('Remote path is still watched by another client') - } - state.clients.delete(clientId) - state.clientWatchIds.delete(clientId) - await this.closeWatch(state) - } - - async runWithRemovalFence(rootPath: string, operation: () => Promise): Promise { - return this.removalFence.run(normalizeRuntimePathForComparison(rootPath), operation) - } - - beginWorktreePtySpawn(path: string): () => void { - return this.removalFence.beginOperation(normalizeRuntimePathForComparison(path)) - } - - setWorktreePtyTeardown(teardown: (rootPath: string) => Promise): void { - this.removalFence.setBeforeRemove(teardown) - } - - dispose(): void { - this.disposed = true - this.watches.forEach((state) => void this.closeWatch(state).catch(() => {})) - this.watcherPool.dispose() - } - - closeWatchesAndWait(): Promise { - this.disposed = true - return closeRelayWatchesAndWait( - this.watches, - this.pendingSetups, - this.teardownTracker, - (state) => this.closeWatch(state) - ) - } - - reopen(): void { - this.disposed = false - this.watcherPool.reopen() - } - - disposeAndWait = (): Promise => - disposeRelayWatchesAndWait(() => this.closeWatchesAndWait(), this.watcherPool) - - private startInitialWatch(state: RelayWatcherTeardownState): Promise { - return startInitialRelayWatch( - state, - () => this.subscribeState(state), - () => emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed), - () => this.closeWatch(state) - ) - } - - private subscribeState(state: RelayWatcherTeardownState): Promise { - const generation = ++state.generation - const emitOverflow = (): void => { - if (state.generation === generation) { - emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) - } - } - return this.watcherPool - .subscribe( - state.rootPath, - (error, events) => { - if (state.closed || state.generation !== generation) { - return - } - if (error) { - process.stderr.write( - `[relay] File watcher error for ${state.rootPath}: ${error.message}\n` - ) - emitOverflow() - return - } - emitRelayWatcherEvents(this.dispatcher, state.rootPath, state.closed, events) - }, - RELAY_WATCH_OPTIONS, - { - delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS }, - onInterruption: emitOverflow, - onOverflow: emitOverflow, - onTerminalError: (error) => this.recoverWatch(state, generation, error), - signal: state.abortController.signal, - subscribeTimeoutMs: 60_000 - } - ) - .then(async (subscription) => { - if ( - state.closed || - state.generation !== generation || - this.watches.get(state.rootKey) !== state - ) { - if (state.closed) { - state.subscription = subscription - } - await subscription.unsubscribe() - if (state.subscription === subscription) { - state.subscription = null - } - return - } - state.subscription = subscription - }) - } - - private recoverWatch( - state: RelayWatcherTeardownState, - failedGeneration: number, - error: Error - ): void { - if (state.closed || state.generation !== failedGeneration) { - return - } - state.subscription = null - emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) - const recovery = this.subscribeState(state) - state.setupWaiters = new PromiseSettlementWaiters(recovery) - void recovery.catch((recoveryError: unknown) => { - if (!state.closed) { - const message = recoveryError instanceof Error ? recoveryError.message : error.message - process.stderr.write( - `[relay] File watcher disabled after bounded recovery for ${state.rootPath}: ${message}\n` - ) - emitRelayWatcherTerminalFailure(this.dispatcher, state, message) - void this.closeWatch(state).catch(() => {}) - } - }) - } - - private releaseClientWatches(clientId: number): void { - this.watches.forEach((state) => this.releaseWatchClient(state, clientId)) - } - - private releaseWatchClient(state: RelayWatcherTeardownState, clientId: number): void { - state.clients.delete(clientId) - state.clientWatchIds.delete(clientId) - if (!state.closed && state.clients.size === 0) { - void this.closeWatch(state).catch(() => {}) - } - } - - private closeWatch(state: RelayWatcherTeardownState): Promise { - return this.teardownTracker.close(state, () => { - if (this.watches.get(state.rootKey) === state) { - this.watches.delete(state.rootKey) - } - }) - } -} diff --git a/src/relay/relay-fs-test-dispatcher.ts b/src/relay/relay-fs-test-dispatcher.ts deleted file mode 100644 index 17d0e20bd19..00000000000 --- a/src/relay/relay-fs-test-dispatcher.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { vi, type Mock } from 'vitest' - -type MockRequestHandler = ( - params: Record, - context?: { clientId: number; isStale: () => boolean } -) => Promise -type MockNotificationHandler = ( - params: Record, - context?: { clientId: number; isStale: () => boolean } -) => void -type MockCallContext = { clientId?: number; isStale: () => boolean } - -// Explicit rather than inferred: vi.fn()'s inferred type is not nameable across project boundaries. -export type MockRelayFsDispatcher = { - onRequest: Mock - onNotification: Mock - notify: Mock - notifyClient: Mock - onClientDetached: Mock - _requestHandlers: Map - _notificationHandlers: Map - _notifications: { method: string; params?: Record }[] - callRequest: ( - method: string, - params?: Record, - context?: MockCallContext - ) => Promise - callNotification: ( - method: string, - params?: Record, - context?: { clientId: number; isStale: () => boolean } - ) => void - detachClient: (clientId: number) => void -} - -/** Records handlers and notifications so a test can drive FsHandler without a real transport. */ -export function createMockDispatcher(): MockRelayFsDispatcher { - const requestHandlers = new Map< - string, - ( - params: Record, - context?: { clientId: number; isStale: () => boolean } - ) => Promise - >() - const notificationHandlers = new Map< - string, - ( - params: Record, - context?: { clientId: number; isStale: () => boolean } - ) => void - >() - const detachListeners = new Set<(clientId: number) => void>() - const notifications: { method: string; params?: Record }[] = [] - - return { - onRequest: vi.fn( - ( - method: string, - handler: ( - params: Record, - context?: { clientId: number; isStale: () => boolean } - ) => Promise - ) => { - requestHandlers.set(method, handler) - } - ), - onNotification: vi.fn( - ( - method: string, - handler: ( - params: Record, - context?: { clientId: number; isStale: () => boolean } - ) => void - ) => { - notificationHandlers.set(method, handler) - } - ), - notify: vi.fn((method: string, params?: Record) => { - notifications.push({ method, params }) - }), - notifyClient: vi.fn(), - onClientDetached: vi.fn((listener: (clientId: number) => void) => { - detachListeners.add(listener) - return () => detachListeners.delete(listener) - }), - _requestHandlers: requestHandlers, - _notificationHandlers: notificationHandlers, - _notifications: notifications, - async callRequest( - method: string, - params: Record = {}, - context?: { clientId?: number; isStale: () => boolean } - ) { - const handler = requestHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params, { - clientId: context?.clientId ?? 1, - isStale: context?.isStale ?? (() => false) - }) - }, - callNotification( - method: string, - params: Record = {}, - context?: { clientId: number; isStale: () => boolean } - ) { - const handler = notificationHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - handler(params, context ?? { clientId: 1, isStale: () => false }) - }, - detachClient(clientId: number) { - for (const listener of detachListeners) { - listener(clientId) - } - } - } -} diff --git a/src/relay/relay-grace-branch.test.ts b/src/relay/relay-grace-branch.test.ts deleted file mode 100644 index e7fee583239..00000000000 --- a/src/relay/relay-grace-branch.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - applyRelayGraceTimeConfiguration, - decideRelayGrace, - decideRelayGraceReconfigure, - type RelayGraceBranch, - type RelayGraceDecisionInput, - type RelayGraceReconfigureInput -} from './relay-grace-branch' - -const EMPTY_DETACHED_STARTUP_GRACE_MS = 30_000 -const IDLE_RELAY_GRACE_MS = 15 * 60_000 - -function decide(overrides: Partial = {}) { - return decideRelayGrace({ - configuredGraceMs: 0, - relayIdle: false, - detached: false, - hasAcceptedSocketClient: true, - activePtyCount: 1, - retryDeferredShutdown: false, - emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, - idleRelayGraceMs: IDLE_RELAY_GRACE_MS, - ...overrides - }) -} - -describe('decideRelayGrace', () => { - describe('the zero-only gate', () => { - it('honors a grace raised after launch instead of clamping it to the idle cap', () => { - // Why: the reported bug. startGrace used to read the launch-time argv closure, so a host that - // raised the grace to 24h via relay.configureGraceTime still got the 15-minute idle cap. - expect(decide({ configuredGraceMs: 86_400_000, relayIdle: true, activePtyCount: 0 })).toEqual( - { - branch: 'configured', - timeoutMs: 86_400_000 - } - ) - }) - - it('caps an idle relay running the unlimited default at the idle grace', () => { - // Why: prepareForHostSleep notifies graceTimeSeconds:0, which now reaches this selector. - // Deliberate behavior change: a host-sleep relay holding zero PTYs exits after the idle cap - // instead of living forever. Pinned so the revived path stays a decision, not an accident. - expect(decide({ configuredGraceMs: 0, relayIdle: true, activePtyCount: 0 })).toEqual({ - branch: 'idle-no-ptys', - timeoutMs: IDLE_RELAY_GRACE_MS - }) - }) - - it('leaves a non-idle relay on the configured branch even at the unlimited default', () => { - // Why: an admitted-but-unpooled creation keeps relayIdle false, so the cap must not apply — - // it would kill the shell that creation is about to produce (#6955). - expect(decide({ configuredGraceMs: 0, relayIdle: false })).toEqual({ - branch: 'configured', - timeoutMs: 0 - }) - }) - }) - - describe('branch precedence', () => { - it('prefers shutdown-deferred over every other branch', () => { - expect( - decide({ - retryDeferredShutdown: true, - detached: true, - hasAcceptedSocketClient: false, - relayIdle: true, - activePtyCount: 0 - }) - ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) - }) - - it('prefers startup-empty-detached over the idle cap', () => { - expect( - decide({ - detached: true, - hasAcceptedSocketClient: false, - relayIdle: true, - activePtyCount: 0 - }) - ).toEqual({ branch: 'startup-empty-detached', timeoutMs: EMPTY_DETACHED_STARTUP_GRACE_MS }) - }) - - it('bounds a configured grace on the startup-empty-detached branch', () => { - expect( - decide({ - configuredGraceMs: 5_000, - detached: true, - hasAcceptedSocketClient: false, - activePtyCount: 0 - }) - ).toEqual({ branch: 'startup-empty-detached', timeoutMs: 5_000 }) - }) - - it('does not treat a detached relay that already accepted a client as startup-empty', () => { - expect( - decide({ - detached: true, - hasAcceptedSocketClient: true, - relayIdle: true, - activePtyCount: 0 - }) - ).toEqual({ branch: 'idle-no-ptys', timeoutMs: IDLE_RELAY_GRACE_MS }) - }) - }) -}) - -function reconfigure(overrides: Partial = {}) { - return decideRelayGraceReconfigure({ - previousConfiguredGraceMs: 10_000, - nextConfiguredGraceMs: 86_400_000, - graceTimerArmed: true, - shutdownInFlight: false, - currentBranch: 'configured', - ...overrides - }) -} - -describe('decideRelayGraceReconfigure', () => { - it('re-arms a running window so a raised grace takes effect at the new deadline', () => { - // Why: the reported bug's call site. startGrace samples the grace at arm time, so without this - // re-arm a raise landing mid-window still fires at the old deadline. - expect(reconfigure()).toEqual({ rearm: true, retryDeferredShutdown: false }) - }) - - it('preserves shutdown-deferred across the re-arm', () => { - const rearmed = reconfigure({ nextConfiguredGraceMs: 0, currentBranch: 'shutdown-deferred' }) - - expect(rearmed).toEqual({ rearm: true, retryDeferredShutdown: true }) - expect( - decide({ - configuredGraceMs: 0, - retryDeferredShutdown: rearmed.rearm && rearmed.retryDeferredShutdown - }) - ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) - }) - - it('ignores a re-assertion of the same grace', () => { - // Why: the host re-asserts its grace on every establish; re-arming on those would keep the - // window alive indefinitely. - expect(reconfigure({ previousConfiguredGraceMs: 86_400_000 })).toEqual({ rearm: false }) - }) - - it('does not arm a window that was never running', () => { - expect(reconfigure({ graceTimerArmed: false })).toEqual({ rearm: false }) - }) - - it('does not re-arm once shutdown is in flight', () => { - expect(reconfigure({ shutdownInFlight: true })).toEqual({ rearm: false }) - }) -}) - -/** - * Models relay.ts's grace state around the configureGraceTime call site: `startGrace` re-decides the - * branch exactly as the relay does, so a dropped `retryDeferredShutdown` shows up as a branch downgrade. - */ -function relayGraceHost( - overrides: { - configuredGraceMs?: number - graceTimerArmed?: boolean - shutdownInFlight?: boolean - graceBranch?: RelayGraceBranch | null - relayIdle?: boolean - activePtyCount?: number - } = {} -) { - let configuredGraceMs = overrides.configuredGraceMs ?? 10_000 - let graceBranch: RelayGraceBranch | null = overrides.graceBranch ?? 'configured' - const startGrace = vi.fn( - (_reason: string, options?: { retryDeferredShutdown?: boolean }): void => { - graceBranch = decide({ - configuredGraceMs, - relayIdle: overrides.relayIdle ?? true, - activePtyCount: overrides.activePtyCount ?? 0, - retryDeferredShutdown: options?.retryDeferredShutdown === true - }).branch - } - ) - - return { - startGrace, - branch: () => graceBranch, - configuredGraceMs: () => configuredGraceMs, - apply: (graceTimeSeconds: unknown) => - applyRelayGraceTimeConfiguration(graceTimeSeconds, { - readConfiguredGraceMs: () => configuredGraceMs, - // Mirrors PtyHandler.setGraceTimeMs's clamp so the decision sees the stored value. - writeConfiguredGraceMs: (graceMs) => { - configuredGraceMs = Math.max(0, Math.floor(graceMs)) - }, - isGraceTimerArmed: () => overrides.graceTimerArmed ?? true, - isShutdownInFlight: () => overrides.shutdownInFlight ?? false, - readGraceBranch: () => graceBranch, - startGrace - }) - } -} - -describe('applyRelayGraceTimeConfiguration', () => { - it('re-arms a deferred shutdown without downgrading it to an ordinary configured window', () => { - // Why: the call site the branch selector alone cannot cover — dropping the startGrace option here - // would leave a refused kill with nothing left to retry it. - const host = relayGraceHost({ graceBranch: 'shutdown-deferred', configuredGraceMs: 0 }) - - expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) - expect(host.startGrace).toHaveBeenCalledWith('grace reconfigured', { - retryDeferredShutdown: true - }) - expect(host.branch()).toBe('shutdown-deferred') - }) - - it('re-arms an ordinary window at the raised grace', () => { - const host = relayGraceHost() - - expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) - expect(host.startGrace).toHaveBeenCalledWith('grace reconfigured', { - retryDeferredShutdown: false - }) - expect(host.branch()).toBe('configured') - }) - - it('stores the host-sleep zero and lets the idle cap own the window', () => { - const host = relayGraceHost() - - expect(host.apply(0)).toEqual({ graceTimeMs: 0 }) - expect(host.startGrace).toHaveBeenCalledTimes(1) - expect(host.branch()).toBe('idle-no-ptys') - }) - - it('does not re-arm on a re-asserted grace', () => { - const host = relayGraceHost({ configuredGraceMs: 86_400_000 }) - - expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) - expect(host.startGrace).not.toHaveBeenCalled() - }) - - it('does not arm a window that was never running', () => { - const host = relayGraceHost({ graceTimerArmed: false }) - - expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) - expect(host.startGrace).not.toHaveBeenCalled() - }) - - it('ignores a malformed or negative payload without touching the stored grace', () => { - for (const payload of [undefined, 'later', Number.NaN, -1]) { - const host = relayGraceHost() - - expect(host.apply(payload)).toEqual({ graceTimeMs: 10_000 }) - expect(host.configuredGraceMs()).toBe(10_000) - expect(host.startGrace).not.toHaveBeenCalled() - } - }) -}) diff --git a/src/relay/relay-grace-branch.ts b/src/relay/relay-grace-branch.ts deleted file mode 100644 index 68ba3e2ec9d..00000000000 --- a/src/relay/relay-grace-branch.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** Which rule decided the current relay grace window. */ -export type RelayGraceBranch = - | 'shutdown-deferred' - | 'startup-empty-detached' - | 'idle-no-ptys' - | 'configured' - -export type RelayGraceDecisionInput = { - /** The live configured grace, not the launch-time argv value; 0 means unlimited. */ - configuredGraceMs: number - /** Zero pooled PTYs *and* zero admitted-but-unpooled creations. */ - relayIdle: boolean - detached: boolean - hasAcceptedSocketClient: boolean - activePtyCount: number - retryDeferredShutdown: boolean - emptyDetachedStartupGraceMs: number - idleRelayGraceMs: number -} - -export type RelayGraceDecision = { - branch: RelayGraceBranch - timeoutMs: number -} - -export type RelayGraceReconfigureInput = { - previousConfiguredGraceMs: number - nextConfiguredGraceMs: number - /** A grace window with a real deadline is running; an unlimited (deadline-less) one is not re-armed. */ - graceTimerArmed: boolean - shutdownInFlight: boolean - currentBranch: RelayGraceBranch | null -} - -export type RelayGraceReconfigureDecision = - | { rearm: false } - | { rearm: true; retryDeferredShutdown: boolean } - -/** Live relay grace state, read through accessors so the caller's `let` bindings stay authoritative. */ -export type RelayGraceTimeConfigurationInput = { - readConfiguredGraceMs: () => number - /** Writes the new grace; the value is read back so a normalizing setter wins. */ - writeConfiguredGraceMs: (graceMs: number) => void - isGraceTimerArmed: () => boolean - isShutdownInFlight: () => boolean - readGraceBranch: () => RelayGraceBranch | null - startGrace: (reason: string, options?: { retryDeferredShutdown?: boolean }) => void -} - -/** - * Applies a `relay.configureGraceTime` payload, re-arming the running window when the value changed. - * - * Why extracted from relay.ts: that file runs `main()` on import and exports nothing, so the call site - * — including the `retryDeferredShutdown` hand-off into `startGrace` — is otherwise untestable. - */ -export function applyRelayGraceTimeConfiguration( - graceTimeSeconds: unknown, - state: RelayGraceTimeConfigurationInput -): { graceTimeMs: number } { - const seconds = Number(graceTimeSeconds) - if (Number.isFinite(seconds) && seconds >= 0) { - const previousConfiguredGraceMs = state.readConfiguredGraceMs() - // Why: the host sends 0 before system sleep so live remote PTYs survive longer than the ordinary grace window. - state.writeConfiguredGraceMs(Math.floor(seconds) * 1000) - const reconfigure = decideRelayGraceReconfigure({ - previousConfiguredGraceMs, - nextConfiguredGraceMs: state.readConfiguredGraceMs(), - graceTimerArmed: state.isGraceTimerArmed(), - shutdownInFlight: state.isShutdownInFlight(), - currentBranch: state.readGraceBranch() - }) - if (reconfigure.rearm) { - state.startGrace('grace reconfigured', { - retryDeferredShutdown: reconfigure.retryDeferredShutdown - }) - } - } - return { graceTimeMs: state.readConfiguredGraceMs() } -} - -/** - * Decides whether a live grace change must re-arm the running grace timer. - * - * Why a decision rather than inline gating: `startGrace` samples the configured grace at arm time, so - * this gate is the only thing that makes a post-launch raise take effect on an already-running window. - */ -export function decideRelayGraceReconfigure( - input: RelayGraceReconfigureInput -): RelayGraceReconfigureDecision { - // Why only on an actual change: the host re-asserts the same value on every establish, and - // restarting the window on those would keep a grace alive indefinitely. - if ( - input.nextConfiguredGraceMs === input.previousConfiguredGraceMs || - !input.graceTimerArmed || - input.shutdownInFlight - ) { - return { rearm: false } - } - // Why carry the branch forward: re-arming without it downgrades a deferred shutdown to an ordinary - // configured window, and the refused kill is never retried. - return { rearm: true, retryDeferredShutdown: input.currentBranch === 'shutdown-deferred' } -} - -/** - * Picks the relay's shutdown grace window. - * - * Why a separate module: relay.ts runs `main()` on import and exports nothing, so the zero-only - * gate below — the property that keeps a configured grace from being clamped to the idle cap — - * could not otherwise be tested. - */ -export function decideRelayGrace(input: RelayGraceDecisionInput): RelayGraceDecision { - // Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever. - const startupEmptyDetached = - input.detached && !input.hasAcceptedSocketClient && input.activePtyCount === 0 - // Why: zero PTYs means nothing left to preserve, so only the unlimited default is capped — an - // explicitly configured grace is honored verbatim rather than clamped down to the idle cap. - // Why: a spawn parked mid-creation is not in the pool yet, so capping on it would kill the live - // shell it is about to produce. - const idleNoPtys = input.relayIdle && input.configuredGraceMs === 0 - - const branch: RelayGraceBranch = input.retryDeferredShutdown - ? 'shutdown-deferred' - : startupEmptyDetached - ? 'startup-empty-detached' - : idleNoPtys - ? 'idle-no-ptys' - : 'configured' - - const timeoutMs = - branch === 'startup-empty-detached' - ? input.configuredGraceMs === 0 - ? input.emptyDetachedStartupGraceMs - : Math.min(input.configuredGraceMs, input.emptyDetachedStartupGraceMs) - : // Why: a refused kill leaves the PTY pooled, so the idle branch can't be reached — this - // branch must supply its own bound or the shipped grace=0 default arms no retry at all. - branch === 'idle-no-ptys' || branch === 'shutdown-deferred' - ? input.idleRelayGraceMs - : input.configuredGraceMs - - return { branch, timeoutMs } -} diff --git a/src/relay/relay-grace-lifecycle.test.ts b/src/relay/relay-grace-lifecycle.test.ts deleted file mode 100644 index 409d6c906d7..00000000000 --- a/src/relay/relay-grace-lifecycle.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { PtyHandler } from './pty-handler' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function fixture(options: { clients?: number; dispose?: () => Promise } = {}) { - const graceCallbacks: (() => void)[] = [] - const ptyHandler = { - configuredGraceTimeMs: 1_000, - activePtyCount: 0, - pendingPtyCreationCount: 0, - graceTimerActive: false, - startGraceTimer: vi.fn((callback: () => void) => graceCallbacks.push(callback)), - cancelGraceTimer: vi.fn(), - onPtyPoolEmpty: vi.fn(() => () => {}), - onPtyPoolActive: vi.fn(() => () => {}), - dispose: vi.fn(options.dispose ?? (async () => {})) - } - const dispatcher = { - onNotification: vi.fn(), - onRequest: vi.fn() - } - const disposeOwnedProcesses = vi.fn(async () => {}) - const reopenOwnedProcesses = vi.fn() - const disposeExitOnlyServices = vi.fn(async () => {}) - const disposeRuntime = vi.fn() - const lifecycle = new RelayGraceLifecycle({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every dispatcher member the lifecycle calls. - dispatcher: dispatcher as unknown as RelayDispatcher, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every PTY handler member the lifecycle calls. - ptyHandler: ptyHandler as unknown as PtyHandler, - detached: true, - emptyDetachedStartupGraceMs: 100, - idleRelayGraceMs: 100, - readSocketClientCount: () => options.clients ?? 0, - hasAcceptedSocketClient: () => false, - ownsSocketPath: () => true, - disposeOwnedProcesses, - reopenOwnedProcesses, - disposeExitOnlyServices, - disposeRuntime - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: process.exit never returns; the stub only records the call. - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - return { - lifecycle, - ptyHandler, - dispatcher, - disposeOwnedProcesses, - reopenOwnedProcesses, - disposeExitOnlyServices, - disposeRuntime, - exit, - graceCallbacks - } -} - -it('exits after idle shutdown', async () => { - const f = fixture() - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledOnce() - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() - expect(f.disposeRuntime).toHaveBeenCalledOnce() - expect(f.disposeExitOnlyServices).toHaveBeenCalledOnce() - expect(f.reopenOwnedProcesses).not.toHaveBeenCalled() -}) - -it('defers a failed idle shutdown and retries it through the grace timer', async () => { - let attempts = 0 - const f = fixture({ - dispose: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('pty still exiting') - } - } - }) - vi.spyOn(console, 'error').mockImplementation(() => {}) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.startGraceTimer).toHaveBeenCalledOnce()) - expect(f.lifecycle.reason).toBe('shutdown deferred') - expect(f.exit).not.toHaveBeenCalled() - expect(f.reopenOwnedProcesses, 'a deferred relay serves again').toHaveBeenCalledOnce() - - f.graceCallbacks[0]() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledTimes(2) -}) - -it('does not retry a deferred shutdown while a socket client is attached', async () => { - const f = fixture({ - clients: 1, - dispose: async () => { - throw new Error('pty still exiting') - } - }) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.dispose).toHaveBeenCalledOnce()) - await Promise.resolve() - expect(f.ptyHandler.startGraceTimer).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() -}) - -it('keeps every PTY when owned-process cleanup defers the shutdown', async () => { - const f = fixture({ clients: 1 }) - f.disposeOwnedProcesses.mockRejectedValueOnce(new Error('agent child still closing')) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.reopenOwnedProcesses).toHaveBeenCalledOnce()) - expect(f.ptyHandler.dispose).not.toHaveBeenCalled() - expect( - f.disposeExitOnlyServices, - 'AI Vault and skill uploads keep serving' - ).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() -}) diff --git a/src/relay/relay-grace-lifecycle.ts b/src/relay/relay-grace-lifecycle.ts deleted file mode 100644 index b38301cce08..00000000000 --- a/src/relay/relay-grace-lifecycle.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { PtyHandler } from './pty-handler' -import { - applyRelayGraceTimeConfiguration, - decideRelayGrace, - type RelayGraceBranch -} from './relay-grace-branch' -import { relayLogLine } from './relay-diagnostic-log' -import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { errorMessage } from '../shared/error-message' - -type RelayGraceLifecycleOptions = { - dispatcher: RelayDispatcher - ptyHandler: PtyHandler - detached: boolean - emptyDetachedStartupGraceMs: number - idleRelayGraceMs: number - readSocketClientCount: () => number - hasAcceptedSocketClient: () => boolean - ownsSocketPath: () => boolean - disposeOwnedProcesses: () => Promise - /** Lifts the admission fences disposal raised, for a relay that stays up after a deferral. */ - reopenOwnedProcesses: () => void - /** Never rejects; runs only after nothing can defer the exit. */ - disposeExitOnlyServices: () => Promise - disposeRuntime: () => void -} - -export class RelayGraceLifecycle { - private graceDeadlineAt: number | null = null - private graceReason: string | null = null - private graceBranch: RelayGraceBranch | null = null - private shutdownInFlight = false - private stopPoolWatch = (): void => {} - private stopPoolActiveWatch = (): void => {} - - constructor(private readonly options: RelayGraceLifecycleOptions) { - options.dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { - this.configure(params) - }) - options.dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => - this.configure(params) - ) - } - - get deadlineAt(): number | null { - return this.graceDeadlineAt - } - - get reason(): string | null { - return this.graceReason - } - - cancel(reason: string): void { - if (this.options.ptyHandler.graceTimerActive) { - relayLogLine(`[relay] Grace canceled: ${reason}`) - } - this.graceDeadlineAt = null - this.graceReason = null - this.graceBranch = null - this.options.ptyHandler.cancelGraceTimer() - } - - start(reason: string, options?: { retryDeferredShutdown?: boolean }): void { - const decision = decideRelayGrace({ - configuredGraceMs: this.options.ptyHandler.configuredGraceTimeMs, - relayIdle: this.isRelayIdle(), - detached: this.options.detached, - hasAcceptedSocketClient: this.options.hasAcceptedSocketClient(), - activePtyCount: this.options.ptyHandler.activePtyCount, - retryDeferredShutdown: options?.retryDeferredShutdown === true, - emptyDetachedStartupGraceMs: this.options.emptyDetachedStartupGraceMs, - idleRelayGraceMs: this.options.idleRelayGraceMs - }) - this.graceBranch = decision.branch - this.graceDeadlineAt = decision.timeoutMs === 0 ? null : Date.now() + decision.timeoutMs - this.graceReason = reason - relayLogLine( - `[relay] Grace started (${reason}): timeoutMs=${decision.timeoutMs}, branch=${this.graceBranch}, ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}` - ) - this.options.ptyHandler.startGraceTimer(() => { - if (this.graceBranch === 'idle-no-ptys' && !this.isRelayIdle()) { - relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`) - this.start(reason) - return - } - relayLogLine(`[relay] Grace expired (${reason}); shutting down`) - this.shutdown() - }, decision.timeoutMs) - } - - installProcessLifecycle(): void { - this.stopPoolWatch = this.options.ptyHandler.onPtyPoolEmpty(() => { - if (this.graceReason !== null && this.graceDeadlineAt === null && !this.shutdownInFlight) { - this.start('last pty exited') - } - }) - this.stopPoolActiveWatch = this.options.ptyHandler.onPtyPoolActive(() => { - if ( - this.graceBranch === 'idle-no-ptys' && - this.graceReason !== null && - !this.shutdownInFlight - ) { - this.start(this.graceReason) - } - }) - process.on('SIGTERM', this.shutdown) - process.on('SIGINT', this.shutdown) - process.on('SIGHUP', () => { - relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') - }) - process.on('exit', (code) => { - relayLogLine(`[relay] Process exiting with code ${code}`) - }) - } - - readonly shutdown = (): void => { - if (this.shutdownInFlight) { - return - } - this.shutdownInFlight = true - relayLogLine( - `[relay] Shutdown: ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}, ownsSocket=${this.options.ownsSocketPath()}` - ) - this.graceDeadlineAt = null - this.graceReason = null - this.graceBranch = null - // Why owned processes first: their disposal can defer, and a deferral must leave the PTYs intact, - // because a disposed PTY handler kills every terminal and refuses new ones. - void this.options - .disposeOwnedProcesses() - .then(async () => { - await this.options.ptyHandler.dispose() - await this.options.disposeExitOnlyServices() - this.stopPoolWatch() - this.stopPoolActiveWatch() - this.options.disposeRuntime() - process.exit(0) - }) - .catch((error) => { - this.shutdownInFlight = false - this.options.reopenOwnedProcesses() - relayLogLine(`[relay] Shutdown deferred: ${errorMessage(error)}`) - if (this.options.readSocketClientCount() === 0) { - this.start('shutdown deferred', { retryDeferredShutdown: true }) - } - }) - } - - private configure(params: Record): { graceTimeMs: number } { - return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { - readConfiguredGraceMs: () => this.options.ptyHandler.configuredGraceTimeMs, - writeConfiguredGraceMs: (graceMs) => this.options.ptyHandler.setGraceTimeMs(graceMs), - isGraceTimerArmed: () => this.graceDeadlineAt !== null && this.graceReason !== null, - isShutdownInFlight: () => this.shutdownInFlight, - readGraceBranch: () => this.graceBranch, - startGrace: (reason, startOptions) => this.start(reason, startOptions) - }) - } - - private isRelayIdle(): boolean { - return ( - this.options.ptyHandler.activePtyCount === 0 && - this.options.ptyHandler.pendingPtyCreationCount === 0 - ) - } -} diff --git a/src/relay/relay-handshake-roundtrip.test.ts b/src/relay/relay-handshake-roundtrip.test.ts deleted file mode 100644 index fbfecc8b122..00000000000 --- a/src/relay/relay-handshake-roundtrip.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { createServer, connect, type Server, type Socket } from 'node:net' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { - setupDaemonHandshake, - runConnectHandshake, - EXIT_CODE_VERSION_MISMATCH -} from './relay-handshake' -import { - encodeHandshakeFrame, - encodeJsonRpcFrame, - FrameDecoder, - type DecodedFrame, - type HandshakeMessage, - MessageType -} from '../wsl-guest/protocol' -import { relayTestSocketPath } from './relay-test-socket-path' - -// Why: --connect normally calls process.exit on mismatch / fatal handshake -// errors. Stub it for tests so the harness sees a thrown sentinel error -// rather than tearing down the test runner. -class ExitCalled extends Error { - code: number - constructor(code: number) { - super(`process.exit(${code})`) - this.code = code - } -} - -describe('handshake round-trip over a real Socket pair', () => { - let server: Server - let sockPath: string - let tmpDir: string - let exitSpy: ReturnType - - let uncaughtHandler: (err: Error) => void - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'orca-handshake-test-')) - sockPath = relayTestSocketPath(tmpDir) - exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new ExitCalled(code ?? 0) - }) as never) - // Why: process.exit is called from inside async callbacks - // (process.stderr.write flush callback) which would otherwise surface - // as an uncaughtException after the test resolves and tear down the - // runner. We swallow ExitCalled — exitSpy still records the call so - // assertions hold. - uncaughtHandler = (err: Error): void => { - if (err instanceof ExitCalled) { - return - } - throw err - } - process.on('uncaughtException', uncaughtHandler) - }) - - afterEach(async () => { - process.off('uncaughtException', uncaughtHandler) - exitSpy.mockRestore() - for (const s of liveServerSockets) { - s.destroy() - } - liveServerSockets.length = 0 - if (server) { - await new Promise((r) => server.close(() => r())) - } - rmSync(tmpDir, { recursive: true, force: true }) - }) - - const liveServerSockets: Socket[] = [] - function trackServerSocket(s: Socket): Socket { - liveServerSockets.push(s) - return s - } - - function startDaemon( - version: string, - endpointCredential?: string - ): Promise<{ - accepted: Promise<{ sock: Socket; leftover: Buffer }> - }> { - return new Promise((resolve) => { - const acceptedDeferred: { - promise: Promise<{ sock: Socket; leftover: Buffer }> - resolve: (v: { sock: Socket; leftover: Buffer }) => void - } = (() => { - let _resolve: (v: { sock: Socket; leftover: Buffer }) => void = () => {} - const promise = new Promise<{ sock: Socket; leftover: Buffer }>((r) => { - _resolve = r - }) - return { promise, resolve: _resolve } - })() - - server = createServer((sock) => { - trackServerSocket(sock) - setupDaemonHandshake(sock, { - launchVersion: version, - endpointCredential, - onAccepted: (s, leftover) => acceptedDeferred.resolve({ sock: s, leftover }) - }) - }) - server.listen(sockPath, () => resolve({ accepted: acceptedDeferred.promise })) - }) - } - - it('accepts a matching version and delivers no leftover when the bridge sent only the handshake', async () => { - const { accepted } = await startDaemon('0.1.0+match') - - const bridgeSock = connect(sockPath) - await new Promise((r) => bridgeSock.once('connect', () => r())) - - const acceptedCb = vi.fn<(leftover: Buffer) => void>() - runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: acceptedCb }) - - const { leftover } = await accepted - expect(leftover.length).toBe(0) - - await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1)) - expect(acceptedCb.mock.calls[0][0].length).toBe(0) - - bridgeSock.destroy() - }) - - it('rejects a same-build socket that lacks the detached endpoint credential', async () => { - const { accepted } = await startDaemon('0.1.0+match', 'secret-credential') - const bridgeSock = connect(sockPath) - await new Promise((resolve) => bridgeSock.once('connect', resolve)) - const closed = new Promise((resolve) => bridgeSock.once('close', () => resolve())) - - runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: vi.fn() }, 'wrong-credential') - - await closed - await expect( - Promise.race([ - accepted.then(() => 'accepted'), - new Promise((resolve) => setTimeout(() => resolve('closed'), 20)) - ]) - ).resolves.toBe('closed') - }) - - it('preserves leftover bytes on the daemon side when an extra frame is coalesced after the handshake', async () => { - // Why: simulate an aggressive client that pipelines a frame immediately - // after the handshake. We bypass runConnectHandshake here and write the - // raw bytes directly so we control the coalescing behaviour. - const { accepted } = await startDaemon('0.1.0+match') - - const bridgeSock = connect(sockPath) - await new Promise((r) => bridgeSock.once('connect', () => r())) - - const handshakeFrame = encodeHandshakeFrame({ - type: 'orca-relay-handshake', - version: '0.1.0+match' - }) - const trailingPayload = encodeJsonRpcFrame({ jsonrpc: '2.0', method: 'noop', params: {} }, 1, 0) - bridgeSock.write(Buffer.concat([handshakeFrame, trailingPayload])) - - const { leftover } = await accepted - - const seen: DecodedFrame[] = [] - const dec = new FrameDecoder((f) => seen.push(f)) - dec.feed(leftover) - expect(seen).toHaveLength(1) - expect(seen[0].type).toBe(MessageType.Regular) - - bridgeSock.destroy() - }) - - it('preserves leftover bytes on the bridge side when the daemon coalesces handshake-ok + a JSON-RPC frame', async () => { - let serverHandshakeSeen = false - server = createServer((sock) => { - trackServerSocket(sock) - const decoder = new FrameDecoder((frame) => { - if (frame.type !== MessageType.Handshake || serverHandshakeSeen) { - return - } - serverHandshakeSeen = true - const ok = encodeHandshakeFrame({ - type: 'orca-relay-handshake-ok', - version: '0.1.0+match' - }) - const trailing = encodeJsonRpcFrame( - { jsonrpc: '2.0', method: 'pty.event', params: { evt: 'data' } }, - 7, - 1 - ) - sock.write(Buffer.concat([ok, trailing])) - }) - sock.on('data', (chunk: Buffer) => decoder.feed(chunk)) - }) - await new Promise((r) => server.listen(sockPath, () => r())) - - const bridgeSock = connect(sockPath) - await new Promise((r) => bridgeSock.once('connect', () => r())) - - const acceptedCb = vi.fn<(leftover: Buffer) => void>() - runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: acceptedCb }) - - await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1)) - const leftover = acceptedCb.mock.calls[0][0] - - const seen: DecodedFrame[] = [] - const dec = new FrameDecoder((f) => seen.push(f)) - dec.feed(leftover) - expect(seen).toHaveLength(1) - expect(seen[0].type).toBe(MessageType.Regular) - - bridgeSock.destroy() - }) - - it('exits with EXIT_CODE_VERSION_MISMATCH when the daemon reports a mismatch', async () => { - await startDaemon('0.1.0+server-version') - - const bridgeSock = connect(sockPath) - await new Promise((r) => bridgeSock.once('connect', () => r())) - - const acceptedCb = vi.fn() - runConnectHandshake(bridgeSock, '0.1.0+different', { onAccepted: acceptedCb }) - - await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) - expect(exitSpy).toHaveBeenCalledWith(EXIT_CODE_VERSION_MISMATCH) - expect(acceptedCb).not.toHaveBeenCalled() - - bridgeSock.destroy() - }) - - it('does not call onAccepted before any handshake-ok frame arrives', async () => { - // Why: silent server that never replies. acceptedCb must stay - // un-invoked even though the bridge has flushed its handshake frame. - server = createServer((sock) => { - trackServerSocket(sock) - /* swallow */ - }) - await new Promise((r) => server.listen(sockPath, () => r())) - - const bridgeSock = connect(sockPath) - await new Promise((r) => bridgeSock.once('connect', () => r())) - - const acceptedCb = vi.fn() - runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: acceptedCb }) - - await new Promise((r) => setTimeout(r, 100)) - expect(acceptedCb).not.toHaveBeenCalled() - - bridgeSock.destroy() - }) - - // The daemon reads one handshake frame before any credential check, so every field on it is - // untrusted input. `JSON.parse` hands back objects a template literal cannot stringify, and the - // frame callback runs inside the decoder: a throw there used to escape the socket's data - // handler and take the daemon — and every PTY and agent session it held — down with it. - it('closes a connection whose handshake version is not a string and keeps serving', async () => { - const { accepted } = await startDaemon('0.1.0+server-version') - - const hostile = connect(sockPath) - await new Promise((r) => hostile.once('connect', () => r())) - const hostileClosed = new Promise((r) => hostile.once('close', () => r())) - // The annotation is deliberately a lie: this is the frame a hostile peer sends, and - // HandshakeMessage cannot describe it. JSON.parse answers `any`, so it needs no assertion. - const hostileFrame: HandshakeMessage = JSON.parse( - '{"type":"orca-relay-handshake","version":{"toString":1}}' - ) - hostile.write(encodeHandshakeFrame(hostileFrame)) - await hostileClosed - - const good = connect(sockPath) - await new Promise((r) => good.once('connect', () => r())) - const acceptedCb = vi.fn<(leftover: Buffer) => void>() - runConnectHandshake(good, '0.1.0+server-version', { onAccepted: acceptedCb }) - await accepted - await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1)) - - good.destroy() - }) - - // Same class, different instance: `onAccepted` runs inside the frame callback too, so a throw - // from the accept path must cost that one connection and nothing else. - it('closes only the connection whose accept path throws', async () => { - let connections = 0 - const acceptedSockets: Socket[] = [] - server = createServer((sock) => { - trackServerSocket(sock) - connections += 1 - const failThisOne = connections === 1 - setupDaemonHandshake(sock, { - launchVersion: '0.1.0+server-version', - onAccepted: (s) => { - if (failThisOne) { - throw new Error('accept path failed') - } - acceptedSockets.push(s) - } - }) - }) - await new Promise((r) => server.listen(sockPath, () => r())) - - const first = connect(sockPath) - await new Promise((r) => first.once('connect', () => r())) - const firstClosed = new Promise((r) => first.once('close', () => r())) - runConnectHandshake(first, '0.1.0+server-version', { onAccepted: vi.fn() }) - await firstClosed - - const second = connect(sockPath) - await new Promise((r) => second.once('connect', () => r())) - const acceptedCb = vi.fn<(leftover: Buffer) => void>() - runConnectHandshake(second, '0.1.0+server-version', { onAccepted: acceptedCb }) - await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1)) - expect(acceptedSockets).toHaveLength(1) - - second.destroy() - }) -}) diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts deleted file mode 100644 index 03b01fa4f4e..00000000000 --- a/src/relay/relay-handshake.ts +++ /dev/null @@ -1,257 +0,0 @@ -// Wire-level handshake helpers for the Orca relay. - -import { dirname, join } from 'node:path' -import { existsSync, readFileSync, realpathSync } from 'node:fs' -import type { Socket } from 'node:net' -import { - RELAY_VERSION, - MessageType, - FrameDecoder, - encodeHandshakeFrame, - parseHandshakeMessage, - type DecodedFrame -} from '../wsl-guest/protocol' -import { relayLogLine } from './relay-diagnostic-log' -import { describeRelayRuntime } from './relay-runtime-identity' - -// Why: clients treat this exit code as non-retryable; other non-zero exits are transient. -export const EXIT_CODE_VERSION_MISMATCH = 42 -// Why distinct from 42: a refused credential is a live daemon saying no, which the client must -// not confuse with a crashed bridge (exit 0/1) or with a version skew (42). -export const EXIT_CODE_CREDENTIAL_MISMATCH = 43 - -// Why: read .version beside the resolved script path, not the arbitrary launch cwd. -export function readLaunchVersion(): string { - try { - const entry = process.argv[1] - let dir: string - if (entry) { - let resolved = entry - try { - resolved = realpathSync(entry) - } catch { - /* fall back to the unresolved path */ - } - dir = dirname(resolved) - } else { - dir = process.cwd() - } - const versionFile = join(dir, '.version') - if (existsSync(versionFile)) { - const v = readFileSync(versionFile, 'utf-8').trim() - if (v) { - return v - } - } - } catch { - /* fall through */ - } - return RELAY_VERSION -} - -// ── Daemon side ───────────────────────────────────────────────────── - -export type DaemonHandshakeCallbacks = { - // leftover: bytes buffered after the handshake frame; caller must feed the dispatcher before attaching the data listener or they're lost. - onAccepted: (sock: Socket, leftover: Buffer) => void - launchVersion: string - endpointCredential?: string -} - -// Why: read one handshake frame before attaching the dispatcher; version mismatch closes the socket so the bridge exits 42. -export function setupDaemonHandshake(sock: Socket, cb: DaemonHandshakeCallbacks): void { - let handshakeResolved = false - const decoder: FrameDecoder = new FrameDecoder( - (frame: DecodedFrame) => { - if (handshakeResolved) { - return - } - const accepted = handleDaemonHandshakeFrame(sock, frame, cb) - if (accepted) { - handshakeResolved = true - const leftover = decoder.drain() - detachHandshakeListener(sock) - cb.onAccepted(sock, leftover) - } - }, - (err) => { - process.stderr.write(`[relay] Handshake decode error: ${err.message}\n`) - sock.destroy() - } - ) - - const onHandshakeData = (chunk: Buffer): void => { - decoder.feed(chunk) - } - sock.on('data', onHandshakeData) - ;(sock as Socket & { __orcaOnHandshake?: typeof onHandshakeData }).__orcaOnHandshake = - onHandshakeData -} - -export function detachHandshakeListener(sock: Socket): void { - const tagged = sock as Socket & { __orcaOnHandshake?: (chunk: Buffer) => void } - if (tagged.__orcaOnHandshake) { - sock.removeListener('data', tagged.__orcaOnHandshake) - delete tagged.__orcaOnHandshake - } -} - -function handleDaemonHandshakeFrame( - sock: Socket, - frame: DecodedFrame, - cb: DaemonHandshakeCallbacks -): boolean { - const { launchVersion, endpointCredential } = cb - if (frame.type !== MessageType.Handshake) { - process.stderr.write( - `[relay] Protocol violation pre-handshake: type=${frame.type}; closing socket\n` - ) - sock.destroy() - return false - } - let msg: ReturnType - try { - msg = parseHandshakeMessage(frame.payload) - } catch (err) { - relayLogLine(`[relay] Could not parse handshake: ${(err as Error).message}; closing socket`) - sock.destroy() - return false - } - if (msg.type !== 'orca-relay-handshake') { - relayLogLine(`[relay] Unexpected handshake type from client: ${msg.type}; closing socket`) - sock.destroy() - return false - } - if (msg.version !== launchVersion) { - relayLogLine( - `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket` - ) - try { - sock.write( - encodeHandshakeFrame({ - type: 'orca-relay-handshake-mismatch', - expected: launchVersion, - got: msg.version - }) - ) - } catch { - /* best-effort — close+exit-42 still wins */ - } - sock.end() - return false - } - const presented = 'endpointCredential' in msg ? msg.endpointCredential : undefined - if (endpointCredential !== undefined && presented !== endpointCredential) { - relayLogLine('[relay] Endpoint credential mismatch; closing socket') - try { - sock.write(encodeHandshakeFrame({ type: 'orca-relay-handshake-credential-mismatch' })) - } catch { - /* best-effort — the close alone still refuses */ - } - sock.end() - return false - } - process.stderr.write(`[relay] Handshake OK from version=${msg.version}\n`) - sock.write( - encodeHandshakeFrame({ - type: 'orca-relay-handshake-ok', - version: launchVersion, - runtime: describeRelayRuntime() - }) - ) - return true -} - -// ── --connect side ────────────────────────────────────────────────── - -export type ConnectHandshakeCallbacks = { - // leftover: bytes buffered after handshake-ok; caller must forward to stdout before attaching the bridge or they're dropped. - onAccepted: (leftover: Buffer) => void -} - -// Why: defense-in-depth prevents a bad .version from pairing incompatible bridge and daemon versions. -export function runConnectHandshake( - sock: Socket, - myVersion: string, - cb: ConnectHandshakeCallbacks, - endpointCredential?: string -): void { - let handshakeDone = false - - const decoder: FrameDecoder = new FrameDecoder( - (frame: DecodedFrame) => { - if (handshakeDone) { - return - } - if (frame.type !== MessageType.Handshake) { - process.stderr.write( - `[relay-connect] Protocol violation: expected Handshake frame, got type=${frame.type}\n` - ) - sock.destroy() - process.exit(1) - } - let msg: ReturnType - try { - msg = parseHandshakeMessage(frame.payload) - } catch (err) { - process.stderr.write( - `[relay-connect] Could not parse handshake reply: ${(err as Error).message}\n` - ) - sock.destroy() - process.exit(1) - } - if (msg.type === 'orca-relay-handshake-ok') { - const runtime = msg.runtime ? ` runtime=${msg.runtime.kind}/${msg.runtime.version}` : '' - process.stderr.write(`[relay-connect] Handshake OK at version=${msg.version}${runtime}\n`) - handshakeDone = true - const leftover = decoder.drain() - sock.removeAllListeners('data') - cb.onAccepted(leftover) - return - } - if (msg.type === 'orca-relay-handshake-mismatch') { - // Why: exit inside the write callback; stderr is async on pipe transports, so exiting early drops the version detail. - process.stderr.write( - `[relay-connect] Handshake mismatch: expected=${msg.expected}, daemon=${msg.got}; exiting ${EXIT_CODE_VERSION_MISMATCH}\n`, - () => { - sock.destroy() - process.exit(EXIT_CODE_VERSION_MISMATCH) - } - ) - return - } - if (msg.type === 'orca-relay-handshake-credential-mismatch') { - process.stderr.write( - `[relay-connect] Endpoint credential refused by daemon; exiting ${EXIT_CODE_CREDENTIAL_MISMATCH}\n`, - () => { - sock.destroy() - process.exit(EXIT_CODE_CREDENTIAL_MISMATCH) - } - ) - return - } - process.stderr.write(`[relay-connect] Unexpected handshake type: ${msg.type}\n`) - sock.destroy() - process.exit(1) - }, - (err) => { - process.stderr.write(`[relay-connect] Handshake decode error: ${err.message}\n`) - sock.destroy() - process.exit(1) - } - ) - - sock.on('data', (chunk: Buffer) => { - if (!handshakeDone) { - decoder.feed(chunk) - } - }) - - sock.write( - encodeHandshakeFrame({ - type: 'orca-relay-handshake', - version: myVersion, - ...(endpointCredential ? { endpointCredential } : {}) - }) - ) -} diff --git a/src/relay/relay-launch-options.test.ts b/src/relay/relay-launch-options.test.ts deleted file mode 100644 index e16d3cad783..00000000000 --- a/src/relay/relay-launch-options.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { chmodSync, mkdtempSync, statSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' - -describe('relay launch options', () => { - const temporaryDirectories: string[] = [] - - afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })) - ) - }) - - it('preserves daemon mode flags and grace seconds conversion', () => { - expect( - parseRelayLaunchOptions([ - 'node', - 'relay.js', - '--detached', - '--connect', - '--grace-time', - '17', - '--sock-path', - 'relay-endpoint', - '--endpoint-dir', - 'hooks', - '--log-file', - 'relay.log', - '--credential-file', - 'relay.credential' - ]) - ).toEqual({ - graceTimeMs: 17_000, - connectMode: true, - detached: true, - cliMode: false, - sockPath: 'relay-endpoint', - endpointDir: 'hooks', - logFile: 'relay.log', - credentialFile: 'relay.credential' - }) - }) - - it('reads the bundled ripgrep path, which older launch commands omit', () => { - expect( - parseRelayLaunchOptions([ - 'node', - 'relay.js', - '--detached', - '--ripgrep-path', - '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg' - ]).ripgrepPath - ).toBe('/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg') - expect(parseRelayLaunchOptions(['node', 'relay.js', '--detached']).ripgrepPath).toBe(undefined) - }) - - it('keeps zero grace unlimited and ignores invalid replacements', () => { - expect( - parseRelayLaunchOptions(['node', 'relay.js', '--grace-time', '0', '--grace-time', '-1']) - .graceTimeMs - ).toBe(0) - }) - - it('validates and restricts the endpoint credential file', () => { - const directory = mkdtempSync(join(tmpdir(), 'relay-launch-options-')) - temporaryDirectories.push(directory) - const credentialFile = join(directory, 'endpoint.credential') - const credential = 'a'.repeat(32) - writeFileSync(credentialFile, `${credential}\n`) - if (process.platform !== 'win32') { - chmodSync(credentialFile, 0o644) - } - - expect(readRelayEndpointCredential(credentialFile)).toBe(credential) - if (process.platform !== 'win32') { - expect(statSync(credentialFile).mode & 0o777).toBe(0o600) - } - }) - - it('rejects credentials that cannot authenticate a reconnect client', () => { - const directory = mkdtempSync(join(tmpdir(), 'relay-launch-options-')) - temporaryDirectories.push(directory) - const credentialFile = join(directory, 'endpoint.credential') - writeFileSync(credentialFile, 'too-short') - - expect(() => readRelayEndpointCredential(credentialFile)).toThrow( - 'Relay endpoint credential is missing or invalid' - ) - }) -}) diff --git a/src/relay/relay-launch-options.ts b/src/relay/relay-launch-options.ts deleted file mode 100644 index f9e057ce55f..00000000000 --- a/src/relay/relay-launch-options.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { chmodSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' - -const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 -const DEFAULT_SOCKET_NAME = 'relay.sock' - -export const RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( - 'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS', - 60_000 -) -// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons. -// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env. -export const RELAY_IDLE_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000) - -export type RelayLaunchOptions = { - graceTimeMs: number - connectMode: boolean - detached: boolean - cliMode: boolean - sockPath: string - endpointDir?: string - logFile?: string - credentialFile?: string - /** Orca's uploaded ripgrep; absent when an older client launched this relay or no upload happened. */ - ripgrepPath?: string -} - -export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { - let graceTimeMs = DEFAULT_GRACE_MS - let connectMode = false - let detached = false - let cliMode = false - let sockPath = '' - let endpointDir: string | undefined - let logFile: string | undefined - let credentialFile: string | undefined - let ripgrepPath: string | undefined - for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--grace-time' && argv[i + 1]) { - const parsed = Number.parseInt(argv[i + 1], 10) - // Why: flag is seconds (internally ms); 0 keeps the relay alive until explicitly terminated for synced workspaces. - if (!Number.isNaN(parsed) && parsed >= 0) { - graceTimeMs = parsed * 1000 - } - i++ - } else if (argv[i] === '--connect') { - connectMode = true - } else if (argv[i] === '--orca-cli') { - cliMode = true - } else if (argv[i] === '--detached') { - detached = true - } else if (argv[i] === '--sock-path' && argv[i + 1]) { - sockPath = argv[i + 1] - i++ - } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { - endpointDir = argv[i + 1] - i++ - } else if (argv[i] === '--log-file' && argv[i + 1]) { - logFile = argv[i + 1] - i++ - } else if (argv[i] === '--credential-file' && argv[i + 1]) { - credentialFile = argv[i + 1] - i++ - } else if (argv[i] === '--ripgrep-path' && argv[i + 1]) { - ripgrepPath = argv[i + 1] - i++ - } - } - if (!sockPath) { - sockPath = join(process.cwd(), DEFAULT_SOCKET_NAME) - } - return { - graceTimeMs, - connectMode, - detached, - cliMode, - sockPath, - endpointDir, - logFile, - credentialFile, - ripgrepPath - } -} - -export function readRelayEndpointCredential( - credentialFile: string | undefined -): string | undefined { - if (!credentialFile) { - return undefined - } - const credential = readFileSync(credentialFile, 'utf8').trim() - if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { - throw new Error('Relay endpoint credential is missing or invalid') - } - if (process.platform !== 'win32') { - chmodSync(credentialFile, 0o600) - } - return credential -} - -function parseNonNegativeIntEnv(name: string, fallback: number): number { - const raw = process.env[name] - if (raw === undefined) { - return fallback - } - const parsed = Number.parseInt(raw, 10) - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback -} diff --git a/src/relay/relay-orca-cli-channel.ts b/src/relay/relay-orca-cli-channel.ts deleted file mode 100644 index 3eec5a9b6cf..00000000000 --- a/src/relay/relay-orca-cli-channel.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { createConnection } from 'node:net' -import { DispatcherClientWriter } from '../wsl-guest/dispatcher-client-writer' -import { pickRemoteCliEnv } from './remote-cli-env' -import { shouldReadRemoteCliStdin } from './remote-cli-stdin' -import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' -import { - assertRemoteArtifactCliForwardingFits, - type RemoteArtifactCliForwardingParams -} from './remote-artifact-cli-forwarding' -import { - FrameDecoder, - MessageType, - encodeJsonRpcFrame, - parseJsonRpcMessage, - type DecodedFrame, - type JsonRpcResponse -} from '../wsl-guest/protocol' -import { readLaunchVersion, runConnectHandshake } from './relay-handshake' - -const CONNECT_TIMEOUT_MS = 5_000 - -export async function runRelayOrcaCliChannel( - sockPath: string, - argv: string[], - endpointCredential?: string -): Promise { - const myVersion = readLaunchVersion() - let preparedArtifact: Awaited> - try { - preparedArtifact = await prepareRemoteArtifactCliInput(argv, process.cwd()) - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 - return - } - const stdin = - preparedArtifact.stdin ?? - (shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined) - const env = pickRemoteCliEnv(process.env) - const requestParams: RemoteArtifactCliForwardingParams = { - argv, - cwd: process.cwd(), - env, - ...(stdin !== undefined ? { stdin } : {}), - ...(preparedArtifact.artifactInput ? { artifactInput: preparedArtifact.artifactInput } : {}) - } - if (preparedArtifact.artifactInput) { - try { - assertRemoteArtifactCliForwardingFits(requestParams) - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 - return - } - } - const sock = createConnection({ path: sockPath }) - const stdoutWriter = new DispatcherClientWriter( - (data, onSettled) => - process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }), - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (callback) => { - process.stdout.once('drain', callback) - return () => process.stdout.off('drain', callback) - } - }, - () => process.exit(1) - ) - let nextSeq = 1 - let highestReceivedSeq = 0 - const requestId = 1 - const postOutputRequestId = 2 - let initialExitCode = 0 - - const sendRequest = (): void => { - const frame = encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: requestId, - method: 'orca.cli', - params: requestParams - }, - nextSeq++, - highestReceivedSeq - ) - sock.write(frame) - } - - const finish = (exitCode: number): void => { - sock.destroy() - process.exit(exitCode) - } - - const sendPostOutput = (postOutput: unknown): void => { - sock.write( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: postOutputRequestId, - method: 'orca.cli.postOutput', - params: { postOutput, env: pickRemoteCliEnv(process.env) } - }, - nextSeq++, - highestReceivedSeq - ) - ) - } - - const writeOutput = ( - result: { stdout?: unknown; stderr?: unknown }, - onFlushed: (error?: Error) => void - ): void => { - let pending = 0 - let completed = false - const settle = (error?: Error): void => { - if (completed) { - return - } - if (error) { - completed = true - onFlushed(error) - return - } - pending -= 1 - if (pending === 0) { - completed = true - onFlushed() - } - } - if (typeof result.stdout === 'string' && result.stdout.length > 0) { - pending += 1 - const output = Buffer.from(result.stdout) - stdoutWriter.enqueue( - 'control', - () => output, - output.length, - (settlement) => settle(settlement.ok ? undefined : settlement.error) - ) - } - if (typeof result.stderr === 'string' && result.stderr.length > 0) { - pending += 1 - process.stderr.write(result.stderr, 'utf8', (error) => settle(error ?? undefined)) - } - if (pending === 0) { - completed = true - onFlushed() - } - } - - // Why an explicit error path: the decoder contains a throwing frame owner instead of letting - // it escape, so a malformed relay reply must still end this one-shot command, not park it. - const onDecodeError = (error: Error): void => { - // Why exit inside the write callback: stderr is async on pipe transports, so exiting early - // drops the only evidence this failure ever produces — the same reason relay-handshake.ts - // writes its mismatch line this way. - process.stderr.write(`[orca-cli] Relay protocol error: ${error.message}\n`, () => { - sock.destroy() - process.exit(1) - }) - } - const decoder = new FrameDecoder((frame: DecodedFrame) => { - if (frame.id > highestReceivedSeq) { - highestReceivedSeq = frame.id - } - if (frame.type !== MessageType.Regular) { - return - } - const msg = parseJsonRpcMessage(frame.payload) - if ( - !('id' in msg) || - (msg.id !== requestId && msg.id !== postOutputRequestId) || - !('result' in msg || 'error' in msg) - ) { - return - } - const response = msg as JsonRpcResponse - if (response.error) { - process.stderr.write(`${response.error.message}\n`) - finish(1) - return - } - if (response.id === postOutputRequestId) { - finish(initialExitCode) - return - } - const result = (response.result ?? {}) as { - stdout?: unknown - stderr?: unknown - exitCode?: unknown - postOutput?: unknown - } - initialExitCode = typeof result.exitCode === 'number' ? result.exitCode : 0 - writeOutput(result, (error) => { - if (error) { - finish(1) - return - } - if (result.postOutput === undefined) { - finish(initialExitCode) - return - } - sendPostOutput(result.postOutput) - }) - }, onDecodeError) - - const connectTimeout = setTimeout(() => { - process.stderr.write(`[orca-cli] Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) - sock.destroy() - process.exit(1) - }, CONNECT_TIMEOUT_MS) - - sock.on('connect', () => { - clearTimeout(connectTimeout) - runConnectHandshake( - sock, - myVersion, - { - onAccepted: (leftover) => { - if (leftover.length > 0) { - decoder.feed(leftover) - } - sock.on('data', (chunk) => - decoder.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - ) - sendRequest() - } - }, - endpointCredential - ) - }) - - sock.on('error', (error) => { - clearTimeout(connectTimeout) - process.stderr.write(`[orca-cli] Relay socket error: ${error.message}\n`) - process.exit(1) - }) -} - -async function readOrcaCliStdin(): Promise { - if (process.stdin.isTTY) { - return undefined - } - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) - } - return Buffer.concat(chunks).toString('utf8') -} diff --git a/src/relay/relay-oversized-notification-survival.test.ts b/src/relay/relay-oversized-notification-survival.test.ts deleted file mode 100644 index 2dae43b4cd0..00000000000 --- a/src/relay/relay-oversized-notification-survival.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { relayWriterControlReserve } from '../wsl-guest/dispatcher-writer-admission' -import { encodeJsonRpcFrame, HEADER_LENGTH, parseJsonRpcMessage } from '../wsl-guest/protocol' -import { - AGENT_HOOK_NOTIFICATION_METHOD, - type AgentHookRelayEnvelope -} from '../shared/agent-hook-relay' -import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' -import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' -import { publishAgentHookEnvelope } from '../wsl-guest/agent-hook-envelope-publication' -import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' -import { - AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, - AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, - normalizeAgentStatusPayload -} from '../shared/agent-status-types' - -// The relay runs on the REMOTE host, so the stream default is that host's Node major. -const NODE22_HWM = 64 * 1024 // Node >= 22 default -const NODE21_HWM = 16 * 1024 // Node <= 21 default - -const WATCHER_ROOT = '/home/dev/project' - -function capacityFor(hwm: number): number { - return hwm - relayWriterControlReserve(hwm) -} - -/** Largest envelope the normalizer accepts: every field at its own documented cap. */ -function buildMaximalHookEnvelope(): Record { - const payload = normalizeAgentStatusPayload({ - state: 'waiting', - prompt: 'x'.repeat(200), - interactivePrompt: 'q'.repeat(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH), - lastAssistantMessage: 'a'.repeat(AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH), - toolName: 'Bash', - toolInput: 'i'.repeat(160), - model: 'claude-opus-5', - agentType: 'general-purpose', - subagents: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS }, (_, i) => ({ - id: `subagent-${i}`.padEnd(64, '0'), - state: 'working', - agentType: 'general-purpose', - model: 'claude-opus-5', - toolName: 'Grep', - toolInput: 'i'.repeat(160) - })) - }) - expect(payload, 'normalizer must accept this payload').not.toBeNull() - return { - paneKey: 'worktree-abc:0f2c1d84-3b9a-4c77-9d21-5e6f7a8b9c0d', - connectionId: 'ssh-target-1', - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'Notification', - payload - } as unknown as Record -} - -function watcherProcessEvents(count: number): WatcherProcessEvent[] { - return Array.from({ length: count }, (_, i) => ({ - type: 'update', - path: `${WATCHER_ROOT}/node_modules/.vite/deps/chunk-${String(i).padStart(6, '0')}.js` - })) as unknown as WatcherProcessEvent[] -} - -/** The exact `fs.changed` params emitRelayWatcherEvents publishes, so the pinned sizes measure reality. */ -function buildWatcherBatch(count: number): Record { - return { - events: watcherProcessEvents(count).map((event) => ({ - kind: event.type, - absolutePath: event.path - })) - } -} - -function frameBytes(method: string, params: Record): number { - return encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, 1, 0).length -} - -function frameParams(frame: Buffer): Record { - const payloadLength = frame.readUInt32BE(9) - const message = parseJsonRpcMessage(frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)) - return 'method' in message ? ((message.params ?? {}) as Record) : {} -} - -function createDispatcher(hwm: number, onClose: () => void, sink: Buffer[]): RelayDispatcher { - return new RelayDispatcher( - (data: Buffer) => { - sink.push(data) - return true - }, - { - writableHighWaterMark: () => hwm, - writableLength: () => 0, - supportsWriteCallback: false, - close: onClose - } - ) -} - -describe('relay oversized notification survival', () => { - // Without this the byte measurements below could describe a payload the emitter never sends. - it('measures the params emitRelayWatcherEvents actually publishes', () => { - const sink: Buffer[] = [] - const dispatcher = createDispatcher(NODE22_HWM, () => {}, sink) - - try { - emitRelayWatcherEvents(dispatcher, WATCHER_ROOT, false, watcherProcessEvents(1)) - expect(sink).toHaveLength(1) - expect(frameParams(sink[0])).toEqual(buildWatcherBatch(1)) - } finally { - dispatcher.dispose() - } - }) - - it('measures both producers against both Node tiers', () => { - const hookBytes = frameBytes(AGENT_HOOK_NOTIFICATION_METHOD, buildMaximalHookEnvelope()) - const fullBatchBytes = frameBytes('fs.changed', buildWatcherBatch(MAX_BATCHED_WATCHER_EVENTS)) - - // Binary-search the largest watcher batch that still fits each tier. - const trip = (cap: number): number => { - let lo = 1 - let hi = MAX_BATCHED_WATCHER_EVENTS - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2) - if (frameBytes('fs.changed', buildWatcherBatch(mid)) <= cap) { - lo = mid - } else { - hi = mid - 1 - } - } - return lo - } - - // Capacity is a fixed fraction of the sink's high-water mark, so these are exact. - expect(capacityFor(NODE22_HWM)).toBe(49152) - expect(capacityFor(NODE21_HWM)).toBe(12288) - - // agent.hook: fits a modern remote, over-cap on an older one — which is why the remote's - // Node major, not Orca's, decides whether a maximal envelope has to shed. - expect(hookBytes).toBeLessThan(capacityFor(NODE22_HWM)) - expect(hookBytes).toBeGreaterThan(capacityFor(NODE21_HWM)) - - // fs.changed: the batch cap is orders of magnitude above what one frame can carry, on BOTH tiers. - expect(fullBatchBytes).toBeGreaterThan(capacityFor(NODE22_HWM)) - expect(trip(capacityFor(NODE22_HWM)) * 5).toBeLessThan(MAX_BATCHED_WATCHER_EVENTS) - expect(trip(capacityFor(NODE21_HWM))).toBeLessThan(trip(capacityFor(NODE22_HWM))) - }) - - it('a 5000-event fs.changed no longer closes the client on a Node>=22-sized sink', () => { - const sink: Buffer[] = [] - let closes = 0 - const dispatcher = createDispatcher(NODE22_HWM, () => (closes += 1), sink) - - try { - dispatcher.notify('pty.exit', { id: 'pty-1', exitCode: 0 }) - const before = sink.length - expect(before).toBeGreaterThan(0) - - // The batch the registry is configured to allow (maxEventsPerBatch = MAX_BATCHED_WATCHER_EVENTS). - emitRelayWatcherEvents( - dispatcher, - WATCHER_ROOT, - false, - watcherProcessEvents(MAX_BATCHED_WATCHER_EVENTS) - ) - expect(closes).toBe(0) - expect(sink.length).toBeGreaterThan(before) - expect(sink.every((frame) => frame.length <= capacityFor(NODE22_HWM))).toBe(true) - - // The link is still live: the next frame reaches the sink. - const afterBatch = sink.length - dispatcher.notify('pty.exit', { id: 'pty-2', exitCode: 0 }) - expect(sink.length).toBe(afterBatch + 1) - } finally { - dispatcher.dispose() - } - }) - - it('a maximal agent.hook envelope survives a Node<=21-sized sink through publishAgentHookEnvelope', () => { - const sink: Buffer[] = [] - let closes = 0 - const dispatcher = createDispatcher(NODE21_HWM, () => (closes += 1), sink) - - try { - publishAgentHookEnvelope( - dispatcher, - buildMaximalHookEnvelope() as unknown as AgentHookRelayEnvelope - ) - - expect(closes).toBe(0) - expect(sink).toHaveLength(1) - expect(sink[0].length).toBeLessThanOrEqual(capacityFor(NODE21_HWM)) - - const params = frameParams(sink[0]) - expect(params.paneKey).toBe('worktree-abc:0f2c1d84-3b9a-4c77-9d21-5e6f7a8b9c0d') - expect(params.connectionId).toBe('ssh-target-1') - const payload = params.payload as Record - expect(payload.state).toBe('waiting') - expect(payload.lastAssistantMessage).toBeUndefined() - } finally { - dispatcher.dispose() - } - }) - - it('reattach + replay does not re-kill', () => { - const sink: Buffer[] = [] - let killCloses = 0 - let retiredSinkCloses = 0 - let replacingSink = false - // setWrite deliberately closes the sink it retires; only a close on the LIVE sink is a kill. - const onClose = (): void => { - if (replacingSink) { - retiredSinkCloses += 1 - } else { - killCloses += 1 - } - } - const dispatcher = createDispatcher(NODE21_HWM, onClose, sink) - const envelope = buildMaximalHookEnvelope() as unknown as AgentHookRelayEnvelope - - try { - publishAgentHookEnvelope(dispatcher, envelope) - expect(sink).toHaveLength(1) - - for (let reconnect = 0; reconnect < 4; reconnect += 1) { - // Real reattach: relay.ts swaps the sink onto the new SSH channel, then the - // hook server replays its per-paneKey cache (agent-hook-server.ts). - replacingSink = true - dispatcher.setWrite( - (data: Buffer) => { - sink.push(data) - return true - }, - { - writableHighWaterMark: () => NODE21_HWM, - writableLength: () => 0, - supportsWriteCallback: false, - close: onClose - } - ) - replacingSink = false - publishAgentHookEnvelope(dispatcher, envelope) - } - - expect(killCloses).toBe(0) - expect(retiredSinkCloses).toBe(4) - expect(sink).toHaveLength(5) - } finally { - dispatcher.dispose() - } - }) -}) diff --git a/src/relay/relay-pid-publication.test.ts b/src/relay/relay-pid-publication.test.ts deleted file mode 100644 index f06640e1460..00000000000 --- a/src/relay/relay-pid-publication.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { RELAY_PID_FILENAME, RELAY_VERSION_FILENAME } from '../shared/relay-artifacts' -import { publishRelayPid } from './relay-pid-publication' - -describe('publishRelayPid', () => { - const directories: string[] = [] - afterEach(async () => { - await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) - }) - - function versionDir(installed: boolean): string { - const dir = mkdtempSync(join(tmpdir(), 'relay-pid-')) - directories.push(dir) - writeFileSync(join(dir, 'relay.js'), '') - if (installed) { - writeFileSync(join(dir, RELAY_VERSION_FILENAME), '0.1.0+abc\n') - } - return dir - } - - it('records the daemon PID beside an installed relay.js', () => { - const dir = versionDir(true) - publishRelayPid(join(dir, 'relay.js')) - expect(readFileSync(join(dir, RELAY_PID_FILENAME), 'utf8')).toBe(`${process.pid}\n`) - expect(readdirSync(dir).filter((name) => name.endsWith('.tmp'))).toEqual([]) - }) - - it('leaves a build dir without .version untouched', () => { - const dir = versionDir(false) - publishRelayPid(join(dir, 'relay.js')) - expect(existsSync(join(dir, RELAY_PID_FILENAME))).toBe(false) - }) - - it('does not throw when the entry cannot be resolved', () => { - expect(() => publishRelayPid(join(tmpdir(), 'missing-relay-dir', 'relay.js'))).not.toThrow() - }) -}) diff --git a/src/relay/relay-pid-publication.ts b/src/relay/relay-pid-publication.ts deleted file mode 100644 index 40152def587..00000000000 --- a/src/relay/relay-pid-publication.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { existsSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { RELAY_PID_FILENAME, RELAY_VERSION_FILENAME } from '../shared/relay-artifacts' -import { relayLogLine } from './relay-diagnostic-log' - -/** - * Record this daemon's PID in its version dir once it owns its socket, so version GC can - * tell a stale socket from a live daemon without connecting to one that is about to idle. - */ -export function publishRelayPid(entry: string | undefined = process.argv[1]): void { - if (!entry) { - return - } - let temporary: string | null = null - try { - const versionDir = dirname(realpathSync(entry)) - // Why: only an installed version dir carries `.version`; a dev or test build dir is not GC'd. - if (!existsSync(join(versionDir, RELAY_VERSION_FILENAME))) { - return - } - temporary = join(versionDir, `${RELAY_PID_FILENAME}.${process.pid}.tmp`) - writeFileSync(temporary, `${process.pid}\n`, { mode: 0o600 }) - // Why rename: GC must never read a half-written PID as a different, dead process. - renameSync(temporary, join(versionDir, RELAY_PID_FILENAME)) - temporary = null - } catch (error) { - relayLogLine( - `[relay] Could not record relay PID: ${error instanceof Error ? error.message : String(error)}` - ) - } finally { - if (temporary) { - try { - rmSync(temporary, { force: true }) - } catch { - // A leftover temp file is inert; GC reads only the renamed name. - } - } - } -} diff --git a/src/relay/relay-primary-channel.test.ts b/src/relay/relay-primary-channel.test.ts deleted file mode 100644 index d1ac209e161..00000000000 --- a/src/relay/relay-primary-channel.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { win32 } from 'node:path' -import { describe, expect, it } from 'vitest' -import { nullDevicePath } from './relay-primary-channel' - -describe('nullDevicePath', () => { - it('names the POSIX null device off win32', () => { - expect(nullDevicePath('linux')).toBe('/dev/null') - expect(nullDevicePath('darwin')).toBe('/dev/null') - }) - - /** - * The defect this pins: `openSync('NUL')` on Windows does NOT open the null device. - * node runs the path through `toNamespacedPath`, which resolves it against cwd and - * prefixes `\\?\` — and `\\?\` turns off DOS device-name mapping, so CreateFileW makes - * a real file. v1.4.203's Windows installer shipped one at - * `resources/relay/win32-x64/NUL` because of it. - */ - it('uses a device path win32 cannot rewrite into a file in the relay cwd', () => { - const path = nullDevicePath('win32') - - expect(path).toBe('\\\\.\\NUL') - expect(win32.toNamespacedPath(path)).toBe(path) - // Bare `NUL` never survives as a device name: it is resolved against cwd, and a - // drive-letter cwd then also takes the `\\?\` prefix. Spelled absolute because off - // Windows `resolve` finds no drive letter and stops before that second rewrite. - expect(win32.toNamespacedPath('NUL')).not.toBe('NUL') - expect(win32.toNamespacedPath(String.raw`C:\relay\NUL`)).toBe(String.raw`\\?\C:\relay\NUL`) - }) -}) diff --git a/src/relay/relay-primary-channel.ts b/src/relay/relay-primary-channel.ts deleted file mode 100644 index 92aab95a555..00000000000 --- a/src/relay/relay-primary-channel.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { closeSync, openSync } from 'node:fs' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { RELAY_SENTINEL } from '../wsl-guest/protocol' - -/** - * Why the `\\.\` device prefix and not bare `NUL`: node's fs resolves a relative path - * through `toNamespacedPath`, which hands CreateFileW a `\\?\C:\…\NUL` — and that prefix - * disables DOS device-name mapping, so the open creates a real FILE named `NUL` in the - * relay's cwd and pins fds 0/1 to it. One shipped in the 1.4.203 Windows installer as - * `resources/relay/win32-x64/NUL`. A `\\.\` path is passed through verbatim. - */ -export function nullDevicePath(platform: NodeJS.Platform = process.platform): string { - return platform === 'win32' ? String.raw`\\.\NUL` : '/dev/null' -} - -export class RelayPrimaryChannel { - readonly dispatcher: RelayDispatcher - private stdoutAlive = true - private readonly stdoutDrainWaiters = new Set<() => void>() - - constructor() { - process.stdout.on('drain', this.flushStdoutDrainWaiters) - this.dispatcher = new RelayDispatcher( - (data, onSettled) => { - if (!this.stdoutAlive) { - onSettled({ ok: false, error: new Error('Relay stdout is closed') }) - return false - } - try { - return process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }) - } catch (error) { - this.stdoutAlive = false - this.flushStdoutDrainWaiters() - onSettled({ - ok: false, - error: error instanceof Error ? error : new Error(String(error)) - }) - return false - } - }, - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (callback) => { - if (!this.stdoutAlive) { - callback() - return - } - this.stdoutDrainWaiters.add(callback) - return () => this.stdoutDrainWaiters.delete(callback) - }, - close: () => this.closePrimaryDescriptors() - }, - undefined, - { pauseReads: () => process.stdin.pause(), resumeReads: () => process.stdin.resume() } - ) - } - - get isAlive(): boolean { - return this.stdoutAlive - } - - detachInput(): void { - process.stdin.pause() - process.stdin.removeAllListeners('data') - } - - startOutputFailureHandling(): void { - process.stdout.on('error', () => { - this.markClosed('peer-closed') - }) - } - - startInput(callbacks: { onData: () => void; onDisconnect: (reason: string) => void }): void { - process.stdin.on('data', (chunk: Buffer) => { - callbacks.onData() - this.dispatcher.feed(chunk) - }) - process.stdin.on('end', () => { - this.markClosed('peer-closed') - callbacks.onDisconnect('stdin ended') - }) - process.stdin.on('error', () => { - this.markClosed('peer-closed') - callbacks.onDisconnect('stdin error') - }) - } - - writeSentinel(): void { - this.dispatcher.writePrimaryBytes(Buffer.from(RELAY_SENTINEL)) - } - - detachPrimaryClient(): void { - this.stdoutAlive = false - this.flushStdoutDrainWaiters() - this.dispatcher.invalidateClient() - } - - private readonly flushStdoutDrainWaiters = (): void => { - for (const callback of Array.from(this.stdoutDrainWaiters)) { - this.stdoutDrainWaiters.delete(callback) - callback() - } - } - - private markClosed(cause: 'peer-closed'): void { - this.stdoutAlive = false - this.flushStdoutDrainWaiters() - this.dispatcher.invalidateClient(cause) - } - - private closePrimaryDescriptors(): void { - this.stdoutAlive = false - this.flushStdoutDrainWaiters() - for (const fd of [process.stdin.fd, process.stdout.fd]) { - try { - closeSync(fd) - } catch { - // Already closed by the peer. - } - } - try { - openSync(nullDevicePath(), 'r') - } catch { - // Best-effort pin of the lowest free descriptor. - } - try { - openSync(nullDevicePath(), 'w') - } catch { - // Best-effort pin of the next free descriptor. - } - } -} diff --git a/src/relay/relay-pty-consumer-owner-displacement.test.ts b/src/relay/relay-pty-consumer-owner-displacement.test.ts deleted file mode 100644 index 81f090f8472..00000000000 --- a/src/relay/relay-pty-consumer-owner-displacement.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { - PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, - PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR -} from '../shared/pty-consumer-session' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -function message(buffer: Buffer): Record | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) -} - -function response(writes: readonly Buffer[], id: number): Record | undefined { - return writes.map(message).find((entry) => entry?.id === id) as - | Record - | undefined -} - -function ownerHelloParams(resume?: Record): Record { - return { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } }, - ...(resume ? { resume } : {}) - } -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('relay PTY consumer owner displacement', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - }) - - it('displaces a half-open owner and retains its deliveries for the reconnected client', async () => { - // Why no settle deferral: a half-open peer is indistinguishable from a live one at the sink — writes - // still "succeed" locally, so the relay never learns the owner is gone. - const staleWrites: Buffer[] = [] - const closeStaleTransport = vi.fn() - dispatcher = new RelayDispatcher( - (data, settle) => { - staleWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true, close: closeStaleTransport }, - endpointIdentity - ) - const detached: number[] = [] - dispatcher.onClientDetached((clientId) => detached.push(clientId)) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - dispatcher.feed(requestFrame(1, 'pty.openClient', ownerHelloParams())) - await flushRequests() - const staleGrant = response(staleWrites, 1)!.result as Record - expect(adapter.deliveryMode(1)).toBe('source-owner') - - const staleDelivery = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - adapter.appendSource(staleDelivery, { - spanId: 'span-1', - data: 'tail', - displayStart: 0, - displayEnd: 4, - splittable: true, - transform: { transformed: false, rawLengthSu: 4, scalarSafe: true } - }) - - const reconnectWrites: Buffer[] = [] - const reconnectClientId = dispatcher.attachClient( - (data, settle) => { - reconnectWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - reconnectClientId, - requestFrame( - 2, - 'pty.openClient', - ownerHelloParams({ - ownerGeneration: staleGrant.ownerGeneration, - ownerLease: staleGrant.ownerLease - }) - ) - ) - await flushRequests() - - expect(response(reconnectWrites, 2)!.result).toMatchObject({ - role: 'session-owner', - ownerGeneration: 2 - }) - expect(adapter.deliveryMode(reconnectClientId)).toBe('source-owner') - // Why: revocation is what bounds the takeover — the stale owner cannot open or drive deliveries again - // even though its socket never closed. - expect(adapter.deliveryMode(1)).toBe('unadmitted') - expect(adapter.openDelivery(1, 'pty-2', 'incarnation-1')).toBeNull() - expect(detached).toContain(1) - expect(closeStaleTransport).toHaveBeenCalledOnce() - - // Why: retained, not closed — the reconnected owner still has to rotate the stale delivery forward. - expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1, graceTimers: 1 }) - const rotation = adapter.rotateDelivery(staleDelivery, reconnectClientId, 0) - expect(rotation.identity).toMatchObject({ ownerGeneration: 2, clientGeneration: 2 }) - expect(rotation.recovery.map((span) => span.data)).toEqual(['tail']) - }) - - it('retains the incumbent delivery once when its socket closes mid-publication', async () => { - const incumbentWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settle) => { - incumbentWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - dispatcher.feed(requestFrame(1, 'pty.openClient', ownerHelloParams())) - await flushRequests() - const incumbentGrant = response(incumbentWrites, 1)!.result as Record - const incumbentDelivery = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - adapter.appendSource(incumbentDelivery, { - spanId: 'span-1', - data: 'tail', - displayStart: 0, - displayEnd: 4, - splittable: true, - transform: { transformed: false, rawLengthSu: 4, scalarSafe: true } - }) - - const reconnectWrites: Buffer[] = [] - let grantSettlement: ((result: SinkWriteSettlement) => void) | undefined - const reconnectClientId = dispatcher.attachClient( - (data, settle) => { - reconnectWrites.push(Buffer.from(data)) - grantSettlement = settle - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - reconnectClientId, - requestFrame( - 2, - 'pty.openClient', - ownerHelloParams({ - ownerGeneration: incumbentGrant.ownerGeneration, - ownerLease: incumbentGrant.ownerLease - }) - ) - ) - await flushRequests() - - // Why this ordering: the incumbent's socket close and the replacement's grant write are independent - // events, so detach-then-commit has to leave exactly one retention, not two competing ones. - dispatcher.invalidateClient() - grantSettlement!({ ok: true }) - - expect(adapter.deliveryMode(reconnectClientId)).toBe('source-owner') - expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1, graceTimers: 1 }) - const rotation = adapter.rotateDelivery(incumbentDelivery, reconnectClientId, 0) - expect(rotation.identity).toMatchObject({ ownerGeneration: 2, clientGeneration: 2 }) - expect(adapter.getDebugSnapshot()).toMatchObject({ graceTimers: 0 }) - }) - - it('keeps the incumbent owner when the replacement grant fails to publish', async () => { - const incumbentWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settle) => { - incumbentWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const detached: number[] = [] - dispatcher.onClientDetached((clientId) => detached.push(clientId)) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - dispatcher.feed(requestFrame(1, 'pty.openClient', ownerHelloParams())) - await flushRequests() - const incumbentGrant = response(incumbentWrites, 1)!.result as Record - - const reconnectWrites: Buffer[] = [] - let grantSettlement: ((result: SinkWriteSettlement) => void) | undefined - const reconnectClientId = dispatcher.attachClient( - (data, settle) => { - reconnectWrites.push(Buffer.from(data)) - // Why: the takeover must not commit until the replacement grant is on the wire. - grantSettlement = settle - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - reconnectClientId, - requestFrame( - 2, - 'pty.openClient', - ownerHelloParams({ - ownerGeneration: incumbentGrant.ownerGeneration, - ownerLease: incumbentGrant.ownerLease - }) - ) - ) - await flushRequests() - - expect(adapter.deliveryMode(1)).toBe('source-owner') - expect(detached).not.toContain(1) - - grantSettlement!({ ok: false, error: new Error('reconnect socket closed mid-response') }) - expect(adapter.deliveryMode(1)).toBe('source-owner') - expect(adapter.deliveryMode(reconnectClientId)).toBe('unadmitted') - - // Why: the rolled-back attempt must leave the incumbent's lease reclaimable by the next reconnect. - const retryWrites: Buffer[] = [] - const retryClientId = dispatcher.attachClient( - (data, settle) => { - retryWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - retryClientId, - requestFrame( - 3, - 'pty.openClient', - ownerHelloParams({ - ownerGeneration: incumbentGrant.ownerGeneration, - ownerLease: incumbentGrant.ownerLease - }) - ) - ) - await flushRequests() - - // Why generation 3: the rolled-back attempt consumed generation 2, but not the incumbent's lease. - expect(response(retryWrites, 3)!.result).toMatchObject({ - role: 'session-owner', - ownerGeneration: 3 - }) - expect(adapter.deliveryMode(1)).toBe('unadmitted') - expect(detached).toContain(1) - }) - - it('fences an old proof while its replacement is live', async () => { - const incumbentWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settle) => { - incumbentWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - dispatcher.feed(requestFrame(1, 'pty.openClient', ownerHelloParams())) - await flushRequests() - const incumbentGrant = response(incumbentWrites, 1)!.result as Record - const resume = { - ownerGeneration: incumbentGrant.ownerGeneration, - ownerLease: incumbentGrant.ownerLease - } - - let firstReconnectSettlement: ((result: SinkWriteSettlement) => void) | undefined - const firstReconnectWrites: Buffer[] = [] - const firstReconnectClientId = dispatcher.attachClient( - (data, settle) => { - firstReconnectWrites.push(Buffer.from(data)) - // Why ??=: the test settles the grant response, not whatever frame the dispatcher wrote last. - firstReconnectSettlement ??= settle - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - firstReconnectClientId, - requestFrame(2, 'pty.openClient', ownerHelloParams(resume)) - ) - await flushRequests() - - const retryWrites: Buffer[] = [] - const retryClientId = dispatcher.attachClient( - (data, settle) => { - retryWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - retryClientId, - requestFrame(3, 'pty.openClient', ownerHelloParams(resume)) - ) - await flushRequests() - - expect(response(retryWrites, 3)!.error).toMatchObject({ - code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR - }) - - firstReconnectSettlement!({ ok: true }) - dispatcher.feedClient( - retryClientId, - requestFrame(4, 'pty.openClient', ownerHelloParams(resume)) - ) - await flushRequests() - - expect(response(retryWrites, 4)!.error).toMatchObject({ - code: PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR - }) - expect(adapter.deliveryMode(firstReconnectClientId)).toBe('source-owner') - expect(adapter.deliveryMode(retryClientId)).toBe('unadmitted') - - dispatcher.detachClient(firstReconnectClientId) - dispatcher.feedClient( - retryClientId, - requestFrame(5, 'pty.openClient', ownerHelloParams(resume)) - ) - await flushRequests() - - expect(response(retryWrites, 5)!.result).toMatchObject({ - role: 'session-owner', - ownerGeneration: 3, - ownerLease: incumbentGrant.ownerLease - }) - expect(adapter.deliveryMode(retryClientId)).toBe('source-owner') - }) -}) diff --git a/src/relay/relay-pty-publication-admission.test.ts b/src/relay/relay-pty-publication-admission.test.ts deleted file mode 100644 index 3be0325e50b..00000000000 --- a/src/relay/relay-pty-publication-admission.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type RelayClientSinkOptions, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -type RpcMessage = { - id?: number - method?: string - params?: Record - result?: Record -} - -function message(buffer: Buffer): RpcMessage | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) -} - -function openFrame( - id: number, - requestedRole: 'session-owner' | 'subscriber', - outputFlowControl = false -): Buffer { - return encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id, - method: 'pty.openClient', - params: { - protocolVersion: 1, - clientInstanceId: `client-${id}`, - requestedRole, - ...(outputFlowControl - ? { - capabilities: { - outputFlowControl: { versions: [1], requestedWindowSu: 16 } - } - } - : {}) - } - }, - id, - 0 - ) -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -class SaturatedSink { - readonly writes: Buffer[] = [] - private readonly drainWaiters = new Set<() => void>() - private writableBytes = 0 - saturateNext = false - - readonly options: RelayClientSinkOptions = { - supportsWriteCallback: true, - writableLength: () => this.writableBytes, - writableHighWaterMark: () => 8 * 1024 * 1024, - waitWriteDrain: (callback) => { - this.drainWaiters.add(callback) - return () => this.drainWaiters.delete(callback) - } - } - - write = (data: Buffer, onSettled: (result: SinkWriteSettlement) => void): boolean => { - this.writes.push(Buffer.from(data)) - this.writableBytes += data.length - onSettled({ ok: true }) - if (!this.saturateNext) { - return true - } - this.saturateNext = false - return false - } - - drain(): void { - this.writableBytes = 0 - for (const callback of Array.from(this.drainWaiters)) { - callback() - } - } -} - -describe('relay PTY publication admission', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - }) - - it('retires queued legacy data when the source-credit grant response overtakes it', async () => { - const sink = new SaturatedSink() - dispatcher = new RelayDispatcher(sink.write, sink.options, endpointIdentity) - sink.saturateNext = true - dispatcher.notify('test.blocker') - const settled = vi.fn<(result: SinkWriteSettlement) => void>() - const legacyData = 'x'.repeat(1024 * 1024 + 128) - - expect(dispatcher.tryNotifyPtyDataToClient(1, { id: 'pty-1', data: legacyData }, settled)).toBe( - true - ) - expect(dispatcher.legacyRetentionBelowLowWater).toBe(false) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - expect(adapter.deliveryMode(1)).toBe('unadmitted') - dispatcher.feed(openFrame(1, 'session-owner', true)) - await flushRequests() - - sink.drain() - - expect(sink.writes.map(message).some((entry) => entry?.method === 'pty.data')).toBe(false) - expect(sink.writes.map(message).find((entry) => entry?.id === 1)?.result).toMatchObject({ - role: 'session-owner' - }) - expect(settled).toHaveBeenCalledOnce() - expect(settled.mock.calls[0][0]).toMatchObject({ ok: false }) - expect(dispatcher.legacyRetentionBelowLowWater).toBe(true) - }) - - it('does not publish legacy data to a source-credit owner before PTY attachment', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed(openFrame(1, 'session-owner', true)) - await flushRequests() - - expect(adapter.deliveryMode(1)).toBe('source-owner') - expect(dispatcher.tryNotifyPtyData({ id: 'pty-1', data: 'legacy' })).toBe(true) - - expect(writes.map(message).filter((entry) => entry?.method === 'pty.data')).toEqual([]) - }) - - it('publishes current source output to the admitted source-credit owner', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - const publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) - dispatcher.feed(openFrame(1, 'session-owner', true)) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - - expect( - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }) - ).toBe('opened') - activationSettlements[0]({ ok: true }) - expect(publication.publish('pty-1', { data: 'current' }, false)).toBe(true) - - const data = writes.map(message).find((entry) => entry?.method === 'pty.data')?.params - expect(data).toMatchObject({ - id: 'pty-1', - data: 'current', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1' - }) - expect(data?.deliveryToken).toEqual(expect.any(String)) - }) - - it('publishes legacy output to an admitted subscriber', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed(openFrame(1, 'subscriber')) - await flushRequests() - - expect(adapter.deliveryMode(1)).toBe('subscriber') - expect(dispatcher.tryNotifyPtyData({ id: 'pty-1', data: 'legacy' })).toBe(true) - - expect(writes.map(message).find((entry) => entry?.method === 'pty.data')?.params).toMatchObject( - { id: 'pty-1', data: 'legacy' } - ) - }) -}) diff --git a/src/relay/relay-pty-source-activation.ts b/src/relay/relay-pty-source-activation.ts deleted file mode 100644 index e8a48d13455..00000000000 --- a/src/relay/relay-pty-source-activation.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { - PtySourceRecoveryCheckpoint, - PtySourceRecoveryRequest, - PtySourceRecoveryResult -} from '../shared/pty-source-recovery-contract' -import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' -import type { - PtySourceDeliveryIdentity, - PtySourceDeliverySnapshot -} from '../shared/pty-source-credit-contract' -import type { RequestContext } from '../wsl-guest/dispatcher' -import type { - RelayPtySourceDeliveryRecord, - RelayPtySourceSendScheduler -} from './relay-pty-source-send-scheduler' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -// Takes the post-rotation snapshot: creditedEndSu is the accepted checkpoint and windowSu the -// reconnecting client's window. The pre-rotation snapshot would fence below the checkpoint. -export function boundedPtyRecoveryEnd( - snapshot: Pick -): number { - const { receivedEndSu, creditedEndSu, windowSu } = snapshot - // Oversized quarantine cannot earn credit; fence at the checkpoint and drain it live. - return receivedEndSu - creditedEndSu > windowSu ? creditedEndSu : receivedEndSu -} - -export function createPtySourceReceivingActivation( - identity: PtySourceDeliveryIdentity, - checkpointSourceEndSu: number, - recoveryEndSu: number -): PtySourceReceivingActivation { - return Object.freeze({ - status: 'pending', - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - ptyIncarnation: identity.ptyIncarnation, - deliveryToken: identity.deliveryToken, - checkpointSourceEndSu, - recoveryEndSu - }) -} - -export function registerPtySourceActivationSettlement(options: { - id: string - record: RelayPtySourceDeliveryRecord - context: RequestContext - deliveries: Map - session: SshPtyConsumerSessionAdapter - sender: RelayPtySourceSendScheduler - onCapacity: (id: string) => void -}): void { - const { id, record, context, deliveries, session, sender, onCapacity } = options - context.onResponseSettled!((result) => { - if (deliveries.get(id) !== record || !record.activating) { - return - } - if (!result.ok) { - if (!record.activationRecoveryRequest) { - session.cancelDelivery(record.identity, 'activation-publication-failed') - deliveries.delete(id) - } - return - } - record.activating = false - sender.completeRecoveryIfReady(record) - sender.pump(record) - onCapacity(id) - }) -} - -export function registerCanceledPtySourceRetirement( - record: RelayPtySourceDeliveryRecord, - context: RequestContext, - deliveries: Map, - onCapacity: (id: string) => void -): void { - record.recoveryCheckpointSourceEndSu = null - record.recoveryEndSu = null - context.onResponseSettled!(() => { - if (deliveries.get(record.identity.id) !== record) { - return - } - deliveries.delete(record.identity.id) - onCapacity(record.identity.id) - }) -} - -export function pendingPtySourceRecoveryResult( - record: RelayPtySourceDeliveryRecord -): PtySourceRecoveryResult { - if (record.recoveryCheckpointSourceEndSu === null || record.recoveryEndSu === null) { - return Object.freeze({ status: 'restoreRequired', reason: 'checkpointUnavailable' }) - } - return Object.freeze({ - status: 'pending', - clientGeneration: record.identity.clientGeneration, - ownerGeneration: record.identity.ownerGeneration, - ptyIncarnation: record.identity.ptyIncarnation, - deliveryToken: record.identity.deliveryToken, - checkpointSourceEndSu: record.recoveryCheckpointSourceEndSu, - recoveryEndSu: record.recoveryEndSu - }) -} - -export function samePtySourceRecoveryRequest( - expected: PtySourceRecoveryCheckpoint, - received: PtySourceRecoveryRequest | undefined -): boolean { - return ( - received?.status === 'checkpoint' && - received.deliveryToken === expected.deliveryToken && - received.clientGeneration === expected.clientGeneration && - received.ownerGeneration === expected.ownerGeneration && - received.ptyIncarnation === expected.ptyIncarnation && - received.acceptedSourceEndSu === expected.acceptedSourceEndSu - ) -} diff --git a/src/relay/relay-pty-source-cancellation-exit.test.ts b/src/relay/relay-pty-source-cancellation-exit.test.ts deleted file mode 100644 index d039f98baff..00000000000 --- a/src/relay/relay-pty-source-cancellation-exit.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { PTY_CONSUMER_OWNER_GRACE_MS } from '../shared/pty-consumer-session' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -type Notification = { method: string; params: Record } - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -function decode(buffer: Buffer): Record | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) -} - -function notification(buffer: Buffer): Notification | null { - const message = decode(buffer) - return typeof message?.method === 'string' && message.id === undefined - ? (message as Notification) - : null -} - -function responseResult(buffer: Buffer): Record | null { - const message = decode(buffer) - return !message || message.id === undefined - ? null - : ((message.result as Record) ?? null) -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('RelayPtySourcePublication cancellation and exit', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - }) - - async function createHarness(windowSu = 8, holdExitSettlement = false) { - const writes: Buffer[] = [] - const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const capacityIds: string[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - const frame = notification(data) - if (frame?.method === 'pty.exit') { - exitSettlements.push(onSettled) - if (holdExitSettlement) { - return true - } - } - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - let publication: RelayPtySourcePublication - let creditNoticeEnabled = true - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => { - if (creditNoticeEnabled) { - publication.onCreditAvailable(id) - } - }) - publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: windowSu } } - }) - ) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }) - ).toBe('opened') - activationSettlements[0]({ ok: true }) - return { - adapter, - publication, - exitSettlements, - capacityIds, - writes, - setCreditNoticeEnabled: (enabled: boolean): void => { - creditNoticeEnabled = enabled - } - } - } - - function activateOwner(publication: RelayPtySourcePublication): void { - const settlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => settlements.push(callback) - }) - ).toBe('opened') - settlements[0]({ ok: true }) - } - - function cancelDeliveryFrame( - requestId: number, - activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string } - ): Buffer { - return requestFrame(requestId, 'pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: activation.clientGeneration, - ownerGeneration: activation.ownerGeneration, - deliveryToken: activation.deliveryToken - }) - } - - function exitFrames(writes: Buffer[]): Notification[] { - return writes - .map(notification) - .filter((frame): frame is Notification => frame?.method === 'pty.exit') - } - - function ackFrame( - requestId: number, - activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string }, - creditedEndSu: number - ): Buffer { - return encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: activation.clientGeneration, - ownerGeneration: activation.ownerGeneration, - deliveryToken: activation.deliveryToken, - creditedEndSu - } - ] - } - }, - requestId, - 0 - ) - } - - it('retires the record when a client cancels, so the exit never seals a dead ledger', async () => { - const harness = await createHarness() - harness.publication.publish('pty-1', { data: 'data' }, false) - const activation = harness.publication.receivingActivation('pty-1', 1)! - - dispatcher!.feed(cancelDeliveryFrame(2, activation)) - await flushRequests() - - expect(harness.publication.accepts('pty-1')).toBe(false) - expect(harness.capacityIds).toContain('pty-1') - expect(() => - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(false) - ).not.toThrow() - }) - - it('retires the record when the reconnect grace expires', async () => { - vi.useFakeTimers({ toFake: ['setTimeout'] }) - try { - const harness = await createHarness() - harness.publication.publish('pty-1', { data: 'data' }, false) - - dispatcher!.invalidateClient() - vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) - - expect(harness.publication.accepts('pty-1')).toBe(false) - expect(harness.capacityIds).toContain('pty-1') - expect(() => - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(false) - ).not.toThrow() - } finally { - vi.useRealTimers() - } - }) - - it('opens a fresh delivery when the same client re-attaches after a cancel', async () => { - const harness = await createHarness() - harness.publication.publish('pty-1', { data: 'data' }, false) - const activation = harness.publication.receivingActivation('pty-1', 1)! - dispatcher!.feed(cancelDeliveryFrame(2, activation)) - await flushRequests() - - activateOwner(harness.publication) - - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(true) - expect(exitFrames(harness.writes)).toHaveLength(1) - }) - - it('reopens the delivery when the same client re-attaches over a closed ledger record', async () => { - const harness = await createHarness(8, true) - harness.publication.publish('pty-1', { data: 'data' }, false) - const activation = harness.publication.receivingActivation('pty-1', 1)! - dispatcher!.feed(ackFrame(2, activation, 4)) - harness.publication.sealAndPublishExit({ id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) - dispatcher!.feed(cancelDeliveryFrame(3, activation)) - await flushRequests() - harness.exitSettlements[0]({ ok: true }) - // Why: A' held the record across the in-flight frame, so it outlives the closed ledger until - // a capacity consumer prunes it — a re-attach must not resume that dead delivery. - expect(harness.publication.accepts('pty-1')).toBe(true) - - activateOwner(harness.publication) - - expect(harness.publication.publish('pty-1', { data: 'more' }, false)).toBe(true) - }) - - it('retires the record and defers capacity when an append lands on a closed delivery', async () => { - const harness = await createHarness() - harness.publication.publish('pty-1', { data: 'data' }, false) - const activation = harness.publication.receivingActivation('pty-1', 1)! - // Why: emulate a close the publication never saw — B3 backstops exactly the record/ledger - // disagreement that layer A's credit notification normally prevents. - harness.setCreditNoticeEnabled(false) - dispatcher!.feed(cancelDeliveryFrame(2, activation)) - await flushRequests() - expect(harness.publication.accepts('pty-1')).toBe(true) - const capacityBefore = harness.capacityIds.length - - expect(harness.publication.publish('pty-1', { data: 'more' }, false)).toBe(false) - - expect(harness.publication.accepts('pty-1')).toBe(false) - // Why: publish() can run inside the captured-queue drain, so a synchronous capacity callback - // would publish pty.exit ahead of the chunk that is re-queued later in this same tick. - expect(harness.capacityIds).toHaveLength(capacityBefore) - await Promise.resolve() - expect(harness.capacityIds.slice(capacityBefore)).toEqual(['pty-1']) - }) - - it('survives a cancel while the credit-mode exit frame is still in flight', async () => { - const harness = await createHarness(8, true) - harness.publication.publish('pty-1', { data: 'data' }, false) - const activation = harness.publication.receivingActivation('pty-1', 1)! - dispatcher!.feed(ackFrame(2, activation, 4)) - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(true) - expect(harness.exitSettlements).toHaveLength(1) - - dispatcher!.feed(cancelDeliveryFrame(3, activation)) - await flushRequests() - expect( - harness.writes.map(responseResult).filter((result) => result?.canceled === true) - ).toHaveLength(1) - // Why: A' keeps the record while the frame is in flight so no duplicate legacy exit escapes. - expect(harness.publication.accepts('pty-1')).toBe(true) - - expect(() => harness.exitSettlements[0]({ ok: true })).not.toThrow() - - expect(exitFrames(harness.writes)).toHaveLength(1) - expect(harness.capacityIds).toContain('pty-1') - expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) - expect(harness.publication.accepts('pty-1')).toBe(false) - }) - - it('retires the record after the in-flight exit frame fails on a canceled delivery', async () => { - const harness = await createHarness(8, true) - harness.publication.publish('pty-1', { data: 'data' }, false) - const activation = harness.publication.receivingActivation('pty-1', 1)! - dispatcher!.feed(ackFrame(2, activation, 4)) - harness.publication.sealAndPublishExit({ id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) - dispatcher!.feed(cancelDeliveryFrame(3, activation)) - await flushRequests() - const capacityBefore = harness.capacityIds.length - - expect(() => - harness.exitSettlements[0]({ ok: false, error: new Error('socket write failed') }) - ).not.toThrow() - // Why: the delivery is gone, so no credit event will arrive — D's widened finally must retry. - expect(harness.capacityIds.length).toBeGreaterThan(capacityBefore) - - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(true) - expect(harness.publication.accepts('pty-1')).toBe(false) - // Why: the failed write closed the owner socket, so B1's owner-targeted retry matches no - // client — it must still retire the record rather than fan the exit out to everyone else. - expect(exitFrames(harness.writes)).toHaveLength(1) - expect(harness.publication.getDebugSnapshot()).toMatchObject({ - exitCommitted: 0, - exitRolledBack: 1 - }) - }) - - it('publishes exactly one exit for a healthy completion even when the retry re-runs', async () => { - const harness = await createHarness() - - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(true) - expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) - expect(harness.publication.accepts('pty-1')).toBe(false) - - expect(() => - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(false) - ).not.toThrow() - expect(exitFrames(harness.writes)).toHaveLength(1) - }) - - it('forgets the legacy exit projection once the healthy exit settles', async () => { - const harness = await createHarness() - const exit = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' } - expect(harness.publication.sealAndPublishExit(exit)).toBe(true) - - expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) - - // Why: every client already holds this exit, so a retained index entry would both leak a row - // per exited pty and re-publish to the owner if any later fallback ran for the same id. - expect(harness.publication.publishExitAfterRetire(exit)).toBeNull() - expect(exitFrames(harness.writes)).toHaveLength(1) - }) - - it('re-targets the owner when a re-attach retires a record that already projected the exit', async () => { - const harness = await createHarness() - const exit = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' } - expect(harness.publication.sealAndPublishExit(exit)).toBe(true) - - // Why: the handler holds its settled-check back while legacy output is still buffered, so B2 - // can retire the record first — the index is then all that remembers the projection. - activateOwner(harness.publication) - - expect(harness.publication.publishExitAfterRetire(exit)).toBe(true) - expect(exitFrames(harness.writes)).toHaveLength(2) - expect(harness.publication.publishExitAfterRetire(exit)).toBeNull() - }) -}) diff --git a/src/relay/relay-pty-source-exit-publication.test.ts b/src/relay/relay-pty-source-exit-publication.test.ts deleted file mode 100644 index 8e01b4ddfa4..00000000000 --- a/src/relay/relay-pty-source-exit-publication.test.ts +++ /dev/null @@ -1,478 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - PtySourceDeliveryIdentity, - PtySourceDeliverySnapshot -} from '../shared/pty-source-credit-contract' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - RelayPtySourceLegacyExitIndex, - sealAndPublishPtySourceExit, - sealAndPublishTrackedPtySourceExit -} from './relay-pty-source-exit-publication' -import type { - RelayPtySourceDeliveryRecord, - RelayPtySourcePublicationCounters, - RelayPtySourceSendScheduler -} from './relay-pty-source-send-scheduler' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const identity: PtySourceDeliveryIdentity = Object.freeze({ - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1' -}) - -const params = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' } - -function deliveryRecord( - overrides: Partial = {} -): RelayPtySourceDeliveryRecord { - return { - clientId: 1, - identity, - sourceActivation: { - status: 'pending', - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - ptyIncarnation: identity.ptyIncarnation, - deliveryToken: identity.deliveryToken, - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }, - displayEnd: 0, - activating: false, - activationRecoveryRequest: null, - sealed: false, - legacyExitAccepted: false, - sourceExitState: 'idle', - sending: false, - turnFrames: 0, - turnSourceSu: 0, - turnScheduled: false, - sendWaiters: new Set(), - recoveryCheckpointSourceEndSu: null, - recoveryEndSu: null, - recoveryCompletionPending: false, - restoreRequired: false, - rotationPending: false, - ...overrides - } -} - -function closedSnapshot( - overrides: Partial = {} -): PtySourceDeliverySnapshot { - return Object.freeze({ - ...identity, - state: 'closed', - windowSu: 8, - receivedEndSu: 0, - sentEndSu: 0, - creditedEndSu: 0, - exitPublished: false, - generationClosed: false, - ...overrides - }) -} - -function createScenario( - initialProbe: PtySourceDeliverySnapshot | null, - record: RelayPtySourceDeliveryRecord, - notifyAccepted = true -) { - const deliveries = new Map([['pty-1', record]]) - let probe = initialProbe - const setProbe = (next: PtySourceDeliverySnapshot | null): void => { - probe = next - } - const session = { - sourceDeliverySnapshotIfKnown: vi.fn(() => probe), - sourceDeliverySnapshot: vi.fn(() => { - if (!probe) { - throw new Error('Unknown or stale PTY source delivery') - } - return probe - }), - cancelDelivery: vi.fn(), - sealDelivery: vi.fn(), - settleExitPublication: vi.fn(), - deliveryMode: vi.fn(() => 'source-owner' as const) - } - const dispatcher = { - tryNotifyPtyExit: vi.fn(() => notifyAccepted), - tryNotifyPtyExitToMatchingClients: vi.fn( - (_matches: (clientId: number) => boolean, _params: unknown) => notifyAccepted - ), - projectPtyExitToMatchingClients: vi.fn(() => true), - tryNotifyPtyExitToClient: vi.fn(() => true) - } - const sender = { pump: vi.fn(), wakeSendWaiters: vi.fn() } - const counters: RelayPtySourcePublicationCounters = { - opened: 0, - rotated: 0, - appendDenied: 0, - sendCommitted: 0, - sendRolledBack: 0, - exitCommitted: 0, - exitRolledBack: 0 - } - const capacityIds: string[] = [] - let capacityError: Error | null = null - const onCapacity = (id: string): void => { - capacityIds.push(id) - if (capacityError) { - throw capacityError - } - } - const run = (): boolean => - sealAndPublishPtySourceExit({ - params, - record, - deliveries, - dispatcher: dispatcher as unknown as RelayDispatcher, - session: session as unknown as SshPtyConsumerSessionAdapter, - sender: sender as unknown as RelayPtySourceSendScheduler, - counters, - onCapacity - }) - const runTracked = (legacyExits: RelayPtySourceLegacyExitIndex): boolean => - sealAndPublishTrackedPtySourceExit({ - params, - legacyExits, - deliveries, - dispatcher: dispatcher as unknown as RelayDispatcher, - session: session as unknown as SshPtyConsumerSessionAdapter, - sender: sender as unknown as RelayPtySourceSendScheduler, - counters, - onCapacity - }) - return { - deliveries, - session, - dispatcher, - sender, - counters, - capacityIds, - run, - runTracked, - setProbe, - setCapacityError: (error: Error): void => { - capacityError = error - } - } -} - -describe('sealAndPublishPtySourceExit closed-delivery guard', () => { - it.each([ - ['an unknown probe', null], - ['a closed probe', closedSnapshot()], - ['a closing probe', closedSnapshot({ state: 'closing' })] - ])('broadcasts a legacy exit and retires the record for %s', (_label, probe) => { - const record = deliveryRecord() - const scenario = createScenario(probe, record) - - expect(scenario.run()).toBe(true) - - expect(scenario.dispatcher.tryNotifyPtyExit).toHaveBeenCalledWith(params) - expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() - expect(scenario.session.sealDelivery).not.toHaveBeenCalled() - expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() - expect(scenario.sender.pump).not.toHaveBeenCalled() - expect(scenario.deliveries.has('pty-1')).toBe(false) - }) - - it('re-targets only source-owner clients once a legacy broadcast already landed', () => { - const record = deliveryRecord({ legacyExitAccepted: true }) - const scenario = createScenario(closedSnapshot(), record) - - expect(scenario.run()).toBe(true) - - expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() - const matches = scenario.dispatcher.tryNotifyPtyExitToMatchingClients.mock.calls[0][0] - expect(matches(1)).toBe(true) - scenario.session.deliveryMode.mockReturnValue('legacy-owner' as never) - expect(matches(1)).toBe(false) - expect(scenario.deliveries.has('pty-1')).toBe(false) - }) - - it('keeps the record retryable when the legacy notify is refused', () => { - const record = deliveryRecord() - const scenario = createScenario(closedSnapshot(), record, false) - - expect(scenario.run()).toBe(false) - - expect(scenario.deliveries.get('pty-1')).toBe(record) - }) - - it.each([ - ['the tombstone retains exitPublished', closedSnapshot({ exitPublished: true }), 'idle'], - ['the tombstone was evicted', null, 'published'] - ])('retires a healthily completed delivery silently when %s', (_label, probe, exitState) => { - const record = deliveryRecord({ - sourceExitState: exitState as RelayPtySourceDeliveryRecord['sourceExitState'] - }) - const scenario = createScenario(probe, record) - - expect(scenario.run()).toBe(true) - - expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() - expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() - expect(scenario.sender.pump).not.toHaveBeenCalled() - expect(scenario.deliveries.has('pty-1')).toBe(false) - }) - - it('defers to the in-flight exit frame settlement', () => { - const record = deliveryRecord({ sourceExitState: 'pending' }) - const scenario = createScenario(closedSnapshot(), record) - - expect(scenario.run()).toBe(false) - - expect(scenario.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() - expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() - expect(scenario.deliveries.get('pty-1')).toBe(record) - }) -}) - -describe('RelayPtySourceLegacyExitIndex', () => { - function createIndex(notifyAccepted = true) { - const index = new RelayPtySourceLegacyExitIndex() - const dispatcher = { - tryNotifyPtyExitToMatchingClients: vi.fn( - (_matches: (clientId: number) => boolean, _params: unknown) => notifyAccepted - ) - } - const session = { deliveryMode: vi.fn(() => 'source-owner' as const) } - const publish = (): boolean | null => - index.publishAfterRetire( - params, - dispatcher as unknown as RelayDispatcher, - session as unknown as SshPtyConsumerSessionAdapter - ) - return { index, dispatcher, session, publish } - } - - it('defers to the caller when nothing was projected for the pty', () => { - const scenario = createIndex() - - expect(scenario.publish()).toBeNull() - - expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() - }) - - it('re-targets the owner exactly once for a remembered projection', () => { - const scenario = createIndex() - scenario.index.remember(params, true) - - expect(scenario.publish()).toBe(true) - - const matches = scenario.dispatcher.tryNotifyPtyExitToMatchingClients.mock.calls[0][0] - expect(matches(1)).toBe(true) - scenario.session.deliveryMode.mockReturnValue('legacy-owner' as never) - expect(matches(1)).toBe(false) - // Why: the second pass is the handler's retry after a later capacity event; re-publishing - // would hand the owner a duplicate exit. - expect(scenario.publish()).toBeNull() - }) - - it('keeps the projection retryable when the owner notify is refused', () => { - const scenario = createIndex(false) - scenario.index.remember(params, true) - - expect(scenario.publish()).toBe(false) - expect(scenario.publish()).toBe(false) - }) - - it('consumes a completed retired exit without publishing it again', () => { - const scenario = createIndex() - scenario.index.remember(params, true) - scenario.index.complete(params) - - expect(scenario.publish()).toBe(true) - expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() - expect(scenario.publish()).toBeNull() - }) - - it('does not let stale completion overwrite a later incarnation', () => { - const scenario = createIndex() - const next = { ...params, incarnationId: 'incarnation-2' } - scenario.index.remember(next, true) - - scenario.index.complete(params) - - expect(scenario.publish()).toBeNull() - expect( - scenario.index.publishAfterRetire( - next, - scenario.dispatcher as unknown as RelayDispatcher, - scenario.session as unknown as SshPtyConsumerSessionAdapter - ) - ).toBe(true) - }) - - it('remembers a subscriber projection when the owner publication throws', () => { - const record = deliveryRecord({ sealed: true }) - const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record) - const index = new RelayPtySourceLegacyExitIndex() - scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation(() => { - throw new Error('owner write failed') - }) - - expect(() => scenario.runTracked(index)).toThrow('owner write failed') - expect(scenario.dispatcher.projectPtyExitToMatchingClients).toHaveBeenCalledOnce() - expect(scenario.session.cancelDelivery).toHaveBeenCalledWith( - record.identity, - 'exit-publication-failed' - ) - expect(scenario.deliveries.has(params.id)).toBe(false) - expect( - index.publishAfterRetire( - params, - scenario.dispatcher as unknown as RelayDispatcher, - scenario.session as unknown as SshPtyConsumerSessionAdapter - ) - ).toBe(true) - expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() - expect( - index.publishAfterRetire( - params, - scenario.dispatcher as unknown as RelayDispatcher, - scenario.session as unknown as SshPtyConsumerSessionAdapter - ) - ).toBeNull() - }) - - it.each([ - [ - 'a later incarnation reused the pty id', - (index: RelayPtySourceLegacyExitIndex) => - index.remember({ ...params, incarnationId: 'incarnation-2' }, true) - ], - [ - 'the projection was withdrawn', - (index: RelayPtySourceLegacyExitIndex) => index.remember(params, false) - ], - [ - 'the exit completed for every client', - (index: RelayPtySourceLegacyExitIndex) => index.forget(params.id) - ], - ['the publication was disposed', (index: RelayPtySourceLegacyExitIndex) => index.clear()] - ])('defers to the caller once %s', (_label, mutate) => { - const scenario = createIndex() - scenario.index.remember(params, true) - - mutate(scenario.index) - - expect(scenario.publish()).toBeNull() - }) -}) - -describe('sealAndPublishPtySourceExit settlement closure', () => { - function createInFlightScenario(settlementProbe: PtySourceDeliverySnapshot | null) { - const record = deliveryRecord({ sealed: true, legacyExitAccepted: true }) - const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record) - let settle: ((result: { ok: true } | { ok: false; error: Error }) => void) | undefined - scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation( - (...args: unknown[]): boolean => { - settle = args[2] as typeof settle - return true - } - ) - expect(scenario.run()).toBe(true) - scenario.setProbe(settlementProbe) - return { ...scenario, record, settle: settle! } - } - - it('skips the ledger settle and still resumes capacity when the delivery vanished', () => { - const scenario = createInFlightScenario(null) - - expect(() => scenario.settle({ ok: true })).not.toThrow() - - expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() - expect(scenario.record.sourceExitState).toBe('published') - expect(scenario.capacityIds).toEqual(['pty-1']) - }) - - it('resumes capacity on a failed settlement once the delivery is gone', () => { - const scenario = createInFlightScenario(closedSnapshot()) - - scenario.settle({ ok: false, error: new Error('socket write failed') }) - - expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() - expect(scenario.record.sourceExitState).toBe('idle') - expect(scenario.capacityIds).toEqual(['pty-1']) - }) - - it('retains committed completion until a failed capacity signal is retried', () => { - const record = deliveryRecord({ sealed: true }) - const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record) - const index = new RelayPtySourceLegacyExitIndex() - let settle: ((result: { ok: true }) => void) | undefined - scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation((...args: unknown[]) => { - settle = args[2] as typeof settle - return true - }) - scenario.session.settleExitPublication.mockImplementation(() => { - throw new Error('PTY source delivery is not sealed') - }) - scenario.setCapacityError(new Error('capacity callback failed')) - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - expect(scenario.runTracked(index)).toBe(true) - expect(() => settle!({ ok: true })).not.toThrow() - - expect( - index.publishAfterRetire( - params, - scenario.dispatcher as unknown as RelayDispatcher, - scenario.session as unknown as SshPtyConsumerSessionAdapter - ) - ).toBe(true) - expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() - expect( - index.publishAfterRetire( - params, - scenario.dispatcher as unknown as RelayDispatcher, - scenario.session as unknown as SshPtyConsumerSessionAdapter - ) - ).toBeNull() - } finally { - stderr.mockRestore() - } - }) - - it.each([ - ['committed', { ok: true } as const], - ['rolled back', { ok: false, error: new Error('socket write failed') } as const] - ])('logs, contains and resumes after a %s ledger settlement fault', (_label, result) => { - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - const scenario = createInFlightScenario(closedSnapshot({ state: 'sealed-unsettled' })) - scenario.record.sendWaiters.add(vi.fn()) - scenario.session.settleExitPublication.mockImplementation(() => { - throw new Error('PTY source delivery is not sealed') - }) - scenario.sender.wakeSendWaiters.mockImplementation(() => { - throw new Error('send waiter failed') - }) - scenario.setCapacityError(new Error('capacity callback failed')) - - expect(() => scenario.settle(result)).not.toThrow() - - expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( - '[pty-source-exit] exit settlement failed for pty-1' - ) - expect(scenario.capacityIds).toEqual(['pty-1']) - expect(scenario.session.cancelDelivery).toHaveBeenCalledWith( - scenario.record.identity, - 'exit-publication-failed' - ) - expect(scenario.deliveries.has(params.id)).toBe(false) - expect(scenario.record.sendWaiters.size).toBe(0) - } finally { - stderr.mockRestore() - } - }) -}) diff --git a/src/relay/relay-pty-source-exit-publication.ts b/src/relay/relay-pty-source-exit-publication.ts deleted file mode 100644 index 55249312299..00000000000 --- a/src/relay/relay-pty-source-exit-publication.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - onceSinkSettlement, - type RelayPtySourceDeliveryRecord, - type RelayPtySourcePublicationCounters, - type RelayPtySourceSendScheduler -} from './relay-pty-source-send-scheduler' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -export type PtyExitParams = { id: string; code: number; incarnationId: string } - -type PtySourceExitOptions = { - params: PtyExitParams - record: RelayPtySourceDeliveryRecord - deliveries: Map - legacyExits?: RelayPtySourceLegacyExitIndex - dispatcher: RelayDispatcher - session: SshPtyConsumerSessionAdapter - sender: RelayPtySourceSendScheduler - counters: RelayPtySourcePublicationCounters - onCapacity: (id: string) => void -} - -/** - * Remembers which exits already reached the legacy subscribers. `legacyExitAccepted` dies with - * the delivery record, but a cancel or grace expiry can retire the record between the legacy - * projection and the owner's exit frame, and the fallback must not broadcast a second copy. - */ -export class RelayPtySourceLegacyExitIndex { - private readonly stateByPty = new Map< - string, - Readonly<{ incarnationId: string; state: 'owner-pending' | 'complete' }> - >() - - remember(params: PtyExitParams, delivered: boolean): void { - if (delivered) { - this.stateByPty.set(params.id, { - incarnationId: params.incarnationId, - state: 'owner-pending' - }) - } else { - this.forget(params.id) - } - } - - complete(params: PtyExitParams): void { - if (this.stateByPty.get(params.id)?.incarnationId !== params.incarnationId) { - return - } - this.stateByPty.set(params.id, { incarnationId: params.incarnationId, state: 'complete' }) - } - - /** Drops the entry once the exit is complete for every client, so the map cannot grow unbounded. */ - forget(id: string): void { - this.stateByPty.delete(id) - } - - clear = (): void => this.stateByPty.clear() - - /** Publishes a retired record's exit to the owner alone, or null when nothing was projected. */ - publishAfterRetire( - params: PtyExitParams, - dispatcher: RelayDispatcher, - session: SshPtyConsumerSessionAdapter - ): boolean | null { - const remembered = this.stateByPty.get(params.id) - if (remembered?.incarnationId !== params.incarnationId) { - return null - } - if (remembered.state === 'complete') { - this.forget(params.id) - return true - } - const published = dispatcher.tryNotifyPtyExitToMatchingClients( - (clientId) => session.deliveryMode(clientId) === 'source-owner', - params - ) - if (published) { - this.forget(params.id) - } - return published - } -} - -function logExitSettlementFault(id: string, err: unknown): void { - process.stderr.write( - `[pty-source-exit] exit settlement failed for ${id}: ${ - err instanceof Error ? (err.stack ?? err.message) : String(err) - }\n` - ) -} - -function retireFaultedPtySourceExit( - options: PtySourceExitOptions, - record: RelayPtySourceDeliveryRecord -): void { - try { - options.session.cancelDelivery(record.identity, 'exit-publication-failed') - } catch (err) { - logExitSettlementFault(options.params.id, err) - } - try { - options.sender.wakeSendWaiters(record) - } catch (err) { - logExitSettlementFault(options.params.id, err) - } finally { - record.sendWaiters.clear() - } - if (options.deliveries.get(options.params.id) === record) { - options.deliveries.delete(options.params.id) - } -} - -/** Seals and publishes the pty's exit, recording whether the legacy projection outlives the record. */ -export function sealAndPublishTrackedPtySourceExit( - options: Omit & { legacyExits: RelayPtySourceLegacyExitIndex } -): boolean { - const record = options.deliveries.get(options.params.id) - if (!record) { - return false - } - try { - return sealAndPublishPtySourceExit({ ...options, record }) - } catch (err) { - retireFaultedPtySourceExit({ ...options, record }, record) - throw err - } -} - -export function sealAndPublishPtySourceExit(options: PtySourceExitOptions): boolean { - const { params, record, deliveries, dispatcher, session, sender, counters, onCapacity } = options - if (record.restoreRequired) { - const published = dispatcher.tryNotifyPtyExit(params) - if (published && deliveries.get(params.id) === record) { - deliveries.delete(params.id) - options.legacyExits?.forget(params.id) - } - return published - } - if (record.sourceExitState === 'pending') { - // Why: an exit frame is in flight; its settlement drives the next step. - return false - } - const probe = session.sourceDeliverySnapshotIfKnown(record.identity) - // Why: 'closing' is defensive — the ledger closes a canceled record in the same call, so it - // only ever hands back 'closed' (or null once the tombstone is evicted). - if (!probe || probe.state === 'closed' || probe.state === 'closing') { - if (probe?.exitPublished === true || record.sourceExitState === 'published') { - // Why: the delivery completed healthily — the owner already has the credit-mode exit. - if (deliveries.get(params.id) === record) { - deliveries.delete(params.id) - } - options.legacyExits?.forget(params.id) - return true - } - // Why: the delivery was canceled out from under the record; never touch the sealed - // ledger — the exit flows as a legacy broadcast instead. - const published = record.legacyExitAccepted - ? dispatcher.tryNotifyPtyExitToMatchingClients( - (clientId) => session.deliveryMode(clientId) === 'source-owner', - params - ) - : dispatcher.tryNotifyPtyExit(params) - if (published && deliveries.get(params.id) === record) { - deliveries.delete(params.id) - options.legacyExits?.forget(params.id) - } - return published - } - if (!record.sealed) { - session.sealDelivery(record.identity) - record.sealed = true - } - sender.pump(record) - const snapshot = session.sourceDeliverySnapshot(record.identity) - if (snapshot.sentEndSu !== snapshot.receivedEndSu) { - return false - } - if (!record.legacyExitAccepted) { - record.legacyExitAccepted = dispatcher.projectPtyExitToMatchingClients( - (clientId) => session.deliveryMode(clientId) !== 'source-owner', - params - ) - options.legacyExits?.remember(params, record.legacyExitAccepted) - if (!record.legacyExitAccepted) { - return false - } - } - if (record.sourceExitState !== 'idle') { - return true - } - record.sourceExitState = 'pending' - const settle = onceSinkSettlement((result) => { - if (result.ok) { - record.sourceExitState = 'published' - counters.exitCommitted++ - } else { - record.sourceExitState = 'idle' - counters.exitRolledBack++ - } - let deliveryGone = false - let settlementFailed = false - try { - // Why: a client cancel or rotation can close the delivery while this frame is in - // flight; settling a closed ledger entry throws out of a bare socket write/drain - // callback (dispatcher-client-writer releaseEntry) straight into uncaughtException. - deliveryGone = - session.sourceDeliverySnapshotIfKnown(record.identity)?.state !== 'sealed-unsettled' - if (!deliveryGone) { - session.settleExitPublication(record.identity, result) - } - } catch (err) { - settlementFailed = true - logExitSettlementFault(params.id, err) - if (result.ok) { - options.legacyExits?.complete(params) - } - retireFaultedPtySourceExit(options, record) - } finally { - if (result.ok || deliveryGone || settlementFailed) { - try { - // Why: capacity fans out into arbitrary handler code, still from the bare socket callback. - onCapacity(params.id) - } catch (err) { - logExitSettlementFault(params.id, err) - } - } - } - }) - const accepted = dispatcher.tryNotifyPtyExitToClient(record.clientId, params, settle) - if (!accepted && record.sourceExitState === 'pending') { - record.sourceExitState = 'idle' - } - return accepted -} diff --git a/src/relay/relay-pty-source-output.ts b/src/relay/relay-pty-source-output.ts deleted file mode 100644 index d9302734aeb..00000000000 --- a/src/relay/relay-pty-source-output.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { RelayPtySourceDeliveryRecord } from './relay-pty-source-send-scheduler' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -export type RelayPtySourceOutput = { - data: string - rawLength?: number - transformed?: boolean - seq?: number - sourceAccepted?: boolean - sourceSpanId?: string -} - -// Why: an evicted tombstone probes null, so an unknown delivery counts as closed. -export function ptySourceDeliveryClosed( - session: SshPtyConsumerSessionAdapter, - identity: PtySourceDeliveryIdentity -): boolean { - const state = session.sourceDeliverySnapshotIfKnown(identity)?.state - return !state || state === 'closed' || state === 'closing' -} - -export function appendPtySourceOutput( - session: SshPtyConsumerSessionAdapter, - record: RelayPtySourceDeliveryRecord, - output: RelayPtySourceOutput -): boolean { - const rawLength = output.rawLength ?? output.data.length - output.sourceSpanId ??= randomUUID() - try { - session.appendSource(record.identity, { - spanId: output.sourceSpanId, - data: output.data, - displayStart: record.displayEnd, - displayEnd: record.displayEnd + output.data.length, - splittable: output.transformed !== true, - transform: { - transformed: output.transformed === true, - rawLengthSu: rawLength, - scalarSafe: output.transformed !== true - } - }) - } catch { - return false - } - output.sourceAccepted = true - record.displayEnd += output.data.length - return true -} - -export function projectPtySourceOutputToLegacy( - dispatcher: RelayDispatcher, - session: SshPtyConsumerSessionAdapter, - id: string, - output: RelayPtySourceOutput, - interactive: boolean -): boolean { - return dispatcher.projectPtyDataToMatchingClients( - (clientId) => { - const mode = session.deliveryMode(clientId) - return mode === 'legacy-owner' || mode === 'subscriber' - }, - { - id, - data: output.data, - ...(output.seq === undefined ? {} : { seq: output.seq }), - ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}) - }, - { interactive } - ) -} diff --git a/src/relay/relay-pty-source-publication.test.ts b/src/relay/relay-pty-source-publication.test.ts deleted file mode 100644 index 6e9150f6a87..00000000000 --- a/src/relay/relay-pty-source-publication.test.ts +++ /dev/null @@ -1,790 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -type Notification = { method: string; params: Record } - -function notification(buffer: Buffer): Notification | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - const message = JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) - return typeof message.method === 'string' && message.id === undefined ? message : null -} - -function responseResult(buffer: Buffer): Record | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - const message = JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) - return message.id === undefined ? null : (message.result ?? null) -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('RelayPtySourcePublication', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - }) - - async function createHarness( - windowSu = 4, - settleSourceImmediately = true, - highWaterMark?: number, - holdExitSettlement = false - ) { - const writes: Buffer[] = [] - const sourceSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const capacityIds: string[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - const frame = notification(data) - if (frame?.method === 'pty.data' || frame?.method === 'pty.exit') { - sourceSettlements.push(onSettled) - if (frame.method === 'pty.exit') { - exitSettlements.push(onSettled) - } - if (settleSourceImmediately && !(holdExitSettlement && frame.method === 'pty.exit')) { - onSettled({ ok: true }) - } - } else { - onSettled({ ok: true }) - } - return true - }, - { - supportsWriteCallback: true, - ...(highWaterMark - ? { - writableLength: () => 0, - writableHighWaterMark: () => highWaterMark - } - : {}) - }, - endpointIdentity - ) - let publication: RelayPtySourcePublication - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: windowSu } } - }) - ) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }) - ).toBe('opened') - activationSettlements[0]({ ok: true }) - return { adapter, publication, sourceSettlements, exitSettlements, capacityIds, writes } - } - - it('commits only from writer settlement and resumes exactly after cumulative ACK', async () => { - const harness = await createHarness(4, false) - expect(harness.publication.publish('pty-1', { data: 'abcdefgh' }, false)).toBe(true) - expect(harness.publication.getDebugSnapshot()).toMatchObject({ - outstandingSourceUnits: 0, - sendCommitted: 0 - }) - - harness.sourceSettlements[0]({ ok: true }) - expect(harness.publication.getDebugSnapshot()).toMatchObject({ - outstandingSourceUnits: 4, - sendCommitted: 1 - }) - const first = harness.writes.map(notification).find((frame) => frame?.method === 'pty.data')! - dispatcher!.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: first.params.clientGeneration, - ownerGeneration: first.params.ownerGeneration, - deliveryToken: first.params.deliveryToken, - creditedEndSu: 4 - } - ] - } - }, - 2, - 0 - ) - ) - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(2) - await flushRequests() - }) - - it('settles a reentrant cumulative ACK only after the matching writer callback', async () => { - const harness = await createHarness(4, false) - expect(harness.publication.publish('pty-1', { data: 'abcdefgh' }, false)).toBe(true) - const first = harness.writes.map(notification).find((frame) => frame?.method === 'pty.data')! - - dispatcher!.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: first.params.clientGeneration, - ownerGeneration: first.params.ownerGeneration, - deliveryToken: first.params.deliveryToken, - creditedEndSu: 4 - } - ] - } - }, - 2, - 0 - ) - ) - expect(harness.publication.getDebugSnapshot()).toMatchObject({ - outstandingSourceUnits: 0, - sendCommitted: 0 - }) - - harness.sourceSettlements[0]({ ok: true }) - - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(2) - expect(harness.publication.getDebugSnapshot()).toMatchObject({ - outstandingSourceUnits: 0, - sendCommitted: 1 - }) - }) - - it('slices source frames to the encoded HWM-minus-reserve capacity', async () => { - const highWaterMark = 4096 - const harness = await createHarness(10_000, true, highWaterMark) - const payload = '\u0000'.repeat(4000) - - expect(harness.publication.publish('pty-1', { data: payload }, false)).toBe(true) - for (let turn = 0; turn < 4; turn++) { - await flushRequests() - } - - const frames = harness.writes.filter((buffer) => notification(buffer)?.method === 'pty.data') - expect(frames.length).toBeGreaterThan(1) - expect(Math.max(...frames.map((buffer) => buffer.length))).toBeLessThanOrEqual(3072) - expect( - frames - .map(notification) - .map((frame) => frame!.params.data) - .join('') - ).toBe(payload) - }) - - it('keeps mixed legacy and V1 clients on distinct frame authority', async () => { - const harness = await createHarness(8) - const legacyWrites: Buffer[] = [] - const legacyClientId = dispatcher!.attachClient( - (data, onSettled) => { - legacyWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher!.feedClient( - legacyClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'legacy-client', - requestedRole: 'subscriber' - }) - ) - await flushRequests() - - harness.publication.publish('pty-1', { data: 'data' }, false) - - const sourceFrame = harness.writes - .map(notification) - .find((frame) => frame?.method === 'pty.data')! - const oldGrant = harness.writes.map(responseResult).find((result) => result?.ownerLease)! - const legacyFrame = legacyWrites - .map(notification) - .find((frame) => frame?.method === 'pty.data')! - expect(sourceFrame.params).toMatchObject({ - data: 'data', - sourceEndSu: 4, - sourceLengthSu: 4 - }) - expect(legacyFrame.params).toEqual({ id: 'pty-1', data: 'data' }) - - dispatcher!.invalidateClient() - const replacementWrites: Buffer[] = [] - const replacementClientId = dispatcher!.attachClient( - (data, onSettled) => { - replacementWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher!.feedClient( - replacementClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: oldGrant.ownerGeneration, - ownerLease: oldGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - harness.publication.publish('pty-1', { data: 'next' }, false) - - expect( - replacementWrites.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(0) - expect( - legacyWrites.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(2) - }) - - it('detaches only a saturated subscriber while the V1 owner stays live', async () => { - const harness = await createHarness(8) - const detached: number[] = [] - const saturatedWrites: Buffer[] = [] - const healthyWrites: Buffer[] = [] - const heldSettlements: ((result: SinkWriteSettlement) => void)[] = [] - let saturateSubscriber = false - dispatcher!.onClientDetached((clientId) => detached.push(clientId)) - const saturatedId = dispatcher!.attachClient( - (data, onSettled) => { - saturatedWrites.push(Buffer.from(data)) - if (!saturateSubscriber) { - onSettled({ ok: true }) - return true - } - heldSettlements.push(onSettled) - return false - }, - { - supportsWriteCallback: true, - writableLength: () => 128 * 1024, - writableHighWaterMark: () => 4 * 1024 * 1024 - }, - endpointIdentity - ) - const healthyId = dispatcher!.attachClient( - (data, onSettled) => { - healthyWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher!.feedClient( - saturatedId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'saturated-subscriber', - requestedRole: 'subscriber' - }) - ) - dispatcher!.feedClient( - healthyId, - requestFrame(3, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'healthy-subscriber', - requestedRole: 'subscriber' - }) - ) - await flushRequests() - saturateSubscriber = true - const saturatedPayload = 's'.repeat(128 * 1024) - let admitted = 0 - while ( - dispatcher!.tryNotifyPtyDataToClient( - saturatedId, - { id: 'saturated', data: saturatedPayload }, - () => {} - ) - ) { - admitted++ - } - - expect(admitted).toBeGreaterThan(0) - expect(admitted).toBeLessThan(20) - expect(harness.publication.publish('pty-1', { data: saturatedPayload }, false)).toBe(true) - - expect(detached).toEqual([saturatedId]) - expect(detached).not.toContain(healthyId) - expect( - saturatedWrites.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - expect(heldSettlements).toHaveLength(1) - expect( - healthyWrites.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - expect(harness.publication.getDebugSnapshot()).toMatchObject({ sendCommitted: 1 }) - }) - - it('keeps an early ACK retryable when its source write fails', async () => { - const harness = await createHarness(4, false) - harness.publication.publish('pty-1', { data: 'abcdefgh' }, false) - const first = harness.writes.map(notification).find((frame) => frame?.method === 'pty.data')! - dispatcher!.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: first.params.clientGeneration, - ownerGeneration: first.params.ownerGeneration, - deliveryToken: first.params.deliveryToken, - creditedEndSu: 4 - } - ] - } - }, - 2, - 0 - ) - ) - harness.sourceSettlements[0]({ ok: false, error: new Error('socket write failed') }) - - expect(harness.publication.getDebugSnapshot()).toMatchObject({ - outstandingSourceUnits: 0, - sendCommitted: 0, - sendRolledBack: 1 - }) - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - expect(harness.adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1, sourceSu: 8 }) - }) - - it('fences idle publication and pumping before the wait continuation', async () => { - const harness = await createHarness(4) - harness.publication.publish('pty-1', { data: 'abcdefgh' }, false) - const firstData = harness.writes - .map(notification) - .find((frame) => frame?.method === 'pty.data')! - const fence = harness.publication.waitForPendingSend('pty-1') - - expect(harness.publication.publish('pty-1', { data: 'ijkl' }, false)).toBe(false) - dispatcher!.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: firstData.params.clientGeneration, - ownerGeneration: firstData.params.ownerGeneration, - deliveryToken: firstData.params.deliveryToken, - creditedEndSu: 4 - } - ] - } - }, - 2, - 0 - ) - ) - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - expect(await fence).toBe(true) - - expect( - harness.publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: () => {} - }) - ).toBe('existing') - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(2) - await flushRequests() - }) - - it('does not pump an old-token suffix after a successful fenced settlement', async () => { - const harness = await createHarness(4, false) - harness.publication.publish('pty-1', { data: 'abcdefgh' }, false) - const oldData = harness.writes.map(notification).find((frame) => frame?.method === 'pty.data')! - const oldGrant = harness.writes.map(responseResult).find((result) => result?.ownerLease)! - const fence = harness.publication.waitForPendingSend('pty-1') - - harness.sourceSettlements[0]({ ok: true }) - expect(await fence).toBe(true) - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - dispatcher!.invalidateClient() - - const recoveredWrites: Buffer[] = [] - const recoveredClientId = dispatcher!.attachClient( - (data, onSettled) => { - recoveredWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher!.feedClient( - recoveredClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: oldGrant.ownerGeneration, - ownerLease: oldGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - harness.publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }, - { - status: 'checkpoint', - deliveryToken: String(oldData.params.deliveryToken), - clientGeneration: Number(oldData.params.clientGeneration), - ownerGeneration: Number(oldData.params.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 4 - } - ) - ).toMatchObject({ status: 'pending', checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - activationSettlements[0]({ ok: true }) - - const replacementData = recoveredWrites - .map(notification) - .find((frame) => frame?.method === 'pty.data')! - expect(replacementData.params).toMatchObject({ data: 'efgh', sourceEndSu: 8 }) - expect(replacementData.params.deliveryToken).not.toBe(oldData.params.deliveryToken) - expect( - harness.writes.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(1) - }) - - it('recovers a receiver-accepted frame after detach rolls back its send', async () => { - const harness = await createHarness(4, false) - harness.publication.publish('pty-1', { data: 'data' }, false) - const oldData = harness.writes.map(notification).find((frame) => frame?.method === 'pty.data')! - const oldGrant = harness.writes.map(responseResult).find((result) => result?.ownerLease)! - let fenceSettled = false - const fence = harness.publication.waitForPendingSend('pty-1').then((result) => { - fenceSettled = true - return result - }) - await Promise.resolve() - expect(fenceSettled).toBe(false) - - dispatcher!.invalidateClient() - expect(await fence).toBe(true) - expect(harness.publication.getDebugSnapshot()).toMatchObject({ - sendCommitted: 0, - sendRolledBack: 1 - }) - - const recoveredWrites: Buffer[] = [] - const recoveredClientId = dispatcher!.attachClient( - (data, onSettled) => { - recoveredWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher!.feedClient( - recoveredClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: oldGrant.ownerGeneration, - ownerLease: oldGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const activation = harness.publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }, - { - status: 'checkpoint', - deliveryToken: String(oldData.params.deliveryToken), - clientGeneration: Number(oldData.params.clientGeneration), - ownerGeneration: Number(oldData.params.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 4 - } - ) - expect(activation).toMatchObject({ - status: 'pending', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - activationSettlements[0]({ ok: true }) - expect( - recoveredWrites.map(notification).filter((frame) => frame?.method === 'pty.data') - ).toHaveLength(0) - expect( - recoveredWrites.map(notification).find((frame) => frame?.method === 'pty.recoveryComplete') - ?.params - ).toMatchObject({ checkpointSourceEndSu: 4, recoveryEndSu: 4 }) - const beforeLateSettlement = harness.publication.getDebugSnapshot() - - harness.sourceSettlements[0]({ ok: true }) - - expect(harness.publication.getDebugSnapshot()).toEqual(beforeLateSettlement) - }) - - it('publishes exit only after preceding source data settles', async () => { - const harness = await createHarness(4, false) - harness.publication.publish('pty-1', { data: 'data' }, false) - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(false) - - harness.sourceSettlements[0]({ ok: true }) - expect( - harness.publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(true) - expect( - harness.writes - .map(notification) - .filter((frame): frame is NonNullable => frame !== null) - .map((frame) => frame.method) - ).toEqual(['pty.data', 'pty.exit']) - }) - - it('settles the recovery fence before sending buffered live output', async () => { - const harness = await createHarness(4) - harness.publication.publish('pty-1', { data: 'abcdefgh' }, false) - const firstData = harness.writes - .map(notification) - .find((frame) => frame?.method === 'pty.data')! - const firstGrant = harness.writes.map(responseResult).find((result) => result?.ownerLease)! - dispatcher!.invalidateClient() - - const recoveredWrites: Buffer[] = [] - const recoverySettlements: ((result: SinkWriteSettlement) => void)[] = [] - const completionSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const recoveredClientId = dispatcher!.attachClient( - (data, onSettled) => { - recoveredWrites.push(Buffer.from(data)) - const method = notification(data)?.method - if (method === 'pty.data') { - recoverySettlements.push(onSettled) - } else if (method === 'pty.recoveryComplete') { - completionSettlements.push(onSettled) - } else { - onSettled({ ok: true }) - } - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher!.feedClient( - recoveredClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: firstGrant.ownerGeneration, - ownerLease: firstGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const activation = harness.publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }, - { - status: 'checkpoint', - deliveryToken: String(firstData.params.deliveryToken), - clientGeneration: Number(firstData.params.clientGeneration), - ownerGeneration: Number(firstData.params.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 4 - } - ) - expect(activation).toMatchObject({ - status: 'pending', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }) - activationSettlements[0]({ ok: true }) - expect(harness.publication.publish('pty-1', { data: 'ijkl' }, false)).toBe(false) - - expect( - recoveredWrites.map(notification).filter((frame) => frame?.method === 'pty.recoveryComplete') - ).toHaveLength(0) - const recoveredData = recoveredWrites - .map(notification) - .find((frame) => frame?.method === 'pty.data')! - harness.adapter.appendSource( - { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: Number(recoveredData.params.clientGeneration), - ownerGeneration: Number(recoveredData.params.ownerGeneration), - ptyIncarnation: String(recoveredData.params.ptyIncarnation), - deliveryToken: String(recoveredData.params.deliveryToken) - }, - { - spanId: 'buffered-live', - data: 'ijkl', - displayStart: 8, - displayEnd: 12, - splittable: true, - transform: { transformed: false, rawLengthSu: 4, scalarSafe: true } - } - ) - recoverySettlements[0]({ ok: true }) - expect( - recoveredWrites - .map(notification) - .filter((frame): frame is NonNullable => frame !== null) - .map((frame) => frame.method) - ).toEqual(['pty.deliveryCanceled', 'pty.data', 'pty.recoveryComplete']) - dispatcher!.feedClient( - recoveredClientId, - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: recoveredData.params.clientGeneration, - ownerGeneration: recoveredData.params.ownerGeneration, - deliveryToken: recoveredData.params.deliveryToken, - creditedEndSu: 8 - } - ] - } - }, - 3, - 0 - ) - ) - expect(harness.publication.publish('pty-1', { data: 'ijkl' }, false)).toBe(false) - expect( - recoveredWrites - .map(notification) - .filter((frame): frame is NonNullable => frame !== null) - .map((frame) => frame.method) - ).toEqual(['pty.deliveryCanceled', 'pty.data', 'pty.recoveryComplete']) - - completionSettlements[0]({ ok: true }) - expect( - recoveredWrites - .map(notification) - .filter((frame): frame is NonNullable => frame !== null) - .map((frame) => frame.method) - ).toEqual(['pty.deliveryCanceled', 'pty.data', 'pty.recoveryComplete', 'pty.data']) - expect(recoveredWrites.map(notification).at(-1)?.params).toMatchObject({ - data: 'ijkl', - sourceEndSu: 12 - }) - await flushRequests() - }) -}) diff --git a/src/relay/relay-pty-source-publication.ts b/src/relay/relay-pty-source-publication.ts deleted file mode 100644 index b62a718a086..00000000000 --- a/src/relay/relay-pty-source-publication.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' -import type { - PtySourceRecoveryRequest, - PtySourceRecoveryResult -} from '../shared/pty-source-recovery-contract' -import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' -import { - createPtySourceReceivingActivation, - pendingPtySourceRecoveryResult, - boundedPtyRecoveryEnd, - registerCanceledPtySourceRetirement, - registerPtySourceActivationSettlement, - samePtySourceRecoveryRequest -} from './relay-pty-source-activation' -import { - RelayPtySourceSendScheduler, - type RelayPtySourceDeliveryRecord, - type RelayPtySourcePublicationCounters -} from './relay-pty-source-send-scheduler' -import { - RelayPtySourceLegacyExitIndex, - sealAndPublishTrackedPtySourceExit, - type PtyExitParams -} from './relay-pty-source-exit-publication' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { - appendPtySourceOutput, - projectPtySourceOutputToLegacy, - ptySourceDeliveryClosed, - type RelayPtySourceOutput -} from './relay-pty-source-output' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -export class RelayPtySourcePublication { - private readonly deliveries = new Map() - private readonly legacyExits = new RelayPtySourceLegacyExitIndex() - private readonly counters: RelayPtySourcePublicationCounters = { - opened: 0, - rotated: 0, - appendDenied: 0, - sendCommitted: 0, - sendRolledBack: 0, - exitCommitted: 0, - exitRolledBack: 0 - } - - constructor( - private readonly dispatcher: RelayDispatcher, - private readonly session: SshPtyConsumerSessionAdapter, - private readonly onCapacity: (id: string) => void - ) { - this.sender = new RelayPtySourceSendScheduler( - dispatcher, - session, - this.deliveries, - this.counters, - onCapacity - ) - } - - private readonly sender: RelayPtySourceSendScheduler - - activate( - id: string, - ptyIncarnation: string, - context: RequestContext | undefined, - recovery?: PtySourceRecoveryRequest - ): false | 'opened' | 'rotated' | 'existing' | PtySourceRecoveryResult { - let current = this.deliveries.get(id) - // Only release this caller's delivery; its replacement may still be rotating. - const owned = current?.clientId === context?.clientId ? current : undefined - if (!context?.onResponseSettled) { - this.sender.releaseRotationFence(owned) - return false - } - const mode = this.session.deliveryMode(context.clientId) - if (mode === 'unadmitted' || mode === 'subscriber') { - this.sender.releaseRotationFence(owned) - return false - } - if (mode === 'legacy-owner') { - if (owned) { - this.session.cancelDelivery(owned.identity, 'source-credit-disabled') - this.sender.wakeSendWaiters(owned) - this.deliveries.delete(id) - this.onCapacity(id) - } - return false - } - if ( - current?.clientId === context.clientId && - !current.restoreRequired && - current.sourceExitState !== 'pending' && - ptySourceDeliveryClosed(this.session, current.identity) - ) { - // Why: a canceled delivery can never resume as 'existing'; retire it so re-attach opens fresh. - this.sender.wakeSendWaiters(current) - this.deliveries.delete(id) - this.onCapacity(id) - current = undefined - } - if (current?.clientId === context.clientId) { - this.sender.releaseRotationFence(current) - if (current.activating && current.activationRecoveryRequest) { - if (!samePtySourceRecoveryRequest(current.activationRecoveryRequest, recovery)) { - return this.publishRestoreRequired(id, context, 'checkpointUnavailable') - } - this.registerActivationSettlement(id, current, context) - return pendingPtySourceRecoveryResult(current) - } - return 'existing' - } - let identity: PtySourceDeliveryIdentity | null = null - let displayEnd = 0 - let recoveryCheckpointSourceEndSu: number | null = null - let recoveryEndSu: number | null = null - let recoveryWasSealed = false - if (!current && recovery) { - return this.publishRestoreRequired(id, context, 'deliveryUnavailable') - } - if (current) { - try { - const snapshot = this.session.sourceDeliverySnapshot(current.identity) - if ( - snapshot.state === 'closed' || - snapshot.state === 'closing' || - recovery?.status !== 'checkpoint' || - recovery.deliveryToken !== current.identity.deliveryToken || - recovery.clientGeneration !== current.identity.clientGeneration || - recovery.ownerGeneration !== current.identity.ownerGeneration || - recovery.ptyIncarnation !== current.identity.ptyIncarnation - ) { - return this.requireRestore(id, current, context, 'checkpointUnavailable') - } - const rotation = this.session.rotateDelivery( - current.identity, - context.clientId, - recovery.acceptedSourceEndSu - ) - identity = rotation.identity - displayEnd = current.displayEnd - recoveryCheckpointSourceEndSu = recovery.acceptedSourceEndSu - recoveryEndSu = boundedPtyRecoveryEnd(this.session.sourceDeliverySnapshot(identity)) - recoveryWasSealed = snapshot.state === 'sealed-unsettled' - this.counters.rotated++ - } catch (error) { - return this.requireRestore( - id, - current, - context, - error instanceof Error ? error.message : 'invalidCheckpoint' - ) - } - } - identity ??= this.session.openDelivery(context.clientId, id, ptyIncarnation) - if (!identity) { - return false - } - if (!current || identity !== current.identity) { - this.counters.opened++ - } - const activationSnapshot = this.session.sourceDeliverySnapshot(identity) - const activationCheckpointSourceEndSu = - recoveryCheckpointSourceEndSu ?? activationSnapshot.sentEndSu - const activationRecoveryEndSu = recoveryEndSu ?? activationSnapshot.receivedEndSu - const record: RelayPtySourceDeliveryRecord = { - clientId: context.clientId, - identity, - sourceActivation: createPtySourceReceivingActivation( - identity, - activationCheckpointSourceEndSu, - activationRecoveryEndSu - ), - displayEnd, - activating: true, - activationRecoveryRequest: - recovery?.status === 'checkpoint' ? Object.freeze({ ...recovery }) : null, - sealed: recoveryWasSealed, - legacyExitAccepted: false, - sourceExitState: 'idle', - sending: false, - turnFrames: 0, - turnSourceSu: 0, - turnScheduled: false, - sendWaiters: new Set(), - recoveryCheckpointSourceEndSu, - recoveryEndSu, - recoveryCompletionPending: false, - restoreRequired: false, - rotationPending: false - } - this.deliveries.set(id, record) - this.registerActivationSettlement(id, record, context) - if (recoveryEndSu !== null && recoveryCheckpointSourceEndSu !== null) { - return pendingPtySourceRecoveryResult(record) - } - return current ? 'rotated' : 'opened' - } - - accepts = (id: string): boolean => this.deliveries.has(id) - - receivingActivation(id: string, clientId: number): PtySourceReceivingActivation | undefined { - const record = this.deliveries.get(id) - return record?.clientId === clientId && !record.restoreRequired - ? record.sourceActivation - : undefined - } - - waitForPendingSend = (id: string, timeoutMs = 5_000): Promise => - this.sender.waitForPendingSend(id, timeoutMs) - - publish(id: string, output: RelayPtySourceOutput, interactive: boolean): boolean { - const record = this.deliveries.get(id) - if ( - !record || - record.sealed || - record.recoveryEndSu !== null || - record.restoreRequired || - record.rotationPending - ) { - return false - } - if (!output.sourceAccepted && !appendPtySourceOutput(this.session, record, output)) { - this.counters.appendDenied++ - if (ptySourceDeliveryClosed(this.session, record.identity)) { - this.sender.wakeSendWaiters(record) - this.deliveries.delete(id) - // Why: deferred — publish() can run inside flushPendingOutput's captured-queue drain, - // where pendingOutputByPty is transiently empty; a synchronous capacity callback would - // publish pty.exit ahead of still-buffered output. By microtask time the failed chunk - // has been re-queued (flushPtyOutput re-sets the queue synchronously on failure). - queueMicrotask(() => this.onCapacity(id)) - return false - } - return false - } - if (!projectPtySourceOutputToLegacy(this.dispatcher, this.session, id, output, interactive)) { - return false - } - this.sender.pump(record) - return true - } - - sealAndPublishExit = (params: PtyExitParams): boolean => - sealAndPublishTrackedPtySourceExit({ - params, - legacyExits: this.legacyExits, - deliveries: this.deliveries, - dispatcher: this.dispatcher, - session: this.session, - sender: this.sender, - counters: this.counters, - onCapacity: this.onCapacity - }) - - /** Returns null when the caller should fall back to its own legacy exit broadcast. */ - publishExitAfterRetire = (params: PtyExitParams): boolean | null => - this.legacyExits.publishAfterRetire(params, this.dispatcher, this.session) - - onCreditAvailable = (id: string): void => this.sender.onCreditAvailable(id) - - exitPublicationSettled(id: string): boolean { - const record = this.deliveries.get(id) - if (!record || record.sourceExitState !== 'published') { - return false - } - // Why: owner and legacy subscribers both hold this exit now, so the index row would otherwise - // outlive the pty for the daemon's lifetime and re-publish on any later fallback. - this.legacyExits.forget(id) - this.sender.pruneClosed(id, record) - return true - } - - getDebugSnapshot = () => this.sender.getDebugSnapshot() - - dispose = (): void => { - this.legacyExits.clear() - this.sender.dispose() - } - - private registerActivationSettlement( - id: string, - record: RelayPtySourceDeliveryRecord, - context: RequestContext - ): void { - registerPtySourceActivationSettlement({ - id, - record, - context, - deliveries: this.deliveries, - session: this.session, - sender: this.sender, - onCapacity: this.onCapacity - }) - } - - private requireRestore( - id: string, - current: RelayPtySourceDeliveryRecord, - context: RequestContext, - reason: string - ): Readonly<{ status: 'restoreRequired'; reason: string }> { - this.session.cancelDelivery(current.identity, `recovery-${reason}`) - current.restoreRequired = true - current.activating = false - this.sender.wakeSendWaiters(current) - registerCanceledPtySourceRetirement(current, context, this.deliveries, this.onCapacity) - return this.publishRestoreRequired(id, context, reason) - } - - private publishRestoreRequired( - id: string, - context: RequestContext, - reason: string - ): Readonly<{ status: 'restoreRequired'; reason: string }> { - const result = Object.freeze({ status: 'restoreRequired' as const, reason }) - context.onResponseSettled?.((settlement) => { - if (settlement.ok) { - this.dispatcher.notifyClient(context.clientId, 'pty.restoreRequired', { id, reason }) - } - }) - this.onCapacity(id) - return result - } -} diff --git a/src/relay/relay-pty-source-recovery-completion.test.ts b/src/relay/relay-pty-source-recovery-completion.test.ts deleted file mode 100644 index d46cd32f09c..00000000000 --- a/src/relay/relay-pty-source-recovery-completion.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { expect, it } from 'vitest' -import type { RelayDispatcher, SinkWriteSettlement } from '../wsl-guest/dispatcher' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { - RelayPtySourceSendScheduler, - type RelayPtySourceDeliveryRecord -} from './relay-pty-source-send-scheduler' - -it('retries an unadmitted recovery completion once capacity returns', () => { - let capacityListener = () => {} - let listenerRemoved = false - let admissions = 0 - const completionSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = { - onLegacyPtyCapacity(listener: () => void) { - capacityListener = listener - return () => { - listenerRemoved = true - } - }, - tryNotifyClient( - _clientId: number, - _method: string, - _params: Record, - onSettled: (result: SinkWriteSettlement) => void - ) { - admissions++ - if (admissions === 1) { - return false - } - completionSettlements.push(onSettled) - return true - }, - producerDataBudget: () => 1024, - tryNotifyPtyDataToClient: () => false - } as unknown as RelayDispatcher - const session = { - sourceDeliverySnapshot: () => ({ sentEndSu: 4, state: 'active' }), - reserveSourceSend: () => null, - cancelDelivery: () => {} - } as unknown as SshPtyConsumerSessionAdapter - const record: RelayPtySourceDeliveryRecord = { - clientId: 1, - identity: { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1' - }, - sourceActivation: { - status: 'pending', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - checkpointSourceEndSu: 0, - recoveryEndSu: 4 - }, - displayEnd: 4, - activating: false, - activationRecoveryRequest: null, - sealed: false, - legacyExitAccepted: false, - sourceExitState: 'idle', - sending: false, - turnFrames: 0, - turnSourceSu: 0, - turnScheduled: false, - sendWaiters: new Set(), - recoveryCheckpointSourceEndSu: 0, - recoveryEndSu: 4, - recoveryCompletionPending: false, - restoreRequired: false, - rotationPending: false - } - const deliveries = new Map([['pty-1', record]]) - let capacityCalls = 0 - const scheduler = new RelayPtySourceSendScheduler( - dispatcher, - session, - deliveries, - { - opened: 0, - rotated: 0, - appendDenied: 0, - sendCommitted: 0, - sendRolledBack: 0, - exitCommitted: 0, - exitRolledBack: 0 - }, - () => { - capacityCalls++ - } - ) - - scheduler.completeRecoveryIfReady(record) - expect(admissions).toBe(1) - expect(record.recoveryCompletionPending).toBe(false) - expect(record.recoveryEndSu).toBe(4) - - capacityListener() - capacityListener() - expect(admissions).toBe(2) - expect(completionSettlements).toHaveLength(1) - expect(record.recoveryCompletionPending).toBe(true) - - completionSettlements[0]({ ok: true }) - capacityListener() - expect(admissions).toBe(2) - expect(record.recoveryEndSu).toBeNull() - expect(capacityCalls).toBe(1) - - scheduler.dispose() - expect(listenerRemoved).toBe(true) -}) diff --git a/src/relay/relay-pty-source-recovery-completion.ts b/src/relay/relay-pty-source-recovery-completion.ts deleted file mode 100644 index e5fc2a32f31..00000000000 --- a/src/relay/relay-pty-source-recovery-completion.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { RelayDispatcher, SinkWriteSettlement } from '../wsl-guest/dispatcher' -import type { RelayPtySourceDeliveryRecord } from './relay-pty-source-send-scheduler' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -function onceSettlement( - callback: (result: SinkWriteSettlement) => void -): (result: SinkWriteSettlement) => void { - let settled = false - return (result) => { - if (settled) { - return - } - settled = true - callback(result) - } -} - -export function completePtySourceRecovery(options: { - record: RelayPtySourceDeliveryRecord - deliveries: Map - dispatcher: RelayDispatcher - session: SshPtyConsumerSessionAdapter - onCompleted: (id: string) => void -}): void { - const { record, deliveries, dispatcher, session, onCompleted } = options - const recoveryEndSu = record.recoveryEndSu - const checkpointSourceEndSu = record.recoveryCheckpointSourceEndSu - if ( - record.activating || - record.sending || - record.recoveryCompletionPending || - recoveryEndSu === null || - checkpointSourceEndSu === null || - session.sourceDeliverySnapshot(record.identity).sentEndSu < recoveryEndSu - ) { - return - } - record.recoveryCompletionPending = true - const settle = onceSettlement((result) => { - if (deliveries.get(record.identity.id) !== record) { - return - } - record.recoveryCompletionPending = false - if (!result.ok) { - return - } - record.recoveryEndSu = null - record.recoveryCheckpointSourceEndSu = null - onCompleted(record.identity.id) - }) - const accepted = dispatcher.tryNotifyClient( - record.clientId, - 'pty.recoveryComplete', - { - id: record.identity.id, - clientGeneration: record.identity.clientGeneration, - ownerGeneration: record.identity.ownerGeneration, - ptyIncarnation: record.identity.ptyIncarnation, - deliveryToken: record.identity.deliveryToken, - checkpointSourceEndSu, - recoveryEndSu - }, - settle - ) - if (!accepted) { - settle({ ok: false, error: new Error('PTY recovery completion was not admitted') }) - } -} diff --git a/src/relay/relay-pty-source-recovery-interleavings.test.ts b/src/relay/relay-pty-source-recovery-interleavings.test.ts deleted file mode 100644 index fc7588f507b..00000000000 --- a/src/relay/relay-pty-source-recovery-interleavings.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' -import type { PtySourceRecoveryRequest } from '../shared/pty-source-recovery-contract' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function deliveryIdentity( - deliveryToken: string, - overrides: Partial = {} -): PtySourceDeliveryIdentity { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken, - ...overrides - } -} - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -function message(buffer: Buffer): Record | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) -} - -function responseResult( - writes: readonly Buffer[], - id: number -): Record | undefined { - const response = writes.map(message).find((entry) => entry?.id === id) - return response?.result as Record | undefined -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('relay PTY source recovery interleavings', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - }) - - it('retains failed exit delivery for a late ACK and exact sealed recovery', () => { - const ledger = new RelayPtySourceCreditLedger() - const oldIdentity = deliveryIdentity('old-token') - const replacement = deliveryIdentity('replacement-token', { - clientGeneration: 2, - ownerGeneration: 2 - }) - ledger.open(oldIdentity, 4) - ledger.append(oldIdentity, { - spanId: 'tail', - data: 'tail', - displayStart: 0, - displayEnd: 4, - splittable: true, - transform: { transformed: false, rawLengthSu: 4, scalarSafe: true } - }) - const send = ledger.reserveNextSend(oldIdentity)! - ledger.commitSend(send) - ledger.seal(oldIdentity) - - ledger.settleExitPublication(oldIdentity, { - ok: false, - error: new Error('socket closed before exit publication') - }) - ledger.acknowledge(oldIdentity, { - id: oldIdentity.id, - clientGeneration: oldIdentity.clientGeneration, - ownerGeneration: oldIdentity.ownerGeneration, - deliveryToken: oldIdentity.deliveryToken, - creditedEndSu: 4 - }) - const rotation = ledger.rotate(oldIdentity, replacement, 4, 4) - - expect(rotation.recovery).toEqual([]) - expect(ledger.snapshot(replacement)).toMatchObject({ - state: 'sealed-unsettled', - creditedEndSu: 4, - receivedEndSu: 4, - exitPublished: false - }) - expect(ledger.cancel(replacement, 'client-request')).toMatchObject({ - deliveryToken: 'replacement-token', - remainingStartSu: 4, - remainingEndSu: 4 - }) - expect(ledger.snapshot(replacement).state).toBe('closed') - }) - - it('retries a failed exit publication after exact owner recovery', async () => { - const primaryWrites: Buffer[] = [] - let exitSettlement: ((result: SinkWriteSettlement) => void) | undefined - dispatcher = new RelayDispatcher( - (data, settle) => { - primaryWrites.push(Buffer.from(data)) - if (message(data)?.method === 'pty.exit') { - exitSettlement = settle - return true - } - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - let publication: RelayPtySourcePublication - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }) - activationSettlements[0]({ ok: true }) - publication.publish('pty-1', { data: 'tail' }, false) - const oldData = primaryWrites.map(message).find((entry) => entry?.method === 'pty.data')! - .params as Record - const oldGrant = responseResult(primaryWrites, 1)! - - expect( - publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(true) - expect(publication.exitPublicationSettled('pty-1')).toBe(false) - exitSettlement!({ ok: false, error: new Error('socket closed') }) - expect(publication.exitPublicationSettled('pty-1')).toBe(false) - expect(publication.getDebugSnapshot()).toMatchObject({ - sealedUnsettled: 1, - exitCommitted: 0, - exitRolledBack: 1 - }) - expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1, sourceSu: 4 }) - - const recoveredWrites: Buffer[] = [] - const recoveredClientId = dispatcher.attachClient( - (data, settle) => { - recoveredWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - recoveredClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: oldGrant.ownerGeneration, - ownerLease: oldGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const recoveredActivationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const recovered = publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => recoveredActivationSettlements.push(callback) - }, - { - status: 'checkpoint', - deliveryToken: String(oldData.deliveryToken), - clientGeneration: Number(oldData.clientGeneration), - ownerGeneration: Number(oldData.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 4 - } - ) - recoveredActivationSettlements[0]({ ok: true }) - - expect(recovered).toMatchObject({ - status: 'pending', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - expect( - publication.sealAndPublishExit({ - id: 'pty-1', - code: 0, - incarnationId: 'incarnation-1' - }) - ).toBe(true) - expect(publication.exitPublicationSettled('pty-1')).toBe(true) - expect( - recoveredWrites.map(message).filter((entry) => entry?.method === 'pty.exit') - ).toHaveLength(1) - }) - - it('keeps failed recovery activation private and retryable until exact response settles', async () => { - const primaryWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settle) => { - primaryWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - let publication: RelayPtySourcePublication - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => activationSettlements.push(callback) - }) - ).toBe('opened') - activationSettlements[0]({ ok: true }) - expect(publication.publish('pty-1', { data: 'abcdefgh' }, false)).toBe(true) - const oldData = primaryWrites.map(message).find((entry) => entry?.method === 'pty.data')! - .params as Record - const oldGrant = responseResult(primaryWrites, 1)! - dispatcher.invalidateClient() - - const recoveredWrites: Buffer[] = [] - const recoveredClientId = dispatcher.attachClient( - (data, settle) => { - recoveredWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - recoveredClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: oldGrant.ownerGeneration, - ownerLease: oldGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const recoveryRequest: PtySourceRecoveryRequest = { - status: 'checkpoint', - deliveryToken: String(oldData.deliveryToken), - clientGeneration: Number(oldData.clientGeneration), - ownerGeneration: Number(oldData.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 4 - } - const firstActivationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const firstRecovery = publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => firstActivationSettlements.push(callback) - }, - recoveryRequest - ) - expect(firstRecovery).toMatchObject({ - status: 'pending', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }) - - firstActivationSettlements[0]({ ok: false, error: new Error('response publication failed') }) - expect(publication.getDebugSnapshot()).toMatchObject({ active: 0, activating: 1 }) - const writesBeforeRetry = recoveredWrites.length - publication.onCreditAvailable('pty-1') - expect(recoveredWrites).toHaveLength(writesBeforeRetry) - - const retryActivationSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const retriedRecovery = publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => retryActivationSettlements.push(callback) - }, - recoveryRequest - ) - - expect(retriedRecovery).toEqual(firstRecovery) - retryActivationSettlements[0]({ ok: true }) - expect(publication.getDebugSnapshot()).toMatchObject({ active: 1, activating: 0 }) - }) - - it('rolls back a failed recovery fence and republishes it for the next exact owner', async () => { - const primaryWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settle) => { - primaryWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - let publication: RelayPtySourcePublication - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const firstActivation: ((result: SinkWriteSettlement) => void)[] = [] - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => firstActivation.push(callback) - }) - firstActivation[0]({ ok: true }) - publication.publish('pty-1', { data: 'abcdefgh' }, false) - const firstData = primaryWrites.map(message).find((entry) => entry?.method === 'pty.data')! - .params as Record - const firstGrant = responseResult(primaryWrites, 1)! - dispatcher.invalidateClient() - - const recoveredWrites: Buffer[] = [] - let recoveryDataSettlement: ((result: SinkWriteSettlement) => void) | undefined - let failedCompletionSettlement: ((result: SinkWriteSettlement) => void) | undefined - const recoveredClientId = dispatcher.attachClient( - (data, settle) => { - recoveredWrites.push(Buffer.from(data)) - const method = message(data)?.method - if (method === 'pty.data') { - recoveryDataSettlement = settle - } else if (method === 'pty.recoveryComplete') { - failedCompletionSettlement = settle - } else { - settle({ ok: true }) - } - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - recoveredClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: firstGrant.ownerGeneration, - ownerLease: firstGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const recoveredGrant = responseResult(recoveredWrites, 2)! - const recoveredActivation: ((result: SinkWriteSettlement) => void)[] = [] - publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => recoveredActivation.push(callback) - }, - { - status: 'checkpoint', - deliveryToken: String(firstData.deliveryToken), - clientGeneration: Number(firstData.clientGeneration), - ownerGeneration: Number(firstData.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 4 - } - ) - recoveredActivation[0]({ ok: true }) - recoveryDataSettlement!({ ok: true }) - const recoveredData = recoveredWrites - .map(message) - .find((entry) => entry?.method === 'pty.data')!.params as Record - expect(publication.publish('pty-1', { data: 'live' }, false)).toBe(false) - - failedCompletionSettlement!({ ok: false, error: new Error('completion write failed') }) - - const replacementWrites: Buffer[] = [] - let replacementCompletionSettlement: ((result: SinkWriteSettlement) => void) | undefined - const replacementClientId = dispatcher.attachClient( - (data, settle) => { - replacementWrites.push(Buffer.from(data)) - if (message(data)?.method === 'pty.recoveryComplete') { - replacementCompletionSettlement = settle - } else { - settle({ ok: true }) - } - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - replacementClientId, - requestFrame(3, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: recoveredGrant.ownerGeneration, - ownerLease: recoveredGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const replacementActivation: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: replacementClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => replacementActivation.push(callback) - }, - { - status: 'checkpoint', - deliveryToken: String(recoveredData.deliveryToken), - clientGeneration: Number(recoveredData.clientGeneration), - ownerGeneration: Number(recoveredData.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 8 - } - ) - ).toMatchObject({ status: 'pending', checkpointSourceEndSu: 8, recoveryEndSu: 8 }) - replacementActivation[0]({ ok: true }) - expect(publication.publish('pty-1', { data: 'live' }, false)).toBe(false) - - replacementCompletionSettlement!({ ok: true }) - expect(publication.publish('pty-1', { data: 'live' }, false)).toBe(true) - expect( - replacementWrites.map(message).filter((entry) => entry?.method === 'pty.recoveryComplete') - ).toHaveLength(1) - expect( - replacementWrites.map(message).filter((entry) => entry?.method === 'pty.data') - ).toHaveLength(1) - expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1, sourceSu: 4 }) - }) -}) diff --git a/src/relay/relay-pty-source-recovery-window.test.ts b/src/relay/relay-pty-source-recovery-window.test.ts deleted file mode 100644 index 361b9056603..00000000000 --- a/src/relay/relay-pty-source-recovery-window.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { afterEach, expect, it } from 'vitest' -import { RelayDispatcher, type RelayClientSessionIdentity } from '../wsl-guest/dispatcher' -import { boundedPtyRecoveryEnd } from './relay-pty-source-activation' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -type Frame = { - id?: number - method?: string - params?: Record - result?: Record -} - -function decode(buffer: Buffer): Frame | null { - return buffer[0] === MessageType.Regular - ? JSON.parse(buffer.subarray(13, 13 + buffer.readUInt32BE(9)).toString('utf8')) - : null -} - -const flushRequests = (): Promise => new Promise((resolve) => setImmediate(resolve)) -let dispatcher: RelayDispatcher | undefined - -afterEach(() => dispatcher?.dispose()) - -it.each([0, 4])( - 'drains a retained tail larger than the window from checkpoint %i', - async (checkpoint) => { - const original: Frame[] = [] - dispatcher = new RelayDispatcher( - (data, settled) => { - const frame = decode(data) - if (frame) { - original.push(frame) - } - settled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const mux = dispatcher - let publication: RelayPtySourcePublication - const adapter = new SshPtyConsumerSessionAdapter(mux, 'build', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(mux, adapter, () => {}) - const open = (clientId: number, id: number, resume?: Record): void => { - mux.feedClient( - clientId, - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id, - method: 'pty.openClient', - params: { - protocolVersion: 1, - clientInstanceId: 'client', - requestedRole: 'session-owner', - resume, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - } - }, - id, - 0 - ) - ) - } - open(1, 1) - await flushRequests() - publication.activate('pty', 'incarnation', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (settle) => queueMicrotask(() => settle({ ok: true })) - }) - await flushRequests() - expect(publication.publish('pty', { data: 'abcdefghijkl' }, false)).toBe(true) - const oldFrame = original.find((frame) => frame.method === 'pty.data')!.params! - const grant = original.find((frame) => frame.id === 1)!.result! - mux.invalidateClient() - const replacement: Frame[] = [] - const clientId = mux.attachClient( - (data, settled) => { - const frame = decode(data) - if (frame) { - replacement.push(frame) - } - settled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - open(clientId, 2, { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease }) - await flushRequests() - const recovery = publication.activate( - 'pty', - 'incarnation', - { - clientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (settle) => queueMicrotask(() => settle({ ok: true })) - }, - { - status: 'checkpoint', - clientGeneration: Number(oldFrame.clientGeneration), - ownerGeneration: Number(oldFrame.ownerGeneration), - deliveryToken: String(oldFrame.deliveryToken), - ptyIncarnation: 'incarnation', - acceptedSourceEndSu: checkpoint - } - ) - // The fence lands on the checkpoint itself: the tail drains live rather than behind it. - expect(recovery).toMatchObject({ - status: 'pending', - checkpointSourceEndSu: checkpoint, - recoveryEndSu: checkpoint - }) - await flushRequests() - // The receiver cannot ACK quarantined data until this fence arrives. - expect(replacement.filter((frame) => frame.method === 'pty.recoveryComplete')).toHaveLength(1) - let accepted = checkpoint - let output = '' - for (let turn = 0; accepted < 12 && turn < 4; turn++) { - const frames = replacement.filter( - (frame) => frame.method === 'pty.data' && Number(frame.params!.sourceEndSu) > accepted - ) - expect(frames.length).toBeGreaterThan(0) - for (const frame of frames) { - const params = frame.params! - expect(Number(params.sourceEndSu) - Number(params.sourceLengthSu)).toBe(accepted) - accepted = Number(params.sourceEndSu) - output += String(params.data) - } - expect(publication.getDebugSnapshot().outstandingSourceUnits).toBeLessThanOrEqual(4) - const params = frames.at(-1)!.params! - mux.feedClient( - clientId, - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: 'pty', - clientGeneration: params.clientGeneration, - ownerGeneration: params.ownerGeneration, - deliveryToken: params.deliveryToken, - creditedEndSu: accepted - } - ] - } - }, - 3 + turn, - 0 - ) - ) - await flushRequests() - } - expect(accepted).toBe(12) - expect(output).toBe('abcdefghijkl'.slice(checkpoint)) - expect(publication.getDebugSnapshot().outstandingSourceUnits).toBe(0) - } -) - -it('fences at the checkpoint only once the tail outgrows the window', () => { - const tail = (receivedEndSu: number) => ({ receivedEndSu, creditedEndSu: 4, windowSu: 4 }) - // Exactly one window is still deliverable without credit, so it keeps the ordinary fence. - expect(boundedPtyRecoveryEnd(tail(8))).toBe(8) - expect(boundedPtyRecoveryEnd(tail(9))).toBe(4) -}) diff --git a/src/relay/relay-pty-source-restore-retry.test.ts b/src/relay/relay-pty-source-restore-retry.test.ts deleted file mode 100644 index c22cba9e350..00000000000 --- a/src/relay/relay-pty-source-restore-retry.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -function message(buffer: Buffer): Record | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) -} - -function responseResult( - writes: readonly Buffer[], - id: number -): Record | undefined { - const response = writes.map(message).find((entry) => entry?.id === id) - return response?.result as Record | undefined -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('relay PTY source restore retry', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - }) - - it('mints a fresh activation after invalid-checkpoint restore and retry', async () => { - const primaryWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settle) => { - primaryWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - let publication: RelayPtySourcePublication - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const initialSettlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate('pty-1', 'incarnation-1', { - clientId: 1, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => initialSettlements.push(callback) - }) - ).toBe('opened') - initialSettlements[0]({ ok: true }) - expect(publication.publish('pty-1', { data: 'old' }, false)).toBe(true) - const oldData = primaryWrites.map(message).find((entry) => entry?.method === 'pty.data')! - .params as Record - const oldGrant = responseResult(primaryWrites, 1)! - dispatcher.invalidateClient() - - const recoveredWrites: Buffer[] = [] - const recoveredClientId = dispatcher.attachClient( - (data, settle) => { - recoveredWrites.push(Buffer.from(data)) - settle({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - recoveredClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: oldGrant.ownerGeneration, - ownerLease: oldGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const restoreSettlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate( - 'pty-1', - 'incarnation-1', - { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => restoreSettlements.push(callback) - }, - { - status: 'checkpoint', - deliveryToken: String(oldData.deliveryToken), - clientGeneration: Number(oldData.clientGeneration), - ownerGeneration: Number(oldData.ownerGeneration), - ptyIncarnation: 'incarnation-1', - acceptedSourceEndSu: 2 - } - ) - ).toMatchObject({ status: 'restoreRequired' }) - expect(publication.accepts('pty-1')).toBe(true) - for (const settle of restoreSettlements) { - settle({ ok: true }) - } - expect(publication.accepts('pty-1')).toBe(false) - - const retrySettlements: ((result: SinkWriteSettlement) => void)[] = [] - expect( - publication.activate('pty-1', 'incarnation-1', { - clientId: recoveredClientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => retrySettlements.push(callback) - }) - ).toBe('opened') - const sourceActivation = publication.receivingActivation('pty-1', recoveredClientId) - expect(sourceActivation).toMatchObject({ - status: 'pending', - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }) - expect(sourceActivation?.deliveryToken).not.toBe(oldData.deliveryToken) - retrySettlements[0]({ ok: true }) - - expect(publication.publish('pty-1', { data: 'live' }, false)).toBe(true) - const sourceFrames = recoveredWrites - .map(message) - .filter((entry) => entry?.method === 'pty.data') - expect(sourceFrames).toHaveLength(1) - expect(sourceFrames[0]?.params).toMatchObject({ - data: 'live', - deliveryToken: sourceActivation?.deliveryToken - }) - }) -}) diff --git a/src/relay/relay-pty-source-send-scheduler.test.ts b/src/relay/relay-pty-source-send-scheduler.test.ts deleted file mode 100644 index c57f34a11d9..00000000000 --- a/src/relay/relay-pty-source-send-scheduler.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - PtySourceDeliveryIdentity, - PtySourceDeliverySnapshot -} from '../shared/pty-source-credit-contract' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - RelayPtySourceSendScheduler, - type RelayPtySourceDeliveryRecord, - type RelayPtySourcePublicationCounters -} from './relay-pty-source-send-scheduler' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const identity: PtySourceDeliveryIdentity = Object.freeze({ - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1' -}) - -function deliveryRecord( - overrides: Partial = {} -): RelayPtySourceDeliveryRecord { - return { - clientId: 1, - identity, - sourceActivation: { - status: 'pending', - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - ptyIncarnation: identity.ptyIncarnation, - deliveryToken: identity.deliveryToken, - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }, - displayEnd: 0, - activating: false, - activationRecoveryRequest: null, - sealed: false, - legacyExitAccepted: false, - sourceExitState: 'idle', - sending: false, - turnFrames: 0, - turnSourceSu: 0, - turnScheduled: false, - sendWaiters: new Set(), - recoveryCheckpointSourceEndSu: null, - recoveryEndSu: null, - recoveryCompletionPending: false, - restoreRequired: false, - rotationPending: false, - ...overrides - } -} - -function createScheduler( - probe: PtySourceDeliverySnapshot | null, - record: RelayPtySourceDeliveryRecord -) { - const deliveries = new Map([['pty-1', record]]) - const session = { - sourceDeliverySnapshotIfKnown: vi.fn(() => probe), - sourceDeliverySnapshot: vi.fn(() => { - throw new Error('Unknown or stale PTY source delivery') - }), - reserveSourceSend: vi.fn(() => null) - } - const dispatcher = { - onLegacyPtyCapacity: vi.fn(() => () => {}), - producerDataBudget: vi.fn(() => 4096), - tryNotifyPtyDataToClient: vi.fn(() => true) - } - const counters: RelayPtySourcePublicationCounters = { - opened: 0, - rotated: 0, - appendDenied: 0, - sendCommitted: 0, - sendRolledBack: 0, - exitCommitted: 0, - exitRolledBack: 0 - } - const capacityIds: string[] = [] - const scheduler = new RelayPtySourceSendScheduler( - dispatcher as unknown as RelayDispatcher, - session as unknown as SshPtyConsumerSessionAdapter, - deliveries, - counters, - (id) => capacityIds.push(id) - ) - return { scheduler, deliveries, session, capacityIds } -} - -describe('RelayPtySourceSendScheduler close handling', () => { - it('leaves an in-flight exit frame untouched on a credit event', () => { - const record = deliveryRecord({ sourceExitState: 'pending' }) - const harness = createScheduler(null, record) - - harness.scheduler.onCreditAvailable('pty-1') - - expect(harness.deliveries.get('pty-1')).toBe(record) - expect(harness.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() - expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() - expect(harness.capacityIds).toEqual([]) - }) - - it('prunes a record whose tombstone was already evicted', () => { - const waiter = vi.fn() - const record = deliveryRecord({ sendWaiters: new Set([waiter]) }) - const harness = createScheduler(null, record) - - expect(() => harness.scheduler.onCreditAvailable('pty-1')).not.toThrow() - - expect(waiter).toHaveBeenCalledOnce() - expect(record.sendWaiters.size).toBe(0) - expect(harness.deliveries.has('pty-1')).toBe(false) - expect(harness.capacityIds).toEqual(['pty-1']) - }) - - it('preserves a closed record after the legacy exit projection landed', () => { - const record = deliveryRecord({ legacyExitAccepted: true }) - const harness = createScheduler(null, record) - - harness.scheduler.onCreditAvailable('pty-1') - - expect(harness.deliveries.get('pty-1')).toBe(record) - expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() - expect(harness.capacityIds).toEqual(['pty-1']) - }) - - it('prunes a healthily published exit after its final credit closes the delivery', () => { - const record = deliveryRecord({ - legacyExitAccepted: true, - sourceExitState: 'published' - }) - const harness = createScheduler(null, record) - - harness.scheduler.onCreditAvailable('pty-1') - - expect(harness.deliveries.has('pty-1')).toBe(false) - expect(harness.capacityIds).toEqual(['pty-1']) - }) - - it('skips unknown deliveries in the debug snapshot instead of throwing', () => { - const harness = createScheduler(null, deliveryRecord({ sealed: true })) - - expect(harness.scheduler.getDebugSnapshot()).toMatchObject({ - active: 0, - activating: 0, - sealedUnsettled: 0, - outstandingSourceUnits: 0 - }) - }) -}) diff --git a/src/relay/relay-pty-source-send-scheduler.ts b/src/relay/relay-pty-source-send-scheduler.ts deleted file mode 100644 index b687a729127..00000000000 --- a/src/relay/relay-pty-source-send-scheduler.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { - PtySourceDeliveryIdentity, - PtySourceDeliverySnapshot -} from '../shared/pty-source-credit-contract' -import type { PtySourceRecoveryCheckpoint } from '../shared/pty-source-recovery-contract' -import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' -import type { RelayDispatcher, SinkWriteSettlement } from '../wsl-guest/dispatcher' -import { - PTY_SOURCE_SCHEDULER_MAX_FRAMES, - PTY_SOURCE_SCHEDULER_MAX_SU -} from './pty-source-credit-scheduler' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { completePtySourceRecovery } from './relay-pty-source-recovery-completion' - -export type RelayPtySourceDeliveryRecord = { - clientId: number - identity: PtySourceDeliveryIdentity - sourceActivation: PtySourceReceivingActivation - displayEnd: number - activating: boolean - activationRecoveryRequest: PtySourceRecoveryCheckpoint | null - sealed: boolean - legacyExitAccepted: boolean - sourceExitState: 'idle' | 'pending' | 'published' - sending: boolean - turnFrames: number - turnSourceSu: number - turnScheduled: boolean - sendWaiters: Set<() => void> - recoveryCheckpointSourceEndSu: number | null - recoveryEndSu: number | null - recoveryCompletionPending: boolean - restoreRequired: boolean - rotationPending: boolean -} - -export type RelayPtySourcePublicationCounters = { - opened: number - rotated: number - appendDenied: number - sendCommitted: number - sendRolledBack: number - exitCommitted: number - exitRolledBack: number -} - -const PTY_SOURCE_FRAME_MAX_SU = 16 * 1024 - -export function onceSinkSettlement( - callback: (result: SinkWriteSettlement) => void -): (result: SinkWriteSettlement) => void { - let settled = false - return (result) => { - if (settled) { - return - } - settled = true - callback(result) - } -} - -export class RelayPtySourceSendScheduler { - private readonly removeCompletionCapacityListener: () => void - - constructor( - private readonly dispatcher: RelayDispatcher, - private readonly session: SshPtyConsumerSessionAdapter, - private readonly deliveries: Map, - private readonly counters: RelayPtySourcePublicationCounters, - private readonly onCapacity: (id: string) => void - ) { - this.removeCompletionCapacityListener = dispatcher.onLegacyPtyCapacity(() => { - for (const record of deliveries.values()) { - this.completeRecoveryIfReady(record) - } - }) - } - - async waitForPendingSend(id: string, timeoutMs = 5_000): Promise { - const record = this.deliveries.get(id) - if (!record) { - return true - } - record.rotationPending = true - if (!record.sending) { - return true - } - return new Promise((resolve) => { - const settle = (settled: boolean): void => { - clearTimeout(timer) - record.sendWaiters.delete(onSettled) - resolve(settled) - } - const onSettled = (): void => settle(true) - const timer = setTimeout(() => settle(false), timeoutMs) - timer.unref?.() - record.sendWaiters.add(onSettled) - }) - } - - onCreditAvailable(id: string): void { - const record = this.deliveries.get(id) - if (!record || record.restoreRequired) { - return - } - if (record.sourceExitState === 'pending') { - // Why: the credit-mode exit frame is in flight; pruning now diverts publishPendingExit - // into a duplicate legacy pty.exit, and pumping a closed delivery throws. The exit - // settlement (which fires onCapacity) resumes progress. - return - } - const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) - if ( - record.sourceExitState === 'idle' && - record.legacyExitAccepted && - (!snapshot || snapshot.state === 'closed' || snapshot.state === 'closing') - ) { - // Why: preserve partial exit progress so the retry targets only the source owner. - this.onCapacity(id) - return - } - if (this.pruneClosed(id, record, snapshot)) { - this.onCapacity(id) - return - } - this.pump(record) - this.onCapacity(id) - } - - getDebugSnapshot() { - let active = 0 - let activating = 0 - let sealedUnsettled = 0 - let outstandingSourceUnits = 0 - for (const record of this.deliveries.values()) { - const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) - if (!snapshot) { - continue - } - outstandingSourceUnits += snapshot.sentEndSu - snapshot.creditedEndSu - if (record.activating) { - activating++ - } else if (record.sealed && snapshot.state !== 'closed') { - sealedUnsettled++ - } else if (snapshot.state === 'active') { - active++ - } - } - return Object.freeze({ - active, - activating, - sealedUnsettled, - outstandingSourceUnits, - ...this.counters - }) - } - - dispose(): void { - this.removeCompletionCapacityListener() - for (const record of this.deliveries.values()) { - this.session.cancelDelivery(record.identity, 'source-publication-disposed') - this.wakeSendWaiters(record) - } - this.deliveries.clear() - } - - pump(record: RelayPtySourceDeliveryRecord): void { - if ( - record.activating || - record.sending || - record.turnScheduled || - record.restoreRequired || - record.rotationPending || - this.deliveries.get(record.identity.id) !== record - ) { - return - } - if ( - record.recoveryEndSu !== null && - this.session.sourceDeliverySnapshot(record.identity).sentEndSu >= record.recoveryEndSu - ) { - this.completeRecoveryIfReady(record) - return - } - if ( - record.turnFrames >= PTY_SOURCE_SCHEDULER_MAX_FRAMES || - record.turnSourceSu >= PTY_SOURCE_SCHEDULER_MAX_SU - ) { - record.turnScheduled = true - setImmediate(() => { - record.turnScheduled = false - record.turnFrames = 0 - record.turnSourceSu = 0 - if (this.deliveries.get(record.identity.id) !== record || record.restoreRequired) { - return - } - this.pump(record) - this.onCapacity(record.identity.id) - }) - return - } - const snapshot = this.session.sourceDeliverySnapshot(record.identity) - const encodedDataBudget = this.dispatcher.producerDataBudget( - 'pty.data', - { - id: record.identity.id, - rawLength: PTY_SOURCE_FRAME_MAX_SU, - transformed: false, - deliveryToken: record.identity.deliveryToken, - clientGeneration: record.identity.clientGeneration, - ownerGeneration: record.identity.ownerGeneration, - ptyIncarnation: record.identity.ptyIncarnation, - sourceEndSu: snapshot.receivedEndSu, - sourceLengthSu: PTY_SOURCE_FRAME_MAX_SU - }, - record.clientId - ) - const maxSourceSu = Math.min( - PTY_SOURCE_FRAME_MAX_SU, - Math.max(1, Math.floor(Math.max(0, encodedDataBudget - 32) / 6)) - ) - const reservation = this.session.reserveSourceSend(record.identity, maxSourceSu) - if (!reservation) { - return - } - record.sending = true - const sourceLengthSu = reservation.span.sourceEndSu - reservation.span.sourceStartSu - const settle = onceSinkSettlement((result) => { - record.sending = false - this.wakeSendWaiters(record) - if (this.deliveries.get(record.identity.id) !== record || record.restoreRequired) { - this.onCapacity(record.identity.id) - return - } - if (result.ok) { - this.session.commitSourceSend(reservation) - record.turnFrames++ - record.turnSourceSu += sourceLengthSu - this.counters.sendCommitted++ - this.completeRecoveryIfReady(record) - } else { - this.session.rollbackSourceSend(reservation) - this.counters.sendRolledBack++ - } - this.pruneClosed(record.identity.id, record) - this.onCapacity(record.identity.id) - if (result.ok && this.deliveries.get(record.identity.id) === record) { - this.pump(record) - } - }) - const accepted = this.dispatcher.tryNotifyPtyDataToClient( - record.clientId, - { - id: reservation.identity.id, - data: reservation.span.data, - rawLength: sourceLengthSu, - transformed: reservation.span.transform.transformed, - deliveryToken: reservation.identity.deliveryToken, - clientGeneration: reservation.identity.clientGeneration, - ownerGeneration: reservation.identity.ownerGeneration, - ptyIncarnation: reservation.identity.ptyIncarnation, - sourceEndSu: reservation.span.sourceEndSu, - sourceLengthSu - }, - settle - ) - if (!accepted) { - settle({ ok: false, error: new Error('PTY source publication was not admitted') }) - } - } - - completeRecoveryIfReady(record: RelayPtySourceDeliveryRecord): void { - completePtySourceRecovery({ - record, - deliveries: this.deliveries, - dispatcher: this.dispatcher, - session: this.session, - onCompleted: (id) => { - this.onCapacity(id) - if (this.deliveries.get(id) === record) { - this.pump(record) - } - } - }) - } - - pruneClosed( - id: string, - record: RelayPtySourceDeliveryRecord, - snapshot: PtySourceDeliverySnapshot | null = this.session.sourceDeliverySnapshotIfKnown( - record.identity - ) - ): boolean { - // Why: an evicted tombstone probes null and must count as closed — this runs from paths - // (credit callbacks, write settlements) where a throw escapes every caller's try/catch. - if (snapshot && snapshot.state !== 'closed') { - return false - } - if (this.deliveries.get(id) === record) { - this.wakeSendWaiters(record) - this.deliveries.delete(id) - } - return true - } - - wakeSendWaiters(record: RelayPtySourceDeliveryRecord): void { - for (const resolve of record.sendWaiters) { - resolve() - } - record.sendWaiters.clear() - } - - releaseRotationFence(record: RelayPtySourceDeliveryRecord | undefined): void { - if (!record?.rotationPending) { - return - } - record.rotationPending = false - this.pump(record) - this.onCapacity(record.identity.id) - } -} diff --git a/src/relay/relay-pty-source-superseded-activation.test.ts b/src/relay/relay-pty-source-superseded-activation.test.ts deleted file mode 100644 index c2bc99ad850..00000000000 --- a/src/relay/relay-pty-source-superseded-activation.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - RelayDispatcher, - type RelayClientSessionIdentity, - type RequestContext, - type SinkWriteSettlement -} from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function requestFrame(id: number, method: string, params: Record): Buffer { - return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) -} - -function responseResult(buffer: Buffer): Record | null { - if (buffer[0] !== MessageType.Regular) { - return null - } - const length = buffer.readUInt32BE(9) - const message = JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) - return message.id === undefined ? null : (message.result ?? null) -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('PTY source activation from a superseded owner', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - }) - - async function createHarness() { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - let publication: RelayPtySourcePublication - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => - publication.onCreditAvailable(id) - ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) - dispatcher.feed( - requestFrame(1, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - return { adapter, publication, writes } - } - - function contextFor( - clientId: number, - settlements: ((result: SinkWriteSettlement) => void)[] - ): RequestContext { - return { - clientId, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (callback) => settlements.push(callback) - } - } - - /** - * The superseded transport must not release, cancel or retire the delivery its own replacement - * opened: releasing the fence resumes a send the replacement is still rotating, and retiring it - * blanks the pane that owns it. - */ - it('leaves the replacement delivery intact when the superseded owner re-activates', async () => { - const { publication, adapter, writes } = await createHarness() - const settlements: ((result: SinkWriteSettlement) => void)[] = [] - expect(publication.activate('pty-1', 'incarnation-1', contextFor(1, settlements))).toBe( - 'opened' - ) - settlements[0]({ ok: true }) - const activation = publication.receivingActivation('pty-1', 1)! - const ownerGrant = writes.map(responseResult).find((result) => result?.ownerLease)! - - // The original transport arms its rotation fence while waiting for a checkpoint-safe send. - await expect(publication.waitForPendingSend('pty-1')).resolves.toBe(true) - - const replacementWrites: Buffer[] = [] - const replacementClientId = dispatcher!.attachClient( - (data, onSettled) => { - replacementWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher!.feedClient( - replacementClientId, - requestFrame(2, 'pty.openClient', { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { - ownerGeneration: ownerGrant.ownerGeneration, - ownerLease: ownerGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - - const replacementSettlements: ((result: SinkWriteSettlement) => void)[] = [] - const recovery = { - status: 'checkpoint' as const, - clientGeneration: activation.clientGeneration, - ownerGeneration: activation.ownerGeneration, - ptyIncarnation: activation.ptyIncarnation, - deliveryToken: activation.deliveryToken, - acceptedSourceEndSu: 0 - } - expect( - publication.activate( - 'pty-1', - 'incarnation-1', - contextFor(replacementClientId, replacementSettlements), - recovery - ) - ).toMatchObject({ status: 'pending' }) - replacementSettlements[0]({ ok: true }) - - // Arm the replacement fence, then let the superseded transport's activation resume. isStale() - // stays false on purpose: the superseded client is still attached, so production reaches the - // delivery-mode bail-outs rather than any stale early-out. - await expect(publication.waitForPendingSend('pty-1')).resolves.toBe(true) - const cancelDelivery = vi.spyOn(adapter, 'cancelDelivery') - expect(publication.activate('pty-1', 'incarnation-1', contextFor(1, []), recovery)).toBe(false) - expect(cancelDelivery).not.toHaveBeenCalled() - expect(publication.publish('pty-1', { data: 'replacement-output' }, false)).toBe(false) - - // Release the replacement fence through its owning transport so no parked work is left behind. - expect( - publication.activate('pty-1', 'incarnation-1', contextFor(replacementClientId, [])) - ).toBe('existing') - expect(replacementWrites.length).toBeGreaterThan(0) - }) -}) diff --git a/src/relay/relay-reconnect-listener-credential-gate.test.ts b/src/relay/relay-reconnect-listener-credential-gate.test.ts deleted file mode 100644 index 4edb91a6ea5..00000000000 --- a/src/relay/relay-reconnect-listener-credential-gate.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { connect, type Socket } from 'node:net' -import { mkdtempSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { RelayReconnectListener } from './relay-reconnect-listener' -import { RelaySocketOwnership } from './relay-socket-ownership' -import { - encodeHandshakeFrame, - FrameDecoder, - parseHandshakeMessage, - RELAY_VERSION -} from '../wsl-guest/protocol' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -const noopCallbacks = { - detachPrimaryInput: () => {}, - cancelGrace: () => {}, - onLastClientClosed: () => {} -} - -function dispatcherStub(): { dispatcher: RelayDispatcher; attached: () => number } { - let attached = 0 - const dispatcher = { - attachClient: () => { - attached += 1 - return attached - }, - detachClient: () => {}, - feedClient: () => {} - } as unknown as RelayDispatcher - return { dispatcher, attached: () => attached } -} - -async function handshake(sockPath: string, credential: string): Promise<'ok' | 'closed'> { - const sock: Socket = connect(sockPath) - await new Promise((resolve, reject) => { - sock.once('connect', resolve) - sock.once('error', reject) - }) - return new Promise((resolve) => { - const decoder = new FrameDecoder( - (frame) => { - const msg = parseHandshakeMessage(frame.payload) - resolve(msg.type === 'orca-relay-handshake-ok' ? 'ok' : 'closed') - sock.destroy() - }, - () => resolve('closed') - ) - sock.on('data', (chunk: Buffer) => decoder.feed(chunk)) - sock.once('close', () => resolve('closed')) - sock.write( - encodeHandshakeFrame({ - type: 'orca-relay-handshake', - version: RELAY_VERSION, - endpointCredential: credential - }) - ) - }) -} - -describe.skipIf(process.platform === 'win32')('reconnect listener credential gate', () => { - let dir: string - let ownership: RelaySocketOwnership | null = null - - afterEach(async () => { - ownership?.closeAndCleanup() - ownership = null - await rm(dir, { recursive: true, force: true }).catch(() => {}) - }) - - it('refuses clients between bind and publication, then serves the published credential', async () => { - dir = mkdtempSync(path.join(tmpdir(), 'relay-cred-gate-')) - const sockPath = path.join(dir, 'relay.sock') - ownership = new RelaySocketOwnership(sockPath) - const { dispatcher, attached } = dispatcherStub() - const listener = new RelayReconnectListener( - dispatcher, - ownership, - RELAY_VERSION, - `${sockPath}.credential`, - noopCallbacks - ) - await listener.start() - - // The window the daemon closes synchronously after start(); it must never admit anyone. - const credential = 'k'.repeat(40) - expect(await handshake(sockPath, credential)).toBe('closed') - expect(attached()).toBe(0) - expect(listener.acceptedConnections).toBe(0) - - listener.setEndpointCredential(credential) - expect(await handshake(sockPath, credential)).toBe('ok') - expect(attached()).toBe(1) - expect(await handshake(sockPath, 'x'.repeat(40))).toBe('closed') - expect(attached()).toBe(1) - }) - - it('does not gate a daemon launched without a credential file', async () => { - dir = mkdtempSync(path.join(tmpdir(), 'relay-cred-gate-')) - const sockPath = path.join(dir, 'relay.sock') - ownership = new RelaySocketOwnership(sockPath) - const { dispatcher, attached } = dispatcherStub() - const listener = new RelayReconnectListener( - dispatcher, - ownership, - RELAY_VERSION, - undefined, - noopCallbacks - ) - await listener.start() - expect(await handshake(sockPath, 'ignored'.padEnd(32, 'z'))).toBe('ok') - expect(attached()).toBe(1) - }) -}) diff --git a/src/relay/relay-reconnect-listener-peer-end.test.ts b/src/relay/relay-reconnect-listener-peer-end.test.ts deleted file mode 100644 index b1069898279..00000000000 --- a/src/relay/relay-reconnect-listener-peer-end.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { connect, type Socket } from 'node:net' -import { mkdtempSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { RelayReconnectListener } from './relay-reconnect-listener' -import { RelaySocketOwnership } from './relay-socket-ownership' -import { encodeHandshakeFrame, FrameDecoder, RELAY_VERSION } from '../wsl-guest/protocol' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' - -describe.skipIf(process.platform === 'win32')('reconnect listener peer end', () => { - let dir: string - let ownership: RelaySocketOwnership | null = null - let dispatcher: RelayDispatcher | null = null - - afterEach(async () => { - dispatcher?.dispose() - dispatcher = null - ownership?.closeAndCleanup() - ownership = null - await rm(dir, { recursive: true, force: true }).catch(() => {}) - }) - - // Why: between the destroy on 'end' and 'close', a relay write fails and the dispatcher closes the - // client as 'local', which holds its PTY owner for the full grace instead of the peer-closed floor. - it('detaches the client as peer-closed before destroying an ended socket', async () => { - dir = mkdtempSync(path.join(tmpdir(), 'relay-peer-end-')) - const sockPath = path.join(dir, 'relay.sock') - ownership = new RelaySocketOwnership(sockPath) - const detached: PtyConsumerCloseCause[] = [] - const relay = new RelayDispatcher(() => true) - dispatcher = relay - relay.onClientDetached((_id, cause) => detached.push(cause)) - const listen = ownership.listen.bind(ownership) - const atServerEnd: { destroyed: boolean; detached: PtyConsumerCloseCause[] }[] = [] - ownership.listen = (onConnection) => - listen((socket) => { - onConnection(socket) - // Registered after the listener's own 'end' handler, so it observes what that handler did. - socket.on('end', () => - atServerEnd.push({ destroyed: socket.destroyed, detached: [...detached] }) - ) - }) - const listener = new RelayReconnectListener(relay, ownership, RELAY_VERSION, undefined, { - detachPrimaryInput: () => {}, - cancelGrace: () => {}, - onLastClientClosed: () => {} - }) - await listener.start() - - const client: Socket = connect(sockPath) - await new Promise((resolve, reject) => { - client.once('connect', resolve) - client.once('error', reject) - }) - await new Promise((resolve) => { - const decoder = new FrameDecoder( - () => resolve(), - () => resolve() - ) - client.on('data', (chunk: Buffer) => decoder.feed(chunk)) - client.write(encodeHandshakeFrame({ type: 'orca-relay-handshake', version: RELAY_VERSION })) - }) - expect(listener.clientCount).toBe(1) - - client.end() - await new Promise((resolve) => { - const poll = (): void => (listener.clientCount === 0 ? resolve() : void setTimeout(poll, 5)) - poll() - }) - - expect(atServerEnd).toEqual([{ destroyed: true, detached: ['peer-closed'] }]) - expect(detached).toEqual(['peer-closed']) - }) -}) diff --git a/src/relay/relay-reconnect-listener-peer-write-error.test.ts b/src/relay/relay-reconnect-listener-peer-write-error.test.ts deleted file mode 100644 index dd443c9aecf..00000000000 --- a/src/relay/relay-reconnect-listener-peer-write-error.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { connect, type Socket } from 'node:net' -import { mkdtempSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { RelayReconnectListener } from './relay-reconnect-listener' -import { RelaySocketOwnership } from './relay-socket-ownership' -import { encodeHandshakeFrame, FrameDecoder, RELAY_VERSION } from '../wsl-guest/protocol' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' - -// Why: on Windows named pipes a relay write can fail with EPIPE/ECONNRESET before EOF is read. The -// writer then closed the client as 'local', holding its PTY owner for the full grace. -describe.skipIf(process.platform === 'win32')('reconnect listener peer write error', () => { - let dir: string - let ownership: RelaySocketOwnership | null = null - let dispatcher: RelayDispatcher | null = null - let client: Socket | null = null - - afterEach(async () => { - client?.destroy() - client = null - dispatcher?.dispose() - dispatcher = null - ownership?.closeAndCleanup() - ownership = null - await rm(dir, { recursive: true, force: true }).catch(() => {}) - }) - - async function attachClient(): Promise<{ - serverSocket: Socket - detached: PtyConsumerCloseCause[] - relay: RelayDispatcher - }> { - dir = mkdtempSync(path.join(tmpdir(), 'relay-peer-write-')) - const sockPath = path.join(dir, 'relay.sock') - ownership = new RelaySocketOwnership(sockPath) - const relay = new RelayDispatcher(() => true) - dispatcher = relay - const detached: PtyConsumerCloseCause[] = [] - relay.onClientDetached((_id, cause) => detached.push(cause)) - const sockets: Socket[] = [] - const listen = ownership.listen.bind(ownership) - ownership.listen = (onConnection) => - listen((socket) => { - sockets.push(socket) - onConnection(socket) - }) - const listener = new RelayReconnectListener(relay, ownership, RELAY_VERSION, undefined, { - detachPrimaryInput: () => {}, - cancelGrace: () => {}, - onLastClientClosed: () => {} - }) - await listener.start() - - const peer = connect(sockPath) - client = peer - await new Promise((resolve, reject) => { - peer.once('connect', resolve) - peer.once('error', reject) - }) - await new Promise((resolve) => { - const decoder = new FrameDecoder( - () => resolve(), - () => resolve() - ) - peer.on('data', (chunk: Buffer) => decoder.feed(chunk)) - peer.write(encodeHandshakeFrame({ type: 'orca-relay-handshake', version: RELAY_VERSION })) - }) - expect(listener.clientCount).toBe(1) - return { serverSocket: sockets[0], detached, relay } - } - - // Why at the handle: pause() does not stop libuv reading, so EOF would win the race this test needs. - // Leaving `_handle.reading` set keeps a later resume() from restarting reads. - function stopReading(socket: Socket): void { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a connected net.Socket owns a libuv stream handle exposing readStop(). - const handle = (socket as unknown as { _handle: { readStop(): number } })._handle - expect(handle.readStop()).toBe(0) - } - - it('detaches as peer-closed when a write fails because the peer is gone', async () => { - const { serverSocket, detached, relay } = await attachClient() - stopReading(serverSocket) - client?.destroy() - client = null - await new Promise((resolve) => setTimeout(resolve, 20)) - - for (let frame = 0; frame < 200 && detached.length === 0; frame += 1) { - relay.notifyControl('control.probe', { frame }) - await new Promise((resolve) => setTimeout(resolve, 5)) - } - - // EOF never reached the relay, so the write error was the only evidence the peer left. - expect(serverSocket.readableEnded).toBe(false) - expect(detached).toEqual(['peer-closed']) - }) - - it('detaches as peer-closed when a write lands after a peer reset destroyed the socket', async () => { - const { serverSocket, detached, relay } = await attachClient() - serverSocket.on('error', () => {}) - serverSocket.destroy(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' })) - - // Before 'close' runs: the listener has not detached the client yet. - relay.notifyControl('control.probe', {}) - - expect(detached).toEqual(['peer-closed']) - }) - - it('keeps a write after our own destroy a local close', async () => { - const { serverSocket, detached, relay } = await attachClient() - serverSocket.destroy() - - relay.notifyControl('control.probe', {}) - - expect(detached).toEqual(['local']) - }) -}) diff --git a/src/relay/relay-reconnect-listener.ts b/src/relay/relay-reconnect-listener.ts deleted file mode 100644 index 984ff327264..00000000000 --- a/src/relay/relay-reconnect-listener.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Socket } from 'node:net' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { setupDaemonHandshake } from './relay-handshake' -import { relayLogLine } from './relay-diagnostic-log' -import type { RelaySocketOwnership } from './relay-socket-ownership' -import { isRelaySocketPeerClosed } from './relay-socket-peer-close' - -type RelayReconnectCallbacks = { - detachPrimaryInput: () => void - cancelGrace: (reason: string) => void - onLastClientClosed: () => void -} - -export class RelayReconnectListener { - private readonly socketClients = new Map() - private acceptedSocketConnections = 0 - private acceptedSocketClient = false - private endpointCredential: string | undefined - private endpointCredentialPublished = false - - constructor( - private readonly dispatcher: RelayDispatcher, - readonly ownership: RelaySocketOwnership, - private readonly launchVersion: string, - private readonly credentialFile: string | undefined, - private readonly callbacks: RelayReconnectCallbacks - ) {} - - /** Set once the bind succeeded and the file is published; fixed for the process lifetime. */ - setEndpointCredential(credential: string | undefined): void { - this.endpointCredential = credential - this.endpointCredentialPublished = true - } - - get clientCount(): number { - return this.socketClients.size - } - - get hasAcceptedClient(): boolean { - return this.acceptedSocketClient - } - - get acceptedConnections(): number { - return this.acceptedSocketConnections - } - - async start(): Promise { - await this.ownership.listen((socket) => this.acceptConnection(socket)) - } - - private acceptConnection(socket: Socket): void { - // Why fail closed: the credential is set right after listen() resolves, and today no - // connection can be delivered in between. Do not let an auth boundary rest on event-loop - // ordering — a client that arrives before publication is refused, never admitted unproved. - if (this.credentialFile !== undefined && !this.endpointCredentialPublished) { - relayLogLine('[relay] Client arrived before the endpoint credential was published; refusing') - socket.destroy() - return - } - setupDaemonHandshake(socket, { - launchVersion: this.launchVersion, - endpointCredential: this.endpointCredential, - onAccepted: (acceptedSocket, leftover) => this.attachAcceptedSocket(acceptedSocket, leftover) - }) - socket.on('end', () => { - // Why detach first: a relay write between this destroy and 'close' fails, and the dispatcher - // would close the client as 'local', holding its PTY owner for the full grace (seen on Windows). - this.detachSocketClient(socket) - if (!socket.destroyed) { - socket.destroy() - } - }) - socket.on('error', () => { - // The close event owns client cleanup and grace startup. - }) - socket.on('close', () => this.handleSocketClose(socket)) - } - - private attachAcceptedSocket(socket: Socket, leftover: Buffer): void { - this.callbacks.detachPrimaryInput() - this.acceptedSocketClient = true - this.acceptedSocketConnections++ - relayLogLine( - `[relay] Socket client accepted (clients=${this.socketClients.size + 1}, accepted=${this.acceptedSocketConnections})` - ) - this.callbacks.cancelGrace('socket client accepted') - - const drainWaiters = new Set<() => void>() - const flushDrainWaiters = (): void => { - for (const callback of Array.from(drainWaiters)) { - drainWaiters.delete(callback) - callback() - } - } - socket.on('drain', flushDrainWaiters) - socket.on('close', flushDrainWaiters) - socket.on('error', flushDrainWaiters) - const clientId = this.dispatcher.attachClient( - (data, onSettled) => { - // Why detach before settling: a failed settlement closes the client as 'local', and a peer - // that reset the pipe must get the peer-closed grace floor, not the full grace. - if (!socket.destroyed) { - return socket.write(data, (error) => { - if (error && isRelaySocketPeerClosed(socket, error)) { - this.detachSocketClient(socket) - } - onSettled(error ? { ok: false, error } : { ok: true }) - }) - } - if (isRelaySocketPeerClosed(socket)) { - this.detachSocketClient(socket) - } - onSettled({ ok: false, error: new Error('Relay socket is closed') }) - return false - }, - { - supportsWriteCallback: true, - writableLength: () => socket.writableLength, - writableHighWaterMark: () => socket.writableHighWaterMark, - close: () => socket.destroy(), - waitWriteDrain: (callback) => { - if (socket.destroyed) { - callback() - return - } - drainWaiters.add(callback) - return () => drainWaiters.delete(callback) - } - }, - { - principal: `relay-endpoint:${this.launchVersion}`, - authenticated: this.endpointCredential !== undefined, - allowSessionOwner: this.endpointCredential !== undefined, - authenticationKind: this.endpointCredential ? 'endpoint-credential' : 'unproved' - }, - { pauseReads: () => socket.pause(), resumeReads: () => socket.resume() } - ) - this.socketClients.set(socket, clientId) - if (leftover.length > 0) { - this.dispatcher.feedClient(clientId, leftover) - } - socket.on('data', (chunk: Buffer) => { - this.callbacks.cancelGrace('socket client data') - this.dispatcher.feedClient(clientId, chunk) - }) - } - - private detachSocketClient(socket: Socket): void { - const clientId = this.socketClients.get(socket) - this.socketClients.delete(socket) - if (clientId !== undefined) { - this.dispatcher.detachClient(clientId, 'peer-closed') - } - } - - private handleSocketClose(socket: Socket): void { - this.detachSocketClient(socket) - relayLogLine(`[relay] Socket client closed (clients=${this.socketClients.size})`) - if (this.socketClients.size === 0) { - this.callbacks.onLastClientClosed() - } - } -} diff --git a/src/relay/relay-ripgrep-cwd-env.test.ts b/src/relay/relay-ripgrep-cwd-env.test.ts deleted file mode 100644 index c30590b4ad1..00000000000 --- a/src/relay/relay-ripgrep-cwd-env.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listFilesWithRg } from './fs-handler-list-files' -import { searchWithRg } from './fs-handler-utils' -import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' - -describe.runIf(process.platform !== 'win32')( - 'relay ripgrep launch classification environment', - () => { - let dir: string - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'relay-rg-cwd-env-')) - writeFileSync(join(dir, 'found.txt'), 'needle') - const cargo = join(dir, 'cargo') - mkdirSync(join(cargo, 'bin'), { recursive: true }) - writeFileSync(join(cargo, 'bin', 'rg'), "#!/bin/sh\nprintf 'found.txt\\0'\n", { mode: 0o755 }) - vi.stubEnv('PATH', join(dir, 'empty-path')) - vi.stubEnv('CARGO_HOME', cargo) - configureRelayBundledRipgrep(undefined) - }) - - afterEach(() => { - vi.unstubAllEnvs() - configureRelayBundledRipgrep(undefined) - rmSync(dir, { recursive: true, force: true }) - }) - - it('uses the search PATH to distinguish a moved root from missing ripgrep', async () => { - await expect(listFilesWithRg(dir, [], { maxResults: 10 })).resolves.toContain('found.txt') - const missing = join(dir, 'moved-root') - await expect(listFilesWithRg(missing)).rejects.toThrow( - `Search root is not reachable: ${missing}` - ) - await expect(searchWithRg(missing, 'needle', { maxResults: 10 })).rejects.toThrow( - `Search root is not reachable: ${missing}` - ) - }) - } -) diff --git a/src/relay/relay-runtime-identity.ts b/src/relay/relay-runtime-identity.ts deleted file mode 100644 index 35f677833e5..00000000000 --- a/src/relay/relay-runtime-identity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import path from 'node:path' -import process from 'node:process' -import type { RelayRuntimeKind } from '../shared/relay-runtime-self-test-report' - -const RUNTIME_DIR_PATTERN = /^node-[0-9a-f]{64}$/ - -/** - * Which runtime is executing this relay: Orca's pinned Node lives at - * `runtimes/node-/bin/node`, or `runtimes\node-\node.exe` on Windows - * (design D5); any other path is a host Node. - */ -export function describeRelayRuntime( - execPath: string = process.execPath, - paths: Pick = path -): { - kind: RelayRuntimeKind - version: string -} { - const { basename, dirname } = paths - const inStore = (runtimeDir: string): boolean => - RUNTIME_DIR_PATTERN.test(basename(runtimeDir)) && basename(dirname(runtimeDir)) === 'runtimes' - const posix = basename(dirname(execPath)) === 'bin' && inStore(dirname(dirname(execPath))) - const windows = basename(execPath).toLowerCase() === 'node.exe' && inStore(dirname(execPath)) - return { - kind: posix || windows ? 'pinned-node' : 'host-node', - version: process.versions.node - } -} diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts deleted file mode 100644 index 6cdef3ed1fc..00000000000 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayRuntimeServices } from './relay-runtime-services' - -afterEach(() => vi.restoreAllMocks()) - -function fixture() { - const agents = vi.fn(async () => {}) - const skill = vi.fn(async () => {}) - const vault = vi.fn(async () => {}) - const responses = vi.fn(async () => {}) - const fileStreams = vi.fn(async () => {}) - const watchers = vi.fn(async () => {}) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shutdown paths only read the owners stubbed here. - const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { - agentExecHandler: { dispose: agents }, - skillInstallHandler: { dispose: skill }, - aiVaultService: { dispose: vault }, - responseStreams: { disposeAllAndWait: responses }, - fsHandler: { disposeFileStreams: fileStreams, disposeWatchers: watchers } - }) as RelayRuntimeServices - return { runtime, agents, skill, vault, responses, fileStreams, watchers } -} - -it.each(['agents', 'responses', 'fileStreams', 'watchers'] as const)( - 'does not acknowledge cleanup before %s have settled', - async (owner) => { - const f = fixture() - let release!: () => void - f[owner].mockReturnValue( - new Promise((resolve) => { - release = resolve - }) - ) - const finished = vi.fn() - const shutdown = f.runtime.disposeOwnedProcesses().then(finished) - expect(f[owner]).toHaveBeenCalledOnce() - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - release() - await shutdown - expect(finished).toHaveBeenCalledOnce() - } -) - -it('rejects stream drain failures after attempting every owner and supports retry', async () => { - const f = fixture() - const responseError = new Error('response drain failed') - const fileError = new Error('file drain failed') - f.responses.mockRejectedValueOnce(responseError) - f.fileStreams.mockRejectedValueOnce(fileError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [responseError, fileError] - }) - expect(f.skill, 'a deferrable pass leaves one-way services serving').not.toHaveBeenCalled() - expect(f.vault).not.toHaveBeenCalled() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) - -it('keeps exit-only skill and AI Vault cleanup failures log-and-continue', async () => { - const f = fixture() - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - f.skill.mockRejectedValueOnce(new Error('skill cleanup failed')) - f.vault.mockRejectedValueOnce(new Error('vault cleanup failed')) - await expect(f.runtime.disposeExitOnlyServices()).resolves.toBeUndefined() - expect(f.skill).toHaveBeenCalledOnce() - expect(f.vault).toHaveBeenCalledOnce() -}) - -it('handles hosts without a vault service', async () => { - const f = fixture() - Object.assign(f.runtime, { aiVaultService: null }) - await expect(f.runtime.disposeExitOnlyServices()).resolves.toBeUndefined() - expect(f.vault).not.toHaveBeenCalled() -}) - -it('rejects a failed agent or watcher shutdown after cleaning every other owner', async () => { - const f = fixture() - const agentError = new Error('agent cleanup failed') - const watcherError = new Error('watcher cleanup failed') - f.agents.mockRejectedValueOnce(agentError) - f.watchers.mockRejectedValueOnce(watcherError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [agentError, watcherError] - }) - expect(f.fileStreams).toHaveBeenCalledOnce() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) diff --git a/src/relay/relay-runtime-self-test.test.ts b/src/relay/relay-runtime-self-test.test.ts deleted file mode 100644 index 41e52c07404..00000000000 --- a/src/relay/relay-runtime-self-test.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path, { dirname, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { describeRelayRuntime } from './relay-runtime-identity' -import { runRelayRuntimeSelfTest } from './relay-runtime-self-test' -import { relayConptyDllSpawnOptions } from './relay-windows-conpty' - -const nodePtyDir = dirname(require.resolve('node-pty/package.json')) -const directories: string[] = [] -afterEach(() => { - for (const dir of directories.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('relay runtime identity', () => { - it('names the pinned store layout pinned-node and anything else host-node', () => { - const sha = 'a'.repeat(64) - expect(describeRelayRuntime(`/h/.orca-remote/runtimes/node-${sha}/bin/node`).kind).toBe( - 'pinned-node' - ) - expect(describeRelayRuntime('/usr/bin/node').kind).toBe('host-node') - expect(describeRelayRuntime(`/tmp/node-${sha}/bin/node`).kind).toBe('host-node') - expect(describeRelayRuntime('/usr/bin/node').version).toBe(process.versions.node) - }) - - it('recognizes node.exe at the Windows store root', () => { - const sha = 'b'.repeat(64) - const win = (execPath: string): string => describeRelayRuntime(execPath, path.win32).kind - expect(win(`C:\\Users\\u\\.orca-remote\\runtimes\\node-${sha}\\node.exe`)).toBe('pinned-node') - expect(win(`C:/Users/u/.orca-remote/runtimes/node-${sha}/NODE.EXE`)).toBe('pinned-node') - expect(win('C:\\Program Files\\nodejs\\node.exe')).toBe('host-node') - expect(win(`C:\\Users\\u\\runtimes\\node-${sha}\\bun.exe`)).toBe('host-node') - }) -}) - -function fakeWindowsNodePty(withConpty: boolean): string { - const dir = mkdtempSync(join(tmpdir(), 'relay-win-pty-')) - directories.push(dir) - const release = join(dir, 'build', 'Release') - mkdirSync(join(release, 'conpty'), { recursive: true }) - writeFileSync(join(release, 'conpty.node'), 'not a real addon') - if (withConpty) { - writeFileSync(join(release, 'conpty', 'conpty.dll'), 'dll') - writeFileSync(join(release, 'conpty', 'OpenConsole.exe'), 'exe') - } - return dir -} - -describe('Windows bundled ConPTY', () => { - it('opts only a pinned-Node relay on win32 into the bundled DLL', () => { - const dir = fakeWindowsNodePty(true) - expect(relayConptyDllSpawnOptions(dir, 'pinned-node', 'win32')).toEqual({ useConptyDll: true }) - expect(relayConptyDllSpawnOptions(dir, 'host-node', 'win32')).toEqual({}) - expect(relayConptyDllSpawnOptions(dir, 'pinned-node', 'linux')).toEqual({}) - expect(relayConptyDllSpawnOptions(fakeWindowsNodePty(false), 'pinned-node', 'win32')).toEqual( - {} - ) - }) - - it('reports a quarantined ConPTY pair as a load failure before touching the addon', async () => { - const report = await runRelayRuntimeSelfTest('n', fakeWindowsNodePty(false), 'win32') - expect(report).toMatchObject({ ok: false, stage: 'load' }) - expect(report.ok === false && report.error).toMatch(/bundled ConPTY file missing after upload/) - }) - - it('looks for conpty.node, not pty.node, on win32', async () => { - const report = await runRelayRuntimeSelfTest('n', fakeWindowsNodePty(true), 'win32') - // The fake addon cannot load here; reaching dlopen proves the Windows binding was chosen. - expect(report).toMatchObject({ ok: false, stage: 'load' }) - expect(report.ok === false && report.error).not.toMatch(/no PTY binding/) - }) -}) - -describe.skipIf(process.platform === 'win32')('relay runtime self-test', () => { - it('loads pty.node and opens and closes a PTY, echoing the nonce', async () => { - const report = await runRelayRuntimeSelfTest('nonce-1', nodePtyDir) - expect(report).toMatchObject({ nonce: 'nonce-1', ok: true, node: process.version }) - }) - - it('reports a load-stage failure when the binding is absent', async () => { - const empty = mkdtempSync(join(tmpdir(), 'relay-selftest-')) - directories.push(empty) - const report = await runRelayRuntimeSelfTest('n', empty) - expect(report).toMatchObject({ ok: false, stage: 'load' }) - }) -}) diff --git a/src/relay/relay-runtime-self-test.ts b/src/relay/relay-runtime-self-test.ts deleted file mode 100644 index 271dc7b853c..00000000000 --- a/src/relay/relay-runtime-self-test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * `relay.js --orca-runtime-selftest `: prove this runtime can load the shipped - * node-pty binding and open a PTY before a client launches a daemon on it (design D5). - * - * Why a separate process run rather than trusting the daemon's first spawn: a runtime - * that cannot load the addon must be refused before a daemon holds the version dir, so - * the client can still choose another runtime for that directory. - */ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import process from 'node:process' -import type * as NodePty from 'node-pty' -import { detectNativeHostAbi } from '../main/orcad/native-host-abi' -import { - RELAY_RUNTIME_SELF_TEST_PREFIX, - type RelayRuntimeSelfTestReport -} from '../shared/relay-runtime-self-test-report' -import { describeRelayRuntime } from './relay-runtime-identity' -import { relayBundledConptyPaths } from './relay-windows-conpty' - -const PTY_EXIT_TIMEOUT_MS = 10_000 - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function ptyBindingPath(nodePtyDir: string, platform: NodeJS.Platform): string | null { - const binding = platform === 'win32' ? 'conpty.node' : 'pty.node' - for (const dir of ['build/Release', 'build/Debug']) { - const candidate = join(nodePtyDir, dir, binding) - if (existsSync(candidate)) { - return candidate - } - } - return null -} - -function openAndClosePty(pty: typeof NodePty, platform: NodeJS.Platform): Promise { - return new Promise((resolve, reject) => { - const common = { name: 'xterm', cols: 80, rows: 24, cwd: process.cwd() } - const child = - platform === 'win32' - ? // Why the bundled DLL: it is the ConPTY a pinned relay's terminals run on. - pty.spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', 'exit 0'], { - ...common, - env: { ...process.env }, - useConptyDll: true - }) - : pty.spawn('/bin/sh', ['-c', 'exit 0'], { - ...common, - env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } - }) - const timer = setTimeout(() => { - child.kill() - reject(new Error(`the PTY child did not exit within ${PTY_EXIT_TIMEOUT_MS / 1000}s`)) - }, PTY_EXIT_TIMEOUT_MS) - child.onExit(() => { - clearTimeout(timer) - resolve() - }) - }) -} - -export async function runRelayRuntimeSelfTest( - nonce: string, - nodePtyDir: string = join(__dirname, 'node_modules', 'node-pty'), - platform: NodeJS.Platform = process.platform -): Promise { - const abi = detectNativeHostAbi() - const base = { - nonce, - node: process.version, - napi: process.versions.napi ?? null, - glibcVersionRuntime: abi.glibcVersion, - runtime: describeRelayRuntime().kind - } - const binding = ptyBindingPath(nodePtyDir, platform) - if (!binding) { - return { ...base, ok: false, stage: 'load', error: `no PTY binding under ${nodePtyDir}` } - } - if (platform === 'win32') { - // The upload completed, so a file gone now was removed on the host (AV quarantine). - const missing = relayBundledConptyPaths(nodePtyDir).find((path) => !existsSync(path)) - if (missing) { - return { - ...base, - ok: false, - stage: 'load', - error: `bundled ConPTY file missing after upload: ${missing}` - } - } - } - try { - // Why dlopen first: node-pty's loader rethrows only its last attempt, which hides the - // dynamic loader's message the client classifies (GLIBC_x not found, missing .so). - process.dlopen({ exports: {} }, binding) - } catch (error) { - return { ...base, ok: false, stage: 'load', error: errorText(error) } - } - let pty: typeof NodePty - try { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty's own entry, the module the relay's PtyHandler requires by the same path. - pty = require(join(nodePtyDir, 'lib', 'index.js')) as typeof NodePty - } catch (error) { - return { ...base, ok: false, stage: 'load', error: errorText(error) } - } - try { - await openAndClosePty(pty, platform) - } catch (error) { - return { ...base, ok: false, stage: 'spawn', error: errorText(error) } - } - return { ...base, ok: true } -} - -export async function runRelayRuntimeSelfTestCommand(nonce: string): Promise { - const report = await runRelayRuntimeSelfTest(nonce) - process.stdout.write(`${RELAY_RUNTIME_SELF_TEST_PREFIX}${JSON.stringify(report)}\n`, () => - process.exit(0) - ) - return new Promise(() => {}) -} diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts deleted file mode 100644 index e7306c1a3ca..00000000000 --- a/src/relay/relay-runtime-services.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { parseUnameToRelayPlatform, RELAY_REMOTE_DIR } from '../main/ssh/relay-protocol' -import { DEFAULT_AI_VAULT_SEARCH_SETTINGS } from '../shared/ai-vault-search-settings' -import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' -import { installInProcessSessionSearchService } from '../main/ai-vault-search/session-search-in-process-service' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { RelayContext, expandTilde } from './context' -import { PtyHandler } from './pty-handler' -import { FsHandler } from './fs-handler' -import { GitHandler } from './git-handler' -import { GitResponseStreamRegistry } from './git-response-stream' -import { PreflightHandler } from '../wsl-guest/preflight-handler' -import { ExternalAutomationsHandler } from './external-automations-handler' -import { PortScanHandler } from './port-scan-handler' -import { AgentExecHandler } from './agent-exec-handler' -import { WorkspaceSessionHandler } from './workspace-session-handler' -import { AiVaultHandler } from './ai-vault-handler' -import { createRelayAiVaultService } from './ai-vault-service-factory' -import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SkillInstallHandler } from './skill-install-handler' -import { relayLogLine } from './relay-diagnostic-log' -import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' -import { errorMessage } from '../shared/error-message' - -export class RelayRuntimeServices { - readonly ptyHandler: PtyHandler - readonly ptyConsumerSessionAdapter: SshPtyConsumerSessionAdapter - readonly ptySourcePublication: RelayPtySourcePublication - readonly fsHandler: FsHandler - readonly gitHandler: GitHandler - readonly skillInstallHandler: SkillInstallHandler - readonly agentExecHandler: AgentExecHandler - private readonly responseStreams: GitResponseStreamRegistry - private readonly aiVaultService: ReturnType | null - private readonly sessionSearch: { dispose(): void } | null - - constructor( - readonly dispatcher: RelayDispatcher, - graceTimeMs: number, - launchVersion: string - ) { - const context = new RelayContext() - this.registerSessionHandlers(context) - this.ptyHandler = new PtyHandler(dispatcher, graceTimeMs) - this.ptyConsumerSessionAdapter = new SshPtyConsumerSessionAdapter( - dispatcher, - launchVersion, - (id, paused) => this.ptyHandler.setConsumerDeliveryPaused(id, paused), - (id) => this.ptyHandler.handleSourceCreditAvailable(id) - ) - // Why wired after construction: the handler is built first, but PTY ownership has to be - // attested from the consumer grant the adapter holds. - this.ptyHandler.setConsumerIdentityResolver((clientId) => - this.ptyConsumerSessionAdapter.clientInstanceIdFor(clientId) - ) - this.ptySourcePublication = new RelayPtySourcePublication( - dispatcher, - this.ptyConsumerSessionAdapter, - (id) => this.ptyHandler.handleSourcePublicationCapacity(id) - ) - this.ptyHandler.setSourcePublication(this.ptySourcePublication) - - // Why one instance for both handlers: a client reassembles a streamed reply by `streamId` alone, - // so two registries would hand out the same id, and only GitHandler routes the `git.responseAck` - // credit every pump waits on. A second registry is not an option — see git-response-stream.ts. - const responseStreams = new GitResponseStreamRegistry() - this.responseStreams = responseStreams - this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) - const watchRegistry = this.fsHandler.getWatchRegistry() - this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) - watchRegistry.setWorktreePtyTeardown((rootPath) => - this.ptyHandler.shutdownForWorktreePath(rootPath) - ) - this.gitHandler = new GitHandler(dispatcher, context, watchRegistry, responseStreams) - new PreflightHandler(dispatcher) - this.skillInstallHandler = new SkillInstallHandler(dispatcher) - new ExternalAutomationsHandler(dispatcher) - new PortScanHandler(dispatcher) - this.agentExecHandler = new AgentExecHandler(dispatcher) - new WorkspaceSessionHandler(dispatcher) - const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) - const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined - this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null - // Why beside the AI Vault sidecar and not inside it: that sidecar runs the - // remote scanner, which reads through a filesystem provider and publishes - // nothing to the transcript channel the index consumes. This process is the - // one that would drive the index's own reads, and the only writer on the file. - // Off until something can carry consent to a remote host (see the PR body); - // registering it anyway is what makes this host answer `disabled` and not - // `no-service`, which is the difference between off and too old. - this.sessionSearch = installInProcessSessionSearchService({ - dataRoot: join(homedir(), RELAY_REMOTE_DIR), - roots: { executionHostId: LOCAL_EXECUTION_HOST_ID }, - settings: DEFAULT_AI_VAULT_SEARCH_SETTINGS, - onError: (error) => relayLogLine(`[relay] session search: ${errorMessage(error)}`) - }) - new AiVaultHandler(dispatcher, { hostPlatform, service: this.aiVaultService ?? undefined }) - - registerRelayPluginHostCallHandlers( - dispatcher, - () => null, - () => ({ grantedCapabilities: null, services: null }) - ) - this.registerRemoteCliRoutes() - } - - // Why: answering a stream's request does not prove its detached pumps, descriptors or children - // are gone; only these registry drains do. - async disposeOwnedProcesses(): Promise { - const owned = await Promise.allSettled([ - this.agentExecHandler.dispose(), - this.responseStreams.disposeAllAndWait(), - this.fsHandler.disposeFileStreams(), - this.fsHandler.disposeWatchers() - ]) - const failures = owned.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - // Why: an unclosed fd, watcher child or agent child defers shutdown so the next attempt retries it. - if (failures.length > 0) { - throw new AggregateError(failures, 'relay_owned_process_shutdown_incomplete') - } - } - - /** - * One-way disposals, so they run only once shutdown can no longer defer; a deferred relay keeps - * serving reconnecting clients. Log-and-continue: they never hold the exit. - */ - async disposeExitOnlyServices(): Promise { - await this.skillInstallHandler.dispose().catch((error) => { - relayLogLine(`[relay] Skill upload cleanup failed: ${errorMessage(error)}`) - }) - await this.aiVaultService?.dispose().catch((error) => { - relayLogLine(`[relay] AI Vault sidecar shutdown failed: ${errorMessage(error)}`) - }) - } - - reopenOwnedProcesses(): void { - this.agentExecHandler.reopen() - this.responseStreams.reopen() - this.fsHandler.reopen() - } - - disposeHandlers(): void { - this.sessionSearch?.dispose() - this.fsHandler.dispose() - this.gitHandler.dispose() - } - - private registerSessionHandlers(context: RelayContext): void { - const registerRoot = (params: Record): void => { - if (typeof params.rootPath === 'string' && params.rootPath) { - context.registerRoot(params.rootPath) - } - } - this.dispatcher.onNotification('session.registerRoot', (params) => registerRoot(params)) - this.dispatcher.onRequest('session.registerRoot', async (params) => { - registerRoot(params) - return { ok: true } - }) - this.dispatcher.onRequest('session.resolveHome', async (params) => { - if (typeof params.path !== 'string') { - throw new Error('session.resolveHome: path must be a string') - } - return { resolvedPath: expandTilde(params.path) } - }) - } - - private registerRemoteCliRoutes(): void { - this.dispatcher.onRequest('orca.cli', async (params, context) => - this.dispatcher.requestAnyClient('orca.cli', params, { - excludeClientId: context.clientId, - timeoutMs: remoteCliRequestTimeoutMs(params) - }) - ) - this.dispatcher.onRequest('orca.cli.postOutput', async (params, context) => - this.dispatcher.requestAnyClient('orca.cli.postOutput', params, { - excludeClientId: context.clientId, - timeoutMs: remoteCliRequestTimeoutMs(params) - }) - ) - } -} diff --git a/src/relay/relay-socket-ownership.ts b/src/relay/relay-socket-ownership.ts deleted file mode 100644 index 45200c2a93d..00000000000 --- a/src/relay/relay-socket-ownership.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { existsSync, statSync, unlinkSync } from 'node:fs' -import { createConnection, createServer, type Server, type Socket } from 'node:net' -import { relayLogLine } from './relay-diagnostic-log' - -const STALE_SOCKET_PROBE_TIMEOUT_MS = 500 - -type SocketIdentity = { - dev: bigint - ino: bigint - ctimeNs: bigint -} - -function sameSocketIdentity(a: SocketIdentity, b: SocketIdentity): boolean { - return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs -} - -// Why both codes: libuv reports a path that already exists as EADDRINUSE once someone listens on -// it and as EEXIST while the file exists but its owner has not reached listen() yet (macOS), so a -// bind racing another starter sees either. Both mean "held or stale", never "free". -function isSocketPathOccupiedError(error: NodeJS.ErrnoException): boolean { - return error.code === 'EADDRINUSE' || error.code === 'EEXIST' -} - -export function isRelayNamedPipePath(sockPath: string): boolean { - return process.platform === 'win32' && /^\\\\[.?]\\pipe\\/i.test(sockPath) -} - -export class RelaySocketOwnership { - private ownsSocketPath = false - private ownedSocketIdentity: SocketIdentity | null = null - private socketServer: Server | null = null - - constructor(readonly sockPath: string) {} - - get server(): Server | null { - return this.socketServer - } - - get owned(): boolean { - return this.ownsSocketPath - } - - ownsCurrentPath(): boolean { - if (isRelayNamedPipePath(this.sockPath)) { - return this.ownsSocketPath - } - const currentIdentity = this.readIdentity() - return ( - this.ownsSocketPath && - this.ownedSocketIdentity !== null && - currentIdentity !== null && - sameSocketIdentity(currentIdentity, this.ownedSocketIdentity) - ) - } - - async listen(onConnection: (socket: Socket) => void): Promise { - const server = createServer(onConnection) - const shouldSetUmask = !isRelayNamedPipePath(this.sockPath) - const previousUmask = shouldSetUmask ? process.umask(0o177) : 0 - let umaskRestored = false - const restoreUmask = (): void => { - if (shouldSetUmask && !umaskRestored) { - process.umask(previousUmask) - umaskRestored = true - } - } - - await new Promise((resolve, reject) => { - let staleRetryAttempted = false - - const removeStartupListeners = (): void => { - server.off('listening', onListening) - server.off('error', onInitialError) - server.off('error', failInitial) - } - const listenForStartupError = (onError: (error: NodeJS.ErrnoException) => void): void => { - server.once('listening', onListening) - server.once('error', onError) - server.listen(this.sockPath) - } - const onListening = (): void => { - removeStartupListeners() - restoreUmask() - this.ownsSocketPath = true - this.ownedSocketIdentity = this.readIdentity() - this.socketServer = server - server.on('error', (error) => { - relayLogLine(`[relay] Socket server error: ${error.message}`) - }) - relayLogLine(`[relay] Socket server listening: ${this.sockPath}`) - resolve() - } - const failInitial = (error: NodeJS.ErrnoException): void => { - removeStartupListeners() - restoreUmask() - if (isSocketPathOccupiedError(error)) { - relayLogLine( - `[relay] Socket path already in use: ${this.sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.` - ) - } else { - relayLogLine(`[relay] Socket server error before listen: ${error.message}`) - } - reject(error) - } - const onInitialError = (error: NodeJS.ErrnoException): void => { - if ( - !isSocketPathOccupiedError(error) || - staleRetryAttempted || - isRelayNamedPipePath(this.sockPath) - ) { - failInitial(error) - return - } - staleRetryAttempted = true - this.probeBlockedPath(error, failInitial, () => { - relayLogLine(`[relay] Removed stale socket at ${this.sockPath} and retrying listen`) - removeStartupListeners() - listenForStartupError(failInitial) - }) - } - listenForStartupError(onInitialError) - }) - return server - } - - closeAndCleanup(): void { - if (this.socketServer && this.ownsCurrentPath()) { - this.socketServer.close() - } - this.cleanup() - } - - cleanup(): void { - if (this.ownsCurrentPath()) { - this.unlinkPath() - } - this.ownsSocketPath = false - this.ownedSocketIdentity = null - } - - private readIdentity(): SocketIdentity | null { - if (isRelayNamedPipePath(this.sockPath)) { - return null - } - try { - const stat = statSync(this.sockPath, { bigint: true }) - return { dev: stat.dev, ino: stat.ino, ctimeNs: stat.ctimeNs } - } catch { - return null - } - } - - private probeBlockedPath( - listenError: NodeJS.ErrnoException, - fail: (error: NodeJS.ErrnoException) => void, - retry: () => void - ): void { - const blockedIdentity = this.readIdentity() - const probe = createConnection({ path: this.sockPath }) - let settled = false - let timeout: NodeJS.Timeout | null = null - const finish = (callback: () => void): void => { - if (settled) { - return - } - settled = true - if (timeout) { - clearTimeout(timeout) - } - callback() - } - probe.once('connect', () => { - finish(() => { - probe.destroy() - fail(listenError) - }) - }) - probe.once('error', (probeError: NodeJS.ErrnoException) => { - finish(() => { - if ( - (probeError.code !== 'ECONNREFUSED' && probeError.code !== 'ENOENT') || - !this.unlinkIfStillStale(blockedIdentity) - ) { - fail(listenError) - return - } - retry() - }) - }) - timeout = setTimeout(() => { - finish(() => { - probe.destroy() - fail(listenError) - }) - }, STALE_SOCKET_PROBE_TIMEOUT_MS) - } - - private unlinkIfStillStale(blockedIdentity: SocketIdentity | null): boolean { - const currentIdentity = this.readIdentity() - if (currentIdentity === null) { - return true - } - if (blockedIdentity === null || !sameSocketIdentity(currentIdentity, blockedIdentity)) { - return false - } - try { - unlinkSync(this.sockPath) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'ENOENT' - } - } - - private unlinkPath(): void { - if (isRelayNamedPipePath(this.sockPath)) { - return - } - try { - if (existsSync(this.sockPath)) { - unlinkSync(this.sockPath) - } - } catch { - // Best-effort cleanup. - } - } -} diff --git a/src/relay/relay-socket-peer-close.test.ts b/src/relay/relay-socket-peer-close.test.ts deleted file mode 100644 index b3e09d23a42..00000000000 --- a/src/relay/relay-socket-peer-close.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Socket } from 'node:net' -import { isRelaySocketPeerClosed } from './relay-socket-peer-close' - -const coded = (code: string): Error => Object.assign(new Error(code), { code }) - -describe('isRelaySocketPeerClosed', () => { - it('treats EPIPE and ECONNRESET write failures as the peer leaving', () => { - expect(isRelaySocketPeerClosed(new Socket(), coded('EPIPE'))).toBe(true) - expect(isRelaySocketPeerClosed(new Socket(), coded('ECONNRESET'))).toBe(true) - }) - - it('does not treat errors our own teardown can raise as a peer close', () => { - for (const code of [ - 'ECANCELED', - 'ECONNABORTED', - 'ERR_STREAM_DESTROYED', - 'ERR_STREAM_WRITE_AFTER_END' - ]) { - expect(isRelaySocketPeerClosed(new Socket(), coded(code))).toBe(false) - } - expect(isRelaySocketPeerClosed(new Socket())).toBe(false) - }) - - it('attributes ERR_STREAM_DESTROYED to the peer only when a peer error destroyed the socket', () => { - const reset = new Socket() - reset.on('error', () => {}) - reset.destroy(coded('ECONNRESET')) - expect(isRelaySocketPeerClosed(reset, coded('ERR_STREAM_DESTROYED'))).toBe(true) - - const ours = new Socket() - ours.destroy() - expect(isRelaySocketPeerClosed(ours, coded('ERR_STREAM_DESTROYED'))).toBe(false) - }) -}) diff --git a/src/relay/relay-socket-peer-close.ts b/src/relay/relay-socket-peer-close.ts deleted file mode 100644 index 1246d18404d..00000000000 --- a/src/relay/relay-socket-peer-close.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Socket } from 'node:net' - -// Why only these two: libuv folds the Windows pipe breaks (ERROR_NO_DATA, ERROR_PIPE_NOT_CONNECTED, -// ERROR_BAD_PIPE) into EPIPE and ERROR_NETNAME_DELETED/WSAECONNRESET into ECONNRESET, and both -// mean the other end went away. ECANCELED and ECONNABORTED can come from our own close, so a -// teardown we started would clamp a live owner's grace (see pty-consumer-owner-admission.ts). -const PEER_CLOSED_SOCKET_ERROR_CODES: ReadonlySet = new Set(['EPIPE', 'ECONNRESET']) - -function hasPeerClosedCode(error: unknown): boolean { - if (typeof error !== 'object' || error === null || !('code' in error)) { - return false - } - return typeof error.code === 'string' && PEER_CLOSED_SOCKET_ERROR_CODES.has(error.code) -} - -/** - * Whether a failed socket write (or a write attempted after destroy) is evidence the peer left. - * ERR_STREAM_DESTROYED and a bare destroyed socket carry no cause of their own, so they count only - * when the socket itself was destroyed by a peer-caused error; a plain destroy() leaves no error. - */ -export function isRelaySocketPeerClosed(socket: Socket, writeError?: unknown): boolean { - return hasPeerClosedCode(writeError) || hasPeerClosedCode(socket.errored) -} diff --git a/src/relay/relay-stream-ack-window.ts b/src/relay/relay-stream-ack-window.ts deleted file mode 100644 index 7d1f11b7807..00000000000 --- a/src/relay/relay-stream-ack-window.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { STREAM_ACK_STALL_RECHECK_MS } from '../wsl-guest/protocol' - -/** Waiters wake on ack, abort, or a stall recheck so a vanished client cannot strand a pump. */ -export class RelayStreamAckWindow { - aborted = false - /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ - ackedThroughSeq = -1 - private readonly waiters = new Set<() => void>() - - recordAck(seq: number): void { - if (typeof seq !== 'number' || !Number.isFinite(seq)) { - return - } - if (seq > this.ackedThroughSeq) { - this.ackedThroughSeq = seq - } - this.wake() - } - - abort(): void { - this.aborted = true - this.wake() - } - - wake(): void { - for (const waiter of Array.from(this.waiters)) { - waiter() - } - } - - wait(): Promise { - if (this.aborted) { - return Promise.resolve() - } - return new Promise((resolve) => { - let settled = false - const finish = (): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - this.waiters.delete(finish) - resolve() - } - const timer = setTimeout(finish, STREAM_ACK_STALL_RECHECK_MS) - timer.unref?.() - this.waiters.add(finish) - }) - } -} diff --git a/src/relay/relay-test-socket-path.ts b/src/relay/relay-test-socket-path.ts deleted file mode 100644 index 3bae458d66b..00000000000 --- a/src/relay/relay-test-socket-path.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createHash } from 'node:crypto' -import { join } from 'node:path' - -export function relayTestSocketPath(dir: string, name = 'relay.sock'): string { - if (process.platform !== 'win32') { - return join(dir, name) - } - - const suffix = createHash('sha256') - .update(`${process.pid}\0${dir}\0${name}`) - .digest('hex') - .slice(0, 20) - - // Why: Node's net server requires Windows IPC endpoints to be named pipes; - // filesystem-style .sock paths fail with EACCES on Windows hosts. - return `\\\\.\\pipe\\orca-relay-test-${suffix}` -} diff --git a/src/relay/relay-watch-root-capacity-gate.ts b/src/relay/relay-watch-root-capacity-gate.ts deleted file mode 100644 index c275e303507..00000000000 --- a/src/relay/relay-watch-root-capacity-gate.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - assertRelayWatcherRootCapacity, - exceedsRelayWatcherRootCapacity -} from './relay-watcher-root-capacity' - -type RelayWatchRootTeardowns = { - rootPaths: () => string[] - /** Resolves when every teardown in flight has settled, or undefined when none is. */ - settlePending: () => Promise | undefined -} - -/** - * Decides whether a prospective watch root fits, and waits out an over-cap that only unsubscribing - * roots are causing. - * - * Why waiting beats refusing: a reconnect tears the old roots down as it installs the new ones, so - * the cap is briefly full of slots already promised back. The client answers a capacity refusal - * with a 60s-to-30min dormancy that no release event can shorten, so refusing on a transient - * overlap costs half an hour of blindness. Mirrors WatcherSupervisorCapacityWait. - */ -export class RelayWatchRootCapacityGate { - // Why tracked: a root parked on the wait has been granted nothing, so counting its setup entry - // would let it hold a slot away from the root already reclaiming one. - private readonly waiting = new Set() - - constructor( - private readonly activeRoots: ReadonlyMap, - private readonly setupRoots: ReadonlyMap, - // Thunk: the registry builds its teardown tracker after this field initializes. - private readonly teardowns: () => RelayWatchRootTeardowns - ) {} - - assert(rootKey: string): void { - assertRelayWatcherRootCapacity( - this.activeRoots.keys(), - this.claimedSetupRoots(rootKey), - this.teardowns().rootPaths(), - rootKey - ) - } - - /** - * The wait to hold before {@link assert}, or undefined when there is nothing to wait for. - * - * Undefined rather than a resolved promise so an install that already fits stays synchronous — - * a suspension here would let a concurrent watch of the same root join the setup, not the watch. - */ - release(rootKey: string, signal?: AbortSignal): Promise | undefined { - if ( - !exceedsRelayWatcherRootCapacity( - this.activeRoots.keys(), - this.claimedSetupRoots(rootKey), - this.teardowns().rootPaths(), - rootKey - ) - ) { - return undefined - } - const released = this.teardowns().settlePending() - if (!released) { - return undefined - } - this.waiting.add(rootKey) - // Once, and never past the caller: a genuinely full cap must still reach the refusal that sends - // the client dormant, and an unsubscribe that never settles must not park the request with it. - let onAbort = (): void => {} - const abandoned = new Promise((resolve) => { - onAbort = () => resolve() - if (signal?.aborted) { - resolve() - } else { - signal?.addEventListener('abort', onAbort, { once: true }) - } - }) - return (signal ? Promise.race([released, abandoned]) : released).finally(() => { - signal?.removeEventListener('abort', onAbort) - this.waiting.delete(rootKey) - }) - } - - /** Setup roots that currently hold a slot — a parked capacity waiter holds none. */ - private claimedSetupRoots(rootKey: string): string[] { - return [...this.setupRoots.keys()].filter((key) => key === rootKey || !this.waiting.has(key)) - } -} diff --git a/src/relay/relay-watch-root-capacity.test.ts b/src/relay/relay-watch-root-capacity.test.ts deleted file mode 100644 index 8bf03b9ff6b..00000000000 --- a/src/relay/relay-watch-root-capacity.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { RelayContext } from './context' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { FsHandler } from './fs-handler' -import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback' -import { createMockDispatcher } from './relay-fs-test-dispatcher' -import { RelayWatchRootCapacityGate } from './relay-watch-root-capacity-gate' - -const { mockSubscribe } = vi.hoisted(() => ({ - mockSubscribe: vi.fn() -})) - -vi.mock('@parcel/watcher', () => ({ - subscribe: mockSubscribe -})) - -describe('relay watch-root capacity', () => { - let dispatcher: ReturnType - let handler: FsHandler - let tmpDir: string - - beforeEach(() => { - mockSubscribe.mockReset() - mockSubscribe.mockResolvedValue({ unsubscribe: vi.fn() }) - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-cap-')) - dispatcher = createMockDispatcher() - handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext(), { - dispose: vi.fn(), - forgetRoot: vi.fn(), - disposeAndWait: vi.fn(async () => {}), - reopen: vi.fn(), - subscribe: subscribeWithInProcessWatcher - }) - }) - - afterEach(async () => { - handler.dispose() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - it('removes an abort listener after capacity teardown settles', async () => { - const activeRoots = new Map(Array.from({ length: 20 }, (_, index) => [`active-${index}`, {}])) - let resolveTeardown!: () => void - const teardown = new Promise((resolve) => { - resolveTeardown = resolve - }) - const gate = new RelayWatchRootCapacityGate(activeRoots, new Map(), () => ({ - rootPaths: () => ['retiring'], - settlePending: () => teardown - })) - const controller = new AbortController() - const addListener = vi.spyOn(controller.signal, 'addEventListener') - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - - const waiting = gate.release('new-root', controller.signal) - expect(waiting).toBeDefined() - expect(addListener).toHaveBeenCalledTimes(1) - resolveTeardown() - await waiting - - expect(removeListener).toHaveBeenCalledTimes(1) - }) - - it.each([false, true])( - 'resolves abandoned capacity waits (already aborted=%s)', - async (alreadyAborted) => { - const activeRoots = new Map(Array.from({ length: 20 }, (_, index) => [`active-${index}`, {}])) - const gate = new RelayWatchRootCapacityGate(activeRoots, new Map(), () => ({ - rootPaths: () => ['retiring'], - settlePending: () => new Promise(() => {}) - })) - const controller = new AbortController() - if (alreadyAborted) { - controller.abort() - } - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - const waiting = gate.release('new-root', controller.signal) - expect(waiting).toBeDefined() - controller.abort() - await expect(waiting).resolves.toBeUndefined() - expect(removeListener).toHaveBeenCalledTimes(1) - } - ) - - it('blocks replacement watches behind physical unsubscribe and counts the pending slot', async () => { - let resolveUnsubscribe: () => void = () => {} - const unsubscribe = vi.fn( - () => - new Promise((resolve) => { - resolveUnsubscribe = resolve - }) - ) - mockSubscribe.mockResolvedValue({ unsubscribe }) - await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) - dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) - - const replacement = dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) - for (let index = 0; index < 19; index += 1) { - await dispatcher.callRequest('fs.watch', { - rootPath: path.join(tmpDir, `pending-cap-${index}`) - }) - } - // The replacement claims the slot the teardown releases, so this cap is genuinely full: the - // request waits for the release event and is still refused once it has happened. - const overCap = dispatcher - .callRequest('fs.watch', { rootPath: path.join(tmpDir, 'over-pending-cap') }) - .then( - () => null, - (error: Error) => error - ) - expect(mockSubscribe).toHaveBeenCalledTimes(20) - - resolveUnsubscribe() - await replacement - expect(await overCap).toMatchObject({ message: 'Maximum number of file watchers reached' }) - expect(mockSubscribe).toHaveBeenCalledTimes(21) - }) - - it('waits out a teardown that frees a slot instead of refusing on it', async () => { - let resolveUnsubscribe: () => void = () => {} - mockSubscribe.mockResolvedValue({ - unsubscribe: vi.fn( - () => - new Promise((resolve) => { - resolveUnsubscribe = resolve - }) - ) - }) - for (let index = 0; index < 20; index += 1) { - await dispatcher.callRequest('fs.watch', { rootPath: path.join(tmpDir, `full-${index}`) }) - } - dispatcher.callNotification('fs.unwatch', { rootPath: path.join(tmpDir, 'full-0') }) - - // Why not a refusal: the slot is already promised back, and the client answers a capacity - // refusal with a 60s-to-30min dormancy that no release event can shorten. - let settled = false - const fresh = dispatcher - .callRequest('fs.watch', { rootPath: path.join(tmpDir, 'fresh') }) - .then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - expect(mockSubscribe).toHaveBeenCalledTimes(20) - - resolveUnsubscribe() - await fresh - expect(mockSubscribe).toHaveBeenCalledTimes(21) - }) -}) diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts deleted file mode 100644 index ebddbc26260..00000000000 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ /dev/null @@ -1,860 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - DISPATCHER_CONTROL_QUEUE_MAX_BYTES, - DEFAULT_PRODUCER_QUEUE_MAX_BYTES, - DISPATCHER_CONTROL_QUEUE_MAX_FRAMES -} from '../wsl-guest/dispatcher-writer-admission' -import type { RelayClientSinkOptions, RelayClientWrite } from '../wsl-guest/dispatcher-writer-sink' -import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from '../wsl-guest/legacy-relay-publication-ledger' -import { HEADER_LENGTH, parseJsonRpcMessage } from '../wsl-guest/protocol' -import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' - -type WatcherFrameEvent = { kind: string; absolutePath: string; isDirectory?: boolean } - -function frameMessage(frame: Buffer): { method: string; params?: Record } { - const payloadLength = frame.readUInt32BE(9) - const message = parseJsonRpcMessage(frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)) - return 'method' in message - ? { method: message.method, params: message.params as Record | undefined } - : { method: '' } -} - -function frameMethod(frame: Buffer): string { - return frameMessage(frame).method -} - -function frameEvents(frame: Buffer): WatcherFrameEvent[] { - const params = frameMessage(frame).params - return (params?.events ?? []) as WatcherFrameEvent[] -} - -function createRecordingSink(highWaterMark?: number): { - frames: Buffer[] - closes: () => number - drainWaiters: (() => void)[] - blockNextWrite: () => void - write: RelayClientWrite - options: RelayClientSinkOptions -} { - const frames: Buffer[] = [] - const drainWaiters: (() => void)[] = [] - let closes = 0 - let blocked = false - const write: RelayClientWrite = (frame) => { - frames.push(Buffer.from(frame)) - if (blocked) { - blocked = false - return false - } - return true - } - const options: RelayClientSinkOptions = { - ...(highWaterMark === undefined ? {} : { writableHighWaterMark: () => highWaterMark }), - close: () => { - closes += 1 - }, - waitWriteDrain: (callback) => { - drainWaiters.push(callback) - return () => { - const index = drainWaiters.indexOf(callback) - if (index !== -1) { - drainWaiters.splice(index, 1) - } - } - } - } - return { - frames, - closes: () => closes, - drainWaiters, - blockNextWrite: () => { - blocked = true - }, - write, - options - } -} - -// A sink that acknowledges writes on demand, so frames can be held in flight one lane at a time. -function createCallbackSink(): { - frames: Buffer[] - settle: (index: number) => void - write: RelayClientWrite - options: RelayClientSinkOptions -} { - const frames: Buffer[] = [] - const settlements: ((result: { ok: true }) => void)[] = [] - const write: RelayClientWrite = (frame, onSettled) => { - frames.push(Buffer.from(frame)) - settlements.push(onSettled) - return true - } - return { - frames, - settle: (index) => settlements[index]?.({ ok: true }), - write, - options: { supportsWriteCallback: true, close: () => {} } - } -} - -function watcherBatch(count: number): { type: 'create'; path: string; isDirectory: false }[] { - return Array.from({ length: count }, (_unused, index) => ({ - type: 'create' as const, - path: `/workspace/project/src/module-${index}/component-${index}.tsx`, - isDirectory: false as const - })) -} - -function parentDir(absolutePath: string): string { - return absolutePath.slice(0, absolutePath.lastIndexOf('/')) -} - -// Fills the 2 MB producer queue so every subsequent watcher batch is rejected on the producer lane. -function saturateProducerQueue(dispatcher: RelayDispatcher): void { - let admitted = 0 - for (const chunk of [40_000, 1_000, 1]) { - while ( - admitted < 5_000 && - dispatcher.tryNotifyPtyData({ paneId: 'pane', data: 'x'.repeat(chunk) }) - ) { - admitted += 1 - } - } - expect(admitted).toBeGreaterThan(0) - expect(admitted).toBeLessThan(5_000) -} - -// Parks PTY frames in the producer queue of a stalled sink without filling it. -function parkProducerBytes(dispatcher: RelayDispatcher, bytes: number): number { - const chunk = 'x'.repeat(40_000) - let parked = 0 - while (parked < bytes && dispatcher.tryNotifyPtyData({ paneId: 'pane', data: chunk })) { - parked += chunk.length - } - expect(parked).toBeGreaterThanOrEqual(bytes) - return parked -} - -// Parks PTY frames in ONE client's producer queue, leaving every peer's retention untouched. -function parkProducerBytesForClient( - dispatcher: RelayDispatcher, - clientId: number, - bytes: number -): number { - const chunk = 'x'.repeat(40_000) - let parked = 0 - while ( - parked < bytes && - dispatcher.publishProducerNotification(clientId, 'pty.data', { paneId: 'pane', data: chunk }) - ) { - parked += chunk.length - } - expect(parked).toBeGreaterThanOrEqual(bytes) - return parked -} - -function isOverflowFrame(frame: Buffer): boolean { - return frameEvents(frame).some((event) => event.kind === 'overflow') -} - -function watcherFrames(frames: readonly Buffer[]): Buffer[] { - return frames.filter((frame) => frameMethod(frame) === 'fs.changed') -} - -function deliveredEvents(events: readonly WatcherProcessEvent[]): WatcherFrameEvent[] { - return events.map((event) => ({ - kind: event.type, - absolutePath: event.path, - ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) - })) -} - -describe('relay watcher writer admission', () => { - it('keeps watcher batches on the producer lane while overflow markers ride the control lane', () => { - const sink = createRecordingSink() - sink.blockNextWrite() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, [ - { type: 'create', path: '/workspace/first', isDirectory: false } - ]) - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - dispatcher.notifyClient(1, 'control.event') - - expect(sink.frames.map(frameMethod)).toEqual(['fs.changed']) - sink.drainWaiters.shift()?.() - // The marker now shares the control lane with control.event and drains ahead of the producer lanes. - expect(sink.frames.map(frameMethod)).toEqual(['fs.changed', 'fs.changed', 'control.event']) - expect(frameEvents(sink.frames[1])).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - } finally { - dispatcher.dispose() - } - }) -}) - -describe('relay watcher overflow suppression key', () => { - it('scopes the outstanding marker per client as well as per root', () => { - const primary = createRecordingSink(65536) - const secondary = createRecordingSink(65536) - primary.blockNextWrite() - secondary.blockNextWrite() - const dispatcher = new RelayDispatcher(primary.write, primary.options) - - try { - dispatcher.attachClient(secondary.write, secondary.options) - // Two clients, same root: a key that collapses them would silently desync the second tree. - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - primary.drainWaiters.shift()?.() - secondary.drainWaiters.shift()?.() - - for (const sink of [primary, secondary]) { - expect(sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed')).toHaveLength(1) - expect(sink.closes()).toBe(0) - } - } finally { - dispatcher.dispose() - } - }) - - it('drops the marker instead of retaining it when the client is already gone', () => { - const primary = createRecordingSink(65536) - const secondary = createRecordingSink(65536) - const dispatcher = new RelayDispatcher(primary.write, primary.options) - const secondaryId = dispatcher.attachClient(secondary.write, secondary.options) - dispatcher.detachClient(secondaryId) - // The race the emitter has to survive: the id list was sampled before the client went away. - const activeIds = vi.spyOn(dispatcher, 'activeClientIds').mockReturnValue([secondaryId]) - const notify = vi.spyOn(dispatcher, 'tryNotifyClient') - - try { - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - // A second attempt proves nothing was retained: a removed client has no tree left to resync. - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(notify).toHaveBeenCalledTimes(2) - expect(watcherFrames(secondary.frames)).toHaveLength(0) - } finally { - notify.mockRestore() - activeIds.mockRestore() - dispatcher.dispose() - } - }) - - it('republishes an admitted marker the replaced sink never wrote', () => { - const stalled = createRecordingSink(65536) - const replacement = createRecordingSink(65536) - stalled.blockNextWrite() - const dispatcher = new RelayDispatcher(stalled.write, stalled.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - // Saturates the sink without filling the control queue, so the marker is admitted and then parked. - dispatcher.notifyClient(clientId, 'control.warmup') - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(stalled.frames)).toHaveLength(0) - - // setWrite fails every queued frame; an admitted-but-unwritten marker is as lost as a rejected one. - dispatcher.setWrite(replacement.write, replacement.options) - - expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - } finally { - dispatcher.dispose() - } - }) - - it('republishes an admitted marker when invalidateClient precedes the sink replacement', () => { - const stalled = createRecordingSink(65536) - const replacement = createRecordingSink(65536) - stalled.blockNextWrite() - const dispatcher = new RelayDispatcher(stalled.write, stalled.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - dispatcher.notifyClient(clientId, 'control.warmup') - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(stalled.frames)).toHaveLength(0) - - // A dropped SSH channel invalidates the primary — a detach that keeps the id, so the marker the - // failed settlement retains must survive it and ride out on the reconnected sink. - dispatcher.invalidateClient() - dispatcher.setWrite(replacement.write, replacement.options) - - expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - } finally { - dispatcher.dispose() - } - }) - - it('retries a marker rejected by a full control queue once capacity returns', () => { - const sink = createRecordingSink(65536) - sink.blockNextWrite() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { - dispatcher.notifyClient(clientId, `control.${index}`) - } - - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(sink.frames)).toHaveLength(0) - expect(sink.closes()).toBe(0) - - // No second emit: the watcher has no further trigger, so the retry must come from the retained marker. - sink.drainWaiters.shift()?.() - expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - expect(sink.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('leaves a retained marker alone until the control lane actually frees a slot', () => { - const sink = createCallbackSink() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - dispatcher.tryNotifyPtyData({ paneId: 'pane', data: 'x' }) - for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { - dispatcher.notifyClient(clientId, `control.${index}`) - } - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(sink.frames)).toHaveLength(0) - - const notify = vi.spyOn(dispatcher, 'tryNotifyClient') - // Capacity fires for every lane: a producer settlement frees no control slot, so re-encoding - // the marker here could only be rejected again. - sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'pty.data')) - expect(notify).not.toHaveBeenCalled() - - // A control settlement does free one, and the retained marker goes out on it. - sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'control.0')) - expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - notify.mockRestore() - } finally { - dispatcher.dispose() - } - }) - - it('waits until the control byte budget can admit the retained marker', () => { - const sink = createCallbackSink() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - const markerParams = { - events: [{ kind: 'overflow', absolutePath: '/workspace' }] - } - const markerBytes = dispatcher.notificationFrameBytes('fs.changed', markerParams) - const emptyFillBytes = dispatcher.notificationFrameBytes('control.byte-fill', { data: '' }) - const fillDataBytes = DISPATCHER_CONTROL_QUEUE_MAX_BYTES - markerBytes + 1 - emptyFillBytes - - dispatcher.tryNotifyPtyData({ paneId: 'pane', data: 'x' }) - dispatcher.notifyClient(clientId, 'control.byte-fill', { data: 'x'.repeat(fillDataBytes) }) - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(sink.frames)).toHaveLength(0) - - const notify = vi.spyOn(dispatcher, 'tryNotifyClient') - sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'pty.data')) - expect(notify).not.toHaveBeenCalled() - - sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'control.byte-fill')) - expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - notify.mockRestore() - } finally { - dispatcher.dispose() - } - }) - - it('keeps a retained marker per root and sends each exactly once', () => { - const sink = createRecordingSink(65536) - sink.blockNextWrite() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { - dispatcher.notifyClient(clientId, `control.${index}`) - } - - for (let index = 0; index < 3; index += 1) { - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - emitRelayWatcherOverflow(dispatcher, '/other', false) - } - sink.drainWaiters.shift()?.() - - // A retained root must neither duplicate nor suppress a peer root's resync. - expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' }, - { kind: 'overflow', absolutePath: '/other' } - ]) - expect(sink.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('republishes a retained marker against a replaced primary sink', () => { - const stalled = createRecordingSink(65536) - const replacement = createRecordingSink(65536) - stalled.blockNextWrite() - const dispatcher = new RelayDispatcher(stalled.write, stalled.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { - dispatcher.notifyClient(clientId, `control.${index}`) - } - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(stalled.frames)).toHaveLength(0) - - // setWrite replaces the sink without a detach, so the marker must follow the client, not the writer. - dispatcher.setWrite(replacement.write, replacement.options) - - expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - } finally { - dispatcher.dispose() - } - }) - - it('republishes a retained marker when invalidateClient precedes the sink replacement', () => { - const stalled = createRecordingSink(65536) - const replacement = createRecordingSink(65536) - stalled.blockNextWrite() - const dispatcher = new RelayDispatcher(stalled.write, stalled.options) - - try { - const clientId = dispatcher.activeClientIds()[0] - for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { - dispatcher.notifyClient(clientId, `control.${index}`) - } - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(stalled.frames)).toHaveLength(0) - - // The SSH reconnect order: invalidateClient detaches the primary without retiring the id, so the - // pending marker must survive that detach and ride out on the replacement sink. - dispatcher.invalidateClient() - dispatcher.setWrite(replacement.write, replacement.options) - - expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - } finally { - dispatcher.dispose() - } - }) - - it('forgets a retained marker when the client detaches', () => { - const primary = createRecordingSink(65536) - const secondary = createRecordingSink(65536) - secondary.blockNextWrite() - const dispatcher = new RelayDispatcher(primary.write, primary.options) - - try { - const secondaryId = dispatcher.attachClient(secondary.write, secondary.options) - for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { - dispatcher.notifyClient(secondaryId, `control.${index}`) - } - emitRelayWatcherOverflow(dispatcher, '/workspace', false) - expect(watcherFrames(secondary.frames)).toHaveLength(0) - - dispatcher.detachClient(secondaryId) - secondary.drainWaiters.shift()?.() - - expect(watcherFrames(secondary.frames)).toHaveLength(0) - // The primary's own marker went out on its first attempt and stays unaffected. - expect(watcherFrames(primary.frames).flatMap(frameEvents)).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - } finally { - dispatcher.dispose() - } - }) -}) - -describe('relay watcher batch chunking', () => { - it.each([ - [65536, 49152], - [16384, 12288] - ])('splits a 5000-event batch into frames that fit a %i-byte sink', (highWaterMark, capacity) => { - const sink = createRecordingSink(highWaterMark) - const dispatcher = new RelayDispatcher(sink.write, sink.options) - const events = watcherBatch(5000) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(sink.frames.length).toBeGreaterThan(1) - expect(sink.frames.every((frame) => frameMethod(frame) === 'fs.changed')).toBe(true) - expect(sink.frames.every((frame) => frame.length <= capacity)).toBe(true) - expect(sink.frames.flatMap(frameEvents)).toEqual( - events.map((event) => ({ - kind: event.type, - absolutePath: event.path, - isDirectory: event.isDirectory - })) - ) - expect(sink.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('encodes the empty envelope once instead of re-encoding candidate chunks', () => { - const sink = createRecordingSink(16384) - const dispatcher = new RelayDispatcher(sink.write, sink.options) - const budget = vi.spyOn(dispatcher, 'producerEnvelopeBudget') - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) - expect(budget).toHaveBeenCalledTimes(1) - expect(sink.frames.flatMap(frameEvents)).toHaveLength(5000) - } finally { - budget.mockRestore() - dispatcher.dispose() - } - }) - - it('sizes each event once across clients that need different chunk cuts', () => { - const primary = createRecordingSink(16384) - const secondary = createRecordingSink(65536) - const dispatcher = new RelayDispatcher(primary.write, primary.options) - const stringify = vi.spyOn(JSON, 'stringify') - const events = watcherBatch(5000) - - try { - dispatcher.attachClient(secondary.write, secondary.options) - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - const sizedEvents = stringify.mock.calls.filter(([value]) => { - if (typeof value !== 'object' || value === null) { - return false - } - return 'kind' in value && 'absolutePath' in value - }) - expect(sizedEvents).toHaveLength(events.length) - expect(primary.frames.flatMap(frameEvents)).toHaveLength(events.length) - expect(secondary.frames.flatMap(frameEvents)).toHaveLength(events.length) - } finally { - stringify.mockRestore() - dispatcher.dispose() - } - }) - - it('falls back to one overflow marker when a single event exceeds the frame budget', () => { - const sink = createRecordingSink(65536) - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, [ - { type: 'update', path: `/workspace/${'p'.repeat(60_000)}`, isDirectory: false } - ]) - - expect(sink.frames.map(frameMethod)).toEqual(['fs.changed']) - expect(frameEvents(sink.frames[0])).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - expect(sink.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('emits the overflow marker on the control lane when a chunk is rejected by a full producer queue', () => { - const sink = createRecordingSink(65536) - sink.blockNextWrite() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - // Fill the 2 MB producer queue down to a residue smaller than a one-event fs.changed frame. - saturateProducerQueue(dispatcher) - - emitRelayWatcherEvents(dispatcher, '/workspace', false, [ - { type: 'create', path: '/workspace/first', isDirectory: false } - ]) - sink.drainWaiters.shift()?.() - - const markers = sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed') - expect(markers).toHaveLength(1) - expect(frameEvents(markers[0])).toEqual([{ kind: 'overflow', absolutePath: '/workspace' }]) - // Control lane preempts: the marker lands before the producer frames still queued behind it. - expect(sink.frames.indexOf(markers[0])).toBeLessThan(sink.frames.length - 1) - expect(sink.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('keeps at most one outstanding overflow marker per root without closing the client', () => { - const sink = createRecordingSink(65536) - sink.blockNextWrite() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - saturateProducerQueue(dispatcher) - - // More rejected batches than the 256-frame control queue can hold: one marker each would close the link. - for (let index = 0; index < 300; index += 1) { - emitRelayWatcherEvents(dispatcher, '/workspace', false, [ - { type: 'create', path: `/workspace/file-${index}`, isDirectory: false } - ]) - } - // A second root must still get its own marker, or that tree silently desyncs. - emitRelayWatcherEvents(dispatcher, '/other', false, [ - { type: 'create', path: '/other/file', isDirectory: false } - ]) - expect(sink.closes()).toBe(0) - - sink.drainWaiters.shift()?.() - const markers = sink.frames - .filter((frame) => frameMethod(frame) === 'fs.changed') - .flatMap(frameEvents) - expect(markers).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' }, - { kind: 'overflow', absolutePath: '/other' } - ]) - expect(sink.closes()).toBe(0) - - // The slot clears once the marker drains, so a later batch can still resync the same root. - emitRelayWatcherEvents(dispatcher, '/workspace', false, [ - { type: 'create', path: '/workspace/later', isDirectory: false } - ]) - expect( - sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed').flatMap(frameEvents) - ).toHaveLength(3) - } finally { - dispatcher.dispose() - } - }) - - it('keeps one directory in one chunk when the batch interleaves directories', () => { - const sink = createRecordingSink(16384) - const dispatcher = new RelayDispatcher(sink.write, sink.options) - const directoryCount = 8 - const replacedPath = '/workspace/project/src/module-7/component-0.tsx' - const events: WatcherProcessEvent[] = [ - ...Array.from({ length: 200 }, (_unused, index) => ({ - type: 'create' as const, - path: `/workspace/project/src/module-${index % directoryCount}/component-${Math.floor(index / directoryCount)}.tsx`, - isDirectory: false - })), - { type: 'delete' as const, path: replacedPath } - ] - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(sink.frames.length).toBeGreaterThan(1) - const frameDirs = sink.frames.map( - (frame) => new Set(frameEvents(frame).map((event) => parentDir(event.absolutePath))) - ) - const dirs = Array.from(new Set(events.map((event) => parentDir(event.path)))) - expect(dirs).toHaveLength(directoryCount) - // Each directory in one frame; the renderer dedupes refreshes per payload, so extra frames = extra readDir RPCs. - const refreshes = frameDirs.reduce((total, frameDir) => total + frameDir.size, 0) - expect(refreshes).toBeLessThanOrEqual(directoryCount + sink.frames.length - 1) - - const delivered = sink.frames.flatMap(frameEvents) - for (const dir of dirs) { - const positions = delivered - .map((event, index) => ({ event, index })) - .filter((entry) => parentDir(entry.event.absolutePath) === dir) - .map((entry) => entry.index) - expect(positions.at(-1)! - positions[0]).toBe(positions.length - 1) - } - // Stable grouping keeps per-path order: the create still precedes the delete of the same path. - expect( - delivered.filter((event) => event.absolutePath === replacedPath).map((event) => event.kind) - ).toEqual(['create', 'delete']) - expect(delivered).toHaveLength(events.length) - } finally { - dispatcher.dispose() - } - }) - - it('does not report a drop for a batch it goes on to deliver in full', () => { - const sink = createRecordingSink(16384) - const dispatcher = new RelayDispatcher(sink.write, sink.options) - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - - try { - const events = watcherBatch(5000) - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(sink.frames.length).toBeGreaterThan(1) - expect(sink.frames.flatMap(frameEvents)).toHaveLength(events.length) - // A "Dropped fs.changed" line here would both lie and burn the one-per-generation - // suppression slot a genuine over-capacity drop needs. - const lines = stderr.mock.calls.map(([line]) => String(line)) - expect(lines.filter((line) => line.includes('fs.changed'))).toEqual([]) - } finally { - stderr.mockRestore() - dispatcher.dispose() - } - }) - - it('sizes chunks near the byte minimum instead of overshooting it', () => { - const sink = createRecordingSink(16384) - const capacity = 12288 - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) - - const totalBytes = sink.frames.reduce((total, frame) => total + frame.length, 0) - expect(sink.frames.length).toBeLessThanOrEqual(Math.ceil(totalBytes / capacity) + 1) - for (const frame of sink.frames.slice(0, -1)) { - expect(frame.length).toBeGreaterThanOrEqual(capacity * 0.9) - } - } finally { - dispatcher.dispose() - } - }) - - it('stops the chunk walk while the producer queue sits past its retention reserve', () => { - const sink = createRecordingSink(65536) - sink.blockNextWrite() - const dispatcher = new RelayDispatcher(sink.write, sink.options) - - try { - // Past the retention reserve but far from the 2 MB queue limit: every chunk would still be admitted. - const parked = parkProducerBytes(dispatcher, LEGACY_CLIENT_RETAINED_BYTES_LOW) - expect(parked).toBeLessThan(DEFAULT_PRODUCER_QUEUE_MAX_BYTES / 2 + 256 * 1024) - - emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) - sink.drainWaiters.shift()?.() - - const watcherFrames = sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed') - expect(watcherFrames.map(isOverflowFrame)).toEqual([true]) - // The flood injected nothing: the interactive lane keeps the whole reserve instead of ~425 KB less. - const injected = watcherFrames - .filter((frame) => !isOverflowFrame(frame)) - .reduce((total, frame) => total + frame.length, 0) - expect(injected).toBe(0) - expect(injected).toBeLessThan( - DEFAULT_PRODUCER_QUEUE_MAX_BYTES - LEGACY_CLIENT_RETAINED_BYTES_LOW - ) - expect(sink.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('delivers a whole flood to a healthy client while a second client sits past its reserve', () => { - const stalled = createRecordingSink(65536) - const healthy = createRecordingSink(65536) - stalled.blockNextWrite() - const dispatcher = new RelayDispatcher(stalled.write, stalled.options) - const events = watcherBatch(5000) - - try { - dispatcher.attachClient(healthy.write, healthy.options) - const stalledId = dispatcher.activeClientIds()[0] - parkProducerBytesForClient(dispatcher, stalledId, LEGACY_CLIENT_RETAINED_BYTES_LOW) - - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - stalled.drainWaiters.shift()?.() - - // A resync forces a readDir per directory, so an unrelated peer's stall must never trigger one here. - expect(watcherFrames(healthy.frames).some(isOverflowFrame)).toBe(false) - expect(watcherFrames(healthy.frames).flatMap(frameEvents)).toEqual(deliveredEvents(events)) - expect(watcherFrames(stalled.frames).map(isOverflowFrame)).toEqual([true]) - expect(healthy.closes()).toBe(0) - expect(stalled.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('stops the walk for the congested client while a healthy peer takes the whole flood', () => { - const healthy = createRecordingSink(65536) - const stalled = createRecordingSink(65536) - stalled.blockNextWrite() - const dispatcher = new RelayDispatcher(healthy.write, healthy.options) - const events = watcherBatch(5000) - - try { - // Reversed attach order from the peer-stall case: the gate must read the client being written to. - const stalledId = dispatcher.attachClient(stalled.write, stalled.options) - parkProducerBytesForClient(dispatcher, stalledId, LEGACY_CLIENT_RETAINED_BYTES_LOW) - - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - stalled.drainWaiters.shift()?.() - - const stalledWatcher = watcherFrames(stalled.frames) - expect(stalledWatcher.map(isOverflowFrame)).toEqual([true]) - const injected = stalledWatcher - .filter((frame) => !isOverflowFrame(frame)) - .reduce((total, frame) => total + frame.length, 0) - // Nothing injected: the congested client keeps its whole reserve for interactive PTY frames. - expect(injected).toBe(0) - expect(watcherFrames(healthy.frames).some(isOverflowFrame)).toBe(false) - expect(watcherFrames(healthy.frames).flatMap(frameEvents)).toEqual(deliveredEvents(events)) - expect(healthy.closes()).toBe(0) - expect(stalled.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) - - it('still delivers a flood larger than the reserve while the sink keeps draining', () => { - const sink = createRecordingSink(16384) - const dispatcher = new RelayDispatcher(sink.write, sink.options) - const events = watcherBatch(20_000) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - const totalBytes = sink.frames.reduce((total, frame) => total + frame.length, 0) - expect(totalBytes).toBeGreaterThan(LEGACY_CLIENT_RETAINED_BYTES_LOW) - expect(sink.frames.some(isOverflowFrame)).toBe(false) - expect(sink.frames.flatMap(frameEvents)).toHaveLength(events.length) - } finally { - dispatcher.dispose() - } - }) - - it('chunks per client so a small sink never degrades a healthy one', () => { - const healthy = createRecordingSink(65536) - const small = createRecordingSink(16384) - const dispatcher = new RelayDispatcher(healthy.write, healthy.options) - - try { - dispatcher.attachClient(small.write, small.options) - const events = watcherBatch(200) - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - const expected = events.map((event) => ({ - kind: event.type, - absolutePath: event.path, - isDirectory: event.isDirectory - })) - expect(healthy.frames).toHaveLength(1) - expect(frameEvents(healthy.frames[0])).toEqual(expected) - expect(small.frames.length).toBeGreaterThan(1) - expect(small.frames.every((frame) => frame.length <= 12288)).toBe(true) - expect(small.frames.flatMap(frameEvents)).toEqual(expected) - expect(healthy.closes()).toBe(0) - expect(small.closes()).toBe(0) - } finally { - dispatcher.dispose() - } - }) -}) diff --git a/src/relay/relay-watcher-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts deleted file mode 100644 index 828fd836492..00000000000 --- a/src/relay/relay-watcher-event-emitter.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' -import { resolveRuntimePath } from '../shared/cross-platform-path' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - createRelayClientResyncMarkerPublisher, - type RelayClientResyncMarkerPublisher -} from './relay-client-resync-marker' - -type MappedWatcherEvent = { - kind: string - absolutePath: string - isDirectory?: boolean -} - -type WatcherBatchSizing = { - eventBytes: Map - batchBytes: number - groupedEventBytes?: number[] -} - -const overflowMarkerPublishers = new WeakMap() - -export function emitRelayWatcherEvents( - dispatcher: RelayDispatcher, - rootPath: string, - closed: boolean, - events: readonly WatcherProcessEvent[] -): void { - if (closed || events.length === 0) { - return - } - const mapped: MappedWatcherEvent[] = events.map((event) => ({ - kind: event.type, - absolutePath: event.path, - ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) - })) - // Grouping walks every path, so only the chunking path pays for it — and only once across all clients. - let grouped: MappedWatcherEvent[] | null = null - const groupedByDirectory = (): MappedWatcherEvent[] => - (grouped ??= groupWatcherEventsByDirectory(mapped)) - let sizing: WatcherBatchSizing | null = null - const batchSizing = (): WatcherBatchSizing => (sizing ??= measureWatcherBatch(mapped)) - for (const clientId of dispatcher.activeClientIds()) { - publishWatcherBatchToClient( - dispatcher, - clientId, - rootPath, - mapped, - groupedByDirectory, - batchSizing - ) - } -} - -/** - * Why: the renderer dedupes directory refreshes within a SINGLE fs.changed payload, so a directory - * scattered across chunks costs one forced readDir RPC per chunk. Grouping is stable, so events for a - * given path keep their relative order and a create-then-delete can never invert. - */ -function groupWatcherEventsByDirectory( - mapped: readonly MappedWatcherEvent[] -): MappedWatcherEvent[] { - const groups = new Map() - for (const event of mapped) { - // Runs on the remote host: derive the parent with the runtime-flavored resolver, not a '/' split. - const parentPath = resolveRuntimePath(event.absolutePath, '..') - const group = groups.get(parentPath) - if (group) { - group.push(event) - } else { - groups.set(parentPath, [event]) - } - } - return Array.from(groups.values()).flat() -} - -function encodedWatcherEventBytes(event: MappedWatcherEvent): number { - return Buffer.byteLength(JSON.stringify(event)) -} - -function measureWatcherBatch(mapped: readonly MappedWatcherEvent[]): WatcherBatchSizing { - const eventBytes = new Map() - let batchBytes = Math.max(0, mapped.length - 1) - for (const event of mapped) { - const bytes = encodedWatcherEventBytes(event) - eventBytes.set(event, bytes) - batchBytes += bytes - } - return { eventBytes, batchBytes } -} - -// Batches are sized to each sink's frame capacity; a batch that cannot be sized degrades to an overflow resync. -function publishWatcherBatchToClient( - dispatcher: RelayDispatcher, - clientId: number, - rootPath: string, - mapped: readonly MappedWatcherEvent[], - groupedByDirectory: () => readonly MappedWatcherEvent[], - batchSizing: () => WatcherBatchSizing -): void { - const publish = (events: readonly MappedWatcherEvent[]): boolean => - dispatcher.publishProducerNotification(clientId, 'fs.changed', { events }) - - // Fast path: publish the whole batch before paying to group or size individual events. - // logDrop:false because rejection here is a measurement, not an outcome: the batch is re-sent in - // chunks below, so logging it would report a drop for events that all arrive. - if ( - dispatcher.publishProducerNotification( - clientId, - 'fs.changed', - { events: mapped }, - { - logDrop: false - } - ) - ) { - return - } - - // Rejection is ambiguous: an over-capacity frame is chunkable, a full producer queue is real data loss. - // The empty envelope is encoded once; event JSON sizes are exact deltas apart from array commas. - const eventsCapacity = dispatcher.producerEnvelopeBudget('fs.changed', { events: [] }, clientId) - if (eventsCapacity < 0) { - emitWatcherOverflowToClient(dispatcher, clientId, rootPath) - return - } - const sizing = batchSizing() - const { eventBytes, batchBytes } = sizing - if (batchBytes <= eventsCapacity) { - emitWatcherOverflowToClient(dispatcher, clientId, rootPath) - return - } - - const grouped = groupedByDirectory() - const groupedEventBytes = (sizing.groupedEventBytes ??= grouped.map((event) => - eventBytes.get(event)! - )) - let index = 0 - while (index < grouped.length) { - // Why: the retention ledger covers every producer publication despite its legacy name, and admission - // is lane-agnostic: chunks queued past its low-water reserve (half the 2 MB queue) starve interactive - // PTY frames until pty-handler pauses every remote pane. A resync costs the user far less. - // Per client, never dispatcher-wide: one stalled peer must not cost a healthy client a resync, - // which forces a readDir per directory in its file tree. - if (!dispatcher.producerRetentionBelowLowWater(clientId)) { - emitWatcherOverflowToClient(dispatcher, clientId, rootPath) - return - } - let end = index - let chunkBytes = 0 - while (end < grouped.length) { - const nextBytes = groupedEventBytes[end] + (end === index ? 0 : 1) - if (chunkBytes + nextBytes > eventsCapacity) { - break - } - chunkBytes += nextBytes - end += 1 - } - if (end === index || !publish(grouped.slice(index, end))) { - emitWatcherOverflowToClient(dispatcher, clientId, rootPath) - return - } - index = end - } -} - -function overflowMarkerPublisher(dispatcher: RelayDispatcher): RelayClientResyncMarkerPublisher { - const existing = overflowMarkerPublishers.get(dispatcher) - if (existing) { - return existing - } - const publisher = createRelayClientResyncMarkerPublisher(dispatcher, 'fs.changed') - overflowMarkerPublishers.set(dispatcher, publisher) - return publisher -} - -function overflowMarkerParams(rootPath: string): Record { - return { events: [{ kind: 'overflow', absolutePath: rootPath }] } -} - -function emitWatcherOverflowToClient( - dispatcher: RelayDispatcher, - clientId: number, - rootPath: string -): void { - overflowMarkerPublisher(dispatcher).emit(clientId, rootPath, overflowMarkerParams(rootPath)) -} - -export function emitRelayWatcherOverflow( - dispatcher: RelayDispatcher, - rootPath: string, - closed: boolean -): void { - if (closed) { - return - } - for (const clientId of dispatcher.activeClientIds()) { - emitWatcherOverflowToClient(dispatcher, clientId, rootPath) - } -} diff --git a/src/relay/relay-watcher-frame-chunking.test.ts b/src/relay/relay-watcher-frame-chunking.test.ts deleted file mode 100644 index 058ca631c75..00000000000 --- a/src/relay/relay-watcher-frame-chunking.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type RelayClientSinkOptions } from '../wsl-guest/dispatcher' -import { HEADER_LENGTH, encodeJsonRpcFrame, parseJsonRpcMessage } from '../wsl-guest/protocol' -import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' -import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' - -// Node >=22 defaults the socket highWaterMark to 64 KiB, Node <=21 to 16 KiB. -const NODE_22_HIGH_WATER_MARK = 64 * 1024 -const NODE_22_PRODUCER_CAPACITY = 49_152 -const NODE_21_HIGH_WATER_MARK = 16 * 1024 -const NODE_21_PRODUCER_CAPACITY = 12_288 - -type WatcherEventPayload = { - kind: string - absolutePath: string - isDirectory?: boolean -} - -type ClientCapture = { - frames: Buffer[] - framedEvents: () => WatcherEventPayload[][] - fsChangedEvents: () => WatcherEventPayload[] -} - -function captureFrames(): { capture: ClientCapture; write: (frame: Buffer) => boolean } { - const frames: Buffer[] = [] - const decode = (frame: Buffer): { method: string; events: WatcherEventPayload[] } => { - const payloadLength = frame.readUInt32BE(9) - const message = parseJsonRpcMessage( - frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength) - ) as { method?: string; params?: { events?: WatcherEventPayload[] } } - return { method: message.method ?? '', events: message.params?.events ?? [] } - } - const framedEvents = (): WatcherEventPayload[][] => - frames - .map((frame) => decode(frame)) - .filter((decoded) => decoded.method === 'fs.changed') - .map((decoded) => decoded.events) - return { - capture: { - frames, - framedEvents, - fsChangedEvents: () => framedEvents().flat() - }, - write: (frame) => { - frames.push(Buffer.from(frame)) - return true - } - } -} - -function createDispatcher( - highWaterMark: number, - sinkOptions: RelayClientSinkOptions = {} -): { - dispatcher: RelayDispatcher - capture: ClientCapture - detached: number[] -} { - const { capture, write } = captureFrames() - const detached: number[] = [] - const dispatcher = new RelayDispatcher(write, { - writableHighWaterMark: () => highWaterMark, - writableLength: () => 0, - ...sinkOptions - }) - dispatcher.onClientDetached((clientId) => detached.push(clientId)) - return { dispatcher, capture, detached } -} - -function watcherBatch(count: number): WatcherProcessEvent[] { - return Array.from({ length: count }, (_, index) => ({ - type: index % 3 === 0 ? 'create' : index % 3 === 1 ? 'update' : 'delete', - path: `/Users/dev/workspaces/orca/node_modules/.pnpm/@scope+package@1.2.3/dist/chunk-${String(index).padStart(6, '0')}.js`, - isDirectory: false - })) as WatcherProcessEvent[] -} - -// Each path encodes beyond its UTF-16 length, exposing character-based budgets. -function multiByteWatcherBatch(count: number): WatcherProcessEvent[] { - return Array.from({ length: count }, (_, index) => ({ - type: index % 3 === 0 ? 'create' : index % 3 === 1 ? 'update' : 'delete', - path: `/Users/dev/プロジェクト/日本語のディレクトリ/файлы/🚀-переклад/"quoted"\\escaped\\chunk-${String(index).padStart(6, '0')}.js`, - isDirectory: false - })) as WatcherProcessEvent[] -} - -function frameBytesFor(events: readonly WatcherEventPayload[]): number { - return encodeJsonRpcFrame({ jsonrpc: '2.0', method: 'fs.changed', params: { events } }, 0, 0) - .length -} - -// Real encodes keep this boundary oracle independent from the emitter's arithmetic. -function batchEncodingTo(targetBytes: number): WatcherProcessEvent[] { - const events = watcherBatch(400) - let fitting = 1 - while ( - fitting < events.length && - frameBytesFor(expectedPayloads(events.slice(0, fitting + 1))) <= targetBytes - ) { - fitting++ - } - const batch = events.slice(0, fitting) - const last = batch[fitting - 1] - const padding = targetBytes - frameBytesFor(expectedPayloads(batch)) - batch[fitting - 1] = { ...last, path: `${last.path}${'a'.repeat(padding)}` } - return batch -} - -function expectedPayloads(events: readonly WatcherProcessEvent[]): WatcherEventPayload[] { - return events.map((event) => ({ - kind: event.type, - absolutePath: event.path, - ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) - })) -} - -// Moving the next event forward must exceed capacity, catching needless splits. -function expectMaximallyPackedChunks(capture: ClientCapture, capacity: number): void { - const chunks = capture.framedEvents() - expect(chunks.length).toBeGreaterThan(1) - for (let index = 0; index + 1 < chunks.length; index++) { - expect(frameBytesFor([...chunks[index], chunks[index + 1][0]])).toBeGreaterThan(capacity) - } -} - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('relay watcher fs.changed frame chunking', () => { - it('delivers a 5000-event batch to a Node<=21 client without closing it', () => { - const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) - const events = watcherBatch(5000) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(detached).toEqual([]) - for (const frame of capture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) - } - expectMaximallyPackedChunks(capture, NODE_21_PRODUCER_CAPACITY) - expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) - } finally { - dispatcher.dispose() - } - }) - - it('delivers a 5000-event batch to a Node>=22 client without closing it', () => { - const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) - const events = watcherBatch(5000) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(detached).toEqual([]) - for (const frame of capture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_22_PRODUCER_CAPACITY) - } - expectMaximallyPackedChunks(capture, NODE_22_PRODUCER_CAPACITY) - expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) - } finally { - dispatcher.dispose() - } - }) - - it('sizes chunks in encoded bytes for multi-byte and JSON-escaped paths', () => { - const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) - const events = multiByteWatcherBatch(5000) - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(detached).toEqual([]) - for (const frame of capture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) - } - expectMaximallyPackedChunks(capture, NODE_21_PRODUCER_CAPACITY) - expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) - } finally { - dispatcher.dispose() - } - }) - - it('fills the first chunk to exactly the producer capacity and admits it', () => { - const exact = batchEncodingTo(NODE_21_PRODUCER_CAPACITY) - expect(frameBytesFor(expectedPayloads(exact))).toBe(NODE_21_PRODUCER_CAPACITY) - const events = [...exact, ...watcherBatch(50)] - - const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(detached).toEqual([]) - expect(capture.frames).toHaveLength(2) - expect(capture.frames[0].length).toBe(NODE_21_PRODUCER_CAPACITY) - expect(capture.framedEvents()[0]).toEqual(expectedPayloads(exact)) - expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) - } finally { - dispatcher.dispose() - } - }) - - it('splits one byte past the producer capacity into two admitted frames', () => { - const overshoot = batchEncodingTo(NODE_21_PRODUCER_CAPACITY + 1) - expect(frameBytesFor(expectedPayloads(overshoot))).toBe(NODE_21_PRODUCER_CAPACITY + 1) - - const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, overshoot) - - expect(detached).toEqual([]) - expect(capture.frames).toHaveLength(2) - for (const frame of capture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) - } - expect(capture.fsChangedEvents()).toEqual(expectedPayloads(overshoot)) - } finally { - dispatcher.dispose() - } - }) - - it('sizes chunks independently for clients with different capacities', () => { - const { capture: attachedCapture, write: attachedWrite } = captureFrames() - const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) - const events = watcherBatch(2000) - - try { - dispatcher.attachClient(attachedWrite, { - writableHighWaterMark: () => NODE_21_HIGH_WATER_MARK, - writableLength: () => 0 - }) - - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(detached).toEqual([]) - for (const frame of capture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_22_PRODUCER_CAPACITY) - } - for (const frame of attachedCapture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) - } - expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) - expect(attachedCapture.fsChangedEvents()).toEqual(expectedPayloads(events)) - } finally { - dispatcher.dispose() - } - }) - - it('resyncs without closing when a single event cannot fit any frame', () => { - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) - const oversized: WatcherProcessEvent[] = [ - { type: 'update', path: `/workspace/${'p'.repeat(20_000)}.txt`, isDirectory: false } - ] - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, oversized) - - expect(detached).toEqual([]) - expect(capture.fsChangedEvents()).toEqual([{ kind: 'overflow', absolutePath: '/workspace' }]) - } finally { - dispatcher.dispose() - } - }) - - it('delivers the prefix then resyncs when an oversized event appears mid-batch', () => { - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) - const oversized = { - type: 'update', - path: `/workspace/${'p'.repeat(20_000)}.txt`, - isDirectory: false - } as WatcherProcessEvent - const events = [...watcherBatch(300), oversized, ...watcherBatch(50)] - - try { - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - - expect(detached).toEqual([]) - const delivered = capture.fsChangedEvents() - expect(delivered.length).toBeGreaterThan(0) - expect(delivered.at(-1)).toEqual({ kind: 'overflow', absolutePath: '/workspace' }) - const prefix = delivered.slice(0, -1) - expect(prefix).toEqual(expectedPayloads(events.filter((event) => event !== oversized))) - expect(prefix.some((event) => event.absolutePath === oversized.path)).toBe(false) - for (const frame of capture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) - } - } finally { - dispatcher.dispose() - } - }) - - it('keeps the client alive when retained chunks exhaust the producer queue budget', () => { - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - // An unsettled sink models retained relay stdout writes. - const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK, { - supportsWriteCallback: true - }) - const emitted: WatcherProcessEvent[] = [] - - try { - for (let round = 0; round < 10 && detached.length === 0; round++) { - const events = watcherBatch(5000) - emitted.push(...events) - emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - } - - expect(detached).toEqual([]) - const delivered = capture.fsChangedEvents() - expect(delivered.length).toBeGreaterThan(0) - expect(delivered.length).toBeLessThan(emitted.length) - const producerEvents = delivered.filter((event) => event.kind !== 'overflow') - expect(delivered.filter((event) => event.kind === 'overflow')).toEqual([ - { kind: 'overflow', absolutePath: '/workspace' } - ]) - expect(producerEvents).toEqual(expectedPayloads(emitted).slice(0, producerEvents.length)) - for (const frame of capture.frames) { - expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) - } - } finally { - dispatcher.dispose() - } - }) - - it('does not notify at all when no client is attached', () => { - const { capture, write } = captureFrames() - const dispatcher = new RelayDispatcher(write, { - writableHighWaterMark: () => NODE_21_HIGH_WATER_MARK, - writableLength: () => 0 - }) - const notify = vi.spyOn(dispatcher, 'notify') - try { - dispatcher.invalidateClient() - emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(10)) - - expect(notify).not.toHaveBeenCalled() - expect(capture.frames).toEqual([]) - } finally { - dispatcher.dispose() - } - }) -}) diff --git a/src/relay/relay-watcher-parent-removal.test.ts b/src/relay/relay-watcher-parent-removal.test.ts deleted file mode 100644 index e1e36acdb31..00000000000 --- a/src/relay/relay-watcher-parent-removal.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - WatcherProcessHooks, - WatcherProcessSubscription -} from '../main/ipc/parcel-watcher-process-subscription' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -class ParentRemovalPool { - readonly subscriptions: { rootPath: string; unsubscribe: ReturnType }[] = [] - readonly dispose = vi.fn() - readonly forgetRoot = vi.fn() - readonly disposeAndWait = vi.fn(async () => {}) - readonly reopen = vi.fn() - - async subscribe( - rootPath: string, - _callback: unknown, - _options: unknown, - _hooks: WatcherProcessHooks - ): Promise { - const unsubscribe = vi.fn(async () => undefined) - this.subscriptions.push({ rootPath, unsubscribe }) - return { unsubscribe } - } -} - -function context(clientId: number): RequestContext { - return { clientId, isStale: () => false } -} - -function watchDispatcher() { - return { - notify: vi.fn(), - notifyClient: vi.fn(), - onClientDetached: vi.fn(), - broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), - notificationFrameBytes: vi.fn(() => 64) - } -} - -describe('RelayFilesystemWatchRegistry parent removal', () => { - it.each([ - ['/repo', '/repo/nested', '/repo-sibling'], - ['C:\\Repo', 'c:\\repo\\nested', 'C:\\Repo-Sibling'] - ])( - 'closes descendant watches before deleting parent %s and preserves siblings', - async (parent, descendant, sibling) => { - const pool = new ParentRemovalPool() - const dispatcher = watchDispatcher() - const registry = new RelayFilesystemWatchRegistry( - dispatcher as unknown as RelayDispatcher, - pool - ) - await registry.watch(descendant, context(1), 101) - await registry.watch(sibling, context(2), 202) - const remove = vi.fn(async () => { - expect(pool.subscriptions[0].unsubscribe).toHaveBeenCalledOnce() - expect(pool.subscriptions[1].unsubscribe).not.toHaveBeenCalled() - }) - - await registry.runWithRemovalFence(parent, remove) - - expect(remove).toHaveBeenCalledOnce() - expect(dispatcher.notifyClient).toHaveBeenCalledWith(1, 'fs.watchFailed', { - rootPath: descendant, - watchId: 101, - message: 'Remote worktree is being removed' - }) - } - ) - - it('waits for a descendant setup and closes its published subscription before removal', async () => { - const pool = new ParentRemovalPool() - const dispatcher = watchDispatcher() - const registry = new RelayFilesystemWatchRegistry( - dispatcher as unknown as RelayDispatcher, - pool - ) - let releaseSetup: () => void = () => undefined - const setupGate = new Promise((resolve) => { - releaseSetup = resolve - }) - const subscribe = pool.subscribe.bind(pool) - vi.spyOn(pool, 'subscribe').mockImplementation(async (...args) => { - await setupGate - return subscribe(...args) - }) - - const watch = registry.watch('/repo/nested', context(1), 101) - const remove = vi.fn(async () => { - expect(pool.subscriptions[0].unsubscribe).toHaveBeenCalledOnce() - }) - const removal = registry.runWithRemovalFence('/repo', remove) - await Promise.resolve() - expect(remove).not.toHaveBeenCalled() - - releaseSetup() - await Promise.all([watch, removal]) - expect(remove).toHaveBeenCalledOnce() - }) - - it('waits for an already-retiring descendant before removal', async () => { - const pool = new ParentRemovalPool() - const dispatcher = watchDispatcher() - const registry = new RelayFilesystemWatchRegistry( - dispatcher as unknown as RelayDispatcher, - pool - ) - await registry.watch('/repo/nested', context(1), 101) - let releaseTeardown: () => void = () => undefined - pool.subscriptions[0].unsubscribe.mockImplementation( - () => - new Promise((resolve) => { - releaseTeardown = resolve - }) - ) - const teardown = registry.unwatchAndWait('/repo/nested', context(1)) - const remove = vi.fn(async () => undefined) - const removal = registry.runWithRemovalFence('/repo', remove) - await Promise.resolve() - expect(remove).not.toHaveBeenCalled() - - releaseTeardown() - await Promise.all([teardown, removal]) - expect(remove).toHaveBeenCalledOnce() - }) -}) diff --git a/src/relay/relay-watcher-pending-setup-join.ts b/src/relay/relay-watcher-pending-setup-join.ts deleted file mode 100644 index 17c7cb5427a..00000000000 --- a/src/relay/relay-watcher-pending-setup-join.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' -import { awaitRelayWatcherSetup } from './relay-watcher-setup-wait' - -export async function joinRelayWatcherPendingSetup( - pendingSetup: RelayWatcherPendingSetup, - signal: AbortSignal | undefined, - retry: () => Promise -): Promise { - try { - await awaitRelayWatcherSetup(pendingSetup, signal) - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return - } - // Why: the previous caller's failure must not poison a later same-root - // attempt once its single serialized setup generation has released. - } - if (!signal?.aborted) { - await retry() - } -} diff --git a/src/relay/relay-watcher-pending-setup-waiters.test.ts b/src/relay/relay-watcher-pending-setup-waiters.test.ts deleted file mode 100644 index 85a1dbfcd7b..00000000000 --- a/src/relay/relay-watcher-pending-setup-waiters.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { WatcherProcessSubscription } from '../main/ipc/parcel-watcher-process-subscription' -import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -class PendingSetupPool { - readonly installed: { unsubscribe: () => Promise }[] = [] - readonly dispose = vi.fn() - readonly forgetRoot = vi.fn() - readonly disposeAndWait = vi.fn(async () => {}) - readonly reopen = vi.fn() - - async subscribe(): Promise { - const subscription = { unsubscribe: vi.fn(async () => undefined) } - this.installed.push(subscription) - return subscription - } -} - -function context(clientId: number, signal?: AbortSignal): RequestContext { - return { clientId, isStale: () => false, signal } -} - -describe('RelayFilesystemWatchRegistry pending setup waiters', () => { - it('removes ten thousand aborted callers queued behind one teardown anchor', async () => { - const pool = new PendingSetupPool() - const dispatcher = { - notify: vi.fn(), - onClientDetached: vi.fn(), - broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), - notificationFrameBytes: vi.fn(() => 64) - } - const registry = new RelayFilesystemWatchRegistry( - dispatcher as unknown as RelayDispatcher, - pool - ) - await registry.watch('/repo', context(1)) - let releaseTeardown: () => void = () => undefined - pool.installed[0].unsubscribe = vi.fn( - () => - new Promise((resolve) => { - releaseTeardown = resolve - }) - ) - const teardown = registry.unwatchAndWait('/repo', context(1)) - const leader = registry.watch('/repo', context(2)) - const anchorController = new AbortController() - const anchor = registry.watch('/repo', context(3, anchorController.signal)) - const controllers = Array.from({ length: 10_000 }, () => new AbortController()) - const cancelled = controllers.map((controller, index) => - registry.watch('/repo', context(index + 4, controller.signal)) - ) - - for (const controller of controllers) { - controller.abort() - } - await Promise.all(cancelled) - - const pendingSetups = ( - registry as unknown as { pendingSetups: Map> } - ).pendingSetups - expect(pendingSetups.values().next().value?.waiterCount).toBe(1) - expect(pool.installed).toHaveLength(1) - releaseTeardown() - await Promise.all([teardown, leader, anchor]) - expect(pool.installed).toHaveLength(2) - expect(pendingSetups.size).toBe(0) - }) -}) diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts deleted file mode 100644 index 82241a61da1..00000000000 --- a/src/relay/relay-watcher-process-pool.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { join } from 'node:path' -import { RuntimeWatcherProcessPool } from '../main/ipc/runtime-watcher-process-pool' -import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor' - -export type RelayWatcherProcessPool = Pick< - RuntimeWatcherProcessPool, - 'dispose' | 'disposeAndWait' | 'forgetRoot' | 'reopen' | 'subscribe' -> - -export function getRelayWatcherProcessEntryPath(): string { - return join(__dirname, 'relay-watcher.js') -} - -export function createRelayWatcherProcessPool( - entryPath = getRelayWatcherProcessEntryPath() -): RelayWatcherProcessPool { - return new RuntimeWatcherProcessPool({ - // Why: one SSH workspace normally installs three roots, and each remote - // watcher child carries substantial Node/native-addon process overhead. - maxSharedSupervisors: 1, - createSupervisor: () => - new WatcherProcessSupervisor({ - entryPath, - // Why: a leaked VITEST environment must never move the native addon - // back into the relay when its crash-isolation child is missing. - useInProcessVitestFallback: false - }) - }) -} diff --git a/src/relay/relay-watcher-removal-fence.ts b/src/relay/relay-watcher-removal-fence.ts deleted file mode 100644 index 28859c256ef..00000000000 --- a/src/relay/relay-watcher-removal-fence.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { - RelayWatcherTeardownState, - RelayWatcherTeardownTracker -} from './relay-watcher-teardown-tracker' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' -import { isPathInsideOrEqual } from '../shared/cross-platform-path' -import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' - -export class RelayWatcherRemovalFence { - private readonly roots = new Set() - private readonly operations = new Map() - private readonly operationWaiters = new Map void>>() - private beforeRemove: ((rootKey: string) => Promise) | null = null - - constructor( - private readonly watches: Map, - private readonly pendingSetups: Map, - private readonly teardownTracker: RelayWatcherTeardownTracker, - private readonly dispatcher: RelayDispatcher - ) {} - - isActive(rootKey: string): boolean { - return [...this.roots].some((activeRoot) => isPathInsideOrEqual(activeRoot, rootKey)) - } - - setBeforeRemove(beforeRemove: (rootKey: string) => Promise): void { - this.beforeRemove = beforeRemove - } - - beginOperation(operationPath: string): () => void { - if ([...this.roots].some((rootKey) => isPathInsideOrEqual(rootKey, operationPath))) { - throw new Error('Remote worktree deletion already in progress') - } - this.operations.set(operationPath, (this.operations.get(operationPath) ?? 0) + 1) - let finished = false - return () => { - if (finished) { - return - } - finished = true - const remaining = (this.operations.get(operationPath) ?? 1) - 1 - if (remaining > 0) { - this.operations.set(operationPath, remaining) - } else { - this.operations.delete(operationPath) - } - this.resolveOperationWaiters() - } - } - - async run(rootKey: string, operation: () => Promise): Promise { - if ( - [...this.roots].some( - (activeRoot) => - isPathInsideOrEqual(activeRoot, rootKey) || isPathInsideOrEqual(rootKey, activeRoot) - ) - ) { - throw new Error('Remote worktree deletion already in progress') - } - this.roots.add(rootKey) - try { - await this.waitForOperations(rootKey) - await this.closeRoot(rootKey) - await this.beforeRemove?.(rootKey) - return await operation() - } finally { - this.roots.delete(rootKey) - } - } - - private hasOperationInside(rootKey: string): boolean { - return [...this.operations.keys()].some((operationPath) => - isPathInsideOrEqual(rootKey, operationPath) - ) - } - - private waitForOperations(rootKey: string): Promise { - if (!this.hasOperationInside(rootKey)) { - return Promise.resolve() - } - return new Promise((resolve) => { - const waiters = this.operationWaiters.get(rootKey) ?? new Set<() => void>() - waiters.add(resolve) - this.operationWaiters.set(rootKey, waiters) - }) - } - - private resolveOperationWaiters(): void { - for (const [rootKey, waiters] of this.operationWaiters) { - if (this.hasOperationInside(rootKey)) { - continue - } - this.operationWaiters.delete(rootKey) - for (const resolve of waiters) { - resolve() - } - } - } - - private async closeRoot(rootKey: string): Promise { - const pending = [...this.pendingSetups.entries()].filter(([setupRoot]) => - isPathInsideOrEqual(rootKey, setupRoot) - ) - await Promise.all(pending.map(([, setup]) => setup.promise.catch(() => undefined))) - const states = [...this.watches.entries()] - .filter(([watchRoot]) => isPathInsideOrEqual(rootKey, watchRoot)) - .map(([, state]) => state) - for (const state of states) { - emitRelayWatcherTerminalFailure(this.dispatcher, state, 'Remote worktree is being removed') - state.clients.clear() - state.clientWatchIds.clear() - } - await Promise.all(states.map((state) => this.closeWatch(state))) - const trackedRoots = this.teardownTracker - .rootPaths() - .filter((trackedRoot) => isPathInsideOrEqual(rootKey, trackedRoot)) - for (const trackedRoot of trackedRoots) { - const failed = this.teardownTracker.failedState(trackedRoot) - await (failed ? this.closeWatch(failed) : this.teardownTracker.join(trackedRoot)) - } - } - - private closeWatch(state: RelayWatcherTeardownState): Promise { - return this.teardownTracker.close(state, () => { - if (this.watches.get(state.rootKey) === state) { - this.watches.delete(state.rootKey) - } - }) - } -} diff --git a/src/relay/relay-watcher-root-capacity.ts b/src/relay/relay-watcher-root-capacity.ts deleted file mode 100644 index 35178166f7c..00000000000 --- a/src/relay/relay-watcher-root-capacity.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { WATCH_ROOT_CAPACITY_REFUSAL_MESSAGE } from '../shared/watch-root-capacity-refusal' - -const MAX_RELAY_WATCH_ROOTS = 20 - -// Why teardown roots count: a root still unsubscribing owns its native handles until it settles. -export function exceedsRelayWatcherRootCapacity( - activeRoots: Iterable, - pendingRoots: Iterable, - teardownRoots: Iterable, - prospectiveRoot: string -): boolean { - const physicalRoots = new Set([...activeRoots, ...pendingRoots, ...teardownRoots]) - physicalRoots.add(prospectiveRoot) - return physicalRoots.size > MAX_RELAY_WATCH_ROOTS -} - -export function assertRelayWatcherRootCapacity( - activeRoots: Iterable, - pendingRoots: Iterable, - teardownRoots: Iterable, - prospectiveRoot: string -): void { - if (exceedsRelayWatcherRootCapacity(activeRoots, pendingRoots, teardownRoots, prospectiveRoot)) { - throw new Error(WATCH_ROOT_CAPACITY_REFUSAL_MESSAGE) - } -} diff --git a/src/relay/relay-watcher-setup-tracking.ts b/src/relay/relay-watcher-setup-tracking.ts deleted file mode 100644 index edf3d3cb976..00000000000 --- a/src/relay/relay-watcher-setup-tracking.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' - -export type RelayWatcherPendingSetup = PromiseSettlementWaiters - -export function trackRelayWatcherSetup( - pendingSetups: Map, - rootKey: string, - setup: Promise -): Promise { - let tracked!: RelayWatcherPendingSetup - const trackedPromise = setup.finally(() => { - if (pendingSetups.get(rootKey) === tracked) { - pendingSetups.delete(rootKey) - } - }) - tracked = new PromiseSettlementWaiters(trackedPromise) - pendingSetups.set(rootKey, tracked) - return tracked.promise -} diff --git a/src/relay/relay-watcher-setup-wait.test.ts b/src/relay/relay-watcher-setup-wait.test.ts deleted file mode 100644 index 69f31a4e59c..00000000000 --- a/src/relay/relay-watcher-setup-wait.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import { awaitRelayWatcherSetup } from './relay-watcher-setup-wait' - -describe('awaitRelayWatcherSetup', () => { - it('removes ten thousand cancelled relay callers while one anchor remains', async () => { - let resolveSetup: () => void = () => undefined - const setupPromise = new Promise((resolve) => { - resolveSetup = resolve - }) - const thenSpy = vi.spyOn(setupPromise, 'then') - const setupWaiters = new PromiseSettlementWaiters(setupPromise) - const anchor = awaitRelayWatcherSetup(setupWaiters) - const controllers = Array.from({ length: 10_000 }, () => new AbortController()) - const cancelled = controllers.map((controller) => - awaitRelayWatcherSetup(setupWaiters, controller.signal).catch((error) => error) - ) - - for (const controller of controllers) { - controller.abort() - } - await Promise.all(cancelled) - - expect(setupWaiters.waiterCount).toBe(1) - expect(thenSpy).toHaveBeenCalledOnce() - resolveSetup() - await expect(anchor).resolves.toBeUndefined() - expect(setupWaiters.waiterCount).toBe(0) - }) -}) diff --git a/src/relay/relay-watcher-setup-wait.ts b/src/relay/relay-watcher-setup-wait.ts deleted file mode 100644 index 95cc65b38b0..00000000000 --- a/src/relay/relay-watcher-setup-wait.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' -import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RequestContext } from '../wsl-guest/dispatcher' -import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' -import { errorMessage } from '../shared/error-message' - -export async function startInitialRelayWatch( - state: RelayWatcherTeardownState, - subscribe: () => Promise, - emitOverflow: () => void, - close: () => Promise -): Promise { - try { - await subscribe() - } catch (firstError) { - if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { - try { - await subscribe() - emitOverflow() - return - } catch (quarantineError) { - void close().catch(() => {}) - throw quarantineError - } - } - void close().catch(() => {}) - throw firstError - } -} - -export async function awaitRelayWatcherSetupForClient( - state: RelayWatcherTeardownState, - context: RequestContext | undefined, - releaseClient: () => void -): Promise { - try { - await awaitRelayWatcherSetup(state.setupWaiters, context?.signal) - } catch (error) { - releaseClient() - const expectedAbort = - (error instanceof Error && error.name === 'AbortError') || - (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') - if (expectedAbort) { - return - } - const message = errorMessage(error) - process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) - throw error - } - if (context?.isStale()) { - releaseClient() - } -} - -export function shouldRetryInitialRelayWatch(error: unknown): boolean { - return ( - isWatcherProcessFailure(error) && - error.code !== 'entry_missing' && - error.code !== 'subscribe_aborted' && - error.code !== 'supervisor_disposed' && - (error.scope === 'supervisor' || error.code === 'subscribe_timeout') - ) -} - -export function awaitRelayWatcherSetup( - setupWaiters: PromiseSettlementWaiters, - signal?: AbortSignal -): Promise { - return setupWaiters.wait({ - signal, - createAbortError: createRelayWatchAbortError - }) -} - -function createRelayWatchAbortError(): Error { - const error = new Error('Request "fs.watch" was cancelled') - error.name = 'AbortError' - return error -} diff --git a/src/relay/relay-watcher-shared-byte-index.test.ts b/src/relay/relay-watcher-shared-byte-index.test.ts deleted file mode 100644 index 41ed16aaff8..00000000000 --- a/src/relay/relay-watcher-shared-byte-index.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import type { RelayClientSinkOptions, RelayClientWrite } from '../wsl-guest/dispatcher-writer-sink' -import { encodeJsonRpcFrame } from '../wsl-guest/protocol' -import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' - -type WatcherPayload = { kind: string; absolutePath: string; isDirectory?: boolean } - -function captureClient(highWaterMark: number, onFrame?: () => void) { - const frames: Buffer[] = [] - let closed = 0 - const write: RelayClientWrite = (frame) => { - frames.push(Buffer.from(frame)) - onFrame?.() - return true - } - const options: RelayClientSinkOptions = { - writableHighWaterMark: () => highWaterMark, - writableLength: () => 0, - close: () => { - closed += 1 - } - } - return { frames, write, options, closed: () => closed } -} - -function watcherBatch(root: string, count: number): WatcherProcessEvent[] { - const separator = root.startsWith('/') ? '/' : '\\' - return Array.from({ length: count }, (_, index): WatcherProcessEvent => ({ - type: index % 3 === 0 ? 'create' : index % 3 === 1 ? 'update' : 'delete', - path: `${root}${separator}dir-${index % 7}${separator}quoted-"雪😀-${index}.txt`, - ...(index % 2 === 0 ? { isDirectory: index % 10 === 0 } : {}) - })) -} - -function payloads(events: readonly WatcherProcessEvent[]): WatcherPayload[] { - return events.map((event) => ({ - kind: event.type, - absolutePath: event.path, - ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) - })) -} - -function encoded(events: readonly WatcherPayload[], sequence: number): Buffer { - return encodeJsonRpcFrame( - { jsonrpc: '2.0', method: 'fs.changed', params: { events } }, - sequence, - 0 - ) -} - -// Size actual complete frames independently of the emitter's per-event arithmetic. -function expectedFrames( - root: string, - events: readonly WatcherProcessEvent[], - capacity: number, - firstSequence = 1 -): Buffer[] { - const mapped = payloads(events) - const whole = encoded(mapped, firstSequence) - if (whole.length <= capacity) { - return [whole] - } - const separator = root.startsWith('/') ? '/' : '\\' - const groups = new Map() - for (const event of mapped) { - const parent = event.absolutePath.slice(0, event.absolutePath.lastIndexOf(separator)) - const group = groups.get(parent) ?? [] - group.push(event) - groups.set(parent, group) - } - const ordered = [...groups.values()].flat() - const frames: Buffer[] = [] - let index = 0 - while (index < ordered.length) { - const sequence = firstSequence + frames.length - let low = index - let high = ordered.length - while (low < high) { - const end = Math.ceil((low + high + 1) / 2) - if (encoded(ordered.slice(index, end), sequence).length <= capacity) { - low = end - } else { - high = end - 1 - } - } - if (low === index) { - frames.push(encoded([{ kind: 'overflow', absolutePath: root }], sequence)) - break - } - frames.push(encoded(ordered.slice(index, low), sequence)) - index = low - } - return frames -} - -function eventLookupCount(calls: readonly (readonly unknown[])[]): number { - return calls.filter(([key]) => { - return typeof key === 'object' && key !== null && 'kind' in key && 'absolutePath' in key - }).length -} - -function expectFrameBytes(actual: readonly Buffer[], expected: readonly Buffer[]): void { - expect(actual).toHaveLength(expected.length) - for (const [index, expectedFrame] of expected.entries()) { - const actualFrame = actual[index] - expect(Buffer.isBuffer(actualFrame), `frame ${index} type`).toBe(true) - const equal = actualFrame?.equals(expectedFrame) - if (!equal) { - expect(actualFrame).toEqual(expectedFrame) - } - expect(equal, `frame ${index} bytes`).toBe(true) - } -} - -afterEach(() => vi.restoreAllMocks()) - -describe('relay watcher shared byte index', () => { - it.each(['/folder-workspace', 'C:\\folder-workspace', '\\\\host\\share\\folder-workspace'])( - 'looks up each grouped event once while preserving every client frame for %s', - (root) => { - const clients = [captureClient(16384), captureClient(32768), captureClient(65536)] - const dispatcher = new RelayDispatcher(clients[0].write, clients[0].options) - dispatcher.attachClient(clients[1].write, clients[1].options) - dispatcher.attachClient(clients[2].write, clients[2].options) - const events = watcherBatch(root, 5000) - try { - const get = vi.spyOn(Map.prototype, 'get') - emitRelayWatcherEvents(dispatcher, root, false, events) - const lookups = eventLookupCount(get.mock.calls) - get.mockRestore() - for (const [index, capacity] of [12288, 24576, 49152].entries()) { - expectFrameBytes(clients[index].frames, expectedFrames(root, events, capacity)) - expect(clients[index].closed()).toBe(0) - } - expect(lookups).toBe(events.length) - } finally { - dispatcher.dispose() - } - } - ) - - it('keeps whole-batch clients in original order while chunking clients use directory order', () => { - const root = '/folder-workspace' - const clients = [captureClient(65536), captureClient(16384)] - const dispatcher = new RelayDispatcher(clients[0].write, clients[0].options) - dispatcher.attachClient(clients[1].write, clients[1].options) - const events = watcherBatch(root, 200) - try { - emitRelayWatcherEvents(dispatcher, root, false, events) - expectFrameBytes(clients[0].frames, expectedFrames(root, events, 49152)) - expect(clients[0].frames).toHaveLength(1) - expectFrameBytes(clients[1].frames, expectedFrames(root, events, 12288)) - expect(clients[1].frames.length).toBeGreaterThan(1) - } finally { - dispatcher.dispose() - } - }) - - it('does no event lookup for whole batches and rebuilds the index on each new emit', () => { - const root = '/folder-workspace' - const clients = [captureClient(16384), captureClient(65536)] - const dispatcher = new RelayDispatcher(clients[0].write, clients[0].options) - dispatcher.attachClient(clients[1].write, clients[1].options) - try { - const get = vi.spyOn(Map.prototype, 'get') - emitRelayWatcherEvents(dispatcher, root, false, watcherBatch(root, 3)) - const smallLookups = eventLookupCount(get.mock.calls) - get.mockClear() - const events = watcherBatch(root, 1000) - emitRelayWatcherEvents(dispatcher, root, false, events) - events[0].path += '雪'.repeat(100) - events[0].isDirectory = true - emitRelayWatcherEvents(dispatcher, root, false, events) - const largeLookups = eventLookupCount(get.mock.calls) - get.mockRestore() - expect(smallLookups).toBe(0) - for (const [index, capacity] of [12288, 49152].entries()) { - const freshFrames = expectedFrames(root, events, capacity) - const firstSequence = clients[index].frames.length - freshFrames.length + 1 - expectFrameBytes( - clients[index].frames.slice(-freshFrames.length), - expectedFrames(root, events, capacity, firstSequence) - ) - } - expect(largeLookups).toBe(2 * events.length) - } finally { - dispatcher.dispose() - } - }) - - it('preserves the delivered prefix and overflow frame for an oversized grouped event', () => { - const root = '/folder-workspace' - const clients = [captureClient(16384), captureClient(65536)] - const dispatcher = new RelayDispatcher(clients[0].write, clients[0].options) - dispatcher.attachClient(clients[1].write, clients[1].options) - const events = watcherBatch(root, 300) - events.splice(100, 0, { type: 'update', path: `${root}/oversized/${'x'.repeat(60000)}` }) - try { - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - emitRelayWatcherEvents(dispatcher, root, false, events) - for (const [index, capacity] of [12288, 49152].entries()) { - expectFrameBytes(clients[index].frames, expectedFrames(root, events, capacity)) - expect(clients[index].closed()).toBe(0) - } - } finally { - dispatcher.dispose() - } - }) - - it('does not publish to a peer detached synchronously during the first client chunk', () => { - const root = '/folder-workspace' - let dispatcher: RelayDispatcher - let peerId = 0 - const primary = captureClient(16384, () => dispatcher.detachClient(peerId)) - const peer = captureClient(65536) - dispatcher = new RelayDispatcher(primary.write, primary.options) - peerId = dispatcher.attachClient(peer.write, peer.options) - const events = watcherBatch(root, 1000) - try { - emitRelayWatcherEvents(dispatcher, root, false, events) - expectFrameBytes(primary.frames, expectedFrames(root, events, 12288)) - expect(peer.frames).toEqual([]) - } finally { - dispatcher.dispose() - } - }) - - it('preserves first-publication errors before doing any event lookup', () => { - const primary = captureClient(16384) - const dispatcher = new RelayDispatcher(primary.write, primary.options) - const failure = new Error('publication failed') - try { - vi.spyOn(dispatcher, 'publishProducerNotification').mockImplementationOnce(() => { - throw failure - }) - const get = vi.spyOn(Map.prototype, 'get') - expect(() => - emitRelayWatcherEvents( - dispatcher, - '/folder-workspace', - false, - watcherBatch('/folder-workspace', 5000) - ) - ).toThrow(failure) - const lookups = eventLookupCount(get.mock.calls) - get.mockRestore() - expect(lookups).toBe(0) - expect(primary.frames).toEqual([]) - } finally { - dispatcher.dispose() - } - }) - - it('does no event lookup or publication for closed roots or detached clients', () => { - const root = '/folder-workspace' - const primary = captureClient(16384) - const dispatcher = new RelayDispatcher(primary.write, primary.options) - const events = watcherBatch(root, 5000) - try { - const get = vi.spyOn(Map.prototype, 'get') - emitRelayWatcherEvents(dispatcher, root, true, events) - dispatcher.invalidateClient() - emitRelayWatcherEvents(dispatcher, root, false, events) - const lookups = eventLookupCount(get.mock.calls) - get.mockRestore() - expect(lookups).toBe(0) - expect(primary.frames).toEqual([]) - } finally { - dispatcher.dispose() - } - }) -}) diff --git a/src/relay/relay-watcher-shutdown.test.ts b/src/relay/relay-watcher-shutdown.test.ts deleted file mode 100644 index 9d92b36d456..00000000000 --- a/src/relay/relay-watcher-shutdown.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -function fixture() { - const unsubscribe = vi.fn(async () => {}) - const pool = { - subscribe: vi.fn(async () => ({ unsubscribe })), - forgetRoot: vi.fn(), - dispose: vi.fn(), - disposeAndWait: vi.fn(async () => {}), - reopen: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: shutdown paths never reach the dispatcher. - const registry = new RelayFilesystemWatchRegistry({} as RelayDispatcher, pool) - const root = join(tmpdir(), 'orca-watcher-shutdown-fixture') - return { registry, pool, unsubscribe, root } -} - -it('fences new watches synchronously and joins an in-flight native unsubscribe', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.unsubscribe.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const duplicate = f.registry.closeWatchesAndWait() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - expect(f.unsubscribe).toHaveBeenCalledOnce() - expect(f.pool.dispose).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([shutdown, duplicate]) - expect(f.pool.forgetRoot).toHaveBeenCalledWith(f.root) -}) - -it('retains failed teardown and retries without admitting replacement watches', async () => { - const f = fixture() - await f.registry.watch(f.root) - const failure = new Error('native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - await expect(f.registry.closeWatchesAndWait()).rejects.toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('waits for a late subscription and its unsubscribe after setup has begun', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const watch = f.registry.watch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - setup.resolve({ unsubscribe: f.unsubscribe }) - await vi.waitFor(() => expect(f.unsubscribe).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await Promise.all([watch, shutdown]) - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('joins teardown already started by client unwatch', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await shutdown - expect(f.unsubscribe).toHaveBeenCalledOnce() -}) - -it('retains a late subscription whose cleanup fails instead of treating it as failed setup', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const failure = new Error('late native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - const watch = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait().catch((error: unknown) => error) - setup.resolve({ unsubscribe: f.unsubscribe }) - expect(await watch).toBe(failure) - expect(await shutdown).toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('does not publish a replacement watch when its predecessor closes after shutdown fencing', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const replacement = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait() - close.resolve() - expect(await replacement).toMatchObject({ message: 'relay_watcher_shutdown_fenced' }) - await shutdown - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('full watcher disposal waits for physical pool exit after native unsubscribe completes', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.pool.disposeAndWait.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.disposeAndWait().then(finished) - await vi.waitFor(() => expect(f.pool.forgetRoot).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await shutdown -}) - -it('does not acknowledge a failed pool exit after native subscriptions close', async () => { - const f = fixture() - await f.registry.watch(f.root) - const error = new Error('pool exit unproven') - f.pool.disposeAndWait.mockRejectedValueOnce(error) - await expect(f.registry.disposeAndWait()).rejects.toMatchObject({ errors: [error] }) - await f.registry.disposeAndWait() -}) diff --git a/src/relay/relay-watcher-shutdown.ts b/src/relay/relay-watcher-shutdown.ts deleted file mode 100644 index a50b44fb359..00000000000 --- a/src/relay/relay-watcher-shutdown.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' -import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' -import type { - RelayWatcherTeardownState, - RelayWatcherTeardownTracker -} from './relay-watcher-teardown-tracker' - -export async function disposeRelayWatchesAndWait( - closeWatches: () => Promise, - pool: RelayWatcherProcessPool -): Promise { - const close = closeWatches() - const children = Promise.resolve().then(() => pool.disposeAndWait()) - const results = await Promise.allSettled([close, children]) - const failures = results.filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - failures.map((failure) => failure.reason), - 'relay_watcher_shutdown_incomplete' - ) - } -} - -export async function closeRelayWatchesAndWait( - watches: ReadonlyMap, - pendingSetups: ReadonlyMap, - tracker: RelayWatcherTeardownTracker, - close: (state: RelayWatcherTeardownState) => Promise -): Promise { - const states = new Set(watches.values()) - for (const root of tracker.rootPaths()) { - const failed = tracker.failedState(root) - if (failed) { - states.add(failed) - } - } - const closures = Promise.allSettled([...states].map(close)) - // Setup refusal is expected after fencing; retained teardown failures remain authoritative. - await Promise.allSettled([...pendingSetups.values()].map((setup) => setup.promise)) - const results = await closures - // Why the fallback: join answers undefined for a root with nothing left in flight. - const remaining = await Promise.allSettled( - tracker.rootPaths().map((root) => tracker.join(root) ?? Promise.resolve()) - ) - const failures = [...results, ...remaining].filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - [...new Set(failures.map((failure) => failure.reason))], - 'relay_watcher_shutdown_incomplete' - ) - } -} diff --git a/src/relay/relay-watcher-stale-client-release.ts b/src/relay/relay-watcher-stale-client-release.ts deleted file mode 100644 index 89ce884eacf..00000000000 --- a/src/relay/relay-watcher-stale-client-release.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' - -/** Drops stale clients from one watch, sparing `keep` (the caller's own client). */ -export function pruneStaleRelayWatchClients(state: RelayWatcherTeardownState, keep?: number): void { - for (const [clientId, isStale] of state.clients) { - if (clientId !== keep && isStale()) { - state.clients.delete(clientId) - state.clientWatchIds.delete(clientId) - } - } -} - -export function releaseStaleRelayWatches( - states: Iterable, - closeWatch: (state: RelayWatcherTeardownState) => Promise -): Promise | undefined { - const teardowns: Promise[] = [] - for (const state of Array.from(states)) { - pruneStaleRelayWatchClients(state) - if (state.clients.size === 0) { - teardowns.push(closeWatch(state)) - } - } - return teardowns.length > 0 ? Promise.all(teardowns).then(() => undefined) : undefined -} diff --git a/src/relay/relay-watcher-state.ts b/src/relay/relay-watcher-state.ts deleted file mode 100644 index 90d6b978a8d..00000000000 --- a/src/relay/relay-watcher-state.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' - -export function createRelayWatcherState( - rootKey: string, - rootPath: string, - clientId: number, - isStale: () => boolean, - watchId?: number -): RelayWatcherTeardownState { - return { - clientWatchIds: watchId === undefined ? new Map() : new Map([[clientId, watchId]]), - rootKey, - rootPath, - clients: new Map([[clientId, isStale]]), - setupWaiters: new PromiseSettlementWaiters(Promise.resolve()), - subscription: null, - abortController: new AbortController(), - generation: 0, - closed: false - } -} diff --git a/src/relay/relay-watcher-teardown-tracker.ts b/src/relay/relay-watcher-teardown-tracker.ts deleted file mode 100644 index d9feff34b9f..00000000000 --- a/src/relay/relay-watcher-teardown-tracker.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { WatcherProcessSubscription } from '../main/ipc/parcel-watcher-process' -import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' -import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' - -export type RelayWatcherTeardownState = { - rootKey: string - rootPath: string - clients: Map boolean> - clientWatchIds: Map - setupWaiters: PromiseSettlementWaiters - subscription: WatcherProcessSubscription | null - abortController: AbortController - generation: number - closed: boolean -} - -export class RelayWatcherTeardownTracker { - private readonly pending = new Map>() - private readonly failed = new Map() - - constructor(private readonly forgetRoot: (rootPath: string) => void) {} - - close(state: RelayWatcherTeardownState, removeActiveState: () => void): Promise { - const pending = this.pending.get(state.rootKey) - if (pending) { - return pending - } - if (!state.closed) { - state.closed = true - state.generation++ - state.abortController.abort() - state.clients.clear() - state.clientWatchIds.clear() - removeActiveState() - } - const subscription = state.subscription - const teardown = subscription - ? callUnsubscribe(subscription) - : state.setupWaiters.promise.then(() => undefined) - let tracked: Promise - tracked = teardown - .then( - () => { - state.subscription = null - this.failed.delete(state.rootKey) - this.forgetRoot(state.rootPath) - }, - (error) => { - const physicalExit = isWatcherProcessFailure(error) ? error.physicalExit : undefined - if (!subscription && !state.subscription && !physicalExit) { - this.failed.delete(state.rootKey) - this.forgetRoot(state.rootPath) - return - } - // Why: failed physical teardown must remain retryable and consume capacity. - this.failed.set(state.rootKey, { state, error }) - if (physicalExit) { - // Why: an unkillable child still owns native handles until its later - // physical exit, even when teardown began before setup published. - void physicalExit.then(() => { - const failed = this.failed.get(state.rootKey) - if (failed?.state === state && failed.error === error) { - this.failed.delete(state.rootKey) - this.forgetRoot(state.rootPath) - } - }) - } - throw error - } - ) - .finally(() => { - if (this.pending.get(state.rootKey) === tracked) { - this.pending.delete(state.rootKey) - } - }) - this.pending.set(state.rootKey, tracked) - return tracked - } - - join(rootKey: string): Promise | undefined { - const pending = this.pending.get(rootKey) - const failed = this.failed.get(rootKey) - if (!pending) { - return failed ? Promise.reject(failed.error) : undefined - } - return pending.then(() => { - const settledFailure = this.failed.get(rootKey) - if (settledFailure) { - throw settledFailure.error - } - }) - } - - failedState(rootKey: string): RelayWatcherTeardownState | undefined { - return this.failed.get(rootKey)?.state - } - - rootPaths(): string[] { - return [...this.pending.keys(), ...this.failed.keys()] - } - - /** - * The capacity-release event: resolves once every teardown in flight right now has settled. - * - * `undefined` when nothing is unsubscribing, which is the only honest answer to "could a slot - * still come back?" — a failed teardown keeps its handles and releases nothing. - */ - settlePending(): Promise | undefined { - const inFlight = [...this.pending.values()] - return inFlight.length === 0 ? undefined : Promise.allSettled(inFlight).then(() => undefined) - } -} - -function callUnsubscribe(subscription: WatcherProcessSubscription): Promise { - try { - return Promise.resolve(subscription.unsubscribe()) - } catch (error) { - return Promise.reject(error) - } -} diff --git a/src/relay/relay-watcher-terminal-notifier.ts b/src/relay/relay-watcher-terminal-notifier.ts deleted file mode 100644 index f5ef6b86f42..00000000000 --- a/src/relay/relay-watcher-terminal-notifier.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { RelayDispatcher } from '../wsl-guest/dispatcher' - -type RelayTerminalWatchOwners = { - rootPath: string - clients: Map boolean> - clientWatchIds: Map -} - -export function emitRelayWatcherTerminalFailure( - dispatcher: RelayDispatcher, - state: RelayTerminalWatchOwners, - message: string -): void { - for (const [clientId, isStale] of state.clients) { - if (isStale()) { - state.clients.delete(clientId) - state.clientWatchIds.delete(clientId) - continue - } - const watchId = state.clientWatchIds.get(clientId) - if (watchId === undefined) { - continue - } - // Why: a shared relay may serve multiple Orca clients; only owners of - // this exact logical watch should invalidate their provider state. - dispatcher.notifyClient(clientId, 'fs.watchFailed', { - rootPath: state.rootPath, - watchId, - message - }) - } -} diff --git a/src/relay/relay-windows-conpty.ts b/src/relay/relay-windows-conpty.ts deleted file mode 100644 index 39dc7889fb6..00000000000 --- a/src/relay/relay-windows-conpty.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * The bundled ConPTY a pinned-Node relay ships in its node-pty slot (design D2): conpty.dll plus - * OpenConsole.exe, the pair the desktop daemon already spawns through (`useConptyDll`). - */ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import type { RelayRuntimeKind } from '../shared/relay-runtime-self-test-report' - -export const RELAY_BUNDLED_CONPTY_FILES = ['conpty/conpty.dll', 'conpty/OpenConsole.exe'] as const - -export function relayBundledConptyPaths(nodePtyDir: string): string[] { - return RELAY_BUNDLED_CONPTY_FILES.map((file) => - join(nodePtyDir, 'build', 'Release', ...file.split('/')) - ) -} - -/** - * Only a pinned-Node relay opts in: its slot always carries the bundled pair, while a host-Node - * relay's npm tree keeps the inbox ConPTY it has always used. - */ -export function relayConptyDllSpawnOptions( - nodePtyDir: string, - runtimeKind: RelayRuntimeKind, - platform: NodeJS.Platform = process.platform -): { useConptyDll: true } | Record { - if (platform !== 'win32' || runtimeKind !== 'pinned-node') { - return {} - } - return relayBundledConptyPaths(nodePtyDir).every((path) => existsSync(path)) - ? { useConptyDll: true } - : {} -} diff --git a/src/relay/relay-windows-path-ripgrep-cache.test.ts b/src/relay/relay-windows-path-ripgrep-cache.test.ts deleted file mode 100644 index bea305bd741..00000000000 --- a/src/relay/relay-windows-path-ripgrep-cache.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type * as fs from 'node:fs' -import { statSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { pathRipgrepCommand, resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, statSync: vi.fn(actual.statSync) } -}) - -const originalPlatform = process.platform -const present = new Set() -const statMock = vi.mocked(statSync) -const fileStats = statSync(new URL(import.meta.url)) - -describe('Windows relay ripgrep effective PATH cache', () => { - beforeEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.stubEnv('Path', undefined) - vi.stubEnv('PATH', 'C:\\tools') - vi.stubEnv('CARGO_HOME', 'C:\\cargo') - vi.useFakeTimers() - vi.setSystemTime(1000) - present.clear() - resetRelayRipgrepPathCacheForTests() - statMock.mockImplementation((path) => { - if (!present.has(String(path))) { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - } - return fileStats - }) - statMock.mockClear() - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - vi.unstubAllEnvs() - vi.useRealTimers() - statMock.mockReset() - resetRelayRipgrepPathCacheForTests() - }) - - it('finds rg in the Cargo fallback appended to the command environment', () => { - present.add('C:\\cargo\\bin\\rg.exe') - expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') - const calls = statMock.mock.calls.length - expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') - expect(statMock).toHaveBeenCalledTimes(calls) - }) - - it('supports mixed-case Path and never probes cwd-relative entries', () => { - vi.stubEnv('PATH', undefined) - vi.stubEnv('Path', '.;node_modules\\.bin;\\tools;C:tools;C:\\safe') - present.add('C:\\safe\\rg.exe') - expect(pathRipgrepCommand()).toBe('C:\\safe\\rg.exe') - expect(statMock.mock.calls.map(([path]) => path)).toEqual(['C:\\safe\\rg.exe']) - }) - - it('invalidates both successful and missing resolutions when effective PATH changes', () => { - expect(pathRipgrepCommand()).toBeNull() - vi.stubEnv('CARGO_HOME', 'D:\\cargo') - present.add('D:\\cargo\\bin\\rg.exe') - expect(pathRipgrepCommand()).toBe('D:\\cargo\\bin\\rg.exe') - vi.stubEnv('PATH', 'E:\\tools') - present.add('E:\\tools\\rg.exe') - expect(pathRipgrepCommand()).toBe('E:\\tools\\rg.exe') - }) - - it('retries cached misses after a bounded delay without rescanning on each call', () => { - expect(pathRipgrepCommand()).toBeNull() - const calls = statMock.mock.calls.length - present.add('C:\\tools\\rg.exe') - vi.advanceTimersByTime(59_999) - expect(pathRipgrepCommand()).toBeNull() - expect(statMock).toHaveBeenCalledTimes(calls) - vi.advanceTimersByTime(1) - expect(pathRipgrepCommand()).toBe('C:\\tools\\rg.exe') - }) -}) diff --git a/src/relay/relay-windows-path-ripgrep.test.ts b/src/relay/relay-windows-path-ripgrep.test.ts deleted file mode 100644 index f27c14200a9..00000000000 --- a/src/relay/relay-windows-path-ripgrep.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - configureRelayBundledRipgrep, - isDriveRootedWindowsPath, - pathRipgrepCommand, - resetRelayRipgrepPathCacheForTests -} from './relay-bundled-ripgrep' - -// These tests isolate PATH safety; effective-environment coverage lives in the cache suite. -vi.mock('../wsl-guest/relay-command-env', () => ({ - buildRelayCommandEnv: () => ({ ...process.env }) -})) - -const originalPlatform = process.platform -const originalPath = process.env.PATH - -function setPlatform(value: string): void { - Object.defineProperty(process, 'platform', { configurable: true, value }) -} - -describe('relay PATH ripgrep resolution', () => { - afterEach(() => { - setPlatform(originalPlatform) - process.env.PATH = originalPath - resetRelayRipgrepPathCacheForTests() - configureRelayBundledRipgrep(undefined) - }) - - // Why a pure predicate test and not a filesystem one: the walk applies win32 path semantics, and - // a temp directory on a POSIX CI host has no drive letter to exercise them with. - it('accepts only drive-lettered and UNC roots', () => { - expect(isDriveRootedWindowsPath('C:\\tools')).toBe(true) - expect(isDriveRootedWindowsPath('d:/tools')).toBe(true) - expect(isDriveRootedWindowsPath('\\\\server\\share\\tools')).toBe(true) - // Rooted but drive-less: `path.isAbsolute` says true, yet these resolve against whatever drive - // the process is on -- the relay's for the probe, the user's repo for the spawn. - expect(isDriveRootedWindowsPath('\\tools')).toBe(false) - expect(isDriveRootedWindowsPath('/tools')).toBe(false) - // Drive-relative, and relative. - expect(isDriveRootedWindowsPath('C:tools')).toBe(false) - expect(isDriveRootedWindowsPath('tools')).toBe(false) - }) - - // Why this matters on Windows only: CreateProcessW searches the spawn cwd -- the user's repo -- - // before PATH, so a bare `rg` there runs a planted rg.exe out of a cloned repository. - it.runIf(process.platform === 'win32')('resolves an absolute rg.exe from PATH', () => { - const dir = mkdtempSync(join(tmpdir(), 'relay-rg-')) - try { - writeFileSync(join(dir, 'rg.exe'), '') - setPlatform('win32') - process.env.PATH = `${join(dir, 'missing')}${delimiter}${dir}` - resetRelayRipgrepPathCacheForTests() - - expect(pathRipgrepCommand()).toBe(join(dir, 'rg.exe')) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('returns null on Windows rather than a bare name when PATH has no rg', () => { - setPlatform('win32') - process.env.PATH = join(tmpdir(), 'definitely-not-here') - resetRelayRipgrepPathCacheForTests() - - expect(pathRipgrepCommand()).toBeNull() - }) - - // Why drive-rooted and not merely "absolute": path.isAbsolute accepts `\\tools` and `/tools` on - // Windows. Those carry no drive, so the probe would validate them against the relay's drive - // while the spawn -- running with the user's repo as cwd -- resolves them against the repo's. - it('skips rooted PATH entries that carry no drive', () => { - setPlatform('win32') - process.env.PATH = '\\tools;/tools;C:tools' - resetRelayRipgrepPathCacheForTests() - - expect(pathRipgrepCommand()).toBeNull() - }) - - // Why a relative PATH entry is skipped: it resolves against the cwd, the hazard being avoided. - it('ignores relative PATH entries on Windows', () => { - setPlatform('win32') - process.env.PATH = '.;node_modules/.bin' - resetRelayRipgrepPathCacheForTests() - - expect(pathRipgrepCommand()).toBeNull() - }) - - // Why POSIX keeps the bare name: execvp never consults the cwd, so there is nothing to resolve. - it('keeps the bare name on POSIX', () => { - setPlatform('linux') - resetRelayRipgrepPathCacheForTests() - - expect(pathRipgrepCommand()).toBe('rg') - }) -}) diff --git a/src/relay/relay.ts b/src/relay/relay.ts deleted file mode 100644 index 51ab5398d27..00000000000 --- a/src/relay/relay.ts +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node - -// Orca Relay — remote-host daemon and reconnect bridge entry point. - -import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' -import { runRelayConnectChannel } from './relay-connect-channel' -import { runRelayOrcaCliChannel } from './relay-orca-cli-channel' -import { runRelayDaemon } from './relay-daemon' -import { relayLogLine } from './relay-diagnostic-log' -import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' -import { runRelayRuntimeSelfTestCommand } from './relay-runtime-self-test' -import { runWindowsBreakawayLaunchIfRequested } from '../shared/windows-breakaway-launcher' -import { RELAY_WINDOWS_BREAKAWAY_CONTRACT } from '../shared/windows-breakaway-launch' -import { RELAY_RUNTIME_SELF_TEST_FLAG } from '../shared/relay-runtime-self-test-report' - -async function main(): Promise { - const selfTestFlag = process.argv.indexOf(RELAY_RUNTIME_SELF_TEST_FLAG) - if (selfTestFlag !== -1) { - await runRelayRuntimeSelfTestCommand(process.argv[selfTestFlag + 1] ?? '') - return - } - if (runWindowsBreakawayLaunchIfRequested(RELAY_WINDOWS_BREAKAWAY_CONTRACT, process.argv)) { - return - } - const options = parseRelayLaunchOptions(process.argv) - if (options.connectMode) { - runRelayConnectChannel(options.sockPath, readRelayEndpointCredential(options.credentialFile)) - return - } - if (options.cliMode) { - const marker = process.argv.indexOf('--orca-cli') - await runRelayOrcaCliChannel( - options.sockPath, - marker === -1 ? [] : process.argv.slice(marker + 1), - readRelayEndpointCredential(options.credentialFile) - ) - return - } - configureRelayBundledRipgrep(options.ripgrepPath) - // Why no read here: the daemon publishes its credential itself, after it owns the socket. - await runRelayDaemon(options) -} - -void main().catch((error) => { - relayLogLine( - `[relay] Fatal startup error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` - ) - process.exit(1) -}) diff --git a/src/relay/remote-artifact-cli-forwarding.ts b/src/relay/remote-artifact-cli-forwarding.ts deleted file mode 100644 index f82ae8adb82..00000000000 --- a/src/relay/remote-artifact-cli-forwarding.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { RemoteArtifactInput } from '../shared/artifact-cli-bridge' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' -import { encodeJsonRpcFrame } from '../wsl-guest/protocol' - -export type RemoteArtifactCliForwardingParams = { - argv: string[] - cwd: string - env: Record - stdin?: string - artifactInput?: RemoteArtifactInput -} - -// Why: the daemon assigns the forwarded request id, so size against its widest safe integer. -const FORWARDED_REQUEST_SIZE_ID = Number.MAX_SAFE_INTEGER - -export function remoteArtifactCliForwardingFrameBytes( - params: RemoteArtifactCliForwardingParams -): number { - return encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: FORWARDED_REQUEST_SIZE_ID, - method: 'orca.cli', - params - }, - 0, - 0 - ).length -} - -export function assertRemoteArtifactCliForwardingFits( - params: RemoteArtifactCliForwardingParams -): void { - if (remoteArtifactCliForwardingFrameBytes(params) > DISPATCHER_CONTROL_QUEUE_MAX_BYTES) { - throw new Error( - 'Artifact is too large for the Orca SSH transport. Use the browser upload page instead.' - ) - } -} diff --git a/src/relay/remote-artifact-cli-input.test.ts b/src/relay/remote-artifact-cli-input.test.ts deleted file mode 100644 index 3c7cf102468..00000000000 --- a/src/relay/remote-artifact-cli-input.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdtemp, open, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../shared/artifacts' -import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' -import { - assertRemoteArtifactCliForwardingFits, - remoteArtifactCliForwardingFrameBytes, - type RemoteArtifactCliForwardingParams -} from './remote-artifact-cli-forwarding' - -const createdPaths: string[] = [] - -async function remoteFolder(): Promise { - const path = await mkdtemp(join(tmpdir(), 'orca-remote-artifact-')) - createdPaths.push(path) - return path -} - -afterEach(async () => { - await Promise.all( - createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) -}) - -describe('prepareRemoteArtifactCliInput', () => { - it('reads a folder-workspace file on the SSH host and preserves its source path', async () => { - const cwd = await remoteFolder() - await writeFile(join(cwd, 'report.md'), '# Remote report', 'utf8') - - await expect( - prepareRemoteArtifactCliInput(['artifacts', 'share', 'report.md'], cwd) - ).resolves.toEqual({ - stdin: '# Remote report', - artifactInput: { - sourceKey: join(cwd, 'report.md'), - fileName: 'report.md', - contentType: 'text/markdown' - } - }) - }) - - it('rejects a sparse oversized file from stat metadata before reading its contents', async () => { - const cwd = await remoteFolder() - const path = join(cwd, 'sparse.html') - const handle = await open(path, 'w') - await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1) - await handle.close() - - await expect( - prepareRemoteArtifactCliInput(['artifacts', 'share', 'sparse.html'], cwd) - ).rejects.toThrow(/too large/) - }) - - it('transfers source identity without reading content for unshare', async () => { - const cwd = await remoteFolder() - - await expect( - prepareRemoteArtifactCliInput(['artifacts', 'unshare', 'missing.html'], cwd) - ).resolves.toEqual({ - artifactInput: { sourceKey: join(cwd, 'missing.html'), fileName: 'missing.html' } - }) - }) -}) - -function forwardingParams(stdin: string): RemoteArtifactCliForwardingParams { - return { - argv: ['artifacts', 'share', 'report.md'], - cwd: '/workspace', - env: { ORCA_WORKSPACE_ID: 'workspace-1' }, - stdin, - artifactInput: { - sourceKey: '/workspace/report.md', - fileName: 'report.md', - contentType: 'text/markdown' - } - } -} - -describe('remote artifact CLI forwarding admission', () => { - it.each([ - ['backslash', '\\'], - ['quote', '"'] - ])('uses the complete control frame for the %s escape boundary', (_label, character) => { - const emptyBytes = remoteArtifactCliForwardingFrameBytes(forwardingParams('')) - const escapedCharacterBytes = Buffer.byteLength(JSON.stringify(character), 'utf8') - 2 - const fittingCharacters = Math.floor( - (DISPATCHER_CONTROL_QUEUE_MAX_BYTES - emptyBytes) / escapedCharacterBytes - ) - const fitting = forwardingParams(character.repeat(fittingCharacters)) - const oversized = forwardingParams(character.repeat(fittingCharacters + 1)) - - expect(remoteArtifactCliForwardingFrameBytes(fitting)).toBeLessThanOrEqual( - DISPATCHER_CONTROL_QUEUE_MAX_BYTES - ) - expect(remoteArtifactCliForwardingFrameBytes(oversized)).toBeGreaterThan( - DISPATCHER_CONTROL_QUEUE_MAX_BYTES - ) - expect(() => assertRemoteArtifactCliForwardingFits(fitting)).not.toThrow() - expect(() => assertRemoteArtifactCliForwardingFits(oversized)).toThrow( - /too large for the Orca SSH transport/ - ) - }) - - it.each([600 * 1024, ARTIFACT_CLI_MAX_RPC_BYTES])( - 'keeps an ordinary %i-byte artifact inside the control budget', - (bytes) => { - const params = forwardingParams('a'.repeat(bytes)) - - expect(remoteArtifactCliForwardingFrameBytes(params)).toBeLessThanOrEqual( - DISPATCHER_CONTROL_QUEUE_MAX_BYTES - ) - expect(() => assertRemoteArtifactCliForwardingFits(params)).not.toThrow() - } - ) - - it('rejects an escaped artifact that is below the raw file limit', () => { - const params = forwardingParams('\\'.repeat(600 * 1024)) - - expect(Buffer.byteLength(params.stdin ?? '', 'utf8')).toBeLessThan(ARTIFACT_CLI_MAX_RPC_BYTES) - expect(() => assertRemoteArtifactCliForwardingFits(params)).toThrow( - /too large for the Orca SSH transport/ - ) - }) -}) diff --git a/src/relay/remote-artifact-cli-input.ts b/src/relay/remote-artifact-cli-input.ts deleted file mode 100644 index 56d5ad0329c..00000000000 --- a/src/relay/remote-artifact-cli-input.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { basename, extname, resolve } from 'node:path' -import type { RemoteArtifactInput } from '../shared/artifact-cli-bridge' -import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../shared/artifacts' -import { readArtifactFileWithinLimit } from '../shared/artifact-file-read' -import { MAX_MESSAGE_SIZE } from '../wsl-guest/protocol' - -export type PreparedRemoteArtifactCliInput = { - stdin?: string - artifactInput?: RemoteArtifactInput -} - -const BOOLEAN_FLAGS = new Set(['help', 'json']) -const VALUE_FLAGS = new Set(['api-url', 'environment', 'file', 'pairing-code']) - -function parseArtifactInvocation(argv: string[]): { command: string; file?: string } | null { - const positionals: string[] = [] - let file: string | undefined - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index] - if (!token.startsWith('--')) { - positionals.push(token) - continue - } - const assignment = token.slice(2) - const equalsIndex = assignment.indexOf('=') - const flag = equalsIndex === -1 ? assignment : assignment.slice(0, equalsIndex) - if (flag === 'file' && equalsIndex !== -1) { - file = assignment.slice(equalsIndex + 1) - continue - } - if (equalsIndex !== -1 || BOOLEAN_FLAGS.has(flag)) { - continue - } - if (VALUE_FLAGS.has(flag) && argv[index + 1] && !argv[index + 1].startsWith('--')) { - if (flag === 'file') { - file = argv[index + 1] - } - index += 1 - } - } - if (positionals[0] !== 'artifacts' || !['share', 'update', 'unshare'].includes(positionals[1])) { - return null - } - return { command: positionals[1], file: file ?? positionals[2] } -} - -function contentTypeForPath(path: string): NonNullable | null { - const extension = extname(path).toLowerCase() - return ['.html', '.htm'].includes(extension) - ? 'text/html' - : ['.md', '.markdown'].includes(extension) - ? 'text/markdown' - : null -} - -export async function prepareRemoteArtifactCliInput( - argv: string[], - cwd: string -): Promise { - const invocation = parseArtifactInvocation(argv) - if (!invocation || argv.includes('--help')) { - return {} - } - if (!invocation.file) { - return {} - } - const sourceKey = resolve(cwd, invocation.file) - if (invocation.command === 'unshare') { - return { artifactInput: { sourceKey, fileName: basename(sourceKey) } } - } - const contentType = contentTypeForPath(sourceKey) - if (!contentType) { - throw new Error('Artifacts must be HTML or Markdown files.') - } - const result = await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES) - if (result.status === 'not-file') { - throw new Error('Artifact file was not found or is not a file.') - } - if (result.status === 'too-large') { - throw new Error( - 'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.' - ) - } - if (result.status === 'empty') { - throw new Error('Artifact file is empty.') - } - const prepared = { - stdin: result.content, - artifactInput: { sourceKey, fileName: basename(sourceKey), contentType } - } - if (Buffer.byteLength(JSON.stringify(prepared), 'utf8') > MAX_MESSAGE_SIZE - 64 * 1024) { - throw new Error('Artifact content exceeds the SSH relay message limit.') - } - return prepared -} diff --git a/src/relay/remote-cli-env.test.ts b/src/relay/remote-cli-env.test.ts deleted file mode 100644 index 68941e09768..00000000000 --- a/src/relay/remote-cli-env.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { pickRemoteCliEnv } from './remote-cli-env' - -describe('pickRemoteCliEnv', () => { - it('forwards SSH Orca terminal and worktree context for remote CLI calls', () => { - expect( - pickRemoteCliEnv({ - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote', - ORCA_PANE_KEY: 'pane-1', - ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret', - ORCA_WORKSPACE_ID: 'workspace-1', - ORCA_USER_DATA_PATH: '/tmp/orca', - PATH: '/usr/bin', - SECRET_TOKEN: 'nope' - }) - ).toEqual({ - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote', - ORCA_PANE_KEY: 'pane-1', - ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret', - ORCA_WORKSPACE_ID: 'workspace-1', - ORCA_USER_DATA_PATH: '/tmp/orca', - PATH: '/usr/bin' - }) - }) -}) diff --git a/src/relay/remote-cli-env.ts b/src/relay/remote-cli-env.ts deleted file mode 100644 index 6161862c54c..00000000000 --- a/src/relay/remote-cli-env.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function pickRemoteCliEnv(env: NodeJS.ProcessEnv): Record { - const picked: Record = {} - for (const key of [ - 'ORCA_TERMINAL_HANDLE', - 'ORCA_WORKTREE_ID', - 'ORCA_PANE_KEY', - 'ORCA_AGENT_LAUNCH_TOKEN', - 'ORCA_WORKSPACE_ID', - 'ORCA_USER_DATA_PATH', - 'PATH', - 'Path' - ]) { - const value = env[key] - if (typeof value === 'string') { - picked[key] = value - } - } - return picked -} diff --git a/src/relay/remote-cli-stdin.test.ts b/src/relay/remote-cli-stdin.test.ts deleted file mode 100644 index 494ad94e5d5..00000000000 --- a/src/relay/remote-cli-stdin.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { shouldReadRemoteCliStdin } from './remote-cli-stdin' - -describe('shouldReadRemoteCliStdin', () => { - it('reads stdin only for body-file stdin requests', () => { - expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body-file', '-'])).toBe(true) - expect(shouldReadRemoteCliStdin(['linear', 'create', '--body-file=-'])).toBe(true) - expect(shouldReadRemoteCliStdin(['status'])).toBe(false) - expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body', 'done'])).toBe(false) - expect(shouldReadRemoteCliStdin(['linear', 'issue', '--body-file', '-'])).toBe(false) - expect( - shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--help', '--body-file', '-']) - ).toBe(false) - expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body-file', 'body.md'])).toBe( - false - ) - }) - - it('reads stdin for *-stdin payload flags bridged to the full host CLI', () => { - expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin'])).toBe( - true - ) - expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text', 'hi'])).toBe( - false - ) - expect( - shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin', '--help']) - ).toBe(false) - }) -}) diff --git a/src/relay/remote-cli-stdin.ts b/src/relay/remote-cli-stdin.ts deleted file mode 100644 index f9ab8c56da8..00000000000 --- a/src/relay/remote-cli-stdin.ts +++ /dev/null @@ -1,50 +0,0 @@ -export function shouldReadRemoteCliStdin(argv: string[]): boolean { - if (argv.includes('--help') || argv.includes('-h')) { - return false - } - // Why: computer-use style flags (`--text-stdin`, ...) declare a stdin - // payload directly in the flag name; the full-CLI bridge (#7716) must - // forward stdin for them the same way local shells provide it. - if (argv.some((part) => /^--[a-z0-9][a-z0-9-]*-stdin(?:=|$)/.test(part))) { - return true - } - const commandPath = parseRemoteCliCommandPath(argv) - if (!isLinearBodyWriteCommand(commandPath)) { - return false - } - return argv.some((part, index) => { - if (part === '--body-file') { - return argv[index + 1] === '-' - } - return part === '--body-file=-' - }) -} - -const REMOTE_STDIN_BOOLEAN_FLAGS = new Set(['current', 'help', 'json', 'parent-current']) - -function parseRemoteCliCommandPath(argv: string[]): string[] { - const commandPath: string[] = [] - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index] - if (!token.startsWith('--')) { - commandPath.push(token) - continue - } - const assignment = token.slice(2) - if (assignment.includes('=')) { - continue - } - const next = argv[index + 1] - if (!REMOTE_STDIN_BOOLEAN_FLAGS.has(assignment) && next && !next.startsWith('--')) { - index += 1 - } - } - return commandPath -} - -function isLinearBodyWriteCommand(commandPath: string[]): boolean { - if (commandPath[0] !== 'linear') { - return false - } - return (commandPath[1] === 'comment' && commandPath[2] === 'add') || commandPath[1] === 'create' -} diff --git a/src/relay/remote-cli-timeout.test.ts b/src/relay/remote-cli-timeout.test.ts deleted file mode 100644 index d7489b4a364..00000000000 --- a/src/relay/remote-cli-timeout.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' -import { MAX_TIMER_DELAY_MS } from '../shared/timer-delay' - -describe('remoteCliRequestTimeoutMs', () => { - it('gives Linear issue context reads the general CLI budget', () => { - expect( - remoteCliRequestTimeoutMs({ - argv: ['linear', 'issue', 'ENG-123', '--json'] - }) - ).toBe(300_000) - }) - - it('gives ordinary remote CLI requests the general CLI budget instead of the 30s relay default', () => { - // Why: mutation commands bridged through the full host CLI (worktree - // create, orchestration dispatch, ...) can legitimately exceed 30s. - expect(remoteCliRequestTimeoutMs({ argv: ['status'] })).toBe(300_000) - expect(remoteCliRequestTimeoutMs({ argv: ['worktree', 'create', '--repo', 'r'] })).toBe(300_000) - }) - - it('extends the timeout for wait-style commands', () => { - expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'wait', '--for', 'exit'] })).toBe(600_000) - expect( - remoteCliRequestTimeoutMs({ - argv: ['orchestration', 'check', '--wait', '--json'] - }) - ).toBe(600_000) - expect( - remoteCliRequestTimeoutMs({ - argv: ['orchestration', 'ask', '--to', 'term_x', '--question', 'ok?'] - }) - ).toBe(780_000) - }) - - it('extends past an explicit --timeout-ms waiter budget', () => { - expect( - remoteCliRequestTimeoutMs({ - argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '1800000'] - }) - ).toBe(1_860_000) - expect( - remoteCliRequestTimeoutMs({ - argv: ['orchestration', 'check', '--wait', '--timeout-ms=1800000'] - }) - ).toBe(1_860_000) - }) - - it('keeps the wait base budget when --timeout-ms is small', () => { - expect( - remoteCliRequestTimeoutMs({ - argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '5000'] - }) - ).toBe(600_000) - }) - - it.each([ - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER)], 1_980_000], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER + 1)], 780_000], - [['--timeout-ms', '9007199254740991.1'], 780_000], - [['--timeout-ms', '1', '--timeout-ms=1800000'], 1_980_000], - [['--timeout-ms=1800000', '--timeout-ms', '1'], 660_000], - [['--timeout-ms', '1800000', '--timeout-ms'], 780_000], - [['--timeout-ms=1800000', '--timeout-ms='], 780_000], - [['--timeout-ms=1800000', '--timeout-ms', 'bad'], 780_000], - [['--timeout-ms', 'bad', '--timeout-ms=1800000'], 1_980_000], - [['--timeout-ms=bad', '--timeout-ms', '1800000'], 1_980_000] - ])('bounds ask outer timers with last-wins flags %#', (timeoutArgs, expected) => { - expect( - remoteCliRequestTimeoutMs({ - argv: ['orchestration', 'ask', '--to', 'term_x', ...timeoutArgs] - }) - ).toBe(expected) - }) - - it('does not apply the ask maximum to other wait commands', () => { - expect( - remoteCliRequestTimeoutMs({ - argv: ['terminal', 'wait', '--timeout-ms', '1800001'] - }) - ).toBe(1_860_001) - }) - - it.each(['+1000000', '1000000.0', '1e6'])( - 'extends non-ask waits using CLI-compatible integer syntax %s', - (raw) => { - expect( - remoteCliRequestTimeoutMs({ - argv: ['terminal', 'wait', '--timeout-ms', raw] - }) - ).toBe(1_060_000) - } - ) - - it.each([ - ['Infinity'], - ['1.5'], - ['-1'], - ['bad'], - [String(Number.MAX_SAFE_INTEGER)], - [String(MAX_TIMER_DELAY_MS - 60_000 + 1)] - ])('falls back to the base budget when a non-ask --timeout-ms %s is unusable', (raw) => { - expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'wait', '--timeout-ms', raw] })).toBe( - 600_000 - ) - expect(remoteCliRequestTimeoutMs({ argv: ['status', '--timeout-ms', raw] })).toBe(300_000) - }) - - it('keeps the largest non-ask budget that stays inside the timer range', () => { - expect( - remoteCliRequestTimeoutMs({ - argv: ['terminal', 'wait', '--timeout-ms', String(MAX_TIMER_DELAY_MS - 60_000)] - }) - ).toBe(MAX_TIMER_DELAY_MS) - }) - - it('does not treat a flag value named wait as a command path element', () => { - expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'read', '--terminal', 'wait'] })).toBe( - 300_000 - ) - }) - - it('falls back to the relay default for malformed argv', () => { - expect(remoteCliRequestTimeoutMs({ argv: 'status' })).toBeUndefined() - expect(remoteCliRequestTimeoutMs({})).toBeUndefined() - }) -}) diff --git a/src/relay/remote-cli-timeout.ts b/src/relay/remote-cli-timeout.ts deleted file mode 100644 index 97cacda3e33..00000000000 --- a/src/relay/remote-cli-timeout.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { CLI_BOOLEAN_FLAGS } from '../shared/cli-argument-boundary' -import { clampOrchestrationAskTimeoutMs } from '../shared/orchestration-ask-timeout' -import { - isSafeTimerDelayMs, - parsePositiveSafeIntegerNumericText, - parsePositiveSafeIntegerText -} from '../shared/timer-delay' - -// Why: the host bridges the full Orca CLI over the relay (#7716), so mutation -// commands (worktree create, orchestration dispatch, Linear writes, ...) can -// legitimately outlive the relay's 30 s default request timeout. Long-poll -// commands carry their waiter budget in --timeout-ms; extend past it so the -// host-side CLI produces its proper timeout error instead of the relay -// cutting the request short. -const REMOTE_CLI_DEFAULT_TIMEOUT_MS = 5 * 60_000 -const REMOTE_CLI_WAIT_TIMEOUT_MS = 10 * 60_000 -const REMOTE_CLI_TIMEOUT_GRACE_MS = 60_000 -const REMOTE_CLI_LONG_WAIT_GRACE_MS = 3 * 60_000 -const REMOTE_CLI_LONG_WAIT_BASE_MS = 11 * 60_000 - -export function remoteCliRequestTimeoutMs(params: Record): number | undefined { - const argv = getStringArgv(params) - if (!argv) { - return undefined - } - const commandPath = parseRemoteCommandPath(argv) - const timeoutFlag = findLastTimeoutFlag(argv, 'timeout-ms') - if (commandPath[0] === 'terminal' && commandPath[1] === 'send') { - const waitFlag = findLastTimeoutFlag(argv, 'wait-submit') - const seconds = - waitFlag?.raw === undefined ? null : parsePositiveSafeIntegerNumericText(waitFlag.raw) - if (seconds !== null && seconds <= 3600) { - return Math.max(REMOTE_CLI_LONG_WAIT_BASE_MS, seconds * 1000 + REMOTE_CLI_LONG_WAIT_GRACE_MS) - } - } - if (commandPath[0] === 'orchestration' && commandPath[1] === 'ask') { - const parsed = - timeoutFlag?.raw === undefined ? null : parsePositiveSafeIntegerText(timeoutFlag.raw) - const effective = clampOrchestrationAskTimeoutMs(parsed ?? undefined) - return Math.max(REMOTE_CLI_LONG_WAIT_BASE_MS, effective + REMOTE_CLI_LONG_WAIT_GRACE_MS) - } - const base = isWaitStyleCliRequest(argv, commandPath) - ? REMOTE_CLI_WAIT_TIMEOUT_MS - : REMOTE_CLI_DEFAULT_TIMEOUT_MS - const explicit = - timeoutFlag?.raw === undefined ? null : parsePositiveSafeIntegerNumericText(timeoutFlag.raw) - // Why: the relay forwards this straight into a timer, so a budget that would - // overflow the timer range after grace has to degrade to the base budget. - const extended = explicit === null ? null : explicit + REMOTE_CLI_TIMEOUT_GRACE_MS - if (extended !== null && isSafeTimerDelayMs(extended)) { - return Math.max(base, extended) - } - return base -} - -function isWaitStyleCliRequest(argv: string[], commandPath: string[]): boolean { - if (argv.includes('--wait')) { - return true - } - return ( - (commandPath[0] === 'terminal' && commandPath[1] === 'wait') || - (commandPath[0] === 'orchestration' && commandPath[1] === 'ask') - ) -} - -function findLastTimeoutFlag(argv: string[], name: string): { raw: string | undefined } | null { - const flag = `--${name}` - let result: { raw: string | undefined } | null = null - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index] - if (token === flag) { - const next = argv[index + 1] - result = { raw: next?.startsWith('--') ? undefined : next } - } else if (token.startsWith(`${flag}=`)) { - result = { raw: token.slice(flag.length + 1) } - } - } - return result -} - -function getStringArgv(params: Record): string[] | null { - const argv = params.argv - if (!Array.isArray(argv) || !argv.every((part) => typeof part === 'string')) { - return null - } - return argv -} - -function parseRemoteCommandPath(argv: string[]): string[] { - const commandPath: string[] = [] - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index] - if (!token.startsWith('--')) { - commandPath.push(token) - continue - } - - const assignment = token.slice(2) - if (assignment.includes('=')) { - continue - } - - const next = argv[index + 1] - if (!CLI_BOOLEAN_FLAGS.has(assignment) && next && !next.startsWith('--')) { - index += 1 - } - } - return commandPath -} diff --git a/src/relay/retired-pane-surfaces.test.ts b/src/relay/retired-pane-surfaces.test.ts deleted file mode 100644 index 1ee4ab96dd1..00000000000 --- a/src/relay/retired-pane-surfaces.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { RETIRED_PANE_SURFACE_LIMIT, RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' - -describe('RetiredPaneSurfaceRegistry', () => { - it('records a retired pane and forgets it once the surface exists again', () => { - const registry = new RetiredPaneSurfaceRegistry() - - expect(registry.isRetired('pane-a')).toBe(false) - registry.retire('pane-a') - expect(registry.isRetired('pane-a')).toBe(true) - - registry.restore('pane-a') - expect(registry.isRetired('pane-a')).toBe(false) - }) - - it('ignores empty pane keys rather than retiring a catch-all entry', () => { - const registry = new RetiredPaneSurfaceRegistry() - - registry.retire('') - expect(registry.size).toBe(0) - expect(registry.isRetired('')).toBe(false) - }) - - it('caps growth by evicting the longest-retired pane', () => { - const registry = new RetiredPaneSurfaceRegistry() - - for (let index = 0; index < RETIRED_PANE_SURFACE_LIMIT + 25; index += 1) { - registry.retire(`pane-${index}`) - } - - expect(registry.size).toBe(RETIRED_PANE_SURFACE_LIMIT) - expect(registry.isRetired('pane-0')).toBe(false) - expect(registry.isRetired('pane-24')).toBe(false) - expect(registry.isRetired('pane-25')).toBe(true) - expect(registry.isRetired(`pane-${RETIRED_PANE_SURFACE_LIMIT + 24}`)).toBe(true) - }) - - it('re-retiring refreshes recency so an active pane is not evicted first', () => { - const registry = new RetiredPaneSurfaceRegistry() - - registry.retire('pane-oldest') - for (let index = 0; index < RETIRED_PANE_SURFACE_LIMIT - 1; index += 1) { - registry.retire(`pane-${index}`) - } - registry.retire('pane-oldest') - registry.retire('pane-overflow') - - expect(registry.isRetired('pane-oldest')).toBe(true) - expect(registry.isRetired('pane-0')).toBe(false) - }) -}) diff --git a/src/relay/retired-pane-surfaces.ts b/src/relay/retired-pane-surfaces.ts deleted file mode 100644 index 0b39572fbc0..00000000000 --- a/src/relay/retired-pane-surfaces.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * PaneKeys whose client surface the host has been told is gone — a `pty.shutdown` arrived for the - * PTY bound to them — but whose host-side process the relay has not proven dead. - * - * Retirement is deliberately NOT a liveness verdict. Per docs/reference/ssh-execution-boundary.md - * the vocabulary stays `live` / `unverifiable` / `exited`, and this registry asserts none of them - * about the process: it records only that no client tab can own this pane any more. That is the - * fact the relay needs to tell an orphaned agent's hook post apart from a live agent pane, and it - * is knowable without guessing, because the client stated it. - */ - -/** Bounded so a long-lived relay cannot accumulate one entry per pane ever closed. Insertion order - * is age, so eviction drops the longest-retired pane first; an evicted pane simply degrades to the - * pre-fix behaviour (its posts are forwarded again) rather than failing. */ -export const RETIRED_PANE_SURFACE_LIMIT = 512 - -export class RetiredPaneSurfaceRegistry { - private readonly retired = new Set() - - retire(paneKey: string): void { - if (!paneKey) { - return - } - // Delete-then-add makes iteration order = recency of retirement for the cap below. - this.retired.delete(paneKey) - this.retired.add(paneKey) - while (this.retired.size > RETIRED_PANE_SURFACE_LIMIT) { - const oldest = this.retired.values().next().value - if (oldest === undefined) { - break - } - this.retired.delete(oldest) - } - } - - /** A PTY joining the pool under this paneKey means the surface exists again — the user reopened - * the pane, or a revive re-bound it — so the retirement no longer describes anything. */ - restore(paneKey: string): void { - if (paneKey) { - this.retired.delete(paneKey) - } - } - - isRetired(paneKey: string): boolean { - return Boolean(paneKey) && this.retired.has(paneKey) - } - - get size(): number { - return this.retired.size - } - - clear(): void { - this.retired.clear() - } -} diff --git a/src/relay/rotating-log-writer.test.ts b/src/relay/rotating-log-writer.test.ts deleted file mode 100644 index 61c988766bb..00000000000 --- a/src/relay/rotating-log-writer.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Regression: relay.log grows unbounded on long-lived relays. - * - * The relay is launched detached with `> relay.log 2>&1`, which truncates only - * at relaunch; a relay that stays up for days accumulates per-stream stderr - * lines forever. These tests assert the in-process rotator caps size, keeps one - * archived generation, and always leaves the CURRENT log at relay.log so the - * `tail -100 relay.log` diagnostics workflow keeps working. - */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, mkdirSync, readFileSync, existsSync, statSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import * as path from 'node:path' -import { tmpdir } from 'node:os' - -import { RotatingLogWriter, installRelayLogRotation } from './rotating-log-writer' - -describe('RotatingLogWriter', () => { - let dir: string - let logPath: string - - beforeEach(() => { - dir = mkdtempSync(path.join(tmpdir(), 'relay-log-rot-')) - logPath = path.join(dir, 'relay.log') - }) - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }) - }) - - it('rotates relay.log -> relay.log.1 at the cap and keeps the current log tail-able', () => { - const cap = 4 * 1024 - const writer = new RotatingLogWriter(logPath, cap) - try { - const line = `${'a'.repeat(200)}\n` - // Write well past the cap so at least one rotation happens. - for (let i = 0; i < 60; i += 1) { - writer.write(line) - } - - // Current log exists at relay.log (tail target) and is under the cap. - expect(existsSync(logPath)).toBe(true) - expect(statSync(logPath).size).toBeLessThanOrEqual(cap) - // Exactly one archived generation. - expect(existsSync(`${logPath}.1`)).toBe(true) - expect(existsSync(`${logPath}.2`)).toBe(false) - - // The most recent lines are in the current log (tail-ability). - writer.write('MARKER-LAST\n') - expect(readFileSync(logPath, 'utf-8')).toContain('MARKER-LAST') - } finally { - writer.dispose() - } - }) - - it('preserves pre-existing boot output already in relay.log (append, not truncate)', () => { - writeFileSync(logPath, 'BOOT-LINE-FROM-SHELL-REDIRECT\n') - const writer = new RotatingLogWriter(logPath, 1024 * 1024) - try { - writer.write('runtime line\n') - const contents = readFileSync(logPath, 'utf-8') - expect(contents).toContain('BOOT-LINE-FROM-SHELL-REDIRECT') - expect(contents).toContain('runtime line') - } finally { - writer.dispose() - } - }) - - it('bounds a single oversized log write and keeps its newest tail', () => { - const cap = 1024 - const writer = new RotatingLogWriter(logPath, cap) - try { - writer.write(`${'old'.repeat(1000)}LATEST-CONTEXT`) - expect(statSync(logPath).size).toBeLessThanOrEqual(cap) - expect(readFileSync(logPath, 'utf-8')).toContain('LATEST-CONTEXT') - } finally { - writer.dispose() - } - }) - - it('caps total footprint to ~2x maxBytes (current + one archive)', () => { - const cap = 8 * 1024 - const writer = new RotatingLogWriter(logPath, cap) - try { - const line = `${'z'.repeat(256)}\n` - for (let i = 0; i < 500; i += 1) { - writer.write(line) - } - const currentSize = statSync(logPath).size - const archiveSize = existsSync(`${logPath}.1`) ? statSync(`${logPath}.1`).size : 0 - // Never more than the current file + a single archived generation. - expect(currentSize).toBeLessThanOrEqual(cap) - expect(archiveSize).toBeLessThanOrEqual(cap) - expect(existsSync(`${logPath}.2`)).toBe(false) - } finally { - writer.dispose() - } - }) - - it('caps size via truncate-in-place when rename cannot succeed (Windows shell-handle case)', () => { - // Model the platform where renameSync fails (e.g. Windows, where the launch - // shell's own `1>relay.log` handle blocks renaming the live file): make the - // archive path a directory so renameSync(logPath, `${logPath}.1`) throws. - mkdirSync(`${logPath}.1`, { recursive: true }) - const cap = 4 * 1024 - const writer = new RotatingLogWriter(logPath, cap) - try { - const line = `${'q'.repeat(200)}\n` - for (let i = 0; i < 100; i += 1) { - writer.write(line) - } - // The cap still holds via truncate-in-place even though no archive was made. - expect(statSync(logPath).size).toBeLessThanOrEqual(cap) - writer.write('MARKER-LAST\n') - expect(readFileSync(logPath, 'utf-8')).toContain('MARKER-LAST') - } finally { - writer.dispose() - } - }) - - it('installRelayLogRotation routes stdout/stderr through the rotator and restores', () => { - const cap = 2 * 1024 - const { restore } = installRelayLogRotation(logPath, cap) - try { - process.stderr.write('via-stderr-line\n') - process.stdout.write('via-stdout-line\n') - expect(readFileSync(logPath, 'utf-8')).toContain('via-stderr-line') - expect(readFileSync(logPath, 'utf-8')).toContain('via-stdout-line') - } finally { - restore() - } - // After restore, process.stderr no longer targets the rotator file. Spy so - // the assertion write does not leak to the real test-runner stderr. - const sizeAfterRestore = statSync(logPath).size - const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - process.stderr.write('should-not-be-in-relay-log\n') - } finally { - spy.mockRestore() - } - expect(statSync(logPath).size).toBe(sizeAfterRestore) - }) - - it('leaves the original streams active when the log cannot be opened', () => { - mkdirSync(logPath) - const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - const { writer, restore } = installRelayLogRotation(logPath) - expect(writer.active).toBe(false) - process.stdout.write('stdout fallback\n') - process.stderr.write('stderr fallback\n') - expect(stdout).toHaveBeenCalledWith('stdout fallback\n') - expect(stderr).toHaveBeenCalledWith('stderr fallback\n') - restore() - } finally { - stdout.mockRestore() - stderr.mockRestore() - } - }) -}) diff --git a/src/relay/rotating-log-writer.ts b/src/relay/rotating-log-writer.ts deleted file mode 100644 index e685e6fd520..00000000000 --- a/src/relay/rotating-log-writer.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Size-capped, crash-safe rotation for the relay's diagnostic log. -// -// Why: the relay is launched detached with `> relay.log 2>&1`, which only -// truncates at relaunch — a long-lived relay's relay.log grows unbounded -// (per-stream stderr lines, reconnect flaps, etc.). Rotation must live in the -// relay process because it owns its own stderr; the shell redirect cannot cap -// size. Constraints honored: -// - keeps working when the launch fd is a pipe/redirect on Linux/macOS/Windows -// (append-only, no fd tricks; a rotation failure falls back to the prior fd); -// - the diagnostics tail (`tail -100 ~/.orca-remote/relay-*/relay.log`) keeps -// working because the CURRENT log is always at relay.log; -// - crash-safe: writes and rotation are guarded so logging never throws, and -// rotation renames (never deletes the live file) so no window loses logging. -import { closeSync, openSync, renameSync, statSync, writeSync } from 'node:fs' - -/** Default size cap before rotating relay.log → relay.log.1. 10 MB balances - * keeping enough history for diagnosis against bounding disk use on small - * remote hosts (e.g. a Raspberry Pi); one archived generation is kept. */ -export const DEFAULT_RELAY_LOG_MAX_BYTES = 10 * 1024 * 1024 - -type StreamWrite = typeof process.stderr.write - -export class RotatingLogWriter { - private readonly logPath: string - private readonly rotatedPath: string - private readonly maxBytes: number - private fd: number | null = null - private currentBytes = 0 - private failed = false - - constructor(logPath: string, maxBytes: number = DEFAULT_RELAY_LOG_MAX_BYTES) { - this.logPath = logPath - this.rotatedPath = `${logPath}.1` - this.maxBytes = maxBytes - this.open() - } - - private open(mode: 'a' | 'w' = 'a'): void { - try { - // 'a' preserves pre-JS boot output already in relay.log and keeps - // concurrent appends atomic; 'w' truncates in place (used as the rotation - // fallback when a rename cannot succeed — e.g. Windows, where the launch - // shell's own redirect handle blocks renaming the live file). - this.fd = openSync(this.logPath, mode) - try { - this.currentBytes = mode === 'w' ? 0 : statSync(this.logPath).size - } catch { - this.currentBytes = 0 - } - } catch { - // Cannot open the log file (permission/full disk): disable rotation and - // let callers fall back to the original stream. - this.failed = true - this.fd = null - } - } - - /** True when the writer is usable; false means fall back to raw stderr. */ - get active(): boolean { - return !this.failed && this.fd !== null - } - - write(chunk: string | Uint8Array): void { - if (!this.active || this.fd === null) { - return - } - const encoded = typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : Buffer.from(chunk) - // Why: one pathological log write must not bypass the disk cap. Preserve - // the newest tail because it carries the most useful failure context. - const buf = - encoded.length > this.maxBytes ? encoded.subarray(encoded.length - this.maxBytes) : encoded - try { - // Rotate BEFORE writing when the incoming write would cross the cap, so a - // single large line still lands wholly in the fresh file. - if (this.currentBytes > 0 && this.currentBytes + buf.length > this.maxBytes) { - this.rotate() - } - writeSync(this.fd, buf) - this.currentBytes += buf.length - } catch { - // A write failure must never crash the relay; disable and fall back. - this.failed = true - this.closeQuietly() - } - } - - private rotate(): void { - if (this.fd !== null) { - closeSync(this.fd) - this.fd = null - } - let renamed = false - try { - // Preferred path: archive one generation. rename() replaces any existing - // relay.log.1 atomically on POSIX; the live file is renamed (never - // deleted) so no log window is lost, then we reopen a fresh relay.log. - renameSync(this.logPath, this.rotatedPath) - renamed = true - } catch { - // Why: on Windows the launch shell holds its own `1>relay.log` handle - // WITHOUT FILE_SHARE_DELETE, so renaming the live file fails (EPERM/EBUSY) - // — and a cross-device relay.log.1 would fail too. Fall back to truncating - // relay.log in place ('w') so the size cap STILL holds and we don't churn a - // failing rename on every subsequent line. The shell's fd keeps appending - // at its own offset, but the truncation bounds total growth. Trade-off: no - // archived generation on this platform/path. - } - // Successful rename → fresh append file (size 0). Failed rename → truncate. - this.open(renamed ? 'a' : 'w') - } - - private closeQuietly(): void { - if (this.fd !== null) { - try { - closeSync(this.fd) - } catch { - // best-effort - } - this.fd = null - } - } - - dispose(): void { - this.closeQuietly() - } -} - -/** - * Route process.stdout/stderr writes through a RotatingLogWriter that owns - * `logPath`. Returns a restore function. If the writer cannot open the file, - * the original streams are left untouched (logging still works, just uncapped). - */ -export function installRelayLogRotation( - logPath: string, - maxBytes: number = DEFAULT_RELAY_LOG_MAX_BYTES -): { writer: RotatingLogWriter; restore: () => void } { - const writer = new RotatingLogWriter(logPath, maxBytes) - const originalStdout = process.stdout.write.bind(process.stdout) - const originalStderr = process.stderr.write.bind(process.stderr) - - if (!writer.active) { - return { writer, restore: () => {} } - } - - const wrap = - (original: StreamWrite): StreamWrite => - (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown): boolean => { - writer.write(chunk) - // Why: preserve the Writable.write callback contract so callers awaiting - // the write (rare, but e.g. flush-before-exit) are not left hanging. - const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb - if (typeof callback === 'function') { - ;(callback as (err?: Error | null) => void)(null) - } - // If the writer went inactive mid-run (write failure), fall back so logs - // are not silently dropped for the rest of the session. - if (!writer.active) { - return (original as (c: string | Uint8Array) => boolean)(chunk) - } - return true - } - - process.stdout.write = wrap(originalStdout as StreamWrite) as typeof process.stdout.write - process.stderr.write = wrap(originalStderr as StreamWrite) as typeof process.stderr.write - - return { - writer, - restore: () => { - process.stdout.write = originalStdout as typeof process.stdout.write - process.stderr.write = originalStderr as typeof process.stderr.write - writer.dispose() - } - } -} diff --git a/src/relay/session-search-transport.test.ts b/src/relay/session-search-transport.test.ts deleted file mode 100644 index 04be40876ea..00000000000 --- a/src/relay/session-search-transport.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { AiVaultHandler } from './ai-vault-handler' -import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' -import { createSessionSearchClient } from '../shared/ai-vault-search-client' -import { AI_VAULT_AGENTS } from '../shared/ai-vault-types' -import { fakeSearchService } from '../shared/ai-vault-search-test-fixture' -import { setSessionSearchService } from '../main/ai-vault-search/session-search-service-registry' - -const cleanups: (() => void)[] = [] -afterEach(() => { - cleanups.splice(0).forEach((close) => close()) - setSessionSearchService(null) -}) - -function wire(register: boolean) { - let receive!: (data: Buffer) => void - const host = new RelayDispatcher((data) => receive(Buffer.from(data))) - const mux = new SshChannelMultiplexer({ - write: (data) => host.feed(data), - onData: (callback) => { - receive = callback - }, - onClose: () => {} - }) - cleanups.push(() => { - mux.dispose() - host.dispose() - }) - if (register) { - new AiVaultHandler(host, { remoteHome: '/synthetic-host' }) - } - const client = createSessionSearchClient((method, params) => mux.request(method, params), 'relay') - return { host, mux, client } -} - -describe('session search over real relay frames', () => { - it('parses on the host and client and withholds host paths on the wire', async () => { - const service = fakeSearchService() - setSessionSearchService(service) - const { client, mux } = wire(true) - const result = await client.searchSessions({ query: 'needle' }) - expect(result).toMatchObject({ - kind: 'results', - hits: [{ sessionId: 'host-session', source: { presence: 'present' } }] - }) - const raw = await mux.request('aiVault.searchSessions', { - query: 'needle', - tier: 'conversation', - refresh: true - }) - expect(raw).toMatchObject({ hits: [{ source: { presence: 'present' } }] }) - expect(JSON.stringify(raw)).not.toContain('/host/transcript') - expect(JSON.stringify(raw)).not.toContain('/host/codex') - expect(JSON.stringify(raw)).not.toContain('resumeCommand') - expect(service.search).toHaveBeenLastCalledWith( - { - query: 'needle', - limit: 20, - filters: { - agents: AI_VAULT_AGENTS.filter( - (agent) => !['codebuddy', 'zcode', 'qoder', 'jcode', 'kiro'].includes(agent) - ) - } - }, - undefined - ) - expect(service.reconcile).not.toHaveBeenCalled() - expect(await client.searchStatus()).toMatchObject({ enabled: true, generation: 7 }) - await expect(mux.request('aiVault.searchSessions', { query: 42 })).rejects.toThrow() - expect(service.search).toHaveBeenCalledTimes(2) - }) - it('maps a real old-host unknown-method response to unavailable without invoking a local service', async () => { - const local = fakeSearchService() - setSessionSearchService(local) - const { client } = wire(false) - expect(await client.searchSessions({ query: 'needle' })).toEqual({ - kind: 'unavailable', - reason: 'no-service' - }) - expect(await client.searchStatus()).toMatchObject({ enabled: false, generation: 0 }) - expect(local.search).not.toHaveBeenCalled() - }) - it('registers the endpoints without a scanner service or production index', async () => { - const { client } = wire(true) - expect(await client.searchSessions({ query: 'needle' })).toEqual({ - kind: 'unavailable', - reason: 'no-service' - }) - }) - it('propagates transport loss rather than substituting local results', async () => { - const local = fakeSearchService() - setSessionSearchService(local) - const { client, mux } = wire(true) - mux.dispose() - await expect(client.searchSessions({ query: 'needle' })).rejects.toThrow() - expect(local.search).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/settles-within.ts b/src/relay/settles-within.ts deleted file mode 100644 index 5fb3225e75c..00000000000 --- a/src/relay/settles-within.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** True when `work` settles (either way) within `deadlineMs`; false at the deadline. Never rejects. */ -export function settlesWithin(work: Promise, deadlineMs: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(() => resolve(false), deadlineMs) - timer.unref?.() - const settle = (): void => { - clearTimeout(timer) - resolve(true) - } - void work.then(settle, settle) - }) -} diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts deleted file mode 100644 index 3a46506d093..00000000000 --- a/src/relay/skill-install-handler.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { MethodHandler, RelayDispatcher } from '../wsl-guest/dispatcher' -import { createSkillPackageArchive } from '../main/skills/skill-package-creation' -import { createSkillBundleArchive } from '../main/skills/skill-bundle-creation' -import { - SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, - SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, - SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, - SKILL_SSH_RELAY_INSTALL_METHOD, - SKILL_SSH_RELAY_LIST_METHOD, - SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, - SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD -} from '../shared/skill-ssh-relay-contract' -import { - readSkillInstallReceipt, - writeSkillStateFile, - type SkillInstallReceiptV1 -} from '../main/skills/skill-install-provenance' -import { - skillInstallJournalPath, - type SkillInstallJournalV1 -} from '../main/skills/skill-install-recovery' -import { SKILL_RELAY_CAPABILITIES, SkillInstallHandler } from './skill-install-handler' - -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -async function fixture( - beforeStart?: (input: { - archive: Awaited> - home: string - source: string - state: string - }) => Promise, - options: { detectProviders?: () => Promise; recovery?: Promise } = {} -) { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-skill-test-')) - roots.push(root) - const home = join(root, 'home') - const state = join(root, 'state') - const source = join(root, 'source') - await Promise.all([mkdir(home), mkdir(source)]) - await writeFile( - join(source, 'SKILL.md'), - '---\nname: relay-skill\ndescription: Relay test\n---\n\n# Relay\n' - ) - const archive = await createSkillPackageArchive({ - sourceDirectory: source, - archivePath: join(root, 'package.tar.gz'), - packageId: 'package_1', - versionId: 'version_1' - }) - await beforeStart?.({ archive, home, source, state }) - const bytes = await readFile(archive.archivePath) - const handlers = new Map() - const dispatcher = { - onRequest: vi.fn((method: string, handler: MethodHandler) => handlers.set(method, handler)) - } as unknown as RelayDispatcher - new SkillInstallHandler(dispatcher, { - homeDirectory: home, - stateDirectory: state, - detectProviders: options.detectProviders ?? (async () => []), - recovery: options.recovery - }) - const call = (method: string, params: Record) => - handlers.get(method)!(params, { - clientId: 1, - isStale: () => false, - signal: new AbortController().signal - }) - return { archive, bytes, call, home, root, state } -} - -describe('SkillInstallHandler', () => { - it('installs a client-mediated package entirely on the SSH host', async () => { - const { archive, bytes, call, home } = await fixture() - const packageIdentity = { - packageId: archive.manifest.packageId, - versionId: archive.manifest.versionId, - packageDigest: archive.manifest.packageDigest, - archiveSha256: archive.archiveSha256, - compressedBytes: bytes.length - } - const begun = (await call(SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, { - package: packageIdentity - })) as { uploadId: string } - await call(SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, { - uploadId: begun.uploadId, - offset: 0, - bytesBase64: bytes.toString('base64') - }) - await call(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, { uploadId: begun.uploadId }) - - const result = (await call(SKILL_SSH_RELAY_INSTALL_METHOD, { - request: { - operationId: 'operation_1', - package: packageIdentity, - ingress: { kind: 'staged-upload', uploadId: begun.uploadId }, - destination: { scope: 'global', executionTarget: { kind: 'host' } } - } - })) as { status: string } - - expect(result.status).toBe('installed') - expect( - await readFile(join(home, '.agents', 'skills', 'relay-skill', 'SKILL.md'), 'utf8') - ).toContain('# Relay') - }) - - it('advertises separately gateable install, upload, and management capabilities', () => { - expect(SKILL_RELAY_CAPABILITIES).toEqual([ - 'skills.install.v1', - 'skills.install-providers.v1', - 'skills.install.bundle.v1', - 'skills.preview.bundle.v1', - 'skills.install-progress.v1', - 'skills.upload.v1', - 'skills.manage.v1' - ]) - }) - - it('previews a bundle with one provider detection pass', async () => { - const detectProviders = vi.fn(async () => []) - const { call } = await fixture(undefined, { detectProviders }) - const selectedSkills = Array.from({ length: 30 }, (_, index) => ({ - id: `skill-${index}`, - name: `skill-${index}`, - digest: String(index).padStart(64, '0') - })) - - const result = (await call(SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, { - request: { - package: { - packageId: 'package_1', - versionId: 'version_1', - bundleDigest: 'a'.repeat(64), - archiveSha256: 'b'.repeat(64), - compressedBytes: 100 - }, - selectedSkills, - destination: { scope: 'global', executionTarget: { kind: 'host' } } - } - })) as { skills: unknown[] } - - expect(result.skills).toHaveLength(30) - expect(detectProviders).toHaveBeenCalledOnce() - }) - - it('continues after transient startup recovery failure', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const recovery = Promise.reject(new Error('transient-recovery-failure')) - void recovery.catch(() => undefined) - const { call } = await fixture(undefined, { - recovery - }) - - await expect(call(SKILL_SSH_RELAY_LIST_METHOD, { workspaces: [] })).resolves.toEqual([]) - }) - - it('recovers a committed install before the first managed-install listing', async () => { - let canonicalPath = '' - const { archive, call, state } = await fixture(async (input) => { - canonicalPath = join(input.home, '.agents', 'skills', input.archive.manifest.name) - await mkdir(canonicalPath, { recursive: true }) - await writeFile( - join(canonicalPath, 'SKILL.md'), - await readFile(join(input.source, 'SKILL.md')) - ) - const receipt: SkillInstallReceiptV1 = { - schemaVersion: 1, - packageId: input.archive.manifest.packageId, - versionId: input.archive.manifest.versionId, - packageDigest: input.archive.manifest.packageDigest, - archiveSha256: input.archive.archiveSha256, - scope: 'global', - destinationIdentity: 'global:ssh-host', - canonicalPath, - placements: [ - { - provider: 'agent-skills', - path: canonicalPath, - topology: 'canonical-copy', - status: 'installed' - } - ], - installedAt: '2026-08-12T12:00:00.000Z', - hostIdentity: 'ssh-host', - fileModes: input.archive.manifest.files - } - const extractionPath = join(input.home, '.agents', 'skills', '.orca-skill-extract-crashed') - const journal: SkillInstallJournalV1 = { - schemaVersion: 1, - operation: 'install', - phase: 'canonical-placed', - canonicalPath, - extractionPath, - stagingPath: join( - input.home, - '.agents', - 'skills', - `.${input.archive.manifest.name}.orca-staging-crashed` - ), - backupPath: join( - input.home, - '.agents', - 'skills', - `.${input.archive.manifest.name}.orca-backup-crashed` - ), - backupDigest: null, - stagingFileModes: input.archive.manifest.files, - backupFileModes: input.archive.manifest.files, - receipt - } - await writeSkillStateFile( - skillInstallJournalPath(join(input.state, 'skill-installs'), canonicalPath), - journal - ) - }) - - const installs = (await call(SKILL_SSH_RELAY_LIST_METHOD, { workspaces: [] })) as { - packageId: string - versionId: string - state: string - }[] - - expect(installs).toEqual([ - expect.objectContaining({ - packageId: archive.manifest.packageId, - versionId: archive.manifest.versionId, - state: 'unchanged' - }) - ]) - await expect( - readSkillInstallReceipt(join(state, 'skill-installs'), canonicalPath) - ).resolves.toMatchObject({ versionId: archive.manifest.versionId }) - await expect( - readFile(skillInstallJournalPath(join(state, 'skill-installs'), canonicalPath)) - ).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('installs a selected bundle through the additive SSH method', async () => { - const { call, home, root } = await fixture() - const alpha = join(root, 'alpha-skill') - const beta = join(root, 'beta-skill') - await Promise.all([mkdir(alpha), mkdir(beta)]) - await writeFile(join(alpha, 'SKILL.md'), '---\nname: alpha-skill\ndescription: Alpha\n---\n') - await writeFile(join(beta, 'SKILL.md'), '---\nname: beta-skill\ndescription: Beta\n---\n') - const bundle = await createSkillBundleArchive({ - sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }], - archivePath: join(root, 'bundle.tar.gz'), - packageId: 'bundle_package', - versionId: 'bundle_version', - bundleName: 'relay-bundle' - }) - const bundleBytes = await readFile(bundle.archivePath) - const packageIdentity = { - packageId: bundle.manifest.packageId, - versionId: bundle.manifest.versionId, - bundleDigest: bundle.manifest.bundleDigest, - archiveSha256: bundle.archiveSha256, - compressedBytes: bundleBytes.length - } - const begun = (await call(SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, { - package: packageIdentity - })) as { uploadId: string } - await call(SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, { - uploadId: begun.uploadId, - offset: 0, - bytesBase64: bundleBytes.toString('base64') - }) - await call(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, { uploadId: begun.uploadId }) - const alphaId = bundle.manifest.skills.find((skill) => skill.name === 'alpha-skill')!.id - - const result = (await call(SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, { - request: { - operationId: 'bundle_operation', - package: packageIdentity, - selectedSkillIds: [alphaId], - ingress: { kind: 'staged-upload', uploadId: begun.uploadId }, - destination: { scope: 'global', executionTarget: { kind: 'host' } }, - conflictDecisions: [] - } - })) as { status: string; skills: { name: string }[] } - - expect(result).toMatchObject({ status: 'complete', skills: [{ name: 'alpha-skill' }] }) - await expect( - readFile(join(home, '.agents', 'skills', 'alpha-skill', 'SKILL.md')) - ).resolves.toBeDefined() - await expect( - readFile(join(home, '.agents', 'skills', 'beta-skill', 'SKILL.md')) - ).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('exposes invalid staged archives through the stable SSH failure contract', async () => { - const { call } = await fixture() - const bytes = Buffer.from('not a skill archive') - const packageIdentity = { - packageId: 'package_1', - versionId: 'version_1', - packageDigest: 'a'.repeat(64), - archiveSha256: createHash('sha256').update(bytes).digest('hex'), - compressedBytes: bytes.length - } - const begun = (await call(SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, { - package: packageIdentity - })) as { uploadId: string } - await call(SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, { - uploadId: begun.uploadId, - offset: 0, - bytesBase64: bytes.toString('base64') - }) - await call(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, { uploadId: begun.uploadId }) - - await expect( - call(SKILL_SSH_RELAY_INSTALL_METHOD, { - request: { - operationId: 'operation_invalid', - package: packageIdentity, - ingress: { kind: 'staged-upload', uploadId: begun.uploadId }, - destination: { scope: 'global', executionTarget: { kind: 'host' } } - } - }) - ).rejects.toMatchObject({ - code: 'skill_install_failure', - data: { category: 'archive', retryable: false } - }) - }) -}) diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts deleted file mode 100644 index 91abf85d1c2..00000000000 --- a/src/relay/skill-install-handler.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { - SKILL_BUNDLE_INSTALL_CAPABILITY, - SKILL_BUNDLE_PREVIEW_CAPABILITY, - SKILL_INSTALL_CAPABILITY, - SKILL_INSTALL_PROVIDERS_CAPABILITY, - SKILL_INSTALL_PROGRESS_CAPABILITY, - SKILL_MANAGEMENT_CAPABILITY, - SKILL_UPLOAD_CAPABILITY -} from '../shared/skill-install-capability' -import type { ManagedSkillInstall } from '../shared/skill-install-contract' -import type { SkillBundleInstallProgress } from '../shared/skill-bundle-install-contract' -import { - SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, - SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, - SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, - SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, - SKILL_SSH_RELAY_INSTALL_METHOD, - SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, - SKILL_SSH_RELAY_LIST_METHOD, - SKILL_SSH_RELAY_PREVIEW_METHOD, - SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, - SKILL_SSH_RELAY_REMOVE_METHOD, - SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, - SkillSshInstallBundleParamsSchema, - SkillSshInstallProgressParamsSchema, - SkillSshInstallParamsSchema, - SkillSshListParamsSchema, - SkillSshPreviewParamsSchema, - SkillSshPreviewBundleParamsSchema, - SkillSshRemoveParamsSchema, - type SkillSshWorkspaceAuthority -} from '../shared/skill-ssh-relay-contract' -import { - SkillUploadBeginRequestSchema, - SkillUploadChunkRequestSchema, - SkillUploadCommitRequestSchema -} from '../shared/skill-upload-session-contract' -import { - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - getTuiAgentDetectionProbeCommands, - resolveDetectedTuiAgentIds -} from '../shared/tui-agent-detection-commands' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { isCommandOnPathForRelay } from '../wsl-guest/preflight-handler' -import type { SkillInstallDestinationAuthority } from '../main/skills/skill-install-destinations' -import { - previewSharedSkillBundleInstall, - previewSharedSkillInstall, - removeSharedSkillInstall -} from '../main/skills/skill-install-management-service' -import { listManagedSkillInstalls } from '../main/skills/skill-install-provenance' -import { executeSkillInstallRequest } from '../main/skills/skill-install-request-service' -import { executeSkillBundleInstallRequest } from '../main/skills/skill-bundle-install-request-service' -import { SkillUploadSessionService } from '../main/skills/skill-upload-session-service' -import { SKILL_UPLOAD_STAGING_ROOT_NAME } from '../main/skills/skill-upload-staging-ownership' -import { - SkillInstallOperationError, - skillInstallFailureFromError -} from '../main/skills/skill-install-operation-error' -import { recoverPendingSkillTransactions } from '../main/skills/skill-transaction-startup-recovery' -import { resolveEnvironmentSkillProviderRoots } from '../main/skills/skill-provider-runtime-roots' - -const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host' - -export const SKILL_RELAY_CAPABILITIES = [ - SKILL_INSTALL_CAPABILITY, - SKILL_INSTALL_PROVIDERS_CAPABILITY, - SKILL_BUNDLE_INSTALL_CAPABILITY, - SKILL_BUNDLE_PREVIEW_CAPABILITY, - SKILL_INSTALL_PROGRESS_CAPABILITY, - SKILL_UPLOAD_CAPABILITY, - SKILL_MANAGEMENT_CAPABILITY -] as const - -export class SkillInstallHandler { - private readonly homeDirectory: string - private readonly stateDirectory: string - private readonly uploads: SkillUploadSessionService - private readonly detectProviders: () => Promise - private readonly recovery: Promise - private readonly installProgress = new Map() - - constructor( - private readonly dispatcher: RelayDispatcher, - options: { - homeDirectory?: string - stateDirectory?: string - detectProviders?: () => Promise - recovery?: Promise - } = {} - ) { - this.homeDirectory = options.homeDirectory ?? homedir() - this.stateDirectory = options.stateDirectory ?? join(this.homeDirectory, '.orca') - this.uploads = new SkillUploadSessionService( - join(this.stateDirectory, 'skill-installs', SKILL_UPLOAD_STAGING_ROOT_NAME) - ) - this.detectProviders = options.detectProviders ?? detectRelaySkillProviders - this.recovery = ( - options.recovery ?? - recoverPendingSkillTransactions(join(this.stateDirectory, 'skill-installs')) - ).catch((error) => { - console.warn('[skills] relay startup transaction recovery failed:', error) - }) - this.registerHandlers() - } - - private registerHandlers(): void { - this.dispatcher.onRequest(SKILL_SSH_RELAY_INSTALL_METHOD, async (params, context) => { - const input = SkillSshInstallParamsSchema.parse(params) - return this.executeSkillOperation(() => - executeSkillInstallRequest(input.request, { - authority: this.authority(input.workspace), - stateDirectory: this.stateDirectory, - allowedDownloadOrigins: ['https://storage.googleapis.com'], - requireHttps: true, - resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity), - detectProviders: this.detectProviders, - resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(), - signal: context.signal - }) - ) - }) - this.dispatcher.onRequest(SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, async (params, context) => { - const input = SkillSshInstallBundleParamsSchema.parse(params) - return this.executeSkillOperation(async () => { - try { - return await executeSkillBundleInstallRequest(input.request, { - authority: this.authority(input.workspace), - stateDirectory: this.stateDirectory, - allowedDownloadOrigins: ['https://storage.googleapis.com'], - requireHttps: true, - resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity), - detectProviders: this.detectProviders, - resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(), - signal: context.signal, - onProgress: (progress) => this.installProgress.set(input.request.operationId, progress) - }) - } finally { - this.installProgress.delete(input.request.operationId) - } - }) - }) - this.dispatcher.onRequest(SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, async (params) => { - const input = SkillSshInstallProgressParamsSchema.parse(params) - return this.installProgress.get(input.operationId) ?? null - }) - this.dispatcher.onRequest(SKILL_SSH_RELAY_PREVIEW_METHOD, async (params) => { - const input = SkillSshPreviewParamsSchema.parse(params) - return this.executeSkillOperation(() => - previewSharedSkillInstall(input.request, { - authority: this.authority(input.workspace), - stateDirectory: this.stateDirectory, - detectProviders: this.detectProviders, - resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots() - }) - ) - }) - this.dispatcher.onRequest(SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD, async (params) => { - const input = SkillSshPreviewBundleParamsSchema.parse(params) - return this.executeSkillOperation(() => - previewSharedSkillBundleInstall(input.request, { - authority: this.authority(input.workspace), - stateDirectory: this.stateDirectory, - detectProviders: this.detectProviders, - resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots() - }) - ) - }) - this.dispatcher.onRequest(SKILL_SSH_RELAY_REMOVE_METHOD, async (params) => { - const input = SkillSshRemoveParamsSchema.parse(params) - return this.executeSkillOperation(() => - removeSharedSkillInstall(input.request, { - authority: this.authority(input.workspace), - stateDirectory: this.stateDirectory, - detectProviders: this.detectProviders, - resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots() - }) - ) - }) - this.dispatcher.onRequest(SKILL_SSH_RELAY_LIST_METHOD, async (params) => { - const input = SkillSshListParamsSchema.parse(params) - return this.listManagedInstalls(input.workspaces) - }) - this.dispatcher.onRequest(SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, (params) => - this.uploads.begin(SkillUploadBeginRequestSchema.parse(params)) - ) - this.dispatcher.onRequest(SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, (params) => - this.uploads.append(SkillUploadChunkRequestSchema.parse(params)) - ) - this.dispatcher.onRequest(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, (params) => - this.uploads.commit(SkillUploadCommitRequestSchema.parse(params).uploadId) - ) - this.dispatcher.onRequest(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, async (params) => { - await this.uploads.cancel(SkillUploadCommitRequestSchema.parse(params).uploadId) - return { ok: true } - }) - } - - private authority(workspace?: SkillSshWorkspaceAuthority): SkillInstallDestinationAuthority { - return { - environmentId: SSH_SKILL_ENVIRONMENT_ID, - homeDirectory: this.homeDirectory, - resolveWorktree: async (id) => - workspace?.kind === 'worktree' && workspace.id === id ? workspace : null, - resolveFolderWorkspace: async (id) => - workspace?.kind === 'folder' && workspace.id === id ? workspace : null - } - } - - private async executeSkillOperation(operation: () => Promise): Promise { - try { - await this.recovery - return await operation() - } catch (error) { - const failure = skillInstallFailureFromError(error) - if (!failure) { - throw error - } - throw new SkillInstallOperationError(failure, { cause: error }) - } - } - - async dispose(): Promise { - await this.uploads.dispose() - } - - private async listManagedInstalls( - workspaces: SkillSshWorkspaceAuthority[] - ): Promise { - await this.recovery - const installs = await listManagedSkillInstalls(join(this.stateDirectory, 'skill-installs')) - return installs.flatMap((install): ManagedSkillInstall[] => { - if (install.scope === 'global') { - return [{ ...install, destination: { scope: 'global', executionTarget: { kind: 'host' } } }] - } - const prefix = `workspace:${SSH_SKILL_ENVIRONMENT_ID}:` - const workspace = workspaces.find( - (candidate) => install.destinationIdentity === `${prefix}${candidate.id}` - ) - if (!workspace) { - return [] - } - return [ - { - ...install, - destination: - workspace.kind === 'worktree' - ? { scope: 'workspace', worktreeId: workspace.id } - : { scope: 'workspace', folderWorkspaceId: workspace.id } - } - ] - }) - } -} - -export async function detectRelaySkillProviders(): Promise { - const runtime = process.platform - const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) - const found = await Promise.all( - probes.map(async (command) => ({ command, found: await isCommandOnPathForRelay(command) })) - ) - return resolveDetectedTuiAgentIds( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - new Set(found.filter((item) => item.found).map((item) => item.command)), - runtime - ) -} diff --git a/src/relay/skill-upload-multi-relay.integration.test.ts b/src/relay/skill-upload-multi-relay.integration.test.ts deleted file mode 100644 index de2cfbf119e..00000000000 --- a/src/relay/skill-upload-multi-relay.integration.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { createHash } from 'node:crypto' -import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' -import { build } from 'esbuild' -import { JSONC_PARSER_ESM_ALIAS } from '../../config/build-plugins/jsonc-parser-esm' -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' -import { spawnRelay, type RelayProcess } from './subprocess-test-utils' - -let bundleRoot: string -let relayEntry: string -const relays: RelayProcess[] = [] -const roots: string[] = [] - -beforeAll(async () => { - const externalEntry = process.env.ORCA_SKILL_UPLOAD_RELAY_ENTRY - if (externalEntry) { - relayEntry = resolve(externalEntry) - return - } - bundleRoot = await mkdtemp(join(tmpdir(), 'orca-skill-multi-relay-bundle-')) - relayEntry = join(bundleRoot, 'relay.js') - await build({ - entryPoints: [resolve('src/relay/relay.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: relayEntry, - external: ['node-pty', '@parcel/watcher', 'electron'], - alias: JSONC_PARSER_ESM_ALIAS, - logLevel: 'silent' - }) -}) - -afterAll(async () => { - if (bundleRoot) { - await rm(bundleRoot, { recursive: true, force: true }) - } -}) - -afterEach(async () => { - await Promise.all( - relays.splice(0).map(async (relay) => { - relay.kill('SIGTERM') - await relay.waitForExit().catch(() => undefined) - }) - ) - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -async function request( - relay: RelayProcess, - method: string, - params: Record -): Promise { - const response = await relay.waitForResponse(relay.send(method, params)) - expect(response.error).toBeUndefined() - return response.result -} - -async function stagedArchives(uploadRoot: string): Promise { - const entries = await readdir(uploadRoot, { withFileTypes: true }) - const rootArchives = entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.tar.gz')) - .map((entry) => join(uploadRoot, entry.name)) - const archives = await Promise.all( - entries - .filter((entry) => entry.isDirectory()) - .map(async (entry) => - (await readdir(join(uploadRoot, entry.name))) - .filter((name) => name.endsWith('.tar.gz')) - .map((name) => join(uploadRoot, entry.name, name)) - ) - ) - return [...rootArchives, ...archives.flat()].sort() -} - -async function uploadRootForOracle(home: string): Promise { - const installRoot = join(home, '.orca', 'skill-installs') - const current = join(installRoot, 'remote-uploads-v2') - return (await stat(current).catch(() => null)) ? current : join(installRoot, 'remote-uploads') -} - -function packageIdentity(bytes: Buffer, suffix: string) { - return { - packageId: `package_${suffix}`, - versionId: `version_${suffix}`, - packageDigest: createHash('sha256').update(`manifest-${suffix}`).digest('hex'), - archiveSha256: createHash('sha256').update(bytes).digest('hex'), - compressedBytes: bytes.length - } -} - -describe('skill upload ownership across relay processes', () => { - it('keeps each live relay upload isolated and cleans each exact owner', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-skill-multi-relay-')) - roots.push(root) - const home = join(root, 'home') - const environment = { ...process.env, HOME: home, USERPROFILE: home } - const first = spawnRelay( - relayEntry, - ['--sock-path', join(root, 'first.sock'), '--endpoint-dir', join(root, 'first-hooks')], - { env: environment } - ) - const second = spawnRelay( - relayEntry, - ['--sock-path', join(root, 'second.sock'), '--endpoint-dir', join(root, 'second-hooks')], - { env: environment } - ) - relays.push(first, second) - await Promise.all([first.sentinelReceived, second.sentinelReceived]) - const firstBytes = Buffer.from('first relay live upload') - const secondBytes = Buffer.from('second relay upload') - const firstUpload = (await request(first, 'skills.beginUpload', { - package: packageIdentity(firstBytes, 'first') - })) as { uploadId: string } - await request(first, 'skills.uploadChunk', { - uploadId: firstUpload.uploadId, - offset: 0, - bytesBase64: firstBytes.subarray(0, 5).toString('base64') - }) - - const secondUpload = (await request(second, 'skills.beginUpload', { - package: packageIdentity(secondBytes, 'second') - })) as { uploadId: string } - await request(second, 'skills.uploadChunk', { - uploadId: secondUpload.uploadId, - offset: 0, - bytesBase64: secondBytes.toString('base64') - }) - const uploadRoot = await uploadRootForOracle(home) - const ownerEntries = (await readdir(uploadRoot, { withFileTypes: true })).filter((entry) => - entry.isDirectory() - ) - const archives = await stagedArchives(uploadRoot) - const firstPath = archives.find((path) => path.endsWith(`${firstUpload.uploadId}.tar.gz`)) - const secondPath = archives.find((path) => path.endsWith(`${secondUpload.uploadId}.tar.gz`)) - const firstStagedBytes = firstPath ? await readFile(firstPath) : null - const secondStagedBytes = secondPath ? await readFile(secondPath) : null - const ownerPattern = /^owner-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - - expect.soft(ownerEntries).toHaveLength(2) - expect.soft(ownerEntries.every((entry) => ownerPattern.test(entry.name))).toBe(true) - expect - .soft(ownerEntries.some((entry) => entry.name.startsWith(`owner-${first.proc.pid}-`))) - .toBe(true) - expect - .soft(ownerEntries.some((entry) => entry.name.startsWith(`owner-${second.proc.pid}-`))) - .toBe(true) - expect.soft(archives).toHaveLength(2) - expect.soft(basename(dirname(firstPath!))).toMatch(new RegExp(`^owner-${first.proc.pid}-`)) - expect.soft(basename(dirname(secondPath!))).toMatch(new RegExp(`^owner-${second.proc.pid}-`)) - expect.soft(firstStagedBytes).toEqual(firstBytes.subarray(0, 5)) - expect.soft(secondStagedBytes).toEqual(secondBytes) - await request(second, 'skills.cancelUpload', { uploadId: secondUpload.uploadId }) - expect(await stagedArchives(uploadRoot)).toEqual([firstPath]) - await request(first, 'skills.cancelUpload', { uploadId: firstUpload.uploadId }) - expect(await stagedArchives(uploadRoot)).toEqual([]) - - const firstDisposalBytes = Buffer.from('first relay disposal upload') - const secondDisposalBytes = Buffer.from('second relay disposal upload') - const firstDisposalUpload = (await request(first, 'skills.beginUpload', { - package: packageIdentity(firstDisposalBytes, 'first_disposal') - })) as { uploadId: string } - await request(first, 'skills.uploadChunk', { - uploadId: firstDisposalUpload.uploadId, - offset: 0, - bytesBase64: firstDisposalBytes.toString('base64') - }) - const secondDisposalUpload = (await request(second, 'skills.beginUpload', { - package: packageIdentity(secondDisposalBytes, 'second_disposal') - })) as { uploadId: string } - await request(second, 'skills.uploadChunk', { - uploadId: secondDisposalUpload.uploadId, - offset: 0, - bytesBase64: secondDisposalBytes.toString('base64') - }) - const disposalArchives = await stagedArchives(uploadRoot) - const secondDisposalPath = disposalArchives.find((path) => - path.endsWith(`${secondDisposalUpload.uploadId}.tar.gz`) - ) - - first.kill('SIGTERM') - await first.waitForExit() - relays.splice(relays.indexOf(first), 1) - expect(await readdir(uploadRoot)).toEqual([ - expect.stringMatching(new RegExp(`^owner-${second.proc.pid}-`)) - ]) - expect(await stagedArchives(uploadRoot)).toEqual([secondDisposalPath]) - await expect(readFile(secondDisposalPath!)).resolves.toEqual(secondDisposalBytes) - second.kill('SIGTERM') - await second.waitForExit() - relays.splice(relays.indexOf(second), 1) - expect(await readdir(uploadRoot)).toEqual([]) - }) - - it - .runIf(Boolean(process.env.ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY)) - .each(['legacy-first', 'current-first'] as const)( - 'keeps %s mixed-version uploads isolated', - async (order) => { - const root = await mkdtemp(join(tmpdir(), 'orca-skill-mixed-relay-')) - roots.push(root) - const home = join(root, 'home') - const environment = { ...process.env, HOME: home, USERPROFILE: home } - const legacyEntry = resolve(process.env.ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY!) - const entries = - order === 'legacy-first' - ? [ - { kind: 'legacy', entry: legacyEntry }, - { kind: 'current', entry: relayEntry } - ] - : [ - { kind: 'current', entry: relayEntry }, - { kind: 'legacy', entry: legacyEntry } - ] - const uploads: { relay: RelayProcess; uploadId: string; bytes: Buffer; kind: string }[] = [] - for (const [index, owner] of entries.entries()) { - const relay = spawnRelay( - owner.entry, - [ - '--sock-path', - join(root, `${owner.kind}.sock`), - '--endpoint-dir', - join(root, `${owner.kind}-hooks`) - ], - { env: environment } - ) - relays.push(relay) - await relay.sentinelReceived - const bytes = Buffer.from(`${owner.kind} live upload`) - const begun = (await request(relay, 'skills.beginUpload', { - package: packageIdentity(bytes, `${owner.kind}_${index}`) - })) as { uploadId: string } - await request(relay, 'skills.uploadChunk', { - uploadId: begun.uploadId, - offset: 0, - bytesBase64: bytes.toString('base64') - }) - uploads.push({ relay, uploadId: begun.uploadId, bytes, kind: owner.kind }) - } - - const installRoot = join(home, '.orca', 'skill-installs') - const legacyFiles = (await readdir(join(installRoot, 'remote-uploads'))).filter((name) => - name.endsWith('.tar.gz') - ) - const currentFiles = await stagedArchives(join(installRoot, 'remote-uploads-v2')) - expect(legacyFiles).toHaveLength(1) - expect(currentFiles).toHaveLength(1) - for (const upload of uploads) { - const path = - upload.kind === 'legacy' - ? join(installRoot, 'remote-uploads', legacyFiles[0]!) - : currentFiles[0]! - await expect(readFile(path)).resolves.toEqual(upload.bytes) - await request(upload.relay, 'skills.cancelUpload', { uploadId: upload.uploadId }) - } - } - ) -}) diff --git a/src/relay/ssh-pty-consumer-session-adapter.test.ts b/src/relay/ssh-pty-consumer-session-adapter.test.ts deleted file mode 100644 index 87b938c4f83..00000000000 --- a/src/relay/ssh-pty-consumer-session-adapter.test.ts +++ /dev/null @@ -1,692 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - PTY_CONSUMER_OWNER_GRACE_MS, - PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR, - PTY_CONSUMER_OWNER_HELD_GRACE_FLOOR_MS, - PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR -} from '../shared/pty-consumer-session' -import { RelayDispatcher, type RelayClientSessionIdentity } from '../wsl-guest/dispatcher' -import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function openFrame(id: number, overrides: Record = {}): Buffer { - return encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id, - method: 'pty.openClient', - params: { - protocolVersion: 1, - clientInstanceId: `client-${id}`, - requestedRole: 'session-owner', - ...overrides - } - }, - 1, - 0 - ) -} - -function responsePayload(buffer: Buffer): Record { - expect(buffer[0]).toBe(MessageType.Regular) - const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) -} - -function responseResult(buffer: Buffer): Record { - return responsePayload(buffer).result as Record -} - -function responseError(buffer: Buffer): Record { - return responsePayload(buffer).error as Record -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('SshPtyConsumerSessionAdapter', () => { - let dispatcher: RelayDispatcher | null = null - - afterEach(() => { - dispatcher?.dispose() - dispatcher = null - vi.useRealTimers() - }) - - it('does not activate owner authority until the grant write settles', async () => { - const firstWrites: Buffer[] = [] - const firstSettlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - firstWrites.push(Buffer.from(data)) - firstSettlements.push(onSettled) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - dispatcher.feed(openFrame(1)) - await flushRequests() - - const secondWrites: Buffer[] = [] - const secondId = dispatcher.attachClient( - (data, onSettled) => { - secondWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - { ...endpointIdentity, principal: 'competitor' } - ) - dispatcher.feedClient(secondId, openFrame(2)) - await flushRequests() - - expect(responseResult(firstWrites[0])).toMatchObject({ - role: 'session-owner', - ownerGeneration: 1, - resumed: false - }) - // Why a coded refusal reaches the wire: the dispatcher transports code and message only, so the - // competitor has to be able to tell "still settling" from "held" without parsing prose. - expect(responseError(secondWrites[0])).toMatchObject({ - code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR - }) - firstSettlements[0]({ ok: true }) - }) - - it('rolls back owner election when the grant write fails', async () => { - dispatcher = new RelayDispatcher( - (_data, onSettled) => { - onSettled({ ok: false, error: new Error('send failed') }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed(openFrame(1)) - await flushRequests() - - const retryWrites: Buffer[] = [] - const retryId = dispatcher.attachClient( - (data, onSettled) => { - retryWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient(retryId, openFrame(2, { clientInstanceId: 'client-1' })) - await flushRequests() - - expect(responseResult(retryWrites[0])).toMatchObject({ - role: 'session-owner', - ownerGeneration: 2 - }) - }) - - it.each([['peer-closed'], ['local']] as const)( - 'shortens a refused owner grace only for a %s detach', - async (cause) => { - // Why only Date: flushRequests rides setImmediate, which fake timers would otherwise capture. - vi.useFakeTimers({ toFake: ['Date'] }) - dispatcher = new RelayDispatcher( - (_data, onSettled) => { - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - const ownerWrites: Buffer[] = [] - const ownerId = dispatcher.attachClient( - (data, onSettled) => { - ownerWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient(ownerId, openFrame(1)) - await flushRequests() - expect(responseResult(ownerWrites[0])).toMatchObject({ role: 'session-owner' }) - - dispatcher.detachClient(ownerId, cause) - - const rivalWrites: Buffer[] = [] - const rivalId = dispatcher.attachClient( - (data, onSettled) => { - rivalWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - { ...endpointIdentity, principal: 'competitor' } - ) - // The first refusal is what applies the floor, so it has to happen before the clock moves. - dispatcher.feedClient(rivalId, openFrame(2)) - await flushRequests() - expect(responseError(rivalWrites[0])).toMatchObject({ - code: PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR - }) - - vi.setSystemTime(Date.now() + PTY_CONSUMER_OWNER_HELD_GRACE_FLOOR_MS + 1) - const retryId = dispatcher.attachClient( - (data, onSettled) => { - rivalWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - { ...endpointIdentity, principal: 'competitor' } - ) - dispatcher.feedClient(retryId, openFrame(3)) - await flushRequests() - - // Why this pair is the whole point of the cause: a socket that ended is evidence the owner is - // gone, and a queue the relay overran is not. Only the first may cost the incumbent its claim. - if (cause === 'peer-closed') { - expect(responseResult(rivalWrites[1])).toMatchObject({ role: 'session-owner' }) - } else { - expect(responseError(rivalWrites[1])).toMatchObject({ - code: PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR - }) - } - } - ) - - it('rejects an unproved constructor stream as an owner principal', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher((data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - dispatcher.feed(openFrame(1)) - await flushRequests() - - const response = JSON.parse( - writes[0].subarray(13, 13 + writes[0].readUInt32BE(9)).toString('utf8') - ) - expect(response.error.message).toContain('authentication') - }) - - it('rejects an invalid requested role instead of promoting it to owner', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - - dispatcher.feed(openFrame(1, { requestedRole: 'administrator' })) - await flushRequests() - - const response = JSON.parse( - writes[0].subarray(13, 13 + writes[0].readUInt32BE(9)).toString('utf8') - ) - expect(response.error.message).toContain('requestedRole') - }) - - it('generation-fences per-PTY delivery pause notifications', async () => { - const setPaused: { id: string; paused: boolean }[] = [] - dispatcher = new RelayDispatcher( - (_data, onSettled) => { - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', (id, paused) => { - setPaused.push({ id, paused }) - }) - dispatcher.feed(openFrame(1)) - await flushRequests() - - dispatcher.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.setDeliveryPaused', - params: { - id: 'pty-1', - paused: true, - clientGeneration: 1, - ownerGeneration: 1 - } - }, - 2, - 0 - ) - ) - dispatcher.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.setDeliveryPaused', - params: { - id: 'pty-1', - paused: false, - clientGeneration: 99, - ownerGeneration: 1 - } - }, - 3, - 0 - ) - ) - - expect(setPaused).toEqual([{ id: 'pty-1', paused: true }]) - }) - - it('token-fences V1 pause and clears an owned pause before detach retention', async () => { - const setPaused: { id: string; paused: boolean }[] = [] - dispatcher = new RelayDispatcher( - (_data, onSettled) => { - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', (id, paused) => { - setPaused.push({ id, paused }) - }) - dispatcher.feed( - openFrame(1, { - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - const identity = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - - for (const [sequence, deliveryToken] of [ - [2, 'stale-token'], - [3, identity.deliveryToken] - ] as const) { - dispatcher.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.setDeliveryPaused', - params: { - id: identity.id, - paused: true, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - deliveryToken - } - }, - sequence, - 0 - ) - ) - } - dispatcher.invalidateClient() - - expect(setPaused).toEqual([ - { id: 'pty-1', paused: true }, - { id: 'pty-1', paused: false } - ]) - }) - - it('clears the exact token pause before cancellation cleanup', async () => { - const setPaused: { id: string; paused: boolean }[] = [] - dispatcher = new RelayDispatcher( - (_data, onSettled) => { - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', (id, paused) => { - setPaused.push({ id, paused }) - }) - dispatcher.feed( - openFrame(1, { - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - const identity = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - dispatcher.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.setDeliveryPaused', - params: { - id: identity.id, - paused: true, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - deliveryToken: identity.deliveryToken - } - }, - 2, - 0 - ) - ) - - adapter.cancelDelivery(identity, 'restore-required') - - expect(setPaused).toEqual([ - { id: 'pty-1', paused: true }, - { id: 'pty-1', paused: false } - ]) - }) - - it('intersects the negotiated V1 source window without changing legacy omission', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed( - openFrame(1, { - capabilities: { - outputFlowControl: { versions: [1], requestedWindowSu: 512 * 1024 } - } - }) - ) - await flushRequests() - - expect(responseResult(writes[0])).toMatchObject({ - capabilities: { - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - } - }) - }) - - it('keeps an old client token-free when the new relay supports V1', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed(openFrame(1)) - await flushRequests() - - const grant = responseResult(writes[0]) - expect(grant).not.toHaveProperty('capabilities') - expect(grant).not.toHaveProperty('deliveryToken') - }) - - it('accepts cumulative source ACKs only from the negotiated token owner', async () => { - dispatcher = new RelayDispatcher( - (_data, onSettled) => { - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed( - openFrame(1, { - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } - }) - ) - await flushRequests() - const identity = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - adapter.appendSource(identity, { - spanId: 'span-1', - data: 'data', - displayStart: 0, - displayEnd: 4, - splittable: true, - transform: { transformed: false, rawLengthSu: 4, scalarSafe: true } - }) - const reservation = adapter.reserveSourceSend(identity)! - adapter.commitSourceSend(reservation) - - dispatcher.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: [ - { - id: identity.id, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - deliveryToken: identity.deliveryToken, - creditedEndSu: 4 - } - ] - } - }, - 2, - 0 - ) - ) - - expect(adapter.sourceDeliverySnapshot(identity).creditedEndSu).toBe(4) - }) - - it('returns exact token-scoped cancellation proof before local cleanup', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed( - openFrame(1, { - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - const identity = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - adapter.appendSource(identity, { - spanId: 'span-1', - data: 'data', - displayStart: 0, - displayEnd: 4, - splittable: true, - transform: { transformed: false, rawLengthSu: 4, scalarSafe: true } - }) - adapter.commitSourceSend(adapter.reserveSourceSend(identity)!) - dispatcher.feed( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: 2, - method: 'pty.cancelDelivery', - params: { - id: identity.id, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - deliveryToken: identity.deliveryToken - } - }, - 2, - 0 - ) - ) - await flushRequests() - - expect(responseResult(writes.at(-1)!)).toEqual({ - canceled: true, - sentEndSu: 4, - creditedEndSu: 0 - }) - expect(adapter.sourceDeliverySnapshot(identity).state).toBe('closed') - }) - - it('rotates reconnect ownership and transfers only exact retained recovery', async () => { - const firstWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - firstWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed( - openFrame(1, { - clientInstanceId: 'stable-client', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - const firstGrant = responseResult(firstWrites[0]) - const oldIdentity = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - adapter.appendSource(oldIdentity, { - spanId: 'span-1', - data: 'data', - displayStart: 0, - displayEnd: 4, - splittable: true, - transform: { transformed: false, rawLengthSu: 4, scalarSafe: true } - }) - dispatcher.invalidateClient() - - const recoveredWrites: Buffer[] = [] - const recoveredClientId = dispatcher.attachClient( - (data, onSettled) => { - recoveredWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - recoveredClientId, - openFrame(2, { - clientInstanceId: 'stable-client', - resume: { - ownerGeneration: firstGrant.ownerGeneration, - ownerLease: firstGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - - const rotation = adapter.rotateDelivery(oldIdentity, recoveredClientId, 0) - expect(rotation.recovery.map((sourceSpan) => sourceSpan.data)).toEqual(['data']) - expect(rotation.identity).toMatchObject({ - providerGeneration: oldIdentity.providerGeneration, - clientGeneration: 2, - ownerGeneration: 2 - }) - expect(rotation.cancellation.replacementDeliveryToken).toBe(rotation.identity.deliveryToken) - }) - - it('keeps the old-token deadline after a resume grant until exact rotation', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) - const firstWrites: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - firstWrites.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed( - openFrame(1, { - clientInstanceId: 'stable-client', - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - const firstGrant = responseResult(firstWrites[0]) - const oldIdentity = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - dispatcher.invalidateClient() - - const recoveredClientId = dispatcher.attachClient( - (_data, onSettled) => { - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - dispatcher.feedClient( - recoveredClientId, - openFrame(2, { - clientInstanceId: 'stable-client', - resume: { - ownerGeneration: firstGrant.ownerGeneration, - ownerLease: firstGrant.ownerLease - }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) - - expect(adapter.sourceDeliverySnapshot(oldIdentity).state).toBe('closed') - }) - - it('closes retained source tokens when the dispatcher is disposed', async () => { - dispatcher = new RelayDispatcher( - (_data, onSettled) => { - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - dispatcher.feed( - openFrame(1, { - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 8 } } - }) - ) - await flushRequests() - const identity = adapter.openDelivery(1, 'pty-1', 'incarnation-1')! - - dispatcher.dispose() - - expect(adapter.sourceDeliverySnapshot(identity).state).toBe('closed') - }) -}) diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts deleted file mode 100644 index c5d5fedcae0..00000000000 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { - PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - PtyConsumerSession, - type PtyConsumerSessionAdmission, - type PtyConsumerSessionGrant -} from '../shared/pty-consumer-session' -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../shared/pty-source-credit-contract' -import type { - PtySourceDeliveryIdentity, - PtySourceDeliverySnapshot, - PtySourceSpan, - PtySourceTransform -} from '../shared/pty-source-credit-contract' -import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' -import type { PtySourceSendReservation } from './pty-source-credit-ledger' -import { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' -import { - admitsSshPtyDataPublication, - sshPtyDeliveryMode, - type SshPtyDeliveryMode -} from './ssh-pty-data-publication-admission' -import { parseOpenClientParams, requireIdentity } from './ssh-pty-open-client-request' - -export const SSH_PTY_OPEN_CLIENT_METHOD = 'pty.openClient' - -export class SshPtyConsumerSessionAdapter { - private readonly session: PtyConsumerSession - private readonly sourceCredit: SshPtySourceCreditAdapter - private readonly pausedDeliveryByPty = new Map() - - constructor( - private readonly dispatcher: RelayDispatcher, - serverBuildId: string, - private readonly setDeliveryPaused?: (id: string, paused: boolean) => void, - onSourceCreditAvailable?: (id: string) => void - ) { - this.sourceCredit = new SshPtySourceCreditAdapter( - (proof) => - dispatcher.notifyControl( - 'pty.deliveryCanceled', - proof as unknown as Record - ), - onSourceCreditAvailable - ) - this.session = new PtyConsumerSession({ - serverBuildId, - outputFlowControl: { versions: [1], maxWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } - }) - // Why the admission is consulted again at drain time (see isStillAdmitted): a frame can sit - // queued behind a saturated socket long enough for the grant or the delivery to be retired, and - // publishing it then hands the client output from an owner it no longer is. - dispatcher.registerPtyDataPublicationAdmission((clientId, params) => - admitsSshPtyDataPublication( - this.session.activeGrant(String(clientId)), - params, - this.sourceCredit - ) - ) - dispatcher.onRequest(SSH_PTY_OPEN_CLIENT_METHOD, (params, context) => - this.openClient(params, context) - ) - dispatcher.onClientDetached((clientId, cause) => { - const connectionKey = String(clientId) - const grant = this.session.activeGrant(connectionKey) - if (grant) { - this.clearPausedForGrant(grant) - } - this.session.close(connectionKey, cause) - if (grant) { - this.sourceCredit.retainOrCloseOnDetach(grant) - } - }) - dispatcher.onNotification('pty.setDeliveryPaused', (params, context) => { - const grant = this.session.activeGrant(String(context.clientId)) - if ( - !grant || - grant.clientGeneration !== params.clientGeneration || - grant.ownerGeneration !== params.ownerGeneration || - typeof params.id !== 'string' || - typeof params.paused !== 'boolean' - ) { - return - } - if (grant.capabilities?.outputFlowControl) { - const token = typeof params.deliveryToken === 'string' ? params.deliveryToken : '' - const identity = this.sourceCredit.ownsDelivery(token, grant, params.id) - if (!identity) { - return - } - if (params.paused) { - this.pausedDeliveryByPty.set(params.id, identity) - } else if (this.pausedDeliveryByPty.get(params.id) !== identity) { - return - } else { - this.pausedDeliveryByPty.delete(params.id) - } - } - this.setDeliveryPaused?.(params.id, params.paused) - }) - dispatcher.onNotification('pty.ackData', (params, context) => { - this.sourceCredit.acknowledge(params, this.session.activeGrant(String(context.clientId))) - }) - dispatcher.onRequest('pty.cancelDelivery', async (params, context) => - this.sourceCredit.cancel(params, this.session.activeGrant(String(context.clientId))) - ) - dispatcher.onDisposed(() => { - for (const id of this.pausedDeliveryByPty.keys()) { - this.setDeliveryPaused?.(id, false) - } - this.pausedDeliveryByPty.clear() - this.sourceCredit.dispose() - }) - } - - /** The authenticated client identity behind a transport connection, or null when it holds no - * active grant. Used to stamp host-attested ownership on a PTY at spawn. */ - clientInstanceIdFor(clientId: number): string | null { - return this.session.activeClientInstanceId(String(clientId)) - } - - openDelivery( - clientId: number, - id: string, - ptyIncarnation: string, - checkpointSourceEndSu = 0 - ): PtySourceDeliveryIdentity | null { - return this.sourceCredit.open( - this.session.activeGrant(String(clientId)), - id, - ptyIncarnation, - checkpointSourceEndSu - ) - } - - rotateDelivery( - oldIdentity: PtySourceDeliveryIdentity, - newClientId: number, - acceptedSourceEndSu: number - ) { - this.clearPausedIdentity(oldIdentity) - return this.sourceCredit.rotate( - oldIdentity, - this.session.activeGrant(String(newClientId)), - acceptedSourceEndSu - ) - } - - appendSource( - identity: PtySourceDeliveryIdentity, - input: Readonly<{ - spanId: string - data: string - displayStart: number - displayEnd: number - splittable: boolean - transform: PtySourceTransform - }> - ): PtySourceSpan { - return this.sourceCredit.append(identity, input) - } - - reserveSourceSend( - identity: PtySourceDeliveryIdentity, - maxSourceSu?: number - ): PtySourceSendReservation | null { - return this.sourceCredit.reserveSend(identity, maxSourceSu) - } - - commitSourceSend(reservation: PtySourceSendReservation): void { - this.sourceCredit.commitSend(reservation) - } - - rollbackSourceSend(reservation: PtySourceSendReservation): void { - this.sourceCredit.rollbackSend(reservation) - } - - sealDelivery(identity: PtySourceDeliveryIdentity): void { - this.sourceCredit.seal(identity) - } - - settleExitPublication( - identity: PtySourceDeliveryIdentity, - result: { ok: true } | { ok: false; error: Error } - ): void { - this.sourceCredit.settleExit(identity, result) - } - - sourceDeliverySnapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { - return this.sourceCredit.snapshot(identity) - } - - sourceDeliverySnapshotIfKnown( - identity: PtySourceDeliveryIdentity - ): PtySourceDeliverySnapshot | null { - return this.sourceCredit.snapshotIfKnown(identity) - } - - cancelDelivery(identity: PtySourceDeliveryIdentity, reason: string): void { - this.clearPausedIdentity(identity) - this.sourceCredit.cancelIdentity(identity, reason) - } - - getDebugSnapshot(): Readonly<{ - deliveryTokens: number - graceTimers: number - sourceSu: number - dataBytes: number - spans: number - }> { - return this.sourceCredit.retentionSnapshot() - } - - deliveryMode(clientId: number): SshPtyDeliveryMode { - return sshPtyDeliveryMode(this.session.activeGrant(String(clientId))) - } - - private async openClient( - rawParams: Record, - context: RequestContext - ): Promise { - const params = parseOpenClientParams(rawParams) - if (params.protocolVersion !== PTY_CONSUMER_SESSION_PROTOCOL_VERSION) { - throw new Error( - `Unsupported pty.openClient protocol version: ${params.protocolVersion || 'missing'}` - ) - } - const identity = requireIdentity(context) - const admission = this.session.admit(params, { - connectionId: String(context.clientId), - principal: identity.principal, - authenticated: identity.authenticated, - allowSessionOwner: identity.allowSessionOwner - }) - if (!context.onResponseSettled) { - admission.rollbackPublication() - throw new Error('SSH PTY consumer response publication fence is unavailable') - } - context.onResponseSettled((result) => { - if (!result.ok) { - admission.rollbackPublication() - return - } - admission.commitPublication() - this.closeDisplacedOwner(admission.displacedOwner) - }) - return admission.grant - } - - // Why: only after the replacement grant is published — until then the admission can still roll back - // onto the incumbent. Its deliveries are retained for the new owner to rotate, exactly as on detach. - private closeDisplacedOwner(displaced: PtyConsumerSessionAdmission['displacedOwner']): void { - if (!displaced) { - return - } - this.clearPausedForGrant(displaced.grant) - this.sourceCredit.retainOrCloseOnDetach(displaced.grant) - const clientId = Number(displaced.connectionId) - if (Number.isSafeInteger(clientId)) { - this.dispatcher.releaseDisplacedClient(clientId) - } - } - - private clearPausedIdentity(identity: PtySourceDeliveryIdentity): void { - if (this.pausedDeliveryByPty.get(identity.id) === identity) { - this.pausedDeliveryByPty.delete(identity.id) - this.setDeliveryPaused?.(identity.id, false) - } - } - - private clearPausedForGrant(grant: Readonly): void { - for (const [id, identity] of this.pausedDeliveryByPty) { - if ( - identity.clientGeneration !== grant.clientGeneration || - identity.ownerGeneration !== grant.ownerGeneration - ) { - continue - } - this.pausedDeliveryByPty.delete(id) - this.setDeliveryPaused?.(id, false) - } - } -} diff --git a/src/relay/ssh-pty-data-publication-admission.ts b/src/relay/ssh-pty-data-publication-admission.ts deleted file mode 100644 index 95c86ee539d..00000000000 --- a/src/relay/ssh-pty-data-publication-admission.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PtyConsumerSessionGrant } from '../shared/pty-consumer-session' -import type { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' - -export type SshPtyDeliveryMode = 'unadmitted' | 'source-owner' | 'legacy-owner' | 'subscriber' - -export function sshPtyDeliveryMode( - grant: Readonly | null -): SshPtyDeliveryMode { - if (!grant) { - return 'unadmitted' - } - if (grant.role !== 'session-owner') { - return 'subscriber' - } - return grant.capabilities?.outputFlowControl ? 'source-owner' : 'legacy-owner' -} - -export function admitsSshPtyDataPublication( - grant: Readonly | null, - params: Readonly>, - sourceCredit: Pick -): boolean { - if (!grant) { - return false - } - if (grant.role !== 'session-owner' || !grant.capabilities?.outputFlowControl) { - return true - } - const id = typeof params.id === 'string' ? params.id : '' - const token = typeof params.deliveryToken === 'string' ? params.deliveryToken : '' - const identity = token ? sourceCredit.ownsDelivery(token, grant, id) : null - return ( - identity !== null && - params.clientGeneration === identity.clientGeneration && - params.ownerGeneration === identity.ownerGeneration && - params.ptyIncarnation === identity.ptyIncarnation - ) -} diff --git a/src/relay/ssh-pty-open-client-request.ts b/src/relay/ssh-pty-open-client-request.ts deleted file mode 100644 index f776cc1a99d..00000000000 --- a/src/relay/ssh-pty-open-client-request.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { PtyConsumerSessionHello } from '../shared/pty-consumer-session' -import type { RelayClientSessionIdentity, RequestContext } from '../wsl-guest/dispatcher' - -export type OpenClientParams = PtyConsumerSessionHello & { - protocolVersion: number -} - -// Why every field is coerced rather than trusted: these arrive off the wire from a peer that may be -// a different build, so a missing or wrong-typed member has to degrade into a value the session can -// reject by contract instead of throwing somewhere further in. -export function parseOpenClientParams(params: Record): OpenClientParams { - const resume = - typeof params.resume === 'object' && params.resume !== null - ? (params.resume as Record) - : undefined - const capabilities = - typeof params.capabilities === 'object' && params.capabilities !== null - ? (params.capabilities as Record) - : undefined - const outputFlowControl = - typeof capabilities?.outputFlowControl === 'object' && capabilities.outputFlowControl !== null - ? (capabilities.outputFlowControl as Record) - : undefined - return { - protocolVersion: Number(params.protocolVersion), - clientInstanceId: String(params.clientInstanceId ?? ''), - requestedRole: String(params.requestedRole ?? '') as PtyConsumerSessionHello['requestedRole'], - ...(resume - ? { - resume: { - ownerGeneration: Number(resume.ownerGeneration), - ownerLease: String(resume.ownerLease ?? '') - } - } - : {}), - ...(outputFlowControl - ? { - capabilities: { - outputFlowControl: { - versions: Array.isArray(outputFlowControl.versions) - ? outputFlowControl.versions.map(Number) - : [], - requestedWindowSu: Number(outputFlowControl.requestedWindowSu) - } - } - } - : {}) - } -} - -export function requireIdentity(context: RequestContext): RelayClientSessionIdentity { - if (!context.sessionIdentity) { - throw new Error('SSH PTY consumer transport identity is unavailable') - } - return context.sessionIdentity -} diff --git a/src/relay/ssh-pty-source-credit-adapter.test.ts b/src/relay/ssh-pty-source-credit-adapter.test.ts deleted file mode 100644 index 915a7683f09..00000000000 --- a/src/relay/ssh-pty-source-credit-adapter.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - PTY_CONSUMER_OWNER_GRACE_MS, - PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - type PtyConsumerSessionGrant -} from '../shared/pty-consumer-session' -import { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' - -function ownerGrant( - clientGeneration: number, - ownerGeneration: number -): Readonly { - return Object.freeze({ - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - serverBuildId: 'build-a', - clientGeneration, - role: 'session-owner', - ownerGeneration, - ownerLease: `lease-${ownerGeneration}`, - capabilities: { outputFlowControl: { version: 1 as const, windowSu: 8 } } - }) -} - -afterEach(() => vi.useRealTimers()) - -describe('SshPtySourceCreditAdapter cleanup', () => { - it('grace expiry cancels old-owner tokens without closing a rotated replacement', () => { - vi.useFakeTimers() - const publishCancellation = vi.fn() - const adapter = new SshPtySourceCreditAdapter(publishCancellation) - const oldGrant = ownerGrant(1, 1) - const replacementGrant = ownerGrant(2, 2) - const rotated = adapter.open(oldGrant, 'pty-1', 'incarnation-1')! - const expiring = adapter.open(oldGrant, 'pty-2', 'incarnation-2')! - adapter.retainOrCloseOnDetach(oldGrant) - - const replacement = adapter.rotate(rotated, replacementGrant, 0).identity - vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) - - expect(adapter.snapshot(expiring).state).toBe('closed') - expect(adapter.snapshot(replacement).state).toBe('active') - expect(publishCancellation).toHaveBeenCalledWith( - expect.objectContaining({ - deliveryToken: expiring.deliveryToken, - reason: 'reconnect-grace-expired', - remainingStartSu: 0, - remainingEndSu: 0 - }) - ) - }) - - it('non-owner detach cleanup is exact to the client generation', () => { - const adapter = new SshPtySourceCreditAdapter() - const detached = adapter.open(ownerGrant(1, 1), 'pty-1', 'incarnation-1')! - const active = adapter.open(ownerGrant(2, 2), 'pty-2', 'incarnation-2')! - const subscriber: Readonly = Object.freeze({ - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - serverBuildId: 'build-a', - clientGeneration: 1, - role: 'subscriber' - }) - - adapter.retainOrCloseOnDetach(subscriber) - - expect(adapter.snapshot(detached).state).toBe('closed') - expect(adapter.snapshot(active).state).toBe('active') - }) - - it('prunes tokens across many normal sealed exits', () => { - const adapter = new SshPtySourceCreditAdapter() - const grant = ownerGrant(1, 1) - - for (let index = 0; index < 300; index++) { - const identity = adapter.open(grant, `pty-${index}`, `incarnation-${index}`)! - adapter.seal(identity) - adapter.settleExit(identity, { ok: true }) - } - - expect(adapter.retentionSnapshot()).toEqual({ - deliveryTokens: 0, - graceTimers: 0, - sourceSu: 0, - dataBytes: 0, - spans: 0 - }) - }) - - it('disposes grace timers and exact retained tokens', () => { - vi.useFakeTimers() - const adapter = new SshPtySourceCreditAdapter() - const grant = ownerGrant(1, 1) - const first = adapter.open(grant, 'pty-1', 'incarnation-1')! - const second = adapter.open(grant, 'pty-2', 'incarnation-2')! - adapter.retainOrCloseOnDetach(grant) - expect(adapter.retentionSnapshot()).toEqual({ - deliveryTokens: 2, - graceTimers: 1, - sourceSu: 0, - dataBytes: 0, - spans: 0 - }) - - adapter.dispose() - - expect(adapter.retentionSnapshot()).toEqual({ - deliveryTokens: 0, - graceTimers: 0, - sourceSu: 0, - dataBytes: 0, - spans: 0 - }) - expect(adapter.snapshot(first)).toMatchObject({ state: 'closed', generationClosed: true }) - expect(adapter.snapshot(second)).toMatchObject({ state: 'closed', generationClosed: true }) - expect(() => adapter.open(grant, 'pty-late', 'incarnation-late')).toThrow('disposed') - }) - - it('notifies credit availability on every adapter-side cancellation but not on rotate', () => { - vi.useFakeTimers() - const onCreditAvailable = vi.fn() - const adapter = new SshPtySourceCreditAdapter(undefined, onCreditAvailable) - const grant = ownerGrant(1, 1) - const canceled = adapter.open(grant, 'pty-cancel', 'incarnation-1')! - - adapter.cancel( - { - id: canceled.id, - deliveryToken: canceled.deliveryToken, - clientGeneration: canceled.clientGeneration, - ownerGeneration: canceled.ownerGeneration - }, - grant - ) - expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel']]) - - const expiring = adapter.open(grant, 'pty-grace', 'incarnation-2')! - adapter.retainOrCloseOnDetach(grant) - vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) - expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace']]) - expect(adapter.snapshot(expiring).state).toBe('closed') - - const detaching = adapter.open(ownerGrant(3, 3), 'pty-detach', 'incarnation-3')! - adapter.retainOrCloseOnDetach( - Object.freeze({ - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - serverBuildId: 'build-a', - clientGeneration: 3, - role: 'subscriber' - }) - ) - expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace'], ['pty-detach']]) - expect(adapter.snapshot(detaching).state).toBe('closed') - - const rotating = adapter.open(ownerGrant(4, 4), 'pty-rotate', 'incarnation-4')! - adapter.rotate(rotating, ownerGrant(5, 5), 0) - const publicationOwned = adapter.open(ownerGrant(6, 6), 'pty-publication', 'incarnation-5')! - adapter.cancelIdentity(publicationOwned, 'publication-owned') - expect(onCreditAvailable).toHaveBeenCalledTimes(3) - expect(adapter.snapshot(publicationOwned).state).toBe('closed') - }) - - it('swallows a throwing credit-available callback in cancel and in the grace timer', () => { - vi.useFakeTimers() - const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) - try { - const adapter = new SshPtySourceCreditAdapter(undefined, () => { - throw new Error('publication faulted') - }) - const grant = ownerGrant(1, 1) - const canceled = adapter.open(grant, 'pty-1', 'incarnation-1')! - - expect(() => - adapter.cancel( - { - id: canceled.id, - deliveryToken: canceled.deliveryToken, - clientGeneration: canceled.clientGeneration, - ownerGeneration: canceled.ownerGeneration - }, - grant - ) - ).not.toThrow() - - const expiring = adapter.open(grant, 'pty-2', 'incarnation-2')! - adapter.retainOrCloseOnDetach(grant) - expect(() => vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS)).not.toThrow() - - expect(adapter.snapshot(expiring).state).toBe('closed') - expect(stderr.mock.calls.map(([line]) => String(line))).toEqual([ - expect.stringContaining( - '[pty-source-credit] credit-available notification failed for pty-1' - ), - expect.stringContaining( - '[pty-source-credit] credit-available notification failed for pty-2' - ) - ]) - } finally { - stderr.mockRestore() - } - }) - - it('returns the same bounded proof for duplicate token cancellation', () => { - const adapter = new SshPtySourceCreditAdapter() - const grant = ownerGrant(1, 1) - const identity = adapter.open(grant, 'pty-1', 'incarnation-1')! - const params = { - id: identity.id, - deliveryToken: identity.deliveryToken, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration - } - - const first = adapter.cancel(params, grant) - const duplicate = adapter.cancel(params, grant) - - expect(duplicate).toEqual(first) - }) -}) diff --git a/src/relay/ssh-pty-source-credit-adapter.ts b/src/relay/ssh-pty-source-credit-adapter.ts deleted file mode 100644 index 1c900fb9684..00000000000 --- a/src/relay/ssh-pty-source-credit-adapter.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { - MAX_PTY_ACK_ENTRIES, - type PtySourceDeliveryCancellation, - type PtySourceDeliveryIdentity, - type PtySourceDeliverySnapshot, - type PtySourceSpan, - type PtySourceTransform -} from '../shared/pty-source-credit-contract' -import { - PTY_CONSUMER_OWNER_GRACE_MS, - type PtyConsumerSessionGrant -} from '../shared/pty-consumer-session' -import { - ptySourceCancellationResult, - RecentPtySourceCancellationIndex -} from './pty-source-cancellation-index' -import { notifyPtySourceCreditAvailable } from './pty-source-credit-availability-notice' -import { ownedPtySourceDelivery } from './pty-source-delivery-ownership' -import { - RelayPtySourceCreditLedger, - type PtySourceSendReservation -} from './pty-source-credit-ledger' - -export class SshPtySourceCreditAdapter { - private readonly sourceCredit = new RelayPtySourceCreditLedger() - private readonly identityByToken = new Map() - private readonly graceTimers = new Map>() - private readonly recentCancellations = new RecentPtySourceCancellationIndex() - private readonly relayProviderGeneration = 1 - private disposed = false - - constructor( - private readonly publishCancellation?: (proof: PtySourceDeliveryCancellation) => void, - private readonly onCreditAvailable?: (id: string) => void - ) {} - - open( - grant: Readonly | null, - id: string, - ptyIncarnation: string, - checkpointSourceEndSu = 0 - ): PtySourceDeliveryIdentity | null { - if (this.disposed) { - throw new Error('SSH PTY source credit adapter is disposed') - } - const flow = grant?.capabilities?.outputFlowControl - if ( - !grant || - grant.role !== 'session-owner' || - !grant.ownerGeneration || - !flow || - !id || - !ptyIncarnation - ) { - return null - } - const identity = Object.freeze({ - id, - providerGeneration: this.relayProviderGeneration, - clientGeneration: grant.clientGeneration, - ownerGeneration: grant.ownerGeneration, - ptyIncarnation, - deliveryToken: randomUUID() - }) - this.sourceCredit.open(identity, flow.windowSu, checkpointSourceEndSu) - this.identityByToken.set(identity.deliveryToken, identity) - return identity - } - - rotate( - oldIdentity: PtySourceDeliveryIdentity, - grant: Readonly | null, - acceptedSourceEndSu: number - ) { - const flow = grant?.capabilities?.outputFlowControl - if (!grant || !flow || !grant.ownerGeneration || grant.role !== 'session-owner') { - throw new Error('PTY source delivery recovery requires the active negotiated owner') - } - const replacement = Object.freeze({ - ...oldIdentity, - clientGeneration: grant.clientGeneration, - ownerGeneration: grant.ownerGeneration, - deliveryToken: randomUUID() - }) - const rotation = this.sourceCredit.rotate( - oldIdentity, - replacement, - acceptedSourceEndSu, - flow.windowSu - ) - this.identityByToken.delete(oldIdentity.deliveryToken) - this.recentCancellations.remember(rotation.cancellation) - this.publishCancellation?.(rotation.cancellation) - this.identityByToken.set(replacement.deliveryToken, replacement) - this.clearGraceWhenSettled(oldIdentity.ownerGeneration) - return Object.freeze({ identity: replacement, ...rotation }) - } - - append( - identity: PtySourceDeliveryIdentity, - input: Readonly<{ - spanId: string - data: string - displayStart: number - displayEnd: number - splittable: boolean - transform: PtySourceTransform - }> - ): PtySourceSpan { - return this.sourceCredit.append(identity, input) - } - - reserveSend( - identity: PtySourceDeliveryIdentity, - maxSourceSu?: number - ): PtySourceSendReservation | null { - return this.sourceCredit.reserveNextSend(identity, maxSourceSu) - } - - commitSend(reservation: PtySourceSendReservation): void { - this.sourceCredit.commitSend(reservation) - } - - rollbackSend(reservation: PtySourceSendReservation): void { - this.sourceCredit.rollbackSend(reservation) - } - - seal(identity: PtySourceDeliveryIdentity): void { - this.sourceCredit.seal(identity) - } - - settleExit( - identity: PtySourceDeliveryIdentity, - result: { ok: true } | { ok: false; error: Error } - ): void { - this.sourceCredit.settleExitPublication(identity, result) - this.pruneClosed(identity.deliveryToken, identity) - } - - snapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { - return this.sourceCredit.snapshot(identity) - } - - snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { - return this.sourceCredit.snapshotIfKnown(identity) - } - - acknowledge( - params: Record, - grant: Readonly | null - ): void { - const acknowledgements = params.acknowledgements - if ( - !grant?.capabilities?.outputFlowControl || - !Array.isArray(acknowledgements) || - acknowledgements.length > MAX_PTY_ACK_ENTRIES - ) { - return - } - for (const raw of acknowledgements) { - if (typeof raw !== 'object' || raw === null) { - continue - } - const candidate = raw as Record - const token = typeof candidate.deliveryToken === 'string' ? candidate.deliveryToken : '' - const identity = token.length > 0 ? this.identityByToken.get(token) : undefined - if (!identity || identity.clientGeneration !== grant.clientGeneration) { - continue - } - try { - const result = this.sourceCredit.acknowledge(identity, { - id: String(candidate.id ?? ''), - clientGeneration: Number(candidate.clientGeneration), - ownerGeneration: Number(candidate.ownerGeneration), - deliveryToken: String(candidate.deliveryToken), - creditedEndSu: Number(candidate.creditedEndSu) - }) - if (result === 'advanced') { - this.onCreditAvailable?.(identity.id) - } - this.pruneClosed(token, identity) - } catch { - /* Invalid and stale cumulative ACKs never mutate credit. */ - } - } - } - - cancel( - params: Record, - grant: Readonly | null - ): Readonly<{ canceled: true; sentEndSu: number; creditedEndSu: number }> { - const token = typeof params.deliveryToken === 'string' ? params.deliveryToken : '' - const identity = this.identityByToken.get(token) - const recent = this.recentCancellations.owned(token, params, grant) - if (!identity && recent) { - return ptySourceCancellationResult(recent) - } - if ( - !grant?.capabilities?.outputFlowControl || - !identity || - identity.clientGeneration !== grant.clientGeneration || - params.id !== identity.id || - Number(params.clientGeneration) !== identity.clientGeneration || - Number(params.ownerGeneration) !== identity.ownerGeneration - ) { - throw new Error('Unknown or stale PTY source delivery cancellation') - } - const proof = this.sourceCredit.cancel(identity, 'client-request') - this.identityByToken.delete(token) - this.recentCancellations.remember(proof) - this.clearGraceWhenSettled(identity.ownerGeneration) - // Why: the publication must retire its record the moment the delivery closes, or the next - // exit seals a dead ledger entry. - notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) - return ptySourceCancellationResult(proof) - } - - retainOrCloseOnDetach(grant: Readonly): void { - if (grant.role === 'session-owner' && grant.capabilities?.outputFlowControl) { - if (this.hasOwnerDeliveries(grant.ownerGeneration!)) { - this.scheduleGraceExpiry(grant.ownerGeneration!) - } - return - } - this.closeClientDeliveries(grant.clientGeneration) - } - - retentionSnapshot() { - return Object.freeze({ - deliveryTokens: this.identityByToken.size, - graceTimers: this.graceTimers.size, - ...this.sourceCredit.retentionSnapshot() - }) - } - - ownsDelivery( - token: string, - grant: Readonly, - id: string - ): PtySourceDeliveryIdentity | null { - return ownedPtySourceDelivery(this.identityByToken.get(token), grant, id) - } - - cancelIdentity(identity: PtySourceDeliveryIdentity, reason: string): void { - const token = identity.deliveryToken - if (this.identityByToken.get(token) !== identity) { - return - } - this.cancelExact(token, identity, reason) - } - - dispose(): void { - if (this.disposed) { - return - } - this.disposed = true - this.graceTimers.forEach((timer) => clearTimeout(timer)) - this.graceTimers.clear() - this.sourceCredit.closeGeneration(this.relayProviderGeneration) - this.identityByToken.clear() - this.recentCancellations.clear() - } - - private closeClientDeliveries(clientGeneration: number): void { - for (const [token, identity] of this.identityByToken) { - if (identity.clientGeneration !== clientGeneration) { - continue - } - this.cancelExact(token, identity, 'client-detached') - notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) - } - } - - private scheduleGraceExpiry(ownerGeneration: number): void { - this.clearGraceTimer(ownerGeneration) - const timer = setTimeout(() => { - this.graceTimers.delete(ownerGeneration) - for (const [token, identity] of this.identityByToken) { - if (identity.ownerGeneration !== ownerGeneration) { - continue - } - this.cancelExact(token, identity, 'reconnect-grace-expired') - notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) - } - }, PTY_CONSUMER_OWNER_GRACE_MS) - timer.unref?.() - this.graceTimers.set(ownerGeneration, timer) - } - - private cancelExact(token: string, identity: PtySourceDeliveryIdentity, reason: string): void { - // Why: this runs from the bare grace setTimeout, where an evicted tombstone probing unknown - // would throw straight into uncaughtException. - const snapshot = this.sourceCredit.snapshotIfKnown(identity) - if (snapshot && snapshot.state !== 'closed') { - const proof = this.sourceCredit.cancel(identity, reason) - this.recentCancellations.remember(proof) - this.publishCancellation?.(proof) - } - if (this.identityByToken.get(token) === identity) { - this.identityByToken.delete(token) - } - this.clearGraceWhenSettled(identity.ownerGeneration) - } - - private pruneClosed(token: string, identity: PtySourceDeliveryIdentity): void { - if ( - this.sourceCredit.snapshot(identity).state === 'closed' && - this.identityByToken.get(token) === identity - ) { - this.identityByToken.delete(token) - this.clearGraceWhenSettled(identity.ownerGeneration) - } - } - - private hasOwnerDeliveries = (ownerGeneration: number): boolean => - Array.from(this.identityByToken.values()).some( - (identity) => identity.ownerGeneration === ownerGeneration - ) - - private clearGraceWhenSettled(ownerGeneration: number): void { - if (!this.hasOwnerDeliveries(ownerGeneration)) { - this.clearGraceTimer(ownerGeneration) - } - } - - private clearGraceTimer(ownerGeneration: number): void { - clearTimeout(this.graceTimers.get(ownerGeneration)) - this.graceTimers.delete(ownerGeneration) - } -} diff --git a/src/relay/subprocess-test-utils.ts b/src/relay/subprocess-test-utils.ts deleted file mode 100644 index db0744d53bc..00000000000 --- a/src/relay/subprocess-test-utils.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' -import { - RELAY_SENTINEL, - FrameDecoder, - encodeJsonRpcFrame, - parseJsonRpcMessage, - MessageType, - type JsonRpcRequest, - type JsonRpcResponse, - type JsonRpcNotification -} from '../wsl-guest/protocol' - -export type RelayProcess = { - proc: ChildProcess - responses: (JsonRpcResponse | JsonRpcNotification)[] - sentinelReceived: Promise - send: (method: string, params?: Record) => number - sendNotification: (method: string, params?: Record) => void - waitForResponse: (id: number, timeoutMs?: number) => Promise - waitForNotification: (method: string, timeoutMs?: number) => Promise - kill: (signal?: NodeJS.Signals) => void - waitForExit: (timeoutMs?: number) => Promise -} - -export function spawnRelay( - entryPath: string, - args: string[] = [], - options: Pick = {} -): RelayProcess { - const proc = spawn('node', [entryPath, ...args], { - stdio: ['pipe', 'pipe', 'pipe'], - ...options - }) - - const responses: (JsonRpcResponse | JsonRpcNotification)[] = [] - let nextSeq = 1 - let sentinelResolved = false - let stdoutBuffer = Buffer.alloc(0) - let sentinelResolve: () => void - let decoderActive = false - - const sentinelReceived = new Promise((resolve) => { - sentinelResolve = resolve - }) - - const decoder = new FrameDecoder((frame) => { - if (frame.type !== MessageType.Regular) { - return - } - try { - const msg = parseJsonRpcMessage(frame.payload) - responses.push(msg as JsonRpcResponse | JsonRpcNotification) - } catch { - /* skip malformed */ - } - }) - - proc.stdout!.on('data', (chunk: Buffer) => { - if (!sentinelResolved) { - stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) - const sentinelBuf = Buffer.from(RELAY_SENTINEL, 'utf-8') - const idx = stdoutBuffer.indexOf(sentinelBuf) - if (idx !== -1) { - sentinelResolved = true - decoderActive = true - sentinelResolve() - const remainder = stdoutBuffer.subarray(idx + sentinelBuf.length) - if (remainder.length > 0) { - decoder.feed(remainder) - } - } - } else if (decoderActive) { - decoder.feed(chunk) - } - }) - - proc.stderr!.on('data', () => { - /* drain */ - }) - - const send = (method: string, params?: Record): number => { - const id = nextSeq++ - const req: JsonRpcRequest = { - jsonrpc: '2.0', - id, - method, - ...(params !== undefined ? { params } : {}) - } - proc.stdin!.write(encodeJsonRpcFrame(req, id, 0)) - return id - } - - const sendNotification = (method: string, params?: Record): void => { - const seq = nextSeq++ - const notif: JsonRpcNotification = { - jsonrpc: '2.0', - method, - ...(params !== undefined ? { params } : {}) - } - proc.stdin!.write(encodeJsonRpcFrame(notif, seq, 0)) - } - - const waitForResponse = (id: number, timeoutMs = 5000): Promise => { - return new Promise((resolve, reject) => { - const deadline = Date.now() + timeoutMs - const check = () => { - const found = responses.find((r) => 'id' in r && r.id === id) as JsonRpcResponse | undefined - if (found) { - resolve(found) - return - } - if (Date.now() > deadline) { - reject(new Error(`Timed out waiting for response id=${id}`)) - return - } - setTimeout(check, 10) - } - check() - }) - } - - const waitForNotification = (method: string, timeoutMs = 5000): Promise => { - return new Promise((resolve, reject) => { - const deadline = Date.now() + timeoutMs - const seen = responses.length - const check = () => { - for (let i = seen; i < responses.length; i++) { - const r = responses[i] - if ('method' in r && r.method === method) { - resolve(r as JsonRpcNotification) - return - } - } - if (Date.now() > deadline) { - reject(new Error(`Timed out waiting for notification "${method}"`)) - return - } - setTimeout(check, 10) - } - check() - }) - } - - const kill = (signal: NodeJS.Signals = 'SIGTERM') => { - proc.kill(signal) - } - - const waitForExit = (timeoutMs = 5000): Promise => { - return new Promise((resolve, reject) => { - if (proc.exitCode !== null) { - resolve(proc.exitCode) - return - } - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for process exit')) - }, timeoutMs) - proc.once('exit', (code) => { - clearTimeout(timer) - resolve(code) - }) - }) - } - - return { - proc, - responses, - sentinelReceived, - send, - sendNotification, - waitForResponse, - waitForNotification, - kill, - waitForExit - } -} diff --git a/src/relay/subprocess-tree-termination.test.ts b/src/relay/subprocess-tree-termination.test.ts deleted file mode 100644 index 58abe2017b6..00000000000 --- a/src/relay/subprocess-tree-termination.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as ChildProcessModule from 'node:child_process' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, execFile: vi.fn() } -}) - -import { terminateRelaySubprocessTree } from './subprocess-tree-termination' - -const originalPlatform = process.platform - -afterEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - vi.mocked(ChildProcessModule.execFile).mockReset() -}) - -describe('terminateRelaySubprocessTree', () => { - it('invokes Windows taskkill without a shell command', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const child = { pid: 12345, kill: vi.fn() } as unknown as ChildProcessModule.ChildProcess - - terminateRelaySubprocessTree(child) - - expect(ChildProcessModule.execFile).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - expect(child.kill).not.toHaveBeenCalled() - }) -}) diff --git a/src/relay/subprocess-tree-termination.ts b/src/relay/subprocess-tree-termination.ts deleted file mode 100644 index 85cbc001bf9..00000000000 --- a/src/relay/subprocess-tree-termination.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { execFile, type ChildProcess } from 'node:child_process' - -// Why: Windows commands may run through wrappers, so killing only the direct -// child can leave Git or an agent alive after cancellation. -export function terminateRelaySubprocessTree(child: ChildProcess): void { - const pid = child.pid - if (!pid) { - return - } - if (process.platform === 'win32') { - execFile('taskkill', ['/pid', String(pid), '/T', '/F'], () => { - // Best-effort; the child close listener owns completion. - }) - return - } - try { - child.kill('SIGKILL') - } catch { - // Child may already have exited between the kill request and now. - } -} diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts deleted file mode 100644 index 93299a67d02..00000000000 --- a/src/relay/subprocess.test.ts +++ /dev/null @@ -1,904 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest' -import { - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { rm } from 'node:fs/promises' -import * as path from 'node:path' -import { tmpdir } from 'node:os' -import { execFileSync, spawn as spawnChild } from 'node:child_process' -import { build } from 'esbuild' -import { JSONC_PARSER_ESM_ALIAS } from '../../config/build-plugins/jsonc-parser-esm' -import { spawnRelay, type RelayProcess } from './subprocess-test-utils' -import { getEndpointFileName } from '../shared/agent-hook-listener/endpoint-publication' -import { relayTestSocketPath } from './relay-test-socket-path' - -const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') -const WATCHER_TS_ENTRY = path.resolve(__dirname, '../main/ipc/parcel-watcher-process-entry.ts') -let bundleDir: string -let relayEntry: string -const spawnedSocketDirs: string[] = [] - -beforeAll(async () => { - bundleDir = mkdtempSync(path.join(tmpdir(), 'relay-bundle-')) - relayEntry = path.join(bundleDir, 'relay.js') - await build({ - entryPoints: [RELAY_TS_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: relayEntry, - external: ['node-pty', '@parcel/watcher', 'electron'], - alias: JSONC_PARSER_ESM_ALIAS, - sourcemap: false - }) - await build({ - entryPoints: [WATCHER_TS_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: path.join(bundleDir, 'relay-watcher.js'), - external: ['@parcel/watcher'], - sourcemap: false - }) -}, 30_000) - -afterAll(async () => { - if (bundleDir) { - await rm(bundleDir, { recursive: true, force: true }).catch(() => {}) - } -}) - -function spawnRelayEntry( - entryPath: string, - args: string[] = [], - env?: NodeJS.ProcessEnv -): RelayProcess { - let relayArgs = args - if (!args.includes('--sock-path')) { - // Why: Windows relays require a named pipe; filesystem socket paths fail with EACCES. - const socketDir = mkdtempSync(path.join(tmpdir(), 'relay-sock-')) - spawnedSocketDirs.push(socketDir) - relayArgs = [ - ...args, - '--sock-path', - relayTestSocketPath(socketDir), - '--endpoint-dir', - path.join(socketDir, 'agent-hooks') - ] - } - return spawnRelay(entryPath, relayArgs, env ? { env } : undefined) -} - -function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { - return spawnRelayEntry(relayEntry, args, env) -} - -function waitForChildExit( - proc: ReturnType, - timeoutMs = 5000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Timed out waiting for child exit')), timeoutMs) - proc.once('exit', (code, signal) => { - clearTimeout(timer) - resolve({ code, signal }) - }) - }) -} - -function writeMockNodePty(root: string, source: string, withPackageEntry = false): void { - const nodePtyDir = path.join(root, 'node_modules', 'node-pty') - const libDir = path.join(nodePtyDir, 'lib') - mkdirSync(libDir, { recursive: true }) - if (withPackageEntry) { - writeFileSync(path.join(nodePtyDir, 'package.json'), '{"main":"lib/index.js"}\n') - } - writeFileSync(path.join(libDir, 'index.js'), source) -} - -const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return { - pid: process.pid, - process: 'mock-shell', - onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {} -} } }\n` - -// A shell that reports its own exit after a delay, so the relay sees the pool drain on its own. -function selfExitingNodePtyModule(exitAfterMs: number): string { - return `module.exports = { spawn() { - const exitHandlers = [] - setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, ${exitAfterMs}) - return { - pid: process.pid, - process: 'mock-shell', - onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, kill() {}, clear() {} - } -} }\n` -} - -// A shell whose first dispose is refused, so ptyHandler.dispose() rejects once before a retry can succeed. -const KILL_REJECTS_FIRST_DISPOSE_MODULE = `let killAttempts = 0 -module.exports = { spawn() { - const exitHandlers = [] - return { - pid: 2147483646, - process: 'mock-shell', - onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {}, - kill() { - killAttempts++ - if (killAttempts <= 2) { throw new Error('kill refused') } - setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0) - } - } -} }\n` - -// Why: an ESM mock with top-level await parks loadPty() in the window where a spawn is admitted but not yet pooled. -function writeSlowLoadingNodePty(root: string, loadDelayMs: number): void { - const nodePtyDir = path.join(root, 'node_modules', 'node-pty') - mkdirSync(path.join(nodePtyDir, 'lib'), { recursive: true }) - writeFileSync(path.join(nodePtyDir, 'package.json'), '{"type":"module","main":"lib/index.js"}\n') - writeFileSync( - path.join(nodePtyDir, 'lib', 'index.js'), - `await new Promise((resolve) => setTimeout(resolve, ${loadDelayMs})) -export function spawn() { - const exitHandlers = [] - return { - pid: process.pid, - process: 'mock-shell', - onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {}, - // Report the exit so relay shutdown can complete instead of parking on waitForPhysicalExit. - kill() { setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0) } - } -} -` - ) -} - -describe('Subprocess: Relay entry point', () => { - let relay: RelayProcess | null = null - let tmpDir: string - - afterEach(async () => { - if (relay && relay.proc.exitCode === null) { - relay.proc.kill('SIGKILL') - await relay.waitForExit().catch(() => {}) - } - relay = null - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) - } - while (spawnedSocketDirs.length > 0) { - const socketDir = spawnedSocketDirs.pop()! - await rm(socketDir, { recursive: true, force: true }).catch(() => {}) - } - }) - - it('loads node-pty after an in-place dependency repair without restarting', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-repair-')) - const repairedRelayEntry = path.join(tmpDir, 'relay.js') - copyFileSync(relayEntry, repairedRelayEntry) - - relay = spawnRelayEntry(repairedRelayEntry) - await relay.sentinelReceived - - const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) - const failed = await relay.waitForResponse(failedId) - expect(failed.error?.message).toContain('Remote terminals are unavailable') - - writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE) - - const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) - const repaired = await relay.waitForResponse(repairedId) - expect(repaired.error).toBeUndefined() - // Why: a spawn that never loaded node-pty must not burn a mint sequence, so the repair is still :1. - expect(repaired.result).toMatchObject({ id: expect.stringMatching(/^pty2:[^:]+:1$/) }) - }, 10_000) - - it('reloads node-pty after a late native binding failure without restarting', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-late-repair-')) - const repairedRelayEntry = path.join(tmpDir, 'relay.js') - copyFileSync(relayEntry, repairedRelayEntry) - writeMockNodePty( - tmpDir, - `module.exports = { spawn() { throw new Error('Failed to load native module: conpty.node, checked: prebuilds/win32-x64') } }\n`, - true - ) - - relay = spawnRelayEntry(repairedRelayEntry) - await relay.sentinelReceived - - const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) - const failed = await relay.waitForResponse(failedId) - expect(failed.error?.message).toContain('Remote terminals are unavailable') - - writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE, true) - const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) - const repaired = await relay.waitForResponse(repairedId) - expect(repaired.error).toBeUndefined() - // Why: the late failure happens after the id is minted, so the repair lands on the next sequence. - expect(repaired.result).toMatchObject({ id: expect.stringMatching(/^pty2:[^:]+:2$/) }) - }, 10_000) - - it('responds to fs.readFile and fs.writeFile', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - - relay = spawn() - await relay.sentinelReceived - - const filePath = path.join(tmpDir, 'output.txt') - const wId = relay.send('fs.writeFile', { filePath, content: 'via subprocess' }) - const wResp = await relay.waitForResponse(wId) - expect(wResp.error).toBeUndefined() - - const rId = relay.send('fs.readFile', { filePath }) - const rResp = await relay.waitForResponse(rId) - const result = rResp.result as { content: string; isBinary: boolean } - expect(result.content).toBe('via subprocess') - expect(result.isBinary).toBe(false) - }, 10_000) - - it('responds to git.status on a real repo', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'file.txt'), 'content') - execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'file.txt'), 'dirty') - - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('git.status', { worktreePath: tmpDir }) - const resp = await relay.waitForResponse(id) - - const result = resp.result as { entries: { path: string; status: string }[] } - expect(result.entries.length).toBeGreaterThan(0) - expect(result.entries[0].path).toBe('file.txt') - expect(result.entries[0].status).toBe('modified') - }, 10_000) - - it('returns JSON-RPC error for unknown method', async () => { - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('does.not.exist', {}) - const resp = await relay.waitForResponse(id) - - expect(resp.error).toBeDefined() - expect(resp.error!.code).toBe(-32601) - expect(resp.error!.message).toContain('Method not found') - }, 10_000) - - it('returns error for failing handler', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('fs.readFile', { filePath: path.join(tmpDir, 'nonexistent.txt') }) - const resp = await relay.waitForResponse(id) - - expect(resp.error).toBeDefined() - }, 10_000) - - it('handles multiple concurrent requests', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - writeFileSync(path.join(tmpDir, 'one.txt'), '1') - writeFileSync(path.join(tmpDir, 'two.txt'), '22') - writeFileSync(path.join(tmpDir, 'three.txt'), '333') - - relay = spawn() - await relay.sentinelReceived - - const id1 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'one.txt') }) - const id2 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'two.txt') }) - const id3 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'three.txt') }) - - const [r1, r2, r3] = await Promise.all([ - relay.waitForResponse(id1), - relay.waitForResponse(id2), - relay.waitForResponse(id3) - ]) - - expect((r1.result as { size: number }).size).toBe(1) - expect((r2.result as { size: number }).size).toBe(2) - expect((r3.result as { size: number }).size).toBe(3) - }, 10_000) - - it('shuts down cleanly on SIGTERM', async () => { - relay = spawn() - await relay.sentinelReceived - - relay.kill('SIGTERM') - await relay.waitForExit() - expect(relay.proc.exitCode !== null || relay.proc.signalCode !== null).toBe(true) - }, 10_000) - - it('exits after grace period on stdin close when no PTYs exist', async () => { - // Why: grace timer always waits the full period now (even with zero PTYs) - // so a detached relay has time for a --connect client to arrive. - relay = spawn(['--grace-time', '1']) - await relay.sentinelReceived - - relay.proc.stdin!.end() - - await relay.waitForExit(5000) - expect(relay.proc.exitCode).toBe(0) - }, 10_000) - - it.skipIf(process.platform === 'win32')( - 'refuses a duplicate detached daemon without unlinking the active relay socket', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-dup-')) - const sockPath = path.join(tmpDir, 'relay.sock') - relay = spawn(['--detached', '--grace-time', '10', '--sock-path', sockPath]) - await relay.sentinelReceived - const endpointFile = path.join(tmpDir, 'agent-hooks', 'relay.sock', getEndpointFileName()) - const endpointBeforeDuplicate = readFileSync(endpointFile, 'utf8') - - let duplicateStderr = '' - const duplicate = spawnChild( - 'node', - [relayEntry, '--detached', '--grace-time', '10', '--sock-path', sockPath], - { stdio: ['ignore', 'ignore', 'pipe'] } - ) - duplicate.stderr!.on('data', (chunk: Buffer) => { - duplicateStderr += chunk.toString('utf8') - }) - - const duplicateExit = await waitForChildExit(duplicate, 5000) - expect(duplicateExit.code).toBe(1) - expect(duplicateStderr).toContain('Socket path already in use') - expect(readFileSync(endpointFile, 'utf8')).toBe(endpointBeforeDuplicate) - - const bridge = spawn(['--connect', '--sock-path', sockPath]) - try { - await bridge.sentinelReceived - const id = bridge.send('relay.status') - const resp = await bridge.waitForResponse(id) - expect(resp.error).toBeUndefined() - expect( - (resp.result as { socket: { path: string; acceptedConnections: number } }).socket - ).toMatchObject({ - path: sockPath, - acceptedConnections: 1 - }) - } finally { - bridge.kill('SIGTERM') - await bridge.waitForExit().catch(() => {}) - } - }, - 10_000 - ) - - it.skipIf(process.platform === 'win32')( - 'leaves the endpoint credential equal to the winning daemon when two starts race one socket', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-cred-race-')) - const sockPath = path.join(tmpDir, 'relay.sock') - const credentialFile = `${sockPath}.credential` - const starters = [0, 1].map(() => - spawnRelay(relayEntry, [ - '--detached', - '--grace-time', - '10', - '--sock-path', - sockPath, - '--endpoint-dir', - path.join(tmpDir, 'agent-hooks'), - '--credential-file', - credentialFile - ]) - ) - const stderrByStarter = starters.map((starter) => { - let text = '' - starter.proc.stderr!.on('data', (chunk: Buffer) => { - text += chunk.toString('utf8') - }) - return () => text - }) - try { - const outcomes = await Promise.all( - starters.map((starter) => - Promise.race([ - starter.sentinelReceived.then(() => 'ready'), - starter.waitForExit(8000).then((code) => `exit:${code}`) - ]) - ) - ) - expect(outcomes.filter((outcome) => outcome === 'ready')).toHaveLength(1) - expect(outcomes.filter((outcome) => outcome === 'exit:1')).toHaveLength(1) - const winnerIndex = outcomes.indexOf('ready') - const loserIndex = 1 - winnerIndex - const loserStderr = stderrByStarter[loserIndex]() - expect(loserStderr, loserStderr).toContain('Socket path already in use') - - // The loser must not have touched the file: whatever is on disk authenticates against - // the daemon that owns the socket, with the mode the relay requires. - const credential = readFileSync(credentialFile, 'utf8').trim() - expect(credential).toMatch(/^[A-Za-z0-9_-]{32,256}$/) - expect(statSync(credentialFile).mode & 0o777).toBe(0o600) - - const bridge = spawn([ - '--connect', - '--sock-path', - sockPath, - '--credential-file', - credentialFile - ]) - try { - await bridge.sentinelReceived - const resp = await bridge.waitForResponse(bridge.send('relay.status')) - expect(resp.error).toBeUndefined() - expect(resp.result as { pid: number }).toMatchObject({ - pid: starters[winnerIndex].proc.pid - }) - } finally { - bridge.kill('SIGTERM') - await bridge.waitForExit().catch(() => {}) - } - expect(stderrByStarter[winnerIndex]()).not.toContain('credential mismatch') - } finally { - for (const starter of starters) { - if (starter.proc.exitCode === null) { - starter.proc.kill('SIGKILL') - await starter.waitForExit().catch(() => {}) - } - } - } - }, - 20_000 - ) - - it.skipIf(process.platform === 'win32')( - 'reclaims a socket path left behind by a killed detached relay', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stale-')) - const sockPath = path.join(tmpDir, 'relay.sock') - const first = spawn(['--detached', '--grace-time', '10', '--sock-path', sockPath]) - let bridge: RelayProcess | null = null - try { - await first.sentinelReceived - - first.kill('SIGKILL') - await first.waitForExit(2000) - expect(existsSync(sockPath)).toBe(true) - - relay = spawn(['--detached', '--grace-time', '10', '--sock-path', sockPath]) - await relay.sentinelReceived - - bridge = spawn(['--connect', '--sock-path', sockPath]) - await bridge.sentinelReceived - const id = bridge.send('relay.status') - const resp = await bridge.waitForResponse(id) - expect(resp.error).toBeUndefined() - expect( - resp.result as { - pid: number | undefined - socket: { path: string; owned: boolean; listening: boolean } - } - ).toMatchObject({ - pid: relay.proc.pid, - socket: { path: sockPath, owned: true, listening: true } - }) - } finally { - bridge?.kill('SIGTERM') - await bridge?.waitForExit().catch(() => {}) - if (first.proc.exitCode === null && first.proc.signalCode === null) { - first.kill('SIGKILL') - await first.waitForExit().catch(() => {}) - } - } - }, - 10_000 - ) - - it.skipIf(process.platform === 'win32')( - 'does not unlink a newer relay socket when an older relay exits', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-rebound-')) - const sockPath = path.join(tmpDir, 'relay.sock') - const first = spawn(['--detached', '--grace-time', '10', '--sock-path', sockPath]) - let second: RelayProcess | null = null - let bridge: RelayProcess | null = null - try { - await first.sentinelReceived - unlinkSync(sockPath) - - second = spawn(['--detached', '--grace-time', '10', '--sock-path', sockPath]) - await second.sentinelReceived - - first.kill('SIGTERM') - await first.waitForExit(2000) - - bridge = spawn(['--connect', '--sock-path', sockPath]) - await bridge.sentinelReceived - const id = bridge.send('relay.status') - const resp = await bridge.waitForResponse(id) - expect(resp.error).toBeUndefined() - expect((resp.result as { pid: number }).pid).toBe(second.proc.pid) - } finally { - bridge?.kill('SIGTERM') - await bridge?.waitForExit().catch(() => {}) - first.kill('SIGTERM') - await first.waitForExit().catch(() => {}) - second?.kill('SIGTERM') - await second?.waitForExit().catch(() => {}) - } - }, - 10_000 - ) - - it.skipIf(process.platform === 'win32')( - 'uses a short startup grace for empty detached relays before any client connects', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-empty-')) - relay = spawn( - ['--detached', '--grace-time', '10', '--sock-path', path.join(tmpDir, 'relay.sock')], - { ...process.env, ORCA_RELAY_EMPTY_STARTUP_GRACE_MS: '100' } - ) - await relay.sentinelReceived - - await relay.waitForExit(3000) - expect(relay.proc.exitCode).toBe(0) - }, - 10_000 - ) - - it.skipIf(process.platform === 'win32')( - 'uses a short startup grace for unlimited empty detached relays before any client connects', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-empty-unlimited-')) - relay = spawn( - ['--detached', '--grace-time', '0', '--sock-path', path.join(tmpDir, 'relay.sock')], - { ...process.env, ORCA_RELAY_EMPTY_STARTUP_GRACE_MS: '100' } - ) - await relay.sentinelReceived - - await relay.waitForExit(3000) - expect(relay.proc.exitCode).toBe(0) - }, - 10_000 - ) - - it.skipIf(process.platform === 'win32')( - 'uses configured grace after a detached relay has accepted a socket client', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-connected-')) - const sockPath = path.join(tmpDir, 'relay.sock') - relay = spawn(['--detached', '--grace-time', '1', '--sock-path', sockPath], { - ...process.env, - ORCA_RELAY_EMPTY_STARTUP_GRACE_MS: '500' - }) - await relay.sentinelReceived - - const bridge = spawn(['--connect', '--sock-path', sockPath]) - try { - await bridge.sentinelReceived - } finally { - bridge.kill('SIGTERM') - await bridge.waitForExit().catch(() => {}) - } - - await new Promise((resolve) => setTimeout(resolve, 650)) - expect(relay.proc.exitCode).toBeNull() - - await relay.waitForExit(2000) - expect(relay.proc.exitCode).toBe(0) - }, - 10_000 - ) - - // Why: a relay holding zero PTYs preserves nothing, so the unlimited default must still be - // bounded once a client has come and gone — but an explicitly configured grace is never shortened. - function spawnIdleGraceDaemon( - nodePtyModule: string, - graceTimeSeconds: string, - idleGraceMs: string - ): { daemon: RelayProcess; sockPath: string } { - const daemonEntry = path.join(tmpDir, 'relay.js') - copyFileSync(relayEntry, daemonEntry) - writeMockNodePty(tmpDir, nodePtyModule) - const sockPath = path.join(tmpDir, 'relay.sock') - const daemon = spawnRelayEntry( - daemonEntry, - ['--detached', '--grace-time', graceTimeSeconds, '--sock-path', sockPath], - { ...process.env, ORCA_RELAY_IDLE_GRACE_MS: idleGraceMs } - ) - return { daemon, sockPath } - } - - async function connectAndDisconnect( - sockPath: string, - whileConnected?: (bridge: RelayProcess) => Promise - ): Promise { - const bridge = spawn(['--connect', '--sock-path', sockPath]) - try { - await bridge.sentinelReceived - await whileConnected?.(bridge) - } finally { - bridge.kill('SIGTERM') - await bridge.waitForExit().catch(() => {}) - } - } - - it.skipIf(process.platform === 'win32')( - 'shuts down an idle relay with no PTYs after the idle grace even with --grace-time 0', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-')) - const sockPath = path.join(tmpDir, 'relay.sock') - relay = spawn(['--detached', '--grace-time', '0', '--sock-path', sockPath], { - ...process.env, - ORCA_RELAY_IDLE_GRACE_MS: '200' - }) - await relay.sentinelReceived - - // Accepting a client defeats the startup-empty branch, so only the idle cap can end this relay. - await connectAndDisconnect(sockPath) - - await relay.waitForExit(2000) - expect(relay.proc.exitCode).toBe(0) - }, - 15_000 - ) - - it.skipIf(process.platform === 'win32')( - 'keeps a relay with a live PTY alive past the idle grace', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-live-pty-')) - const { daemon, sockPath } = spawnIdleGraceDaemon(WORKING_NODE_PTY_MODULE, '0', '200') - relay = daemon - await relay.sentinelReceived - - await connectAndDisconnect(sockPath, async (bridge) => { - const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) - expect(resp.error).toBeUndefined() - }) - - await new Promise((resolve) => setTimeout(resolve, 1200)) - expect(relay.proc.exitCode).toBeNull() - }, - 15_000 - ) - - it.skipIf(process.platform === 'win32')( - 're-arms the idle grace when the last PTY exits during grace', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-rearm-')) - const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(1500), '0', '200') - relay = daemon - await relay.sentinelReceived - - await connectAndDisconnect(sockPath, async (bridge) => { - const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) - expect(resp.error).toBeUndefined() - }) - - // The live PTY must suppress the idle grace at disconnect; only its own exit re-arms it. - await new Promise((resolve) => setTimeout(resolve, 300)) - expect(relay.proc.exitCode).toBeNull() - - await relay.waitForExit(5000) - expect(relay.proc.exitCode).toBe(0) - }, - 20_000 - ) - - it.skipIf(process.platform === 'win32')( - 'honors an explicitly configured grace instead of clamping it to the idle cap', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-configured-')) - const sockPath = path.join(tmpDir, 'relay.sock') - relay = spawn(['--detached', '--grace-time', '3600', '--sock-path', sockPath], { - ...process.env, - ORCA_RELAY_IDLE_GRACE_MS: '200' - }) - await relay.sentinelReceived - - await connectAndDisconnect(sockPath) - - await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(relay.proc.exitCode).toBeNull() - }, - 15_000 - ) - - it.skipIf(process.platform === 'win32')( - 'keeps the relay alive when the client drops while a PTY creation is still in flight', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-inflight-')) - const daemonEntry = path.join(tmpDir, 'relay.js') - copyFileSync(relayEntry, daemonEntry) - writeSlowLoadingNodePty(tmpDir, 1500) - const sockPath = path.join(tmpDir, 'relay.sock') - relay = spawnRelayEntry( - daemonEntry, - ['--detached', '--grace-time', '0', '--sock-path', sockPath], - { ...process.env, ORCA_RELAY_IDLE_GRACE_MS: '200' } - ) - await relay.sentinelReceived - - const bridge = spawn(['--connect', '--sock-path', sockPath]) - await bridge.sentinelReceived - // Revive, not spawn: it has no client-disconnect abort, so it really does produce a live shell - // after the parked module load resolves. - bridge.send('pty.revive', { - state: JSON.stringify([ - { id: 'pty-restored', pid: process.pid, cols: 80, rows: 24, cwd: tmpDir } - ]) - }) - // Let the creation park on the module load: it already owns a shell but is not in the pool yet. - await new Promise((resolve) => setTimeout(resolve, 400)) - bridge.kill('SIGTERM') - await bridge.waitForExit().catch(() => {}) - - // Well past the idle cap and past the point where the parked creation lands in the pool. - await new Promise((resolve) => setTimeout(resolve, 2500)) - expect(relay.proc.exitCode).toBeNull() - - const probe = spawn(['--connect', '--sock-path', sockPath]) - try { - await probe.sentinelReceived - const status = await probe.waitForResponse(probe.send('relay.status')) - expect((status.result as { ptys: { active: number } }).ptys.active).toBe(1) - } finally { - probe.kill('SIGTERM') - await probe.waitForExit().catch(() => {}) - } - }, - 20_000 - ) - - it.skipIf(process.platform === 'win32')( - 'does not extend an explicitly configured grace when the last PTY exits mid-window', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-configured-rearm-')) - const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(2500), '3', '200') - relay = daemon - await relay.sentinelReceived - - await connectAndDisconnect(sockPath, async (bridge) => { - const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) - expect(resp.error).toBeUndefined() - }) - - // The 3s window keeps governing; re-arming at the ~2.5s PTY exit would push shutdown past 5s. - await relay.waitForExit(4200) - expect(relay.proc.exitCode).toBe(0) - }, - 20_000 - ) - - it.skipIf(process.platform === 'win32')( - 're-arms grace after a shutdown deferred by a rejected kill, so the relay still exits', - async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-shutdown-deferred-')) - const { daemon, sockPath } = spawnIdleGraceDaemon( - KILL_REJECTS_FIRST_DISPOSE_MODULE, - '2', - '200' - ) - relay = daemon - await relay.sentinelReceived - - await connectAndDisconnect(sockPath, async (bridge) => { - const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) - expect(resp.error).toBeUndefined() - }) - - // First grace expiry hits the refused kill; only the re-armed window can retry it. - await relay.waitForExit(8000) - expect(relay.proc.exitCode).toBe(0) - }, - 20_000 - ) - - it('reports relay diagnostics over relay.status', async () => { - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('relay.status') - const resp = await relay.waitForResponse(id) - expect(resp.error).toBeUndefined() - const status = resp.result as { - pid: number - memory: { rss: number } - ptys: { active: number } - socket: { owned: boolean; listening: boolean; clients: number } - } - expect(status.pid).toBeGreaterThan(0) - expect(status.memory.rss).toBeGreaterThan(0) - expect(status.ptys.active).toBe(0) - expect(status.socket).toMatchObject({ owned: true, listening: true, clients: 0 }) - }, 10_000) - - it('session.registerRoot request returns ok acknowledgment', async () => { - // Why: session.registerRoot is a protocol-level no-op since the FS - // allowlist removal, but the request form still must reply { ok: true } - // for back-compat with mains during the upgrade window. See - // docs/relay-fs-allowlist-removal.md. - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('session.registerRoot', { rootPath: '/tmp/anything' }) - const resp = await relay.waitForResponse(id) - - expect(resp.error).toBeUndefined() - expect(resp.result).toEqual({ ok: true }) - }, 10_000) - - it('reads files outside any registered root', async () => { - // Regression test for the architecture change in docs/relay-fs-allowlist-removal.md: - // the relay no longer enforces a workspace allowlist. - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-outside-')) - writeFileSync(path.join(outsideDir, 'secret.txt'), 'visible') - - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('fs.readFile', { filePath: path.join(outsideDir, 'secret.txt') }) - const resp = await relay.waitForResponse(id) - - expect(resp.error).toBeUndefined() - expect((resp.result as { content: string }).content).toBe('visible') - - await rm(outsideDir, { recursive: true, force: true }).catch(() => {}) - }, 10_000) - - it('reads files via symlinks resolving outside the workspace', async () => { - // Regression test for issue #1661: a symlink under the workspace pointing - // to a directory outside it must resolve transparently. The pre-removal - // relay rejected this with "Path outside authorized workspace". - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-outside-')) - writeFileSync(path.join(outsideDir, 'data.txt'), 'symlinked-target') - const { symlinkSync } = require('node:fs') - symlinkSync(outsideDir, path.join(tmpDir, 'link')) - - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('fs.readFile', { - filePath: path.join(tmpDir, 'link', 'data.txt') - }) - const resp = await relay.waitForResponse(id) - - expect(resp.error).toBeUndefined() - expect((resp.result as { content: string }).content).toBe('symlinked-target') - - await rm(outsideDir, { recursive: true, force: true }).catch(() => {}) - }, 10_000) - - it('resolves ~ to home directory via session.resolveHome', async () => { - relay = spawn() - await relay.sentinelReceived - - const homeDir = require('node:os').homedir() - - const id1 = relay.send('session.resolveHome', { path: '~' }) - const id2 = relay.send('session.resolveHome', { path: '~/projects' }) - const id3 = relay.send('session.resolveHome', { path: '/absolute/path' }) - - const [r1, r2, r3] = await Promise.all([ - relay.waitForResponse(id1), - relay.waitForResponse(id2), - relay.waitForResponse(id3) - ]) - - expect((r1.result as { resolvedPath: string }).resolvedPath).toBe(homeDir) - expect((r2.result as { resolvedPath: string }).resolvedPath).toBe( - path.join(homeDir, 'projects') - ) - expect((r3.result as { resolvedPath: string }).resolvedPath).toBe('/absolute/path') - }, 10_000) -}) diff --git a/src/relay/terminal-history-wsl.test.ts b/src/relay/terminal-history-wsl.test.ts deleted file mode 100644 index acb58f8bd69..00000000000 --- a/src/relay/terminal-history-wsl.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type * as NodeFs from 'node:fs' -import type * as NodeOs from 'node:os' -import { hashWorktreeId } from '../main/terminal-history-id' - -// Why mocked rather than a real temp dir: the assertion is the Windows -> Linux -// path conversion, which only happens for a drive-letter root that cannot exist -// on the macOS/Linux runners this suite executes on. -vi.mock('node:os', async (importOriginal) => ({ - ...(await importOriginal()), - homedir: () => 'C:\\Users\\relay' -})) - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - const dirStat = { isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false } - const fileStat = { - isSymbolicLink: () => false, - isDirectory: () => false, - isFile: () => true, - dev: 1n, - ino: 2n - } - return { - ...actual, - constants: actual.constants, - mkdirSync: vi.fn(), - lstatSync: vi.fn((path: string) => (path.endsWith('terminal-history') ? dirStat : fileStat)), - openSync: vi.fn(() => 42), - fstatSync: vi.fn(() => fileStat), - closeSync: vi.fn(), - unlinkSync: vi.fn() - } -}) - -const worktreeId = 'relay-wsl::/remote/worktree' - -describe('relay WSL shell history', () => { - it('hands the guest a drvfs path for the host history file', async () => { - const { injectRelayHistoryEnv } = await import('./terminal-history') - const env: Record = {} - - const root = injectRelayHistoryEnv(env, worktreeId, 'C:\\Windows\\System32\\wsl.exe', { - wsl: true - }) - - expect(root).toBe('C:\\Users\\relay/.orca-remote/terminal-history') - expect(env.HISTFILE).toBe( - `/mnt/c/Users/relay/.orca-remote/terminal-history/${hashWorktreeId(worktreeId)}-bash_history` - ) - expect(env.ORCA_HISTFILE).toBe(env.HISTFILE) - }) - - it('leaves a host shell on the untranslated host path', async () => { - const { injectRelayHistoryEnv } = await import('./terminal-history') - const env: Record = {} - - injectRelayHistoryEnv(env, worktreeId, '/bin/bash') - - expect(env.HISTFILE).toBe( - `C:\\Users\\relay/.orca-remote/terminal-history/${hashWorktreeId(worktreeId)}-bash_history` - ) - }) - - it('scopes nothing for wsl.exe when the caller does not flag it as WSL', async () => { - const { injectRelayHistoryEnv } = await import('./terminal-history') - const env: Record = {} - - expect(injectRelayHistoryEnv(env, worktreeId, 'wsl.exe')).toBeNull() - expect(env.HISTFILE).toBeUndefined() - }) -}) diff --git a/src/relay/terminal-history.test.ts b/src/relay/terminal-history.test.ts deleted file mode 100644 index c38f7711d05..00000000000 --- a/src/relay/terminal-history.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { hashWorktreeId } from '../main/terminal-history-id' -import { fishHistorySessionName, relayFishHistorySessionName } from '../main/fish-history-session' -import { - deleteRelayHistory, - injectRelayFishHistoryEnv, - injectRelayHistoryEnv -} from './terminal-history' - -const worktreeId = 'relay-test::/remote/worktree' -const historyDir = join(homedir(), '.orca-remote', 'terminal-history') -const historyPrefix = hashWorktreeId(worktreeId) - -afterEach(() => { - for (const filename of ['bash_history', 'zsh_history']) { - rmSync(join(historyDir, `${historyPrefix}-${filename}`), { force: true }) - } -}) - -describe('relay shell history', () => { - it.each(['/bin/bash', '/usr/bin/zsh'])('scopes %s without replacing caller HISTFILE', (shell) => { - const env: Record = {} - const dir = injectRelayHistoryEnv(env, worktreeId, shell) - expect(dir).toBe(historyDir) - expect(env.HISTFILE).toBe( - join( - historyDir, - `${historyPrefix}-${shell.endsWith('bash') ? 'bash_history' : 'zsh_history'}` - ) - ) - - const custom = { HISTFILE: '/custom/history' } - expect(injectRelayHistoryEnv(custom, worktreeId, shell)).toBeNull() - expect(custom.HISTFILE).toBe('/custom/history') - }) - - it.each([ - ['relay', join(historyDir, `${hashWorktreeId('relay-test::/remote/other')}-zsh_history`)], - [ - 'desktop', - join( - '/fake/userData/terminal-history', - hashWorktreeId('relay-test::/remote/other'), - 'zsh_history' - ) - ] - ])('replaces a %s HISTFILE inherited from a parent Orca', (_kind, inherited) => { - // HISTFILE is exported, so a relay (or the client that spawned it) started - // from an Orca pane would otherwise scope every remote pane to that one - // worktree's history file. - const env: Record = { HISTFILE: inherited } - - expect(injectRelayHistoryEnv(env, worktreeId, '/usr/bin/zsh')).toBe(historyDir) - expect(env.HISTFILE).toBe(join(historyDir, `${historyPrefix}-zsh_history`)) - }) - - it('does not scope unsupported shells and cleans up idempotently', () => { - const env: Record = {} - expect(injectRelayHistoryEnv(env, worktreeId, '/bin/fish')).toBeNull() - deleteRelayHistory(worktreeId) - deleteRelayHistory(worktreeId) - expect(existsSync(join(historyDir, `${historyPrefix}-bash_history`))).toBe(false) - }) - - it.each([ - ['relay', relayFishHistorySessionName(hashWorktreeId('relay-test::/remote/other'))], - ['desktop', fishHistorySessionName(hashWorktreeId('relay-test::/remote/other'))] - ])('replaces an inherited %s fish_history', (_kind, inherited) => { - // fish EXPORTS fish_history, so a relay started from a fish pane would give - // every remote pane the launching worktree's session file. - const env: Record = { fish_history: inherited } - - injectRelayFishHistoryEnv(env, worktreeId) - - expect(env.fish_history).toBe(relayFishHistorySessionName(historyPrefix)) - }) - - it('preserves a caller-supplied fish_history', () => { - const env: Record = { fish_history: 'mine' } - - injectRelayFishHistoryEnv(env, worktreeId) - - expect(env.fish_history).toBe('mine') - }) - - it.skipIf(process.platform === 'win32')('refuses a pre-existing final symlink', () => { - mkdirSync(historyDir, { recursive: true }) - const target = join(historyDir, 'relay-unrelated-history') - const path = join(historyDir, `${historyPrefix}-bash_history`) - writeFileSync(target, 'unrelated') - symlinkSync(target, path) - - expect(injectRelayHistoryEnv({}, worktreeId, '/bin/bash')).toBeNull() - expect(readFileSync(target, 'utf8')).toBe('unrelated') - rmSync(path) - rmSync(target) - }) -}) diff --git a/src/relay/terminal-history.ts b/src/relay/terminal-history.ts deleted file mode 100644 index 7a6716c5d34..00000000000 --- a/src/relay/terminal-history.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - closeSync, - constants as fsConstants, - fstatSync, - lstatSync, - mkdirSync, - openSync, - unlinkSync -} from 'node:fs' -import { homedir } from 'node:os' -import { basename, join } from 'node:path' -import { toLinuxPath } from '../shared/wsl-paths' -import { hashWorktreeId } from '../main/terminal-history-id' -import { dropInheritedOrcaHistFile } from '../main/worktree-history-file-path' -import { - deleteFishHistoryFile, - dropInheritedOrcaFishHistory, - relayFishHistorySessionName, - resolveFishHistoryDir -} from '../main/fish-history-session' - -const HISTORY_ROOT = join(homedir(), '.orca-remote', 'terminal-history') - -function historyFilename(shell: string): string | null { - const name = basename(shell).toLowerCase() - if (name.startsWith('bash')) { - return 'bash_history' - } - if (name.startsWith('zsh')) { - return 'zsh_history' - } - return null -} - -export function injectRelayHistoryEnv( - env: Record, - worktreeId: string, - shell: string, - options: { wsl?: boolean } = {} -): string | null { - // Why first: same reason as the desktop path — an inherited ORCA_HISTFILE - // would otherwise survive every early return below and let the remote wrapper - // re-export another worktree's history path. - delete env.ORCA_HISTFILE - // Why: HISTFILE stays exported, so a relay (or a client) launched from an Orca - // pane carries the launching worktree's path into this one; honouring it below - // would scope every pane to that worktree's history file. - dropInheritedOrcaHistFile(env) - if (env.HISTFILE) { - return null - } - // WSL's outer exe is wsl.exe, which matches no shell name; the guest login - // shell reads HISTFILE regardless, so pick the file the desktop path picks. - const filename = historyFilename(options.wsl ? 'bash' : shell) - if (!filename) { - return null - } - try { - mkdirSync(HISTORY_ROOT, { recursive: true, mode: 0o700 }) - const rootStat = lstatSync(HISTORY_ROOT) - if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { - return null - } - const path = join(HISTORY_ROOT, `${hashWorktreeId(worktreeId)}-${filename}`) - let existing: ReturnType | null = null - try { - const stat = lstatSync(path, { bigint: true }) - if (stat.isSymbolicLink() || !stat.isFile()) { - return null - } - existing = stat - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - return null - } - } - const fd = openSync( - path, - fsConstants.O_RDWR | - fsConstants.O_CREAT | - (process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW), - 0o600 - ) - const actual = fstatSync(fd, { bigint: true }) - if ( - !actual.isFile() || - (existing && (actual.dev !== existing.dev || actual.ino !== existing.ino)) - ) { - closeSync(fd) - return null - } - closeSync(fd) - // Why the same host file rather than a distro-scoped one: the guest reaches - // it over drvfs, so `deleteRelayHistory` still reclaims it by host path. - env.HISTFILE = options.wsl ? toLinuxPath(path) : path - // Why a second variable: a remote macOS `/etc/zshrc` assigns HISTFILE - // unconditionally before the wrapper runs, so the injected value is gone by - // the first prompt. The wrapper restores it from here (#11044) — the same - // contract the desktop PTY path uses. Under WSL it holds the guest-visible - // path and stays out of WSLENV, matching the desktop; no wrapper reads it there. - env.ORCA_HISTFILE = env.HISTFILE - return HISTORY_ROOT - } catch { - return null - } -} - -export function deleteRelayHistory(worktreeId: string): void { - try { - const rootStat = lstatSync(HISTORY_ROOT) - if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { - return - } - for (const filename of ['bash_history', 'zsh_history']) { - const path = join(HISTORY_ROOT, `${hashWorktreeId(worktreeId)}-${filename}`) - try { - if (!lstatSync(path).isSymbolicLink()) { - unlinkSync(path) - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - } - } catch (error) { - console.warn( - `[pty:history] Failed to delete relay shell history: ${error instanceof Error ? error.message : String(error)}` - ) - } -} - -/** fish keeps history in its own data dir under a session NAME, so the relay - * isolates it the same way the desktop app does and deletes by that name. - * No metadata file is needed: the name is a pure function of the worktree id. */ -export function injectRelayFishHistoryEnv(env: Record, worktreeId: string): void { - // Own precondition, not the caller's: the check below may only honour a genuine - // user value, and fish EXPORTS `fish_history` so an Orca-minted name arrives from - // the relay's own env or the client's. `PtyHandler.buildSpawnEnv` already scrubs - // every spawn path, so this is belt-and-braces for any other caller. - dropInheritedOrcaFishHistory(env) - if (env.fish_history) { - return - } - env.fish_history = relayFishHistorySessionName(hashWorktreeId(worktreeId)) -} - -export function deleteRelayFishHistory(worktreeId: string, env?: NodeJS.ProcessEnv): void { - deleteFishHistoryFile(relayFishHistorySessionName(hashWorktreeId(worktreeId)), [ - resolveFishHistoryDir(env) - ]) -} diff --git a/src/relay/windows-port-scan.test.ts b/src/relay/windows-port-scan.test.ts deleted file mode 100644 index 6626bad5c23..00000000000 --- a/src/relay/windows-port-scan.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const runProcessMock = vi.fn() -vi.mock('@orca/process-host', () => ({ - runProcess: (spec: unknown) => runProcessMock(spec) -})) - -vi.mock('../wsl-guest/relay-command-env', () => ({ - buildRelayCommandEnv: () => ({ PATH: 'C:\\Windows\\System32' }) -})) - -import { - __setWindowsProcessTableCimScanForTests, - __setWindowsProcessTreeLoaderForTests, - resetWindowsProcessTableForTests -} from '../main/windows/windows-process-table' -import { - resetWindowsPortScanDiagnosticsForTests, - scanWindowsListeningPorts -} from './windows-port-scan' - -type Spec = { - program: string - args?: readonly string[] - timeoutMs?: number | null - signal?: AbortSignal -} - -// The scanner drops any row whose pid is the relay process or its parent, so a fixture pid -// that happens to match the vitest worker's own pid silently empties the result and the -// assertion sees []. Shift off the literals only on collision, which keeps the readable -// 1234/2468 in the normal case while staying hermetic. -const SELF_PIDS = new Set([process.pid, process.ppid]) - -function pidUnlikeSelf(seed: number): number { - let pid = seed - while (SELF_PIDS.has(pid)) { - pid += 1 - } - return pid -} - -const NETSTAT_PID = pidUnlikeSelf(2468) -const SSHD_PID = pidUnlikeSelf(4321) -const POWERSHELL_PID = pidUnlikeSelf(1234) - -const NETSTAT_STDOUT = [ - ' Proto Local Address Foreign Address State PID', - ` TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING ${NETSTAT_PID}`, - ` TCP [::]:3000 [::]:0 LISTENING ${NETSTAT_PID}`, - ` TCP 0.0.0.0:4000 93.184.216.34:443 ESTABLISHED ${NETSTAT_PID}`, - ` UDP 0.0.0.0:5353 *:* ${NETSTAT_PID}`, - ` TCP 0.0.0.0:2222 0.0.0.0:0 LISTENING ${SSHD_PID}` -].join('\r\n') - -function ok(stdout: string): { - code: number - signal: null - stdout: string - stderr: string - timedOut: boolean -} { - return { code: 0, signal: null, stdout, stderr: '', timedOut: false } -} - -type NativeRow = { - pid: number - ppid: number - name: string - memory?: number - commandLine?: string - creationTimeMs?: number -} - -/** A native snapshot must contain the reader's own pid or the table rejects. */ -function nativeTable(rows: NativeRow[]) { - return () => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, - getAllProcesses: (callback: (processes: NativeRow[] | undefined) => void) => - callback([{ pid: process.pid, ppid: 0, name: 'vitest.exe' }, ...rows]) - }) -} - -function specs(): Spec[] { - return runProcessMock.mock.calls.map((call) => call[0] as Spec) -} - -describe('scanWindowsListeningPorts', () => { - beforeEach(() => { - runProcessMock.mockReset() - resetWindowsPortScanDiagnosticsForTests() - resetWindowsProcessTableForTests() - __setWindowsProcessTreeLoaderForTests( - nativeTable([ - { pid: NETSTAT_PID, ppid: 4, name: 'node.exe' }, - { pid: SSHD_PID, ppid: 4, name: 'sshd.exe' } - ]) - ) - }) - - afterEach(() => { - __setWindowsProcessTreeLoaderForTests() - __setWindowsProcessTableCimScanForTests() - resetWindowsProcessTableForTests() - }) - - it('reads netstat first and never starts PowerShell', async () => { - const controller = new AbortController() - runProcessMock.mockResolvedValueOnce(ok(NETSTAT_STDOUT)) - - await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ - { host: '::', port: 3000, pid: NETSTAT_PID, processName: 'node' }, - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } - ]) - - expect(specs()).toHaveLength(1) - expect(specs()[0].program).toMatch(/netstat\.exe$/) - // `-p tcp` is absent on purpose: on Windows it means IPv4-only and would - // hide every `[::]` listener. - expect(specs()[0].args).toEqual(['-ano']) - expect(specs()[0].signal).toBe(controller.signal) - expect(specs()[0].timeoutMs).toBe(5000) - }) - - it('keeps the sshd and self-pid filters working off native process names', async () => { - runProcessMock.mockResolvedValueOnce( - ok( - [ - NETSTAT_STDOUT, - ` TCP 0.0.0.0:9999 0.0.0.0:0 LISTENING ${process.pid}` - ].join('\r\n') - ) - ) - - const ports = await scanWindowsListeningPorts() - - // sshd.exe is matched despite the table's `.exe` spelling, and the relay's - // own listener never reaches a client. - expect(ports.map((port) => `${port.host}:${port.port}`)).toEqual([':::3000', '0.0.0.0:3000']) - }) - - it('still reports host/port/pid when no process table is readable', async () => { - runProcessMock.mockResolvedValueOnce(ok(NETSTAT_STDOUT)) - __setWindowsProcessTreeLoaderForTests(() => null) - __setWindowsProcessTableCimScanForTests(() => - Promise.reject(new Error('windows process table unavailable')) - ) - resetWindowsProcessTableForTests() - - await expect(scanWindowsListeningPorts()).resolves.toEqual([ - { host: '0.0.0.0', port: 2222, pid: SSHD_PID }, - { host: '::', port: 3000, pid: NETSTAT_PID }, - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID } - ]) - // Names were unavailable, so nothing else was spawned to go get them. - expect(specs()).toHaveLength(1) - }) - - it('falls back to PowerShell without an execution-policy override', async () => { - const controller = new AbortController() - runProcessMock - .mockResolvedValueOnce({ - code: 1, - signal: null, - stdout: '', - stderr: 'blocked', - timedOut: false - }) - .mockResolvedValueOnce( - ok( - JSON.stringify({ - host: '127.0.0.1', - port: 5173, - pid: POWERSHELL_PID, - processName: 'node' - }) - ) - ) - - await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ - { - host: '127.0.0.1', - port: 5173, - pid: POWERSHELL_PID, - processName: 'node' - } - ]) - - const powershell = specs()[1] - expect(powershell.program).toMatch(/powershell\.exe$/i) - expect(powershell.args?.slice(0, 3)).toEqual(['-NoProfile', '-NonInteractive', '-Command']) - expect(powershell.args).not.toContain('-ExecutionPolicy') - expect(powershell.args).not.toContain('-EncodedCommand') - expect(powershell.args).toHaveLength(4) - expect(powershell.args?.[3]).toContain('Get-NetTCPConnection') - expect(powershell.signal).toBe(controller.signal) - expect(powershell.timeoutMs).toBe(5000) - }) - - it('tries pwsh when Windows PowerShell cannot answer, then fails instead of answering empty', async () => { - runProcessMock - .mockResolvedValueOnce(ok('')) - .mockRejectedValueOnce(new Error('powershell unavailable')) - .mockRejectedValueOnce(new Error('pwsh unavailable')) - - await expect(scanWindowsListeningPorts()).rejects.toThrow(/Could not list listening ports/) - - expect(specs().map((spec) => spec.program)).toEqual([ - expect.stringMatching(/netstat\.exe$/), - expect.stringMatching(/powershell\.exe$/i), - 'pwsh.exe' - ]) - }) - - it('gives up rather than falling back once the scan is cancelled', async () => { - const controller = new AbortController() - controller.abort() - runProcessMock.mockResolvedValueOnce({ - code: null, - signal: null, - stdout: '', - stderr: '', - timedOut: false - }) - - await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([]) - - expect(runProcessMock).toHaveBeenCalledTimes(1) - }) - - // `LISTENING` ships in netstat.exe.mui, picked by UI language, so no env can - // pin it. Without the shape-based re-read a German host parses zero rows, - // reads that as a blocked reader, and runs the flagged payload every 12-30s. - it('reads a localized host by socket shape rather than the state word', async () => { - runProcessMock.mockResolvedValueOnce( - ok( - [ - 'Aktive Verbindungen', - '', - ' Proto Lokale Adresse Remoteadresse Status PID', - ' TCP 0.0.0.0:135 0.0.0.0:0 ABHÖREN 1116', - ` TCP 0.0.0.0:3000 0.0.0.0:0 ABHÖREN ${NETSTAT_PID}`, - ` TCP [::]:3000 [::]:0 ABHÖREN ${NETSTAT_PID}`, - ` TCP 192.168.0.5:52000 93.184.216.34:443 HERGESTELLT ${NETSTAT_PID}` - ].join('\r\n') - ) - ) - - await expect(scanWindowsListeningPorts()).resolves.toEqual([ - { host: '0.0.0.0', port: 135, pid: 1116 }, - { host: '::', port: 3000, pid: NETSTAT_PID, processName: 'node' }, - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } - ]) - expect(specs()).toHaveLength(1) - }) - - // The gap the state-word cases cannot cover: on a localized host BOUND is as - // unreadable as ABHÖREN, so shape alone would publish 8080 as a listener. - // Listeners dominate, and that is what separates them. - it('drops a BOUND socket that shape alone would promote on a localized host', async () => { - runProcessMock.mockResolvedValueOnce( - ok( - [ - ` TCP 0.0.0.0:3000 0.0.0.0:0 ABHÖREN ${NETSTAT_PID}`, - ` TCP [::]:3000 [::]:0 ABHÖREN ${NETSTAT_PID}`, - ` TCP 0.0.0.0:8080 0.0.0.0:0 GEBUNDEN ${NETSTAT_PID}`, - ` TCP 192.168.0.5:52000 93.184.216.34:443 HERGESTELLT ${NETSTAT_PID}` - ].join('\r\n') - ) - ) - - await expect(scanWindowsListeningPorts()).resolves.toEqual([ - { host: '::', port: 3000, pid: NETSTAT_PID, processName: 'node' }, - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } - ]) - }) - - // Only on an exact tie does shape have nothing left to go on, and then it - // keeps both rather than guessing — no worse than reading shape alone. - it('keeps every tied zero-peer state when none dominates', async () => { - runProcessMock.mockResolvedValueOnce( - ok( - [ - ` TCP 0.0.0.0:3000 0.0.0.0:0 ABHÖREN ${NETSTAT_PID}`, - ` TCP 0.0.0.0:8080 0.0.0.0:0 GEBUNDEN ${NETSTAT_PID}` - ].join('\r\n') - ) - ) - - await expect(scanWindowsListeningPorts()).resolves.toEqual([ - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' }, - { host: '0.0.0.0', port: 8080, pid: NETSTAT_PID, processName: 'node' } - ]) - }) - - // Windows prints BOUND with a zero peer too, so the shape test must stay the - // fallback: a host with at least one readable LISTENING row never reaches it. - it('does not promote a BOUND socket on a host whose state word parsed', async () => { - runProcessMock.mockResolvedValueOnce( - ok( - [ - ` TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING ${NETSTAT_PID}`, - ` TCP 0.0.0.0:8080 0.0.0.0:0 BOUND ${NETSTAT_PID}` - ].join('\r\n') - ) - ) - - await expect(scanWindowsListeningPorts()).resolves.toEqual([ - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } - ]) - }) - - // A capped read exits 0 and its head parses, and netstat orders IPv4 TCP - // before IPv6 TCP, so publishing the head would drop every `[::]` listener. - it('refuses a netstat table that hit the capture cap', async () => { - const filler = Array.from( - { length: 60_000 }, - (_, index) => - ` TCP 10.0.0.1:${1000 + (index % 5000)} 10.0.0.2:443 TIME_WAIT 4` - ).join('\r\n') - runProcessMock - .mockResolvedValueOnce(ok(`${NETSTAT_STDOUT}\r\n${filler}`.slice(0, 4 * 1024 * 1024))) - .mockResolvedValueOnce(ok('[]')) - - await expect(scanWindowsListeningPorts()).resolves.toEqual([]) - - // Fell through instead of publishing the IPv4 head it could still parse. - expect(specs()).toHaveLength(2) - expect(specs()[1].args).toContain('-Command') - }) - - it('does not wait on the shared process table once the scan is cancelled', async () => { - const controller = new AbortController() - const getAllProcesses = vi.fn() - __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, - getAllProcesses - })) - resetWindowsProcessTableForTests() - // netstat answered, then the request was abandoned before names were needed. - runProcessMock.mockImplementationOnce(() => { - controller.abort() - return Promise.resolve(ok(NETSTAT_STDOUT)) - }) - - await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ - // Unnamed, so the sshd row survives its own filter — the cost of not - // waiting, and strictly better than blocking an abandoned request. - { host: '0.0.0.0', port: 2222, pid: SSHD_PID }, - { host: '::', port: 3000, pid: NETSTAT_PID }, - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID } - ]) - expect(getAllProcesses).not.toHaveBeenCalled() - }) - - // The relay daemon's stderr is what installRelayLogRotation routes into - // relay.log, so a fall-through logged anywhere else is a fall-through nobody - // can diagnose. Pin the stream, not just the fact that something was called. - it('reports leaving the native path on the relay diagnostic stream, once', async () => { - const lines: string[] = [] - const stderr = vi - .spyOn(process.stderr, 'write') - .mockImplementation((chunk: string | Uint8Array) => { - lines.push(String(chunk)) - return true - }) - try { - runProcessMock.mockResolvedValue(ok('')) - await scanWindowsListeningPorts() - await scanWindowsListeningPorts() - // A second, different fault on the same host must still be heard: one - // flag for the whole module would have swallowed it. - runProcessMock.mockResolvedValue(ok('x'.repeat(4 * 1024 * 1024))) - await scanWindowsListeningPorts() - await scanWindowsListeningPorts() - } finally { - stderr.mockRestore() - } - - const reported = lines.filter((line) => line.includes('[ports] netstat unusable')) - expect(reported).toHaveLength(2) - expect(reported[0]).toContain('no listening row parsed') - expect(reported[1]).toContain('truncated') - // relayLogLine's ISO stamp: an unplaceable line cannot be read against the - // reconnect flaps around it. - expect(reported[0]).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z /) - }) - - it('treats a netstat timeout as unanswered and falls through', async () => { - runProcessMock - .mockResolvedValueOnce({ - code: null, - signal: 'SIGKILL', - stdout: '', - stderr: '', - timedOut: true - }) - .mockResolvedValueOnce(ok('[]')) - - await expect(scanWindowsListeningPorts()).resolves.toEqual([]) - - expect(specs()).toHaveLength(2) - }) -}) diff --git a/src/relay/windows-port-scan.ts b/src/relay/windows-port-scan.ts deleted file mode 100644 index 30e4f572767..00000000000 --- a/src/relay/windows-port-scan.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { readWindowsProcessTable } from '../main/windows/windows-process-table' -import { runProcess } from '@orca/process-host' -import { - windowsPowerShellPath, - windowsSystem32Binary -} from '@orca/process-host/windows-system-binary' -import { getProcessOutputFields } from '../shared/process-output-field-scanner' -import type { DetectedPort } from './port-scan-handler' -import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' -import { relayLogLine } from './relay-diagnostic-log' - -const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) -const MAX_DETECTED_PORTS = 50 -const WINDOWS_PORT_SCAN_TIMEOUT_MS = 5_000 -// Wide enough for `netstat -ano` on a busy host: it prints every connection, not -// just the listeners, and a truncated table silently drops the tail. -const WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES = 4 * 1024 * 1024 - -/** - * Listening TCP ports, attributed to their owning process. - * - * `netstat.exe -ano` answers all of it except the process name, which comes - * from the shared process table -- so this scan starts no PowerShell of its - * own. One still runs on a released relay: without the optional - * `windows-process-tree.node` addon (built only by dev-channel-win-build.yml, - * so no release carries it) that table falls back to a CIM scan that forks one - * `powershell.exe`. That scan is TTL-shared with pane naming, so a relay with a - * live pane pays nothing extra for it. - * - * The EDR win is therefore the shape, not the absence of PowerShell. The - * retired payload ran `-ExecutionPolicy Bypass -EncodedCommand ` - * wrapping `Get-NetTCPConnection` joined to `Get-Process`: base64 beside a - * policy override is the highest-weighted token pair Defender for Endpoint - * scores on a PowerShell command line, and listing listeners with their owners - * reads as network discovery (T1049) on top of it. The shared CIM scan carries - * neither token. That payload survives only as the last resort below, without - * the override. - */ -export async function scanWindowsListeningPorts(signal?: AbortSignal): Promise { - const netstatPorts = await readWindowsNetstatPorts(signal) - if (netstatPorts) { - return normalizeWindowsDetectedPorts(await attachWindowsProcessNames(netstatPorts, signal)) - } - if (signal?.aborted) { - return [] - } - let json: string - try { - json = await runWindowsPortScanPowerShell(signal) - } catch { - // Why throw: neither scanner answered, so "no listeners" would be a guess. - throw new Error('Could not list listening ports on this host.') - } - return normalizeWindowsDetectedPorts(parseWindowsPowerShellPortRows(json)) -} - -/** Rows, or null when netstat could not answer and the fallback should run. */ -async function readWindowsNetstatPorts(signal?: AbortSignal): Promise { - let stdout: string - try { - const result = await runProcess({ - program: windowsSystem32Binary('netstat.exe'), - // No `-p tcp`: on Windows that protocol name means TCP over IPv4 only, so - // it hides every `[::]` listener the retired PowerShell payload reported. - args: ['-ano'], - env: buildRelayCommandEnv(), - timeoutMs: WINDOWS_PORT_SCAN_TIMEOUT_MS, - maxOutputBytes: WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES, - signal - }) - if (result.timedOut || result.code !== 0) { - return null - } - // A capped read still exits 0 and its head still parses, so nothing - // downstream can tell a partial table from a whole one. netstat prints IPv4 - // TCP, then IPv6 TCP, then UDP, so the rows lost first are exactly the - // `[::]` listeners that dropping `-p tcp` above exists to keep. Refuse the - // whole read rather than publish its head. - if (Buffer.byteLength(result.stdout) >= WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES) { - reportWindowsNetstatUnusable('output hit the capture cap and was truncated') - return null - } - stdout = result.stdout - } catch { - return null - } - const ports = parseWindowsNetstatOutput(stdout) - // Windows always has a listener (RPC endpoint mapper, SMB), so an exit-0 scan - // that parses to nothing is a reader that was blocked, not an idle host. - if (ports.length === 0) { - reportWindowsNetstatUnusable('exited 0 but no listening row parsed') - return null - } - return ports -} - -/** Reasons already reported. A fixed two-value vocabulary, so it cannot grow. */ -const reportedNetstatFailures = new Set() - -/** - * Say once why the scan left the native path. - * - * Both fall-throughs are permanent when they are wrong — the host stays on the - * PowerShell payload, or on nothing, for the life of the relay — and the scan - * repeats every 12-30s, so this logs one line rather than a stream. - * - * Through relayLogLine, not console.warn: this only ever runs in the detached - * daemon, whose stderr installRelayLogRotation routes into the relay.log that - * the remote-diagnostics tail reads. An untimestamped line in that file cannot - * be placed against the reconnect flaps around it (#7773), and "since when" is - * most of what this line is for. - */ -function reportWindowsNetstatUnusable(reason: string): void { - // Per reason, not per module: a host that parses nothing today and truncates - // tomorrow has two different faults, and one flag would hide the second. - if (reportedNetstatFailures.has(reason)) { - return - } - reportedNetstatFailures.add(reason) - relayLogLine(`[ports] netstat unusable on this host (${reason}); falling back to PowerShell`) -} - -/** Test-only: re-arm the one-shot so each case can observe its own line. */ -export function resetWindowsPortScanDiagnosticsForTests(): void { - reportedNetstatFailures.clear() -} - -/** - * Fill in owning-process names from the shared process-table snapshot. - * - * Names are optional data — the panel renders host/port/pid without them — so a - * host that cannot read the table keeps its rows. This shares whatever scan the - * table already runs rather than avoiding one, and on a relay that scan is a - * `powershell.exe` CIM query -- no released relay carries the native addon, so - * that is the path every SSH host takes, not a fallback. - * See docs/reference/windows-process-enumeration.md. - * - * Only `name` is read here, so this wants `readWindowsProcessIdentityTable` - * once #17866 lands -- on the detailed reader it would pay per-process handles - * for a field it discards. - * - * Best-effort by design: the snapshot is shared and TTL-cached, so it can - * predate netstat and hand a recycled PID its previous owner's name. Only - * labels read this field, and a fresh read would cost every caller a scan. - */ -async function attachWindowsProcessNames( - ports: DetectedPort[], - signal?: AbortSignal -): Promise { - const pids = new Set(ports.flatMap((port) => (port.pid == null ? [] : [port.pid]))) - // The shared snapshot takes no signal and must not be cancelled on one - // caller's behalf, so an abandoned scan declines to wait for it instead. - if (pids.size === 0 || signal?.aborted) { - return ports - } - let names: Map - try { - const rows = await readWindowsProcessTable() - names = new Map( - rows.flatMap((row) => - pids.has(row.pid) && row.name ? [[row.pid, stripExecutableSuffix(row.name)] as const] : [] - ) - ) - } catch { - return ports - } - return ports.map((port) => { - const processName = port.pid == null ? undefined : names.get(port.pid) - return processName ? { ...port, processName } : port - }) -} - -// The process table reports `sshd.exe`; the retired `Get-Process` payload -// reported `sshd`. The sshd filter below and every client that already renders -// these rows read the bare name, so keep publishing that spelling. -function stripExecutableSuffix(name: string): string { - return name.replace(/\.exe$/i, '') -} - -/** - * Single line so it survives as one argv element regardless of how the - * shell-less spawn hands it to PowerShell's `-Command` parser. Exported so - * windows-port-scan.win32.test.ts can run it: a missing `;` between statements - * is a parse error the mocked tests cannot see. - */ -export const WINDOWS_PORT_SCAN_SCRIPT = [ - "$ErrorActionPreference = 'Stop';", - 'Get-NetTCPConnection -State Listen | ForEach-Object {', - '$connection = $_; $name = $null;', - 'try { $name = (Get-Process -Id $connection.OwningProcess -ErrorAction Stop).ProcessName } catch { };', - '[pscustomobject]@{ host = [string]$connection.LocalAddress; port = [int]$connection.LocalPort;', - 'pid = [int]$connection.OwningProcess; processName = $name }', - '} | ConvertTo-Json -Compress -Depth 3' -].join(' ') - -async function runWindowsPortScanPowerShell(signal?: AbortSignal): Promise { - let lastError: unknown - - for (const program of [windowsPowerShellPath(), 'pwsh.exe']) { - try { - const result = await runProcess({ - program, - // No `-ExecutionPolicy` override: the policy gates script *files*, never - // `-Command`. Verified on Windows 11 — `-ExecutionPolicy Restricted - // -Command` still runs, while `-File` against an unsigned .ps1 does not. - args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PORT_SCAN_SCRIPT], - env: buildRelayCommandEnv(), - timeoutMs: WINDOWS_PORT_SCAN_TIMEOUT_MS, - maxOutputBytes: WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES, - signal - }) - if (signal?.aborted) { - throw new Error('windows port scan aborted') - } - if (result.timedOut || result.code !== 0) { - lastError ??= new Error( - `windows port scan PowerShell failed (code=${result.code} timedOut=${result.timedOut})` - ) - continue - } - return result.stdout - } catch (error) { - if (signal?.aborted) { - throw error - } - lastError ??= error - } - } - - throw lastError ?? new Error('PowerShell unavailable') -} - -export function parseWindowsPowerShellPortRows(json: string): DetectedPort[] { - const trimmed = json.trim() - if (!trimmed) { - return [] - } - - let parsed: unknown - try { - parsed = JSON.parse(trimmed) - } catch { - return [] - } - - const rows = Array.isArray(parsed) ? parsed : [parsed] - return rows.flatMap((row) => parseWindowsPortRow(row)) -} - -/** - * Listening rows, on a host in any UI language. - * - * `LISTENING` is not in `netstat.exe` — it lives in - * `System32\\netstat.exe.mui` beside `ESTABLISHED` and `Proto`, and MUI - * selection follows the UI language, so the pinned-locale env in - * relay-command-env.ts cannot reach it. A German host prints `ABHÖREN` and the - * word test finds nothing at all. - * - * The shape is language-independent: a listening socket has no peer, so its - * foreign address is `0.0.0.0:0` / `[::]:0`, and on every state Windows prints - * with a real peer that port is non-zero. Shape stays the fallback because the - * converse does not hold — `BOUND` and `CLOSED` print a zero peer too, and on a - * localized host their words are just as unreadable as the listening one. - */ -export function parseWindowsNetstatOutput(output: string): DetectedPort[] { - const { rows, tcpRows } = scanWindowsNetstatTcpRows(output) - const byStateWord = rows.filter((row) => row.state === 'LISTENING') - if (byStateWord.length > 0 || tcpRows === 0) { - return byStateWord.map((row) => row.port) - } - return readDominantZeroPeerState(rows) -} - -/** - * Of the zero-peer states, keep only the one that dominates. - * - * Shape alone would publish a phantom listener: one `BOUND` socket among real - * listeners looks identical to them once the state word is unreadable. But it - * cannot dominate — listeners outnumber those transients by roughly 50:1 on a - * real host (51 against 0 here), so the largest zero-peer group is the - * listening one. An exact tie keeps every tied group rather than guessing, - * which is no worse than reading shape alone. - * - * A majority rule inverts if the majority is wrong: enough transient zero-peer - * sockets and the phantoms win, publishing those and dropping the real - * listeners. The hatch is to return [] here and defer to the PowerShell reader, - * which reads the state word instead of inferring it. - */ -function readDominantZeroPeerState(rows: NetstatTcpRow[]): DetectedPort[] { - const countByState = new Map() - for (const row of rows) { - if (row.zeroPeer) { - countByState.set(row.state, (countByState.get(row.state) ?? 0) + 1) - } - } - const largest = Math.max(0, ...countByState.values()) - const dominant = new Set( - [...countByState].filter(([, count]) => count === largest).map(([state]) => state) - ) - return rows.flatMap((row) => (row.zeroPeer && dominant.has(row.state) ? [row.port] : [])) -} - -type NetstatTcpRow = { state: string; zeroPeer: boolean; port: DetectedPort } - -/** `tcpRows` separates a localized host from one with genuinely no TCP output. */ -function scanWindowsNetstatTcpRows(output: string): { rows: NetstatTcpRow[]; tcpRows: number } { - const rows: NetstatTcpRow[] = [] - let tcpRows = 0 - - for (const line of output.split(/\r?\n/)) { - const fields = getProcessOutputFields(line, 5) - if (fields.length < 5 || fields[0].toUpperCase() !== 'TCP') { - continue - } - tcpRows += 1 - const hostPort = parseWindowsNetstatAddress(fields[1]) - const pid = Number.parseInt(fields[4], 10) - if (!hostPort || !Number.isSafeInteger(pid) || pid <= 0) { - continue - } - rows.push({ - state: fields[3].toUpperCase(), - zeroPeer: readWindowsNetstatPort(fields[2]) === 0, - port: { ...hostPort, pid } - }) - } - - return { rows, tcpRows } -} - -function parseWindowsPortRow(row: unknown): DetectedPort[] { - if (!row || typeof row !== 'object') { - return [] - } - const value = row as { - host?: unknown - LocalAddress?: unknown - port?: unknown - LocalPort?: unknown - pid?: unknown - OwningProcess?: unknown - processName?: unknown - ProcessName?: unknown - } - const host = readString(value.host ?? value.LocalAddress) - const port = readInteger(value.port ?? value.LocalPort) - const pid = readInteger(value.pid ?? value.OwningProcess) - const processName = readString(value.processName ?? value.ProcessName) - if (!host || port == null || pid == null) { - return [] - } - return [ - { - host, - port, - pid, - ...(processName ? { processName } : {}) - } - ] -} - -function readString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function readInteger(value: unknown): number | undefined { - const parsed = - typeof value === 'number' - ? value - : typeof value === 'string' - ? Number.parseInt(value, 10) - : Number.NaN - return Number.isSafeInteger(parsed) ? parsed : undefined -} - -/** Port alone, keeping 0 — the foreign-address test above turns on that value. */ -function readWindowsNetstatPort(value: string): number | null { - const ipv6Match = /^\[.*\]:(\d+)$/.exec(value) - const portText = ipv6Match?.[1] ?? value.slice(value.lastIndexOf(':') + 1) - const port = Number.parseInt(portText, 10) - return Number.isSafeInteger(port) ? port : null -} - -function parseWindowsNetstatAddress(value: string): { host: string; port: number } | null { - const port = readWindowsNetstatPort(value) - if (port == null || port <= 0) { - return null - } - const ipv6Match = /^\[(.*)\]:\d+$/.exec(value) - if (ipv6Match) { - return { host: ipv6Match[1], port } - } - const idx = value.lastIndexOf(':') - if (idx <= 0) { - return null - } - return { host: value.slice(0, idx), port } -} - -function normalizeWindowsDetectedPorts(ports: DetectedPort[]): DetectedPort[] { - const seen = new Set() - const relayPid = process.pid - const relayParentPid = process.ppid - const normalized: DetectedPort[] = [] - - for (const port of ports) { - const processName = port.processName?.toLowerCase() - const key = `${port.host}:${port.port}:${port.pid ?? ''}` - if ( - seen.has(key) || - SYSTEM_PORTS_TO_EXCLUDE.has(port.port) || - port.pid === relayPid || - port.pid === relayParentPid || - processName === 'sshd' - ) { - continue - } - seen.add(key) - normalized.push(port) - } - - normalized.sort((a, b) => a.port - b.port || a.host.localeCompare(b.host)) - return normalized.slice(0, MAX_DETECTED_PORTS) -} diff --git a/src/relay/windows-port-scan.win32.test.ts b/src/relay/windows-port-scan.win32.test.ts deleted file mode 100644 index 07650bc2d01..00000000000 --- a/src/relay/windows-port-scan.win32.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' -import { - WINDOWS_PORT_SCAN_SCRIPT, - parseWindowsPowerShellPortRows, - scanWindowsListeningPorts -} from './windows-port-scan' - -/** - * The mocked suite pins the argv; this pins that the argv works. - * - * Both halves are things a mock cannot see: netstat's real column layout (a - * `-p tcp` here silently drops every `[::]` listener), and whether the joined - * one-line PowerShell script even parses — a missing `;` between statements is - * a ParserError, and the fallback would then be dead on the day it is needed. - * - * Runs only on win32; skipped elsewhere. - */ -const describeOnWindows = process.platform === 'win32' ? describe : describe.skip - -describeOnWindows('windows port scan against the real host', () => { - it('finds listeners over both address families through netstat', async () => { - const ports = await scanWindowsListeningPorts() - - expect(ports.length).toBeGreaterThan(0) - for (const port of ports) { - expect(port.port).toBeGreaterThan(0) - expect(port.host.length).toBeGreaterThan(0) - } - // Windows binds RPC/SMB dual-stack, so both families must be represented. - expect(ports.some((port) => port.host.includes(':'))).toBe(true) - expect(ports.some((port) => !port.host.includes(':'))).toBe(true) - // Names come from the shared process table, never from a shell of our own. - expect(ports.some((port) => port.processName)).toBe(true) - // The table spells them `svchost.exe`; clients have always seen `svchost`. - expect(ports.every((port) => !port.processName?.endsWith('.exe'))).toBe(true) - }, 30_000) - - it('runs the de-escalated PowerShell fallback command line', async () => { - const result = await runProcess({ - program: windowsPowerShellPath(), - args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PORT_SCAN_SCRIPT], - timeoutMs: 20_000 - }) - - // No stderr assertion: an autoload or first-run banner writes there without - // the scan having failed. - expect(result.code).toBe(0) - expect(parseWindowsPowerShellPortRows(result.stdout).length).toBeGreaterThan(0) - }, 30_000) -}) diff --git a/src/relay/workspace-session-handler.test.ts b/src/relay/workspace-session-handler.test.ts deleted file mode 100644 index 55be98a2f69..00000000000 --- a/src/relay/workspace-session-handler.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { WorkspaceSessionHandler } from './workspace-session-handler' -import { encodeJsonRpcFrame, MessageType, type JsonRpcRequest } from '../wsl-guest/protocol' - -function decodeJsonFrames(written: Buffer[]): unknown[] { - return written - .filter((buf) => buf[0] === MessageType.Regular) - .map((buf) => { - const len = buf.readUInt32BE(9) - return JSON.parse(buf.subarray(13, 13 + len).toString('utf-8')) as unknown - }) -} - -async function sendRequest( - dispatcher: RelayDispatcher, - method: string, - params: Record, - id: number -): Promise { - const req: JsonRpcRequest = { - jsonrpc: '2.0', - id, - method, - params - } - dispatcher.feed(encodeJsonRpcFrame(req, id, 0)) - await Promise.resolve() -} - -describe('WorkspaceSessionHandler', () => { - let baseDir: string - let dispatcher: RelayDispatcher - let written: Buffer[] - - beforeEach(() => { - baseDir = mkdtempSync(join(tmpdir(), 'orca-workspace-session-')) - written = [] - dispatcher = new RelayDispatcher((data) => { - written.push(Buffer.from(data)) - }) - new WorkspaceSessionHandler(dispatcher, baseDir) - }) - - afterEach(() => { - dispatcher.dispose() - rmSync(baseDir, { recursive: true, force: true }) - }) - - it('stores snapshots atomically and rejects stale revisions', async () => { - const session = { - activeWorktreePath: '/repo/worktree', - activeTabId: 'tab-1', - tabsByWorktreePath: { - '/repo/worktree': [{ id: 'tab-1', title: 'Terminal', worktreePath: '/repo/worktree' }] - }, - terminalLayoutsByTabId: {} - } - - await sendRequest( - dispatcher, - 'workspace.patch', - { - namespace: 'ssh target/path', - baseRevision: 0, - clientId: 'client-a', - patch: { kind: 'replace-session', session } - }, - 1 - ) - - const frames = decodeJsonFrames(written) - const response = frames.find((frame) => (frame as { id?: number }).id === 1) as { - result: { ok: boolean; snapshot: { revision: number; session: unknown } } - } - expect(response.result.ok).toBe(true) - expect(response.result.snapshot.revision).toBe(1) - expect(response.result.snapshot.session).toEqual(session) - expect( - frames.some((frame) => (frame as { method?: string }).method === 'workspace.changed') - ).toBe(true) - - written = [] - await sendRequest( - dispatcher, - 'workspace.patch', - { - namespace: 'ssh target/path', - baseRevision: 0, - clientId: 'client-b', - patch: { kind: 'replace-session', session: { ...session, activeTabId: 'tab-2' } } - }, - 2 - ) - - const staleResponse = decodeJsonFrames(written).find( - (frame) => (frame as { id?: number }).id === 2 - ) as { result: { ok: boolean; reason: string; snapshot: { revision: number } } } - expect(staleResponse.result.ok).toBe(false) - expect(staleResponse.result.reason).toBe('stale-revision') - expect(staleResponse.result.snapshot.revision).toBe(1) - }) - - it('tracks presence per namespace', async () => { - await sendRequest( - dispatcher, - 'workspace.presence', - { - namespace: 'team', - clientId: 'client-a', - clientName: ' Laptop A ' - }, - 1 - ) - await sendRequest( - dispatcher, - 'workspace.presence', - { - namespace: 'team', - clientId: 'client-b', - clientName: 'Laptop B' - }, - 2 - ) - - const response = decodeJsonFrames(written).find( - (frame) => (frame as { id?: number }).id === 2 - ) as { - result: { clients: { clientId: string; name: string }[] } - } - expect(response.result.clients.map((client) => client.clientId).sort()).toEqual([ - 'client-a', - 'client-b' - ]) - expect(response.result.clients.find((client) => client.clientId === 'client-a')?.name).toBe( - 'Laptop A' - ) - }) -}) diff --git a/src/relay/workspace-session-handler.ts b/src/relay/workspace-session-handler.ts deleted file mode 100644 index aa5e4448d59..00000000000 --- a/src/relay/workspace-session-handler.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { dirname, join } from 'node:path' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { publishWorkspaceSnapshotChange } from './workspace-snapshot-publication' - -type RemoteWorkspaceSnapshot = { - namespace: string - revision: number - updatedAt: number - schemaVersion: number - session: Record -} - -type ConnectedClient = { - clientId: string - name: string - lastSeenAt: number -} - -type PatchResult = - | { ok: true; snapshot: RemoteWorkspaceSnapshot } - | { - ok: false - reason: 'stale-revision' | 'unavailable' - snapshot?: RemoteWorkspaceSnapshot - message?: string - } - -const SNAPSHOT_SCHEMA_VERSION = 1 -const PRESENCE_TTL_MS = 45_000 - -function emptySession(): Record { - return { - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {} - } -} - -function sanitizeNamespace(namespace: unknown): string { - const raw = typeof namespace === 'string' && namespace.trim() ? namespace.trim() : 'default' - return raw.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 160) || 'default' -} - -function sanitizeClientName(value: string): string { - return value.replace(/\s+/g, ' ').trim().slice(0, 80) -} - -function sanitizeClientId(value: string): string { - return value.trim().slice(0, 200) -} - -export class WorkspaceSessionHandler { - private readonly clientsByNamespace = new Map>() - - constructor( - private dispatcher: RelayDispatcher, - private baseDir = join(homedir(), '.orca', 'sessions') - ) { - this.dispatcher.onRequest('workspace.get', (params) => this.get(params)) - this.dispatcher.onRequest('workspace.patch', (params) => this.patch(params)) - this.dispatcher.onRequest('workspace.presence', (params) => this.presence(params)) - } - - private snapshotPath(namespace: string): string { - return join(this.baseDir, `${namespace}.json`) - } - - private read(namespace: string): RemoteWorkspaceSnapshot { - const path = this.snapshotPath(namespace) - if (!existsSync(path)) { - return { - namespace, - revision: 0, - updatedAt: 0, - schemaVersion: SNAPSHOT_SCHEMA_VERSION, - session: emptySession() - } - } - - try { - const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial - return { - namespace, - revision: - typeof parsed.revision === 'number' && Number.isFinite(parsed.revision) - ? parsed.revision - : 0, - updatedAt: - typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt) - ? parsed.updatedAt - : 0, - schemaVersion: - typeof parsed.schemaVersion === 'number' && Number.isFinite(parsed.schemaVersion) - ? parsed.schemaVersion - : SNAPSHOT_SCHEMA_VERSION, - session: - parsed.session && typeof parsed.session === 'object' && !Array.isArray(parsed.session) - ? (parsed.session as Record) - : emptySession() - } - } catch { - return { - namespace, - revision: 0, - updatedAt: 0, - schemaVersion: SNAPSHOT_SCHEMA_VERSION, - session: emptySession() - } - } - } - - private write(snapshot: RemoteWorkspaceSnapshot): void { - const path = this.snapshotPath(snapshot.namespace) - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - const tmpPath = `${path}.tmp` - writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2), { mode: 0o600 }) - renameSync(tmpPath, path) - } - - private async get(params: Record): Promise { - return this.read(sanitizeNamespace(params.namespace)) - } - - private async patch(params: Record): Promise { - const namespace = sanitizeNamespace(params.namespace) - const current = this.read(namespace) - const baseRevision = Number(params.baseRevision) - if (Number.isFinite(baseRevision) && baseRevision !== current.revision) { - return { ok: false, reason: 'stale-revision', snapshot: current } - } - - const patch = params.patch as { kind?: unknown; session?: unknown } | undefined - if ( - !patch || - patch.kind !== 'replace-session' || - !patch.session || - typeof patch.session !== 'object' || - Array.isArray(patch.session) - ) { - return { ok: false, reason: 'unavailable', message: 'Invalid workspace patch' } - } - - const snapshot: RemoteWorkspaceSnapshot = { - namespace, - revision: current.revision + 1, - updatedAt: Date.now(), - schemaVersion: SNAPSHOT_SCHEMA_VERSION, - session: patch.session as Record - } - this.write(snapshot) - publishWorkspaceSnapshotChange( - this.dispatcher, - { - namespace, - snapshot, - sourceClientId: typeof params.clientId === 'string' ? params.clientId : undefined - }, - namespace - ) - return { ok: true, snapshot } - } - - private async presence(params: Record): Promise<{ clients: ConnectedClient[] }> { - const namespace = sanitizeNamespace(params.namespace) - const clientId = typeof params.clientId === 'string' ? sanitizeClientId(params.clientId) : '' - const name = typeof params.clientName === 'string' ? sanitizeClientName(params.clientName) : '' - const clients = this.clientsByNamespace.get(namespace) ?? new Map() - this.clientsByNamespace.set(namespace, clients) - - const now = Date.now() - for (const [id, client] of clients) { - if (now - client.lastSeenAt > PRESENCE_TTL_MS) { - clients.delete(id) - } - } - if (clientId) { - clients.set(clientId, { - clientId, - name: name || 'Unknown device', - lastSeenAt: now - }) - } - - return { - clients: Array.from(clients.values()).sort((a, b) => b.lastSeenAt - a.lastSeenAt) - } - } -} diff --git a/src/relay/workspace-snapshot-publication.test.ts b/src/relay/workspace-snapshot-publication.test.ts deleted file mode 100644 index 8f422a2f946..00000000000 --- a/src/relay/workspace-snapshot-publication.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { RelayDispatcher } from '../wsl-guest/dispatcher' -import { relayWriterControlReserve } from '../wsl-guest/dispatcher-writer-admission' -import { encodeJsonRpcFrame, MessageType, type JsonRpcRequest } from '../wsl-guest/protocol' -import { WorkspaceSessionHandler } from './workspace-session-handler' -import { - REMOTE_WORKSPACE_CHANGED_NOTIFICATION, - REMOTE_WORKSPACE_STALE_NOTIFICATION -} from '../shared/remote-workspace-types' - -// The relay runs on the REMOTE host, so the sink default is that host's Node major. -// Node <= 21 defaults to a 16KB high-water mark, which is the 12288B capacity issue #15238 reports. -const NODE21_HWM = 16 * 1024 - -function decodeNotifications( - written: Buffer[] -): { method: string; params: Record }[] { - return written - .filter((buf) => buf[0] === MessageType.Regular) - .map((buf) => { - const len = buf.readUInt32BE(9) - return JSON.parse(buf.subarray(13, 13 + len).toString('utf-8')) as { - method?: string - params?: Record - } - }) - .filter( - (msg): msg is { method: string; params: Record } => - typeof msg.method === 'string' - ) - .map((msg) => ({ method: msg.method, params: msg.params ?? {} })) -} - -/** A session shaped like the report: several worktrees, each with a handful of tabs. */ -function oversizedSession(worktrees: number, tabsPerWorktree: number): Record { - const tabsByWorktreePath: Record = {} - const terminalLayoutsByTabId: Record = {} - for (let w = 0; w < worktrees; w++) { - const worktreePath = `/home/dev/orca/workspaces/project/feature-branch-${w}` - tabsByWorktreePath[worktreePath] = Array.from({ length: tabsPerWorktree }, (_, t) => ({ - id: `tab-${w}-${t}-1f7a4c2e-9b0d-4e51-8a63-2c9f0d1e4b7a`, - title: `claude — feature-branch-${w} — pane ${t}`, - worktreePath, - kind: 'terminal', - startupCommand: 'claude --dangerously-skip-permissions', - cwd: worktreePath - })) - for (let t = 0; t < tabsPerWorktree; t++) { - terminalLayoutsByTabId[`tab-${w}-${t}-1f7a4c2e-9b0d-4e51-8a63-2c9f0d1e4b7a`] = { - direction: 'row', - panes: [ - { id: `pane-${w}-${t}-a`, size: 50, remoteSessionId: `orca-remote-${w}-${t}-a` }, - { id: `pane-${w}-${t}-b`, size: 50, remoteSessionId: `orca-remote-${w}-${t}-b` } - ] - } - } - } - return { - activeWorktreePath: '/home/dev/orca/workspaces/project/feature-branch-0', - activeTabId: 'tab-0-0-1f7a4c2e-9b0d-4e51-8a63-2c9f0d1e4b7a', - tabsByWorktreePath, - terminalLayoutsByTabId - } -} - -describe('workspace snapshot publication over a bounded producer frame', () => { - let baseDir: string - let dispatcher: RelayDispatcher - let written: Buffer[] - - beforeEach(() => { - baseDir = mkdtempSync(join(tmpdir(), 'orca-workspace-publication-')) - written = [] - dispatcher = new RelayDispatcher( - (data) => { - written.push(Buffer.from(data)) - return true - }, - { - writableHighWaterMark: () => NODE21_HWM, - writableLength: () => 0, - supportsWriteCallback: false - } - ) - new WorkspaceSessionHandler(dispatcher, baseDir) - }) - - afterEach(() => { - dispatcher.dispose() - rmSync(baseDir, { recursive: true, force: true }) - }) - - async function patch(session: Record, id: number): Promise { - const req: JsonRpcRequest = { - jsonrpc: '2.0', - id, - method: 'workspace.patch', - params: { - namespace: 'ssh_host_project', - baseRevision: id - 1, - clientId: 'client-a', - patch: { kind: 'replace-session', session } - } - } - dispatcher.feed(encodeJsonRpcFrame(req, id, 0)) - await Promise.resolve() - await Promise.resolve() - } - - it('publishes the snapshot inline while it fits the producer frame', async () => { - await patch(oversizedSession(1, 1), 1) - - const methods = decodeNotifications(written).map((msg) => msg.method) - expect(methods).toContain(REMOTE_WORKSPACE_CHANGED_NOTIFICATION) - expect(methods).not.toContain(REMOTE_WORKSPACE_STALE_NOTIFICATION) - }) - - it('tells the client its view is stale instead of dropping an oversized snapshot', async () => { - const session = oversizedSession(3, 8) - // Pin the premise: this really is over the 12288B capacity the issue reports, so the assertion - // below measures the drop path and not a payload that happened to fit. - const capacity = NODE21_HWM - relayWriterControlReserve(NODE21_HWM) - expect(capacity).toBe(12288) - expect( - dispatcher.notificationFrameBytes(REMOTE_WORKSPACE_CHANGED_NOTIFICATION, { - namespace: 'ssh_host_project', - snapshot: { - namespace: 'ssh_host_project', - revision: 1, - updatedAt: 0, - schemaVersion: 1, - session - }, - sourceClientId: 'client-a' - }) - ).toBeGreaterThan(capacity) - - await patch(session, 1) - - const notifications = decodeNotifications(written) - expect(notifications.map((msg) => msg.method)).not.toContain( - REMOTE_WORKSPACE_CHANGED_NOTIFICATION - ) - const stale = notifications.filter((msg) => msg.method === REMOTE_WORKSPACE_STALE_NOTIFICATION) - expect(stale).toHaveLength(1) - expect(stale[0].params).toEqual({ namespace: 'ssh_host_project' }) - }) - - it('carries no revision or author, so a coalesced marker cannot replay a superseded generation', async () => { - const session = oversizedSession(3, 8) - await patch(session, 1) - written = [] - await patch({ ...session, activeTabId: 'tab-1-1-1f7a4c2e-9b0d-4e51-8a63-2c9f0d1e4b7a' }, 2) - - for (const marker of decodeNotifications(written).filter( - (msg) => msg.method === REMOTE_WORKSPACE_STALE_NOTIFICATION - )) { - expect(marker.params).not.toHaveProperty('revision') - expect(marker.params).not.toHaveProperty('sourceClientId') - expect(marker.params).not.toHaveProperty('snapshot') - } - }) -}) diff --git a/src/relay/workspace-snapshot-publication.ts b/src/relay/workspace-snapshot-publication.ts deleted file mode 100644 index f951b01141d..00000000000 --- a/src/relay/workspace-snapshot-publication.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - REMOTE_WORKSPACE_CHANGED_NOTIFICATION, - REMOTE_WORKSPACE_STALE_NOTIFICATION -} from '../shared/remote-workspace-types' -import type { RelayDispatcher } from '../wsl-guest/dispatcher' -import { - createRelayClientResyncMarkerPublisher, - type RelayClientResyncMarkerPublisher -} from './relay-client-resync-marker' - -const stalePublishers = new WeakMap() - -function stalePublisher(dispatcher: RelayDispatcher): RelayClientResyncMarkerPublisher { - const existing = stalePublishers.get(dispatcher) - if (existing) { - return existing - } - const publisher = createRelayClientResyncMarkerPublisher( - dispatcher, - REMOTE_WORKSPACE_STALE_NOTIFICATION - ) - stalePublishers.set(dispatcher, publisher) - return publisher -} - -/** - * Per client, because frame capacity is per client: one peer on a small sink must not cost the - * others their snapshot, and a peer that cannot take the snapshot still learns it is behind. - * The marker deliberately carries no revision or author — a coalesced marker would then replay a - * generation the producer has moved past, which is the same silent staleness this exists to remove. - */ -export function publishWorkspaceSnapshotChange( - dispatcher: RelayDispatcher, - params: Record, - namespace: string -): void { - for (const clientId of dispatcher.activeClientIds()) { - if ( - dispatcher.publishProducerNotification( - clientId, - REMOTE_WORKSPACE_CHANGED_NOTIFICATION, - params - ) - ) { - continue - } - stalePublisher(dispatcher).emit(clientId, namespace, { namespace }) - } -} diff --git a/src/relay/workspace-space-scan-du-capacity.test.ts b/src/relay/workspace-space-scan-du-capacity.test.ts deleted file mode 100644 index 179fa37cfc5..00000000000 --- a/src/relay/workspace-space-scan-du-capacity.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type * as NodeProcess from 'node:process' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as WorkspaceSpaceScanBudgetModule from '../shared/workspace-space-scan-budget' -import type { RequestContext } from '../wsl-guest/dispatcher' - -const { execFileMock, budgetState } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - budgetState: { created: 0 } -})) - -vi.mock('node:child_process', () => ({ execFile: execFileMock })) - -vi.mock('node:process', async () => { - const actual = await vi.importActual('node:process') - return { ...actual, platform: 'linux' } -}) - -vi.mock('../shared/workspace-space-scan-budget', async () => { - const actual = await vi.importActual( - '../shared/workspace-space-scan-budget' - ) - return { - ...actual, - // Why: only the du path's top-level listing is capped, so a swallowed - // capacity error would let the portable retry succeed and fail this test. - createWorkspaceSpaceScanBudget: () => { - budgetState.created += 1 - return actual.createWorkspaceSpaceScanBudget( - budgetState.created === 1 ? { maxEntries: 2 } : undefined - ) - } - } -}) - -import { WorkspaceSpaceScanCapacityError } from '../shared/workspace-space-scan-budget' -import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' - -const context: RequestContext = { - clientId: 1, - isStale: () => false -} - -describe('relay workspace space scan du path', () => { - let tempDir: string | null = null - - afterEach(async () => { - execFileMock.mockReset() - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }) - tempDir = null - } - }) - - it('fails closed instead of repeating the traversal through the portable walker', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-relay-du-capacity-')) - const rootPath = join(tempDir, 'repo') - await mkdir(rootPath, { recursive: true }) - await Promise.all(['one', 'two', 'three'].map((name) => writeFile(join(rootPath, name), name))) - execFileMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, output: { stdout: string; stderr: string }) => void - ) => { - callback(null, { stdout: `1\t${args.at(-1)}\n`, stderr: '' }) - return { kill: vi.fn() } - } - ) - - await expect(scanWorkspaceSpaceDirectory(rootPath, context)).rejects.toBeInstanceOf( - WorkspaceSpaceScanCapacityError - ) - expect(execFileMock).toHaveBeenCalledWith( - 'du', - ['-k', '-d', '1', rootPath], - expect.any(Object), - expect.any(Function) - ) - }) -}) diff --git a/src/relay/workspace-space-scan.test.ts b/src/relay/workspace-space-scan.test.ts deleted file mode 100644 index afa01b32973..00000000000 --- a/src/relay/workspace-space-scan.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type * as NodeProcess from 'node:process' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as WorkspaceSpaceScanBudgetModule from '../shared/workspace-space-scan-budget' -import type { RequestContext } from '../wsl-guest/dispatcher' - -vi.mock('node:process', async () => { - const actual = await vi.importActual('node:process') - return { ...actual, platform: 'win32' } -}) - -vi.mock('../shared/workspace-space-scan-budget', async () => { - const actual = await vi.importActual( - '../shared/workspace-space-scan-budget' - ) - return { - ...actual, - createWorkspaceSpaceScanBudget: () => actual.createWorkspaceSpaceScanBudget({ maxEntries: 2 }) - } -}) - -import { WorkspaceSpaceScanCapacityError } from '../shared/workspace-space-scan-budget' -import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' - -const context: RequestContext = { - clientId: 1, - isStale: () => false -} - -describe('relay workspace space scan', () => { - let tempDir: string | null = null - - afterEach(async () => { - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }) - tempDir = null - } - }) - - it('fails closed when the portable scan exceeds its entry budget', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-relay-space-capacity-')) - const rootPath = join(tempDir, 'repo') - await mkdir(rootPath, { recursive: true }) - await Promise.all(['one', 'two', 'three'].map((name) => writeFile(join(rootPath, name), name))) - - await expect(scanWorkspaceSpaceDirectory(rootPath, context)).rejects.toBeInstanceOf( - WorkspaceSpaceScanCapacityError - ) - }) -}) diff --git a/src/relay/workspace-space-scan.ts b/src/relay/workspace-space-scan.ts deleted file mode 100644 index ef020ab0f04..00000000000 --- a/src/relay/workspace-space-scan.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { execFile } from 'node:child_process' -import type { Dirent } from 'node:fs' -import { lstat, opendir } from 'node:fs/promises' -import { basename, join } from 'node:path' -import { platform } from 'node:process' -import { promisify } from 'node:util' -import type { - WorkspaceSpaceDirectoryScanResult, - WorkspaceSpaceItem -} from '../shared/workspace-space-types' -import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' -import { mapWithConcurrency } from '../shared/map-with-concurrency' -import { - scanWorkspaceSpaceEntryTree, - type WorkspaceSpaceEntryScan -} from '../shared/workspace-space-entry-traversal' -import { - collectWorkspaceSpaceDirectoryEntries, - createWorkspaceSpaceScanBudget, - WorkspaceSpaceScanCapacityError -} from '../shared/workspace-space-scan-budget' -import type { RequestContext } from '../wsl-guest/dispatcher' - -const RELAY_FS_CONCURRENCY = 48 -const DU_TIMEOUT_MS = 120_000 -const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 -const execFileAsync = promisify(execFile) - -type ScanStats = WorkspaceSpaceEntryScan - -class RelayWorkspaceSpaceScanCancelledError extends Error { - constructor() { - super('Workspace space scan cancelled') - this.name = 'RelayWorkspaceSpaceScanCancelledError' - } -} - -function throwIfCancelled(context: RequestContext): void { - if (context.isStale() || context.signal?.aborted) { - throw new RelayWorkspaceSpaceScanCancelledError() - } -} - -function normalizeDuPath(pathValue: string): string { - const trimmed = pathValue.replace(/\/+$/, '') - return trimmed.length > 0 ? trimmed : pathValue -} - -function parseDuDepthOneOutput(stdout: string): Map { - const sizes = new Map() - for (const line of stdout.split('\n')) { - const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line - if (!normalizedLine) { - continue - } - const match = /^(\d+)\s+(.+)$/.exec(normalizedLine) - if (!match) { - continue - } - sizes.set(normalizeDuPath(match[2]), Number(match[1]) * 1024) - } - return sizes -} - -async function readDuDepthOne( - rootPath: string, - context: RequestContext -): Promise> { - throwIfCancelled(context) - const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], { - encoding: 'utf8', - maxBuffer: DU_MAX_BUFFER_BYTES, - signal: context.signal, - timeout: DU_TIMEOUT_MS - }) - throwIfCancelled(context) - return parseDuDepthOneOutput(stdout) -} - -function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem { - return { - name: stats.name, - path: stats.path, - kind: stats.kind, - sizeBytes: stats.sizeBytes - } -} - -async function scanTopLevelEntryWithDu( - entryPath: string, - name: string, - duSizes: Map, - context: RequestContext -): Promise { - throwIfCancelled(context) - const stats = await lstat(entryPath) - throwIfCancelled(context) - - if (stats.isSymbolicLink()) { - return { - name, - path: entryPath, - kind: 'symlink', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - if (!stats.isDirectory()) { - return { - name, - path: entryPath, - kind: 'file', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes: duSizes.get(normalizeDuPath(entryPath)) ?? stats.size, - skippedEntryCount: 0 - } -} - -async function scanEntryAggregate( - entryPath: string, - name: string, - context: RequestContext -): Promise { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: RELAY_FS_CONCURRENCY, - signal: context.signal, - entryName: (entry) => entry.name, - joinPath: join, - classifyEntry: async (path) => { - const stats = await lstat(path) - throwIfCancelled(context) - if (stats.isSymbolicLink()) { - return { kind: 'symlink', sizeBytes: stats.size } - } - return stats.isDirectory() - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => opendir(path), - checkCancelled: () => throwIfCancelled(context), - createCancellationError: () => new RelayWorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof RelayWorkspaceSpaceScanCancelledError - }) -} - -async function scanDirectoryWithDu( - rootPath: string, - context: RequestContext -): Promise { - throwIfCancelled(context) - const rootStats = await lstat(rootPath) - throwIfCancelled(context) - if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { - return scanDirectoryWithNode(rootPath, context) - } - - const [entries, duSizes] = await Promise.all([ - opendir(rootPath).then(async (directory) => { - const admission = await collectWorkspaceSpaceDirectoryEntries( - directory, - rootPath, - (entry) => entry.name, - createWorkspaceSpaceScanBudget(), - () => throwIfCancelled(context) - ) - return admission.entries - }), - readDuDepthOne(rootPath, context) - ]) - throwIfCancelled(context) - const childStats = await mapWithConcurrency( - entries, - RELAY_FS_CONCURRENCY, - async (entry): Promise => { - try { - return await scanTopLevelEntryWithDu( - join(rootPath, entry.name), - entry.name, - duSizes, - context - ) - } catch (error) { - if (error instanceof RelayWorkspaceSpaceScanCancelledError) { - throw error - } - return null - } - } - ) - const children = childStats.filter((child): child is ScanStats => child !== null) - const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) - - return { - sizeBytes: - duSizes.get(normalizeDuPath(rootPath)) ?? - rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0), - skippedEntryCount: childStats.length - children.length, - ...compact - } -} - -async function scanDirectoryWithNode( - rootPath: string, - context: RequestContext -): Promise { - const root = await scanEntryAggregate(rootPath, basename(rootPath), context) - const children = root.children ?? [] - const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) - - return { - sizeBytes: root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, - ...compact - } -} - -export async function scanWorkspaceSpaceDirectory( - rootPath: string, - context: RequestContext -): Promise { - if (platform !== 'win32') { - try { - return await scanDirectoryWithDu(rootPath, context) - } catch (error) { - if ( - error instanceof RelayWorkspaceSpaceScanCancelledError || - error instanceof WorkspaceSpaceScanCapacityError - ) { - throw error - } - } - } - return scanDirectoryWithNode(rootPath, context) -} diff --git a/src/shared/git-grep-record-limit.ts b/src/shared/git-grep-record-limit.ts deleted file mode 100644 index eba2c235b8e..00000000000 --- a/src/shared/git-grep-record-limit.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const GIT_GREP_MAX_RECORD_BYTES = 8 * 1024 * 1024 - -export class GitGrepRecordCapacityError extends Error { - readonly code = 'git_grep_record_capacity' - - constructor() { - super('Git search record exceeds the 8 MiB limit.') - this.name = 'GitGrepRecordCapacityError' - } -} diff --git a/src/shared/pty-retained-string-memory.ts b/src/shared/pty-retained-string-memory.ts deleted file mode 100644 index a6d1143dbb1..00000000000 --- a/src/shared/pty-retained-string-memory.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const PTY_RETAINED_RECORD_BYTES = 128 - -export function chargedPtyRetainedStringBytes(value: string): number { - return Math.max(Buffer.byteLength(value, 'utf8'), 2 * value.length) + PTY_RETAINED_RECORD_BYTES -} diff --git a/src/shared/quick-open-directory-reader.test.ts b/src/shared/quick-open-directory-reader.test.ts deleted file mode 100644 index 33f9cb46a9b..00000000000 --- a/src/shared/quick-open-directory-reader.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { lstatMock, opendirMock } = vi.hoisted(() => ({ - lstatMock: vi.fn(), - opendirMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - lstat: lstatMock, - opendir: opendirMock -})) - -import { readQuickOpenDirectoryEntries } from './quick-open-directory-reader' -import { createQuickOpenReaddirBudget } from './quick-open-readdir-budget' - -beforeEach(() => { - vi.clearAllMocks() - lstatMock.mockResolvedValue({ - isDirectory: () => true, - isSymbolicLink: () => false - }) -}) - -describe('quick-open streaming directory reader', () => { - it('orders numbered entries naturally before the recursive walk', async () => { - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const name of ['100 - notes', '9 - notes', '99 - notes']) { - yield { - name, - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false - } - } - } - }) - - const entries = await readQuickOpenDirectoryEntries({ - absPath: '/numbered', - allowSymlinkedRoot: false, - budget: createQuickOpenReaddirBudget() - }) - - expect(entries.map((entry) => entry.name)).toEqual(['9 - notes', '99 - notes', '100 - notes']) - }) - - it('stops a huge directory one entry beyond the exact cap and closes its iterator', async () => { - let produced = 0 - let closed = false - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - try { - while (produced < 1_000_000) { - produced += 1 - yield { - name: `directory-${produced}`, - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false - } - } - } finally { - closed = true - } - } - }) - - await expect( - readQuickOpenDirectoryEntries({ - absPath: '/streamed', - allowSymlinkedRoot: false, - budget: createQuickOpenReaddirBudget({ maxEntries: 3 }) - }) - ).rejects.toThrow('File listing exceeded 3 entries') - expect(produced).toBe(4) - expect(closed).toBe(true) - }) -}) diff --git a/src/shared/quick-open-directory-reader.ts b/src/shared/quick-open-directory-reader.ts deleted file mode 100644 index ebafae4d360..00000000000 --- a/src/shared/quick-open-directory-reader.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { lstat, opendir } from 'node:fs/promises' -import { compareFileNames } from './file-name-sort' -import { isFileListingCancellation, throwIfFileListingCancelled } from './file-listing-cancellation' -import { isQuickOpenReadableDirectory } from './quick-open-directory-validation' -import { - assertQuickOpenReaddirDeadline, - consumeQuickOpenReaddirEntryBudget, - consumeQuickOpenReaddirPathBudget, - isQuickOpenReaddirBudgetError, - type QuickOpenReaddirBudget -} from './quick-open-readdir-budget' - -export type QuickOpenDirectoryEntry = { - name: string - kind: 'directory' | 'file' | 'symlink' | 'other' -} - -export async function readQuickOpenDirectoryEntries(opts: { - absPath: string - allowSymlinkedRoot: boolean - budget: QuickOpenReaddirBudget - signal?: AbortSignal -}): Promise { - try { - const stat = await lstat(opts.absPath) - if (!isQuickOpenReadableDirectory(stat, opts.allowSymlinkedRoot)) { - return [] - } - - const entries: QuickOpenDirectoryEntry[] = [] - const directory = await opendir(opts.absPath) - try { - throwIfFileListingCancelled(opts.signal) - assertQuickOpenReaddirDeadline(opts.budget) - for await (const entry of directory) { - throwIfFileListingCancelled(opts.signal) - assertQuickOpenReaddirDeadline(opts.budget) - consumeQuickOpenReaddirEntryBudget(opts.budget) - consumeQuickOpenReaddirPathBudget(opts.budget, entry.name) - entries.push({ - name: entry.name, - kind: entry.isDirectory() - ? 'directory' - : entry.isFile() - ? 'file' - : entry.isSymbolicLink() - ? 'symlink' - : 'other' - }) - } - } finally { - try { - await directory.close() - } catch {} - } - entries.sort((left, right) => compareFileNames(left.name, right.name)) - - // Why: discard buffered names if the path became a symlink while its - // directory handle was open; descendants must never escape the root. - const statAfterRead = await lstat(opts.absPath) - return isQuickOpenReadableDirectory(statAfterRead, opts.allowSymlinkedRoot) ? entries : [] - } catch (error) { - if (isQuickOpenReaddirBudgetError(error) || isFileListingCancellation(error)) { - throw error - } - // Permission denied or a vanished subtree must not hide readable siblings. - return [] - } -} diff --git a/src/shared/quick-open-directory-validation.ts b/src/shared/quick-open-directory-validation.ts deleted file mode 100644 index 501791bb050..00000000000 --- a/src/shared/quick-open-directory-validation.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Stats } from 'node:fs' - -export function isQuickOpenReadableDirectory(stat: Stats, allowSymlinkedRoot = false): boolean { - // Why: an explicitly selected workspace root may be a symlink, while nested - // traversal must never follow a symlink outside that authorized root. - return stat.isDirectory() || Boolean(allowSymlinkedRoot && stat.isSymbolicLink()) -} diff --git a/src/shared/quick-open-expansion-paths.test.ts b/src/shared/quick-open-expansion-paths.test.ts deleted file mode 100644 index e5e91e9b40c..00000000000 --- a/src/shared/quick-open-expansion-paths.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { collapseQuickOpenExpansionPaths } from './quick-open-expansion-paths' - -describe('collapseQuickOpenExpansionPaths', () => { - it('collapses descendants without confusing shared-prefix siblings', () => { - const paths = new Map([ - ['foo/bar/deep', false], - ['foo-sibling', false], - ['foo', false], - ['foo/bar', true] - ]) - - expect(collapseQuickOpenExpansionPaths(paths)).toEqual([ - ['foo', true], - ['foo-sibling', false] - ]) - }) - - it('keeps many sibling placeholders distinct', () => { - const paths = new Map( - Array.from({ length: 10_001 }, (_, index) => [`generated-${index}`, true] as const) - ) - - expect(collapseQuickOpenExpansionPaths(paths)).toHaveLength(paths.size) - }) -}) diff --git a/src/shared/quick-open-expansion-paths.ts b/src/shared/quick-open-expansion-paths.ts deleted file mode 100644 index 43f094edd3f..00000000000 --- a/src/shared/quick-open-expansion-paths.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Remove descendant placeholders already covered by an ancestor. Sorting puts - * ancestors first; prefix lookups avoid quadratic scans across sibling paths. - */ -export function collapseQuickOpenExpansionPaths( - expansionPaths: ReadonlyMap -): [string, boolean][] { - const sortedPaths = Array.from(expansionPaths).sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0 - ) - const collapsedPaths = new Map() - - for (const [relPath, includeSymlinks] of sortedPaths) { - let ancestorPath: string | undefined - let slashIndex = relPath.indexOf('/') - while (slashIndex !== -1) { - const candidate = relPath.substring(0, slashIndex) - if (collapsedPaths.has(candidate)) { - ancestorPath = candidate - break - } - slashIndex = relPath.indexOf('/', slashIndex + 1) - } - - if (ancestorPath) { - // Why: primary and ignored passes can overlap; the ancestor walk covers - // the descendant, but must preserve either pass's symlink-leaf contract. - if (includeSymlinks) { - collapsedPaths.set(ancestorPath, true) - } - continue - } - collapsedPaths.set(relPath, includeSymlinks) - } - - return Array.from(collapsedPaths) -} diff --git a/src/shared/quick-open-git-entry-classification.ts b/src/shared/quick-open-git-entry-classification.ts deleted file mode 100644 index 971a4106dce..00000000000 --- a/src/shared/quick-open-git-entry-classification.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { lstat } from 'node:fs/promises' -import { join } from 'node:path' - -export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder' - -export type QuickOpenGitLsFilesEntry = { - path: string - isGitlink: boolean - isUntrackedDir: boolean -} - -const GIT_LS_FILES_STAGE_ENTRY = /^([0-7]{6}) [0-9a-f]{40,64} [0-3]\t/ - -export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFilesEntry { - const match = GIT_LS_FILES_STAGE_ENTRY.exec(entry) - if (match) { - return { - path: entry.slice(match[0].length), - isGitlink: match[1] === '160000', - isUntrackedDir: false - } - } - return { - path: entry, - isGitlink: false, - isUntrackedDir: entry.endsWith('/') - } -} - -function joinQuickOpenRootPath(rootPath: string, relPath: string): string { - return join(rootPath, ...relPath.split('/').filter(Boolean)) -} - -async function hasGitEntry(absPath: string): Promise { - try { - const stat = await lstat(join(absPath, '.git')) - return stat.isDirectory() || stat.isFile() - } catch { - return false - } -} - -export async function classifyQuickOpenGitEntry( - rootPath: string, - entry: string -): Promise<{ kind: QuickOpenGitEntryKind; relPath: string }> { - const parsed = parseQuickOpenGitLsFilesEntry(entry) - const relPath = parsed.path.replace(/\/+$/, '') - if (!relPath) { - return { kind: 'drop-placeholder', relPath } - } - if (!parsed.isGitlink && !parsed.isUntrackedDir) { - return { kind: 'keep', relPath } - } - - let stat - try { - stat = await lstat(joinQuickOpenRootPath(rootPath, relPath)) - } catch { - return { kind: 'drop-placeholder', relPath } - } - if (!stat.isDirectory()) { - return { kind: 'drop-placeholder', relPath } - } - return (await hasGitEntry(joinQuickOpenRootPath(rootPath, relPath))) - ? { kind: 'fill-nested-repo', relPath } - : { kind: 'drop-placeholder', relPath } -} diff --git a/src/shared/quick-open-install-rg.test.ts b/src/shared/quick-open-install-rg.test.ts deleted file mode 100644 index a9e99e94399..00000000000 --- a/src/shared/quick-open-install-rg.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { detectLinuxInstallCommandFromOsRelease } from './quick-open-install-rg' - -describe('detectLinuxInstallCommandFromOsRelease', () => { - it('uses apt for Debian and Ubuntu families', () => { - expect(detectLinuxInstallCommandFromOsRelease('ID=ubuntu')).toBe('sudo apt install ripgrep') - expect(detectLinuxInstallCommandFromOsRelease('ID=pop\nID_LIKE="ubuntu debian"')).toBe( - 'sudo apt install ripgrep' - ) - }) - - it('uses distro-specific commands for common non-Debian families', () => { - expect(detectLinuxInstallCommandFromOsRelease('ID="fedora"')).toBe('sudo dnf install ripgrep') - expect(detectLinuxInstallCommandFromOsRelease('ID=manjaro\r\nID_LIKE="arch"')).toBe( - 'sudo pacman -S ripgrep' - ) - expect(detectLinuxInstallCommandFromOsRelease("ID=postmarketos\nID_LIKE='alpine'")).toBe( - 'sudo apk add ripgrep' - ) - }) - - it('falls back to generic Linux package-manager guidance', () => { - expect(detectLinuxInstallCommandFromOsRelease('ID=unknown')).toBe( - 'install ripgrep via your package manager (e.g. apt/dnf/pacman)' - ) - }) -}) diff --git a/src/shared/quick-open-install-rg.ts b/src/shared/quick-open-install-rg.ts deleted file mode 100644 index ba421530370..00000000000 --- a/src/shared/quick-open-install-rg.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { readNodeFileWithinLimit } from './node-bounded-file-reader' -import { getProcessOutputFields, iterateProcessOutputLines } from './process-output-field-scanner' - -const GENERIC_LINUX_RIPGREP_INSTALL = - 'install ripgrep via your package manager (e.g. apt/dnf/pacman)' -const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16 -const MAX_OS_RELEASE_BYTES = 64 * 1024 - -export async function detectInstallCommand(): Promise { - if (process.platform === 'darwin') { - return 'brew install ripgrep' - } - if (process.platform === 'linux') { - try { - const osRelease = ( - await readNodeFileWithinLimit('/etc/os-release', MAX_OS_RELEASE_BYTES) - ).buffer.toString('utf8') - return detectLinuxInstallCommandFromOsRelease(osRelease) - } catch { - /* fall through to generic guidance */ - } - return GENERIC_LINUX_RIPGREP_INSTALL - } - return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)' -} - -export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string { - for (const id of getOsReleasePackageFamilyIds(osRelease)) { - if (id === 'debian' || id === 'ubuntu') { - return 'sudo apt install ripgrep' - } - if (id === 'fedora' || id === 'rhel' || id === 'centos') { - return 'sudo dnf install ripgrep' - } - if (id === 'arch') { - return 'sudo pacman -S ripgrep' - } - if (id === 'alpine') { - return 'sudo apk add ripgrep' - } - } - - return GENERIC_LINUX_RIPGREP_INSTALL -} - -function getOsReleasePackageFamilyIds(osRelease: string): string[] { - const ids: string[] = [] - - for (const line of iterateProcessOutputLines(osRelease)) { - const separatorIndex = line.indexOf('=') - if (separatorIndex <= 0) { - continue - } - - const key = line.slice(0, separatorIndex) - const value = readOsReleaseValue(line.slice(separatorIndex + 1)) - if (key === 'ID') { - const id = getProcessOutputFields(value, 1)[0] - if (id) { - ids.push(id) - } - } else if (key === 'ID_LIKE') { - ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS)) - } - } - - return ids -} - -function readOsReleaseValue(rawValue: string): string { - const trimmed = rawValue.trim() - const quote = trimmed[0] - return (quote === '"' || quote === "'") && trimmed.at(-1) === quote - ? trimmed.slice(1, -1) - : trimmed -} - -// Why remote-only: the local side always has Orca's bundled rg, so only a remote host an upload -// never reached can still fall back to the capped git/readdir listing these messages explain. -export async function buildInstallRgMessage(cause: unknown): Promise { - const reason = cause instanceof Error ? cause.message : String(cause) - return ( - `Quick Open scan too large (${reason}). ` + - `Install ripgrep on the remote to enable fast, gitignore-aware listing: ${await detectInstallCommand()}` - ) -} - -export async function buildRipgrepRequiredMessage(): Promise { - return `Quick Open search requires ripgrep on the remote to stay resource-bounded. Install it with: ${await detectInstallCommand()}` -} diff --git a/src/shared/quick-open-readdir-budget.ts b/src/shared/quick-open-readdir-budget.ts deleted file mode 100644 index ced172a92a6..00000000000 --- a/src/shared/quick-open-readdir-budget.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { QUICK_OPEN_LISTING_MAX_RESULTS } from './quick-open-listing-limits' - -export const QUICK_OPEN_READDIR_MAX_FILES = QUICK_OPEN_LISTING_MAX_RESULTS -export const QUICK_OPEN_READDIR_MAX_ENTRIES = 50_000 -export const QUICK_OPEN_READDIR_MAX_DIRECTORIES = 25_000 -export const QUICK_OPEN_READDIR_MAX_DEPTH = 256 -export const QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS = 16 * 1024 * 1024 -export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 - -export type QuickOpenReaddirBudget = { - remainingFiles: number - remainingEntries: number - remainingDirectories: number - remainingPathCodeUnits: number - maxFiles: number - maxEntries: number - maxDirectories: number - maxDepth: number - maxPathCodeUnits: number - deadlineMs: number -} - -export function createQuickOpenReaddirBudget( - opts: { - maxFiles?: number - maxEntries?: number - maxDirectories?: number - maxDepth?: number - maxPathCodeUnits?: number - timeoutMs?: number - nowMs?: number - } = {} -): QuickOpenReaddirBudget { - const maxFiles = opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES - const maxEntries = opts.maxEntries ?? QUICK_OPEN_READDIR_MAX_ENTRIES - const maxDirectories = opts.maxDirectories ?? QUICK_OPEN_READDIR_MAX_DIRECTORIES - const maxDepth = opts.maxDepth ?? QUICK_OPEN_READDIR_MAX_DEPTH - const maxPathCodeUnits = opts.maxPathCodeUnits ?? QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS - for (const [name, value] of Object.entries({ - maxFiles, - maxEntries, - maxDirectories, - maxDepth, - maxPathCodeUnits - })) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - return { - remainingFiles: maxFiles, - remainingEntries: maxEntries, - remainingDirectories: maxDirectories, - remainingPathCodeUnits: maxPathCodeUnits, - maxFiles, - maxEntries, - maxDirectories, - maxDepth, - maxPathCodeUnits, - deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) - } -} - -const FILE_LISTING_TIMED_OUT = 'File listing timed out' -const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' - -/** Budget errors are the only fallback failures translated into install-rg guidance. */ -export function isQuickOpenReaddirBudgetError(error: unknown): boolean { - const message = error instanceof Error ? error.message : '' - return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) -} - -export function assertQuickOpenReaddirDeadline(budget: QuickOpenReaddirBudget): void { - if (Date.now() > budget.deadlineMs) { - throw new Error(FILE_LISTING_TIMED_OUT) - } -} - -export function consumeQuickOpenReaddirFileBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingFiles <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxFiles} files`) - } - budget.remainingFiles-- -} - -export function consumeQuickOpenReaddirEntryBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingEntries <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxEntries} entries`) - } - budget.remainingEntries-- -} - -export function consumeQuickOpenReaddirDirectoryBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingDirectories <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxDirectories} directories`) - } - budget.remainingDirectories-- -} - -export function assertQuickOpenReaddirDepth(budget: QuickOpenReaddirBudget, depth: number): void { - if (depth > budget.maxDepth) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} depth ${budget.maxDepth}`) - } -} - -export function consumeQuickOpenReaddirPathBudget( - budget: QuickOpenReaddirBudget, - path: string -): void { - if (path.length > budget.remainingPathCodeUnits) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxPathCodeUnits} path code units`) - } - budget.remainingPathCodeUnits -= path.length -} diff --git a/src/shared/quick-open-readdir-memory.test.ts b/src/shared/quick-open-readdir-memory.test.ts deleted file mode 100644 index 4929f33dc96..00000000000 --- a/src/shared/quick-open-readdir-memory.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - createQuickOpenReaddirBudget, - listQuickOpenFilesWithReaddir -} from './quick-open-readdir-walk' - -const tempRoots: string[] = [] - -async function makeRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-quick-open-budget-')) - tempRoots.push(root) - return root -} - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('quick-open readdir memory limits', () => { - it('accepts the exact entry limit and rejects the next zero-file entry', async () => { - const root = await makeRoot() - await mkdir(join(root, 'a')) - await mkdir(join(root, 'b')) - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxEntries: 2 }) - }) - ).resolves.toEqual([]) - - await mkdir(join(root, 'c')) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxEntries: 2 }) - }) - ).rejects.toThrow('File listing exceeded 2 entries') - }) - - it('caps retained directory paths even when the tree contains no files', async () => { - const root = await makeRoot() - await mkdir(join(root, 'a')) - await mkdir(join(root, 'b')) - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDirectories: 3 }) - }) - ).resolves.toEqual([]) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDirectories: 2 }) - }) - ).rejects.toThrow('File listing exceeded 2 directories') - }) - - it('accepts the exact depth limit and rejects a deeper directory', async () => { - const root = await makeRoot() - await mkdir(join(root, 'a', 'b'), { recursive: true }) - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDepth: 2 }) - }) - ).resolves.toEqual([]) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDepth: 1 }) - }) - ).rejects.toThrow('File listing exceeded depth 1') - }) - - it('bounds aggregate path storage without changing exact-boundary output', async () => { - const root = await makeRoot() - const fileName = 'a.ts' - await writeFile(join(root, fileName), 'x') - const exactPathCodeUnits = root.length + fileName.length * 2 - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxPathCodeUnits: exactPathCodeUnits }) - }) - ).resolves.toEqual([fileName]) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxPathCodeUnits: exactPathCodeUnits - 1 }) - }) - ).rejects.toThrow(`File listing exceeded ${exactPathCodeUnits - 1} path code units`) - }) -}) diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts deleted file mode 100644 index 91f0398a0cd..00000000000 --- a/src/shared/quick-open-readdir-walk.test.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFsPromises from 'node:fs/promises' - -const { lstatMock, opendirMock } = vi.hoisted(() => ({ - lstatMock: vi.fn(), - opendirMock: vi.fn() -})) - -vi.mock('fs/promises', async () => { - const actual = await vi.importActual('fs/promises') - lstatMock.mockImplementation(actual.lstat) - opendirMock.mockImplementation(actual.opendir) - return { - ...actual, - lstat: lstatMock, - opendir: opendirMock - } -}) - -import { mkdtemp, mkdir, rename, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { - classifyQuickOpenGitEntry, - createQuickOpenReaddirBudget, - expandQuickOpenGitFileListing, - isQuickOpenReaddirBudgetError, - listQuickOpenFilesWithReaddir, - parseQuickOpenGitLsFilesEntry, - QUICK_OPEN_READDIR_MAX_FILES -} from './quick-open-readdir-walk' -import { isFileListingCancellation } from './file-listing-cancellation' - -const tempDirs: string[] = [] -const SHA1 = '0123456789abcdef0123456789abcdef01234567' -const SHA256 = `${SHA1}89abcdef0123456789abcdef` - -function staged(mode: string, path: string, sha = SHA1): string { - return `${mode} ${sha} 0\t${path}` -} - -async function makeTempRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-quick-open-readdir-')) - tempDirs.push(root) - return root -} - -async function writeRel(root: string, relPath: string, content = 'x'): Promise { - const absPath = join(root, ...relPath.split('/')) - await mkdir(dirname(absPath), { recursive: true }) - await writeFile(absPath, content) -} - -async function mkdirRel(root: string, relPath: string): Promise { - await mkdir(join(root, ...relPath.split('/')), { recursive: true }) -} - -async function makeNestedRepo(root: string, relPath: string, gitEntry: 'dir' | 'file' = 'dir') { - await mkdirRel(root, relPath) - const gitPath = join(root, ...relPath.split('/'), '.git') - await (gitEntry === 'dir' - ? mkdir(gitPath, { recursive: true }) - : writeFile(gitPath, 'gitdir: ../.git/worktrees/example')) -} - -afterEach(async () => { - vi.clearAllMocks() - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) -}) - -describe('quick-open readdir walk', () => { - it('parses git ls-files stage output and bare untracked entries', () => { - expect(parseQuickOpenGitLsFilesEntry(staged('100644', 'src/index.ts'))).toEqual({ - path: 'src/index.ts', - isGitlink: false, - isUntrackedDir: false - }) - expect(parseQuickOpenGitLsFilesEntry(staged('160000', 'packages/app'))).toEqual({ - path: 'packages/app', - isGitlink: true, - isUntrackedDir: false - }) - expect(parseQuickOpenGitLsFilesEntry(staged('100755', 'bin/run', SHA256))).toEqual({ - path: 'bin/run', - isGitlink: false, - isUntrackedDir: false - }) - expect(parseQuickOpenGitLsFilesEntry('scratch.txt')).toEqual({ - path: 'scratch.txt', - isGitlink: false, - isUntrackedDir: false - }) - expect(parseQuickOpenGitLsFilesEntry('packages/lib/')).toEqual({ - path: 'packages/lib/', - isGitlink: false, - isUntrackedDir: true - }) - }) - - it('keeps ordinary git entries without lstat calls', async () => { - await expect( - expandQuickOpenGitFileListing({ - rootPath: '/unused/root', - gitPaths: [ - staged('100644', 'README.md'), - staged('100755', 'bin/run', SHA256), - 'scratch.txt' - ] - }) - ).resolves.toEqual(['README.md', 'bin/run', 'scratch.txt']) - - expect(lstatMock).not.toHaveBeenCalled() - }) - - it('classifies nested repo placeholders without confusing extensionless files', async () => { - const root = await makeTempRoot() - await writeRel(root, 'Makefile') - await makeNestedRepo(root, 'packages/app') - await makeNestedRepo(root, 'packages/lib', 'file') - await mkdirRel(root, 'packages/unchecked') - - await expect(classifyQuickOpenGitEntry(root, staged('100644', 'Makefile'))).resolves.toEqual({ - kind: 'keep', - relPath: 'Makefile' - }) - await expect( - classifyQuickOpenGitEntry(root, staged('160000', 'packages/app')) - ).resolves.toEqual({ - kind: 'fill-nested-repo', - relPath: 'packages/app' - }) - await expect(classifyQuickOpenGitEntry(root, 'packages/lib/')).resolves.toEqual({ - kind: 'fill-nested-repo', - relPath: 'packages/lib' - }) - await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked')).resolves.toEqual({ - kind: 'keep', - relPath: 'packages/unchecked' - }) - await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked/')).resolves.toEqual({ - kind: 'drop-placeholder', - relPath: 'packages/unchecked' - }) - }) - - it('re-prefixes nested children and filters final workspace-relative paths', async () => { - const root = await makeTempRoot() - await writeRel(root, 'README.md') - await writeRel(root, 'Makefile') - await makeNestedRepo(root, 'packages/app') - await makeNestedRepo(root, 'packages/lib', 'file') - await mkdirRel(root, 'packages/empty') - await writeRel(root, 'packages/app/src/main.ts') - await writeRel(root, 'packages/app/node_modules/pkg/index.js') - await writeRel(root, 'packages/app/.git/config') - await writeRel(root, 'packages/app/linked-worktree/file.ts') - await writeRel(root, 'packages/lib/src/lib.ts') - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [ - staged('100644', 'README.md'), - staged('100644', 'Makefile'), - staged('160000', 'packages/app'), - 'packages/lib/', - 'packages/empty/' - ], - excludePathPrefixes: ['packages/app/linked-worktree'] - }) - ).resolves.toEqual([ - 'README.md', - 'Makefile', - 'packages/app/src/main.ts', - 'packages/lib/src/lib.ts' - ]) - }) - - it('rejects on cap and shares one budget across nested subtrees', async () => { - const root = await makeTempRoot() - await makeNestedRepo(root, 'packages/app') - await makeNestedRepo(root, 'packages/lib') - await writeRel(root, 'packages/app/a.ts') - await writeRel(root, 'packages/app/b.ts') - await writeRel(root, 'packages/lib/c.ts') - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [staged('160000', 'packages/app'), staged('160000', 'packages/lib')], - budget: createQuickOpenReaddirBudget({ maxFiles: 2 }) - }) - ).rejects.toThrow('File listing exceeded') - }) - - it('prunes excluded nested subtrees during traversal without consuming the budget', async () => { - const root = await makeTempRoot() - await makeNestedRepo(root, 'packages/app') - await writeRel(root, 'packages/app/keep.ts') - // A large excluded subtree inside the nested repo: if it were walked before - // being filtered, it would exhaust the tiny budget and reject. - for (let i = 0; i < 20; i += 1) { - await writeRel(root, `packages/app/excluded/file-${i}.ts`) - } - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [staged('160000', 'packages/app')], - excludePathPrefixes: ['packages/app/excluded'], - budget: createQuickOpenReaddirBudget({ maxFiles: 5 }) - }) - ).resolves.toEqual(['packages/app/keep.ts']) - }) - - it('expands allowed ignored directories without walking blocked or excluded directories', async () => { - const root = await makeTempRoot() - await writeRel(root, 'dist/generated.js') - await writeRel(root, 'node_modules/pkg/index.js') - await writeRel(root, '.cache/state.json') - await writeRel(root, '.local/share/state.json') - await writeRel(root, '.local/config.toml') - await writeRel(root, 'excluded/other.js') - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: [ - 'dist/', - '.local/', - 'node_modules/', - '.cache/', - '.local/share/', - 'excluded/' - ], - excludePathPrefixes: ['excluded'], - budget: createQuickOpenReaddirBudget({ maxFiles: 2 }) - }) - ).resolves.toEqual(['.local/config.toml', 'dist/generated.js']) - - const walkedPaths = opendirMock.mock.calls.map(([path]) => path) - expect(walkedPaths).toContain(join(root, 'dist')) - expect(walkedPaths).toContain(join(root, '.local')) - expect(walkedPaths).not.toContain(join(root, '.local', 'share')) - }) - - it('batches many allowed directory placeholders with bounded concurrency', async () => { - const root = await makeTempRoot() - const directoryPaths = Array.from({ length: 40 }, (_, index) => `generated-${index}/`) - await Promise.all( - directoryPaths.map((directoryPath, index) => - writeRel(root, `${directoryPath}file-${index}.ts`) - ) - ) - - const actual = await vi.importActual('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - let activeReads = 0 - let maxActiveReads = 0 - opendirMock.mockImplementation(async (...args: Parameters) => { - activeReads++ - maxActiveReads = Math.max(maxActiveReads, activeReads) - await new Promise((resolve) => setTimeout(resolve, 5)) - try { - return await actual.opendir(...args) - } finally { - activeReads-- - } - }) - - try { - const files = await expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths - }) - expect(files).toHaveLength(directoryPaths.length) - expect(maxActiveReads).toBeGreaterThan(1) - expect(maxActiveReads).toBeLessThanOrEqual(32) - } finally { - opendirMock.mockImplementation(actual.opendir) - } - }) - - it('preserves symlink leaves from collapsed Git directories without following them', async () => { - const root = await makeTempRoot() - await mkdirRel(root, 'scratch') - await writeRel(root, 'target/file.ts') - - try { - await symlink(join(root, 'target', 'file.ts'), join(root, 'scratch', 'link.ts')) - await symlink(join(root, 'target'), join(root, 'scratch', 'linked-dir'), 'dir') - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EPERM') { - return - } - throw err - } - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: ['scratch/'] - }) - ).resolves.toEqual(['scratch/link.ts', 'scratch/linked-dir']) - }) - - it('does not follow a collapsed directory replaced by a symlink', async () => { - const root = await makeTempRoot() - const outsideRoot = await makeTempRoot() - await writeRel(outsideRoot, 'secret.ts') - - try { - await symlink(outsideRoot, join(root, 'dist'), 'dir') - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EPERM') { - return - } - throw err - } - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: ['dist/'] - }) - ).resolves.toEqual([]) - }) - - it('discards entries when a collapsed directory changes during opendir', async () => { - const root = await makeTempRoot() - const outsideRoot = await makeTempRoot() - await mkdirRel(root, 'dist') - await writeRel(outsideRoot, 'secret.ts') - const actual = await vi.importActual('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - const distPath = join(root, 'dist') - let swapped = false - opendirMock.mockImplementation(async (...args: Parameters) => { - if (!swapped && args[0] === distPath) { - swapped = true - await rename(distPath, join(root, 'old-dist')) - await symlink(outsideRoot, distPath, 'dir') - } - return actual.opendir(...args) - }) - - try { - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: ['dist/'] - }) - ).resolves.toEqual([]) - } finally { - opendirMock.mockImplementation(actual.opendir) - } - }) - - it('walks overlapping primary and ignored placeholders only once', async () => { - const root = await makeTempRoot() - await writeRel(root, 'foo/a.ts') - await writeRel(root, 'foo/bar/b.ts') - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: ['foo/', 'foo/bar/'], - budget: createQuickOpenReaddirBudget({ maxFiles: 2 }) - }) - ).resolves.toEqual(['foo/a.ts', 'foo/bar/b.ts']) - - expect( - opendirMock.mock.calls.filter(([path]) => path === join(root, 'foo', 'bar')) - ).toHaveLength(1) - }) - - it('rejects instead of returning a partial ignored-directory expansion', async () => { - const root = await makeTempRoot() - await writeRel(root, 'dist/a.js') - await writeRel(root, 'dist/b.js') - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: ['dist/'], - budget: createQuickOpenReaddirBudget({ maxFiles: 1 }) - }) - ).rejects.toThrow('File listing exceeded') - }) - - it('supports more than 10,000 files while keeping the default safety cap', async () => { - expect(QUICK_OPEN_READDIR_MAX_FILES).toBeGreaterThan(10_000) - const root = await makeTempRoot() - await mkdirRel(root, 'dist') - opendirMock.mockResolvedValueOnce({ - async *[Symbol.asyncIterator]() { - for (let index = 0; index <= QUICK_OPEN_READDIR_MAX_FILES; index += 1) { - yield { - name: `file-${index}.ts`, - isDirectory: () => false, - isFile: () => true, - isSymbolicLink: () => false - } - } - } - }) - - // Why: directory collapse prevents generated trees from flooding the relay; - // the Git fallback must reject rather than silently return a partial list. - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: ['dist/'] - }) - ).rejects.toThrow(`File listing exceeded ${QUICK_OPEN_READDIR_MAX_FILES} files`) - }) - - it('identifies budget errors so callers can translate only those to install-rg guidance', () => { - expect(isQuickOpenReaddirBudgetError(new Error('File listing timed out'))).toBe(true) - expect(isQuickOpenReaddirBudgetError(new Error('File listing exceeded 10000 files'))).toBe(true) - // Genuine git failures must keep their own message, not the install-rg toast. - expect(isQuickOpenReaddirBudgetError(new Error('git ls-files killed by SIGTERM'))).toBe(false) - expect(isQuickOpenReaddirBudgetError('File listing timed out')).toBe(false) - }) - - it('rejects on deadline instead of returning a partial list', async () => { - const root = await makeTempRoot() - await writeRel(root, 'src/index.ts') - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ nowMs: Date.now() - 2_000, timeoutMs: 1_000 }) - }) - ).rejects.toThrow('File listing timed out') - }) - - it('returns a successful bounded prefix without consuming the safety cap', async () => { - const root = await makeTempRoot() - await writeRel(root, 'a.ts') - await writeRel(root, 'b.ts') - - const files = await listQuickOpenFilesWithReaddir(root, { maxResults: 1 }) - - expect(files).toHaveLength(1) - expect(['a.ts', 'b.ts']).toContain(files[0]) - }) - - it('does not list symlinked files or follow symlinked directories', async () => { - const root = await makeTempRoot() - await writeRel(root, 'src/index.ts') - await writeRel(root, 'target/file.ts') - - try { - await symlink(join(root, 'src/index.ts'), join(root, 'src/link.ts')) - await symlink(join(root, 'target'), join(root, 'linked-dir'), 'dir') - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EPERM') { - return - } - throw err - } - - const files = await listQuickOpenFilesWithReaddir(root) - expect(files).toEqual(expect.arrayContaining(['src/index.ts', 'target/file.ts'])) - expect(files).not.toContain('src/link.ts') - expect(files).not.toContain('linked-dir/file.ts') - }) - - it('walks an explicitly selected symlinked workspace root', async () => { - const targetRoot = await makeTempRoot() - const linkContainer = await makeTempRoot() - await writeRel(targetRoot, 'src/index.ts') - const linkedRoot = join(linkContainer, 'linked-workspace') - - try { - await symlink(targetRoot, linkedRoot, 'dir') - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EPERM') { - return - } - throw err - } - - await expect(listQuickOpenFilesWithReaddir(linkedRoot)).resolves.toEqual(['src/index.ts']) - }) - - it('fills nested repo paths containing spaces and glob metacharacters', async () => { - const root = await makeTempRoot() - await makeNestedRepo(root, 'packages/app [one] space') - await writeRel(root, 'packages/app [one] space/src/main.ts') - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: ['packages/app [one] space/'] - }) - ).resolves.toEqual(['packages/app [one] space/src/main.ts']) - }) - - it('stops the walk with a cancellation error when the signal aborts (#7721)', async () => { - const root = await makeTempRoot() - await writeRel(root, 'src/a.ts') - await writeRel(root, 'src/b.ts') - - const controller = new AbortController() - controller.abort() - - const rejection = listQuickOpenFilesWithReaddir(root, { signal: controller.signal }) - await expect(rejection).rejects.toSatisfy(isFileListingCancellation) - // Cancellation must never be mistaken for a budget error, which callers - // translate into "install rg" guidance. - await rejection.catch((err) => expect(isQuickOpenReaddirBudgetError(err)).toBe(false)) - }) - - it('stops ignored-directory expansion when the signal aborts (#7721)', async () => { - const root = await makeTempRoot() - await writeRel(root, 'src/kept.ts') - - const controller = new AbortController() - controller.abort() - - await expect( - expandQuickOpenGitFileListing({ - rootPath: root, - gitPaths: [], - directoryPaths: ['src/'], - signal: controller.signal - }) - ).rejects.toSatisfy(isFileListingCancellation) - }) - - it('closes the directory when cancellation lands after opendir', async () => { - const root = await makeTempRoot() - const controller = new AbortController() - const actual = await vi.importActual('node:fs/promises') - let closeCalls = 0 - opendirMock.mockImplementationOnce(async (...args: Parameters) => { - const directory = await actual.opendir(...args) - const close = directory.close.bind(directory) - directory.close = async () => { - closeCalls += 1 - await close() - } - controller.abort() - return directory - }) - - await expect( - listQuickOpenFilesWithReaddir(root, { signal: controller.signal }) - ).rejects.toSatisfy(isFileListingCancellation) - expect(closeCalls).toBe(1) - }) -}) diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts deleted file mode 100644 index 2fcdbb84377..00000000000 --- a/src/shared/quick-open-readdir-walk.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { join, relative } from 'node:path' -import { throwIfFileListingCancelled } from './file-listing-cancellation' -import { readQuickOpenDirectoryEntries } from './quick-open-directory-reader' -import { collapseQuickOpenExpansionPaths } from './quick-open-expansion-paths' -import { classifyQuickOpenGitEntry } from './quick-open-git-entry-classification' -import { - HIDDEN_DIR_BLOCKLIST, - shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath -} from './quick-open-filter' -import { - assertQuickOpenReaddirDepth, - assertQuickOpenReaddirDeadline, - consumeQuickOpenReaddirDirectoryBudget, - consumeQuickOpenReaddirEntryBudget, - consumeQuickOpenReaddirFileBudget, - consumeQuickOpenReaddirPathBudget, - createQuickOpenReaddirBudget, - type QuickOpenReaddirBudget -} from './quick-open-readdir-budget' - -export { - classifyQuickOpenGitEntry, - parseQuickOpenGitLsFilesEntry, - type QuickOpenGitEntryKind, - type QuickOpenGitLsFilesEntry -} from './quick-open-git-entry-classification' - -export { - createQuickOpenReaddirBudget, - isQuickOpenReaddirBudgetError, - QUICK_OPEN_READDIR_MAX_DEPTH, - QUICK_OPEN_READDIR_MAX_DIRECTORIES, - QUICK_OPEN_READDIR_MAX_ENTRIES, - QUICK_OPEN_READDIR_MAX_FILES, - QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS, - QUICK_OPEN_READDIR_TIMEOUT_MS -} from './quick-open-readdir-budget' - -const QUICK_OPEN_READDIR_CONCURRENCY = 32 - -function shouldDescend(name: string): boolean { - return name !== 'node_modules' && !HIDDEN_DIR_BLOCKLIST.has(name) -} - -function toRelPath(rootPath: string, absPath: string): string { - // Why: path.relative returns backslashes on Windows, while Quick Open paths - // are always stored and matched with POSIX separators. - return relative(rootPath, absPath).replace(/\\/g, '/') -} - -function joinRootRel(rootPath: string, relPath: string): string { - return join(rootPath, ...relPath.split('/').filter(Boolean)) -} - -function normalizeGitEntry(entry: string): string { - return entry.replace(/\/+$/, '') -} - -// Translate workspace-root-relative exclude prefixes into prefixes relative to -// one expanded subtree, so its walk prunes them during traversal. Prefixes -// outside that subtree are dropped because they cannot match. -function rebaseExcludePrefixesForSubtree( - excludePathPrefixes: readonly string[], - subtreeRelPath: string -): string[] { - const base = `${subtreeRelPath}/` - const rebased: string[] = [] - for (const prefix of excludePathPrefixes) { - if (prefix.startsWith(base)) { - rebased.push(prefix.slice(base.length)) - } - } - return rebased -} - -export async function listQuickOpenFilesWithReaddir( - rootPath: string, - opts: { - excludePathPrefixes?: readonly string[] - workspaceRelPathPrefix?: string - budget?: QuickOpenReaddirBudget - maxResults?: number - signal?: AbortSignal - } = {} -): Promise { - return listQuickOpenFilesFromRoots( - [ - { - rootPath, - excludePathPrefixes: opts.excludePathPrefixes ?? [], - workspaceRelPathPrefix: opts.workspaceRelPathPrefix, - allowRootSymlink: true - } - ], - opts.budget ?? createQuickOpenReaddirBudget(), - opts.signal, - opts.maxResults - ) -} - -type QuickOpenReaddirRoot = { - rootPath: string - excludePathPrefixes: readonly string[] - workspaceRelPathPrefix?: string - outputPathPrefix?: string - includeSymlinks?: boolean - allowRootSymlink?: boolean -} - -async function listQuickOpenFilesFromRoots( - roots: readonly QuickOpenReaddirRoot[], - budget: QuickOpenReaddirBudget, - signal?: AbortSignal, - maxResults?: number, - knownFiles?: ReadonlySet -): Promise { - const files: string[] = [] - if (maxResults !== undefined && maxResults <= 0) { - return files - } - let pendingDirectories: { - root: QuickOpenReaddirRoot - absPath: string - depth: number - isRoot: boolean - }[] = [] - for (const root of roots) { - assertQuickOpenReaddirDepth(budget, 0) - consumeQuickOpenReaddirDirectoryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, root.rootPath) - pendingDirectories.push({ root, absPath: root.rootPath, depth: 0, isRoot: true }) - } - - while (pendingDirectories.length > 0) { - const nextDirectories: typeof pendingDirectories = [] - for ( - let offset = 0; - offset < pendingDirectories.length; - offset += QUICK_OPEN_READDIR_CONCURRENCY - ) { - // Why: batch only the filesystem calls. Result processing stays serial, - // so the shared cap remains exact while shallow placeholder-heavy repos - // do not pay one relay event-loop turn per directory. - throwIfFileListingCancelled(signal) - assertQuickOpenReaddirDeadline(budget) - const batch = pendingDirectories.slice(offset, offset + QUICK_OPEN_READDIR_CONCURRENCY) - const readResults = await Promise.allSettled( - batch.map(async (pending) => { - const entries = await readQuickOpenDirectoryEntries({ - absPath: pending.absPath, - allowSymlinkedRoot: Boolean(pending.isRoot && pending.root.allowRootSymlink), - budget, - signal - }) - return { pending, entries } - }) - ) - const entryGroups: { - pending: (typeof pendingDirectories)[number] - entries: Awaited> - }[] = [] - for (const result of readResults) { - if (result.status === 'rejected') { - throw result.reason - } - entryGroups.push(result.value) - } - // Why: an empty directory has no per-entry checkpoint below. Cancellation - // or timeout that lands during opendir must still reject, never resolve []. - throwIfFileListingCancelled(signal) - assertQuickOpenReaddirDeadline(budget) - - for (const { pending, entries } of entryGroups) { - for (const entry of entries) { - throwIfFileListingCancelled(signal) - assertQuickOpenReaddirDeadline(budget) - - const name = entry.name - const absPath = join(pending.absPath, name) - const relPath = toRelPath(pending.root.rootPath, absPath) - const workspaceRelPath = pending.root.workspaceRelPathPrefix - ? `${pending.root.workspaceRelPathPrefix}/${relPath}` - : relPath - if (shouldExcludeQuickOpenRelPath(relPath, pending.root.excludePathPrefixes)) { - continue - } - if (entry.kind === 'directory') { - if (shouldDescend(name) && shouldIncludeQuickOpenPath(workspaceRelPath)) { - const depth = pending.depth + 1 - assertQuickOpenReaddirDepth(budget, depth) - consumeQuickOpenReaddirDirectoryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, absPath) - nextDirectories.push({ root: pending.root, absPath, depth, isRoot: false }) - } - continue - } - if ( - (entry.kind === 'file' || (pending.root.includeSymlinks && entry.kind === 'symlink')) && - shouldIncludeQuickOpenPath(workspaceRelPath) - ) { - const outputPath = pending.root.outputPathPrefix - ? `${pending.root.outputPathPrefix}/${relPath}` - : relPath - if (knownFiles?.has(outputPath)) { - continue - } - consumeQuickOpenReaddirFileBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, outputPath) - files.push(outputPath) - // Why: a caller result limit is a successful bounded prefix, while - // the separate traversal budget still rejects incomplete scans. - if (maxResults !== undefined && files.length >= maxResults) { - return files - } - } - } - } - } - pendingDirectories = nextDirectories - } - - return files -} - -export async function expandQuickOpenGitFileListing(opts: { - rootPath: string - gitPaths: Iterable - directoryPaths?: Iterable - excludePathPrefixes?: readonly string[] - budget?: QuickOpenReaddirBudget - maxResults?: number - signal?: AbortSignal -}): Promise { - if (opts.maxResults !== undefined && opts.maxResults <= 0) { - return [] - } - const files = new Set() - const excludePathPrefixes = opts.excludePathPrefixes ?? [] - const budget = opts.budget ?? createQuickOpenReaddirBudget() - const expansionPaths = new Map() - - const addFinalPath = (relPath: string): void => { - if (!relPath) { - return - } - if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { - return - } - if (shouldIncludeQuickOpenPath(relPath)) { - files.add(relPath) - } - } - - for (const rawPath of opts.gitPaths) { - throwIfFileListingCancelled(opts.signal) - assertQuickOpenReaddirDeadline(budget) - - const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath) - if (kind === 'keep') { - addFinalPath(relPath) - continue - } - if (kind === 'drop-placeholder') { - continue - } - - consumeQuickOpenReaddirEntryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, relPath) - expansionPaths.set(relPath, expansionPaths.get(relPath) ?? false) - } - - for (const rawPath of opts.directoryPaths ?? []) { - throwIfFileListingCancelled(opts.signal) - assertQuickOpenReaddirDeadline(budget) - - const relPath = normalizeGitEntry(rawPath) - // Why: Git intentionally leaves collapsed directories unexpanded; reject - // blocked and nested-worktree placeholders before any filesystem IO. - if ( - !relPath || - shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) || - !shouldIncludeQuickOpenPath(relPath) - ) { - continue - } - - consumeQuickOpenReaddirEntryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, relPath) - // Why: before directory collapse, Git returned untracked symlink entries - // without following them. Preserve those paths when expanding placeholders. - expansionPaths.set(relPath, true) - } - - const expandedFiles = await listQuickOpenFilesFromRoots( - collapseQuickOpenExpansionPaths(expansionPaths).map(([relPath, includeSymlinks]) => ({ - rootPath: joinRootRel(opts.rootPath, relPath), - // Why: exclude prefixes are workspace-root-relative; rebase them onto - // each expanded subtree so blocked work is pruned before consuming cap. - excludePathPrefixes: rebaseExcludePrefixesForSubtree(excludePathPrefixes, relPath), - // Why: Git can collapse `.local/share/` to `.local/`; keep workspace - // context so the walker still prunes the multi-segment blocklist. - workspaceRelPathPrefix: relPath, - outputPathPrefix: relPath, - includeSymlinks - })), - budget, - opts.signal, - opts.maxResults === undefined ? undefined : Math.max(0, opts.maxResults - files.size), - files - ) - for (const expandedFile of expandedFiles) { - addFinalPath(expandedFile) - } - - return Array.from(files).slice(0, opts.maxResults) -} diff --git a/src/shared/ssh-ai-vault-relay.ts b/src/shared/ssh-ai-vault-relay.ts deleted file mode 100644 index 4a88d7b4558..00000000000 --- a/src/shared/ssh-ai-vault-relay.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const SSH_AI_VAULT_LIST_SESSIONS_METHOD = 'aiVault.listSessions' as const -export const SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD = 'aiVault.resolveSessionTitles' as const -export const SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS = 130_000 -export const SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS = 15_000 -export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000 -export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 - -export type SshAiVaultRelayListParams = { - includeAntigravityIdeSessions?: boolean - limit?: number - unlimited?: boolean - force?: boolean - scopePaths?: string[] - scopePathsTruncated?: boolean -} - -export type SshAiVaultRelayTitleParams = { - requests: { - agent: 'claude' | 'codex' - sessionId: string - transcriptPath?: string - }[] -} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index da51ae74e72..592f0cd7beb 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -419,9 +419,7 @@ export type PortForwardEntry = { advertisedProtocol?: 'http' | 'https' } -/** A listening port detected on the remote host by the relay. - * Keep in sync with src/relay/port-scan-handler.ts — DetectedPort. - * The relay is deployed as a standalone bundle and cannot import from shared. */ +/** A listening port detected on a remote host. */ export type DetectedPort = { port: number host: string diff --git a/src/wsl-guest/dispatcher-client-lifecycle.ts b/src/wsl-guest/dispatcher-client-lifecycle.ts index 672c51763e6..e9e130571e0 100644 --- a/src/wsl-guest/dispatcher-client-lifecycle.ts +++ b/src/wsl-guest/dispatcher-client-lifecycle.ts @@ -246,7 +246,7 @@ export abstract class RelayDispatcherClientLifecycle extends RelayDispatcherClie } // Why keepaliveObserved gates this: not every client speaks the keepalive protocol. The // remote `orca` CLI sends one `orca.cli` request and waits for a result budgeted in minutes - // (src/relay/remote-cli-timeout.ts), so judging it on inbound silence would kill + // so judging it on inbound silence would kill // `terminal wait`, `--wait` and `orchestration ask` after 20s. if (!client.keepaliveObserved || now - client.lastReceivedAt <= TIMEOUT_MS) { continue diff --git a/src/wsl-guest/dispatcher-contract.ts b/src/wsl-guest/dispatcher-contract.ts index 23c62431d2c..f4557a84f92 100644 --- a/src/wsl-guest/dispatcher-contract.ts +++ b/src/wsl-guest/dispatcher-contract.ts @@ -59,7 +59,7 @@ export type RelayClient = { lastReceivedAt: number | null // Why silence is only held against a client that sends keepalives: not every client speaks that // protocol. The remote `orca` CLI opens the socket, sends one `orca.cli` request and then waits - // for a result that is deliberately budgeted in minutes (src/relay/remote-cli-timeout.ts), so + // for a result that is deliberately budgeted in minutes, so // judging it on inbound silence would kill `terminal wait`, `--wait` and `orchestration ask` // after 20s. Only a client that has proven it participates is eligible. keepaliveObserved: boolean diff --git a/src/wsl-guest/filesystem-capacity-error-wire.test.ts b/src/wsl-guest/filesystem-capacity-error-wire.test.ts deleted file mode 100644 index 91c0554de5b..00000000000 --- a/src/wsl-guest/filesystem-capacity-error-wire.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RelayDispatcher } from './dispatcher' -import { encodeJsonRpcFrame, RelayErrorCode } from './protocol' -import { GitGrepRecordCapacityError } from '../shared/git-grep-record-limit' -import { MarkdownDocumentListingCapacityError } from '../shared/markdown-document-listing-limits' -import { DirectoryListingCapacityError } from '../shared/directory-listing-budget' - -it.each([ - [new GitGrepRecordCapacityError(), RelayErrorCode.GitGrepRecordCapacity], - [new MarkdownDocumentListingCapacityError(), RelayErrorCode.MarkdownListingCapacity], - [new DirectoryListingCapacityError(), RelayErrorCode.DirectoryListingCapacity] -])( - 'preserves typed filesystem capacity errors across the existing JSON-RPC envelope', - async (failure, code) => { - vi.useFakeTimers() - const frames: Buffer[] = [] - const dispatcher = new RelayDispatcher((frame) => { - frames.push(frame) - return true - }) - try { - dispatcher.onRequest('fs.fixture', async () => { - throw failure - }) - dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 1, method: 'fs.fixture' }, 1, 0)) - await vi.advanceTimersByTimeAsync(0) - expect(frames).toHaveLength(1) - const frame = frames[0] - const response = JSON.parse(frame.subarray(13, 13 + frame.readUInt32BE(9)).toString()) - expect(response.error.code).toBe(code) - expect(response.error.message).toBe(failure.message) - } finally { - dispatcher.dispose() - vi.useRealTimers() - } - } -) diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts index f2f046ea9ff..24085ede7c4 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -12,7 +12,7 @@ const CHECKOUT_PROCESS_TIMEOUT_MS = 45_000 const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024 // Why: the wire endpoints only need the runtime RPC host, the renderer client, and -// the shared codec, plus the relay an app update leaves running. Skipping cli keeps a cold CI +// the shared codec, plus a release's relay that an app update leaves running. Skipping cli keeps a cold CI // extraction a few seconds. // The phone's `worktree ps` row reader is one self-contained file, so it rides along alone. // Workspace packages the release declares are added per release (releaseWorkspaceArchivePaths). @@ -20,13 +20,12 @@ const ARCHIVE_PATHS = [ 'src/main', 'src/shared', 'src/preload', - 'src/relay', 'src/renderer', 'src/types', 'mobile/src/worktree/agent-row-display.ts' ] -// Why optional: only releases from the move onward have it, and `git archive` refuses a missing pathspec. -const OPTIONAL_ARCHIVE_PATHS = ['src/wsl-guest'] +// Why optional: a release may have either or both, and `git archive` refuses a missing pathspec. +const OPTIONAL_ARCHIVE_PATHS = ['src/relay', 'src/wsl-guest'] const WORKSPACE_POLICY = 'pnpm-workspace.yaml' diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts index e79ea9be8ab..83614957756 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts @@ -36,7 +36,6 @@ const RELEASE_SKELETON: Record = Object.fromEntries( 'src/main/placeholder.ts', 'src/shared/placeholder.ts', 'src/preload/placeholder.ts', - 'src/relay/placeholder.ts', 'src/renderer/src/placeholder.ts', 'src/types/placeholder.ts', 'mobile/src/worktree/agent-row-display.ts' diff --git a/tests/tools/omp-relay-close-lifecycle.test.mjs b/tests/tools/omp-relay-close-lifecycle.test.mjs deleted file mode 100644 index 7adf6c191e6..00000000000 --- a/tests/tools/omp-relay-close-lifecycle.test.mjs +++ /dev/null @@ -1,150 +0,0 @@ -import { it, expect } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { - createMockDispatcher, - createTestPtyHandler -} from '../../src/relay/pty-handler-test-harness.ts' -import { - captureDescendantSnapshot, - readProcessTable -} from '../../src/main/pty-descendant-termination.ts' -import { runProcess } from '@orca/process-host' - -const binary = process.env.ORCA_OMP_PROBE_BINARY -const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) -const quote = (value) => `'${value.replaceAll("'", "'\\''")}'` - -it.skipIf(!binary || process.platform === 'win32')( - 'closes actual OMP detached tools through the relay host', - async () => { - const fixtures = join(process.cwd(), '.bench-fixtures') - mkdirSync(fixtures, { recursive: true }) - const output = mkdtempSync(join(fixtures, 'omp-relay-close-')) - const home = mkdtempSync(join(tmpdir(), 'orca-omp-relay-close-home-')) - const agentHome = join(home, 'agent') - mkdirSync(agentHome) - const config = join(home, 'probe.yml') - writeFileSync( - config, - 'startup:\n setupWizard: false\n showSplash: false\n checkUpdate: false\n' - ) - const dispatcher = createMockDispatcher() - let transcript = '' - dispatcher.notify = (method, params) => { - if (method === 'pty.data' && typeof params?.data === 'string') { - transcript = (transcript + params.data).slice(-131072) - } - } - const handler = createTestPtyHandler(dispatcher) - let snapshot - let id - try { - const spawned = await dispatcher.callRequest('pty.spawn', { - cwd: home, - cols: 120, - rows: 35, - env: { - HOME: home, - USERPROFILE: home, - ZDOTDIR: home, - ORCA_ORIG_ZDOTDIR: home, - SHELL: - process.env.ORCA_OMP_PROBE_SHELL ?? - (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'), - XDG_CONFIG_HOME: join(home, 'config'), - XDG_DATA_HOME: join(home, 'data'), - XDG_CACHE_HOME: join(home, 'cache'), - XDG_STATE_HOME: join(home, 'state'), - OMP_CODING_AGENT_DIR: agentHome, - PI_CODING_AGENT_DIR: agentHome, - OMP_PROFILE: '', - PI_PROFILE: '', - PI_CONFIG_DIR: '.omp', - PI_CONFIG_FILES: '', - ORCA_BACKGROUND_LAUNCH: '1', - ORCA_PANE_KEY: 'omp-relay-probe:owned-leaf', - ORCA_TAB_ID: 'omp-relay-probe' - }, - envToDelete: ['BASH_ENV', 'ENV', 'ORCA_OMP_STATUS_EXTENSION', 'ORCA_PI_STATUS_EXTENSION'] - }) - id = spawned.id - const [entry] = JSON.parse(await dispatcher.callRequest('pty.serialize', { ids: [id] })) - snapshot = await captureDescendantSnapshot(entry.pid) - expect(snapshot?.root?.pid).toBe(entry.pid) - dispatcher.callNotification('pty.data', { - id, - data: `${quote(binary)} --no-session --config ${quote(config)}\r` - }) - await pause(5000) - dispatcher.callNotification('pty.data', { id, data: '! /bin/sleep 120\r' }) - for (let attempt = 0; attempt < 25; attempt++) { - await pause(200) - snapshot = await captureDescendantSnapshot(entry.pid) - if (snapshot?.descendants.length > 1) { - break - } - } - expect(snapshot?.descendants.length).toBeGreaterThan(1) - const pids = [entry.pid, ...snapshot.descendants.map((row) => row.pid)] - const rows = async () => { - const result = await runProcess({ - program: 'ps', - args: ['-p', pids.join(','), '-o', 'pid=,ppid=,pgid=,stat=,comm='], - maxOutputBytes: 16000 - }) - expect(result.timedOut).toBe(false) - expect(result.signal).toBeNull() - expect(result.stderr.trim()).toBe('') - expect([0, 1]).toContain(result.code) - if (result.code === 1) { - expect(result.stdout.trim()).toBe('') - } - return result.stdout.trim() - } - const before = await rows() - expect(before).toContain('omp') - expect(before).toContain('sleep') - await dispatcher.callRequest('pty.shutdown', { - id, - immediate: true, - expectedIncarnationId: spawned.incarnationId - }) - await pause(6000) - const after = await rows() - writeFileSync( - join(output, 'report.json'), - JSON.stringify({ backend: 'relay-host', before, after, pid: entry.pid, id, home }, null, 2) - ) - writeFileSync(join(output, 'transcript.txt'), transcript) - console.log(output) - expect(after).toBe('') - } finally { - if (snapshot) { - const current = await readProcessTable() - const owned = [ - ...snapshot.descendants, - ...(snapshot.root ? [{ ...snapshot.root, pgid: snapshot.rootPgid }] : []) - ] - for (const row of current.rows) { - if ( - owned.some( - (known) => - known.pid === row.pid && - known.startedAt === row.startedAt && - known.pgid === row.pgid - ) - ) { - try { - process.kill(row.pid, 'SIGKILL') - } catch {} - } - } - } - await handler.dispose({ waitForPhysicalExit: false }) - rmSync(home, { recursive: true, force: true }) - } - }, - 45000 -) diff --git a/tsconfig.json b/tsconfig.json index 7b03f1a99f7..52fad07c765 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,7 @@ "files": [], "references": [ { "path": "./config/tsconfig.node.json" }, - { "path": "./config/tsconfig.web.json" }, - { "path": "./config/tsconfig.relay.json" } + { "path": "./config/tsconfig.web.json" } ], "compilerOptions": { "baseUrl": ".",