From 24706ccff0f20ee0b28785096c81aedca83e68ce Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 27 Jul 2026 15:22:55 -0700 Subject: [PATCH] fix(terminals): negotiate explicit close intent for paired runtimes (#10129) --- config/reliability-gates.jsonc | 131 +++++- src/cli/runtime/websocket-transport.test.ts | 22 +- ...ime-request-connection.integration.test.ts | 68 ++- src/main/runtime/rpc/core.ts | 3 + src/main/runtime/rpc/dispatcher.ts | 4 + src/main/runtime/rpc/e2ee-channel-v2.test.ts | 5 +- src/main/runtime/rpc/e2ee-channel.test.ts | 30 ++ src/main/runtime/rpc/e2ee-channel.ts | 4 + .../rpc/methods/session-tab-close-methods.ts | 20 +- .../runtime/rpc/methods/session-tabs.test.ts | 58 ++- .../rpc/mobile-e2ee-auth-validation.ts | 8 +- .../runtime/rpc/mobile-socket-wiring.test.ts | 22 +- src/main/runtime/rpc/mobile-socket-wiring.ts | 12 +- .../rpc/runtime-client-capabilities.test.ts | 22 + .../rpc/runtime-client-capabilities.ts | 12 + src/main/runtime/runtime-rpc.ts | 1 + .../src/web/web-runtime-client.test.ts | 34 ++ src/renderer/src/web/web-runtime-client.ts | 7 +- src/shared/protocol-version.ts | 2 + src/shared/remote-runtime-client.test.ts | 14 +- src/shared/remote-runtime-client.ts | 7 +- .../remote-runtime-request-connection.test.ts | 16 +- .../remote-runtime-request-connection.ts | 4 +- ...-runtime-shared-control-connection.test.ts | 10 + ...te-runtime-shared-control-frame-handler.ts | 7 +- ...mon-generation-legacy-close-safety.spec.ts | 431 ++++++++++++++++++ .../daemon-generation-legacy-close-client.ts | 308 +++++++++++++ .../daemon-generation-runtime-fixture.ts | 7 + .../daemon-generation-safety-fixtures.ts | 7 +- 29 files changed, 1231 insertions(+), 45 deletions(-) create mode 100644 src/main/runtime/rpc/runtime-client-capabilities.test.ts create mode 100644 src/main/runtime/rpc/runtime-client-capabilities.ts create mode 100644 tests/e2e/daemon-generation-legacy-close-safety.spec.ts create mode 100644 tests/e2e/fixtures/daemon-generation-legacy-close-client.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 41f32e1e2..ba6ecb6cf 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1132,47 +1132,77 @@ }, { "id": "terminal-session.daemon-generation-reconnect-safety", - "title": "Reconnect lifecycle echoes cannot kill live daemon-generation terminals", + "title": "Negotiated close intent protects live daemon-generation terminals", "maturity": "experimental", "protection": "partial", "owner": "terminal-runtime", - "layer": "renderer-runtime-rpc-windows-daemon-contract", + "layer": "renderer-runtime-rpc-daemon-contract", "surfaces": [ "runtime session reconnect", "legacy daemon adoption", + "mixed-version paired viewer close", "terminal lifecycle close", "app relaunch and profile reconnect" ], "platforms": ["macos", "linux", "windows"], "providers": ["daemon", "runtime", "ssh", "wsl"], - "coveredPlatforms": ["windows"], + "coveredPlatforms": ["linux", "macos", "windows"], "coveredProviders": ["daemon", "runtime"], - "coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The current harness additionally includes the v26 agent-authority boundary while retaining v24 clean-disconnect and v25 startup-ingress coverage; that six-generation Windows rerun remains to be collected. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, authenticated legacy and unattributed intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.", + "coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The deterministic daemon harness covers capable and legacy paired-runtime request shapes against live v25/v26 PTYs in separate worktrees, plus an unrelated control. Host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, explicit user intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. A headed host paired to a separate live client, headless serve parity, Linux, SSH, and WSL remain explicit gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/9749", + "https://github.com/stablyai/orca/issues/9949", "https://github.com/stablyai/orca/issues/8871", "https://github.com/stablyai/orca/issues/9138", "https://github.com/stablyai/orca/issues/9229" ], - "invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.", - "oracle": "Start six isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v25 from a v26 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, authenticated legacy and explicit user closes to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.", + "invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.", + "oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", - "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/remote-runtime-request-connection.integration.test.ts", "src/main/runtime/rpc/methods/session-tabs.test.ts", "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts", + "src/main/runtime/rpc/e2ee-channel.test.ts", + "src/main/runtime/rpc/e2ee-channel-v2.test.ts", + "src/main/runtime/rpc/mobile-socket-wiring.test.ts", + "src/main/runtime/rpc/runtime-client-capabilities.test.ts", + "src/shared/remote-runtime-client.test.ts", + "src/shared/remote-runtime-request-connection.test.ts", + "src/shared/remote-runtime-shared-control-connection.test.ts", + "src/cli/runtime/websocket-transport.test.ts", + "src/renderer/src/web/web-runtime-client.test.ts", "src/renderer/src/runtime/web-runtime-session.test.ts", "src/renderer/src/runtime/web-session-close-intent.test.ts", "src/renderer/src/runtime/web-session-tabs-sync.test.ts", "src/renderer/src/components/terminal/terminal-tab-actions.test.ts", "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts", "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", - "tests/e2e/daemon-generation-reconnect-safety.spec.ts" + "tests/e2e/daemon-generation-reconnect-safety.spec.ts", + "tests/e2e/daemon-generation-legacy-close-safety.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts", + "assertions": [ + "the real encrypted WebSocket handshake binds close-intent capability through authenticated socket state and RPC context to reasonless-close refusal" + ] + }, + { + "file": "tests/e2e/daemon-generation-legacy-close-safety.spec.ts", + "assertions": [ + "one identified capable viewer and one legacy viewer issue byte-identical sequential reasonless closes while a third viewer lists every target before, between, and after but issues zero closes", + "capable v25/v26 PTY root/descendant incarnations in separate worktrees survive, answer post-close input, and produce zero shutdown calls and zero daemon session-killed events", + "legacy v25/v26 PTY root/descendant incarnations die through ordered immediate shutdown calls with one daemon session-killed event each, matching current-main behavior", + "an unaddressed fifth-worktree PTY root and descendant survive with zero kill events, excluding global fanout", + "the JSON reconstruction records request order, negotiated capabilities, viewer connection, worktree/tab/PTY ids, daemon PID/protocol, call site, and exact before/after process liveness" + ] + }, { "file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts", "assertions": [ @@ -1181,12 +1211,71 @@ "shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree" ] }, + { + "file": "src/main/runtime/rpc/mobile-socket-wiring.test.ts", + "assertions": [ + "the optional client capability is captured from legacy encrypted authentication and bound to the authenticated runtime-scoped socket identity" + ] + }, + { + "file": "src/main/runtime/rpc/e2ee-channel.test.ts", + "assertions": [ + "runtime capabilities are accepted only from encrypted authentication metadata, not the unauthenticated hello" + ] + }, + { + "file": "src/main/runtime/rpc/e2ee-channel-v2.test.ts", + "assertions": [ + "mobile E2EE v2 continues to reject additive runtime capability metadata" + ] + }, + { + "file": "src/main/runtime/rpc/runtime-client-capabilities.test.ts", + "assertions": [ + "the authenticated capability parser accepts only bounded string arrays and rejects malformed or oversized input" + ] + }, + { + "file": "src/shared/remote-runtime-client.test.ts", + "assertions": [ + "one-shot and subscription runtime clients remain compatible while sending encrypted client authentication" + ] + }, + { + "file": "src/shared/remote-runtime-request-connection.test.ts", + "assertions": [ + "the cached paired-desktop request connection advertises close-intent support in encrypted authentication while reusing one socket" + ] + }, + { + "file": "src/shared/remote-runtime-shared-control-connection.test.ts", + "assertions": [ + "the reconnecting shared-control client advertises close-intent support in encrypted authentication" + ] + }, + { + "file": "src/cli/runtime/websocket-transport.test.ts", + "assertions": [ + "updated paired runtime clients advertise close-intent support in encrypted auth fields ignored by legacy servers" + ] + }, + { + "file": "src/renderer/src/web/web-runtime-client.test.ts", + "assertions": [ + "the browser paired-runtime client advertises close-intent support inside encrypted authentication" + ] + }, { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ "live, unknown, stale, missing-intent, non-owner, and inventory-proven but not yet pane-bound lifecycle closes invoke neither PTY kill nor renderer close", - "dead whole-headless retirement removes stale state without signalling a retained PTY id", - "explicit and authenticated legacy user closes remain destructive" + "dead whole-headless retirement removes stale state without signalling a retained PTY id" + ] + }, + { + "file": "src/main/runtime/rpc/methods/session-tabs.test.ts", + "assertions": [ + "in-process and capable-runtime reasonless closes refuse while legacy runtime/mobile and explicit current user closes retain current-main destructive semantics" ] }, { @@ -1210,6 +1299,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-07-27", + "runner": "ci", + "platform": "linux", + "command": "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 5.1, + "summary": "Current-head capability-gated oracle passed in E2E run https://github.com/stablyai/orca/actions/runs/30250731941/job/89928386794: capable v25/v26 roots and descendants survived with snapshot republish and post-close I/O, byte-identical legacy closes retained current-main shutdown behavior, and the unrelated fifth-worktree control survived." + }, { "date": "2026-07-21", "runner": "local", @@ -1222,7 +1320,7 @@ ], "runtimeBudget": { "p95Seconds": 180, - "scope": "isolated native-Windows five-generation production discovery/reconnect/relaunch plus bounded disposal failure and fresh E2E build" + "scope": "isolated native-Windows generation reconnect plus two-generation mixed-version close adjudication and fresh E2E build" }, "flakeHistory": { "status": "unknown", @@ -1230,11 +1328,11 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The pre-fix native run kept daemon/client processes alive but emitted repeated real session-killed events in v21/v22/v23 and terminated every stale-mirror root and descendant. The fixed production-scanner run preserves every exact v21/v22/v23/v24/v25 process incarnation with zero kill events." + "evidence": "Current main and PR #10013 route both reasonless viewer sequences to immediate shutdown, while the prior global-refusal candidate incorrectly preserves the legacy sequence. The capability-gated candidate passed the combined GitHub oracle: capable v25/v26 PTYs survive and answer input, legacy PTYs retain current-main shutdown behavior, and the unrelated control survives. Existing Windows red/green evidence separately covers evidence-bearing lifecycle closes." }, "performanceBudget": { "required": true, - "evidence": "Production adds no polling, subprocess, PowerShell/CIM, or process-per-session work. Concurrent lifecycle closes share one bounded host controller inventory, scan only addressed parent leaves, and reuse the environment/worktree client snapshot deduper. Test-only Windows inventory is capped at 5 seconds and 8 MiB per query; all waits and cleanup are bounded." + "evidence": "Production adds one bounded client-capability parse during the authenticated connection handshake and one constant-time membership branch per close. It adds no polling, subprocess, provider listing, retry, timer, or process-per-session work. Refusal reuses the existing single-worktree snapshot republish. Test-only inventory and cleanup are bounded." }, "promotionCriteria": [ "Collect 100 clean native-Windows runs over 14 days with zero unexplained flakes.", @@ -1242,8 +1340,9 @@ "Add live Linux SSH-relay and Windows WSL reconnect artifacts without weakening keep-on-unknown." ], "knownGaps": [ - "The strongest proof is Electron-as-Node over native Windows, not a packaged UI-driven update journey.", - "Live macOS/Linux adoption and Linux SSH relay are not exercised because Docker is unavailable; Windows WSL reconnect is not exercised because WSL is not installed.", + "The strongest proof is Electron-as-Node over real daemon PTYs, not a packaged headed Orca host paired to a separate old client; that is the primary live E2E still required.", + "Headless orca serve parity, live Linux, Linux SSH relay, and Windows WSL reconnect are not exercised; Docker SSH would cover only the SSH provider and cannot substitute for paired Orca-server evidence.", + "A topology containing any pre-contract paired desktop viewer remains vulnerable to that viewer's stale reasonless close storm; preserving its intentional-close behavior makes this unavoidable until the viewer upgrades.", "Cross-profile daemon inventory and generation handoff/retirement remain the separate #9138/#9229 design.", "A dead split leaf stays with its authoritative owner rather than being remotely pruned." ], diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index d69ce71ba..306bbe0e3 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -18,7 +18,8 @@ import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - RUNTIME_PROTOCOL_VERSION + RUNTIME_PROTOCOL_VERSION, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' vi.mock('./launch', () => ({ @@ -29,6 +30,7 @@ type TestRuntime = { endpoint: string publicKeyB64: string deviceToken: string + authFrames: Record[] close: () => Promise } @@ -55,6 +57,11 @@ describe('CLI remote WebSocket transport', () => { expect(response.ok).toBe(true) expect(response.result.runtimeId).toBe('runtime-ws-1') + expect(runtime.authFrames).toContainEqual( + expect.objectContaining({ + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) + ) }) it('rejects malformed remote pairing codes before local runtime lookup', () => { @@ -206,6 +213,7 @@ async function startTestRuntime( const deviceToken = `token-${runtimeId}` const httpServer = createServer() const wss = new WebSocketServer({ server: httpServer }) + const authFrames: Record[] = [] wss.on('connection', (ws) => { let sharedKey: Uint8Array | null = null @@ -214,7 +222,10 @@ async function startTestRuntime( ws.on('message', (data) => { const frame = data.toString() if (!sharedKey) { - const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string } + const hello = JSON.parse(frame) as Record & { + type?: string + publicKeyB64?: string + } const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64') sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) ws.send(JSON.stringify({ type: 'e2ee_ready' })) @@ -227,7 +238,11 @@ async function startTestRuntime( return } if (!authenticated) { - const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string } + const auth = JSON.parse(plaintext) as Record & { + type?: string + deviceToken?: string + } + authFrames.push(auth) if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) { ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey)) ws.close(4001, 'auth failed') @@ -283,6 +298,7 @@ async function startTestRuntime( endpoint: `ws://127.0.0.1:${address.port}`, publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey), deviceToken, + authFrames, close: async () => { await new Promise((resolve) => { wss.close(() => resolve()) diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index cb494b2f1..98c295c25 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getDefaultRepoHookSettings } from '../../shared/constants' import type { Repo } from '../../shared/types' import { parsePairingCode } from '../../shared/pairing' @@ -25,6 +25,72 @@ const passthroughDedupe = (_repo: string, _id: string | undefined, run: () => run() describe('remote runtime request connection integration', () => { + it( + 'binds encrypted close-intent capability to the real runtime RPC context', + { timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS }, + async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-close-intent-')) + const refuseUnattributedMobileSessionTabClose = vi.fn().mockResolvedValue({ + closed: true, + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + }) + const closeMobileSessionTab = vi.fn() + const runtime = { + getRuntimeId: () => 'close-intent-runtime-test', + getStartedAt: () => 1, + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {}, + refuseUnattributedMobileSessionTabClose, + closeMobileSessionTab + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') + } + const connection = new RemoteRuntimeRequestConnection(pairing) + try { + await expect( + connection.request( + 'session.tabs.close', + { worktree: 'id:wt-1', tabId: 'tab-1' }, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS + ) + ).resolves.toMatchObject({ + ok: true, + result: { + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + } + }) + expect(refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1') + expect(closeMobileSessionTab).not.toHaveBeenCalled() + } finally { + connection.close() + } + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + } + ) + it( 'fetches repos through the real E2EE WebSocket runtime', { timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS }, diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index e56e86009..db4c8ac08 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -8,6 +8,7 @@ import type { PairingGetEndpointsResult, PairingProvisionRelayParams } from '../../../shared/mobile-relay-credential-contract' +import type { RuntimeCapability } from '../../../shared/protocol-version' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -63,6 +64,8 @@ export type RpcContext = { pairedDeviceId?: string // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' + // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. + clientCapabilities?: readonly RuntimeCapability[] // Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields. orchestrationCapability?: string // Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles. diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index b90b697d2..71d327c97 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,6 +29,7 @@ import { import { ALL_RPC_METHODS } from './methods' import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe' import type { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeCapability } from '../../../shared/protocol-version' import { OrchestrationMutationExecutor, authenticatedCallerFingerprint, @@ -125,6 +126,7 @@ export class RpcDispatcher { clientId?: string pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' + clientCapabilities?: readonly RuntimeCapability[] pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( @@ -167,6 +169,7 @@ export class RpcDispatcher { clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, + clientCapabilities: options?.clientCapabilities, orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: authenticatedCallerFingerprint(request), recordMutationReceipt: mutation?.recordReceipt, @@ -208,6 +211,7 @@ export class RpcDispatcher { clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, + clientCapabilities: options?.clientCapabilities, pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts index 00fd0093c..f9e602ced 100644 --- a/src/main/runtime/rpc/e2ee-channel-v2.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -156,7 +156,7 @@ describe('E2EEChannel v2', () => { }) }) - it('rejects legacy downgrade and injected auth metadata when v2 is required', () => { + it('rejects legacy downgrade and runtime-only capability metadata when mobile v2 is required', () => { const legacy = setup() legacy.channel.handleRawMessage( JSON.stringify({ type: 'e2ee_hello', publicKeyB64: 'legacy-key' }) @@ -173,13 +173,14 @@ describe('E2EEChannel v2', () => { v: 2, transcriptHashB64, deviceToken: 'valid-token', - relayDeviceId: 'injected' + clientCapabilities: ['session-tabs.close-intent.v1'] }), schedule, 0n ) ) expect(ctx.resolveAuthenticatedDevice).not.toHaveBeenCalled() + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_auth') }) it('rejects a captured auth frame replayed onto a fresh desktop nonce', () => { diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index 8ca3c2062..860226340 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -7,6 +7,7 @@ import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { REMOTE_RUNTIME_JSON_STRUCTURE_LIMITS } from '../../../shared/remote-runtime-request-frames' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' function publicKeyToBase64(key: Uint8Array): string { return Buffer.from(key).toString('base64') @@ -101,6 +102,35 @@ describe('E2EEChannel', () => { expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' }) }) + it('binds runtime capabilities to encrypted authenticated metadata', () => { + const ctx = setup({ + resolveAuthenticatedDevice: (token) => + token === 'valid-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'runtime' } + : null + }) + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt( + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: 'valid-token', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }), + sharedKey + ) + ) + + expect(ctx.channel.clientCapabilities).toEqual([SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY]) + expect(ctx.onReady).toHaveBeenCalledOnce() + }) + it('rejects invalid encrypted token', () => { const ctx = setup() ctx.channel.handleRawMessage( diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 95ae6f8d8..1cef6a102 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -16,6 +16,8 @@ import { import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner' +import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' +import type { RuntimeCapability } from '../../../shared/protocol-version' const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 @@ -62,6 +64,7 @@ export class E2EEChannel { deviceToken: string | null = null authenticatedDevice: E2EEAuthenticatedDevice | null = null + clientCapabilities: readonly RuntimeCapability[] = [] constructor(ws: WebSocket, options: E2EEChannelOptions) { this.ws = ws @@ -246,6 +249,7 @@ export class E2EEChannel { } const authenticatedDevice = authentication.device + this.clientCapabilities = parseRuntimeClientCapabilities(authentication.auth.clientCapabilities) this.deviceToken = authenticatedDevice.deviceToken this.authenticatedDevice = authenticatedDevice this.state = 'ready' diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 0a3af68a9..e11e2b78a 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -1,4 +1,5 @@ import { withSpan } from '../../../observability/tracer' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' @@ -6,11 +7,17 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.close', params: CloseTab, - handler: async (params, context) => - withSpan( + handler: async (params, context) => { + const requiresIntent = + context.clientKind === undefined || + (context.clientKind === 'runtime' && + context.clientCapabilities?.includes(SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY) === + true) + return withSpan( 'runtime.session-tabs.close', async (span) => { - if (!params.reason && context.clientKind === undefined) { + // Why: old runtime clicks and cleanup are wire-identical, so changing their behavior would regress mixed-version pairings. + if (!params.reason && requiresIntent) { const result = await context.runtime.refuseUnattributedMobileSessionTabClose( params.worktree, params.tabId @@ -36,12 +43,17 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ origin: context.clientKind ?? 'in-process', closeReason: params.reason ?? - (context.clientKind ? `legacy-${context.clientKind}-user` : 'missing'), + (requiresIntent + ? 'missing' + : context.clientKind === 'mobile' + ? 'legacy-mobile-user' + : 'legacy-runtime-user'), connectionGeneration: context.connectionId ?? 'in-process', requestId: context.requestId ?? 'in-process' } } ) + } }), defineMethod({ name: 'session.tabs.closeLifecycle', diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index eb70d393c..b9496b3da 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -114,6 +115,32 @@ describe('session tab RPC methods', () => { expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() }) + it('preserves explicit user closes from current runtime clients', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn(), + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.close', { + worktree: 'id:wt-1', + tabId: 'tab-1', + reason: 'user' + }), + (response) => replies.push(response), + { clientKind: 'runtime', pairedDeviceId: 'current-runtime' } + ) + + expect(replies).toHaveLength(1) + expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', { + reason: 'user' + }) + expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() + }) + it('preserves reasonless explicit closes from authenticated legacy mobile clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -136,7 +163,7 @@ describe('session tab RPC methods', () => { expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() }) - it('preserves reasonless explicit closes from authenticated legacy runtime clients', async () => { + it('preserves reasonless closes from authenticated legacy runtime clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', refuseUnattributedMobileSessionTabClose: vi.fn(), @@ -158,6 +185,35 @@ describe('session tab RPC methods', () => { expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() }) + it('refuses reasonless closes from runtime clients that negotiated explicit intent', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ + closed: true, + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + }), + closeMobileSessionTab: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }), + (response) => replies.push(response), + { + clientKind: 'runtime', + pairedDeviceId: 'current-runtime', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + } + ) + + expect(replies).toHaveLength(1) + expect(runtime.refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1') + expect(runtime.closeMobileSessionTab).not.toHaveBeenCalled() + }) + it.each(['pty-exit', 'cleanup'] as const)( 'rejects %s on the legacy close endpoint before host adjudication', async (reason) => { diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts index faeb9b9af..4f7db9fb6 100644 --- a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -5,6 +5,7 @@ import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-reque export type MobileE2EEAuth = { type: 'e2ee_auth' deviceToken: string + clientCapabilities?: unknown v?: 2 transcriptHashB64?: string } @@ -16,6 +17,7 @@ export function isValidMobileE2EEAuthVersion( if (!v2Session) { return auth.v === undefined && auth.transcriptHashB64 === undefined } + // Why: mobile v2 keeps an exact transcript-bound shape; runtime capabilities use legacy paired-runtime auth. return ( Object.keys(auth).sort().join(',') === 'deviceToken,transcriptHashB64,type,v' && auth.v === 2 && @@ -27,7 +29,9 @@ export function authenticateMobileE2EE( plaintext: string v2Session: DesktopMobileE2EEV2Session | null resolveDevice: (token: string) => TDevice | null -}): { ok: true; device: TDevice } | { ok: false; code: 'bad_auth' | 'unauthorized' } { +}): + | { ok: true; device: TDevice; auth: MobileE2EEAuth } + | { ok: false; code: 'bad_auth' | 'unauthorized' } { let auth: MobileE2EEAuth try { auth = parseRemoteRuntimeJsonText(args.plaintext) as MobileE2EEAuth @@ -43,7 +47,7 @@ export function authenticateMobileE2EE( } const device = args.resolveDevice(auth.deviceToken) return device?.deviceToken === auth.deviceToken - ? { ok: true, device } + ? { ok: true, device, auth } : { ok: false, code: 'unauthorized' } } diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts index f9d71c85b..ac2d92509 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -49,7 +49,11 @@ class FakeTransport implements MobileSocketTransport { } } -function registryFor(deviceId: string, token: string): DeviceRegistry { +function registryFor( + deviceId: string, + token: string, + scope: 'mobile' | 'runtime' = 'mobile' +): DeviceRegistry { return { validateToken: (candidate: string) => candidate === token @@ -57,7 +61,7 @@ function registryFor(deviceId: string, token: string): DeviceRegistry { deviceId, token, name: 'Phone', - scope: 'mobile' as const, + scope, pairedAt: 1, lastSeenAt: 0 } @@ -138,7 +142,7 @@ describe('MobileSocketWiring', () => { const onText = vi.fn() const onClose = vi.fn() const wiring = new MobileSocketWiring({ - deviceRegistry: registryFor('device-1', 'valid-token'), + deviceRegistry: registryFor('device-1', 'valid-token', 'runtime'), e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, @@ -160,14 +164,22 @@ describe('MobileSocketWiring', () => { const sharedKey = deriveSharedKey(phone.secretKey, desktop.publicKey) transport.receive( ws, - encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + encrypt( + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: 'valid-token', + clientCapabilities: ['session-tabs.close-intent.v1'] + }), + sharedKey + ) ) transport.receive(ws, encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey)) expect(transport.setClientId).toHaveBeenCalledWith(ws, 'valid-token') expect(onText).toHaveBeenCalledOnce() expect(onText.mock.calls[0]?.[0]).toMatchObject({ - device: { deviceId: 'device-1', deviceToken: 'valid-token', scope: 'mobile' }, + device: { deviceId: 'device-1', deviceToken: 'valid-token', scope: 'runtime' }, + clientCapabilities: ['session-tabs.close-intent.v1'], transport: { transport: 'direct' } }) diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 3235bb8f9..0ca490c1a 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -4,6 +4,7 @@ import type { DeviceEntry, DeviceRegistry } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel' import { createMobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' +import type { RuntimeCapability } from '../../../shared/protocol-version' type MobileSocketPayload = string | Uint8Array @@ -36,6 +37,7 @@ export type AuthenticatedMobileSocket = { ws: WebSocket connectionId: string device: E2EEAuthenticatedDevice + clientCapabilities: readonly RuntimeCapability[] transport: MobileSocketTransportMetadata } @@ -158,8 +160,14 @@ export class MobileSocketWiring { } return toAuthenticatedDevice(device) }, - onReady: (_channel, device) => { - const socket = { ws, connectionId, device, transport: metadata } + onReady: (channel, device) => { + const socket = { + ws, + connectionId, + device, + clientCapabilities: channel.clientCapabilities, + transport: metadata + } this.authenticatedSockets.set(ws, socket) transport.setClientId(ws, device.deviceToken) this.deviceRegistry.updateLastSeen(device.deviceId) diff --git a/src/main/runtime/rpc/runtime-client-capabilities.test.ts b/src/main/runtime/rpc/runtime-client-capabilities.test.ts new file mode 100644 index 000000000..b463ccd83 --- /dev/null +++ b/src/main/runtime/rpc/runtime-client-capabilities.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' + +describe('parseRuntimeClientCapabilities', () => { + it('accepts a bounded string array', () => { + expect(parseRuntimeClientCapabilities(['session-tabs.close-intent.v1', 'future.v1'])).toEqual([ + 'session-tabs.close-intent.v1', + 'future.v1' + ]) + }) + + it.each([ + undefined, + 'session-tabs.close-intent.v1', + [7], + [''], + ['x'.repeat(129)], + Array.from({ length: 65 }, () => 'future.v1') + ])('rejects malformed or oversized capability input', (value) => { + expect(parseRuntimeClientCapabilities(value)).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/runtime-client-capabilities.ts b/src/main/runtime/rpc/runtime-client-capabilities.ts new file mode 100644 index 000000000..2bda34ae1 --- /dev/null +++ b/src/main/runtime/rpc/runtime-client-capabilities.ts @@ -0,0 +1,12 @@ +import type { RuntimeCapability } from '../../../shared/protocol-version' + +export function parseRuntimeClientCapabilities(value: unknown): readonly RuntimeCapability[] { + if (!Array.isArray(value) || value.length > 64) { + return [] + } + const capabilities = value.filter( + (capability): capability is RuntimeCapability => + typeof capability === 'string' && capability.length > 0 && capability.length <= 128 + ) + return capabilities.length === value.length ? capabilities : [] +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6952eb852..a4ac64704 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1218,6 +1218,7 @@ export class OrcaRuntimeRpcServer { pairedDeviceId: device.deviceId, // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, + clientCapabilities: authenticatedSocket?.clientCapabilities, pairing: pairingContext, signal: abortRegistration?.signal, sendBinary, diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 4a15fcce2..85a5575e3 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -11,6 +11,7 @@ import { encryptBytes as encryptSharedBytes } from '../../../shared/e2ee-crypto' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' const fakeSockets: FakeWebSocket[] = [] @@ -51,6 +52,39 @@ describe('WebRuntimeClient', () => { vi.unstubAllGlobals() }) + it('advertises explicit close intent support in encrypted authentication', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const call = client.call('status.get', {}) + const socket = fakeSockets[0]! + socket.readyState = FakeWebSocket.OPEN + socket.onopen?.() + + expect(JSON.parse(String(socket.send.mock.calls[0]?.[0]))).toEqual({ + type: 'e2ee_hello', + publicKeyB64: expect.any(String) + }) + socket.onmessage?.({ data: JSON.stringify({ type: 'e2ee_ready' }) }) + const sharedKey = ( + client as unknown as { + sharedKey: Uint8Array + } + ).sharedKey + const auth = decrypt(String(socket.send.mock.calls[1]?.[0]), sharedKey) + expect(JSON.parse(auth!)).toEqual({ + type: 'e2ee_auth', + deviceToken: 'token', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) + + client.close() + await expect(call).rejects.toThrow('Remote Orca runtime connection closed.') + }) + it('closes child subscription clients when the owning client closes', () => { const client = new WebRuntimeClient({ v: 2, diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 705743504..9f61eefe3 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -14,6 +14,7 @@ import { publicKeyFromBase64, publicKeyToBase64 } from './web-e2ee' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' type WebRuntimeConnectionState = | 'disconnected' @@ -450,7 +451,11 @@ export class WebRuntimeClient { try { const control = JSON.parse(raw) as { type?: unknown } if (control.type === 'e2ee_ready') { - this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }) + this.sendEncrypted({ + type: 'e2ee_auth', + deviceToken: this.pairing.deviceToken, + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) return } } catch { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 7ba2874c1..b254d45dc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -63,6 +63,7 @@ export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credi // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const +export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const @@ -95,6 +96,7 @@ export const RUNTIME_CAPABILITIES = [ TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY, WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index fa15ca824..d16b3e348 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -13,6 +13,7 @@ import { } from './e2ee-crypto' import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remote-runtime-client' import { MAX_TIMER_DELAY_MS } from './timer-delay' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' const servers: WebSocketServer[] = [] @@ -65,6 +66,11 @@ describe('subscribeRemoteRuntimeRequest', () => { expect.objectContaining({ ok: true, result: { type: 'subscribed' } }) ) ) + await expect(server.nextAuth).resolves.toEqual({ + type: 'e2ee_auth', + deviceToken: 'device-token', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) const bytes = new Uint8Array([1, 2, 3]) expect(subscription.sendBinary(bytes)).toBe(true) await expect(server.nextBinary).resolves.toEqual(bytes) @@ -300,12 +306,17 @@ async function createSubscriptionServer( ): Promise<{ pairing: PairingOffer nextBinary: Promise + nextAuth: Promise }> { const serverKeyPair = generateKeyPair() let resolveBinary: (bytes: Uint8Array) => void = () => {} const nextBinary = new Promise((resolve) => { resolveBinary = resolve }) + let resolveAuth: (auth: unknown) => void = () => {} + const nextAuth = new Promise((resolve) => { + resolveAuth = resolve + }) const wss = new WebSocketServer({ port: 0, autoPong: options.disableAutoPong !== true }) servers.push(wss) @@ -341,6 +352,7 @@ async function createSubscriptionServer( return } if (!authenticated) { + resolveAuth(JSON.parse(plaintext)) authenticated = true sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) return @@ -379,7 +391,7 @@ async function createSubscriptionServer( if (!pairing) { throw new Error('Failed to create test pairing') } - return { pairing, nextBinary } + return { pairing, nextBinary, nextAuth } } function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void { diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index 4eaaeaf5b..a522e709e 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -21,6 +21,7 @@ import { type RuntimeOrchestrationEnvelope, type RuntimeRpcResponse } from './runtime-rpc-envelope' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' // Re-export so existing value importers of `RemoteRuntimeClientError` are // unaffected; the class lives in a ws-free module so type-only consumers // (and mobile's typecheck) don't compile this file's Node-only deps. @@ -94,7 +95,8 @@ export async function sendRemoteRuntimeRequest( const requestId = randomUUID() const serializedAuth = serializeRemoteRuntimePayload({ type: 'e2ee_auth', - deviceToken: pairing.deviceToken + deviceToken: pairing.deviceToken, + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] }) const pendingRequest = { preparedRequest: prepareRemoteRuntimeRequest(new Map(), () => @@ -410,7 +412,8 @@ export async function subscribeRemoteRuntimeRequest( }) const serializedAuth = serializeRemoteRuntimePayload({ type: 'e2ee_auth', - deviceToken: pairing.deviceToken + deviceToken: pairing.deviceToken, + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] }) return await new Promise((resolve, reject) => { const keyPair = generateKeyPair() diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts index 09f70b556..9bd5ff253 100644 --- a/src/shared/remote-runtime-request-connection.test.ts +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -11,11 +11,13 @@ import { publicKeyToBase64 } from './e2ee-crypto' import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' type TestServer = { wss: WebSocketServer pairing: PairingOffer requests: unknown[] + auths: unknown[] connectionCount: () => number } @@ -54,6 +56,11 @@ describe('RemoteRuntimeRequestConnection', () => { _meta: { runtimeId: 'runtime-test' } }) expect(server.connectionCount()).toBe(1) + expect(server.auths).toContainEqual( + expect.objectContaining({ + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) + ) expect(server.requests).toMatchObject([ { method: 'status.get' }, { method: 'terminal.send', params: { terminal: 't1', text: 'ab' } } @@ -66,6 +73,7 @@ describe('RemoteRuntimeRequestConnection', () => { async function createServer(): Promise { const serverKeyPair = generateKeyPair() const requests: unknown[] = [] + const auths: unknown[] = [] let connectionCount = 0 const wss = new WebSocketServer({ port: 0 }) servers.push(wss) @@ -94,7 +102,12 @@ async function createServer(): Promise { } if (!authenticated) { const auth = JSON.parse(plaintext) as { type: string; deviceToken: string } - expect(auth).toEqual({ type: 'e2ee_auth', deviceToken: 'device-token' }) + auths.push(auth) + expect(auth).toEqual({ + type: 'e2ee_auth', + deviceToken: 'device-token', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) authenticated = true sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) return @@ -133,6 +146,7 @@ async function createServer(): Promise { wss, pairing, requests, + auths, connectionCount: () => connectionCount } } diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts index 00acb5492..8ff1f246c 100644 --- a/src/shared/remote-runtime-request-connection.ts +++ b/src/shared/remote-runtime-request-connection.ts @@ -30,6 +30,7 @@ import { type RemoteRuntimeRequestReadyWaiter } from './remote-runtime-request-ready-waiters' import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready' @@ -205,7 +206,8 @@ export class RemoteRuntimeRequestConnection { encrypt( serializeRemoteRuntimePayload({ type: 'e2ee_auth', - deviceToken: this.pairing.deviceToken + deviceToken: this.pairing.deviceToken, + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] }), sharedKey ) diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 4af6e4441..29d8e8a39 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -20,12 +20,14 @@ import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepa import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection' import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' const TEST_PROJECT_PATH = path.join('tmp', 'project') type TestServer = { pairing: PairingOffer requests: { id: string; method: string; params?: unknown }[] + auths: unknown[] connectionCount: () => number flushDelayedResponses: () => void } @@ -57,6 +59,11 @@ describe('RemoteRuntimeSharedControlConnection', () => { expect(first).toMatchObject({ ok: true, result: { method: 'worktree.ps' } }) expect(second).toMatchObject({ ok: true, result: { method: 'session.tabs.listAll' } }) expect(server.connectionCount()).toBe(1) + expect(server.auths).toContainEqual({ + type: 'e2ee_auth', + deviceToken: 'device-token', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) expect(server.requests.map((request) => request.method)).toEqual([ 'worktree.ps', 'session.tabs.listAll' @@ -667,6 +674,7 @@ async function createServer( ): Promise { const serverKeyPair = generateKeyPair() const requests: TestServer['requests'] = [] + const auths: unknown[] = [] const delayedResponses: (() => void)[] = [] let connectionCount = 0 let closedAfterFirstStreamingResponse = false @@ -699,6 +707,7 @@ async function createServer( return } if (!authenticated) { + auths.push(JSON.parse(plaintext)) authenticated = true sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) if (options.sendBinaryAfterAuth) { @@ -742,6 +751,7 @@ async function createServer( return { pairing, requests, + auths, connectionCount: () => connectionCount, flushDelayedResponses: () => delayedResponses.splice(0).forEach((send) => send()) } diff --git a/src/shared/remote-runtime-shared-control-frame-handler.ts b/src/shared/remote-runtime-shared-control-frame-handler.ts index 761804e48..3c08eb88f 100644 --- a/src/shared/remote-runtime-shared-control-frame-handler.ts +++ b/src/shared/remote-runtime-shared-control-frame-handler.ts @@ -1,5 +1,6 @@ import { parseAuthenticatedFrame, parseReadyFrame } from './remote-runtime-request-frames' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch' import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol' import { resolveSharedControlReadyWaiters } from './remote-runtime-shared-control-state' @@ -32,7 +33,11 @@ export function handleSharedControlTextFrame(args: { return } args.setState('awaiting_authenticated') - args.sendEncrypted({ type: 'e2ee_auth', deviceToken: args.deviceToken }) + args.sendEncrypted({ + type: 'e2ee_auth', + deviceToken: args.deviceToken, + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) return } diff --git a/tests/e2e/daemon-generation-legacy-close-safety.spec.ts b/tests/e2e/daemon-generation-legacy-close-safety.spec.ts new file mode 100644 index 000000000..4566902a9 --- /dev/null +++ b/tests/e2e/daemon-generation-legacy-close-safety.spec.ts @@ -0,0 +1,431 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test, type TestInfo } from '@playwright/test' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { + cleanupDaemonGenerationFixtures, + createDaemonGenerationRuntime, + launchDaemonGeneration, + spawnGenerationCanary, + type DaemonGeneration, + type DaemonGenerationRuntime, + type GenerationCanary +} from './helpers/daemon-generation-safety-fixtures' +import { + processIdentityLiveness, + recordProcessIdentity, + recordProcessTree, + terminateRecordedTree, + waitForCondition +} from './helpers/daemon-generation-processes' + +type LegacyCloseReport = { + capableInitiator: { + clientKind: 'runtime' + clientId: string + pairedDeviceId: string + connectionId: string + clientCapabilities: string[] + callSite: string + wireReason: null + } + legacyInitiator: { + clientKind: 'runtime' + clientId: string + pairedDeviceId: string + connectionId: string + callSite: string + wireReason: null + } + observer: { + clientKind: 'runtime' + clientId: string + pairedDeviceId: string + connectionId: string + requestCount: number + closeRequestCount: number + } + observerBefore: Record[] + observerAfterCapable: Record[] + observerAfter: Record[] + postClosePing: Record + calls: Record[] + capableResponses: Record[] + legacyResponses: Record[] +} + +function killEvents(generation: DaemonGeneration, sessionId: string): Record[] { + return generation + .logEvents() + .filter((event) => event.event === 'session-killed' && event.sessionId === sessionId) +} + +function launchLegacyCloseClient(options: { + runtime: DaemonGenerationRuntime + generations: readonly DaemonGeneration[] + capableCanaries: readonly GenerationCanary[] + legacyCanaries: readonly GenerationCanary[] +}): { + child: ChildProcess + ready: Promise + finish(): void + output(): string +} { + const { runtime, generations, capableCanaries, legacyCanaries } = options + const configPath = path.join(runtime.rootDir, 'legacy-close-client-config.json') + writeFileSync( + configPath, + `${JSON.stringify({ + generations: generations.map((generation) => ({ + protocolVersion: generation.protocolVersion, + socketPath: generation.socketPath, + tokenPath: generation.tokenPath + })), + currentProtocolVersion: PROTOCOL_VERSION, + daemonDir: runtime.daemonDir, + historyDir: path.join(runtime.userDataDir, 'terminal-history'), + cwd: runtime.rootDir, + sessions: [...capableCanaries, ...legacyCanaries].map((canary, index) => ({ + protocolVersion: canary.generation.protocolVersion, + sessionId: canary.sessionId, + rootPid: canary.rootIdentity.pid, + worktreeId: canary.worktreeId, + tabId: `legacy-close-tab-${index + 1}`, + closeContract: index < capableCanaries.length ? 'capable' : 'legacy' + })) + })}\n` + ) + let output = '' + const child = fork(runtime.legacyCloseClientEntryPath, ['--config', configPath], { + cwd: runtime.userDataDir, + execPath: runtime.electronPath, + windowsHide: true, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + NODE_PATH: path.join(process.cwd(), 'node_modules'), + ORCA_USER_DATA_PATH: runtime.userDataDir + }, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + child.stderr?.on('data', (chunk: Buffer) => { + output = `${output}${chunk.toString('utf8')}`.slice(-32_768) + }) + const ready = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Legacy close client timed out: ${output}`)), + 60_000 + ) + const settle = (callback: () => void): void => { + clearTimeout(timer) + child.off('message', onMessage) + child.off('exit', onExit) + callback() + } + const onExit = (code: number | null): void => + settle(() => reject(new Error(`Legacy close client exited with ${code}: ${output}`))) + const onMessage = (message: unknown): void => { + const payload = message as LegacyCloseReport & { type?: string; message?: string } + if (payload.type === 'error') { + settle(() => reject(new Error(payload.message ?? 'Legacy close client failed'))) + } else if (payload.type === 'legacy-close-complete') { + settle(() => resolve(payload)) + } + } + child.on('message', onMessage) + child.once('exit', onExit) + }) + return { + child, + ready, + finish: () => { + if (child.connected) { + child.send?.({ type: 'finish' }, () => {}) + } + }, + output: () => output + } +} + +async function finishLegacyCloseClient( + client: ReturnType +): Promise { + if (!client.child.pid || client.child.exitCode !== null) { + return + } + const identity = await recordProcessIdentity(client.child.pid) + client.finish() + try { + await waitForCondition('legacy close client exit', () => client.child.exitCode !== null, 2_000) + } catch { + await terminateRecordedTree(await recordProcessTree(identity)) + } +} + +function writeReconstruction(options: { + testInfo: TestInfo + generations: readonly DaemonGeneration[] + canaries: readonly GenerationCanary[] + report: LegacyCloseReport + capableSessionIds: ReadonlySet + legacySessionIds: ReadonlySet + before: Record + after: Record + postClosePing: Record +}): void { + const { + testInfo, + generations, + canaries, + report, + capableSessionIds, + legacySessionIds, + before, + after, + postClosePing + } = options + writeFileSync( + testInfo.outputPath('legacy-viewer-close-reconstruction.json'), + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + invariant: + 'Strict close attribution activates only for a capable authenticated viewer; legacy peers retain current-main behavior', + authoritativeBoundary: { + capable: + 'session.tabs.close -> refuseUnattributedMobileSessionTabClose -> snapshot republish', + legacy: 'session.tabs.close -> closeMobileSessionTab -> RuntimeNotifier.closeTerminalTab' + }, + capableInitiator: report.capableInitiator, + legacyInitiator: report.legacyInitiator, + observer: report.observer, + observerBefore: report.observerBefore, + observerAfterCapable: report.observerAfterCapable, + observerAfter: report.observerAfter, + requestOrder: [ + ...report.capableResponses.map((response, index) => ({ + sequence: index + 1, + contract: 'capable', + response, + call: null + })), + ...report.legacyResponses.map((response, index) => ({ + sequence: report.capableResponses.length + index + 1, + contract: 'legacy', + response, + call: report.calls[index] ?? null + })) + ], + sessions: canaries.map((canary, index) => ({ + sequence: index + 1, + closeContract: capableSessionIds.has(canary.sessionId) + ? 'capable' + : legacySessionIds.has(canary.sessionId) + ? 'legacy' + : 'control', + worktreeId: canary.worktreeId, + sessionId: canary.sessionId, + daemon: { + label: canary.generation.label, + protocolVersion: canary.generation.protocolVersion, + pid: canary.generation.identity.pid, + startedAtMs: canary.generation.identity.startedAtMs + }, + root: { + ...canary.rootIdentity, + liveBefore: before[canary.rootIdentity.pid], + liveAfter: after[canary.rootIdentity.pid] + }, + descendant: { + ...canary.descendantIdentity, + liveBefore: before[canary.descendantIdentity.pid], + liveAfter: after[canary.descendantIdentity.pid] + }, + postClosePing: postClosePing[canary.sessionId], + daemonKillEvents: killEvents(canary.generation, canary.sessionId) + })), + generations: generations.map((generation) => ({ + label: generation.label, + protocolVersion: generation.protocolVersion, + pid: generation.identity.pid + })) + }, + null, + 2 + )}\n` + ) +} + +test('close-intent negotiation preserves legacy behavior while protecting capable viewers across daemon generations', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument before testInfo. +{}, testInfo) => { + test.setTimeout(120_000) + const runtime = await createDaemonGenerationRuntime(testInfo) + const generations: DaemonGeneration[] = [] + const canaries: GenerationCanary[] = [] + let client: ReturnType | null = null + let assertionsComplete = false + + try { + for (const protocolVersion of [PROTOCOL_VERSION - 1, PROTOCOL_VERSION]) { + const generation = await launchDaemonGeneration({ + runtime, + label: `legacy-close-v${protocolVersion}`, + protocolVersion + }) + generations.push(generation) + canaries.push( + await spawnGenerationCanary({ + runtime, + generation, + role: 'live', + worktreeId: `legacy-close-worktree-v${protocolVersion}` + }) + ) + } + for (const generation of generations) { + canaries.push( + await spawnGenerationCanary({ + runtime, + generation, + role: 'live', + worktreeId: `legacy-compatible-worktree-v${generation.protocolVersion}` + }) + ) + } + canaries.push( + await spawnGenerationCanary({ + runtime, + generation: generations[1]!, + role: 'live', + worktreeId: 'legacy-close-unrelated-worktree' + }) + ) + const capableCanaries = canaries.slice(0, 2) + const legacyCanaries = canaries.slice(2, 4) + const controlCanary = canaries[4]! + const capableSessionIds = new Set(capableCanaries.map((canary) => canary.sessionId)) + const legacySessionIds = new Set(legacyCanaries.map((canary) => canary.sessionId)) + const identities = canaries.flatMap((canary) => [ + canary.rootIdentity, + canary.descendantIdentity + ]) + const beforeMap = await processIdentityLiveness(identities) + const before = Object.fromEntries( + identities.map(({ pid }) => [pid, beforeMap.get(pid) === true]) + ) + expect(Object.values(before).every(Boolean)).toBe(true) + + client = launchLegacyCloseClient({ + runtime, + generations, + capableCanaries, + legacyCanaries + }) + const report = await client.ready + const afterMap = await processIdentityLiveness(identities) + const after = Object.fromEntries(identities.map(({ pid }) => [pid, afterMap.get(pid) === true])) + writeReconstruction({ + testInfo, + generations, + canaries, + report, + capableSessionIds, + legacySessionIds, + before, + after, + postClosePing: report.postClosePing + }) + + expect(report.capableInitiator).toEqual({ + clientKind: 'runtime', + clientId: 'capable-viewer', + pairedDeviceId: 'capable-viewer', + connectionId: 'capable-viewer-generation-2', + clientCapabilities: ['session-tabs.close-intent.v1'], + callSite: 'capable-viewer:stale-pty-exit-cleanup', + wireReason: null + }) + expect(report.legacyInitiator).toEqual({ + clientKind: 'runtime', + clientId: 'legacy-viewer', + pairedDeviceId: 'legacy-viewer', + connectionId: 'legacy-viewer-generation-1', + callSite: 'legacy-viewer:stale-pty-exit-cleanup', + wireReason: null + }) + expect(report.observer).toEqual({ + clientKind: 'runtime', + clientId: 'current-viewer', + pairedDeviceId: 'current-viewer', + connectionId: 'observer-generation-3', + requestCount: (capableCanaries.length + legacyCanaries.length) * 3, + closeRequestCount: 0 + }) + expect(report.observerBefore).toHaveLength(capableCanaries.length + legacyCanaries.length) + expect(report.observerAfterCapable).toHaveLength(capableCanaries.length + legacyCanaries.length) + expect(report.observerAfter).toHaveLength(capableCanaries.length + legacyCanaries.length) + expect( + [...report.observerBefore, ...report.observerAfterCapable, ...report.observerAfter].every( + (response) => response.ok === true + ) + ).toBe(true) + expect( + report.observerAfterCapable.every((response, index) => { + const result = response.result as { tabs?: { ptyId?: string | null }[] } | undefined + return ( + result?.tabs?.some( + (tab) => tab.ptyId === [...capableCanaries, ...legacyCanaries][index]!.sessionId + ) === true + ) + }) + ).toBe(true) + expect(report.capableResponses).toHaveLength(capableCanaries.length) + expect( + report.capableResponses.every((response) => { + const result = response.result as Record | undefined + return ( + response.ok === true && + result?.refused === true && + result.refusalReason === 'missing-intent' && + result.snapshotRepublished === true + ) + }) + ).toBe(true) + expect(report.legacyResponses).toHaveLength(legacyCanaries.length) + expect( + report.legacyResponses.every((response) => { + const result = response.result as Record | undefined + return response.ok === true && result?.refused !== true + }) + ).toBe(true) + expect(report.calls.map((call) => call.sessionId)).toEqual( + legacyCanaries.map((canary) => canary.sessionId) + ) + for (const canary of capableCanaries) { + expect(after[canary.rootIdentity.pid]).toBe(true) + expect(after[canary.descendantIdentity.pid]).toBe(true) + expect(report.postClosePing[canary.sessionId]).toBe(true) + expect(killEvents(canary.generation, canary.sessionId)).toHaveLength(0) + } + for (const canary of legacyCanaries) { + expect(after[canary.rootIdentity.pid]).toBe(false) + expect(after[canary.descendantIdentity.pid]).toBe(false) + expect(report.postClosePing[canary.sessionId]).toBe(false) + expect(killEvents(canary.generation, canary.sessionId)).toHaveLength(1) + } + expect(after[controlCanary.rootIdentity.pid]).toBe(true) + expect(after[controlCanary.descendantIdentity.pid]).toBe(true) + expect(killEvents(controlCanary.generation, controlCanary.sessionId)).toHaveLength(0) + assertionsComplete = true + } finally { + if (client) { + await finishLegacyCloseClient(client) + } + if (!assertionsComplete) { + runtime.retainDiagnostics(generations) + } + await cleanupDaemonGenerationFixtures({ generations, canaries }) + runtime.remove() + } +}) diff --git a/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts b/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts new file mode 100644 index 000000000..b9bbb6215 --- /dev/null +++ b/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts @@ -0,0 +1,308 @@ +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' +import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' +import { SESSION_TAB_METHODS } from '../../../src/main/runtime/rpc/methods/session-tabs' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../src/shared/runtime-types' +import { createDesktopDiscoveredDaemonRouter } from './daemon-generation-desktop-discovery' + +type FixtureSession = { + protocolVersion: number + sessionId: string + rootPid: number + worktreeId: string + tabId: string + closeContract: 'capable' | 'legacy' +} + +type FixtureConfig = { + generations: { protocolVersion: number; socketPath: string; tokenPath: string }[] + currentProtocolVersion: number + daemonDir: string + historyDir: string + cwd: string + sessions: FixtureSession[] +} + +async function waitFor(description: string, predicate: () => boolean): Promise { + const deadline = Date.now() + 10_000 + while (Date.now() <= deadline) { + if (predicate()) { + return + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error(`Timed out waiting for ${description}`) +} + +const LEGACY_VIEWER = { + clientKind: 'runtime' as const, + clientId: 'legacy-viewer', + pairedDeviceId: 'legacy-viewer', + connectionId: 'legacy-viewer-generation-1', + callSite: 'legacy-viewer:stale-pty-exit-cleanup', + wireReason: null +} +const CAPABLE_VIEWER = { + clientKind: 'runtime' as const, + clientId: 'capable-viewer', + pairedDeviceId: 'capable-viewer', + connectionId: 'capable-viewer-generation-2', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY], + callSite: 'capable-viewer:stale-pty-exit-cleanup', + wireReason: null +} +const OBSERVER = { + clientKind: 'runtime' as const, + clientId: 'current-viewer', + pairedDeviceId: 'current-viewer', + connectionId: 'observer-generation-3' +} + +function readConfig(): FixtureConfig { + const configIndex = process.argv.indexOf('--config') + const configPath = configIndex >= 0 ? process.argv[configIndex + 1] : undefined + if (!configPath) { + throw new Error('Legacy close client requires --config ') + } + return JSON.parse(readFileSync(configPath, 'utf8')) as FixtureConfig +} + +async function dispatchReasonlessClose( + dispatcher: RpcDispatcher, + session: FixtureSession, + sequence: number, + viewer: typeof LEGACY_VIEWER | typeof CAPABLE_VIEWER +): Promise> { + const requestId = `${session.closeContract}-close-${sequence}` + return await new Promise((resolve, reject) => { + void dispatcher + .dispatchStreaming( + { + id: requestId, + authToken: 'fixture-only', + method: 'session.tabs.close', + params: { worktree: `id:${session.worktreeId}`, tabId: session.tabId } + }, + (serialized) => resolve(JSON.parse(serialized) as Record), + viewer + ) + .catch(reject) + }) +} + +async function dispatchObserverList( + dispatcher: RpcDispatcher, + session: FixtureSession, + sequence: number +): Promise> { + return await new Promise((resolve, reject) => { + void dispatcher + .dispatchStreaming( + { + id: `observer-list-${sequence}`, + authToken: 'fixture-only', + method: 'session.tabs.list', + params: { worktree: `id:${session.worktreeId}` } + }, + (serialized) => resolve(JSON.parse(serialized) as Record), + OBSERVER + ) + .catch(reject) + }) +} + +async function waitForFinish(): Promise { + await new Promise((resolve) => { + process.on('message', (message) => { + if ((message as { type?: unknown })?.type === 'finish') { + resolve() + } + }) + }) +} + +async function main(): Promise { + const config = readConfig() + const { router } = await createDesktopDiscoveredDaemonRouter(config) + try { + const outputBySessionId = new Map() + router.onData((event) => { + outputBySessionId.set( + event.id, + `${outputBySessionId.get(event.id) ?? ''}${event.data}`.slice(-32_768) + ) + }) + await router.getCurrentAdapter().listProcesses() + await router.discoverLegacySessions() + for (const session of config.sessions) { + const attached = await router.spawn({ + sessionId: session.sessionId, + isNewSession: false, + cols: 100, + rows: 30, + cwd: config.cwd + }) + if (!attached.isReattach || attached.pid !== session.rootPid) { + throw new Error(`Legacy close fixture changed ${session.sessionId} incarnation`) + } + } + + const runtime = new OrcaRuntimeService() + const calls: Record[] = [] + const sessionByTabId = new Map(config.sessions.map((session) => [session.tabId, session])) + runtime.setPtyController({ + write: (ptyId, data) => { + router.write(ptyId, data) + return true + }, + kill: () => false, + listProcesses: (options) => router.listProcesses(options), + hasPty: (ptyId) => router.hasPty(ptyId), + getForegroundProcess: (ptyId) => router.getForegroundProcess(ptyId) + }) + runtime.setNotifier({ + closeTerminal: () => { + throw new Error('Legacy close fixture unexpectedly used the pane-close fallback') + }, + closeTerminalTab: async (tabId: string) => { + const session = sessionByTabId.get(tabId) + if (!session) { + throw new Error(`Legacy close fixture received unknown tab ${tabId}`) + } + calls.push({ + callSite: 'RuntimeNotifier.closeTerminalTab -> DaemonPtyRouter.shutdown', + immediate: true, + tabId, + worktreeId: session.worktreeId, + sessionId: session.sessionId + }) + await router.shutdown(session.sessionId, { immediate: true }) + } + } as never) + runtime.attachWindow(1) + + const snapshots: RuntimeMobileSessionTabsSnapshot[] = config.sessions.map((session, index) => { + const leafId = `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}` + return { + worktree: session.worktreeId, + publicationEpoch: `legacy-viewer-${index + 1}`, + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${session.tabId}::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${session.tabId}::${leafId}`, + parentTabId: session.tabId, + leafId, + ptyId: session.sessionId, + title: session.tabId, + isActive: true + } + ] + } + }) + runtime.syncWindowGraph(1, { + tabs: snapshots.map((snapshot) => ({ + tabId: snapshot.tabs[0]!.parentTabId, + worktreeId: snapshot.worktree, + title: snapshot.tabs[0]!.title, + activeLeafId: snapshot.tabs[0]!.leafId, + layout: null + })), + leaves: snapshots.map((snapshot, index) => ({ + tabId: snapshot.tabs[0]!.parentTabId, + worktreeId: snapshot.worktree, + leafId: snapshot.tabs[0]!.leafId, + paneRuntimeId: index + 1, + ptyId: config.sessions[index]!.sessionId, + paneTitle: snapshot.tabs[0]!.title + })), + mobileSessionTabs: snapshots + }) + + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const observerBefore: Record[] = [] + for (const [index, session] of config.sessions.entries()) { + observerBefore.push(await dispatchObserverList(dispatcher, session, index + 1)) + } + const capableResponses: Record[] = [] + for (const [index, session] of config.sessions + .filter((candidate) => candidate.closeContract === 'capable') + .entries()) { + capableResponses.push( + await dispatchReasonlessClose(dispatcher, session, index + 1, CAPABLE_VIEWER) + ) + } + const observerAfterCapable: Record[] = [] + for (const [index, session] of config.sessions.entries()) { + observerAfterCapable.push(await dispatchObserverList(dispatcher, session, index + 101)) + } + const legacyResponses: Record[] = [] + for (const [index, session] of config.sessions + .filter((candidate) => candidate.closeContract === 'legacy') + .entries()) { + legacyResponses.push( + await dispatchReasonlessClose(dispatcher, session, index + 1, LEGACY_VIEWER) + ) + } + const observerAfter: Record[] = [] + for (const [index, session] of config.sessions.entries()) { + observerAfter.push(await dispatchObserverList(dispatcher, session, index + 201)) + } + const postClosePing: Record = {} + for (const [index, session] of config.sessions.entries()) { + if (calls.some((call) => call.sessionId === session.sessionId)) { + postClosePing[session.sessionId] = false + continue + } + const nonce = `post-close-${index + 1}` + try { + router.write( + session.sessionId, + `PING legacy-close-v${session.protocolVersion}-live ${nonce}\r` + ) + await waitFor(`${session.sessionId} post-close reply`, () => + (outputBySessionId.get(session.sessionId) ?? '').includes( + `ORCA_GENERATION_CANARY_ACK legacy-close-v${session.protocolVersion}-live ${nonce}` + ) + ) + postClosePing[session.sessionId] = true + } catch { + postClosePing[session.sessionId] = false + } + } + process.send?.({ + type: 'legacy-close-complete', + capableInitiator: CAPABLE_VIEWER, + legacyInitiator: LEGACY_VIEWER, + observer: { + ...OBSERVER, + requestCount: observerBefore.length + observerAfterCapable.length + observerAfter.length, + closeRequestCount: 0 + }, + observerBefore, + observerAfterCapable, + observerAfter, + postClosePing, + calls, + capableResponses, + legacyResponses + }) + await waitForFinish() + } finally { + await router.disconnectOnly().catch(() => {}) + router.dispose() + } +} + +void main().catch((error) => { + process.send?.({ + type: 'error', + message: error instanceof Error ? error.stack : String(error) + }) + process.exit(1) +}) diff --git a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts index 39c1c2db6..881110243 100644 --- a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts +++ b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts @@ -21,6 +21,7 @@ export type DaemonGenerationRuntime = { daemonDir: string entryPath: string reconnectClientEntryPath: string + legacyCloseClientEntryPath: string canaryPath: string electronPath: string retainDiagnostics(generations: readonly DiagnosticGeneration[]): void @@ -106,6 +107,7 @@ export async function createDaemonGenerationRuntime( mkdirSync(daemonDir, { recursive: true }) const entryPath = path.join(rootDir, 'daemon-generation-entry.cjs') const reconnectClientEntryPath = path.join(rootDir, 'daemon-generation-reconnect-client.cjs') + const legacyCloseClientEntryPath = path.join(rootDir, 'daemon-generation-legacy-close-client.cjs') const repoRoot = process.cwd() await buildFixtureEntry( path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-entry.ts'), @@ -115,12 +117,17 @@ export async function createDaemonGenerationRuntime( path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-reconnect-client.ts'), reconnectClientEntryPath ) + await buildFixtureEntry( + path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-legacy-close-client.ts'), + legacyCloseClientEntryPath + ) return { rootDir, userDataDir, daemonDir, entryPath, reconnectClientEntryPath, + legacyCloseClientEntryPath, canaryPath: path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-canary.cjs'), electronPath: resolveElectronExecutable(repoRoot), retainDiagnostics: (generations) => { diff --git a/tests/e2e/helpers/daemon-generation-safety-fixtures.ts b/tests/e2e/helpers/daemon-generation-safety-fixtures.ts index d643ffb74..3bbaa94ed 100644 --- a/tests/e2e/helpers/daemon-generation-safety-fixtures.ts +++ b/tests/e2e/helpers/daemon-generation-safety-fixtures.ts @@ -35,6 +35,7 @@ export type DaemonGeneration = { export type GenerationCanary = { generation: DaemonGeneration role: 'live' | 'stale-mirror' + worktreeId: string sessionId: string rootIdentity: RecordedProcessIdentity descendantIdentity: RecordedProcessIdentity @@ -165,13 +166,14 @@ export async function spawnGenerationCanary(options: { runtime: DaemonGenerationRuntime generation: DaemonGeneration role: GenerationCanary['role'] + worktreeId?: string }): Promise { - const { runtime, generation, role } = options + const { runtime, generation, role, worktreeId = DAEMON_GENERATION_WORKTREE_ID } = options const label = `${generation.label}-${role}` const nonce = randomUUID() // Why: production daemon inventory infers ownership from the durable prefix; // keep the fixture on that path so live-host adjudication cannot degrade to unknown. - const sessionId = `${DAEMON_GENERATION_WORKTREE_ID}@@orca-9749-${label}-${randomUUID().slice(0, 8)}` + const sessionId = `${worktreeId}@@orca-9749-${label}-${randomUUID().slice(0, 8)}` const adapter = new DaemonPtyAdapter({ socketPath: generation.socketPath, tokenPath: generation.tokenPath, @@ -212,6 +214,7 @@ export async function spawnGenerationCanary(options: { return { generation, role, + worktreeId, sessionId, rootIdentity, descendantIdentity,