fix(terminals): negotiate explicit close intent for paired runtimes (#10129)

This commit is contained in:
OrcaWin 2026-07-27 15:22:55 -07:00 committed by GitHub
parent 0f91af821d
commit 24706ccff0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1231 additions and 45 deletions

View File

@ -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."
],

View File

@ -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<string, unknown>[]
close: () => Promise<void>
}
@ -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<string, unknown>[] = []
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<string, unknown> & {
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<string, unknown> & {
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<void>((resolve) => {
wss.close(() => resolve())

View File

@ -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 = <T>(_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 },

View File

@ -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<PairingGetEndpointsResult>
@ -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.

View File

@ -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<ArrayBufferLike>) => 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

View File

@ -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', () => {

View File

@ -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(

View File

@ -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'

View File

@ -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',

View File

@ -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) => {

View File

@ -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<TDevice extends { deviceToken: string }>(
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<TDevice extends { deviceToken: string }>(
}
const device = args.resolveDevice(auth.deviceToken)
return device?.deviceToken === auth.deviceToken
? { ok: true, device }
? { ok: true, device, auth }
: { ok: false, code: 'unauthorized' }
}

View File

@ -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' }
})

View File

@ -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<ArrayBufferLike>
@ -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)

View File

@ -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([])
})
})

View File

@ -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 : []
}

View File

@ -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,

View File

@ -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,

View File

@ -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 {

View File

@ -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,

View File

@ -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<Uint8Array>
nextAuth: Promise<unknown>
}> {
const serverKeyPair = generateKeyPair()
let resolveBinary: (bytes: Uint8Array) => void = () => {}
const nextBinary = new Promise<Uint8Array>((resolve) => {
resolveBinary = resolve
})
let resolveAuth: (auth: unknown) => void = () => {}
const nextAuth = new Promise<unknown>((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 {

View File

@ -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<TResult>(
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<TResult>(
})
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()

View File

@ -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<TestServer> {
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<TestServer> {
}
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<TestServer> {
wss,
pairing,
requests,
auths,
connectionCount: () => connectionCount
}
}

View File

@ -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
)

View File

@ -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<TestServer> {
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())
}

View File

@ -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
}

View File

@ -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<string, unknown>[]
observerAfterCapable: Record<string, unknown>[]
observerAfter: Record<string, unknown>[]
postClosePing: Record<string, boolean>
calls: Record<string, unknown>[]
capableResponses: Record<string, unknown>[]
legacyResponses: Record<string, unknown>[]
}
function killEvents(generation: DaemonGeneration, sessionId: string): Record<string, unknown>[] {
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<LegacyCloseReport>
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<LegacyCloseReport>((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<typeof launchLegacyCloseClient>
): Promise<void> {
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<string>
legacySessionIds: ReadonlySet<string>
before: Record<number, boolean>
after: Record<number, boolean>
postClosePing: Record<string, boolean>
}): 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<typeof launchLegacyCloseClient> | 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<string, unknown> | 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<string, unknown> | 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()
}
})

View File

@ -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<void> {
const deadline = Date.now() + 10_000
while (Date.now() <= deadline) {
if (predicate()) {
return
}
await new Promise<void>((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 <path>')
}
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<Record<string, unknown>> {
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<string, unknown>),
viewer
)
.catch(reject)
})
}
async function dispatchObserverList(
dispatcher: RpcDispatcher,
session: FixtureSession,
sequence: number
): Promise<Record<string, unknown>> {
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<string, unknown>),
OBSERVER
)
.catch(reject)
})
}
async function waitForFinish(): Promise<void> {
await new Promise<void>((resolve) => {
process.on('message', (message) => {
if ((message as { type?: unknown })?.type === 'finish') {
resolve()
}
})
})
}
async function main(): Promise<void> {
const config = readConfig()
const { router } = await createDesktopDiscoveredDaemonRouter(config)
try {
const outputBySessionId = new Map<string, string>()
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<string, unknown>[] = []
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<string, unknown>[] = []
for (const [index, session] of config.sessions.entries()) {
observerBefore.push(await dispatchObserverList(dispatcher, session, index + 1))
}
const capableResponses: Record<string, unknown>[] = []
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<string, unknown>[] = []
for (const [index, session] of config.sessions.entries()) {
observerAfterCapable.push(await dispatchObserverList(dispatcher, session, index + 101))
}
const legacyResponses: Record<string, unknown>[] = []
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<string, unknown>[] = []
for (const [index, session] of config.sessions.entries()) {
observerAfter.push(await dispatchObserverList(dispatcher, session, index + 201))
}
const postClosePing: Record<string, boolean> = {}
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)
})

View File

@ -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) => {

View File

@ -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<GenerationCanary> {
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,