mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 19 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$962 | $\color{#cf222e}{\Huge{\mathbf{−}}}$136 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$826 |
| Prod | 18 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$295 | $\color{#cf222e}{\Huge{\mathbf{−}}}$116 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$179 |
<!-- /orca-pr-loc -->
## Symptom
Live 2026-09-05 (Orca 1.4.198 client, Ubuntu host): both relay processes `kill -STOP`ped for 20 s, then `-CONT`. The client redeployed while the host was frozen. Its fresh daemon lost the socket bind (`Socket path already in use`) but had **already rewritten** `relay-<id>.sock.credential`. The surviving daemon kept its in-memory credential, so every later `--connect` got `Endpoint credential mismatch; closing socket`, then `Grace started … timeoutMs=0 … ptys=1, clients=0` every ~20 s, forever. Only a manual `kill -TERM` cleared it. Receipts: `review-archive/orchestration-v3-pr16904/smoke-receipts-t012b/E16,E17,E18,E24`.
Three independent defects kept the wedge alive; each is fixed at its own seam.
## Fix
**1. The relay daemon owns credential publication (race-free under two concurrent starters).**
`relay-daemon.ts` binds the socket first, then publishes via the new `src/relay/relay-endpoint-credential-publication.ts`: adopt a valid pre-existing file (older clients still pre-write), else mint 32 random bytes and write temp+rename at 0600. A start that loses the bind exits inside `listen()` and never reaches the file. Why this option and not restore-on-loss or a client-side write: the only process that can *prove* ownership is the one whose `listen()` succeeded, and that proof is atomic with the bind. The client-side pre-write (`ssh-relay-endpoint-credential.ts`) and the launch-command `chmod 600`/`icacls` are removed on POSIX and Windows. The racing test also exposed that macOS reports a mid-bind collision as `EEXIST` rather than `EADDRINUSE`; `relay-socket-ownership.ts` now treats both as "held or stale".
**2. The client distinguishes "no daemon" from "daemon present but not answering", and never rewrites.**
A credential refusal is now typed on the wire: the daemon replies `orca-relay-handshake-credential-mismatch` (same frame type, no new opcode) and the bridge exits **43**; `waitForSentinel` maps it to `RelayCredentialMismatchError`, which the takeover treats as handshake-refusal evidence exactly like exit 42. A relay that holds the endpoint but **never refused** (the stalled-host shape: kernel backlog accepts the probe, handshake gets no answer) is now `RelayEndpointUnresponsiveError`, routed to the relay-lost backoff instead of the terminal Reset Relay path. Silence is not a decision (`docs/reference/ssh-execution-boundary.md`).
**2b. Deploy honours the verdict.** The 40 s live run exposed that the `--connect` catch block in `deployAndLaunchRelay` predates the incumbent probe and swallowed both verdicts as "probe failed, launch fresh", so a fresh daemon was still launched over the live one (it lost the bind by luck, which is exactly the collision in the incident). Held and Unresponsive now propagate; the session backs off on Unresponsive and surfaces Reset Relay on Held. Red-first in `ssh-relay-deploy-incumbent-verdict.test.ts`.
**3. The daemon cannot be wedged by a rotated file, because nothing can rotate it.**
The credential lives in the content-hashed relay dir, and after (1) the only writer is the daemon that owns the socket, so the "file changed under a live daemon" state the incident depended on is no longer reachable in-product. The credential is therefore fixed for the daemon's lifetime, as a plain secret should be. A hand-edited file is refused with the typed reply until restored (tested). Startup adoption of a pre-written file applies an owner-only + same-uid rule (review finding): anything else is replaced by a fresh mint. An earlier revision of this PR also re-read the file on mismatch and adopted it; that was removed as unreachable machinery that turned the credential into a per-handshake file-ownership check.
**3b. Fail closed between bind and publication.** A client that arrives after `listen()` resolves but before the credential is set is refused, not admitted as `unproved`. Nothing can be delivered in that window today; the guard makes the boundary structural instead of an event-loop ordering fact. Red-first in `relay-reconnect-listener-credential-gate.test.ts`.
**Wire compat.** New optional handshake reply only; an old `--connect` hits `Unknown handshake type` and exits 1 pre-sentinel, which it already treated as a generic failure. New daemon adopts an old client's pre-written file; new client still passes `--credential-file` so an old daemon reads it as before. Absence of exit 43 is never used as evidence.
**Also.** `terminal create` on a reconnecting SSH host now says what to do instead of a bare `No PTY provider for connection "<id>"` (prefix preserved; the renderer matches it).
## Tests (red first)
- `src/relay/subprocess.test.ts`: two `--detached` starts race one socket + credential file → exactly one reaches the sentinel, loser exits 1 with `Socket path already in use`, file valid + 0600, a `--connect` reading it reaches `relay.status` and reports the winner's pid. Red before (both starters died: daemon required a pre-existing file), green 6/6 after.
- `src/relay/relay-endpoint-credential-publication.test.ts`: mints after bind; adopts a pre-written 0600 file; replaces a pre-written 0644 file with a fresh mint; refuses a stale credential with exit 43 while still serving the real one, and keeps refusing a rewritten file until it is restored.
- `src/relay/relay-reconnect-listener-credential-gate.test.ts`: a client in the bind-to-publish window is refused and never attached; after publication the right credential is accepted and a wrong one refused; a daemon launched without a credential file is not gated. Red without the guard.
- `ssh-relay-deploy-incumbent-verdict.test.ts`: live-but-silent incumbent → `RelayEndpointUnresponsiveError`, refused → `RelayEndpointHeldError`, and in neither case is `--detached` launched; a failed `test -S` probe still launches fresh. Red 2/3 without the deploy change.
- `ssh-relay-deploy-helpers.test.ts` (exit 43), `ssh-relay-endpoint-takeover.test.ts` (refused → Held even with no `lsof`; silent → Unresponsive, nothing unlinked or signalled), `ssh-relay-session-terminal-error.test.ts` (Unresponsive → `onRelayLost`, not terminal). Deploy/namespace/native-deps tests updated to assert the client writes **no** credential.
## Live proof
New `tests/e2e/ssh-docker-relay-stall-credential.spec.ts` (claimed in `run-ssh-docker-e2e.mjs` and PR source routing), two cases: `kill -STOP` every relay pid in the container, send input during the freeze, hold **20 s** (the incident's duration, which races the mux liveness timeout) or **40 s** (past it for sure), `kill -CONT`; assert status back to `connected`, same pty, same daemon pid, same credential inode and content, relay.log did not shrink (a relaunch truncates it) and has zero `Endpoint credential mismatch` / `Socket path already in use` lines, in-stall input delivered at most once.
Run output (local, fixture image `orca-e2e-ssh-relay:3a864c665ba2cefd`, `ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 … --project electron-headless --workers=1`, head `c2c20fd994`; re-run identically on the final head after the credential-lifetime change, 2 passed (1.7m), same annotations, and the bind-to-publish refusal never fired):
```
✓ keeps the same daemon and credential across a 20s relay freeze (38.3s)
relay-processes-stopped: 2 relay-processes-continued: 2
bridge-pids-before-after: 480 -> 480
socket-clients-accepted-before-after: 1 -> 1
in-stall-input-delivered: 1
✓ backs off and reattaches, never relaunching, across a 40s relay freeze (57.5s)
relay-processes-stopped: 2 relay-processes-continued: 4
bridge-pids-before-after: 480 -> 1202
socket-clients-accepted-before-after: 1 -> 3
in-stall-input-delivered: 1
2 passed (1.6m)
```
Client log in the 40 s case shows the new path end to end: `Relay channel lost … reconnect attempt 1/6` → `Socket probe result: "ALIVE"` → `Socket reconnect failed … Relay failed to start within 10s` → `Relay endpoint incumbent: … verdict=live evidence=accepted-connection holders=unenumerable` → `Failed to re-establish relay … A relay still owns … but did not answer the handshake … Orca will retry` → `reconnect attempt 2/6` → `Reconnected to existing relay via socket`. The 20 s case never left the frozen bridge (same bridge pid, one accept), so it exercises the "silence is not death" side of the same race. The 20 s case passed 6/6 across the session; the 40 s case was red on the prior head (`Socket path already in use` + `Startup failed: listen EADDRINUSE` in relay.log from the swallowed verdict) and is green after 2b. Before the fix the same injection produced a fresh daemon that rewrote the credential and a survivor refusing every client.
The `relay-processes-continued` count exceeds `stopped` in the 40 s case because the timed-out client's `--connect` bridge and the loser-side processes are parked behind the frozen listener when `CONT` runs; they exit on their own once it resumes.
## Gates
`pnpm test src/relay src/main/ssh` 332 files / 3884 tests pass · `pnpm typecheck:tsc:node` clean · `check:code-quality:changed` 0 findings · `check:react-doctor:changed` 0 findings · `pr-e2e-gate-contract.test.mjs` 42 pass · no lint disables or max-lines bumps added.
## Noted, not fixed here
- `terminal list` `orphaned:false` / `terminal close` `ptyKilled:true` for a pane whose relay is gone (`orca-runtime-stop-explicitly-closed-tab-ptys.ts`): different seam, `@ts-nocheck` characterization-covered file.
- On a host with no `lsof`, a stalled relay still cannot be enumerated as the holder; it is now retried rather than declared held, but a relay frozen past the backoff budget still ends in the existing "reconnect manually" banner.
944 lines
34 KiB
TypeScript
944 lines
34 KiB
TypeScript
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 { 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'],
|
|
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<typeof spawnChild>,
|
|
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('prints sentinel on startup', async () => {
|
|
relay = spawn()
|
|
await relay.sentinelReceived
|
|
}, 10_000)
|
|
|
|
it('keeps the Node-18 relay bundle free of unsupported array copy methods', () => {
|
|
expect(readFileSync(relayEntry, 'utf8')).not.toContain('.toReversed(')
|
|
})
|
|
|
|
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.stat over stdin/stdout', async () => {
|
|
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-'))
|
|
writeFileSync(path.join(tmpDir, 'test.txt'), 'hello')
|
|
|
|
relay = spawn()
|
|
await relay.sentinelReceived
|
|
|
|
const id = relay.send('fs.stat', { filePath: path.join(tmpDir, 'test.txt') })
|
|
const resp = await relay.waitForResponse(id)
|
|
|
|
expect(resp.result).toBeDefined()
|
|
const result = resp.result as { size: number; type: string }
|
|
expect(result.type).toBe('file')
|
|
expect(result.size).toBe(5)
|
|
}, 10_000)
|
|
|
|
it('responds to fs.readDir', async () => {
|
|
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-'))
|
|
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
|
|
writeFileSync(path.join(tmpDir, 'b.txt'), 'b')
|
|
|
|
relay = spawn()
|
|
await relay.sentinelReceived
|
|
|
|
const id = relay.send('fs.readDir', { dirPath: tmpDir })
|
|
const resp = await relay.waitForResponse(id)
|
|
|
|
const entries = resp.result as { name: string }[]
|
|
const names = entries.map((e) => e.name).sort()
|
|
expect(names).toEqual(['a.txt', 'b.txt'])
|
|
}, 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<void>
|
|
): Promise<void> {
|
|
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)
|
|
})
|