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.
This commit is contained in:
Brennan Benson
2026-10-09 16:26:37 -07:00
committed by GitHub
parent 9764dc8b97
commit 0f7c06aedb
4 changed files with 106 additions and 5 deletions
@@ -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',
@@ -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'}`
}
}
@@ -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>): 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<string[]> {
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)
})
})
@@ -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<StructuredCodexRpcHarness> {
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()