diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts new file mode 100644 index 00000000000..68f6e97e632 --- /dev/null +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -0,0 +1,109 @@ +/** + * The desktop renderer talks to two hosts — its own main process and a paired remote — and used to + * advertise a different capability set to each, hand-maintained on both sides. `agent.launch` is + * what that drift cost: admitted remotely, refused locally. These tests pin the divergence so the + * next capability cannot be added to one side and forgotten on the other. + */ + +import { describe, expect, it } from 'vitest' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' +import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' + +/** Advertised to a remote host and deliberately NOT to main: each would change local behaviour or + * has no local meaning. Adding to this set is a decision; leaving it out of both lists is not. */ +const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ + // Flips `requiresIntent` on, so an unattributed desktop tab close would start being refused. + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + // Carried as a group under one rationale, not audited one by one: these are mixed-version wire + // terms, and main and the renderer are a single build. Before moving any of them across, check + // what the host actually gates on it — the entry above is what that check looks like. + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + // Being a page host for a REMOTE runtime; main hosts its own pages directly. + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + // Opts into a delta feed in place of the full tab list — a remote-transport concern. + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY +] + +/** Gates the renderer must pass against its own main process. The Electron remote list omits all + * five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a + * statement that no remote client wants them. Why it is one is not recorded here. */ +const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] + +function missingFrom( + source: readonly RuntimeCapability[], + other: readonly RuntimeCapability[] +): RuntimeCapability[] { + return source.filter((capability) => !other.includes(capability)).sort() +} + +describe('desktop renderer runtime client capabilities', () => { + it('passes the host gate that refuses agent.launch', () => { + const renderer = { + clientKind: 'runtime', + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + } as const + expect(supportsAgentLaunch(renderer)).toBe(true) + // Negative control: the gate really discriminates, so the assertion above is not vacuous. + expect( + supportsAgentLaunch({ + clientKind: 'runtime', + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== AGENT_LAUNCH_RUNTIME_CAPABILITY + ) + }) + ).toBe(false) + }) + + it('advertises each capability once so none can be dropped by a stale duplicate', () => { + expect(new Set(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES).size).toBe( + DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.length + ) + }) + + it('diverges from the remote Electron list only where a decision was recorded', () => { + expect( + missingFrom( + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + ) + ).toEqual([...REMOTE_ONLY_BY_DECISION].sort()) + expect( + missingFrom( + DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + ) + ).toEqual([...LOCAL_ONLY_BY_DECISION].sort()) + }) +}) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts new file mode 100644 index 00000000000..63ba9b1d0b1 --- /dev/null +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -0,0 +1,35 @@ +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' + +/** + * What the desktop renderer advertises when it calls its own main process over `runtime:call`. + * + * Main and the renderer ship as one build, so nothing here is about version skew — the renderer + * arrives as `clientKind: 'runtime'`, not in the `clientKind === undefined` population, so any + * capability the host uses as an authorization gate has to be named here or the method is refused. + * That is why this stays a curated set rather than the remote list: several remote-only entries + * would change local behaviour if adopted (`SESSION_TAB_CLOSE_INTENT` alone would start refusing + * an unattributed desktop tab close), and the divergence is pinned in this module's test. + * + * One constant, not one list per dispatch path: the unary and streaming handlers held separate + * copies, and a capability added to one and missed on the other is invisible until a user hits it. + */ +export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer + // targeting a remote host is admitted — the asymmetry this constant exists to close. + AGENT_LAUNCH_RUNTIME_CAPABILITY +] as const diff --git a/src/main/ipc/runtime-agent-launch-capability.test.ts b/src/main/ipc/runtime-agent-launch-capability.test.ts new file mode 100644 index 00000000000..b88a3ca8b24 --- /dev/null +++ b/src/main/ipc/runtime-agent-launch-capability.test.ts @@ -0,0 +1,131 @@ +/** + * The renderer's own main process refused `agent.launch` while the same renderer aimed at a remote + * host was admitted. This drives the registered `runtime:call` / `runtime:subscribe` handlers and + * feeds what they actually advertise to the real host gate, so wiring and gate are proved together + * rather than each against a restatement of the other. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' + +type AdvertisedClient = { + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: readonly RuntimeCapability[] +} + +const { handlers, advertised } = vi.hoisted( + (): { + handlers: Map unknown> + advertised: { unary: AdvertisedClient[]; streaming: AdvertisedClient[] } + } => ({ + handlers: new Map(), + advertised: { unary: [], streaming: [] } + }) +) + +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: vi.fn() }, + ipcMain: { + handle: vi.fn((channel: string, handler: (event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn(), + removeAllListeners: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('../runtime/rpc/dispatcher', () => ({ + RpcDispatcher: class { + dispatch(_request: unknown, options: AdvertisedClient): Promise { + advertised.unary.push(options) + return Promise.resolve({ ok: true, result: {} }) + } + + dispatchStreaming( + _request: unknown, + _emit: (response: string) => void, + options: AdvertisedClient + ): Promise { + advertised.streaming.push(options) + // Never settles: the handler only attaches a cleanup callback to this. + return new Promise(() => {}) + } + } +})) + +const { registerRuntimeHandlers } = await import('./runtime') +const { supportsAgentLaunch } = await import('../runtime/rpc/methods/agent-launch') + +function rendererEvent() { + const mainFrame = {} + return { + sender: { id: 1, mainFrame, on: vi.fn(), once: vi.fn(), isDestroyed: () => false }, + senderFrame: mainFrame + } +} + +function invoke(channel: string, args: unknown): void { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`no handler registered for ${channel}`) + } + void handler(rendererEvent(), args) +} + +/** Throws rather than defaulting: a missing record would make `supportsAgentLaunch` pass on the + * `clientKind === undefined` branch and every assertion below would be vacuous. */ +function onlyAdvertisedClient(records: readonly AdvertisedClient[]): AdvertisedClient { + const client = records[0] + if (!client) { + throw new Error('the handler dispatched nothing') + } + return client +} + +function withoutLaunchCapability(client: AdvertisedClient): AdvertisedClient { + return { + clientKind: client.clientKind, + clientCapabilities: client.clientCapabilities?.filter( + (capability) => capability !== AGENT_LAUNCH_RUNTIME_CAPABILITY + ) + } +} + +describe('desktop renderer reaching agent.launch on its own main process', () => { + beforeEach(() => { + handlers.clear() + advertised.unary.length = 0 + advertised.streaming.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registerRuntimeHandlers reaches only the sender-lifecycle cleanup on this path; any other runtime method it called would throw here rather than read a wrong value. + registerRuntimeHandlers({ cleanupSubscriptionsForConnection: vi.fn() } as never) + }) + + it('advertises the launch capability on the unary path', () => { + invoke('runtime:call', { + method: 'agent.launch', + params: { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } + }) + + const client = onlyAdvertisedClient(advertised.unary) + expect(client.clientKind).toBe('runtime') + expect(supportsAgentLaunch(client)).toBe(true) + // Negative control: the gate does refuse this same caller once the capability is taken away, + // so the assertion above is about the advertised list, not a permissive predicate. + expect(supportsAgentLaunch(withoutLaunchCapability(client))).toBe(false) + }) + + it('advertises the same set on the streaming path', () => { + invoke('runtime:call', { method: 'status.get' }) + invoke('runtime:subscribe', { subscriptionId: 'sub-1', method: 'session.tabs.watch' }) + + const streaming = onlyAdvertisedClient(advertised.streaming) + expect(streaming.clientCapabilities).toEqual( + onlyAdvertisedClient(advertised.unary).clientCapabilities + ) + expect(supportsAgentLaunch(streaming)).toBe(true) + }) +}) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index ff55c8dbab2..e83a9076eb4 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -10,14 +10,7 @@ import type { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' -import { - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' @@ -72,6 +65,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { if (event.senderFrame !== event.sender.mainFrame) { throw new Error('Runtime RPC call must originate from the current main frame') } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher's RpcSuccess/RpcFailure union is the same envelope RuntimeRpcResponse describes; only the `result` generic differs, and this call site declares it as unknown. return (await new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch( { id: 'desktop-ipc', @@ -83,14 +77,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientId: 'desktop-renderer', clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), - clientCapabilities: [ - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } )) as RuntimeRpcResponse } @@ -135,14 +122,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientId: 'desktop-renderer', clientKind: 'runtime', connectionId, - clientCapabilities: [ - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } ) .finally(stop) diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 3af6bffa801..d9da45950f8 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -38,8 +38,10 @@ import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' /** * Advertising `agent.launch.v2` is a client's statement that it understands EITHER outcome — a * structured session it can open, or a terminal agent. A client that can only render one of the - * two must keep using the surface-specific methods instead. In-process callers are the same build - * as the host and negotiate nothing. + * two must keep using the surface-specific methods instead. The `clientKind === undefined` branch + * is not "whatever ships in this build": it is the `orca` CLI over the runtime socket and the + * SSH-remote CLI bridges, which carry no capability list at all. The desktop renderer ships in + * this build and still arrives as `clientKind: 'runtime'`, so it advertises like any other client. */ export function supportsAgentLaunch( context: Pick