diff --git a/src/main/ipc/runtime-environment-pairing-verification.ts b/src/main/ipc/runtime-environment-pairing-verification.ts index d86f298a5f0..4315df4bdfd 100644 --- a/src/main/ipc/runtime-environment-pairing-verification.ts +++ b/src/main/ipc/runtime-environment-pairing-verification.ts @@ -9,9 +9,9 @@ import { } from '../../shared/remote-pairing-verification' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' import type { RuntimeStatus } from '../../shared/runtime-types' +import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement' type VerifyAndAddRuntimeEnvironmentArgs = { name: string @@ -44,7 +44,7 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode( 15_000, undefined, undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + electronRemoteRuntimeClientCapabilities() ) if (!response.ok) { return { diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 6ba9f273c1e..79825c3589d 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -4,7 +4,6 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' import type { RuntimeStatus } from '../../shared/runtime-types' import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse @@ -22,6 +21,7 @@ import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' +import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement' type CachedRuntimeConnection = { pairingKey: string @@ -102,7 +102,7 @@ export function sendRemoteRuntimeConnectionRequest( pairingKey, connection: new RemoteRuntimeRequestConnection( pairing, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + electronRemoteRuntimeClientCapabilities() ) } requestConnections.set(environmentId, cached) @@ -212,7 +212,7 @@ function getSharedControlConnection( pairingKey, connection: new RemoteRuntimeSharedControlConnection(pairing, { environmentId, - clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + clientCapabilities: electronRemoteRuntimeClientCapabilities(), isManuallyDisconnected: () => isRuntimeEnvironmentManuallyDisconnected(environmentId), isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId), onDiagnosticsChanged: (diagnostics) => { diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 4ac3c067f74..dbcf6ca8b03 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -1,9 +1,6 @@ import { BrowserWindow } from 'electron' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' import { getPreferredPairingOffer, type KnownRuntimeEnvironment @@ -20,6 +17,7 @@ import { captureRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' +import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement' export function createRuntimeEnvironmentStatusOwner( userDataPath: string, @@ -49,7 +47,7 @@ export function createRuntimeEnvironmentStatusOwner( 15_000, undefined, signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + electronRemoteRuntimeClientCapabilities() ) }, verified: (response, active) => { diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index 9566b2fc1b7..40bb2fa3f88 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -7,7 +7,6 @@ import type { import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { markEnvironmentUsed, resolveEnvironment } from '../../shared/runtime-environment-store' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription @@ -24,6 +23,7 @@ import { sendRemoteRuntimeSharedControlRequestAbortable } from './runtime-environment-abortable-requests' import { subscribeRemoteRuntimeSharedControlRequest } from './runtime-environment-request-connections' +import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement' type SupportRoute = { environment: KnownRuntimeEnvironment @@ -98,7 +98,7 @@ export function executeSupportRoutedCall(args: { args.timeoutMs, args.envelope, args.signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + electronRemoteRuntimeClientCapabilities() ), markUsed: (environmentId, response) => { if (response.ok) { @@ -156,7 +156,7 @@ export async function subscribeSupportRoutedRuntimeEnvironment(args: { args.params, args.timeoutMs, callbacks, - { clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } + { clientCapabilities: electronRemoteRuntimeClientCapabilities() } ) } }) diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index b19b0d9e376..6b3c682bec5 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,5 +1,4 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { @@ -30,6 +29,7 @@ import { shouldRouteSubscriptionBySupport, subscribeSupportRoutedRuntimeEnvironment } from './runtime-environment-support-routing' +import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement' const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 @@ -114,7 +114,7 @@ export async function callRuntimeEnvironment( effectiveTimeoutMs, envelope, options?.signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + electronRemoteRuntimeClientCapabilities() ) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response @@ -152,7 +152,7 @@ export async function callRuntimeEnvironment( effectiveTimeoutMs, sharedControlEnvelope, options?.signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + electronRemoteRuntimeClientCapabilities() ) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response @@ -236,7 +236,7 @@ export async function subscribeRuntimeEnvironment( params, effectiveTimeoutMs, callbacksWithMarkUsed, - { clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } + { clientCapabilities: electronRemoteRuntimeClientCapabilities() } ) } catch (error) { if (error instanceof Error) { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 1d4194825d9..8966df0ee21 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -36,6 +36,7 @@ import { computerAwakeSettingsForMode, normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' +import { setStructuredChatRemoteReadSource } from './structured-reader-advertisement' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -72,6 +73,9 @@ export function registerSettingsHandlers( store: Store, agentAwakeService?: AgentAwakeService ): void { + // Read live rather than mirrored: every paired connection this process opens decides its + // structured-reader advertisement from this, and a copy taken here would outlive the switch. + setStructuredChatRemoteReadSource(() => store.getSettings().structuredChatRemoteRead !== false) ipcMain.handle( 'agentAwake:getStatus', () => agentAwakeService?.getStatus() ?? { mode: 'off', active: false } diff --git a/src/main/ipc/structured-reader-advertisement.test.ts b/src/main/ipc/structured-reader-advertisement.test.ts new file mode 100644 index 00000000000..00f647477e3 --- /dev/null +++ b/src/main/ipc/structured-reader-advertisement.test.ts @@ -0,0 +1,73 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES +} from '../../shared/protocol-version' +import { + electronRemoteRuntimeClientCapabilities, + resetStructuredChatRemoteReadSourceForTests, + setStructuredChatRemoteReadSource, + structuredChatRemoteReadEnabled +} from './structured-reader-advertisement' + +const OWNER = 'structured-reader-advertisement.ts' + +function ipcSourceFiles(root: string): string[] { + return readdirSync(root).flatMap((entry) => { + const full = join(root, entry) + if (statSync(full).isDirectory()) { + return ipcSourceFiles(full) + } + return full.endsWith('.ts') && !full.endsWith('.test.ts') ? [full] : [] + }) +} + +afterEach(() => { + resetStructuredChatRemoteReadSourceForTests() +}) + +describe('what this desktop advertises to a paired host', () => { + it('carries the structured reader while the setting is on or unset', () => { + expect(structuredChatRemoteReadEnabled()).toBe(true) + setStructuredChatRemoteReadSource(() => true) + for (const capability of STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) { + expect(electronRemoteRuntimeClientCapabilities()).toContain(capability) + } + }) + + it('drops only the reader when the setting is off, so the retreat is not a new client', () => { + setStructuredChatRemoteReadSource(() => false) + const advertised = electronRemoteRuntimeClientCapabilities() + for (const capability of STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) { + expect(advertised, `${capability} is withheld`).not.toContain(capability) + } + // Everything else is untouched: a user retreating from structured reads must not also lose + // page placement or the retirement-proof ledger and start looking like some other client. + const withheld = new Set(STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) + expect(advertised).toEqual( + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES.filter((capability) => !withheld.has(capability)) + ) + }) + + it('keeps advertising when the settings read throws rather than retreating silently', () => { + setStructuredChatRemoteReadSource(() => { + throw new Error('settings store unavailable') + }) + expect(electronRemoteRuntimeClientCapabilities()).toEqual( + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + ) + }) + + it('is the only place a paired connection reads the list from', () => { + const offenders = ipcSourceFiles(__dirname) + .filter((file) => !file.endsWith(OWNER)) + .filter((file) => + readFileSync(file, 'utf8').includes('ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES') + ) + // A call site holding the constant directly advertises the reader whatever the user set, and + // nothing about it looks wrong: it is the same list this module returns on the default path. + expect(offenders, 'these advertise past the retreat lever').toEqual([]) + }) +}) diff --git a/src/main/ipc/structured-reader-advertisement.ts b/src/main/ipc/structured-reader-advertisement.ts new file mode 100644 index 00000000000..f002d02ef30 --- /dev/null +++ b/src/main/ipc/structured-reader-advertisement.ts @@ -0,0 +1,51 @@ +// What this desktop tells a paired host it can read, and the one lever that takes it back. +// +// The structured reader strings are not inert: a host withholds structured session-tab rows and +// refuses `agentSession.*` to a client that omits them, so advertising them is what makes a paired +// host start publishing other machines' chats here. That is a change to published content, which +// the wire contract treats as a wire change, so it needs a retreat that does not require shipping +// a new build. Every connection this process opens reads the list from here. +// +// The lever is read through a source rather than mirrored, because a stale copy would advertise on +// behalf of a user who had already switched it off. It only reaches connections opened after the +// switch: a capability is negotiated once per connection, so an established pairing keeps reading +// until it reconnects. + +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES, + type RuntimeCapability +} from '../../shared/protocol-version' + +const READER_CAPABILITIES = new Set(STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) + +const WITHOUT_STRUCTURED_READER: readonly RuntimeCapability[] = + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => !READER_CAPABILITIES.has(capability) + ) + +/** Absent source = advertise, matching every other capability this build ships with. */ +let readStructuredChatRemoteRead: (() => boolean) | null = null + +export function setStructuredChatRemoteReadSource(source: () => boolean): void { + readStructuredChatRemoteRead = source +} + +export function resetStructuredChatRemoteReadSourceForTests(): void { + readStructuredChatRemoteRead = null +} + +export function structuredChatRemoteReadEnabled(): boolean { + try { + return readStructuredChatRemoteRead?.() !== false + } catch { + // A settings read that throws must not silently retreat a feature the user left on. + return true + } +} + +export function electronRemoteRuntimeClientCapabilities(): readonly RuntimeCapability[] { + return structuredChatRemoteReadEnabled() + ? ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + : WITHOUT_STRUCTURED_READER +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionNotices.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionNotices.tsx index db23e7ea675..ed8088768be 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionNotices.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionNotices.tsx @@ -60,6 +60,23 @@ function RetryButton({ onClick }: { onClick: () => void }): React.JSX.Element { ) } +/** A live host this build only reads. Deliberately not one of the hold states below: nothing was + * refused and nothing is missing on the other machine, so saying "update that server" would send + * the user after the wrong fix. */ +function RemoteReadOnlyNotice(): React.JSX.Element { + return ( + + + {translate( + 'components.native-chat.structuredSessionRemoteReadOnly', + 'This chat runs on a paired host. This version of Orca shows it here but does not send to it.' + )}{' '} + {holdLifetimeNote()} + + + ) +} + /** The two degraded states stay apart: a host that answers and lacks the method is an update * prompt, never an error; a host that never answered is the read-only one. */ function HoldNotice({ @@ -151,7 +168,11 @@ export function NativeChatStructuredSessionNotices({ )}

) : null} - + {controller.remoteReadOnly ? ( + + ) : ( + + )} {controller.error || composerError ? (

{controller.error ?? composerError} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-hold-degraded-pane.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-hold-degraded-pane.test.tsx index 3948dd8d70c..46530ca7bff 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-hold-degraded-pane.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-hold-degraded-pane.test.tsx @@ -3,6 +3,10 @@ // Two degraded panes that must never look alike: a paired host that answers and has no hold method // still reads and still sends, it just needs an update; a host that has not answered leaves the // pane on the transcript it last read, with writes off and a Retry. +// +// Every case here is about a pane that holds, so remote writes are on for the file: with them off +// a paired pane takes no hold at all, which is its own guard in +// `structured-remote-read-only-pane.test.tsx` rather than a variant of these. import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,6 +27,9 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ ...(await importOriginal()), runtimeEnvironmentSupportsCapability: holdMocks.supportsCapability })) +vi.mock('./structured-remote-session-writes', () => ({ + structuredRemoteSessionWritesEnabled: () => true +})) vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) diff --git a/src/renderer/src/components/native-chat/structured-remote-read-only-pane.test.tsx b/src/renderer/src/components/native-chat/structured-remote-read-only-pane.test.tsx new file mode 100644 index 00000000000..df37fbbb2ee --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-remote-read-only-pane.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment happy-dom +// +// This release advertises that it can READ a paired host's structured chats, and that is the whole +// of it. The host is willing — it admits the hold to any client that advertises the reader — so the +// only thing standing between a restored chat row and a provider child waking up on somebody else's +// machine is this pane. These are that guard. + +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../../shared/structured-agent-session-reducer' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { structuredTabOwnerBinding } from '@/runtime/structured-tab-owner' +import { buildMirroredAgentTabs } from '@/runtime/web-session-tabs-sync/terminal-surfaces' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +// A host with every structured capability: the pane must refuse itself, not be refused. +const hostMocks = vi.hoisted(() => ({ supportsCapability: vi.fn(async () => true) })) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: hostMocks.supportsCapability +})) +vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: readState, + loadingOlder: false, + loadOlder: vi.fn(), + providerSession: undefined + }) +})) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +const ENVIRONMENT_ID = 'env-paired' +const SESSION_ID = 'session-restored' + +const transcriptItem: AgentJournalRenderItem = { + itemId: 'item-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Last answer.' }] } +} + +/** The transcript the host published; a read-only pane still shows all of it. */ +const readState = { + ...EMPTY_STRUCTURED_AGENT_SESSION, + epoch: 'epoch-1', + fence: 1, + status: 'ready' as const, + items: [transcriptItem], + commands: null +} + +/** A worktree the paired host was already holding a chat in before this client ever connected. */ +function preExistingHostSnapshot(): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-paired', + publicationEpoch: 'epoch-1', + snapshotVersion: 4, + activeGroupId: 'group-1', + activeTabId: `agent-session:${SESSION_ID}`, + activeTabType: 'agent-session', + tabs: [ + { + type: 'agent-session', + id: `agent-session:${SESSION_ID}`, + title: 'Codex Chat', + sessionId: SESSION_ID, + agent: 'codex', + isActive: true + } + ] + } +} + +/** + * The tab the mirror really builds from that snapshot, then the owner binding the pane really + * reads off it. Going through both is the point: a pane handed a hand-written environment target + * proves nothing about what a restored host row turns into. + */ +function paneFromRestoredHostRow(): { target: { kind: string }; stale: boolean } { + const mirrored = buildMirroredAgentTabs( + preExistingHostSnapshot(), + new Map(), + 'group-1', + 0, + [], + 1, + `runtime:${ENVIRONMENT_ID}` + ) + const stamp = mirrored[0]?.unifiedTab + if (!stamp) { + throw new Error('the mirror built no tab for a published structured row') + } + const binding = structuredTabOwnerBinding(stamp, null) + return { target: binding.target, stale: binding.ownerPairingStale } +} + +function renderRestoredPane(): void { + const { target, stale } = paneFromRestoredHostRow() + expect(target, 'the restored row must bind to its paired owner').toMatchObject({ + kind: 'environment', + environmentId: ENVIRONMENT_ID + }) + // Not the re-paired degraded state: this pane is read-only while its owner is perfectly current. + expect(stale).toBe(false) + render( + + ) +} + +function callsTo(method: string): unknown[][] { + return mocks.call.mock.calls.filter((call) => call[1] === method) +} + +beforeEach(() => { + replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 7, pairingRevision: 7 }]) + hostMocks.supportsCapability.mockClear() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.options' + ? { current: { model: 'gpt-live' }, models: [], conversationCommands: [] } + : {} + ) +}) + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() + replaceRuntimeEnvironmentRevisions([]) +}) + +describe('a structured chat restored from a paired host', () => { + it('renders the transcript without waking anything on the host that owns it', async () => { + renderRestoredPane() + + expect(await screen.findByTestId('message-list')).toBeTruthy() + // The one call that opens a durable record on the other machine and hands its session a + // provider child back. A read-only pane must never make it, and must not even negotiate it: + // the host here advertises everything, so a pane that asked would have been told yes. + await waitFor(() => expect(mocks.composerProps?.canSend).toBe(false)) + expect(callsTo('agentSession.hold')).toHaveLength(0) + expect(callsTo('agentSession.release')).toHaveLength(0) + expect(hostMocks.supportsCapability).not.toHaveBeenCalled() + }) + + it('says why it is read-only in terms of this client, not of the host', async () => { + renderRestoredPane() + + const notice = await screen.findByText(/runs on a paired host/i) + expect(notice.dataset.nativeChatRemote).toBe('read-only') + // The lifetime sentence this stack is required to use. "Work continues while you are away" + // describes a PTY and is false for a structured session. + expect(notice.textContent).toContain( + 'The in-flight turn and its approvals survive going offline; idle sessions park after about 15 seconds and resume on demand.' + ) + expect(notice.textContent).not.toMatch(/work continues/i) + // Not the update prompt and not the lost-contact pane: neither is true of this host. + expect(document.querySelector('[data-native-chat-hold]')).toBeNull() + }) + + it('sends no mutation, whatever the pane is asked to do', async () => { + renderRestoredPane() + + await waitFor(() => expect(mocks.composerProps?.canSend).toBe(false)) + const transport = mocks.composerProps?.structuredTransport as + | { send?: (text: string, attachments: readonly unknown[]) => boolean } + | undefined + expect(transport?.send, 'the composer was never handed a transport').toBeTypeOf('function') + expect(transport?.send?.('hello', [])).toBe(false) + for (const method of [ + 'agentSession.hold', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.setOption', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.conversationCommand' + ]) { + expect(callsTo(method), `${method} reached the paired host`).toHaveLength(0) + } + // Anti-vacuous: the pane is talking to the host, just only ever reading it. + expect(callsTo('agentSession.options').length).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-remote-session-writes.ts b/src/renderer/src/components/native-chat/structured-remote-session-writes.ts new file mode 100644 index 00000000000..dc9af05de05 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-remote-session-writes.ts @@ -0,0 +1,10 @@ +// Whether a structured chat owned by a paired host may be written to from this client. +// +// The reader advertisement ships ahead of the switch that makes remote creation legal, so for now +// a paired chat is a transcript: no send, no approval, no option change, and above all no hold. +// The hold is the one read-shaped call that is not a read — it opens the durable record on the +// other machine and hands its session a provider child back — so a pane that took one would start +// work on a host nobody is watching. +export function structuredRemoteSessionWritesEnabled(): boolean { + return false +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index f6691d5974c..a6eaab72ca6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -32,6 +32,7 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' +import { structuredRemoteSessionWritesEnabled } from './structured-remote-session-writes' import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' import { pendingStructuredSessionPrompts, @@ -60,17 +61,22 @@ export function useStructuredAgentSession(args: { // A re-paired owner leaves the transcript exactly as last read and stops every write: the id now // names a different machine, so re-reading or mutating would address a stranger's journal. const live = isVisible && !ownerPairingStale + // A chat this client only reads. Not a degraded state and not a host's answer: the host is + // willing, and this build is the side that has not shipped the other half yet. + const remoteReadOnly = target.kind === 'environment' && !structuredRemoteSessionWritesEnabled() // Declared first: the hold is what gives a restored session its provider child back, and the // read below is useless for sending until it lands. const hold = useStructuredAgentSessionHold({ sessionId, target, surface: 'desktop-chat', - enabled: live + // Never merely ignored: the hold is the provider wake, so a read-only pane must not make the + // call at all rather than make it and refuse to use what it reserved. + enabled: live && !remoteReadOnly }) // A host that never answered cannot have taken the hold a restored session needs, so the pane is // looking at the last transcript it read; a write would address a host it has no contact with. - const readOnly = ownerPairingStale || hold.state.kind === 'unreachable' + const readOnly = ownerPairingStale || remoteReadOnly || hold.state.kind === 'unreachable' const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead({ ...args, isVisible: live @@ -219,6 +225,8 @@ export function useStructuredAgentSession(args: { cached: ownerPairingStale, /** Every reason this pane refuses writes, re-paired owner or unreachable host alike. */ readOnly, + /** Specifically: a live, willing paired host this build reads and does not drive. */ + remoteReadOnly, hold: hold.state, retryHold: hold.retry, conversationCommands: diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index ba8e1518921..0ea6a5b08f5 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -262,13 +262,45 @@ describe('ExperimentalPane', () => { expect(container.textContent).toContain( 'Opt in to the host-owned structured chat runtime for Codex and Claude.' ) + // Scoped to STARTING a session: a paired host's own structured chats are readable here, so + // the line must not read as "remote hosts have none of this". expect(container.textContent).toContain( - 'Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' + 'Orca starts these sessions locally for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' ) expect(container.textContent).toContain('Default view') root.unmount() }) + it('offers the paired-host read retreat only once structured chat is on, and writes it off', async () => { + const updateSettings = vi.fn() + const structuredOff = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const hidden = await renderExperimentalPane({ updateSettings, settings: structuredOff }) + // Nothing to retreat from while structured chat itself is off. + expect(hidden.container.textContent).not.toContain('Read structured chats on paired hosts') + hidden.root.unmount() + + const { root, container } = await renderExperimentalPane({ + updateSettings, + settings: { ...structuredOff, experimentalStructuredNativeChat: true } + }) + const remoteReadSwitch = container.querySelector( + '#experimental-native-chat button[role="switch"][aria-label="Toggle reading structured chats on paired hosts"]' + ) + // On for a profile that never saw the setting: the reader ships advertised. + expect(remoteReadSwitch?.getAttribute('aria-checked')).toBe('true') + + await act(async () => { + remoteReadSwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ structuredChatRemoteRead: false }) + root.unmount() + }) + it('hides a stale structured opt-in under Terminal chat without clearing it', async () => { const updateSettings = vi.fn() const settings = { diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 85d27dae2e3..95ff573f365 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -20,6 +20,7 @@ export function NativeChatExperimentalSetting({ }: NativeChatExperimentalSettingProps): React.JSX.Element { const nativeChatEnabled = settings.experimentalNativeChat === true const structuredNativeChatEnabled = settings.experimentalStructuredNativeChat === true + const structuredChatRemoteRead = settings.structuredChatRemoteRead !== false const defaultView: NativeChatDefaultView = settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat' @@ -132,7 +133,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', - 'Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' + 'Orca starts these sessions locally for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' )}

@@ -150,6 +151,41 @@ export function NativeChatExperimentalSetting({ /> ) : null} + + {defaultView === 'native-chat' && structuredNativeChatEnabled ? ( +
+
+ +

+ {translate( + 'components.settings.nativeChat.remoteReadCopy', + 'Show the structured chats a paired Orca host is running. They are read-only here; sending and starting new ones stay on that machine.' + )} +

+

+ {translate( + 'components.settings.nativeChat.remoteReadScope', + 'Turning this off stops Orca asking each paired host for them from the next time it connects to that host.' + )} +

+
+ + updateSettings({ structuredChatRemoteRead: !structuredChatRemoteRead }) + } + /> +
+ ) : null} ) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index de962b43eeb..63fc5659c72 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7030,7 +7030,7 @@ "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", "structuredCopy": "Opt in to the host-owned structured chat runtime for Codex and Claude. Off keeps the existing terminal-backed chat path.", - "structuredScope": "Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.", + "structuredScope": "Orca starts these sessions locally for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.", "structuredToggleLabel": "Toggle updated structured native chat" }, "agentDashboard": { @@ -17250,6 +17250,7 @@ "structuredSessionHoldConflict": "Another surface already holds this session on its host, so this pane did not reserve it.", "structuredSessionHoldOwnerUnknown": "This session\u2019s host could not prove who owns it, so this pane did not reserve it.", "structuredSessionHoldFailed": "This session could not be reserved on its host.", + "structuredSessionRemoteReadOnly": "This chat runs on a paired host. This version of Orca shows it here but does not send to it.", "handoff": { "stage": { "finishingChat": "Finishing chat session…", @@ -17611,6 +17612,14 @@ "copySessionIdSuccess": "Session ID copied", "copySessionIdError": "Unable to copy session ID" } + }, + "settings": { + "nativeChat": { + "remoteReadTitle": "Read structured chats on paired hosts", + "remoteReadCopy": "Show the structured chats a paired Orca host is running. They are read-only here; sending and starting new ones stay on that machine.", + "remoteReadScope": "Turning this off stops Orca asking each paired host for them from the next time it connects to that host.", + "remoteReadToggleLabel": "Toggle reading structured chats on paired hosts" + } } }, "dashboardPopout": { diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 5aac39c52ca..bd26212cf62 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -214,6 +214,9 @@ export type GlobalSettings = { experimentalNativeChat?: boolean /** Opt-in updated structured runtime; off keeps the existing PTY-backed native chat path. */ experimentalStructuredNativeChat?: boolean + /** Read structured chats a paired host owns. On by default; off retreats the advertisement that + * makes a host publish them, and applies to each pairing from its next connection. */ + structuredChatRemoteRead?: boolean /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ nativeChatSessionOptions?: PersistedNativeChatSessionOptions /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index aaec08cf46f..65f7957b065 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -227,6 +227,20 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY ] as const +// Reading a structured chat a paired host owns, and nothing that writes to one. +// +// A host withholds structured session-tab rows and refuses the whole `agentSession.*` surface to a +// client that advertises none of these, so adding them changes what this connection is published — +// a wire change with no codec change. Each string is a separate promise about renderer behaviour +// that is live NOW: hold, reveal and resume-history stay out until the surfaces that answer for +// them ship, because advertising one is what makes a host expect this client to drive it. +export const STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY +] as const + // Electron clients can decode client-hosted page placement; becoming a page // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ @@ -235,7 +249,8 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + ...STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES ] as const export const RUNTIME_CAPABILITIES = [ diff --git a/tests/e2e/cross-version-wire/cross-version-session-tab-sync.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-session-tab-sync.unit.test.ts index 8afcba300ce..3fb82d533f3 100644 --- a/tests/e2e/cross-version-wire/cross-version-session-tab-sync.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-session-tab-sync.unit.test.ts @@ -15,6 +15,10 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES, + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import type { @@ -26,7 +30,8 @@ import { materializeReleaseCheckout, resolveBaselineReleaseRef } from './release-checkout' -import { turnItemSkew } from './structured-agent-session-host-fixture' +import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { structuredHostStub, turnItemSkew } from './structured-agent-session-host-fixture' import { loadAgentSessionWireBuild, WORKING_TREE, @@ -49,6 +54,7 @@ const CLAUDE_TAB_TITLE = 'Claude Chat' const LIST_METHOD = 'session.tabs.list' const SUBSCRIBE_METHOD = 'session.tabs.subscribe' const CLOSE_METHOD = 'session.tabs.close' +const HOLD_METHOD = 'agentSession.hold' const PROJECTION_MODULE = '/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts' @@ -239,6 +245,23 @@ function c1WithClaudeReader(): string[] { return [...c1(), CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } +/** + * D — what a paired desktop of that build actually advertises, taken from the shipped constant + * rather than assembled here. C0 and C1 above are hypotheses about clients; this one is the + * client, so the day someone edits that list these journeys change with it instead of quietly + * continuing to describe a desktop that no longer exists. + */ +function desktopOf(build: AgentSessionWireBuild): string[] { + return [...build.clientCapabilities] +} + +/** A desktop from before the advertisement, derived the way C0 is: once a release ships the reader + * strings, a hand-written list would stop being that client and start being this one. */ +function withoutStructuredReader(capabilities: readonly string[]): string[] { + const reader = new Set(STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) + return capabilities.filter((capability) => !reader.has(capability)) +} + async function callBuild( build: AgentSessionWireBuild, method: string, @@ -557,4 +580,109 @@ describe('cross-version session-tab sync', () => { } }) }) + + /** + * The desktop this release ships is the client PR-12 was written against in the abstract. These + * journeys use its real advertisement, so the harness stops describing a hypothesis and starts + * describing the build. + */ + describe('the paired desktop this release ships', () => { + let stub: SessionTabsRuntimeStub + + beforeEach(() => { + stub = sessionTabsRuntimeStub() + }) + + afterEach(() => { + setStructuredAgentSessionHost(null) + }) + + it('advertises the reader and nothing whose surface it cannot yet drive', () => { + const desktop = desktopOf(current) + // Anti-vacuous: a list read from the wrong export, or an empty one, would satisfy every + // absence below while proving nothing about what this desktop says on the wire. + expect(desktop.length).toBeGreaterThan(0) + for (const capability of STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) { + expect(desktop, `the desktop advertises ${capability}`).toContain(capability) + } + for (const adjunct of [ + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY + ]) { + // This build names all three, so leaving them out is a decision about what the renderer + // can answer for rather than a string nobody has written down yet. + expect(current.capabilities, `${adjunct} exists in this build`).toContain(adjunct) + expect(desktop, `the desktop withholds ${adjunct}`).not.toContain(adjunct) + } + }) + + it('is published the structured rows by either host while an older desktop still is not', async () => { + const olderDesktop = withoutStructuredReader(desktopOf(baseline)) + expect(olderDesktop.length).toBeGreaterThan(0) + for (const build of hostBuilds()) { + const projected = await listTabs(build, stub, runtimeClient(desktopOf(current))) + expect(tabIds(projected), `${build.label} publishes both chats to this desktop`).toEqual([ + TERMINAL_TAB, + CODEX_TAB, + CLAUDE_TAB + ]) + expect(projected.tabs.find((tab) => tab.id === CLAUDE_TAB)).toMatchObject({ + title: CLAUDE_TAB_TITLE, + sessionId: CLAUDE_SESSION + }) + // Same host, same socket, a client that predates the advertisement: the decision is per + // connection, so shipping it here does not change what an older desktop is handed. + const older = await listTabs(build, stub, runtimeClient(olderDesktop)) + expect(tabIds(older), `${build.label} still withholds from an older desktop`).toEqual([ + TERMINAL_TAB + ]) + } + expect(stub.published).toEqual(hostSnapshot()) + }) + + it('finds a session the host already had, at the publication it was already at', async () => { + for (const build of hostBuilds()) { + const projected = await listTabs(build, stub, runtimeClient(desktopOf(current))) + expect(tabIds(projected)).toContain(CODEX_TAB) + // The advertisement restores the host's own records; it does not mint a publication, so a + // desktop that turns it on lands on the work that was already there. + expect(projected.publicationEpoch).toBe(hostSnapshot().publicationEpoch) + expect(projected.snapshotVersion).toBe(hostSnapshot().snapshotVersion) + } + expect(stub.restoreCalls).toBeGreaterThan(0) + expect(stub.published).toEqual(hostSnapshot()) + }) + + it('would be let through to a hold, so only this client keeps it from waking a provider', async () => { + const host = structuredHostStub(CODEX_SESSION, WORKTREE) + setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost) + const held = await callBuild( + current, + HOLD_METHOD, + { sessionId: CODEX_SESSION, holderId: 'desktop-chat' }, + runtimeClient(desktopOf(current)), + stub.runtime + ) + // The reader advertisement admits the whole read surface AND the hold: the host gates the + // hold on the reader string, not on the hold string this desktop withholds. So nothing on + // the host side is what stops a read-only pane opening a record and handing its session a + // provider child back — the renderer is, and its guard is the only one there is. + expect(held[0], 'the host admits a hold from this desktop').toMatchObject({ ok: true }) + expect(host.hold).toHaveBeenCalledTimes(1) + host.hold.mockClear() + const refused = await callBuild( + current, + HOLD_METHOD, + { sessionId: CODEX_SESSION, holderId: 'desktop-chat' }, + runtimeClient(withoutStructuredReader(desktopOf(baseline))), + stub.runtime + ) + expect(refused[0], 'a desktop without the advertisement is refused').toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(host.hold).not.toHaveBeenCalled() + }) + }) }) diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index 4d9e2de68ec..ae1db99c694 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -50,6 +50,10 @@ export type AgentSessionWireBuild = { /** Capability strings this build defines. A peer cannot advertise — nor a client * ask for — a string its own source never names. */ capabilities: readonly string[] + /** What this build's own Electron desktop advertises when it pairs, read from the shipped + * constant. A journey written against a hand-copied list stops describing the desktop the + * moment someone edits that constant, and passes while doing it. */ + clientCapabilities: readonly string[] protocolVersion: number /** RPC method names the build registers, read from source. */ methodNames: readonly string[] @@ -94,6 +98,16 @@ function capabilityStrings(module: Record): readonly string[] { return declared as readonly string[] } +function electronClientCapabilityStrings(module: Record): readonly string[] { + const declared = module.ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error( + 'Cross-version harness found no ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES to advertise' + ) + } + return declared as readonly string[] +} + async function loadWorkingTreeBuild(): Promise { const [protocol, dispatcher, methodRegistry] = await Promise.all([ import('../../../src/shared/protocol-version'), @@ -106,6 +120,9 @@ async function loadWorkingTreeBuild(): Promise { label: WORKING_TREE, revision: WORKING_TREE, capabilities: capabilityStrings(protocol as unknown as Record), + clientCapabilities: electronClientCapabilityStrings( + protocol as unknown as Record + ), protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, methodNames: registeredMethodNames(methods), createDispatcher: (runtime) => @@ -133,6 +150,7 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise