Files
orca/tests/e2e/ssh-docker-relay-stall-credential.spec.ts
Jinwoo Hong 0c33f58e8a fix(ssh-relay): daemon owns the endpoint credential; a losing start never rotates it (#19052)
<!-- 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.
2026-09-06 14:39:25 -04:00

246 lines
10 KiB
TypeScript

import type { Page } from '@playwright/test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import { getTerminalContent } from './helpers/terminal-pane-identity'
import {
cleanupDockerSshRelayTarget,
enableDockerSshRelayTargetShellTitle,
execDockerSshRelayTargetControlCommand,
startDockerSshRelayTarget,
type DockerSshRelayTarget
} from './helpers/docker-ssh-relay-target'
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
import {
clearDockerSshRelayFaults,
continueDockerSshRelayProcesses,
stopDockerSshRelayProcesses
} from './helpers/docker-ssh-relay-faults'
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
// Why two durations: the live incident held both relay pids for 20 s, which is exactly the client
// mux liveness timeout, so which side of it the client lands on is a race. 40 s is past it for
// sure: the client declares the link lost, probes the frozen daemon, and must back off rather
// than launch over it. Both must leave the daemon and its credential untouched.
const STALL_CASES = [
{ stallMs: 20_000, title: 'keeps the same daemon and credential across a 20s relay freeze' },
{
stallMs: 40_000,
title: 'backs off and reattaches, never relaunching, across a 40s relay freeze'
}
]
type RelayEndpointSnapshot = {
daemonPid: string
bridgePids: string
credentialInode: string
credential: string
logLines: number
}
async function readSshStatus(orcaPage: Page, targetId: string): Promise<string | null> {
return orcaPage.evaluate(
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId)?.status ?? null,
targetId
)
}
/**
* Everything the wedge changed, read from the host: the daemon that owns the socket, the
* credential file's identity and content, and how far the relay log had got. Read through the
* control shell (no login profile) so the numbers are the host's, not a shell banner's.
*/
function snapshotRelayEndpoint(target: DockerSshRelayTarget): RelayEndpointSnapshot {
const output = execDockerSshRelayTargetControlCommand(
target,
`
sock=$(find /root/.orca-remote -maxdepth 2 -name 'relay-*.sock' -type s | head -n 1)
[ -n "$sock" ] || { echo NO_SOCKET; exit 0; }
daemon=""
bridges=""
for proc in /proc/[0-9]*; do
[ -r "$proc/cmdline" ] || continue
argv=()
mapfile -d '' -t argv < "$proc/cmdline" 2>/dev/null || continue
[ "\${argv[1]##*/}" = relay.js ] || continue
case " \${argv[*]} " in
*" --detached "*) daemon="\${proc##*/}" ;;
*" --connect "*) bridges="$bridges \${proc##*/}" ;;
esac
done
echo "DAEMON=$daemon"
echo "BRIDGES=$bridges"
echo "INODE=$(stat -c %i "$sock.credential")"
echo "CREDENTIAL=$(cat "$sock.credential")"
echo "LOGLINES=$(wc -l < "$(dirname "$sock")/relay.log")"
`
)
const field = (name: string): string =>
output
.split('\n')
.find((line) => line.startsWith(`${name}=`))
?.slice(name.length + 1)
.trim() ?? ''
const snapshot = {
daemonPid: field('DAEMON'),
bridgePids: field('BRIDGES'),
credentialInode: field('INODE'),
credential: field('CREDENTIAL'),
logLines: Number(field('LOGLINES'))
}
if (
!snapshot.daemonPid ||
!snapshot.credentialInode ||
!snapshot.credential ||
!Number.isInteger(snapshot.logLines)
) {
throw new Error(`Could not snapshot the relay endpoint on ${target.containerName}: ${output}`)
}
return snapshot
}
function readRelayLog(target: DockerSshRelayTarget): string {
return execDockerSshRelayTargetControlCommand(
target,
`cat "$(dirname "$(find /root/.orca-remote -maxdepth 2 -name 'relay-*.sock' -type s | head -n 1)")/relay.log"`
)
}
/**
* The live incident (Orca 1.4.198, 2026-09-05): both relay processes SIGSTOPped for 20 s, then
* continued. The client redeployed while the host was frozen, its fresh daemon lost the bind but
* had already rewritten the endpoint credential, and the surviving daemon then refused every
* client forever — "Endpoint credential mismatch" every ~20 s with a PTY and zero clients, until
* someone sent it SIGTERM by hand.
*
* Three things must hold after the same injection here. The credential file is byte-for-byte
* and inode-for-inode what it was, because only a daemon that owns the socket may write it. The
* same daemon still owns the socket, because a relay that merely went quiet is `live`, not
* `exited`, and is never replaced (docs/reference/ssh-execution-boundary.md). And the relay log
* has no mismatch line at all, because the wedge is gone rather than healed after the fact.
*/
test.describe('SSH relay stall does not rotate the endpoint credential', () => {
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run the dockerized SSH relay tests')
for (const { stallMs, title } of STALL_CASES) {
test(title, async ({ orcaPage }, testInfo) => {
test.slow()
let target: DockerSshRelayTarget | null = null
try {
target = startDockerSshRelayTarget(testInfo)
enableDockerSshRelayTargetShellTitle(target)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const remote = await connectDockerSshRelayTarget(orcaPage, target)
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
const runId = Date.now()
await execInTerminal(orcaPage, ptyId, `printf 'STALL_BEFORE_%s\\n' ${runId}`)
await waitForTerminalOutput(orcaPage, `STALL_BEFORE_${runId}`, 30_000)
const before = snapshotRelayEndpoint(target)
const stopped = stopDockerSshRelayProcesses(target)
expect(stopped, 'no relay process was found to freeze').toBeGreaterThan(0)
testInfo.annotations.push({ type: 'relay-processes-stopped', description: String(stopped) })
testInfo.annotations.push({ type: 'stall-ms', description: String(stallMs) })
// Sent into the freeze, like the orchestration send that was in flight in the incident.
// The oracle below is that it is delivered at most once; whether it is delivered at all
// depends on which side of the liveness timeout the mux disposes, which this spec does not
// pin — the brief's exactly-once guarantee lives at the mailbox, not the PTY byte stream.
await execInTerminal(orcaPage, ptyId, `printf 'STALL_DURING_%s\\n' ${runId}`)
await orcaPage.waitForTimeout(stallMs)
// More than `stopped` is legitimate: a client that timed out during the freeze may have
// launched a bridge and a would-be daemon that are now parked behind the frozen listener.
const continued = continueDockerSshRelayProcesses(target)
testInfo.annotations.push({
type: 'relay-processes-continued',
description: String(continued)
})
expect(continued).toBeGreaterThanOrEqual(stopped)
await expect
.poll(() => readSshStatus(orcaPage, remote.targetId), {
timeout: 120_000,
message: 'SSH target never returned to connected after the relay was continued'
})
.toBe('connected')
await waitForActiveTerminalManager(orcaPage, 60_000)
// Same pty: the session was live the whole time, so nothing may have replaced it.
await expect
.poll(() => waitForActivePanePtyId(orcaPage, 60_000), { timeout: 60_000 })
.toBe(ptyId)
await execInTerminal(orcaPage, ptyId, `printf 'STALL_AFTER_%s\\n' ${runId}`)
await waitForTerminalOutput(orcaPage, `STALL_AFTER_${runId}`, 60_000)
const after = snapshotRelayEndpoint(target)
// Whether the client went through the redeploy path (new bridge) or the frozen bridge simply
// resumed depends on the mux liveness race; both must leave the daemon and credential alone.
testInfo.annotations.push({
type: 'bridge-pids-before-after',
description: `${before.bridgePids} -> ${after.bridgePids}`
})
expect(after.daemonPid, 'a second daemon replaced the frozen one').toBe(before.daemonPid)
expect(after.credential, 'the endpoint credential was rotated').toBe(before.credential)
expect(after.credentialInode, 'the endpoint credential file was rewritten').toBe(
before.credentialInode
)
// Why the whole log and a non-shrinking line count: a fresh launch truncates relay.log
// (`> relay.log 2>&1`), so a "no new lines" delta could also mean "a second daemon was
// launched and wiped the evidence". The count proves the file is the same one.
const relayLog = readRelayLog(target)
const logLines = relayLog.split('\n')
testInfo.annotations.push({
type: 'relay-log-tail',
description: logLines.slice(-40).join('\n')
})
expect(
after.logLines,
'relay.log shrank: a fresh launch truncated it'
).toBeGreaterThanOrEqual(before.logLines)
expect(relayLog).not.toContain('Endpoint credential mismatch')
expect(relayLog).not.toContain('Socket path already in use')
// The daemon must have served a client after the freeze — this is the reattach, not a
// vacuous pass on a relay nobody talked to.
const acceptsBefore = logLines
.slice(0, before.logLines)
.filter((line) => line.includes('Socket client accepted')).length
const acceptsAfter = logLines.filter((line) =>
line.includes('Socket client accepted')
).length
testInfo.annotations.push({
type: 'socket-clients-accepted-before-after',
description: `${acceptsBefore} -> ${acceptsAfter}`
})
const content = await getTerminalContent(orcaPage, 20_000)
const duringCount = content.split(`STALL_DURING_${runId}`).length - 1
testInfo.annotations.push({
type: 'in-stall-input-delivered',
description: String(duringCount)
})
// The echo of the typed command counts once; the printf output counts once more.
expect(
duringCount,
'input sent during the stall was delivered more than once'
).toBeLessThanOrEqual(2)
} finally {
if (target) {
clearDockerSshRelayFaults(target)
cleanupDockerSshRelayTarget(target)
}
}
})
}
})