From 2ed56e422bdd802735ff2c5faef9e6be5fdc3cdf Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 01:31:11 -0700 Subject: [PATCH 1/8] fix(ssh): relay-era terminals' orca orchestration commands work again, behind the per-host switch (STA-10765) --- src/main/ipc/ssh.ts | 3 +- .../runtime/runtime-terminal-contracts.ts | 8 + .../adopt-terminal-orphans-from-inventory.ts | 7 +- .../output/headless-buffer-snapshot.ts | 33 ++- .../wait/terminal-interactive-wait.ts | 2 +- .../legacy-relay-bridge-ipc.ts | 14 +- .../legacy-relay-bridge-leases.ts | 34 +++ .../legacy-relay-bridge-registry.ts | 20 +- .../legacy-relay-cli-args.ts | 101 +++++++ .../legacy-relay-cli-caller.ts | 204 +++++++++++++ .../legacy-relay-cli-output.ts | 148 ++++++++++ .../legacy-relay-cli-server.test.ts | 233 +++++++++++++++ .../legacy-relay-cli-server.ts | 267 ++++++++++++++++++ .../legacy-relay-client.test.ts | 29 +- .../legacy-relay-client.ts | 41 ++- .../legacy-relay-target-bridge.test.ts | 26 ++ .../legacy-relay-target-bridge.ts | 25 +- 17 files changed, 1162 insertions(+), 33 deletions(-) create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-output.ts create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 4feca314d6e5..82ac5d7168c6 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -133,7 +133,8 @@ export function registerSshHandlers( registerLegacyRelayBridgeIpc({ store, getMainWindow: getCurrentMainWindow, - connection: (targetId) => connectionManager?.getConnection(targetId) ?? null + connection: (targetId) => connectionManager?.getConnection(targetId) ?? null, + runtime: () => runtime ?? null }) if (runtime) { diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 03d6569b8af5..4bdba5717306 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -213,6 +213,14 @@ export type OrchestrationCompatibilitySshAttachmentAuthority = Extract< { kind: 'ssh' } > +/** COMPAT(legacy-relay-bridge): a relay-era terminal this runtime no longer hosts, which the + * bridge verified for the one CLI call its attachment lives for. */ +export type OrchestrationCompatibilitySshTerminal = Readonly<{ + handle: string + paneKey: string + processIncarnation?: string +}> + export type TerminalWaiter = { handle: string condition: RuntimeTerminalWaitCondition diff --git a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts index 21577a5287d4..f2afb185899f 100644 --- a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts @@ -205,7 +205,10 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O getTerminalPaneKey(handle: string): string | null { return ( resolveStructuredWorkerAuthority(handle, this.getOrchestrationDbIfAvailable?.() ?? null) - ?.identity.paneKey ?? this.getPaneKeyForTerminalHandle(handle) + ?.identity.paneKey ?? + this.getPaneKeyForTerminalHandle(handle) ?? + this.getOrchestrationCompatibilitySshTerminal(handle)?.paneKey ?? + null ) } @@ -227,7 +230,7 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O try { const leaf = this.resolveLiveLeafForHandle(handle) if (!leaf?.ptyId) { - return null + return this.getOrchestrationCompatibilitySshTerminal(handle)?.paneKey ?? null } const pty = this.ptysById.get(leaf.ptyId) return pty?.connected === false ? null : this.getPaneKeyForTerminalHandle(handle) diff --git a/src/main/runtime/terminal/output/headless-buffer-snapshot.ts b/src/main/runtime/terminal/output/headless-buffer-snapshot.ts index ac7855e94882..c69d537a1f54 100644 --- a/src/main/runtime/terminal/output/headless-buffer-snapshot.ts +++ b/src/main/runtime/terminal/output/headless-buffer-snapshot.ts @@ -1,10 +1,19 @@ import { OrcaRuntimeWithVisibleSnapshotPreview } from './terminal-visible-state' import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' -import type { OrchestrationCompatibilitySshAttachmentAuthority } from '../../runtime-terminal-contracts' +import type { + OrchestrationCompatibilitySshAttachmentAuthority, + OrchestrationCompatibilitySshTerminal +} from '../../runtime-terminal-contracts' import { randomUUID } from 'node:crypto' export abstract class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends OrcaRuntimeWithVisibleSnapshotPreview { + /** Keyed by the attachment that carries each one, so releasing it forgets the terminal. */ + protected readonly orchestrationCompatibilitySshTerminals = new Map< + string, + OrchestrationCompatibilitySshTerminal + >() + protected abstract getLeafKey(tabId: string, leafId: string): string protected async serializeHeadlessTerminalBuffer( @@ -139,7 +148,8 @@ export abstract class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends Orc registerOrchestrationCompatibilitySshAttachment( targetId: string, - connectionIncarnation: string + connectionIncarnation: string, + terminal?: OrchestrationCompatibilitySshTerminal ): OrchestrationCompatibilitySshAttachmentAuthority { const authority = Object.freeze({ kind: 'ssh' as const, @@ -148,10 +158,29 @@ export abstract class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends Orc attachmentId: randomUUID() }) this.orchestrationCompatibilitySshAttachments.set(authority.attachmentId, authority) + if (terminal) { + this.orchestrationCompatibilitySshTerminals.set( + authority.attachmentId, + Object.freeze({ ...terminal }) + ) + } return authority } releaseOrchestrationCompatibilitySshAttachment(attachmentId: string): void { this.orchestrationCompatibilitySshAttachments.delete(attachmentId) + this.orchestrationCompatibilitySshTerminals.delete(attachmentId) + } + + /** Only while an attachment holds it, and only for a handle this runtime does not host. */ + protected getOrchestrationCompatibilitySshTerminal( + handle: string + ): OrchestrationCompatibilitySshTerminal | undefined { + for (const terminal of this.orchestrationCompatibilitySshTerminals.values()) { + if (terminal.handle === handle) { + return terminal + } + } + return undefined } } diff --git a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts index bbc2eca3b955..a3a4d20fa49d 100644 --- a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts +++ b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts @@ -180,7 +180,7 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt const live = this.getLivePtyForHandle(handle) const record = live?.record ?? this.handles.get(handle) if (!record?.ptyId) { - return null + return this.getOrchestrationCompatibilitySshTerminal(handle)?.processIncarnation ?? null } const incarnationId = live?.pty.incarnationId ?? this.ptysById.get(record.ptyId)?.incarnationId if (incarnationId) { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts index ccc9dd85d969..033d9049a0b0 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts @@ -6,6 +6,7 @@ import { ipcMain, type BrowserWindow } from 'electron' import { z } from 'zod' import { LEGACY_RELAY_BRIDGE_CHANNELS as CHANNELS } from '../../../shared/legacy-relay-bridge-contract' import type { Store } from '../../persistence' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { track } from '../../telemetry/client' import type { SshConnection } from '../ssh-connection' import { knownSshHostPlatform } from '../ssh-host-platform-memo' @@ -29,9 +30,10 @@ const PausedArgsSchema = z.object({ ptyId: PtyIdSchema, paused: z.boolean() }) const ResizeArgsSchema = z.object({ ptyId: PtyIdSchema, cols: SizeSchema, rows: SizeSchema }) export function registerLegacyRelayBridgeIpc(args: { - store: Pick + store: Pick getMainWindow: () => BrowserWindow | null connection: (targetId: string) => SshConnection | null + runtime?: () => OrcaRuntimeService | null }): void { const send = (channel: string, payload: unknown): void => { const contents = args.getMainWindow()?.webContents @@ -44,6 +46,16 @@ export function registerLegacyRelayBridgeIpc(args: { retireLease: (targetId, relayPtyId) => args.store.markSshRemotePtyLease(targetId, relayPtyId, 'terminated'), connection: args.connection, + ...(args.runtime + ? { + remoteCli: { + runtime: args.runtime, + // Why strict true: the setting is opt-in, and an absent value is off. + allowed: (targetId: string) => + args.store.getSshTarget(targetId)?.allowRemoteCliControl === true + } + } + : {}), onPanesChanged: (panes) => send(CHANNELS.panes, panes), onData: (ptyId, data) => send(CHANNELS.data, { ptyId, data }), onReplay: (ptyId, replay) => send(CHANNELS.replay, { ptyId, replay }), diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts index b2becf9c0d13..b7920ab17daa 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts @@ -4,6 +4,10 @@ */ import type { SshRemotePtyLease } from '../../../shared/ssh-types' import type { LegacyRelayBridgedPane } from '../../../shared/legacy-relay-bridge-contract' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { LegacyRelayPty } from './legacy-relay-client' + +export type { LegacyRelayPty } /** How long an attach waits for an unreachable relay before answering `unverifiable`. */ export const ATTACH_WAIT_MS = 20_000 @@ -37,6 +41,36 @@ export type Pane = { relayPtyId: string endpoint: string | null state: 'live' | 'unverifiable' + /** What the listing relay claimed about the process, for the relay-era CLI's identity checks. */ + listed?: LegacyRelayPty +} + +export type LegacyRelayLivePane = { ptyId: string; incarnationId?: string; terminalHandle?: string } + +/** The live leased pane a pane key names; the lease is the desktop's own record of that pane. */ +export function findLivePane( + panes: ReadonlyMap, + paneKey: string +): LegacyRelayLivePane | null { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + for (const [ptyId, pane] of panes) { + if ( + pane.state === 'live' && + pane.lease.tabId === parsed.tabId && + pane.lease.leafId === parsed.leafId + ) { + const { incarnationId, terminalHandle } = pane.listed ?? {} + return { + ptyId, + ...(incarnationId ? { incarnationId } : {}), + ...(terminalHandle ? { terminalHandle } : {}) + } + } + } + return null } export function toBridgedPane(targetId: string, ptyId: string, pane: Pane): LegacyRelayBridgedPane { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts index 55a040040040..b53700109a62 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts @@ -13,7 +13,9 @@ import type { SshConnection } from '../ssh-connection' import { waitForSentinel } from '../ssh-relay-sentinel' import { probeRelayEndpointIncumbent } from '../ssh-relay-endpoint-incumbent' import { isWindowsRemoteHost } from '../ssh-remote-platform' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { LegacyRelayClient } from './legacy-relay-client' +import { createLegacyRelayCliServer } from './legacy-relay-cli-server' import { listLegacyRelayEndpoints, type LegacyRelayEndpoint } from './legacy-relay-endpoints' import { LegacyRelayTargetBridge } from './legacy-relay-target-bridge' import { LegacyRelayHiddenOutput } from './legacy-relay-hidden-output' @@ -35,6 +37,12 @@ export type LegacyRelayBridgeHost = { onReplay: (ptyId: string, replay: string) => void onExit: (ptyId: string, code: number) => void report: (targetId: string, phase: 'connected' | 'terminal_exited', terminals: number) => void + /** Absent: relay-era `orca` commands are refused, as before this host served them. */ + remoteCli?: { + runtime: () => OrcaRuntimeService | null + /** The per-host "Allow this host's orca CLI to control Orca" switch. */ + allowed: (targetId: string) => boolean + } } let host: LegacyRelayBridgeHost | null = null @@ -93,7 +101,16 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { } return conn } - const bridge = new LegacyRelayTargetBridge({ + const remoteCli = installed.remoteCli + const cliServer = remoteCli + ? createLegacyRelayCliServer({ + targetId, + runtime: remoteCli.runtime, + remoteCliControl: () => remoteCli.allowed(targetId), + livePane: (paneKey) => bridge.livePaneForKey(paneKey) + }) + : undefined + const bridge: LegacyRelayTargetBridge = new LegacyRelayTargetBridge({ targetId, leases: () => installed.leases(targetId), retireLease: (relayPtyId) => installed.retireLease(targetId, relayPtyId), @@ -105,6 +122,7 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { LegacyRelayClient.open({ clientInstanceId, resume, + cliServer, openTransport: async () => waitForSentinel( await connection().exec(endpoint.connectCommand ?? '', { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts new file mode 100644 index 000000000000..a5740b83817f --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts @@ -0,0 +1,101 @@ +/** + * Reads the argv an older relay forwards for one `orca` command run in its terminal. + * COMPAT(legacy-relay-bridge): revisit 2027-01-15. + */ +import { CLI_BOOLEAN_FLAGS } from '../../../shared/cli-argument-boundary' +import { + isOrchestrationRetryRequestId, + RETRY_REQUEST_ID_GUIDANCE +} from '../../../shared/orchestration-retry-request-id' + +export type LegacyRelayCliArgs = { command: string; flags: Map } + +export function parseLegacyRelayCliArgs(argv: readonly string[]): LegacyRelayCliArgs { + const commandPath: string[] = [] + const flags = new Map() + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] + if (!token.startsWith('--')) { + commandPath.push(token) + continue + } + const assignment = token.slice(2) + const equals = assignment.indexOf('=') + if (equals !== -1) { + flags.set(assignment.slice(0, equals), assignment.slice(equals + 1)) + continue + } + const next = argv[i + 1] + if (!CLI_BOOLEAN_FLAGS.has(assignment) && next !== undefined && !next.startsWith('--')) { + flags.set(assignment, next) + i += 1 + } else { + flags.set(assignment, true) + } + } + return { command: commandPath.join(' '), flags } +} + +/** A valued flag the shell emptied parses as `true`; dropping it would change the command. */ +export function stringFlag(flags: Map, name: string): string | undefined { + const value = flags.get(name) + if (value === true) { + throw new Error(`--${name} requires a value; it was passed with none.`) + } + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export function numberFlag(flags: Map, name: string): number | undefined { + const value = stringFlag(flags, name) + if (value === undefined) { + return undefined + } + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid numeric value for --${name}`) + } + return parsed +} + +/** A damaged `--retry-request` must fail: a fresh identity would re-apply the mutation (#15180). */ +export function retryRequestFlag(flags: Map): string | undefined { + const value = stringFlag(flags, 'retry-request') + if (value !== undefined && !isOrchestrationRetryRequestId(value)) { + throw new Error(RETRY_REQUEST_ID_GUIDANCE) + } + return value +} + +/** `--payload`, or the structured flags the worker preamble uses instead of raw JSON. */ +export function messagePayload(flags: Map): string | undefined { + const raw = stringFlag(flags, 'payload') + const fields = { + taskId: stringFlag(flags, 'task-id'), + dispatchId: stringFlag(flags, 'dispatch-id'), + outcome: stringFlag(flags, 'outcome'), + reportPath: stringFlag(flags, 'report-path'), + phase: stringFlag(flags, 'phase') + } + const filesModified = stringFlag(flags, 'files-modified') + const structured = Object.values(fields).some(Boolean) || filesModified !== undefined + if (!structured) { + return raw + } + if (raw !== undefined) { + throw new Error('Use either --payload or structured payload flags, not both.') + } + if (fields.outcome && fields.outcome !== 'succeeded' && fields.outcome !== 'failed') { + throw new Error('Invalid --outcome. Expected succeeded or failed.') + } + return JSON.stringify({ + ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value)), + ...(filesModified + ? { + filesModified: filesModified + .split(',') + .map((file) => file.trim()) + .filter(Boolean) + } + : {}) + }) +} diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts new file mode 100644 index 000000000000..fc9ea21bf1e4 --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts @@ -0,0 +1,204 @@ +/** + * Who a relay-era terminal's `orca` command speaks as, and what it may reach. + * COMPAT(legacy-relay-bridge): revisit 2027-01-15. + * + * Everything the host sends is a claim. The caller is accepted only as a pane this desktop leased + * on that host, which its relay still runs, and which this desktop's orchestration records bind to + * the claimed handle (as a Dispatch assignee or a Run coordinator). It then reaches only its own + * mailboxes, its own Dispatch or Run, and the other party of that same orchestration. + */ +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { OrchestrationDb } from '../../runtime/orchestration/db' +import { isEquivalentPaneKey } from '../../runtime/orchestration/db/pane-key-match' +import type { DispatchContextRow, RunRow } from '../../runtime/orchestration/types' +import type { LegacyRelayLivePane } from './legacy-relay-bridge-leases' + +const MAX_IDENTITY_LENGTH = 512 + +export const LEGACY_RELAY_CLI_UNVERIFIED = + "Orca could not confirm this terminal is one of this host's supervised Orca terminals, so its " + + 'orca command was refused. Run it in a new terminal on this host.' + +export type LegacyRelayCliCaller = { + handle: string + paneKey: string + ptyId: string + processIncarnation?: string + /** The Dispatch this pane holds as its assignee. */ + dispatch?: DispatchContextRow + /** The Run this pane coordinates. */ + run?: RunRow +} + +export type LegacyRelayCliCall = { method: string; params: Record } + +function bounded(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && trimmed.length <= MAX_IDENTITY_LENGTH ? trimmed : undefined +} + +function samePane(recorded: string | null | undefined, paneKey: string): boolean { + return recorded ? isEquivalentPaneKey(recorded, paneKey) : false +} + +export function resolveLegacyRelayCliCaller( + runtime: OrcaRuntimeService, + env: Readonly>, + livePane: (paneKey: string) => LegacyRelayLivePane | null +): LegacyRelayCliCaller { + const handle = bounded(env.ORCA_TERMINAL_HANDLE) + const paneKey = bounded(env.ORCA_PANE_KEY) + const pane = handle && paneKey && parsePaneKey(paneKey) ? livePane(paneKey) : null + if (!handle || !paneKey || !pane) { + throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) + } + // The relay names the handle it launched the pane with; a different claim is someone else's. + if (pane.terminalHandle && pane.terminalHandle !== handle) { + throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) + } + // A handle this runtime hosts elsewhere belongs to that terminal, never to a relay pane. + const hostedPane = runtime.getTerminalPaneKey(handle) + if (hostedPane && !isEquivalentPaneKey(hostedPane, paneKey)) { + throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) + } + const db = runtime.getOrchestrationDb() + const candidate = db.getActiveDispatchForIdentity(handle, paneKey) + const dispatch = + candidate?.assignee_handle === handle && samePane(candidate.assignee_pane_key, paneKey) + ? candidate + : undefined + const bound = db.getCurrentRunForPane(paneKey) + const run = bound?.coordinator_handle === handle ? bound : undefined + if (!dispatch && !run) { + throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) + } + return { + handle, + paneKey, + ptyId: pane.ptyId, + ...(pane.incarnationId ? { processIncarnation: `${pane.ptyId}:${pane.incarnationId}` } : {}), + ...(dispatch ? { dispatch } : {}), + ...(run ? { run } : {}) + } +} + +function outside(subject: string): Error { + return new Error( + `A terminal started by an older version of Orca can reach only its own orchestration, and ${subject} is outside it.` + ) +} + +function stringParam(params: Record, name: string): string | undefined { + const value = params[name] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function payloadDispatchId(payload: string | undefined): string | undefined { + if (!payload) { + return undefined + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && 'dispatchId' in parsed + ? typeof parsed.dispatchId === 'string' + ? parsed.dispatchId + : undefined + : undefined + } catch { + // The handler owns malformed payloads; nothing here names a Dispatch. + return undefined + } +} + +/** A Dispatch the caller is a party to: its own, or one in the Run it coordinates on this host. */ +function isPartyDispatch( + db: OrchestrationDb, + caller: LegacyRelayCliCaller, + dispatchId: string, + livePane: (paneKey: string) => LegacyRelayLivePane | null +): boolean { + if (dispatchId === caller.dispatch?.id) { + return true + } + const named = db.getDispatchContextById(dispatchId) + return Boolean( + caller.run && + named?.run_id === caller.run.id && + named.assignee_pane_key && + livePane(named.assignee_pane_key) + ) +} + +function ownedAddresses(caller: LegacyRelayCliCaller): Set { + return new Set([ + caller.handle, + ...(caller.dispatch ? [`dispatch:${caller.dispatch.id}`] : []), + ...(caller.run ? [`run:${caller.run.id}`] : []) + ]) +} + +function isAllowedRecipient( + db: OrchestrationDb, + caller: LegacyRelayCliCaller, + to: string, + livePane: (paneKey: string) => LegacyRelayLivePane | null +): boolean { + if (ownedAddresses(caller).has(to)) { + return true + } + const dispatch = caller.dispatch + if (dispatch) { + const coordinator = db.getRun(dispatch.run_id)?.coordinator_handle + if ( + to === `run:${dispatch.run_id}` || + (dispatch.creator_handle && to === dispatch.creator_handle) || + (coordinator && to === coordinator) + ) { + return true + } + } + if (to.startsWith('dispatch:')) { + return isPartyDispatch(db, caller, to.slice('dispatch:'.length), livePane) + } + const worker = caller.run ? db.getActiveDispatchForIdentity(to) : undefined + return Boolean(worker && isPartyDispatch(db, caller, worker.id, livePane)) +} + +/** Throws unless the call stays inside the caller's own orchestration. */ +export function assertLegacyRelayCliScope( + db: OrchestrationDb, + caller: LegacyRelayCliCaller, + call: LegacyRelayCliCall, + livePane: (paneKey: string) => LegacyRelayLivePane | null +): void { + const { method, params } = call + const selfParam = method === 'orchestration.check' || method === 'orchestration.inbox' + const named = stringParam(params, selfParam ? 'terminal' : 'from') + if (named !== caller.handle) { + throw outside(`terminal '${named ?? ''}'`) + } + const run = stringParam(params, 'run') + if (run && run !== caller.dispatch?.run_id && run !== caller.run?.id) { + throw outside(`run '${run}'`) + } + if (method === 'orchestration.reply') { + const id = stringParam(params, 'id') ?? '' + const original = db.getMessageById(id) + if (!original || !ownedAddresses(caller).has(original.to_handle)) { + throw outside(`message '${id}'`) + } + return + } + if (method !== 'orchestration.send' && method !== 'orchestration.ask') { + return + } + const dispatchId = payloadDispatchId(stringParam(params, 'payload')) + if (dispatchId && !isPartyDispatch(db, caller, dispatchId, livePane)) { + throw outside(`dispatch '${dispatchId}'`) + } + const to = stringParam(params, 'to') + if (to && !isAllowedRecipient(db, caller, to, livePane)) { + throw outside(`recipient '${to}'`) + } +} diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-output.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-output.ts new file mode 100644 index 000000000000..d43e8be7f180 --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-output.ts @@ -0,0 +1,148 @@ +/** + * What a relay-era terminal prints for a served `orca orchestration` command, and the ack its + * relay sends back once that output is on screen. + * COMPAT(legacy-relay-bridge): revisit 2027-01-15. + */ +import { + formatOrchestrationCheckText, + prepareOrchestrationCheckOutput, + type OrchestrationCheckOutput +} from '../../../shared/orchestration-check-output' +import type { RpcResponse } from '../../runtime/rpc/core' +import { stringFlag } from './legacy-relay-cli-args' +import type { LegacyRelayCliCall, LegacyRelayCliCaller } from './legacy-relay-cli-caller' + +export type CliResult = { + stdout: string + stderr: string + exitCode: number + postOutput?: PostOutput +} +export type PostOutput = + | { kind: 'legacy_check_ack'; terminal: string; messageIds: string[]; types?: string[] } + | { kind: 'legacy_question_ack'; terminal: string; questionId: string; answerMessageId: string } + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function formatError(response: Extract, json: boolean): CliResult { + return json + ? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '', exitCode: 1 } + : { stdout: '', stderr: `${response.error.message}\n`, exitCode: 1 } +} + +function exitCodeFor(method: string, result: Record): number { + const lifecycle = result.lifecycle + if (isRecord(lifecycle) && lifecycle.action === 'rejected') { + return 1 + } + if (method !== 'orchestration.ask') { + return 0 + } + if (isRecord(result.legacyCompatibility) && result.legacyCompatibility.resumeRequired === true) { + return 75 + } + return result.timedOut === true || result.cancelled === true ? 1 : 0 +} + +export function formatLegacyRelayCliResult( + call: LegacyRelayCliCall, + response: RpcResponse, + caller: LegacyRelayCliCaller, + flags: Map +): CliResult { + const json = flags.has('json') + if (!response.ok) { + return formatError(response, json) + } + const result = isRecord(response.result) ? response.result : {} + const exitCode = exitCodeFor(call.method, result) + const postOutput = legacyPostOutput(call, result, caller, flags) + const finish = (stdout: string, stderr = ''): CliResult => ({ + stdout, + stderr, + exitCode, + ...(postOutput ? { postOutput } : {}) + }) + if (call.method === 'orchestration.check') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: orchestration.check's handler returns this output shape on success. + const check = response.result as OrchestrationCheckOutput + const prepared = prepareOrchestrationCheckOutput(check, caller.handle, flags.has('format')) + return json + ? finish(`${JSON.stringify({ ...response, result: prepared }, null, 2)}\n`) + : finish(`${formatOrchestrationCheckText(prepared, caller.handle)}\n`) + } + if (json) { + return finish(`${JSON.stringify(response, null, 2)}\n`) + } + if (call.method === 'orchestration.ask') { + return finish(...formatAsk(result)) + } + return finish(`${JSON.stringify(result)}\n`) +} + +function formatAsk(result: Record): [string, string] { + const question = String(result.messageId ?? 'unknown') + const compatibility = isRecord(result.legacyCompatibility) ? result.legacyCompatibility : {} + if (compatibility.resumeRequired === true && typeof compatibility.resumeCommand === 'string') { + return [`Question ${question} committed.\nResume with: ${compatibility.resumeCommand}\n`, ''] + } + const answer = typeof result.answer === 'string' && result.answer ? `${result.answer}\n` : '' + if (result.timedOut === true) { + return [ + answer, + `ask timeout after ${String(result.timeoutMs ?? 0)}ms; question ${question} is still pending. Resume waiting with --resume ${question}; do not ask again.\n` + ] + } + return result.cancelled === true + ? [answer, `ask cancelled (question ${question})\n`] + : [answer, ''] +} + +/** The old relay sends this back once the output above is on the terminal (legacy-contract acks). */ +function legacyPostOutput( + call: LegacyRelayCliCall, + result: Record, + caller: LegacyRelayCliCaller, + flags: Map +): PostOutput | undefined { + const compatibility = isRecord(result.legacyCompatibility) ? result.legacyCompatibility : null + if (!compatibility) { + return undefined + } + const ids = compatibility.ackMessageIds + if ( + call.method === 'orchestration.check' && + Array.isArray(ids) && + ids.length > 0 && + ids.every((id): id is string => typeof id === 'string') + ) { + const types = stringFlag(flags, 'types') + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) + return { + kind: 'legacy_check_ack', + terminal: caller.handle, + messageIds: ids, + ...(types ? { types } : {}) + } + } + const ack = compatibility.answerAcknowledgement + if ( + call.method === 'orchestration.ask' && + typeof result.answer === 'string' && + isRecord(ack) && + typeof ack.questionId === 'string' && + typeof ack.answerMessageId === 'string' + ) { + return { + kind: 'legacy_question_ack', + terminal: caller.handle, + questionId: ack.questionId, + answerMessageId: ack.answerMessageId + } + } + return undefined +} diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts new file mode 100644 index 000000000000..99e1e4c9f952 --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts @@ -0,0 +1,233 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { LEGACY_RELAY_CLI_UNVERIFIED } from './legacy-relay-cli-caller' +import { + createLegacyRelayCliServer, + LEGACY_RELAY_CLI_CONTROL_OFF, + LEGACY_RELAY_CLI_UNSUPPORTED +} from './legacy-relay-cli-server' +import type { LegacyRelayLivePane } from './legacy-relay-bridge-leases' + +const TARGET = 'target-1' +const WORKER = 'term_relay_worker' +const WORKER_PANE = 'tab_w:33333333-3333-4333-8333-333333333333' +const COORDINATOR = 'term_local_coord' +const COORDINATOR_PANE = 'tab_c:44444444-4444-4444-8444-444444444444' +const PTY_ID = toAppSshPtyId(TARGET, 'pty-7') +const ENV = { ORCA_TERMINAL_HANDLE: WORKER, ORCA_PANE_KEY: WORKER_PANE } + +type Result = { stdout: string; stderr: string; exitCode: number } + +let dir: string +let db: OrchestrationDb +let runtime: OrcaRuntimeService +let allowed: boolean +let panes: Map +let ids: { runId: string; taskId: string; dispatchId: string } + +function server() { + return createLegacyRelayCliServer({ + targetId: TARGET, + runtime: () => runtime, + remoteCliControl: () => allowed, + livePane: (paneKey) => panes.get(paneKey) ?? null + }) +} + +async function run(argv: string[], env: Record = ENV): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the CLI server always resolves this shape. + return (await server().cli({ argv, env, cwd: '/' })) as Result +} + +function json(result: Result): { ok: boolean; result?: Record } { + return JSON.parse(result.stdout) +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-legacy-relay-cli-')) + db = new OrchestrationDb(join(dir, 'orchestration.db')) + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + allowed = true + const orchestrationRun = db.createRun({ + objective: 'retained work', + coordinatorHandle: COORDINATOR, + coordinatorPaneKey: COORDINATOR_PANE + }) + const task = db.createTask({ + runId: orchestrationRun.id, + spec: 'finish it', + createdByTerminalHandle: COORDINATOR + }) + // The relay era recorded the worker's process as its SSH PTY id and relay incarnation. + const dispatch = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: WORKER, + assigneePaneKey: WORKER_PANE, + processIncarnation: `${PTY_ID}:inc-1`, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + ids = { runId: orchestrationRun.id, taskId: task.id, dispatchId: dispatch.id } + panes = new Map([ + [WORKER_PANE, { ptyId: PTY_ID, incarnationId: 'inc-1', terminalHandle: WORKER }] + ]) +}) + +afterEach(() => { + db.close() + rmSync(dir, { recursive: true, force: true }) +}) + +const workerDone = () => [ + 'orchestration', + 'send', + '--type', + 'worker_done', + '--subject', + 'done', + '--task-id', + ids.taskId, + '--dispatch-id', + ids.dispatchId, + '--outcome', + 'succeeded', + '--json' +] + +describe('the relay-era orca CLI on the desktop runtime', () => { + it("serves a retained worker's inbox check and worker_done as that worker", async () => { + const mail = db.insertMessage({ + runId: ids.runId, + from: COORDINATOR, + to: `dispatch:${ids.dispatchId}`, + subject: 'guidance', + deliveryContract: 'current_delivery' + }) + + const check = await run(['orchestration', 'check', '--json']) + expect(check.exitCode).toBe(0) + expect(json(check).result).toMatchObject({ + dispatchId: ids.dispatchId, + messages: [{ id: mail.id }] + }) + + const done = await run(workerDone()) + expect(json(done)).toMatchObject({ ok: true }) + expect(json(done).result).not.toHaveProperty('lifecycle.action', 'rejected') + expect(done.exitCode).toBe(0) + expect(db.getTask(ids.taskId)?.status).toBe('completed') + // The verified identity lives only as long as the call. + expect(runtime.getTerminalProcessIncarnation(WORKER)).toBeNull() + expect(runtime.getTerminalPaneKey(WORKER)).toBeNull() + }) + + it('refuses every command while the per-host switch is off', async () => { + allowed = false + await expect(run(workerDone())).rejects.toThrow(LEGACY_RELAY_CLI_CONTROL_OFF) + expect(db.getTask(ids.taskId)?.status).toBe('dispatched') + }) + + it('refuses commands outside orchestration', async () => { + await expect(run(['worktree', 'list'])).rejects.toThrow(LEGACY_RELAY_CLI_UNSUPPORTED) + await expect(run(['terminal', 'send', '--terminal', COORDINATOR])).rejects.toThrow( + LEGACY_RELAY_CLI_UNSUPPORTED + ) + }) + + it.each([ + ['a pane the bridge does not run', () => panes.clear(), ENV], + [ + 'a handle the relay did not launch the pane with', + () => panes.set(WORKER_PANE, { ptyId: PTY_ID, terminalHandle: 'term_other' }), + ENV + ], + ['no pane key', () => {}, { ORCA_TERMINAL_HANDLE: WORKER }], + [ + 'a handle the desktop records do not bind to the pane', + () => {}, + { ORCA_TERMINAL_HANDLE: COORDINATOR, ORCA_PANE_KEY: WORKER_PANE } + ], + [ + 'a handle this runtime hosts in another pane', + () => vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue(COORDINATOR_PANE), + ENV + ] + ])('refuses %s', async (_case, arrange, env) => { + arrange() + await expect(run(workerDone(), env)).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED) + expect(db.getTask(ids.taskId)?.status).toBe('dispatched') + }) + + it('keeps a retained worker inside its own orchestration', async () => { + const coordinatorMail = db.insertMessage({ + runId: ids.runId, + from: WORKER, + to: COORDINATOR, + subject: 'not yours', + deliveryContract: 'current_delivery' + }) + const otherTask = db.createTask({ + runId: ids.runId, + spec: 'other', + createdByTerminalHandle: COORDINATOR + }) + const other = db.createDispatchContext({ + taskId: otherTask.id, + assigneeHandle: 'term_local_worker', + assigneePaneKey: 'tab_x:55555555-5555-4555-8555-555555555555', + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + const refusals: string[][] = [ + ['orchestration', 'check', '--terminal', COORDINATOR], + ['orchestration', 'inbox', '--terminal', COORDINATOR], + ['orchestration', 'send', '--from', COORDINATOR, '--to', WORKER, '--subject', 's'], + ['orchestration', 'send', '--to', 'term_local_worker', '--subject', 's'], + ['orchestration', 'send', '--to', `dispatch:${other.id}`, '--subject', 's'], + ['orchestration', 'send', '--to', '@all', '--subject', 's'], + [ + 'orchestration', + 'send', + '--subject', + 's', + '--dispatch-id', + other.id, + '--task-id', + otherTask.id + ], + ['orchestration', 'check', '--run', 'run_someone_else'], + ['orchestration', 'reply', '--id', coordinatorMail.id, '--body', 'b'], + ['orchestration', 'ask', '--to', 'term_local_worker', '--question', 'q'] + ] + for (const argv of refusals) { + await expect(run(argv), argv.join(' ')).rejects.toThrow(/outside it/) + } + expect(db.getInbox(100)).toHaveLength(1) + }) + + it('sends to its own coordinator', async () => { + const sent = await run([ + 'orchestration', + 'send', + '--to', + COORDINATOR, + '--subject', + 'progress', + '--json' + ]) + expect(json(sent)).toMatchObject({ ok: true }) + }) + + it('refuses a malformed post-output action', async () => { + await expect(server().postOutput({ env: ENV, postOutput: { kind: 'other' } })).rejects.toThrow( + 'Invalid SSH CLI post-output action.' + ) + }) +}) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts new file mode 100644 index 000000000000..b6475f8ce2bb --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts @@ -0,0 +1,267 @@ +/** + * Serves the `orca orchestration` commands a relay-era terminal still runs, on this desktop's + * runtime, where that terminal's Dispatch and Run records live. + * COMPAT(legacy-relay-bridge): revisit 2027-01-15. + * + * Gated per host by "Allow this host's orca CLI to control Orca" and read on every call. Calls run + * through a dispatcher that registers only the orchestration methods below, as a paired caller with + * no grants (the desktop browser bridge's shape), after legacy-relay-cli-caller.ts bound them. + */ +import { randomUUID } from 'node:crypto' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RpcResponse } from '../../runtime/rpc/core' +import { RpcDispatcher } from '../../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../../runtime/rpc/methods' +import { + assertLegacyRelayCliScope, + resolveLegacyRelayCliCaller, + type LegacyRelayCliCall, + type LegacyRelayCliCaller +} from './legacy-relay-cli-caller' +import { + messagePayload, + numberFlag, + parseLegacyRelayCliArgs, + retryRequestFlag, + stringFlag +} from './legacy-relay-cli-args' +import type { LegacyRelayCliServer } from './legacy-relay-client' +import { formatLegacyRelayCliResult, isRecord } from './legacy-relay-cli-output' +import type { LegacyRelayLivePane } from './legacy-relay-bridge-leases' + +const METHODS: ReadonlySet = new Set([ + 'orchestration.check', + 'orchestration.inbox', + 'orchestration.send', + 'orchestration.ask', + 'orchestration.reply' +]) + +const COMMANDS: ReadonlySet = new Set( + [...METHODS].map((method) => method.replace('.', ' ')) +) + +export const LEGACY_RELAY_CLI_CONTROL_OFF = + 'This terminal was started by an older version of Orca. Its orca orchestration commands reach ' + + 'this desktop only when "Allow this host\'s orca CLI to control Orca" is on for this host in ' + + 'Settings > SSH. Otherwise, run them in a new terminal on this host.' + +export const LEGACY_RELAY_CLI_UNSUPPORTED = + 'This terminal was started by an older version of Orca, so only orca orchestration check, ' + + 'inbox, send, ask and reply work in it. Run other orca commands in a new terminal on this host.' + +export type LegacyRelayCliServerDeps = { + targetId: string + runtime: () => OrcaRuntimeService | null + /** The per-host switch, read on every call so turning it off applies to the next command. */ + remoteCliControl: () => boolean + livePane: (paneKey: string) => LegacyRelayLivePane | null +} + +const dispatchers = new WeakMap() + +function dispatcherFor(runtime: OrcaRuntimeService): RpcDispatcher { + let dispatcher = dispatchers.get(runtime) + if (!dispatcher) { + dispatcher = new RpcDispatcher({ + runtime, + // Why a registry of only these methods: even a bug upstream cannot reach anything else. + methods: ALL_RPC_METHODS.filter((method) => METHODS.has(method.name)), + callerScope: { kind: 'runtime-paired', grants: [] } + }) + dispatchers.set(runtime, dispatcher) + } + return dispatcher +} + +function stringRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ) +} + +/** The RPC the old CLI command names, with the caller bound from the verified pane. */ +function buildCall( + command: string, + flags: Map, + caller: LegacyRelayCliCaller +): LegacyRelayCliCall { + const flag = (name: string) => stringFlag(flags, name) + const has = (name: string) => flags.has(name) + const self = { terminal: flag('terminal') ?? caller.handle } + switch (command) { + case 'orchestration check': + return { + method: 'orchestration.check', + params: { + ...self, + terminalPaneKey: caller.paneKey, + unread: has('unread') ? true : has('peek') ? false : undefined, + peek: has('peek') ? true : undefined, + all: has('all') ? true : undefined, + types: flag('types'), + format: has('format') ? true : undefined, + inject: has('inject') ? true : undefined, + compatibilityCliCommand: 'orca', + run: flag('run'), + ack: flag('ack'), + wait: has('wait') ? true : undefined, + timeoutMs: numberFlag(flags, 'timeout-ms') + } + } + case 'orchestration inbox': + return { + method: 'orchestration.inbox', + params: { ...self, limit: numberFlag(flags, 'limit') } + } + case 'orchestration send': + return { + method: 'orchestration.send', + params: { + from: flag('from') ?? caller.handle, + to: flag('to'), + subject: flag('subject'), + body: flag('body'), + type: flag('type'), + priority: flag('priority'), + threadId: flag('thread-id'), + payload: messagePayload(flags), + senderPaneKey: caller.paneKey, + run: flag('run') + } + } + case 'orchestration ask': + return { + method: 'orchestration.ask', + params: { + from: flag('from') ?? caller.handle, + to: flag('to'), + run: flag('run'), + question: flag('question'), + resume: flag('resume'), + options: flag('options'), + timeoutMs: numberFlag(flags, 'timeout-ms'), + compatibilityCliCommand: 'orca' + } + } + case 'orchestration reply': + return { + method: 'orchestration.reply', + params: { + id: flag('id'), + body: flag('body'), + from: flag('from') ?? caller.handle, + run: flag('run') + } + } + default: + throw new Error(LEGACY_RELAY_CLI_UNSUPPORTED) + } +} + +async function dispatchAsCaller( + deps: LegacyRelayCliServerDeps, + runtime: OrcaRuntimeService, + caller: LegacyRelayCliCaller, + env: Record, + call: LegacyRelayCliCall, + requestId: string +): Promise { + assertLegacyRelayCliScope(runtime.getOrchestrationDb(), caller, call, deps.livePane) + // Why: the runtime no longer hosts this terminal, so the attachment carries its verified identity. + const authority = runtime.registerOrchestrationCompatibilitySshAttachment( + deps.targetId, + `legacy-relay:${randomUUID()}`, + { + handle: caller.handle, + paneKey: caller.paneKey, + ...(caller.processIncarnation ? { processIncarnation: caller.processIncarnation } : {}) + } + ) + try { + const launchToken = env.ORCA_AGENT_LAUNCH_TOKEN?.trim() + return await dispatcherFor(runtime).dispatch({ + id: `legacy-relay-cli-${requestId}`, + authToken: 'legacy-relay-cli', + method: call.method, + params: call.params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: requestId, + compatibilityInvocationId: requestId, + orchestrationCompatibilityEvidence: { + terminalHandle: caller.handle, + paneKey: caller.paneKey, + ...(launchToken ? { launchToken } : {}), + host: authority + } + }) + } finally { + runtime.releaseOrchestrationCompatibilitySshAttachment(authority.attachmentId) + } +} + +function admit(deps: LegacyRelayCliServerDeps, env: Record) { + const runtime = deps.runtime() + if (!deps.remoteCliControl() || !runtime) { + throw new Error(LEGACY_RELAY_CLI_CONTROL_OFF) + } + return { runtime, caller: resolveLegacyRelayCliCaller(runtime, env, deps.livePane) } +} + +export function createLegacyRelayCliServer(deps: LegacyRelayCliServerDeps): LegacyRelayCliServer { + return { + cli: async (params) => { + const env = stringRecord(params.env) + const argv = Array.isArray(params.argv) + ? params.argv.filter((item): item is string => typeof item === 'string') + : [] + const { command, flags } = parseLegacyRelayCliArgs(argv) + if (!COMMANDS.has(command)) { + throw new Error(LEGACY_RELAY_CLI_UNSUPPORTED) + } + const { runtime, caller } = admit(deps, env) + const call = buildCall(command, flags, caller) + const requestId = retryRequestFlag(flags) ?? randomUUID() + const response = await dispatchAsCaller(deps, runtime, caller, env, call, requestId) + return formatLegacyRelayCliResult(call, response, caller, flags) + }, + postOutput: async (params) => { + const env = stringRecord(params.env) + const { runtime, caller } = admit(deps, env) + const post = isRecord(params.postOutput) ? params.postOutput : {} + const ack = + post.kind === 'legacy_check_ack' && Array.isArray(post.messageIds) + ? { + compatibilityAck: JSON.stringify({ + messageIds: post.messageIds.filter((id) => typeof id === 'string'), + ...(Array.isArray(post.types) + ? { types: post.types.filter((type) => typeof type === 'string') } + : {}) + }) + } + : post.kind === 'legacy_question_ack' && + typeof post.questionId === 'string' && + typeof post.answerMessageId === 'string' + ? { + compatibilityQuestionAck: JSON.stringify({ + questionId: post.questionId, + answerMessageId: post.answerMessageId + }) + } + : null + if (!ack) { + throw new Error('Invalid SSH CLI post-output action.') + } + const call = { method: 'orchestration.check', params: { terminal: caller.handle, ...ack } } + const response = await dispatchAsCaller(deps, runtime, caller, env, call, randomUUID()) + if (!response.ok) { + throw new Error(response.error.message) + } + return { acknowledged: true } + } + } +} diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts index 337d506e7c63..0b2651d87b5e 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts @@ -8,7 +8,7 @@ const fake = vi.hoisted(() => { const requests: Request[] = [] const notifications: Request[] = [] const instances: FakeMux[] = [] - const requestHandlers = new Map unknown>() + const requestHandlers = new Map) => unknown>() return { requestHandlers, requests, @@ -49,7 +49,7 @@ vi.mock('../ssh-channel-multiplexer', () => ({ this.notificationHandlers.push(handler) return () => {} } - onRequest(method: string, handler: () => unknown) { + onRequest(method: string, handler: (params?: Record) => unknown) { fake.requestHandlers.set(method, handler) return () => {} } @@ -216,12 +216,33 @@ describe('the legacy relay client', () => { it('answers an orca command from an old terminal with why it is unavailable', async () => { await open() - expect(() => fake.requestHandlers.get('orca.cli')?.()).toThrow(REMOTE_CLI_UNAVAILABLE) - expect(() => fake.requestHandlers.get('orca.cli.postOutput')?.()).toThrow( + await expect(fake.requestHandlers.get('orca.cli')?.({})).rejects.toThrow(REMOTE_CLI_UNAVAILABLE) + await expect(fake.requestHandlers.get('orca.cli.postOutput')?.({})).rejects.toThrow( REMOTE_CLI_UNAVAILABLE ) }) + it("hands an old terminal's orca command and its post-output ack to the CLI server", async () => { + const cli = vi.fn(async () => ({ stdout: 'ok\n', stderr: '', exitCode: 0 })) + const postOutput = vi.fn(async () => ({ acknowledged: true })) + await LegacyRelayClient.open({ + clientInstanceId: 'bridge', + cliServer: { cli, postOutput }, + openTransport: async () => ({ write: () => true, onData: () => {}, onClose: () => {} }) + }) + const params = { argv: ['orchestration', 'check'], env: { ORCA_TERMINAL_HANDLE: 'term_a' } } + await expect(fake.requestHandlers.get('orca.cli')?.(params)).resolves.toEqual({ + stdout: 'ok\n', + stderr: '', + exitCode: 0 + }) + await expect(fake.requestHandlers.get('orca.cli.postOutput')?.(params)).resolves.toEqual({ + acknowledged: true + }) + expect(cli).toHaveBeenCalledWith(params) + expect(postOutput).toHaveBeenCalledWith(params) + }) + it('hangs up only itself on close', async () => { const client = await open() const closed = vi.fn() diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts index 07dcf969b039..5959ef37453c 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts @@ -30,6 +30,12 @@ export const REMOTE_CLI_UNAVAILABLE = export type LegacyRelayOwnerClaim = { ownerGeneration: number; ownerLease: string } +/** Serves the `orca` commands run inside the old relay's terminals; params are the host's. */ +export type LegacyRelayCliServer = { + cli: (params: Record) => Promise + postOutput: (params: Record) => Promise +} + export type LegacyRelayClientOptions = { clientInstanceId: string /** A claim this client held before a drop, so the owner role resumes without waiting. */ @@ -38,6 +44,8 @@ export type LegacyRelayClientOptions = { openTransport: () => Promise /** Test seam for the owner-claim retry wait. */ sleep?: (ms: number) => Promise + /** Absent: every relay-era `orca` command is refused with REMOTE_CLI_UNAVAILABLE. */ + cliServer?: LegacyRelayCliServer } /** Another client holds this relay's terminals while attached; never displaced. */ @@ -53,6 +61,9 @@ export type LegacyRelayPty = { cwd?: string title?: string worktreeId?: string + /** The relay's own claims about the process; the bridge checks them against desktop records. */ + incarnationId?: string + terminalHandle?: string } type DataListener = (relayPtyId: string, data: string) => void @@ -74,7 +85,9 @@ const PtyRowSchema = z.object({ id: z.string().min(1), cwd: z.string().optional().catch(undefined), title: z.string().optional().catch(undefined), - worktreeId: z.string().optional().catch(undefined) + worktreeId: z.string().optional().catch(undefined), + incarnationId: z.string().min(1).max(512).optional().catch(undefined), + terminalHandle: z.string().min(1).max(512).optional().catch(undefined) }) const AttachResultSchema = z.object({ replay: z.string().optional().catch(undefined) }).nullable() @@ -101,13 +114,15 @@ function parsePtyList(value: unknown): LegacyRelayPty[] { if (!parsed.success) { return [] } - const { id, cwd, title, worktreeId } = parsed.data + const { id, cwd, title, worktreeId, incarnationId, terminalHandle } = parsed.data return [ { relayPtyId: id, ...(cwd === undefined ? {} : { cwd }), ...(title === undefined ? {} : { title }), - ...(worktreeId === undefined ? {} : { worktreeId }) + ...(worktreeId === undefined ? {} : { worktreeId }), + ...(incarnationId === undefined ? {} : { incarnationId }), + ...(terminalHandle === undefined ? {} : { terminalHandle }) } ] }) @@ -121,16 +136,18 @@ export class LegacyRelayClient { private constructor( private readonly mux: SshChannelMultiplexer, /** Null for a relay that predates owner claims (it serves any client). */ - readonly ownerClaim: LegacyRelayOwnerClaim | null + readonly ownerClaim: LegacyRelayOwnerClaim | null, + cliServer: LegacyRelayCliServer | undefined ) { mux.onNotification((method, params) => this.route(method, params)) - // The old relay forwards `orca` commands run inside its terminals to its attached client. - // This build has no relay-era remote CLI, so it says why instead of "Method not found". - for (const method of ['orca.cli', 'orca.cli.postOutput']) { - mux.onRequest(method, () => { - throw new Error(REMOTE_CLI_UNAVAILABLE) - }) - } + // The old relay forwards `orca` commands run inside its terminals to its attached client; + // without a server this says why instead of "Method not found". + mux.onRequest('orca.cli', (params) => + cliServer ? cliServer.cli(params) : Promise.reject(new Error(REMOTE_CLI_UNAVAILABLE)) + ) + mux.onRequest('orca.cli.postOutput', (params) => + cliServer ? cliServer.postOutput(params) : Promise.reject(new Error(REMOTE_CLI_UNAVAILABLE)) + ) mux.onDispose(() => this.closeListeners.forEach((listener) => listener())) } @@ -138,7 +155,7 @@ export class LegacyRelayClient { const mux = new SshChannelMultiplexer(await options.openTransport()) try { const claim = await claimOwner(mux, options) - return new LegacyRelayClient(mux, claim) + return new LegacyRelayClient(mux, claim, options.cliServer) } catch (error) { mux.dispose() throw error diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts index 0f20da73e6d9..403e14bc67e9 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts @@ -160,6 +160,32 @@ describe('one target’s legacy relay bridge', () => { expect(relay.clients[0].writes).toEqual(['ls\r']) }) + it('names a live leased pane by its pane key, with what its relay claims about it', async () => { + const leafId = '33333333-3333-4333-8333-333333333333' + leases = [lease({ leafId })] + const subject = bridge({ + openClient: async () => { + const client = fakeClient(relay) + client.list = async () => [ + { relayPtyId: 'pty-1', incarnationId: 'inc-1', terminalHandle: 'term_a' } + ] + relay.clients.push(client) + return client + } + }) + await subject.refresh() + expect(subject.livePaneForKey(`tab-1:${leafId}`)).toEqual({ + ptyId: PTY, + incarnationId: 'inc-1', + terminalHandle: 'term_a' + }) + expect(subject.livePaneForKey(`tab-2:${leafId}`)).toBeNull() + expect(subject.livePaneForKey('tab-1:not-a-leaf')).toBeNull() + relay.clients[0].drop() + // Unverifiable is not live: a pane the relay cannot vouch for names nobody. + expect(subject.livePaneForKey(`tab-1:${leafId}`)).toBeNull() + }) + it('retires the pane and hangs up when its relay reports the exit', async () => { const subject = bridge() await subject.refresh() diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts index 93a2340fde08..14219f272a63 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts @@ -19,6 +19,9 @@ import { LegacyRelayRetry, LegacyRelayWaiters } from './legacy-relay-bridge-time import { ATTACH_WAIT_MS, bridgeableLeasesByRelayId, + findLivePane, + type LegacyRelayLivePane, + type LegacyRelayPty, PROVEN_EXITED, toBridgedPane, type Pane @@ -128,6 +131,10 @@ export class LegacyRelayTargetBridge { } } + livePaneForKey(paneKey: string): LegacyRelayLivePane | null { + return findLivePane(this.panes, paneKey) + } + detach(ptyId: string): void { const count = (this.attached.get(ptyId) ?? 0) - 1 if (count > 0) { @@ -189,7 +196,7 @@ export class LegacyRelayTargetBridge { if (this.disposed) { return } - const listedBy = new Map() + const listedBy = new Map() let complete = listing.kind === 'listed' && listing.exhaustive for (const endpoint of listing.kind === 'listed' ? listing.endpoints : []) { const listed = await this.listEndpoint(endpoint) @@ -200,16 +207,16 @@ export class LegacyRelayTargetBridge { complete = false continue } - for (const relayPtyId of listed) { - listedBy.set(relayPtyId, endpoint.endpoint) + for (const row of listed) { + listedBy.set(row.relayPtyId, { ...row, endpoint: endpoint.endpoint }) } } const next = new Map() for (const [relayPtyId, lease] of leases) { const ptyId = toAppSshPtyId(this.deps.targetId, relayPtyId) - const endpoint = listedBy.get(relayPtyId) - if (endpoint) { - next.set(ptyId, { lease, relayPtyId, endpoint, state: 'live' }) + const listed = listedBy.get(relayPtyId) + if (listed) { + next.set(ptyId, { lease, relayPtyId, endpoint: listed.endpoint, state: 'live', listed }) } else if (complete) { // Every relay this target could have left answered, and none holds it. const wasLive = this.panes.get(ptyId)?.endpoint @@ -229,8 +236,8 @@ export class LegacyRelayTargetBridge { } } - /** The relay's PTY ids, an empty list for a relay proven gone, or null when it can't be asked. */ - private async listEndpoint(endpoint: LegacyRelayEndpoint): Promise { + /** The relay's PTYs, an empty list for a relay proven gone, or null when it can't be asked. */ + private async listEndpoint(endpoint: LegacyRelayEndpoint): Promise { const state = this.endpoints.get(endpoint.endpoint) ?? { endpoint, client: null, @@ -256,7 +263,7 @@ export class LegacyRelayTargetBridge { } } try { - return (await state.client.list()).map((row) => row.relayPtyId) + return await state.client.list() } catch { return null } From 8cc48c6827c8da25560e9173434f693f5899f430 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 01:42:14 -0700 Subject: [PATCH 2/8] fix(ssh): attest bridge-verified relay terminals for legacy records, replay settled receipts, cancel on disconnect --- ...rify-orchestration-compatibility-caller.ts | 30 ++++- .../legacy-relay-cli-caller.ts | 10 +- .../legacy-relay-cli-legacy-records.test.ts | 111 ++++++++++++++++++ .../legacy-relay-cli-server.test.ts | 37 +++++- .../legacy-relay-cli-server.ts | 86 +++++++++----- .../legacy-relay-client.test.ts | 4 +- .../legacy-relay-client.ts | 19 ++- 7 files changed, 253 insertions(+), 44 deletions(-) create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts diff --git a/src/main/runtime/orchestration/terminal-authority/verify-orchestration-compatibility-caller.ts b/src/main/runtime/orchestration/terminal-authority/verify-orchestration-compatibility-caller.ts index a10535ffc7a7..fc8e7dcaea68 100644 --- a/src/main/runtime/orchestration/terminal-authority/verify-orchestration-compatibility-caller.ts +++ b/src/main/runtime/orchestration/terminal-authority/verify-orchestration-compatibility-caller.ts @@ -5,6 +5,7 @@ import type { } from '../../../../shared/orchestration-compatibility-evidence' import type { OrchestrationCompatibilityCallerAuthority, + OrchestrationCompatibilitySshTerminal, OrchestrationCompatibilityTerminalAuthority } from '../../runtime-terminal-contracts' import { createHash } from 'node:crypto' @@ -28,6 +29,23 @@ export abstract class OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller exte if (!terminalHandle || !claimedPaneKey || !launchToken) { return null } + const sshHost = host?.kind === 'ssh' ? host : undefined + const bridged = sshHost ? this.verifyBridgedSshTerminal(sshHost) : undefined + if (sshHost && bridged) { + // COMPAT(legacy-relay-bridge): no hook can attest a terminal this runtime does not host; the + // bridge bound this one to its lease and Dispatch records for the attachment's one call. + return bridged.handle === terminalHandle && + bridged.paneKey === claimedPaneKey && + bridged.processIncarnation + ? this.freezeOrchestrationCompatibilityCallerAuthority( + { hostScope: { kind: 'ssh', targetId: sshHost.targetId } }, + bridged.processIncarnation, + claimedPaneKey, + terminalHandle, + createHash('sha256').update(launchToken).digest('hex') + ) + : null + } const terminal = this.getOrchestrationDispatchAuthority(terminalHandle) if ( !terminal?.processIncarnation || @@ -91,8 +109,18 @@ export abstract class OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller exte ) } + private verifyBridgedSshTerminal( + host: Extract + ): OrchestrationCompatibilitySshTerminal | undefined { + const terminal = this.orchestrationCompatibilitySshTerminals.get(host.attachmentId) + return terminal && + this.orchestrationCompatibilityHostMatches({ kind: 'ssh', targetId: host.targetId }, host) + ? terminal + : undefined + } + protected freezeOrchestrationCompatibilityCallerAuthority( - terminal: OrchestrationCompatibilityTerminalAuthority, + terminal: Pick, processIncarnation: string, paneKey: string, terminalHandle: string, diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts index fc9ea21bf1e4..629c2a7b2273 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts @@ -45,7 +45,9 @@ function samePane(recorded: string | null | undefined, paneKey: string): boolean export function resolveLegacyRelayCliCaller( runtime: OrcaRuntimeService, env: Readonly>, - livePane: (paneKey: string) => LegacyRelayLivePane | null + livePane: (paneKey: string) => LegacyRelayLivePane | null, + /** A retry of a mutation this caller already completed, which may outlive its Dispatch. */ + replay?: { fingerprint: string; requestId: string } ): LegacyRelayCliCaller { const handle = bounded(env.ORCA_TERMINAL_HANDLE) const paneKey = bounded(env.ORCA_PANE_KEY) @@ -63,7 +65,11 @@ export function resolveLegacyRelayCliCaller( throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) } const db = runtime.getOrchestrationDb() - const candidate = db.getActiveDispatchForIdentity(handle, paneKey) + const replayed = + replay && db.getMutationReceipt(replay.fingerprint, replay.requestId)?.state === 'completed' + const candidate = + db.getActiveDispatchForIdentity(handle, paneKey) ?? + (replayed ? db.getLatestDispatchForTerminal(handle) : undefined) const dispatch = candidate?.assignee_handle === handle && samePane(candidate.assignee_pane_key, paneKey) ? candidate diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts new file mode 100644 index 000000000000..8f1b429b25c7 --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import Database from '../../sqlite/sync-database' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { createRootDispatch } from '../../runtime/orchestration/db/root-dispatch-test-fixture' +import { createLegacyRelayCliServer } from './legacy-relay-cli-server' + +const TARGET = 'target-1' +const WORKER = 'term_relay_worker' +const WORKER_PANE = 'tab_w:33333333-3333-4333-8333-333333333333' +const PTY_ID = toAppSshPtyId(TARGET, 'pty-7') + +let dir: string | undefined +let db: OrchestrationDb | undefined + +afterEach(() => { + db?.close() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + db = undefined + dir = undefined +}) + +/** A desktop whose orchestration records predate the current contract (schema 18). */ +function legacyRuntime(): { runtime: OrcaRuntimeService; database: OrchestrationDb } { + dir = mkdtempSync(join(tmpdir(), 'orca-legacy-relay-records-')) + const path = join(dir, 'orchestration.db') + const before = new OrchestrationDb(path) + const task = before.createTask({ + runId: 'run_legacy_local', + spec: 'legacy assignment', + createdByTerminalHandle: 'term_legacy_coord' + }) + createRootDispatch(before, task.id, WORKER, WORKER_PANE) + before.close() + const raw = new Database(path) + raw.exec(` + UPDATE dispatch_contexts SET process_incarnation = '${PTY_ID}:inc-1'; + DROP INDEX IF EXISTS idx_messages_delivery_contract; + DROP TABLE legacy_mail_receipts; + DROP TABLE legacy_operation_receipts; + DROP TABLE legacy_compatibility_principals; + DROP TABLE legacy_adoptions; + `) + raw.pragma('user_version = 18') + raw.close() + const database = new OrchestrationDb(path) + db = database + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(database) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + return { runtime, database } +} + +describe('the relay-era orca CLI on migrated legacy orchestration records', () => { + it('attests the bridge-verified worker instead of answering read-only', async () => { + const { runtime, database } = legacyRuntime() + expect(database.getLegacyAdoption()).toBeTruthy() + const server = createLegacyRelayCliServer({ + targetId: TARGET, + runtime: () => runtime, + remoteCliControl: () => true, + livePane: (paneKey) => + paneKey === WORKER_PANE + ? { ptyId: PTY_ID, incarnationId: 'inc-1', terminalHandle: WORKER } + : null + }) + const env = { + ORCA_TERMINAL_HANDLE: WORKER, + ORCA_PANE_KEY: WORKER_PANE, + ORCA_AGENT_LAUNCH_TOKEN: 'worker-token' + } + const result = await server.cli( + { argv: ['orchestration', 'check', '--json'], env }, + new AbortController().signal + ) + expect(result).toMatchObject({ exitCode: 0 }) + const stdout = result && typeof result === 'object' && 'stdout' in result ? result.stdout : '' + expect(JSON.parse(String(stdout))).toMatchObject({ + ok: true, + result: { legacyCompatibility: { readOnly: false } } + }) + }) + + it('vouches only for the exact terminal its attachment carries', () => { + const runtime = new OrcaRuntimeService() + const terminal = { handle: WORKER, paneKey: WORKER_PANE, processIncarnation: `${PTY_ID}:inc-1` } + const host = runtime.registerOrchestrationCompatibilitySshAttachment(TARGET, 'c-1', terminal) + const evidence = { terminalHandle: WORKER, paneKey: WORKER_PANE, launchToken: 't', host } + expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toMatchObject({ + terminalHandle: WORKER, + processIncarnation: `${PTY_ID}:inc-1`, + hostScope: { kind: 'ssh', targetId: TARGET } + }) + for (const forged of [ + { ...evidence, terminalHandle: 'term_other' }, + { ...evidence, paneKey: 'tab_x:55555555-5555-4555-8555-555555555555' }, + { ...evidence, host: { ...host, targetId: 'other-target' } }, + { ...evidence, host: { ...host, connectionIncarnation: 'c-2' } } + ]) { + expect(runtime.verifyOrchestrationCompatibilityCaller(forged)).toBeNull() + } + runtime.releaseOrchestrationCompatibilitySshAttachment(host.attachmentId) + expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull() + }) +}) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts index 99e1e4c9f952..c55e9ba2403a 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts @@ -39,9 +39,13 @@ function server() { }) } -async function run(argv: string[], env: Record = ENV): Promise { +async function run( + argv: string[], + env: Record = ENV, + signal = new AbortController().signal +): Promise { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the CLI server always resolves this shape. - return (await server().cli({ argv, env, cwd: '/' })) as Result + return (await server().cli({ argv, env, cwd: '/' }, signal)) as Result } function json(result: Result): { ok: boolean; result?: Record } { @@ -226,8 +230,33 @@ describe('the relay-era orca CLI on the desktop runtime', () => { }) it('refuses a malformed post-output action', async () => { - await expect(server().postOutput({ env: ENV, postOutput: { kind: 'other' } })).rejects.toThrow( - 'Invalid SSH CLI post-output action.' + await expect( + server().postOutput({ env: ENV, postOutput: { kind: 'other' } }, new AbortController().signal) + ).rejects.toThrow('Invalid SSH CLI post-output action.') + }) + + it('replays a completed worker_done after its Dispatch settled, and nothing new', async () => { + const retry = ['--retry-request', '11111111-1111-4111-8111-111111111111'] + const first = await run([...workerDone(), ...retry]) + expect(db.getTask(ids.taskId)?.status).toBe('completed') + const replay = await run([...workerDone(), ...retry]) + expect(json(replay)).toMatchObject({ ok: true }) + expect(json(replay).result?.message).toEqual(json(first).result?.message) + await expect( + run([...workerDone(), '--retry-request', '22222222-2222-4222-8222-222222222222']) + ).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED) + }) + + it('stops a waiting check when the relay connection closes', async () => { + const closed = new AbortController() + const waiting = run( + ['orchestration', 'check', '--wait', '--timeout-ms', '60000', '--json'], + ENV, + closed.signal ) + await new Promise((resolve) => setTimeout(resolve, 50)) + closed.abort() + const result = await waiting + expect(json(result).result).toMatchObject({ cancelled: true }) }) }) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts index b6475f8ce2bb..63fa99e3fdfc 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts @@ -7,7 +7,7 @@ * through a dispatcher that registers only the orchestration methods below, as a paired caller with * no grants (the desktop browser bridge's shape), after legacy-relay-cli-caller.ts bound them. */ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { RpcResponse } from '../../runtime/rpc/core' @@ -163,11 +163,24 @@ function buildCall( } } +/** Its own receipt namespace per host terminal: a host can never replay another caller's result. */ +function callerFingerprint(targetId: string, env: Record): string { + return createHash('sha256') + .update(['legacy-relay-cli-v1', targetId, env.ORCA_TERMINAL_HANDLE ?? ''].join('\0')) + .digest('hex') +} + +type Invocation = { + runtime: OrcaRuntimeService + caller: LegacyRelayCliCaller + env: Record + fingerprint: string + signal: AbortSignal +} + async function dispatchAsCaller( deps: LegacyRelayCliServerDeps, - runtime: OrcaRuntimeService, - caller: LegacyRelayCliCaller, - env: Record, + { runtime, caller, env, fingerprint, signal }: Invocation, call: LegacyRelayCliCall, requestId: string ): Promise { @@ -184,37 +197,48 @@ async function dispatchAsCaller( ) try { const launchToken = env.ORCA_AGENT_LAUNCH_TOKEN?.trim() - return await dispatcherFor(runtime).dispatch({ - id: `legacy-relay-cli-${requestId}`, - authToken: 'legacy-relay-cli', - method: call.method, - params: call.params, - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: requestId, - compatibilityInvocationId: requestId, - orchestrationCompatibilityEvidence: { - terminalHandle: caller.handle, - paneKey: caller.paneKey, - ...(launchToken ? { launchToken } : {}), - host: authority - } - }) + return await dispatcherFor(runtime).dispatch( + { + id: `legacy-relay-cli-${requestId}`, + authToken: 'legacy-relay-cli', + method: call.method, + params: call.params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: requestId, + compatibilityInvocationId: requestId, + orchestrationCompatibilityEvidence: { + terminalHandle: caller.handle, + paneKey: caller.paneKey, + ...(launchToken ? { launchToken } : {}), + host: authority + } + }, + { signal, authenticatedCallerFingerprint: fingerprint } + ) } finally { runtime.releaseOrchestrationCompatibilitySshAttachment(authority.attachmentId) } } -function admit(deps: LegacyRelayCliServerDeps, env: Record) { +function admit( + deps: LegacyRelayCliServerDeps, + env: Record, + signal: AbortSignal, + replayRequestId?: string +): Invocation { const runtime = deps.runtime() if (!deps.remoteCliControl() || !runtime) { throw new Error(LEGACY_RELAY_CLI_CONTROL_OFF) } - return { runtime, caller: resolveLegacyRelayCliCaller(runtime, env, deps.livePane) } + const fingerprint = callerFingerprint(deps.targetId, env) + const replay = replayRequestId ? { fingerprint, requestId: replayRequestId } : undefined + const caller = resolveLegacyRelayCliCaller(runtime, env, deps.livePane, replay) + return { runtime, caller, env, fingerprint, signal } } export function createLegacyRelayCliServer(deps: LegacyRelayCliServerDeps): LegacyRelayCliServer { return { - cli: async (params) => { + cli: async (params, signal) => { const env = stringRecord(params.env) const argv = Array.isArray(params.argv) ? params.argv.filter((item): item is string => typeof item === 'string') @@ -223,15 +247,17 @@ export function createLegacyRelayCliServer(deps: LegacyRelayCliServerDeps): Lega if (!COMMANDS.has(command)) { throw new Error(LEGACY_RELAY_CLI_UNSUPPORTED) } - const { runtime, caller } = admit(deps, env) - const call = buildCall(command, flags, caller) - const requestId = retryRequestFlag(flags) ?? randomUUID() - const response = await dispatchAsCaller(deps, runtime, caller, env, call, requestId) - return formatLegacyRelayCliResult(call, response, caller, flags) + const retryRequestId = retryRequestFlag(flags) + const invocation = admit(deps, env, signal, retryRequestId) + const call = buildCall(command, flags, invocation.caller) + const requestId = retryRequestId ?? randomUUID() + const response = await dispatchAsCaller(deps, invocation, call, requestId) + return formatLegacyRelayCliResult(call, response, invocation.caller, flags) }, - postOutput: async (params) => { + postOutput: async (params, signal) => { const env = stringRecord(params.env) - const { runtime, caller } = admit(deps, env) + const invocation = admit(deps, env, signal) + const { caller } = invocation const post = isRecord(params.postOutput) ? params.postOutput : {} const ack = post.kind === 'legacy_check_ack' && Array.isArray(post.messageIds) @@ -257,7 +283,7 @@ export function createLegacyRelayCliServer(deps: LegacyRelayCliServerDeps): Lega throw new Error('Invalid SSH CLI post-output action.') } const call = { method: 'orchestration.check', params: { terminal: caller.handle, ...ack } } - const response = await dispatchAsCaller(deps, runtime, caller, env, call, randomUUID()) + const response = await dispatchAsCaller(deps, invocation, call, randomUUID()) if (!response.ok) { throw new Error(response.error.message) } diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts index 0b2651d87b5e..ade6bdfc5194 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts @@ -239,8 +239,8 @@ describe('the legacy relay client', () => { await expect(fake.requestHandlers.get('orca.cli.postOutput')?.(params)).resolves.toEqual({ acknowledged: true }) - expect(cli).toHaveBeenCalledWith(params) - expect(postOutput).toHaveBeenCalledWith(params) + expect(cli).toHaveBeenCalledWith(params, expect.any(AbortSignal)) + expect(postOutput).toHaveBeenCalledWith(params, expect.any(AbortSignal)) }) it('hangs up only itself on close', async () => { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts index 5959ef37453c..1ee0d2b427ae 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts @@ -32,8 +32,9 @@ export type LegacyRelayOwnerClaim = { ownerGeneration: number; ownerLease: strin /** Serves the `orca` commands run inside the old relay's terminals; params are the host's. */ export type LegacyRelayCliServer = { - cli: (params: Record) => Promise - postOutput: (params: Record) => Promise + /** `signal` aborts when this client's connection closes, so a waiting check stops with it. */ + cli: (params: Record, signal: AbortSignal) => Promise + postOutput: (params: Record, signal: AbortSignal) => Promise } export type LegacyRelayClientOptions = { @@ -142,13 +143,21 @@ export class LegacyRelayClient { mux.onNotification((method, params) => this.route(method, params)) // The old relay forwards `orca` commands run inside its terminals to its attached client; // without a server this says why instead of "Method not found". + const closed = new AbortController() mux.onRequest('orca.cli', (params) => - cliServer ? cliServer.cli(params) : Promise.reject(new Error(REMOTE_CLI_UNAVAILABLE)) + cliServer + ? cliServer.cli(params, closed.signal) + : Promise.reject(new Error(REMOTE_CLI_UNAVAILABLE)) ) mux.onRequest('orca.cli.postOutput', (params) => - cliServer ? cliServer.postOutput(params) : Promise.reject(new Error(REMOTE_CLI_UNAVAILABLE)) + cliServer + ? cliServer.postOutput(params, closed.signal) + : Promise.reject(new Error(REMOTE_CLI_UNAVAILABLE)) ) - mux.onDispose(() => this.closeListeners.forEach((listener) => listener())) + mux.onDispose(() => { + closed.abort() + this.closeListeners.forEach((listener) => listener()) + }) } static async open(options: LegacyRelayClientOptions): Promise { From 12404baf79783c9cfe714e463981942f9702e4da Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 01:48:27 -0700 Subject: [PATCH 3/8] fix(ssh): admit retained legacy coordinators, their delivery targets, and legacy receipt replays --- .../legacy-relay-cli-caller.ts | 63 +++++++-- .../legacy-relay-cli-legacy-records.test.ts | 128 ++++++++++++++---- 2 files changed, 154 insertions(+), 37 deletions(-) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts index 629c2a7b2273..5d097619ae46 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts @@ -65,17 +65,8 @@ export function resolveLegacyRelayCliCaller( throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) } const db = runtime.getOrchestrationDb() - const replayed = - replay && db.getMutationReceipt(replay.fingerprint, replay.requestId)?.state === 'completed' - const candidate = - db.getActiveDispatchForIdentity(handle, paneKey) ?? - (replayed ? db.getLatestDispatchForTerminal(handle) : undefined) - const dispatch = - candidate?.assignee_handle === handle && samePane(candidate.assignee_pane_key, paneKey) - ? candidate - : undefined - const bound = db.getCurrentRunForPane(paneKey) - const run = bound?.coordinator_handle === handle ? bound : undefined + const dispatch = resolveOwnDispatch(db, handle, paneKey, replay) + const run = resolveCoordinatedRun(db, handle, paneKey) if (!dispatch && !run) { throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) } @@ -89,6 +80,53 @@ export function resolveLegacyRelayCliCaller( } } +/** The Dispatch this pane holds; a settled one only to replay a result it already committed. */ +function resolveOwnDispatch( + db: OrchestrationDb, + handle: string, + paneKey: string, + replay: { fingerprint: string; requestId: string } | undefined +): DispatchContextRow | undefined { + const own = (row: DispatchContextRow | undefined) => + row?.assignee_handle === handle && samePane(row.assignee_pane_key, paneKey) ? row : undefined + const active = own(db.getActiveDispatchForIdentity(handle, paneKey)) + if (active || !replay) { + return active + } + if (db.getMutationReceipt(replay.fingerprint, replay.requestId)?.state === 'completed') { + return own(db.getLatestDispatchForTerminal(handle)) + } + // A legacy worker's results live in legacy receipts, under the principal it settled as. + const adoption = db.getLegacyAdoption() + const principal = adoption + ? db.resolveLegacyCompatibilityPrincipalByIdentity({ + runId: adoption.adopted_run_id, + role: 'worker', + paneKey + }) + : undefined + return principal?.status === 'settled' && principal.terminal_handle === handle + ? own(principal.dispatch_id ? db.getDispatchContextById(principal.dispatch_id) : undefined) + : undefined +} + +/** The Run this pane coordinates, including a migrated Run's retained legacy coordinator. */ +function resolveCoordinatedRun( + db: OrchestrationDb, + handle: string, + paneKey: string +): RunRow | undefined { + const bound = db.getCurrentRunForPane(paneKey) + if (bound) { + return bound.coordinator_handle === handle ? bound : undefined + } + const adopted = db.getLegacyAdoption()?.adopted_run_id + return adopted && + db.resolveLegacyCoordinatorCandidate({ runId: adopted, terminalHandle: handle, paneKey }) + ? db.getRun(adopted) + : undefined +} + function outside(subject: string): Error { return new Error( `A terminal started by an older version of Orca can reach only its own orchestration, and ${subject} is outside it.` @@ -159,7 +197,8 @@ function isAllowedRecipient( if ( to === `run:${dispatch.run_id}` || (dispatch.creator_handle && to === dispatch.creator_handle) || - (coordinator && to === coordinator) + (coordinator && to === coordinator) || + db.isLegacyCoordinatorDeliveryTarget(dispatch.run_id, to) ) { return true } diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts index 8f1b429b25c7..7cbc2cfad42e 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts @@ -13,6 +13,14 @@ const TARGET = 'target-1' const WORKER = 'term_relay_worker' const WORKER_PANE = 'tab_w:33333333-3333-4333-8333-333333333333' const PTY_ID = toAppSshPtyId(TARGET, 'pty-7') +const COORDINATOR = 'term_legacy_coord' +const COORDINATOR_PANE = 'tab_c:44444444-4444-4444-8444-444444444444' +const COORDINATOR_PTY = toAppSshPtyId(TARGET, 'pty-8') +const WORKER_ENV = { + ORCA_TERMINAL_HANDLE: WORKER, + ORCA_PANE_KEY: WORKER_PANE, + ORCA_AGENT_LAUNCH_TOKEN: 'worker-token' +} let dir: string | undefined let db: OrchestrationDb | undefined @@ -27,16 +35,21 @@ afterEach(() => { }) /** A desktop whose orchestration records predate the current contract (schema 18). */ -function legacyRuntime(): { runtime: OrcaRuntimeService; database: OrchestrationDb } { +function legacyRuntime(): { + runtime: OrcaRuntimeService + database: OrchestrationDb + taskId: string + dispatchId: string +} { dir = mkdtempSync(join(tmpdir(), 'orca-legacy-relay-records-')) const path = join(dir, 'orchestration.db') const before = new OrchestrationDb(path) const task = before.createTask({ runId: 'run_legacy_local', spec: 'legacy assignment', - createdByTerminalHandle: 'term_legacy_coord' + createdByTerminalHandle: COORDINATOR }) - createRootDispatch(before, task.id, WORKER, WORKER_PANE) + const dispatch = createRootDispatch(before, task.id, WORKER, WORKER_PANE) before.close() const raw = new Database(path) raw.exec(` @@ -54,39 +67,104 @@ function legacyRuntime(): { runtime: OrcaRuntimeService; database: Orchestration const runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(database) vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - return { runtime, database } + return { runtime, database, taskId: task.id, dispatchId: dispatch.id } +} + +function serverFor(runtime: OrcaRuntimeService) { + const panes = new Map([ + [WORKER_PANE, { ptyId: PTY_ID, incarnationId: 'inc-1', terminalHandle: WORKER }], + [COORDINATOR_PANE, { ptyId: COORDINATOR_PTY, incarnationId: 'inc-2' }] + ]) + return createLegacyRelayCliServer({ + targetId: TARGET, + runtime: () => runtime, + remoteCliControl: () => true, + livePane: (paneKey) => panes.get(paneKey) ?? null + }) +} + +async function run( + runtime: OrcaRuntimeService, + argv: string[], + env: Record = WORKER_ENV +): Promise<{ exitCode: unknown; body: Record }> { + const result = await serverFor(runtime).cli({ argv, env }, new AbortController().signal) + const fields = result && typeof result === 'object' ? result : {} + const stdout = 'stdout' in fields ? String(fields.stdout) : '{}' + return { exitCode: 'exitCode' in fields ? fields.exitCode : undefined, body: JSON.parse(stdout) } } describe('the relay-era orca CLI on migrated legacy orchestration records', () => { it('attests the bridge-verified worker instead of answering read-only', async () => { const { runtime, database } = legacyRuntime() expect(database.getLegacyAdoption()).toBeTruthy() - const server = createLegacyRelayCliServer({ - targetId: TARGET, - runtime: () => runtime, - remoteCliControl: () => true, - livePane: (paneKey) => - paneKey === WORKER_PANE - ? { ptyId: PTY_ID, incarnationId: 'inc-1', terminalHandle: WORKER } - : null - }) - const env = { - ORCA_TERMINAL_HANDLE: WORKER, - ORCA_PANE_KEY: WORKER_PANE, - ORCA_AGENT_LAUNCH_TOKEN: 'worker-token' - } - const result = await server.cli( - { argv: ['orchestration', 'check', '--json'], env }, - new AbortController().signal - ) - expect(result).toMatchObject({ exitCode: 0 }) - const stdout = result && typeof result === 'object' && 'stdout' in result ? result.stdout : '' - expect(JSON.parse(String(stdout))).toMatchObject({ + const check = await run(runtime, ['orchestration', 'check', '--json']) + expect(check.exitCode).toBe(0) + expect(check.body).toMatchObject({ ok: true, result: { legacyCompatibility: { readOnly: false } } }) }) + it('admits the retained legacy coordinator and lets the worker report to it', async () => { + const { runtime, taskId, dispatchId } = legacyRuntime() + const coordinatorEnv = { + ORCA_TERMINAL_HANDLE: COORDINATOR, + ORCA_PANE_KEY: COORDINATOR_PANE, + ORCA_AGENT_LAUNCH_TOKEN: 'coordinator-token' + } + const coordinatorCheck = await run( + runtime, + ['orchestration', 'check', '--json'], + coordinatorEnv + ) + expect(coordinatorCheck.body).toMatchObject({ ok: true }) + const escalation = await run(runtime, [ + 'orchestration', + 'send', + '--to', + COORDINATOR, + '--type', + 'escalation', + '--subject', + 'blocked', + '--task-id', + taskId, + '--dispatch-id', + dispatchId, + '--json' + ]) + expect(escalation.body).toMatchObject({ ok: true }) + }) + + it("replays a legacy worker's completed worker_done after it settled", async () => { + const { runtime, database, taskId, dispatchId } = legacyRuntime() + const done = [ + 'orchestration', + 'send', + '--to', + COORDINATOR, + '--type', + 'worker_done', + '--subject', + 'done', + '--task-id', + taskId, + '--dispatch-id', + dispatchId, + '--outcome', + 'succeeded', + '--retry-request', + '33333333-3333-4333-8333-333333333333', + '--json' + ] + const first = await run(runtime, done) + expect(first.body).toMatchObject({ ok: true }) + expect(database.getTask(taskId)?.status).toBe('completed') + const replay = await run(runtime, done) + expect(replay.body).toMatchObject({ ok: true }) + }) + it('vouches only for the exact terminal its attachment carries', () => { const runtime = new OrcaRuntimeService() const terminal = { handle: WORKER, paneKey: WORKER_PANE, processIncarnation: `${PTY_ID}:inc-1` } From 5b4baaae6991a9c8214b5bba2fb811b2fef48587 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 01:56:27 -0700 Subject: [PATCH 4/8] fix(ssh): reject emptied retry ids, match moved panes by leaf, replay completed retries after reassignment --- .../legacy-relay-bridge-leases.ts | 9 ++---- .../legacy-relay-cli-args.ts | 8 +++-- .../legacy-relay-cli-server.test.ts | 30 +++++++++++++++++++ .../legacy-relay-cli-server.ts | 6 +++- .../legacy-relay-target-bridge.test.ts | 4 ++- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts index b7920ab17daa..48594ce705b4 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts @@ -47,7 +47,7 @@ export type Pane = { export type LegacyRelayLivePane = { ptyId: string; incarnationId?: string; terminalHandle?: string } -/** The live leased pane a pane key names; the lease is the desktop's own record of that pane. */ +/** The live leased pane a pane key's stable leaf names; the lease is the desktop's own record. */ export function findLivePane( panes: ReadonlyMap, paneKey: string @@ -57,11 +57,8 @@ export function findLivePane( return null } for (const [ptyId, pane] of panes) { - if ( - pane.state === 'live' && - pane.lease.tabId === parsed.tabId && - pane.lease.leafId === parsed.leafId - ) { + // Why the leaf alone: a moved pane's lease takes its new tab, but its shell keeps the old key. + if (pane.state === 'live' && pane.lease.leafId === parsed.leafId) { const { incarnationId, terminalHandle } = pane.listed ?? {} return { ptyId, diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts index a5740b83817f..ebfdf9b1cc18 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-args.ts @@ -59,8 +59,12 @@ export function numberFlag(flags: Map, name: string): /** A damaged `--retry-request` must fail: a fresh identity would re-apply the mutation (#15180). */ export function retryRequestFlag(flags: Map): string | undefined { - const value = stringFlag(flags, 'retry-request') - if (value !== undefined && !isOrchestrationRetryRequestId(value)) { + const value = flags.get('retry-request') + if (value === undefined) { + return undefined + } + // Even an explicit empty value is damage, never "no retry". + if (!isOrchestrationRetryRequestId(value)) { throw new Error(RETRY_REQUEST_ID_GUIDANCE) } return value diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts index c55e9ba2403a..f1e7f80bb809 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts @@ -247,6 +247,36 @@ describe('the relay-era orca CLI on the desktop runtime', () => { ).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED) }) + it('refuses an emptied --retry-request instead of starting a new mutation', async () => { + for (const argv of [ + [...workerDone(), '--retry-request='], + [...workerDone(), '--retry-request'] + ]) { + await expect(run(argv)).rejects.toThrow(/--retry-request/) + } + expect(db.getTask(ids.taskId)?.status).toBe('dispatched') + }) + + it('replays a completed worker_done after the terminal took a new assignment', async () => { + const retry = ['--retry-request', '44444444-4444-4444-8444-444444444444'] + const first = await run([...workerDone(), ...retry]) + const next = db.createTask({ + runId: ids.runId, + spec: 'next', + createdByTerminalHandle: COORDINATOR + }) + db.createDispatchContext({ + taskId: next.id, + assigneeHandle: WORKER, + assigneePaneKey: WORKER_PANE, + processIncarnation: `${PTY_ID}:inc-1`, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + const replay = await run([...workerDone(), ...retry]) + expect(json(replay).result?.message).toEqual(json(first).result?.message) + }) + it('stops a waiting check when the relay connection closes', async () => { const closed = new AbortController() const waiting = run( diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts index 63fa99e3fdfc..34e10e86e0c6 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts @@ -184,7 +184,11 @@ async function dispatchAsCaller( call: LegacyRelayCliCall, requestId: string ): Promise { - assertLegacyRelayCliScope(runtime.getOrchestrationDb(), caller, call, deps.livePane) + const db = runtime.getOrchestrationDb() + // Why: a result this caller already committed replays as-is, even after its assignment moved on. + if (db.getMutationReceipt(fingerprint, requestId)?.state !== 'completed') { + assertLegacyRelayCliScope(db, caller, call, deps.livePane) + } // Why: the runtime no longer hosts this terminal, so the attachment carries its verified identity. const authority = runtime.registerOrchestrationCompatibilitySshAttachment( deps.targetId, diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts index 403e14bc67e9..248a5ac772e2 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts @@ -179,7 +179,9 @@ describe('one target’s legacy relay bridge', () => { incarnationId: 'inc-1', terminalHandle: 'term_a' }) - expect(subject.livePaneForKey(`tab-2:${leafId}`)).toBeNull() + // A pane moved to another tab keeps the key its shell was started with. + expect(subject.livePaneForKey(`tab-2:${leafId}`)).toMatchObject({ ptyId: PTY }) + expect(subject.livePaneForKey('tab-1:44444444-4444-4444-8444-444444444444')).toBeNull() expect(subject.livePaneForKey('tab-1:not-a-leaf')).toBeNull() relay.clients[0].drop() // Unverifiable is not live: a pane the relay cannot vouch for names nobody. From 3af8c9aaef795ab6e66361fcd76995092173ee19 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 02:03:18 -0700 Subject: [PATCH 5/8] fix(ssh): answer --help in a relay-era terminal without running the command --- .../legacy-relay-bridge/legacy-relay-cli-server.test.ts | 7 +++++++ .../ssh/legacy-relay-bridge/legacy-relay-cli-server.ts | 8 ++++++++ .../ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts | 6 +----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts index f1e7f80bb809..c491466d75ff 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts @@ -247,6 +247,13 @@ describe('the relay-era orca CLI on the desktop runtime', () => { ).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED) }) + it('answers --help without running the command', async () => { + for (const flag of ['--help', '-h']) { + expect(await run([...workerDone(), flag])).toMatchObject({ exitCode: 0, stderr: '' }) + } + expect(db.getTask(ids.taskId)?.status).toBe('dispatched') + }) + it('refuses an emptied --retry-request instead of starting a new mutation', async () => { for (const argv of [ [...workerDone(), '--retry-request='], diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts index 34e10e86e0c6..3158ff7854ae 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts @@ -51,6 +51,10 @@ export const LEGACY_RELAY_CLI_UNSUPPORTED = 'This terminal was started by an older version of Orca, so only orca orchestration check, ' + 'inbox, send, ask and reply work in it. Run other orca commands in a new terminal on this host.' +const LEGACY_RELAY_CLI_HELP = + 'This terminal was started by an older version of Orca. It runs orca orchestration check, ' + + 'inbox, send, ask and reply; open a new terminal on this host for full orca help.' + export type LegacyRelayCliServerDeps = { targetId: string runtime: () => OrcaRuntimeService | null @@ -248,6 +252,10 @@ export function createLegacyRelayCliServer(deps: LegacyRelayCliServerDeps): Lega ? params.argv.filter((item): item is string => typeof item === 'string') : [] const { command, flags } = parseLegacyRelayCliArgs(argv) + // Why before anything runs: `--help` on a valid command must never apply its effects. + if (flags.has('help') || argv.includes('-h')) { + return { stdout: `${LEGACY_RELAY_CLI_HELP}\n`, stderr: '', exitCode: 0 } + } if (!COMMANDS.has(command)) { throw new Error(LEGACY_RELAY_CLI_UNSUPPORTED) } diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts index 14219f272a63..cdd9bfe788c1 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts @@ -20,7 +20,6 @@ import { ATTACH_WAIT_MS, bridgeableLeasesByRelayId, findLivePane, - type LegacyRelayLivePane, type LegacyRelayPty, PROVEN_EXITED, toBridgedPane, @@ -54,6 +53,7 @@ type EndpointState = { export class LegacyRelayTargetBridge { private readonly endpoints = new Map() private readonly panes = new Map() + readonly livePaneForKey = (paneKey: string) => findLivePane(this.panes, paneKey) private readonly attached = new Map() private readonly waiters = new LegacyRelayWaiters() private readonly retry = new LegacyRelayRetry(() => void this.refresh()) @@ -131,10 +131,6 @@ export class LegacyRelayTargetBridge { } } - livePaneForKey(paneKey: string): LegacyRelayLivePane | null { - return findLivePane(this.panes, paneKey) - } - detach(ptyId: string): void { const count = (this.attached.get(ptyId) ?? 0) - 1 if (count > 0) { From 7d5f1c2f6cd20472a9bb84e1ec77a972477f5283 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 02:11:36 -0700 Subject: [PATCH 6/8] fix(ssh): exempt only matching mutation replays from relay CLI scope; keep legacy retries replayable after reassignment --- .../legacy-relay-cli-caller.ts | 52 +++++++++---------- .../legacy-relay-cli-legacy-records.test.ts | 9 ++++ .../legacy-relay-cli-server.test.ts | 11 ++++ .../legacy-relay-cli-server.ts | 11 +++- 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts index 5d097619ae46..077cf11bfbff 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts @@ -27,6 +27,8 @@ export type LegacyRelayCliCaller = { processIncarnation?: string /** The Dispatch this pane holds as its assignee. */ dispatch?: DispatchContextRow + /** A settled legacy Dispatch this pane may name only to replay a result it committed. */ + replayDispatch?: DispatchContextRow /** The Run this pane coordinates. */ run?: RunRow } @@ -65,9 +67,15 @@ export function resolveLegacyRelayCliCaller( throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) } const db = runtime.getOrchestrationDb() - const dispatch = resolveOwnDispatch(db, handle, paneKey, replay) + const own = (row: DispatchContextRow | undefined) => + row?.assignee_handle === handle && samePane(row.assignee_pane_key, paneKey) ? row : undefined + const active = own(db.getActiveDispatchForIdentity(handle, paneKey)) + const replayed = + replay && db.getMutationReceipt(replay.fingerprint, replay.requestId)?.state === 'completed' + const dispatch = active ?? (replayed ? own(db.getLatestDispatchForTerminal(handle)) : undefined) + const replayDispatch = replay ? own(settledLegacyDispatch(db, handle, paneKey)) : undefined const run = resolveCoordinatedRun(db, handle, paneKey) - if (!dispatch && !run) { + if (!dispatch && !replayDispatch && !run) { throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) } return { @@ -76,27 +84,17 @@ export function resolveLegacyRelayCliCaller( ptyId: pane.ptyId, ...(pane.incarnationId ? { processIncarnation: `${pane.ptyId}:${pane.incarnationId}` } : {}), ...(dispatch ? { dispatch } : {}), + ...(replayDispatch ? { replayDispatch } : {}), ...(run ? { run } : {}) } } -/** The Dispatch this pane holds; a settled one only to replay a result it already committed. */ -function resolveOwnDispatch( +/** A legacy worker's results live in legacy receipts, under the principal it settled as. */ +function settledLegacyDispatch( db: OrchestrationDb, handle: string, - paneKey: string, - replay: { fingerprint: string; requestId: string } | undefined + paneKey: string ): DispatchContextRow | undefined { - const own = (row: DispatchContextRow | undefined) => - row?.assignee_handle === handle && samePane(row.assignee_pane_key, paneKey) ? row : undefined - const active = own(db.getActiveDispatchForIdentity(handle, paneKey)) - if (active || !replay) { - return active - } - if (db.getMutationReceipt(replay.fingerprint, replay.requestId)?.state === 'completed') { - return own(db.getLatestDispatchForTerminal(handle)) - } - // A legacy worker's results live in legacy receipts, under the principal it settled as. const adoption = db.getLegacyAdoption() const principal = adoption ? db.resolveLegacyCompatibilityPrincipalByIdentity({ @@ -105,8 +103,10 @@ function resolveOwnDispatch( paneKey }) : undefined - return principal?.status === 'settled' && principal.terminal_handle === handle - ? own(principal.dispatch_id ? db.getDispatchContextById(principal.dispatch_id) : undefined) + return principal?.status === 'settled' && + principal.terminal_handle === handle && + principal.dispatch_id + ? db.getDispatchContextById(principal.dispatch_id) : undefined } @@ -162,7 +162,7 @@ function isPartyDispatch( dispatchId: string, livePane: (paneKey: string) => LegacyRelayLivePane | null ): boolean { - if (dispatchId === caller.dispatch?.id) { + if (dispatchId === caller.dispatch?.id || dispatchId === caller.replayDispatch?.id) { return true } const named = db.getDispatchContextById(dispatchId) @@ -191,14 +191,14 @@ function isAllowedRecipient( if (ownedAddresses(caller).has(to)) { return true } - const dispatch = caller.dispatch - if (dispatch) { - const coordinator = db.getRun(dispatch.run_id)?.coordinator_handle + for (const dispatch of [caller.dispatch, caller.replayDispatch]) { + const coordinator = dispatch ? db.getRun(dispatch.run_id)?.coordinator_handle : undefined if ( - to === `run:${dispatch.run_id}` || - (dispatch.creator_handle && to === dispatch.creator_handle) || - (coordinator && to === coordinator) || - db.isLegacyCoordinatorDeliveryTarget(dispatch.run_id, to) + dispatch && + (to === `run:${dispatch.run_id}` || + (dispatch.creator_handle && to === dispatch.creator_handle) || + (coordinator && to === coordinator) || + db.isLegacyCoordinatorDeliveryTarget(dispatch.run_id, to)) ) { return true } diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts index 7cbc2cfad42e..21510189db75 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts @@ -163,6 +163,15 @@ describe('the relay-era orca CLI on migrated legacy orchestration records', () = expect(database.getTask(taskId)?.status).toBe('completed') const replay = await run(runtime, done) expect(replay.body).toMatchObject({ ok: true }) + // A new assignment must not strand the settled one's retry. + const next = database.createTask({ + runId: database.getLegacyAdoption()?.adopted_run_id ?? '', + spec: 'next', + createdByTerminalHandle: COORDINATOR + }) + createRootDispatch(database, next.id, WORKER, WORKER_PANE) + const afterReassignment = await run(runtime, done) + expect(afterReassignment.body).toMatchObject({ ok: true }) }) it('vouches only for the exact terminal its attachment carries', () => { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts index c491466d75ff..9911bc503ff6 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.test.ts @@ -284,6 +284,17 @@ describe('the relay-era orca CLI on the desktop runtime', () => { expect(json(replay).result?.message).toEqual(json(first).result?.message) }) + it("never lets a completed retry id unlock another caller's mailbox", async () => { + const retry = ['--retry-request', '55555555-5555-4555-8555-555555555555'] + await run(['orchestration', 'send', '--to', COORDINATOR, '--subject', 'hi', ...retry]) + await expect( + run(['orchestration', 'inbox', '--terminal', `run:${ids.runId}`, ...retry]) + ).rejects.toThrow(/outside it/) + await expect( + run(['orchestration', 'check', '--terminal', COORDINATOR, ...retry]) + ).rejects.toThrow(/outside it/) + }) + it('stops a waiting check when the relay connection closes', async () => { const closed = new AbortController() const waiting = run( diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts index 3158ff7854ae..d4753b327ff4 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts @@ -8,6 +8,7 @@ * no grants (the desktop browser bridge's shape), after legacy-relay-cli-caller.ts bound them. */ import { createHash, randomUUID } from 'node:crypto' +import { isDurableMutation } from '../../../shared/orchestration-rpc-contract' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { RpcResponse } from '../../runtime/rpc/core' @@ -189,8 +190,14 @@ async function dispatchAsCaller( requestId: string ): Promise { const db = runtime.getOrchestrationDb() - // Why: a result this caller already committed replays as-is, even after its assignment moved on. - if (db.getMutationReceipt(fingerprint, requestId)?.state !== 'completed') { + // Why: a mutation this caller already committed replays as-is, even after its assignment moved + // on; the executor refuses a retry whose payload differs. Reads are always scoped. + const receipt = db.getMutationReceipt(fingerprint, requestId) + const replaying = + receipt?.state === 'completed' && + receipt.method === call.method && + isDurableMutation(call.method, call.params) + if (!replaying) { assertLegacyRelayCliScope(db, caller, call, deps.livePane) } // Why: the runtime no longer hosts this terminal, so the attachment carries its verified identity. From a4c7547a9a840a0d4cfdf18dc26fec9d9a52a34e Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 02:34:03 -0700 Subject: [PATCH 7/8] fix(ssh): relay CLI security review: pane-bound legacy coordinators only, revoke and cap calls, exact-hash replays, target-aware identity, consent copy --- config/scripts/locale-ko-key-overrides.json | 2 +- .../output/headless-buffer-snapshot.ts | 24 ++- .../legacy-relay-cli-call-limits.test.ts | 159 ++++++++++++++++++ .../legacy-relay-cli-call-limits.ts | 113 +++++++++++++ .../legacy-relay-cli-caller.ts | 25 ++- .../legacy-relay-cli-legacy-records.test.ts | 96 ++++++++--- .../legacy-relay-cli-server.ts | 155 +++++++++-------- .../settings/SshHostAdvancedFields.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 14 files changed, 472 insertions(+), 116 deletions(-) create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts create mode 100644 src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.ts diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 569dc2024913..b722b98fc22e 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -6264,7 +6264,7 @@ "ko": "이 호스트의 orca CLI가 Orca를 제어하도록 허용" }, "auto.components.settings.SshTargetForm.allowRemoteCliControlHelp": { - "ko": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다." + "ko": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 이 호스트에서 이전 버전의 Orca가 시작한 터미널도 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다." }, "auto.components.settings.AdvancedNetworkSettingsSection.3e431564b5": { "ko": "localhost, 127.0.0.1, *.internal" diff --git a/src/main/runtime/terminal/output/headless-buffer-snapshot.ts b/src/main/runtime/terminal/output/headless-buffer-snapshot.ts index c69d537a1f54..e679fc768caa 100644 --- a/src/main/runtime/terminal/output/headless-buffer-snapshot.ts +++ b/src/main/runtime/terminal/output/headless-buffer-snapshot.ts @@ -172,15 +172,29 @@ export abstract class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends Orc this.orchestrationCompatibilitySshTerminals.delete(attachmentId) } - /** Only while an attachment holds it, and only for a handle this runtime does not host. */ + /** + * Only while an attachment holds it, and only for a handle this runtime does not host. Two hosts + * claiming one handle name nobody: neither may borrow the other's pane or process. + */ protected getOrchestrationCompatibilitySshTerminal( handle: string ): OrchestrationCompatibilitySshTerminal | undefined { - for (const terminal of this.orchestrationCompatibilitySshTerminals.values()) { - if (terminal.handle === handle) { - return terminal + let found: { targetId: string; terminal: OrchestrationCompatibilitySshTerminal } | undefined + for (const [attachmentId, terminal] of this.orchestrationCompatibilitySshTerminals) { + const targetId = this.orchestrationCompatibilitySshAttachments.get(attachmentId)?.targetId + if (terminal.handle !== handle || !targetId) { + continue } + if ( + found && + (found.targetId !== targetId || + found.terminal.paneKey !== terminal.paneKey || + found.terminal.processIncarnation !== terminal.processIncarnation) + ) { + return undefined + } + found = { targetId, terminal } } - return undefined + return found?.terminal } } diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts new file mode 100644 index 000000000000..fe5aa7f4d2eb --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts @@ -0,0 +1,159 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { + LEGACY_RELAY_CLI_BUSY, + cappedWait, + runLegacyRelayCliCall +} from './legacy-relay-cli-call-limits' +import { createLegacyRelayCliServer } from './legacy-relay-cli-server' + +const TARGET = 'target-1' +const WORKER = 'term_relay_worker' +const WORKER_PANE = 'tab_w:33333333-3333-4333-8333-333333333333' +const COORDINATOR = 'term_local_coord' +const PTY_ID = toAppSshPtyId(TARGET, 'pty-7') +const ENV = { ORCA_TERMINAL_HANDLE: WORKER, ORCA_PANE_KEY: WORKER_PANE } + +let dir: string +let db: OrchestrationDb +let runtime: OrcaRuntimeService +let allowed: boolean + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-legacy-relay-limits-')) + db = new OrchestrationDb(join(dir, 'orchestration.db')) + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + allowed = true + const run = db.createRun({ + objective: 'retained work', + coordinatorHandle: COORDINATOR, + coordinatorPaneKey: 'tab_c:44444444-4444-4444-8444-444444444444' + }) + const task = db.createTask({ runId: run.id, spec: 's', createdByTerminalHandle: COORDINATOR }) + db.createDispatchContext({ + taskId: task.id, + assigneeHandle: WORKER, + assigneePaneKey: WORKER_PANE, + processIncarnation: `${PTY_ID}:inc-1`, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) +}) + +afterEach(() => { + vi.useRealTimers() + db.close() + rmSync(dir, { recursive: true, force: true }) +}) + +const server = () => + createLegacyRelayCliServer({ + targetId: TARGET, + runtime: () => runtime, + remoteCliControl: () => allowed, + livePane: (paneKey) => + paneKey === WORKER_PANE + ? { ptyId: PTY_ID, incarnationId: 'inc-1', terminalHandle: WORKER } + : null + }) + +describe('limits on relay-era CLI calls', () => { + it('stops a waiting check once the per-host switch is turned off', async () => { + const waiting = server().cli( + { argv: ['orchestration', 'check', '--wait', '--timeout-ms', '60000', '--json'], env: ENV }, + new AbortController().signal + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + allowed = false + const started = Date.now() + const result = await waiting + expect(Date.now() - started).toBeLessThan(5_000) + const stdout = result && typeof result === 'object' && 'stdout' in result ? result.stdout : '' + expect(JSON.parse(String(stdout)).result).toMatchObject({ cancelled: true }) + }) + + it('caps how many calls one host may hold open', async () => { + const release: (() => void)[] = [] + const held = Array.from({ length: 16 }, () => + runLegacyRelayCliCall( + TARGET, + () => true, + new AbortController().signal, + () => new Promise((resolve) => release.push(resolve)) + ) + ) + await expect( + runLegacyRelayCliCall( + TARGET, + () => true, + new AbortController().signal, + async () => {} + ) + ).rejects.toThrow(LEGACY_RELAY_CLI_BUSY) + // Another host is unaffected. + await expect( + runLegacyRelayCliCall( + 'other-target', + () => true, + new AbortController().signal, + async () => 1 + ) + ).resolves.toBe(1) + release.forEach((resolve) => resolve()) + await Promise.all(held) + await expect( + runLegacyRelayCliCall( + TARGET, + () => true, + new AbortController().signal, + async () => 2 + ) + ).resolves.toBe(2) + }) + + it('caps how long a host may park a desktop waiter', () => { + expect(cappedWait(Number.MAX_SAFE_INTEGER)).toBe(1_800_000) + expect(cappedWait(-5)).toBe(0) + expect(cappedWait(undefined)).toBeUndefined() + }) + + it('scopes a reused retry id whose payload differs from the committed one', async () => { + const retry = ['--retry-request', '66666666-6666-4666-8666-666666666666'] + await server().cli( + { + argv: ['orchestration', 'send', '--to', COORDINATOR, '--subject', 'a', ...retry], + env: ENV + }, + new AbortController().signal + ) + await expect( + server().cli( + { + argv: ['orchestration', 'send', '--to', 'term_unrelated', '--subject', 'a', ...retry], + env: ENV + }, + new AbortController().signal + ) + ).rejects.toThrow(/outside it/) + }) + + it('names nobody when two hosts claim one handle', () => { + const terminal = { handle: WORKER, paneKey: WORKER_PANE, processIncarnation: 'p-1' } + const fresh = new OrcaRuntimeService() + const a = fresh.registerOrchestrationCompatibilitySshAttachment('host-a', 'c-1', terminal) + expect(fresh.getTerminalPaneKey(WORKER)).toBe(WORKER_PANE) + const b = fresh.registerOrchestrationCompatibilitySshAttachment('host-b', 'c-2', terminal) + expect(fresh.getTerminalPaneKey(WORKER)).toBeNull() + expect(fresh.getTerminalProcessIncarnation(WORKER)).toBeNull() + fresh.releaseOrchestrationCompatibilitySshAttachment(b.attachmentId) + expect(fresh.getTerminalProcessIncarnation(WORKER)).toBe('p-1') + fresh.releaseOrchestrationCompatibilitySshAttachment(a.attachmentId) + }) +}) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.ts new file mode 100644 index 000000000000..90a2aded53a7 --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.ts @@ -0,0 +1,113 @@ +/** + * Bounds on the relay-era CLI calls one host can hold open on this desktop, and the one replay + * that may skip part of the scope check. + * COMPAT(legacy-relay-bridge): revisit 2027-01-15. + */ +import { + clampOrchestrationAskTimeoutMs, + ORCHESTRATION_ASK_MAX_TIMEOUT_MS +} from '../../../shared/orchestration-ask-timeout' +import { isDurableMutation } from '../../../shared/orchestration-rpc-contract' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { ALL_RPC_METHODS } from '../../runtime/rpc/methods' +import { + hashCanonical, + readPromptBasePayloadHash, + replayStableCallerParams +} from '../../runtime/rpc/orchestration-mutation-receipt' +import { getCompiledSchema } from '../../zod-schema-compilation' +import type { LegacyRelayCliCall } from './legacy-relay-cli-caller' + +/** Enough for a coordinator and its workers' waits; past it a host is flooding this desktop. */ +const MAX_CALLS_PER_HOST = 16 +/** How soon turning the per-host switch off reaches a call that is already waiting. */ +const SWITCH_POLL_MS = 1_000 + +const inFlightByHost = new Map() + +/** A host must not park a desktop waiter for longer than the longest ask may wait. */ +export function cappedWait(timeoutMs: number | undefined): number | undefined { + return timeoutMs === undefined + ? undefined + : Math.min(Math.max(0, timeoutMs), ORCHESTRATION_ASK_MAX_TIMEOUT_MS) +} + +export function optionalAskTimeout(timeoutMs: number | undefined): number | undefined { + return timeoutMs === undefined ? undefined : clampOrchestrationAskTimeoutMs(timeoutMs) +} + +export const LEGACY_RELAY_CLI_BUSY = + 'This host already has too many older-terminal orca commands waiting on this desktop. ' + + 'Let some finish, then try again.' + +/** + * Runs one call under the host's concurrency cap, aborted when the relay connection closes or the + * per-host switch turns off (read on a timer, so every path that edits the setting is covered). + */ +export async function runLegacyRelayCliCall( + targetId: string, + allowed: () => boolean, + relaySignal: AbortSignal, + run: (signal: AbortSignal) => Promise +): Promise { + const inFlight = inFlightByHost.get(targetId) ?? 0 + if (inFlight >= MAX_CALLS_PER_HOST) { + throw new Error(LEGACY_RELAY_CLI_BUSY) + } + inFlightByHost.set(targetId, inFlight + 1) + const controller = new AbortController() + const abort = (): void => controller.abort() + relaySignal.addEventListener('abort', abort, { once: true }) + const revoked = setInterval(() => { + if (!allowed()) { + abort() + } + }, SWITCH_POLL_MS) + try { + if (relaySignal.aborted) { + abort() + } + return await run(controller.signal) + } finally { + clearInterval(revoked) + relaySignal.removeEventListener('abort', abort) + const remaining = (inFlightByHost.get(targetId) ?? 1) - 1 + if (remaining > 0) { + inFlightByHost.set(targetId, remaining) + } else { + inFlightByHost.delete(targetId) + } + } +} + +/** + * True only for a retry the executor will answer from its receipt: same method, same caller + * fingerprint, and the exact payload hash it recorded. Call while the caller's identity is + * registered, since the hash reads the caller's pane through the runtime. + */ +export function isExactLegacyRelayCliReplay( + runtime: OrcaRuntimeService, + fingerprint: string, + requestId: string, + call: LegacyRelayCliCall +): boolean { + const receipt = runtime.getOrchestrationDb().getMutationReceipt(fingerprint, requestId) + const method = ALL_RPC_METHODS.find((entry) => entry.name === call.method) + if ( + receipt?.state !== 'completed' || + receipt.method !== call.method || + !method?.params || + !isDurableMutation(call.method, call.params) + ) { + return false + } + const parsed = getCompiledSchema(method.params).safeParse(call.params) + if (!parsed.success) { + return false + } + const payloadHash = hashCanonical({ + method: call.method, + params: replayStableCallerParams(runtime, parsed.data) + }) + return readPromptBasePayloadHash(receipt.payload_hash) === payloadHash +} diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts index 077cf11bfbff..573380095765 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts @@ -110,7 +110,11 @@ function settledLegacyDispatch( : undefined } -/** The Run this pane coordinates, including a migrated Run's retained legacy coordinator. */ +/** + * The Run this pane coordinates. A migrated Run's legacy coordinator counts only once a committed + * principal binds it to this exact pane: the unbound "first caller wins" adoption would let any + * host pane claim the coordinator's handle, so a host-attested caller never takes that path. + */ function resolveCoordinatedRun( db: OrchestrationDb, handle: string, @@ -121,7 +125,11 @@ function resolveCoordinatedRun( return bound.coordinator_handle === handle ? bound : undefined } const adopted = db.getLegacyAdoption()?.adopted_run_id + const principal = adopted ? db.getLegacyCoordinatorPrincipal(adopted) : undefined return adopted && + principal?.status === 'committed' && + principal.terminal_handle === handle && + samePane(principal.pane_key, paneKey) && db.resolveLegacyCoordinatorCandidate({ runId: adopted, terminalHandle: handle, paneKey }) ? db.getRun(adopted) : undefined @@ -210,21 +218,26 @@ function isAllowedRecipient( return Boolean(worker && isPartyDispatch(db, caller, worker.id, livePane)) } -/** Throws unless the call stays inside the caller's own orchestration. */ +/** + * Throws unless the call stays inside the caller's own orchestration. `exactReplay`: a mutation + * this caller committed with this exact payload, which passed these checks then; only its sender, + * Run and recipient may have since moved on. + */ export function assertLegacyRelayCliScope( db: OrchestrationDb, caller: LegacyRelayCliCaller, call: LegacyRelayCliCall, - livePane: (paneKey: string) => LegacyRelayLivePane | null + livePane: (paneKey: string) => LegacyRelayLivePane | null, + exactReplay = false ): void { const { method, params } = call const selfParam = method === 'orchestration.check' || method === 'orchestration.inbox' const named = stringParam(params, selfParam ? 'terminal' : 'from') - if (named !== caller.handle) { + if (named !== caller.handle && (selfParam || !exactReplay)) { throw outside(`terminal '${named ?? ''}'`) } const run = stringParam(params, 'run') - if (run && run !== caller.dispatch?.run_id && run !== caller.run?.id) { + if (run && !exactReplay && run !== caller.dispatch?.run_id && run !== caller.run?.id) { throw outside(`run '${run}'`) } if (method === 'orchestration.reply') { @@ -235,7 +248,7 @@ export function assertLegacyRelayCliScope( } return } - if (method !== 'orchestration.send' && method !== 'orchestration.ask') { + if ((method !== 'orchestration.send' && method !== 'orchestration.ask') || exactReplay) { return } const dispatchId = payloadDispatchId(stringParam(params, 'payload')) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts index 21510189db75..95217cde484d 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,6 +8,7 @@ import Database from '../../sqlite/sync-database' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { OrchestrationDb } from '../../runtime/orchestration/db' import { createRootDispatch } from '../../runtime/orchestration/db/root-dispatch-test-fixture' +import { LEGACY_RELAY_CLI_UNVERIFIED } from './legacy-relay-cli-caller' import { createLegacyRelayCliServer } from './legacy-relay-cli-server' const TARGET = 'target-1' @@ -16,6 +18,33 @@ const PTY_ID = toAppSshPtyId(TARGET, 'pty-7') const COORDINATOR = 'term_legacy_coord' const COORDINATOR_PANE = 'tab_c:44444444-4444-4444-8444-444444444444' const COORDINATOR_PTY = toAppSshPtyId(TARGET, 'pty-8') +const UNRELATED_PANE = 'tab_u:66666666-6666-4666-8666-666666666666' +const coordinatorEnv = () => ({ + ORCA_TERMINAL_HANDLE: COORDINATOR, + ORCA_PANE_KEY: COORDINATOR_PANE, + ORCA_AGENT_LAUNCH_TOKEN: 'coordinator-token' +}) +const escalationArgv = (taskId: string, dispatchId: string) => [ + 'orchestration', + 'send', + '--to', + COORDINATOR, + '--type', + 'escalation', + '--subject', + 'blocked', + '--task-id', + taskId, + '--dispatch-id', + dispatchId, + '--json' +] +const messageId = (sent: { body: Record }): string => { + const result = sent.body.result + const message = + result && typeof result === 'object' && 'message' in result ? result.message : null + return message && typeof message === 'object' && 'id' in message ? String(message.id) : '' +} const WORKER_ENV = { ORCA_TERMINAL_HANDLE: WORKER, ORCA_PANE_KEY: WORKER_PANE, @@ -73,7 +102,8 @@ function legacyRuntime(): { function serverFor(runtime: OrcaRuntimeService) { const panes = new Map([ [WORKER_PANE, { ptyId: PTY_ID, incarnationId: 'inc-1', terminalHandle: WORKER }], - [COORDINATOR_PANE, { ptyId: COORDINATOR_PTY, incarnationId: 'inc-2' }] + [COORDINATOR_PANE, { ptyId: COORDINATOR_PTY, incarnationId: 'inc-2' }], + [UNRELATED_PANE, { ptyId: toAppSshPtyId(TARGET, 'pty-9'), incarnationId: 'inc-3' }] ]) return createLegacyRelayCliServer({ targetId: TARGET, @@ -106,35 +136,45 @@ describe('the relay-era orca CLI on migrated legacy orchestration records', () = }) }) - it('admits the retained legacy coordinator and lets the worker report to it', async () => { - const { runtime, taskId, dispatchId } = legacyRuntime() - const coordinatorEnv = { - ORCA_TERMINAL_HANDLE: COORDINATOR, - ORCA_PANE_KEY: COORDINATOR_PANE, - ORCA_AGENT_LAUNCH_TOKEN: 'coordinator-token' + it('never lets a host pane adopt the unbound legacy coordinator by naming its handle', async () => { + const { runtime, database, taskId, dispatchId } = legacyRuntime() + const escalation = await run(runtime, escalationArgv(taskId, dispatchId)) + expect(escalation.body).toMatchObject({ ok: true }) + const adoptedRunId = database.getLegacyAdoption()?.adopted_run_id ?? '' + // The real coordinator pane, still unbound after migration, and an unrelated leased pane alike. + for (const paneKey of [COORDINATOR_PANE, UNRELATED_PANE]) { + const env = { ...coordinatorEnv(), ORCA_PANE_KEY: paneKey } + await expect(run(runtime, ['orchestration', 'check', '--json'], env)).rejects.toThrow( + LEGACY_RELAY_CLI_UNVERIFIED + ) + const answer = ['orchestration', 'reply', '--id', messageId(escalation), '--body', 'go'] + await expect(run(runtime, answer, env)).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED) } - const coordinatorCheck = await run( - runtime, - ['orchestration', 'check', '--json'], - coordinatorEnv - ) - expect(coordinatorCheck.body).toMatchObject({ ok: true }) - const escalation = await run(runtime, [ - 'orchestration', - 'send', - '--to', - COORDINATOR, - '--type', - 'escalation', - '--subject', - 'blocked', - '--task-id', - taskId, - '--dispatch-id', - dispatchId, - '--json' - ]) + expect(database.getLegacyCoordinatorPrincipal(adoptedRunId)).toBeUndefined() + }) + + it('admits a legacy coordinator bound to this pane, and the worker reports to it', async () => { + const { runtime, database, taskId, dispatchId } = legacyRuntime() + database.commitLegacyCompatibilityPrincipal({ + runId: database.getLegacyAdoption()?.adopted_run_id ?? '', + role: 'coordinator', + hostScope: JSON.stringify({ kind: 'ssh', targetId: TARGET }), + terminalHandle: COORDINATOR, + paneKey: COORDINATOR_PANE, + launchTokenHash: createHash('sha256').update('coordinator-token').digest('hex'), + processIncarnation: `${COORDINATOR_PTY}:inc-2` + }) + const escalation = await run(runtime, escalationArgv(taskId, dispatchId)) expect(escalation.body).toMatchObject({ ok: true }) + const check = await run(runtime, ['orchestration', 'check', '--json'], coordinatorEnv()) + expect(check.body).toMatchObject({ ok: true }) + // Bound to its own pane only: the same handle from an unrelated pane is still refused. + await expect( + run(runtime, ['orchestration', 'check', '--json'], { + ...coordinatorEnv(), + ORCA_PANE_KEY: UNRELATED_PANE + }) + ).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED) }) it("replays a legacy worker's completed worker_done after it settled", async () => { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts index d4753b327ff4..3ea0cb5e31ef 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-server.ts @@ -8,7 +8,6 @@ * no grants (the desktop browser bridge's shape), after legacy-relay-cli-caller.ts bound them. */ import { createHash, randomUUID } from 'node:crypto' -import { isDurableMutation } from '../../../shared/orchestration-rpc-contract' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { RpcResponse } from '../../runtime/rpc/core' @@ -28,6 +27,12 @@ import { stringFlag } from './legacy-relay-cli-args' import type { LegacyRelayCliServer } from './legacy-relay-client' +import { + cappedWait, + isExactLegacyRelayCliReplay, + optionalAskTimeout, + runLegacyRelayCliCall +} from './legacy-relay-cli-call-limits' import { formatLegacyRelayCliResult, isRecord } from './legacy-relay-cli-output' import type { LegacyRelayLivePane } from './legacy-relay-bridge-leases' @@ -115,7 +120,7 @@ function buildCall( run: flag('run'), ack: flag('ack'), wait: has('wait') ? true : undefined, - timeoutMs: numberFlag(flags, 'timeout-ms') + timeoutMs: cappedWait(numberFlag(flags, 'timeout-ms')) } } case 'orchestration inbox': @@ -149,7 +154,7 @@ function buildCall( question: flag('question'), resume: flag('resume'), options: flag('options'), - timeoutMs: numberFlag(flags, 'timeout-ms'), + timeoutMs: optionalAskTimeout(numberFlag(flags, 'timeout-ms')), compatibilityCliCommand: 'orca' } } @@ -189,17 +194,6 @@ async function dispatchAsCaller( call: LegacyRelayCliCall, requestId: string ): Promise { - const db = runtime.getOrchestrationDb() - // Why: a mutation this caller already committed replays as-is, even after its assignment moved - // on; the executor refuses a retry whose payload differs. Reads are always scoped. - const receipt = db.getMutationReceipt(fingerprint, requestId) - const replaying = - receipt?.state === 'completed' && - receipt.method === call.method && - isDurableMutation(call.method, call.params) - if (!replaying) { - assertLegacyRelayCliScope(db, caller, call, deps.livePane) - } // Why: the runtime no longer hosts this terminal, so the attachment carries its verified identity. const authority = runtime.registerOrchestrationCompatibilitySshAttachment( deps.targetId, @@ -211,6 +205,11 @@ async function dispatchAsCaller( } ) try { + // A mutation this caller committed with this exact payload replays even after its assignment + // moved on; everything else, and every read, is scoped. + const exactReplay = isExactLegacyRelayCliReplay(runtime, fingerprint, requestId, call) + const db = runtime.getOrchestrationDb() + assertLegacyRelayCliScope(db, caller, call, deps.livePane, exactReplay) const launchToken = env.ORCA_AGENT_LAUNCH_TOKEN?.trim() return await dispatcherFor(runtime).dispatch( { @@ -253,60 +252,78 @@ function admit( export function createLegacyRelayCliServer(deps: LegacyRelayCliServerDeps): LegacyRelayCliServer { return { - cli: async (params, signal) => { - const env = stringRecord(params.env) - const argv = Array.isArray(params.argv) - ? params.argv.filter((item): item is string => typeof item === 'string') - : [] - const { command, flags } = parseLegacyRelayCliArgs(argv) - // Why before anything runs: `--help` on a valid command must never apply its effects. - if (flags.has('help') || argv.includes('-h')) { - return { stdout: `${LEGACY_RELAY_CLI_HELP}\n`, stderr: '', exitCode: 0 } - } - if (!COMMANDS.has(command)) { - throw new Error(LEGACY_RELAY_CLI_UNSUPPORTED) - } - const retryRequestId = retryRequestFlag(flags) - const invocation = admit(deps, env, signal, retryRequestId) - const call = buildCall(command, flags, invocation.caller) - const requestId = retryRequestId ?? randomUUID() - const response = await dispatchAsCaller(deps, invocation, call, requestId) - return formatLegacyRelayCliResult(call, response, invocation.caller, flags) - }, - postOutput: async (params, signal) => { - const env = stringRecord(params.env) - const invocation = admit(deps, env, signal) - const { caller } = invocation - const post = isRecord(params.postOutput) ? params.postOutput : {} - const ack = - post.kind === 'legacy_check_ack' && Array.isArray(post.messageIds) - ? { - compatibilityAck: JSON.stringify({ - messageIds: post.messageIds.filter((id) => typeof id === 'string'), - ...(Array.isArray(post.types) - ? { types: post.types.filter((type) => typeof type === 'string') } - : {}) - }) - } - : post.kind === 'legacy_question_ack' && - typeof post.questionId === 'string' && - typeof post.answerMessageId === 'string' - ? { - compatibilityQuestionAck: JSON.stringify({ - questionId: post.questionId, - answerMessageId: post.answerMessageId - }) - } - : null - if (!ack) { - throw new Error('Invalid SSH CLI post-output action.') - } - const call = { method: 'orchestration.check', params: { terminal: caller.handle, ...ack } } - const response = await dispatchAsCaller(deps, invocation, call, randomUUID()) - if (!response.ok) { - throw new Error(response.error.message) - } - return { acknowledged: true } - } + cli: (params, relaySignal) => + runLegacyRelayCliCall(deps.targetId, deps.remoteCliControl, relaySignal, (signal) => + serveCli(deps, params, signal) + ), + postOutput: (params, relaySignal) => + runLegacyRelayCliCall(deps.targetId, deps.remoteCliControl, relaySignal, (signal) => + servePostOutput(deps, params, signal) + ) + } +} + +async function serveCli( + deps: LegacyRelayCliServerDeps, + params: Record, + signal: AbortSignal +): Promise { + const env = stringRecord(params.env) + const argv = Array.isArray(params.argv) + ? params.argv.filter((item): item is string => typeof item === 'string') + : [] + const { command, flags } = parseLegacyRelayCliArgs(argv) + // Why before anything runs: `--help` on a valid command must never apply its effects. + if (flags.has('help') || argv.includes('-h')) { + return { stdout: `${LEGACY_RELAY_CLI_HELP}\n`, stderr: '', exitCode: 0 } + } + if (!COMMANDS.has(command)) { + throw new Error(LEGACY_RELAY_CLI_UNSUPPORTED) + } + const retryRequestId = retryRequestFlag(flags) + const invocation = admit(deps, env, signal, retryRequestId) + const call = buildCall(command, flags, invocation.caller) + const requestId = retryRequestId ?? randomUUID() + const response = await dispatchAsCaller(deps, invocation, call, requestId) + return formatLegacyRelayCliResult(call, response, invocation.caller, flags) +} + +async function servePostOutput( + deps: LegacyRelayCliServerDeps, + params: Record, + signal: AbortSignal +): Promise { + const env = stringRecord(params.env) + const invocation = admit(deps, env, signal) + const { caller } = invocation + const post = isRecord(params.postOutput) ? params.postOutput : {} + const ack = + post.kind === 'legacy_check_ack' && Array.isArray(post.messageIds) + ? { + compatibilityAck: JSON.stringify({ + messageIds: post.messageIds.filter((id) => typeof id === 'string'), + ...(Array.isArray(post.types) + ? { types: post.types.filter((type) => typeof type === 'string') } + : {}) + }) + } + : post.kind === 'legacy_question_ack' && + typeof post.questionId === 'string' && + typeof post.answerMessageId === 'string' + ? { + compatibilityQuestionAck: JSON.stringify({ + questionId: post.questionId, + answerMessageId: post.answerMessageId + }) + } + : null + if (!ack) { + throw new Error('Invalid SSH CLI post-output action.') + } + const call = { method: 'orchestration.check', params: { terminal: caller.handle, ...ack } } + const response = await dispatchAsCaller(deps, invocation, call, randomUUID()) + if (!response.ok) { + throw new Error(response.error.message) } + return { acknowledged: true } } diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx index b7b5f3c935ce..dea5cc2badaf 100644 --- a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -119,7 +119,7 @@ export function SshHostAdvancedFields({

{translate( 'auto.components.settings.SshTargetForm.allowRemoteCliControlHelp', - "When on, orca commands run on this host can open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server." + "When on, orca commands run on this host can open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected. Terminals started by an older Orca on this host can also run orchestration commands (check, inbox, send, ask, reply) for their own supervised work. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server." )}

{form.allowRemoteCliControlReset && !form.allowRemoteCliControl ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9cfba6870c12..28b53c78f91a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8992,7 +8992,7 @@ "8c922dffba": "Reuse SSH connection for faster setup", "53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.", "allowRemoteCliControl": "Allow this host's orca CLI to control Orca", - "allowRemoteCliControlHelp": "When on, orca commands run on this host can open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server.", + "allowRemoteCliControlHelp": "When on, orca commands run on this host can open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected. Terminals started by an older Orca on this host can also run orchestration commands (check, inbox, send, ask, reply) for their own supervised work. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server.", "allowRemoteCliControlReset": "Orca turned this off after an update, because it now lets this host drive your desktop browser. Turn it on again only if you want that.", "editTitle": "Edit SSH host", "addTitle": "Add SSH host", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 36f484a77cc9..201372c5e9a5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8719,7 +8719,7 @@ "addDescription": "Agrega una máquina persistente en la que puedas iniciar sesión por SSH.", "editingPrefix": "Editando", "allowRemoteCliControl": "Permitir que la orca CLI de este host controle Orca", - "allowRemoteCliControlHelp": "Cuando está activado, los comandos orca ejecutados en este host pueden abrir y controlar pestañas del navegador de Orca en este escritorio, solo en los espacios de trabajo de este host y solo mientras esté conectado. No pueden leer tus archivos, tu portapapeles ni tus cookies. Cuando está desactivado, solo llegan al propio servidor de Orca de este host.", + "allowRemoteCliControlHelp": "Cuando está activado, los comandos orca ejecutados en este host pueden abrir y controlar pestañas del navegador de Orca en este escritorio, solo en los espacios de trabajo de este host y solo mientras esté conectado. Las terminales iniciadas por una versión anterior de Orca en este host también pueden ejecutar comandos de orquestación (check, inbox, send, ask, reply) para su propio trabajo supervisado. No pueden leer tus archivos, tu portapapeles ni tus cookies. Cuando está desactivado, solo llegan al propio servidor de Orca de este host.", "allowRemoteCliControlReset": "Orca lo desactivó tras una actualización, porque ahora permite que este host controle el navegador de tu escritorio. Vuelve a activarlo solo si lo deseas." }, "TasksPane": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index dc1014fddf97..3fd126395c86 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8869,7 +8869,7 @@ "addDescription": "Ajoutez une machine permanente sur laquelle vous pouvez vous connecter en SSH.", "editingPrefix": "Modification", "allowRemoteCliControl": "Autoriser l'orca CLI de cet hôte à contrôler Orca", - "allowRemoteCliControlHelp": "Lorsque cette option est activée, les commandes orca exécutées sur cet hôte peuvent ouvrir et piloter des onglets du navigateur Orca sur ce bureau, uniquement dans les espaces de travail de cet hôte et uniquement tant qu'il est connecté. Elles ne peuvent pas lire vos fichiers, votre presse-papiers ni vos cookies. Lorsqu'elle est désactivée, elles n'atteignent que le serveur Orca de cet hôte.", + "allowRemoteCliControlHelp": "Lorsque cette option est activée, les commandes orca exécutées sur cet hôte peuvent ouvrir et piloter des onglets du navigateur Orca sur ce bureau, uniquement dans les espaces de travail de cet hôte et uniquement tant qu'il est connecté. Les terminaux lancés par une ancienne version d'Orca sur cet hôte peuvent aussi exécuter des commandes d'orchestration (check, inbox, send, ask, reply) pour leur propre travail supervisé. Elles ne peuvent pas lire vos fichiers, votre presse-papiers ni vos cookies. Lorsqu'elle est désactivée, elles n'atteignent que le serveur Orca de cet hôte.", "allowRemoteCliControlReset": "Orca l'a désactivé après une mise à jour, car il permet désormais à cet hôte de piloter le navigateur de votre bureau. Réactivez-le seulement si vous le souhaitez." }, "TasksPane": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 995c18ae25e6..dc4055dc69c2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8745,7 +8745,7 @@ "addDescription": "SSH でログインできる永続的なマシンを追加します。", "editingPrefix": "編集中", "allowRemoteCliControl": "このホストの orca CLI による Orca の操作を許可する", - "allowRemoteCliControlHelp": "オンにすると、このホストで実行される orca コマンドは、このホストのワークスペース内に限り、接続中のみ、このデスクトップの Orca ブラウザのタブを開いて操作できます。ファイル、クリップボード、Cookie は読み取れません。オフの場合、このホスト自身の Orca サーバーにのみアクセスします。", + "allowRemoteCliControlHelp": "オンにすると、このホストで実行される orca コマンドは、このホストのワークスペース内に限り、接続中のみ、このデスクトップの Orca ブラウザのタブを開いて操作できます。このホストで古い Orca が起動したターミナルも、自身の監督下の作業についてオーケストレーションコマンド(check、inbox、send、ask、reply)を実行できます。ファイル、クリップボード、Cookie は読み取れません。オフの場合、このホスト自身の Orca サーバーにのみアクセスします。", "allowRemoteCliControlReset": "アップデート後に Orca がこれをオフにしました。この設定でこのホストがデスクトップのブラウザを操作できるようになったためです。必要な場合のみ再度オンにしてください。" }, "TasksPane": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0f08de86a74d..1a62fb3348b6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8708,7 +8708,7 @@ "addDescription": "SSH로 로그인할 수 있는 영구 머신을 추가합니다.", "editingPrefix": "편집 중", "allowRemoteCliControl": "이 호스트의 orca CLI가 Orca를 제어하도록 허용", - "allowRemoteCliControlHelp": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다.", + "allowRemoteCliControlHelp": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 이 호스트에서 이전 버전의 Orca가 시작한 터미널도 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다.", "allowRemoteCliControlReset": "업데이트 후 Orca가 이 설정을 껐습니다. 이제 이 호스트가 데스크톱 브라우저를 조작할 수 있게 되었기 때문입니다. 원하는 경우에만 다시 켜세요." }, "TasksPane": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7bdfd44f4dae..948d63a0ae2a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8708,7 +8708,7 @@ "addDescription": "添加一台可通过 SSH 登录的持久机器。", "editingPrefix": "正在编辑", "allowRemoteCliControl": "允许此主机的 orca CLI 控制 Orca", - "allowRemoteCliControlHelp": "开启后,在此主机上运行的 orca 命令可以打开并操作此桌面上的 Orca 浏览器标签页,但仅限此主机的工作区,且仅在主机连接期间。它们无法读取你的文件、剪贴板或 Cookie。关闭时,它们只能访问此主机自身的 Orca 服务器。", + "allowRemoteCliControlHelp": "开启后,在此主机上运行的 orca 命令可以打开并操作此桌面上的 Orca 浏览器标签页,但仅限此主机的工作区,且仅在主机连接期间。此主机上由旧版 Orca 启动的终端也可以为其自身受监督的工作运行编排命令(check、inbox、send、ask、reply)。它们无法读取你的文件、剪贴板或 Cookie。关闭时,它们只能访问此主机自身的 Orca 服务器。", "allowRemoteCliControlReset": "更新后 Orca 关闭了此项,因为它现在允许此主机操作你桌面上的浏览器。仅在需要时再重新开启。" }, "TasksPane": { From 1c8f74f8e49dfb89a3b3dbf7f59ffe45e218e00d Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 16 Oct 2026 02:41:36 -0700 Subject: [PATCH 8/8] fix(ssh): admit settled legacy workers only with a committed receipt; honor exact reply replays --- .../legacy-relay-cli-call-limits.test.ts | 44 +++++++++++++++++++ .../legacy-relay-cli-caller.ts | 13 ++++-- .../legacy-relay-cli-legacy-records.test.ts | 33 ++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts index fe5aa7f4d2eb..e807e87aa7c0 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-call-limits.test.ts @@ -144,6 +144,50 @@ describe('limits on relay-era CLI calls', () => { ).rejects.toThrow(/outside it/) }) + it('replays a committed reply after the worker took a new assignment', async () => { + const dispatch = db.getActiveDispatchForIdentity(WORKER, WORKER_PANE) + const question = db.insertMessage({ + runId: dispatch?.run_id ?? '', + from: COORDINATOR, + to: `dispatch:${dispatch?.id ?? ''}`, + subject: 'q', + deliveryContract: 'current_delivery' + }) + const reply = [ + 'orchestration', + 'reply', + '--id', + question.id, + '--body', + 'b', + '--retry-request', + '99999999-9999-4999-8999-999999999999', + '--json' + ] + const first = await server().cli({ argv: reply, env: ENV }, new AbortController().signal) + db.db.prepare(`UPDATE dispatch_contexts SET status = 'completed'`).run() + const next = db.createTask({ + runId: dispatch?.run_id ?? '', + spec: 'next', + createdByTerminalHandle: COORDINATOR + }) + db.createDispatchContext({ + taskId: next.id, + assigneeHandle: WORKER, + assigneePaneKey: WORKER_PANE, + processIncarnation: `${PTY_ID}:inc-1`, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + const replay = await server().cli({ argv: reply, env: ENV }, new AbortController().signal) + const body = (result: unknown) => + JSON.parse( + String(result && typeof result === 'object' && 'stdout' in result ? result.stdout : '') + ) + expect(body(replay)).toMatchObject({ ok: true, result: { mutation: { replayed: true } } }) + expect(body(replay).result.message).toEqual(body(first).result.message) + }) + it('names nobody when two hosts claim one handle', () => { const terminal = { handle: WORKER, paneKey: WORKER_PANE, processIncarnation: 'p-1' } const fresh = new OrcaRuntimeService() diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts index 573380095765..ec9cb6b048f4 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-caller.ts @@ -73,7 +73,9 @@ export function resolveLegacyRelayCliCaller( const replayed = replay && db.getMutationReceipt(replay.fingerprint, replay.requestId)?.state === 'completed' const dispatch = active ?? (replayed ? own(db.getLatestDispatchForTerminal(handle)) : undefined) - const replayDispatch = replay ? own(settledLegacyDispatch(db, handle, paneKey)) : undefined + const replayDispatch = replay + ? own(settledLegacyDispatch(db, handle, paneKey, replay.requestId)) + : undefined const run = resolveCoordinatedRun(db, handle, paneKey) if (!dispatch && !replayDispatch && !run) { throw new Error(LEGACY_RELAY_CLI_UNVERIFIED) @@ -93,7 +95,8 @@ export function resolveLegacyRelayCliCaller( function settledLegacyDispatch( db: OrchestrationDb, handle: string, - paneKey: string + paneKey: string, + requestId: string ): DispatchContextRow | undefined { const adoption = db.getLegacyAdoption() const principal = adoption @@ -103,9 +106,11 @@ function settledLegacyDispatch( paneKey }) : undefined + // Why the receipt: only an operation this principal committed under this request id replays. return principal?.status === 'settled' && principal.terminal_handle === handle && - principal.dispatch_id + principal.dispatch_id && + db.getLegacyOperationReceipt(principal.id, `invocation:${requestId}`) ? db.getDispatchContextById(principal.dispatch_id) : undefined } @@ -243,7 +248,7 @@ export function assertLegacyRelayCliScope( if (method === 'orchestration.reply') { const id = stringParam(params, 'id') ?? '' const original = db.getMessageById(id) - if (!original || !ownedAddresses(caller).has(original.to_handle)) { + if (!original || (!exactReplay && !ownedAddresses(caller).has(original.to_handle))) { throw outside(`message '${id}'`) } return diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts index 95217cde484d..66d9591f9938 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-cli-legacy-records.test.ts @@ -214,6 +214,39 @@ describe('the relay-era orca CLI on migrated legacy orchestration records', () = expect(afterReassignment.body).toMatchObject({ ok: true }) }) + it('admits a settled legacy worker only to replay an operation it committed', async () => { + const { runtime, taskId, dispatchId } = legacyRuntime() + const done = [ + 'orchestration', + 'send', + '--to', + COORDINATOR, + '--type', + 'worker_done', + '--subject', + 'done', + '--task-id', + taskId, + '--dispatch-id', + dispatchId, + '--outcome', + 'succeeded', + '--json' + ] + await run(runtime, [...done, '--retry-request', '77777777-7777-4777-8777-777777777777']) + const fresh = [ + 'orchestration', + 'send', + '--to', + COORDINATOR, + '--subject', + 'new', + '--retry-request', + '88888888-8888-4888-8888-888888888888' + ] + await expect(run(runtime, fresh)).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED) + }) + it('vouches only for the exact terminal its attachment carries', () => { const runtime = new OrcaRuntimeService() const terminal = { handle: WORKER, paneKey: WORKER_PANE, processIncarnation: `${PTY_ID}:inc-1` }