diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index d839482b65c..f150622e61d 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -231,6 +231,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', + 'src/main/runtime/structured-agent-session-paired-caller.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 0b9b25c8005..80faf93335d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -13,6 +13,7 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' +import { rpcCallerOperationKey } from '../rpc-caller-identity' import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' import { createSupportFollowsHostSetting, @@ -130,10 +131,13 @@ export async function requireInstalledStructuredHost( return sessionId ? requireStructuredSessionHost(ctx, sessionId) : requireStructuredHost(ctx) } -/** Mirrors the existing agent-session host-authority derivation so one client - * gets one operation namespace across both surfaces. */ +/** A paired device's `clientId` is its bearer credential, and this key is stored and shown to other + * clients (who answered a prompt), so a paired device is named by its proven device id instead. */ export function structuredCallerFor(ctx: RpcContext): StructuredAgentSessionCaller { return { - callerKey: ctx.clientId?.trim() || `trusted-local:${ctx.clientKind ?? 'runtime'}` + callerKey: + ctx.caller?.kind === 'paired-device' + ? rpcCallerOperationKey(ctx.caller) + : ctx.clientId?.trim() || `trusted-local:${ctx.clientKind ?? 'runtime'}` } } diff --git a/src/main/runtime/structured-agent-session-paired-caller.test.ts b/src/main/runtime/structured-agent-session-paired-caller.test.ts new file mode 100644 index 00000000000..f2fa8584fd7 --- /dev/null +++ b/src/main/runtime/structured-agent-session-paired-caller.test.ts @@ -0,0 +1,89 @@ +// A paired device's socket carries its bearer credential as `clientId`. Native chat keys +// operations, records and prompt answers by caller, and keeps them on disk, so the credential +// must never become that key. + +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from './rpc/core' +import { structuredCallerFor } from './rpc/methods/structured-agent-session-gate' +import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +const CREDENTIAL = 'paired-bearer-credential-0f3c9a71' +const DEVICE = 'device-7d2e41' + +function context(fields: Partial): RpcContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the caller key reads only identity fields. + return { ...fields } as RpcContext +} + +async function filesUnder(root: string): Promise { + const entries = await readdir(root, { recursive: true, withFileTypes: true }) + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) +} + +describe('structuredCallerFor', () => { + it('names a paired device by its device id on every dispatch path', () => { + // The unary path stamps only `caller`; the streaming path also passes `pairedDeviceId`. + const caller = { kind: 'paired-device' as const, deviceId: DEVICE } + expect(structuredCallerFor(context({ clientId: CREDENTIAL, caller }))).toEqual({ + callerKey: DEVICE + }) + expect( + structuredCallerFor(context({ clientId: CREDENTIAL, pairedDeviceId: DEVICE, caller })) + ).toEqual({ callerKey: DEVICE }) + }) + + it('keeps the namespace of callers that hold no credential', () => { + expect( + structuredCallerFor(context({ clientId: 'desktop-renderer', caller: { kind: 'desktop' } })) + ).toEqual({ callerKey: 'desktop-renderer' }) + expect(structuredCallerFor(context({ caller: { kind: 'local-cli' } }))).toEqual({ + callerKey: 'trusted-local:runtime' + }) + }) +}) + +describe('a paired device driving a structured chat', () => { + let harness: StructuredCodexRpcHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + it('stores its device id and never its credential', async () => { + harness = await openStructuredCodexRpcHarness(undefined, { + clientId: CREDENTIAL, + pairedDeviceId: DEVICE + }) + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'hello from the phone' }] + } + await harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, created.fence), + body + }) + await vi.waitFor(() => + expect(harness?.codex.live().calls.some((entry) => entry.method === 'turn/start')).toBe(true) + ) + // Closes the databases, so every committed byte is in the files read below. + await stopStructuredAgentSessionRuntime() + + const stored = await Promise.all((await filesUnder(harness.root)).map((file) => readFile(file))) + expect(stored.filter((bytes) => bytes.includes(CREDENTIAL))).toHaveLength(0) + expect(stored.some((bytes) => bytes.includes(DEVICE))).toBe(true) + }) +}) diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index c86419e6054..ff9839227aa 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -184,9 +184,16 @@ export type StructuredCodexRpcHarness = { } export async function openStructuredCodexRpcHarness( - clientCapabilities: readonly RuntimeCapability[] = DEFAULT_CLIENT_CAPABILITIES + clientCapabilities: readonly RuntimeCapability[] = DEFAULT_CLIENT_CAPABILITIES, + /** What the transport stamps, as a paired device's authenticated socket does. */ + transport: { clientId?: string; pairedDeviceId?: string } = {} ): Promise { - const client = { clientId: 'device-a', clientKind: 'runtime' as const, clientCapabilities } + const client = { + clientId: 'device-a', + clientKind: 'runtime' as const, + clientCapabilities, + ...transport + } let operations = 0 const root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) const codex = fakeCodex()