fix(terminals): negotiate explicit close intent for paired runtimes (#10129)
This commit is contained in:
parent
0f91af821d
commit
24706ccff0
|
|
@ -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."
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 : []
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue