From 0f7c06aedbd82a64289870fbacaf75b770aba0a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:26:37 -0700 Subject: [PATCH] fix(native-chat): identify a paired device by its device id, never its credential (#26777) A paired device's socket passes its bearer credential as clientId, and native chat used it as the caller key it stores in ledger rows, receipts, chat records and prompt answers (shown to other clients). Name a paired device by the device id its socket proved instead. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../methods/structured-agent-session-gate.ts | 10 ++- ...ctured-agent-session-paired-caller.test.ts | 89 +++++++++++++++++++ ...ructured-codex-session-rpc-test-harness.ts | 11 ++- 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-paired-caller.test.ts 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()