diff --git a/packages/cloud-agent-sdk/src/schemas.test.ts b/packages/cloud-agent-sdk/src/schemas.test.ts index 56a35ab0c4..a0d746f741 100644 --- a/packages/cloud-agent-sdk/src/schemas.test.ts +++ b/packages/cloud-agent-sdk/src/schemas.test.ts @@ -1,4 +1,913 @@ import { activeSessionSchema, parseCustomerBillingFailure } from './schemas'; +import * as browser from './schemas'; +import * as relay from '../../../services/session-ingest/src/types/user-connection-protocol'; + +// Canonical v1 examples also appear in the relay protocol tests. +const requestId = '00000000-0000-4000-8000-000000000001'; +const handle = { + providerId: 'bp_00000000-0000-4000-8000-000000000002', + browserTaskId: 'bt_00000000-0000-4000-8000-000000000003', + jobId: 'bj_00000000-0000-4000-8000-000000000004', + invocationId: `b1.1787875200000.${'a'.repeat(64)}`, +} as const; +const owner = { parentSessionId: 'ses_parent', parentProof: 'b'.repeat(64) }; +const binding = { providerId: handle.providerId, generation: 1 }; +const jobBinding = { ...handle, generation: 1 }; +const tab = { + tabId: 7, + title: 'Example', + url: 'https://example.com/', + effectiveMode: 'safe', +} as const; +const job = { + ...jobBinding, + payloadFingerprint: 'c'.repeat(64), + createdAt: '2026-08-28T00:00:00.000Z', + expiresAt: '2026-09-04T00:00:00.000Z', + deadlines: { queue: '2026-08-28T00:10:00.000Z', approval: '2026-08-28T00:02:00.000Z' }, + status: 'awaiting_approval', +} as const; +const completed = { + ...handle, + status: 'succeeded', + reason: 'completed', + effectsUncertain: false, + summary: 'Read the example page', + evidence: [{ text: 'Example Domain', title: tab.title, url: tab.url }], +} satisfies browser.BrowserResult; +const finishedJob = { ...job, status: 'succeeded', approvedTab: tab, result: completed } as const; +const invoke = { + type: 'browser_request', + requestId, + operation: 'invoke', + owner, + providerId: handle.providerId, + invocationId: handle.invocationId, + goal: 'Read the example page', +} as const; +const registration = { + type: 'provider_register', + requestId, + providerId: handle.providerId, + generation: 0, + providerProof: 'd'.repeat(64), + label: 'Work browser', + enabled: true, +} as const; +const provider = { + providerId: handle.providerId, + label: registration.label, + availability: 'available', + queueDepth: 0, +} as const; +const cliRequests = [ + { type: 'browser_request', requestId, operation: 'list' }, + { type: 'browser_request', requestId, operation: 'list', cursor: handle.providerId }, + invoke, + { ...invoke, browserTaskId: handle.browserTaskId }, + ...(['status', 'cancel'] as const).flatMap(operation => [ + { type: 'browser_request', requestId, operation, owner, browserTaskId: handle.browserTaskId }, + { + type: 'browser_request', + requestId, + operation, + owner, + browserTaskId: handle.browserTaskId, + jobId: handle.jobId, + }, + ]), + { + type: 'browser_request', + requestId, + operation: 'recover', + owner, + invocationId: handle.invocationId, + }, +]; +const cliResponses = [ + { type: 'browser_response', requestId, response: { kind: 'providers', providers: [] } }, + { + type: 'browser_response', + requestId, + response: { kind: 'providers', providers: [provider], nextCursor: handle.providerId }, + }, + ...(['invoke', 'cancel'] as const).map(operation => ({ + type: 'browser_response', + requestId, + response: { kind: 'ack', operation, ...handle }, + })), + { type: 'browser_response', requestId, response: { kind: 'status', job } }, + { type: 'browser_response', requestId, response: { kind: 'recovered', job: finishedJob } }, + { + type: 'browser_response', + requestId, + response: { kind: 'not_found', invocationId: handle.invocationId }, + }, + { + type: 'browser_response', + requestId, + response: { + kind: 'error', + code: 'provider_unavailable', + message: 'Open the browser panel', + retryable: true, + }, + }, + { + type: 'browser_response', + requestId, + response: { + kind: 'error', + code: 'owner_mismatch', + message: 'This parent does not own the job', + retryable: false, + }, + }, +]; +const cliEvents = [ + { type: 'browser_event', requestId, event: 'progress', job }, + { type: 'browser_event', requestId, event: 'result', result: completed }, +]; +const providerStatusRequest = { + type: 'provider_status', + requestId, + providerId: handle.providerId, + providerProof: registration.providerProof, +} as const; +const interruptedHandle = { + ...handle, + jobId: 'bj_00000000-0000-4000-8000-000000000005', + invocationId: `b1.1787875200000.${'e'.repeat(64)}`, +} as const; +const providerStatusResult = { + type: 'provider_status_result', + requestId, + providerId: handle.providerId, + jobs: [ + finishedJob, + { + ...finishedJob, + ...interruptedHandle, + generation: 2, + status: 'interrupted', + result: { + ...completed, + ...interruptedHandle, + status: 'interrupted', + reason: 'effects_uncertain', + effectsUncertain: true, + }, + }, + ], + nextCursor: interruptedHandle.jobId, +} as const; +const unresolvedFence = { invocationId: interruptedHandle.invocationId, tabId: tab.tabId }; +const expiredInvocationId = `b1.1.${'f'.repeat(64)}`; +const emptyProviderStatusResult = { + type: 'provider_status_result', + requestId, + providerId: handle.providerId, + jobs: [], +} as const; +const recovery = { + invocationId: handle.invocationId, + tabClosed: true, + locksDrained: true, +} as const; +const providerOutbound = [ + providerStatusRequest, + { ...providerStatusRequest, cursor: handle.jobId }, + registration, + { ...registration, generation: 1, recovery }, + { ...registration, generation: 1, recovery: { ...recovery, tabId: tab.tabId } }, + { type: 'provider_heartbeat', requestId, ...binding, cursor: handle.jobId }, + { type: 'provider_approval', ...jobBinding, approval: { decision: 'approved', tab } }, + { + type: 'provider_approval', + ...jobBinding, + approval: { decision: 'denied', reason: 'approval_denied' }, + }, + { type: 'provider_result', ...jobBinding, tab, result: completed }, + { type: 'provider_quiesced', ...jobBinding }, + { type: 'provider_quiesced', ...jobBinding, tabId: tab.tabId }, + { type: 'provider_unavailable', ...binding, reason: 'provider_lost', effectsUncertain: true }, + { type: 'provider_cancel', ...jobBinding }, +]; +const providerInbound = [ + { type: 'provider_job', job, goal: invoke.goal, ownerLabel: 'Parent chat' }, + ...(['new', 'continue'] as const).map( + conversationMode => + ({ + type: 'provider_job', + job, + goal: invoke.goal, + ownerLabel: 'Parent chat', + conversationMode, + }) satisfies browser.BrowserProviderInboundMessage + ), + { type: 'provider_job_cancel', ...jobBinding, reason: 'cancelled' }, + { + type: 'provider_snapshot', + requestId, + ...binding, + jobs: [job, finishedJob], + nextCursor: handle.jobId, + }, + { type: 'provider_snapshot', ...binding, jobs: [] }, + { type: 'provider_lease_ack', requestId, ...binding, leaseExpiresAt: '2026-08-28T00:00:15.000Z' }, + providerStatusResult, + emptyProviderStatusResult, + { ...providerStatusResult, unresolvedFence }, + { ...emptyProviderStatusResult, unresolvedFence: { invocationId: expiredInvocationId } }, +]; + +describe.each([ + { name: 'provider', schema: browser.browserProviderInboundMessageSchema }, + { name: 'negotiated web', schema: browser.webInboundWithBrowserMessageSchema }, +])('provider conversation intent: $name', ({ schema }) => { + it('keeps legacy jobs parseable without inventing conversation intent', () => { + const parsed = schema.parse(providerInbound[0]); + expect(parsed).toStrictEqual(providerInbound[0]); + expect(parsed).not.toHaveProperty('conversationMode'); + }); + + it.each(['new', 'continue'] as const)( + 'preserves explicit %s intent and rejects unknown fields', + conversationMode => { + const frame = { ...providerInbound[0], conversationMode }; + expect(schema.parse(frame)).toStrictEqual(frame); + expect(schema.safeParse({ ...frame, extra: true }).success).toBe(false); + expect(schema.safeParse({ ...frame, job: { ...job, conversationMode } }).success).toBe(false); + } + ); + + it.each([ + { conversationMode: '' }, + { conversationMode: 'unknown' }, + { conversationMode: 'NEW' }, + { conversationMode: 'CONTINUE' }, + { conversationMode: 'new ' }, + { conversationMode: null }, + { conversationMode: false }, + { conversationMode: 0 }, + { conversationMode: [] }, + { conversationMode: {} }, + ])('rejects invalid conversation modes: %j', fields => { + expect(schema.safeParse({ ...providerInbound[0], ...fields }).success).toBe(false); + }); +}); + +describe.each([ + { name: 'SDK', contract: browser }, + { name: 'relay', contract: relay }, +])('provider quiescence: $name', ({ contract }) => { + const quiesced = { type: 'provider_quiesced', ...jobBinding }; + + it.each([{}, { tabId: tab.tabId }, { tabId: 0 }, { tabId: Number.MAX_SAFE_INTEGER }])( + 'preserves the exact binding and supplied or omitted tab: %j', + tabFields => { + const frame = { ...quiesced, ...tabFields }; + expect(contract.browserProviderOutboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webOutboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + } + ); + + it.each([ + { tabId: null }, + { tabId: -1 }, + { tabId: 1.5 }, + { tabId: Number.MAX_SAFE_INTEGER + 1 }, + { tabId: '7' }, + { tabId: true }, + { tabId: [] }, + { tabId: {} }, + ])('rejects invalid supplied quiescence tabs: %j', fields => { + const frame = { ...quiesced, ...fields }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it.each([ + { providerId: undefined }, + { providerId: handle.jobId }, + { browserTaskId: undefined }, + { browserTaskId: handle.jobId }, + { jobId: undefined }, + { jobId: handle.providerId }, + { invocationId: undefined }, + { invocationId: handle.jobId }, + { generation: undefined }, + { generation: null }, + { generation: 0 }, + { generation: -1 }, + { generation: 1.5 }, + { generation: Number.MAX_SAFE_INTEGER + 1 }, + { generation: '1' }, + ])('rejects invalid or missing quiescence bindings: %j', fields => { + for (const tabFields of [{}, { tabId: tab.tabId }]) { + const frame = JSON.parse(JSON.stringify({ ...quiesced, ...tabFields, ...fields })); + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + }); + + it.each([ + { connectionId: 'foreign-socket' }, + { recovery }, + { tabClosed: true }, + { locksDrained: true }, + ])('rejects socket or recovery authority on quiescence: %j', fields => { + for (const tabFields of [{}, { tabId: tab.tabId }]) { + const frame = { ...quiesced, ...tabFields, ...fields }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + }); + + it.each([{ tab: undefined }, { tab: { ...tab, tabId: undefined } }])( + 'keeps approved tabs required outside quiescence: %j', + ({ tab }) => { + for (const frame of [ + { + type: 'provider_approval', + ...jobBinding, + approval: { decision: 'approved', tab }, + }, + { type: 'provider_result', ...jobBinding, tab, result: completed }, + ]) { + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + } + ); +}); + +describe.each([ + { name: 'SDK', contract: browser }, + { name: 'relay', contract: relay }, +])('provider recovery registration: $name', ({ contract }) => { + it.each([ + recovery, + { ...recovery, tabId: tab.tabId }, + { ...recovery, tabId: 0 }, + { ...recovery, tabId: Number.MAX_SAFE_INTEGER }, + { ...recovery, invocationId: expiredInvocationId }, + { ...recovery, invocationId: expiredInvocationId, tabId: tab.tabId }, + ])('preserves recovery identity and tab omission: %j', recovery => { + const frame = { ...registration, generation: 1, recovery }; + expect(contract.browserProviderOutboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webOutboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + }); + + it.each([ + { tabClosed: undefined }, + { tabClosed: false }, + { tabClosed: 'true' }, + { tabClosed: 1 }, + { locksDrained: undefined }, + { locksDrained: false }, + { locksDrained: 'true' }, + { locksDrained: 1 }, + { invocationId: undefined }, + { invocationId: null }, + { invocationId: 7 }, + { invocationId: '' }, + { invocationId: handle.jobId }, + { invocationId: `b1.0.${'a'.repeat(64)}` }, + { invocationId: `b1.01.${'a'.repeat(64)}` }, + { invocationId: `b1.8640000000000001.${'a'.repeat(64)}` }, + { invocationId: `b1.9007199254740992.${'a'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'A'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'a'.repeat(63)}` }, + { owner }, + { extra: true }, + ])('rejects unsafe or malformed recovery with and without a tab: %j', fields => { + for (const tabFields of [{}, { tabId: tab.tabId }]) { + const frame = { + ...registration, + generation: 1, + recovery: { ...recovery, ...tabFields, ...fields }, + }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + }); + + it.each([ + { tabId: null }, + { tabId: -1 }, + { tabId: 1.5 }, + { tabId: Number.MAX_SAFE_INTEGER + 1 }, + { tabId: '7' }, + { tabId: true }, + { tabId: [] }, + { tabId: {} }, + ])('rejects invalid supplied recovery tabs: %j', fields => { + const frame = { ...registration, generation: 1, recovery: { ...recovery, ...fields } }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); +}); + +describe.each([ + { name: 'SDK', contract: browser }, + { name: 'relay', contract: relay }, +])('read-only provider status: $name', ({ contract }) => { + it('keeps absent fences omitted from old status frames', () => { + for (const frame of [providerStatusResult, emptyProviderStatusResult]) { + expect(contract.browserProviderInboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webInboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + } + }); + + it.each([ + { invocationId: handle.invocationId }, + unresolvedFence, + { ...unresolvedFence, tabId: 0 }, + { ...unresolvedFence, tabId: Number.MAX_SAFE_INTEGER }, + { invocationId: expiredInvocationId }, + { ...unresolvedFence, invocationId: expiredInvocationId }, + ])('preserves a compact fence without retained jobs: %j', unresolvedFence => { + const frame = { ...emptyProviderStatusResult, unresolvedFence }; + expect(contract.browserProviderInboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webInboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + }); + + it.each([ + { unresolvedFence: null }, + { unresolvedFence: false }, + { unresolvedFence: 0 }, + { unresolvedFence: 'fence' }, + { unresolvedFence: [] }, + { unresolvedFence: {} }, + { unresolvedFence: { tabId: tab.tabId } }, + ])('rejects malformed fence objects: %j', fields => { + const frame = { ...providerStatusResult, ...fields }; + expect(contract.browserProviderInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it.each([ + { invocationId: undefined }, + { invocationId: null }, + { invocationId: 7 }, + { invocationId: '' }, + { invocationId: handle.jobId }, + { invocationId: `b1.0.${'a'.repeat(64)}` }, + { invocationId: `b1.01.${'a'.repeat(64)}` }, + { invocationId: `b1.8640000000000001.${'a'.repeat(64)}` }, + { invocationId: `b1.9007199254740992.${'a'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'A'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'a'.repeat(63)}` }, + { tabId: null }, + { tabId: -1 }, + { tabId: 1.5 }, + { tabId: Number.MAX_SAFE_INTEGER + 1 }, + { tabId: '7' }, + { tabId: true }, + ])('rejects invalid fence fields: %j', fields => { + const frame = { ...providerStatusResult, unresolvedFence: { ...unresolvedFence, ...fields } }; + expect(contract.browserProviderInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it.each([ + { parentSessionId: owner.parentSessionId }, + { parentProof: owner.parentProof }, + { providerProof: registration.providerProof }, + { connectionId: 'private-route' }, + { generation: 1 }, + { goal: invoke.goal }, + { result: completed }, + { tabClosed: true }, + { locksDrained: true }, + { extra: true }, + ])('rejects private or unknown fence fields: %j', fields => { + const frame = { ...providerStatusResult, unresolvedFence: { ...unresolvedFence, ...fields } }; + expect(contract.browserProviderInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it('keeps fence discovery out of execution frames and job snapshots', () => { + for (const frame of providerInbound) { + if (frame.type === 'provider_status_result') continue; + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...frame, unresolvedFence }) + .success + ).toBe(false); + } + expect(contract.browserJobSnapshotSchema.safeParse({ ...job, unresolvedFence }).success).toBe( + false + ); + }); + + it('keeps historical generations separate from execution snapshots', () => { + expect(contract.browserProviderInboundMessageSchema.parse(providerStatusResult)).toEqual( + providerStatusResult + ); + for (const generation of [1, 2]) { + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + ...providerStatusResult, + type: 'provider_snapshot', + generation, + }).success + ).toBe(false); + } + }); + + it.each([ + { requestId: undefined }, + { requestId: 'invalid' }, + { providerId: undefined }, + { providerId: handle.jobId }, + { providerProof: undefined }, + { providerProof: 'd'.repeat(63) }, + { providerProof: 'D'.repeat(64) }, + { cursor: handle.providerId }, + { cursor: '' }, + ])('rejects malformed status requests: %j', fields => { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...providerStatusRequest, + ...fields, + }).success + ).toBe(false); + }); + + it.each([ + { requestId: undefined }, + { requestId: 'invalid' }, + { providerId: undefined }, + { providerId: handle.jobId }, + { jobs: undefined }, + { jobs: null }, + { jobs: [{ ...job, generation: undefined }] }, + { jobs: [{ ...job, generation: 0 }] }, + { jobs: [{ ...finishedJob, result: undefined }] }, + { jobs: [{ ...finishedJob, result: { ...completed, jobId: interruptedHandle.jobId } }] }, + { nextCursor: handle.providerId }, + { nextCursor: '' }, + ])('rejects malformed provider history: %j', fields => { + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...providerStatusResult, ...fields }) + .success + ).toBe(false); + }); + + it.each([ + { generation: 1 }, + { enabled: true }, + { leaseExpiresAt: '2026-08-28T00:00:15.000Z' }, + { approval: { decision: 'approved', tab } }, + { + recovery: { + invocationId: handle.invocationId, + tabId: tab.tabId, + tabClosed: true, + locksDrained: true, + }, + }, + ])('rejects authority fields on status frames: %j', fields => { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...providerStatusRequest, + ...fields, + }).success + ).toBe(false); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...providerStatusResult, ...fields }) + .success + ).toBe(false); + }); + + it('rejects otherwise valid history from another provider', () => { + const providerId = 'bp_00000000-0000-4000-8000-000000000006'; + const foreignJob = { ...finishedJob, providerId, result: { ...completed, providerId } }; + expect(contract.browserJobSnapshotSchema.parse(foreignJob)).toEqual(foreignJob); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + ...providerStatusResult, + jobs: [...providerStatusResult.jobs, foreignJob], + }).success + ).toBe(false); + }); + + it('redacts status proofs and attacker-controlled keys from parse errors', () => { + const secret = 'private-status-proof-must-not-appear'; + const cases = [ + { + schema: contract.browserProviderOutboundMessageSchema, + frame: { ...providerStatusRequest, providerProof: secret }, + }, + { + schema: contract.webOutboundWithBrowserMessageSchema, + frame: { ...providerStatusRequest, [secret]: true }, + }, + { + schema: contract.browserProviderInboundMessageSchema, + frame: { ...providerStatusResult, [secret]: true }, + }, + { + schema: contract.webInboundWithBrowserMessageSchema, + frame: { ...providerStatusResult, providerProof: secret }, + }, + ]; + for (const { schema, frame } of cases) { + const parsed = schema.safeParse(frame); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error('Invalid status frame was accepted'); + expect(parsed.error.message).not.toContain(secret); + expect(JSON.stringify(parsed.error)).not.toContain(secret); + } + }); + + it('accepts 25 historical jobs with a fence but rejects a 26th job', () => { + const page = { + ...providerStatusResult, + unresolvedFence, + jobs: Array.from({ length: 25 }, () => job), + }; + expect(contract.browserProviderInboundMessageSchema.parse(page)).toEqual(page); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...page, jobs: [...page.jobs, job] }) + .success + ).toBe(false); + }); + + it('bounds historical pages by serialized UTF-8 bytes', () => { + const largeJob = { + ...finishedJob, + result: { ...completed, summary: '\u00e9'.repeat(16384) }, + }; + const page = { ...providerStatusResult, jobs: Array.from({ length: 3 }, () => largeJob) }; + expect(contract.browserProviderInboundMessageSchema.parse(page)).toEqual(page); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + ...page, + jobs: [...page.jobs, largeJob], + }).success + ).toBe(false); + }); + + it('counts compact fences in the existing frame byte limit', () => { + const largeJob = { + ...finishedJob, + result: { ...completed, summary: '\u00e9'.repeat(16384) }, + }; + const lastJob = { ...finishedJob, result: { ...completed, summary: '' } }; + const page = { + ...providerStatusResult, + unresolvedFence, + jobs: [largeJob, largeJob, largeJob, lastJob], + }; + lastJob.result.summary = 'x'.repeat( + 128 * 1024 - 1 - Buffer.byteLength(JSON.stringify(page), 'utf8') + ); + expect(contract.browserProviderInboundMessageSchema.parse(page)).toEqual(page); + lastJob.result.summary += 'x'; + expect(contract.browserProviderInboundMessageSchema.safeParse(page).success).toBe(false); + const unfencedPage = { ...providerStatusResult, jobs: page.jobs }; + expect(contract.browserProviderInboundMessageSchema.parse(unfencedPage)).toEqual(unfencedPage); + }); +}); + +describe('browser queue metadata Cloud parity', () => { + const queuedJob = { ...job, status: 'queued' } as const; + + it.each([ + {}, + { ownerLabel: owner.parentSessionId }, + { queuePosition: 1 }, + { queuePosition: 100 }, + { ownerLabel: owner.parentSessionId, queuePosition: 50 }, + ])('preserves optional metadata through negotiated frames only: %j', fields => { + const snapshot = { ...queuedJob, ...fields }; + for (const contract of [browser, relay]) { + expect(contract.browserJobSnapshotSchema.parse(snapshot)).toStrictEqual(snapshot); + for (const frame of [ + { type: 'provider_snapshot', ...binding, jobs: [snapshot] }, + { ...emptyProviderStatusResult, jobs: [snapshot] }, + ]) { + expect(contract.browserProviderInboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webInboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + expect(browser.webInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(relay.WebInboundMessageSchema.safeParse(frame).success).toBe(false); + } + for (const frame of [ + { type: 'browser_response', requestId, response: { kind: 'status', job: snapshot } }, + { type: 'browser_response', requestId, response: { kind: 'recovered', job: snapshot } }, + { type: 'browser_event', requestId, event: 'progress', job: snapshot }, + ]) { + expect(contract.browserCLIInboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(relay.cliInboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + expect(relay.CLIInboundMessageSchema.safeParse(frame).success).toBe(false); + } + } + }); + + it.each([ + { queuePosition: 0 }, + { queuePosition: 101 }, + { queuePosition: 1.5 }, + { queuePosition: '1' }, + { queuePosition: null }, + { ownerLabel: '' }, + { ownerLabel: '\u00e9'.repeat(65) }, + { ownerLabel: null }, + { status: 'awaiting_approval' }, + { status: 'running', approvedTab: tab }, + { ...finishedJob }, + ])('rejects malformed or stale metadata in both provider parsers: %j', fields => { + const snapshot = { + ...queuedJob, + ownerLabel: owner.parentSessionId, + queuePosition: 1, + ...fields, + }; + for (const contract of [browser, relay]) { + for (const frame of [ + { type: 'provider_snapshot', ...binding, jobs: [snapshot] }, + { ...emptyProviderStatusResult, jobs: [snapshot] }, + ]) { + expect(contract.browserProviderInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + } + }); + + it('counts queue metadata in the unchanged serialized frame limit', () => { + const largeJob = { + ...finishedJob, + result: { ...completed, summary: '\u00e9'.repeat(16384) }, + }; + const lastJob = { ...finishedJob, result: { ...completed, summary: '' } }; + const snapshot = { ...queuedJob, ownerLabel: '\u00e9'.repeat(64), queuePosition: 100 }; + const page = { + type: 'provider_snapshot', + ...binding, + jobs: [snapshot, largeJob, largeJob, largeJob, lastJob], + }; + lastJob.result.summary = 'x'.repeat( + 128 * 1024 - 1 - Buffer.byteLength(JSON.stringify(page), 'utf8') + ); + for (const contract of [browser, relay]) { + expect(contract.browserProviderInboundMessageSchema.parse(page)).toStrictEqual(page); + } + lastJob.result.summary += 'x'; + const legacyPage = { ...page, jobs: [queuedJob, ...page.jobs.slice(1)] }; + for (const contract of [browser, relay]) { + expect(contract.browserProviderInboundMessageSchema.safeParse(page).success).toBe(false); + expect(contract.browserProviderInboundMessageSchema.parse(legacyPage)).toStrictEqual( + legacyPage + ); + } + }); +}); + +describe('browser jobs v1 Cloud parity', () => { + const boundaries = [ + { schema: 'browserRequestSchema', frames: cliRequests }, + { schema: 'browserResponseSchema', frames: cliResponses }, + { schema: 'browserEventSchema', frames: cliEvents }, + { schema: 'browserProviderOutboundMessageSchema', frames: providerOutbound }, + { schema: 'browserProviderInboundMessageSchema', frames: providerInbound }, + ] as const; + + it.each(boundaries)( + 'matches the relay at $schema without widening legacy parsing', + ({ schema, frames }) => { + for (const frame of frames) { + const input = JSON.parse(JSON.stringify(frame)); + expect(browser[schema].parse(input)).toEqual(frame); + expect(browser[schema].parse(input)).toEqual(relay[schema].parse(input)); + expect(browser[schema].safeParse({ ...frame, extra: true }).success).toBe(false); + expect(browser.webInboundMessageSchema.safeParse(input).success).toBe(false); + expect(browser.webOutboundMessageSchema.safeParse(input).success).toBe(false); + } + } + ); + + it('keeps provider frames opt-in and rejects reversed directions', () => { + for (const frame of providerOutbound) { + expect(browser.webOutboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + expect(browser.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + for (const frame of providerInbound) { + expect(browser.webInboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + expect(browser.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + for (const frame of [...cliRequests, ...cliResponses, ...cliEvents]) { + expect(browser.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + expect(browser.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + }); + + it.each([ + { capabilities: undefined, supported: false }, + { capabilities: {}, supported: false }, + { capabilities: { browserJobsV1: false }, supported: false }, + { capabilities: { browserJobsV1: true }, supported: true }, + ])( + 'normalizes web negotiation while preserving old frames: %j', + ({ capabilities, supported }) => { + for (const { schema, frame } of [ + { + schema: browser.webOutboundMessageSchema, + frame: { type: 'ping', nonce: 'legacy-nonce' }, + }, + { schema: browser.webInboundMessageSchema, frame: { type: 'pong', nonce: 'legacy-nonce' } }, + ]) { + const input = { ...frame, ...(capabilities === undefined ? {} : { capabilities }) }; + const parsed = schema.parse(input); + expect(parsed).toEqual(input); + const advertised = 'capabilities' in parsed ? parsed.capabilities : undefined; + expect(browser.normalizedBrowserCapabilitiesSchema.parse(advertised)).toEqual({ + browserJobsV1: supported, + }); + expect(schema.safeParse({ ...frame, capabilities: { browserJobsV1: 1 } }).success).toBe( + false + ); + } + } + ); + + // This callback accepts only the four legacy variants, independent of new traffic. + type LegacyInbound = + | { type: 'event'; sessionId: string; event: string; data?: unknown } + | { type: 'system'; event: string; data?: unknown } + | { type: 'response'; id: string; result?: unknown; error?: unknown } + | { type: 'pong'; nonce: string }; + const legacyCallback: (message: browser.WebInboundMessage) => unknown = ( + message: LegacyInbound + ) => { + switch (message.type) { + case 'event': + return `${message.sessionId}:${message.event}`; + case 'system': + return message.event; + case 'response': + return message.error ?? message.result; + case 'pong': + return message.nonce; + } + }; + + it('preserves legacy callback types, command results, and error shapes', () => { + const examples = [ + { + frame: { type: 'event', sessionId: 'legacy-session', event: 'message.updated', data: {} }, + value: 'legacy-session:message.updated', + }, + { frame: { type: 'system', event: 'cli.connected', data: {} }, value: 'cli.connected' }, + { + frame: { type: 'response', id: 'legacy-request', result: { ok: true } }, + value: { ok: true }, + }, + { frame: { type: 'response', id: 'legacy-request', error: 'not found' }, value: 'not found' }, + { + frame: { + type: 'response', + id: 'legacy-request', + error: { source: 'relay', code: 'OWNER_CHANGED', message: 'Owner changed' }, + }, + value: { source: 'relay', code: 'OWNER_CHANGED', message: 'Owner changed' }, + }, + { frame: { type: 'pong', nonce: 'legacy-nonce' }, value: 'legacy-nonce' }, + ]; + for (const { frame, value } of examples) { + expect(legacyCallback(browser.webInboundMessageSchema.parse(frame))).toEqual(value); + expect(browser.webInboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + expect(browser.webInboundMessageSchema.parse(frame)).toEqual( + relay.WebInboundMessageSchema.parse(frame) + ); + } + const outbound = [ + { type: 'subscribe', sessionId: 'legacy-session' }, + { type: 'unsubscribe', sessionId: 'legacy-session' }, + { type: 'command', id: 'legacy-request', command: 'list_sessions', data: null }, + { + type: 'command', + id: 'legacy-request', + command: 'send_message', + sessionId: 'legacy-session', + connectionId: 'legacy-connection', + mutationId: 'legacy-intent', + data: {}, + }, + { type: 'ping', nonce: 'legacy-nonce' }, + ]; + for (const frame of outbound) { + expect(browser.webOutboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + expect(browser.webOutboundMessageSchema.parse(frame)).toEqual( + relay.WebOutboundMessageSchema.parse(frame) + ); + } + expect( + browser.webInboundMessageSchema.parse({ type: 'pong', nonce: 'legacy-nonce', extra: true }) + ).toEqual({ type: 'pong', nonce: 'legacy-nonce' }); + }); +}); describe('parseCustomerBillingFailure', () => { const failure = { diff --git a/packages/cloud-agent-sdk/src/schemas.ts b/packages/cloud-agent-sdk/src/schemas.ts index c4b9ced3f1..da231f2e3b 100644 --- a/packages/cloud-agent-sdk/src/schemas.ts +++ b/packages/cloud-agent-sdk/src/schemas.ts @@ -430,7 +430,12 @@ export const webInboundMessageSchema = z.discriminatedUnion('type', [ data: z.unknown(), }), z.object({ type: z.literal('system'), event: z.string(), data: z.unknown() }), - z.object({ type: z.literal('pong'), nonce: z.string() }), + z.object({ + type: z.literal('pong'), + nonce: z.string(), + // Old pongs omit capabilities: normalize to unsupported until all old relays retire. + capabilities: z.object({ browserJobsV1: z.boolean().optional() }).optional(), + }), z.object({ type: z.literal('response'), id: z.string(), @@ -440,6 +445,29 @@ export const webInboundMessageSchema = z.discriminatedUnion('type', [ ]); export type WebInboundMessage = z.infer; +// Additive outbound validation; existing UserWebConnection callers stay unchanged. +export const webOutboundMessageSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('subscribe'), sessionId: z.string() }), + z.object({ type: z.literal('unsubscribe'), sessionId: z.string() }), + z.object({ + type: z.literal('command'), + id: z.string(), + sessionId: z.string().optional(), + connectionId: z.string().optional(), + command: z.string(), + data: z.unknown().optional(), + // Old commands omit mutationId; preserve relay-generated IDs until all old clients retire. + mutationId: z.string().max(128).optional(), + }), + z.object({ + type: z.literal('ping'), + nonce: z.string(), + // Old pings omit capabilities: normalize to unsupported until all old clients retire. + capabilities: z.object({ browserJobsV1: z.boolean().optional() }).optional(), + }), +]); +export type WebOutboundMessage = z.infer; + // --------------------------------------------------------------------------- // Active CLI sessions // --------------------------------------------------------------------------- @@ -1020,3 +1048,516 @@ export function parseCustomerBillingFailure(error: unknown): CustomerBillingFail const billingFailure = customerBillingFailureSchema.safeParse(source); return billingFailure.success ? billingFailure.data : null; } + +// -- Negotiated browser jobs v1 ----------------------------------------------- +// Keep this contract aligned with services/session-ingest/src/types/user-connection-protocol.ts +// and the CLI remote-protocol.ts copy. Legacy parsers above intentionally stay narrow. + +export const BROWSER_GOAL_MAX_BYTES = 16 * 1024; +export const BROWSER_RESULT_MAX_BYTES = 64 * 1024; +export const BROWSER_FRAME_MAX_BYTES = 128 * 1024; +export const BROWSER_PAGE_SIZE = 25; + +export const browserCapabilitiesSchema = z.object({ browserJobsV1: z.boolean().optional() }); +export const normalizedBrowserCapabilitiesSchema = browserCapabilitiesSchema + .optional() + // Old peers omit capabilities or browserJobsV1. Keep this fallback until all old peers retire. + .transform(capabilities => ({ browserJobsV1: capabilities?.browserJobsV1 ?? false })); +export type BrowserCapabilities = z.infer; + +function browserText(maxBytes: number) { + return z + .string() + .max(maxBytes) + .refine(value => new TextEncoder().encode(value).byteLength <= maxBytes, { + message: 'Text exceeds the UTF-8 byte limit', + }); +} + +// Do not propagate Zod issues from proof-bearing inputs: even unknown key names +// can contain secrets. Consumers must not enable Zod's reportInput option. +function browserBoundary(schema: T) { + return z.unknown().transform((input, context): z.output => { + const parsed = schema.safeParse(input); + if ( + parsed.success && + new TextEncoder().encode(JSON.stringify(parsed.data)).byteLength < BROWSER_FRAME_MAX_BYTES + ) { + return parsed.data; + } + context.addIssue({ code: 'custom', message: 'Invalid browser message' }); + return z.NEVER; + }); +} + +export const browserProviderIdSchema = z.templateLiteral(['bp_', z.uuid()]); +export const browserTaskIdSchema = z.templateLiteral(['bt_', z.uuid()]); +export const browserJobIdSchema = z.templateLiteral(['bj_', z.uuid()]); +export const browserInvocationIdSchema = z + .string() + .regex(/^b1\.[1-9][0-9]{0,15}\.[a-f0-9]{64}$/) + .refine( + value => { + const createdAt = Number(value.split('.')[1]); + return Number.isSafeInteger(createdAt) && createdAt <= 8_640_000_000_000_000; + }, + { message: 'Invalid invocation timestamp' } + ); +const browserRequestIdSchema = z.uuid(); +const browserTimestampSchema = z.iso.datetime({ precision: 3 }); +const browserFingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/); +const browserProofSchema = z.string().regex(/^[a-f0-9]{64}$/); +const browserGoalSchema = browserText(BROWSER_GOAL_MAX_BYTES).min(1); +const browserGenerationSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER); +const browserTabIdSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const browserOwnerSchema = z.strictObject({ + parentSessionId: browserText(128).regex(/^ses_[A-Za-z0-9_-]+$/), + parentProof: browserProofSchema, +}); + +export const browserJobStatusSchema = z.enum([ + 'queued', + 'awaiting_approval', + 'running', + 'succeeded', + 'failed', + 'cancelled', + 'interrupted', + 'timed_out', +]); +export const browserTerminalStatusSchema = browserJobStatusSchema.exclude([ + 'queued', + 'awaiting_approval', + 'running', +]); +export const browserFailureReasonSchema = z.enum([ + 'approval_denied', + 'permission_denied', + 'invocation_expired', + 'invocation_conflict', + 'conversation_busy', + 'capacity_exceeded', + 'tab_lost', + 'provider_lost', + 'provider_unavailable', + 'queue_timeout', + 'approval_timeout', + 'execution_timeout', + 'lease_expired', + 'effects_uncertain', + 'cancelled', + 'runner_failed', + 'unsupported', + 'invalid_request', + 'owner_mismatch', + 'not_found', +]); +export const browserReasonCodeSchema = z.enum(['completed', ...browserFailureReasonSchema.options]); + +const browserHandleShape = { + providerId: browserProviderIdSchema, + browserTaskId: browserTaskIdSchema, + jobId: browserJobIdSchema, + invocationId: browserInvocationIdSchema, +}; +export const browserJobHandleSchema = z.strictObject(browserHandleShape); +export type BrowserJobHandle = z.infer; +const browserBindingShape = { + providerId: browserProviderIdSchema, + generation: browserGenerationSchema, +}; +const browserJobBindingShape = { ...browserHandleShape, generation: browserGenerationSchema }; + +export const browserApprovedTabSchema = z.strictObject({ + tabId: browserTabIdSchema, + title: browserText(1024), + url: browserText(8192).url(), + effectiveMode: z.enum(['safe', 'dangerous']), +}); +export const browserDeadlinesSchema = z.strictObject({ + queue: browserTimestampSchema, + approval: browserTimestampSchema.optional(), + execution: browserTimestampSchema.optional(), + lease: browserTimestampSchema.optional(), +}); +const browserJobMetadataShape = { + ...browserJobBindingShape, + payloadFingerprint: browserFingerprintSchema, + createdAt: browserTimestampSchema, + expiresAt: browserTimestampSchema, + deadlines: browserDeadlinesSchema, +}; +const browserEvidenceSchema = z + .strictObject({ + text: browserText(8192).min(1).optional(), + title: browserText(1024).min(1).optional(), + url: browserText(8192).url().optional(), + }) + .refine(evidence => Object.keys(evidence).length > 0, { + message: 'Evidence must contain an observation', + }); +const browserResultShape = { + ...browserHandleShape, + summary: browserText(32 * 1024).min(1), + evidence: z.array(browserEvidenceSchema).max(32), +}; +export const browserResultSchema = z + .discriminatedUnion('status', [ + z.strictObject({ + ...browserResultShape, + status: z.literal('succeeded'), + reason: z.literal('completed'), + effectsUncertain: z.literal(false), + }), + z.strictObject({ + ...browserResultShape, + status: browserTerminalStatusSchema.exclude(['succeeded']), + reason: browserFailureReasonSchema, + effectsUncertain: z.boolean(), + }), + ]) + .refine( + result => + new TextEncoder().encode(JSON.stringify(result)).byteLength <= BROWSER_RESULT_MAX_BYTES, + { + message: 'Result exceeds the serialized UTF-8 byte limit', + } + ); +export type BrowserResult = z.infer; + +function sameBrowserJob(left: BrowserJobHandle, right: BrowserJobHandle) { + return ( + left.providerId === right.providerId && + left.browserTaskId === right.browserTaskId && + left.jobId === right.jobId && + left.invocationId === right.invocationId + ); +} + +export const browserJobSnapshotSchema = z + .strictObject({ + ...browserJobMetadataShape, + status: browserJobStatusSchema, + // Optional display metadata; legacy snapshots omit it. + ownerLabel: browserText(128).min(1).optional(), + queuePosition: z.number().int().min(1).max(100).optional(), + approvedTab: browserApprovedTabSchema.optional(), + result: browserResultSchema.optional(), + }) + .superRefine((job, context) => { + if (job.queuePosition !== undefined && job.status !== 'queued') { + context.addIssue({ code: 'custom', message: 'Queue position requires a queued job' }); + } + const terminal = browserTerminalStatusSchema.safeParse(job.status).success; + if ( + terminal !== (job.result !== undefined) || + (job.result && (job.status !== job.result.status || !sameBrowserJob(job, job.result))) + ) { + context.addIssue({ code: 'custom', message: 'Result must match the terminal job' }); + } + if ( + (job.status === 'running' && !job.approvedTab) || + ((job.status === 'queued' || job.status === 'awaiting_approval') && job.approvedTab) + ) { + context.addIssue({ code: 'custom', message: 'Tab approval must match the job phase' }); + } + const createdAt = Date.parse(job.createdAt); + const expiresAt = Date.parse(job.expiresAt); + if ( + createdAt > expiresAt || + Object.values(job.deadlines).some( + deadline => + deadline !== undefined && + (Date.parse(deadline) < createdAt || Date.parse(deadline) > expiresAt) + ) + ) { + context.addIssue({ code: 'custom', message: 'Deadlines must stay within job retention' }); + } + }); +export type BrowserJobSnapshot = z.infer; + +// Model arguments never select parent, invocation, proof, user, or socket authority. +export const browserTaskArgumentsSchema = browserBoundary( + z.discriminatedUnion('operation', [ + z.strictObject({ operation: z.literal('list') }), + z.strictObject({ + operation: z.literal('run'), + provider_id: browserProviderIdSchema, + goal: browserGoalSchema, + browser_task_id: browserTaskIdSchema.optional(), + }), + z.strictObject({ + operation: z.literal('status'), + browser_task_id: browserTaskIdSchema, + job_id: browserJobIdSchema.optional(), + }), + z.strictObject({ + operation: z.literal('cancel'), + browser_task_id: browserTaskIdSchema, + job_id: browserJobIdSchema.optional(), + }), + z.strictObject({ operation: z.literal('recover') }), + ]) +); +export type BrowserTaskArguments = z.infer; + +const browserRequestShape = { + type: z.literal('browser_request'), + requestId: browserRequestIdSchema, +}; +// An absent jobId selects only this conversation's latest job, after owner verification. +const browserOwnedLookupShape = { + owner: browserOwnerSchema, + browserTaskId: browserTaskIdSchema, + jobId: browserJobIdSchema.optional(), +}; +// Only authenticated, negotiated CLI sockets can submit these requests. Recover +// looks up a persisted invocation; it cannot carry a new goal or choose a provider. +export const browserRequestSchema = browserBoundary( + z.discriminatedUnion('operation', [ + z.strictObject({ + ...browserRequestShape, + operation: z.literal('list'), + cursor: browserProviderIdSchema.optional(), + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('invoke'), + owner: browserOwnerSchema, + providerId: browserProviderIdSchema, + browserTaskId: browserTaskIdSchema.optional(), + invocationId: browserInvocationIdSchema, + goal: browserGoalSchema, + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('status'), + ...browserOwnedLookupShape, + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('cancel'), + ...browserOwnedLookupShape, + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('recover'), + owner: browserOwnerSchema, + invocationId: browserInvocationIdSchema, + }), + ]) +); +export type BrowserRequest = z.infer; + +export const browserProviderDescriptorSchema = z.strictObject({ + providerId: browserProviderIdSchema, + label: browserText(128).min(1), + availability: z.enum(['available', 'busy', 'unavailable']), + queueDepth: z.number().int().min(0).max(100), +}); +export const browserResponseSchema = browserBoundary( + z.strictObject({ + type: z.literal('browser_response'), + requestId: browserRequestIdSchema, + response: z.discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('providers'), + providers: z.array(browserProviderDescriptorSchema).max(BROWSER_PAGE_SIZE), + nextCursor: browserProviderIdSchema.optional(), + }), + // An acknowledgement is not progress or a terminal result, including cancel. + z.strictObject({ + kind: z.literal('ack'), + operation: z.enum(['invoke', 'cancel']), + ...browserHandleShape, + }), + z.strictObject({ kind: z.literal('status'), job: browserJobSnapshotSchema }), + z.strictObject({ kind: z.literal('recovered'), job: browserJobSnapshotSchema }), + z.strictObject({ kind: z.literal('not_found'), invocationId: browserInvocationIdSchema }), + z.strictObject({ + kind: z.literal('error'), + code: browserFailureReasonSchema, + message: browserText(1024).min(1), + retryable: z.boolean(), + }), + ]), + }) +); +export type BrowserResponse = z.infer; +export const browserEventSchema = browserBoundary( + z.discriminatedUnion('event', [ + z.strictObject({ + type: z.literal('browser_event'), + requestId: browserRequestIdSchema, + event: z.literal('progress'), + job: browserJobSnapshotSchema.refine( + job => !browserTerminalStatusSchema.safeParse(job.status).success, + { + message: 'Progress cannot contain a terminal result', + } + ), + }), + z.strictObject({ + type: z.literal('browser_event'), + requestId: browserRequestIdSchema, + event: z.literal('result'), + result: browserResultSchema, + }), + ]) +); +export type BrowserEvent = z.infer; +export const browserCLIInboundMessageSchema = z.union([browserResponseSchema, browserEventSchema]); + +// The registration proof stays on the authenticated provider-to-relay boundary. +// Generation zero means first registration; other values name the last grant. +// The relay allocates the next generation and binds it to the actual socket. +export const browserProviderOutboundMessageSchema = browserBoundary( + z + .discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('provider_register'), + requestId: browserRequestIdSchema, + providerId: browserProviderIdSchema, + generation: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + providerProof: browserProofSchema, + label: browserText(128).min(1), + enabled: z.literal(true), + recovery: z + .strictObject({ + invocationId: browserInvocationIdSchema, + tabId: browserTabIdSchema.optional(), + tabClosed: z.literal(true), + locksDrained: z.literal(true), + }) + .optional(), + }), + // Read-only history requires proof, not registration or a generation grant. + z.strictObject({ + type: z.literal('provider_status'), + requestId: browserRequestIdSchema, + providerId: browserProviderIdSchema, + providerProof: browserProofSchema, + cursor: browserJobIdSchema.optional(), + }), + z.strictObject({ + type: z.literal('provider_heartbeat'), + requestId: browserRequestIdSchema, + ...browserBindingShape, + cursor: browserJobIdSchema.optional(), + }), + z.strictObject({ + type: z.literal('provider_approval'), + ...browserJobBindingShape, + approval: z.discriminatedUnion('decision', [ + z.strictObject({ decision: z.literal('approved'), tab: browserApprovedTabSchema }), + z.strictObject({ decision: z.literal('denied'), reason: z.literal('approval_denied') }), + ]), + }), + z.strictObject({ + type: z.literal('provider_result'), + ...browserJobBindingShape, + tab: browserApprovedTabSchema, + result: browserResultSchema, + }), + z.strictObject({ + type: z.literal('provider_quiesced'), + ...browserJobBindingShape, + tabId: browserTabIdSchema.optional(), + }), + z.strictObject({ + type: z.literal('provider_unavailable'), + ...browserBindingShape, + reason: browserFailureReasonSchema, + effectsUncertain: z.boolean(), + }), + // Provider Stop targets this profile's exact job, not a client-selected parent. + z.strictObject({ type: z.literal('provider_cancel'), ...browserJobBindingShape }), + ]) + .refine( + message => message.type !== 'provider_result' || sameBrowserJob(message, message.result), + { + message: 'Provider result must match the job', + } + ) +); +export type BrowserProviderOutboundMessage = z.infer; + +// Provider frames never target ordinary web subscribers. Execution frames require +// a registered socket; status results require a proof-authorized request. +// A snapshot is reconciliation data, not permission to execute. +export const browserProviderInboundMessageSchema = browserBoundary( + z + .discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('provider_job'), + job: browserJobSnapshotSchema.refine(job => job.status === 'awaiting_approval', { + message: 'Dispatch requires tab approval', + }), + goal: browserGoalSchema, + ownerLabel: browserText(128).min(1), + // Old dispatches omit intent. Consumers must treat absence as unknown + // and reject execution, never infer permission to start a new conversation. + conversationMode: z.enum(['new', 'continue']).optional(), + }), + z.strictObject({ + type: z.literal('provider_job_cancel'), + ...browserJobBindingShape, + reason: browserFailureReasonSchema, + }), + z.strictObject({ + type: z.literal('provider_snapshot'), + ...browserBindingShape, + requestId: browserRequestIdSchema.optional(), + jobs: z.array(browserJobSnapshotSchema).max(BROWSER_PAGE_SIZE), + nextCursor: browserJobIdSchema.optional(), + }), + // History grants no execution, lease, approval, or recovery authority. + z + .strictObject({ + type: z.literal('provider_status_result'), + requestId: browserRequestIdSchema, + providerId: browserProviderIdSchema, + // Old responses omit this field; retained identities can outlive job history. + unresolvedFence: z + .strictObject({ + invocationId: browserInvocationIdSchema, + tabId: browserTabIdSchema.optional(), + }) + .optional(), + jobs: z.array(browserJobSnapshotSchema).max(BROWSER_PAGE_SIZE), + nextCursor: browserJobIdSchema.optional(), + }) + .refine(message => message.jobs.every(job => job.providerId === message.providerId), { + message: 'History must match the requested provider', + }), + z.strictObject({ + type: z.literal('provider_lease_ack'), + ...browserBindingShape, + requestId: browserRequestIdSchema, + leaseExpiresAt: browserTimestampSchema, + }), + ]) + .refine( + message => + message.type !== 'provider_snapshot' || + message.jobs.every( + job => job.providerId === message.providerId && job.generation === message.generation + ), + { + message: 'Snapshot must match the registered provider', + } + ) +); +export type BrowserProviderInboundMessage = z.infer; + +// Opt-in consumers adopt these separately; the legacy parser exports never widen. +export const webOutboundWithBrowserMessageSchema = z.union([ + webOutboundMessageSchema, + browserProviderOutboundMessageSchema, +]); +export const webInboundWithBrowserMessageSchema = z.union([ + webInboundMessageSchema, + browserProviderInboundMessageSchema, +]); +export type WebOutboundWithBrowserMessage = z.infer; +export type WebInboundWithBrowserMessage = z.infer; diff --git a/services/session-ingest/src/types/user-connection-protocol.test.ts b/services/session-ingest/src/types/user-connection-protocol.test.ts index 0b9f9625b9..eeb14356ec 100644 --- a/services/session-ingest/src/types/user-connection-protocol.test.ts +++ b/services/session-ingest/src/types/user-connection-protocol.test.ts @@ -6,6 +6,1631 @@ import { WebInboundMessageSchema, SessionEventPayloadSchema, } from './user-connection-protocol'; +import * as browser from './user-connection-protocol'; +import * as sdk from '../../../../packages/cloud-agent-sdk/src/schemas'; + +// Canonical v1 examples also appear in the SDK schema tests. +const requestId = '00000000-0000-4000-8000-000000000001'; +const handle = { + providerId: 'bp_00000000-0000-4000-8000-000000000002', + browserTaskId: 'bt_00000000-0000-4000-8000-000000000003', + jobId: 'bj_00000000-0000-4000-8000-000000000004', + invocationId: `b1.1787875200000.${'a'.repeat(64)}`, +} as const; +const owner = { parentSessionId: 'ses_parent', parentProof: 'b'.repeat(64) }; +const binding = { providerId: handle.providerId, generation: 1 }; +const jobBinding = { ...handle, generation: 1 }; +const tab = { + tabId: 7, + title: 'Example', + url: 'https://example.com/', + effectiveMode: 'safe', +} as const; +const job = { + ...jobBinding, + payloadFingerprint: 'c'.repeat(64), + createdAt: '2026-08-28T00:00:00.000Z', + expiresAt: '2026-09-04T00:00:00.000Z', + deadlines: { queue: '2026-08-28T00:10:00.000Z', approval: '2026-08-28T00:02:00.000Z' }, + status: 'awaiting_approval', +} as const; +const completed = { + ...handle, + status: 'succeeded', + reason: 'completed', + effectsUncertain: false, + summary: 'Read the example page', + evidence: [{ text: 'Example Domain', title: tab.title, url: tab.url }], +} satisfies browser.BrowserResult; +const finishedJob = { ...job, status: 'succeeded', approvedTab: tab, result: completed } as const; +const invoke = { + type: 'browser_request', + requestId, + operation: 'invoke', + owner, + providerId: handle.providerId, + invocationId: handle.invocationId, + goal: 'Read the example page', +} as const; +const registration = { + type: 'provider_register', + requestId, + providerId: handle.providerId, + generation: 0, + providerProof: 'd'.repeat(64), + label: 'Work browser', + enabled: true, +} as const; +const provider = { + providerId: handle.providerId, + label: registration.label, + availability: 'available', + queueDepth: 0, +} as const; +const cliRequests = [ + { type: 'browser_request', requestId, operation: 'list' }, + { type: 'browser_request', requestId, operation: 'list', cursor: handle.providerId }, + invoke, + { ...invoke, browserTaskId: handle.browserTaskId }, + ...(['status', 'cancel'] as const).flatMap(operation => [ + { type: 'browser_request', requestId, operation, owner, browserTaskId: handle.browserTaskId }, + { + type: 'browser_request', + requestId, + operation, + owner, + browserTaskId: handle.browserTaskId, + jobId: handle.jobId, + }, + ]), + { + type: 'browser_request', + requestId, + operation: 'recover', + owner, + invocationId: handle.invocationId, + }, +]; +const cliResponses = [ + { type: 'browser_response', requestId, response: { kind: 'providers', providers: [] } }, + { + type: 'browser_response', + requestId, + response: { kind: 'providers', providers: [provider], nextCursor: handle.providerId }, + }, + ...(['invoke', 'cancel'] as const).map(operation => ({ + type: 'browser_response', + requestId, + response: { kind: 'ack', operation, ...handle }, + })), + { type: 'browser_response', requestId, response: { kind: 'status', job } }, + { type: 'browser_response', requestId, response: { kind: 'recovered', job: finishedJob } }, + { + type: 'browser_response', + requestId, + response: { kind: 'not_found', invocationId: handle.invocationId }, + }, + { + type: 'browser_response', + requestId, + response: { + kind: 'error', + code: 'provider_unavailable', + message: 'Open the browser panel', + retryable: true, + }, + }, + { + type: 'browser_response', + requestId, + response: { + kind: 'error', + code: 'owner_mismatch', + message: 'This parent does not own the job', + retryable: false, + }, + }, +]; +const cliEvents = [ + { type: 'browser_event', requestId, event: 'progress', job }, + { type: 'browser_event', requestId, event: 'result', result: completed }, +]; +const providerStatusRequest = { + type: 'provider_status', + requestId, + providerId: handle.providerId, + providerProof: registration.providerProof, +} as const; +const interruptedHandle = { + ...handle, + jobId: 'bj_00000000-0000-4000-8000-000000000005', + invocationId: `b1.1787875200000.${'e'.repeat(64)}`, +} as const; +const providerStatusResult = { + type: 'provider_status_result', + requestId, + providerId: handle.providerId, + jobs: [ + finishedJob, + { + ...finishedJob, + ...interruptedHandle, + generation: 2, + status: 'interrupted', + result: { + ...completed, + ...interruptedHandle, + status: 'interrupted', + reason: 'effects_uncertain', + effectsUncertain: true, + }, + }, + ], + nextCursor: interruptedHandle.jobId, +} as const; +const unresolvedFence = { invocationId: interruptedHandle.invocationId, tabId: tab.tabId }; +const expiredInvocationId = `b1.1.${'f'.repeat(64)}`; +const emptyProviderStatusResult = { + type: 'provider_status_result', + requestId, + providerId: handle.providerId, + jobs: [], +} as const; +const recovery = { + invocationId: handle.invocationId, + tabClosed: true, + locksDrained: true, +} as const; +const providerOutbound = [ + providerStatusRequest, + { ...providerStatusRequest, cursor: handle.jobId }, + registration, + { ...registration, generation: 1, recovery }, + { ...registration, generation: 1, recovery: { ...recovery, tabId: tab.tabId } }, + { type: 'provider_heartbeat', requestId, ...binding, cursor: handle.jobId }, + { type: 'provider_approval', ...jobBinding, approval: { decision: 'approved', tab } }, + { + type: 'provider_approval', + ...jobBinding, + approval: { decision: 'denied', reason: 'approval_denied' }, + }, + { type: 'provider_result', ...jobBinding, tab, result: completed }, + { type: 'provider_quiesced', ...jobBinding }, + { type: 'provider_quiesced', ...jobBinding, tabId: tab.tabId }, + { type: 'provider_unavailable', ...binding, reason: 'provider_lost', effectsUncertain: true }, + { type: 'provider_cancel', ...jobBinding }, +]; +const providerInbound = [ + { type: 'provider_job', job, goal: invoke.goal, ownerLabel: 'Parent chat' }, + ...(['new', 'continue'] as const).map( + conversationMode => + ({ + type: 'provider_job', + job, + goal: invoke.goal, + ownerLabel: 'Parent chat', + conversationMode, + }) satisfies browser.BrowserProviderInboundMessage + ), + { type: 'provider_job_cancel', ...jobBinding, reason: 'cancelled' }, + { + type: 'provider_snapshot', + requestId, + ...binding, + jobs: [job, finishedJob], + nextCursor: handle.jobId, + }, + { type: 'provider_snapshot', ...binding, jobs: [] }, + { type: 'provider_lease_ack', requestId, ...binding, leaseExpiresAt: '2026-08-28T00:00:15.000Z' }, + providerStatusResult, + emptyProviderStatusResult, + { ...providerStatusResult, unresolvedFence }, + { ...emptyProviderStatusResult, unresolvedFence: { invocationId: expiredInvocationId } }, +]; + +describe.each([ + { name: 'provider', schema: browser.browserProviderInboundMessageSchema }, + { name: 'negotiated web', schema: browser.webInboundWithBrowserMessageSchema }, +])('provider conversation intent: $name', ({ schema }) => { + it('keeps legacy jobs parseable without inventing conversation intent', () => { + const parsed = schema.parse(providerInbound[0]); + expect(parsed).toStrictEqual(providerInbound[0]); + expect(parsed).not.toHaveProperty('conversationMode'); + }); + + it.each(['new', 'continue'] as const)( + 'preserves explicit %s intent and rejects unknown fields', + conversationMode => { + const frame = { ...providerInbound[0], conversationMode }; + expect(schema.parse(frame)).toStrictEqual(frame); + expect(schema.safeParse({ ...frame, extra: true }).success).toBe(false); + expect(schema.safeParse({ ...frame, job: { ...job, conversationMode } }).success).toBe(false); + } + ); + + it.each([ + { conversationMode: '' }, + { conversationMode: 'unknown' }, + { conversationMode: 'NEW' }, + { conversationMode: 'CONTINUE' }, + { conversationMode: 'new ' }, + { conversationMode: null }, + { conversationMode: false }, + { conversationMode: 0 }, + { conversationMode: [] }, + { conversationMode: {} }, + ])('rejects invalid conversation modes: %j', fields => { + expect(schema.safeParse({ ...providerInbound[0], ...fields }).success).toBe(false); + }); +}); + +describe.each([ + { name: 'relay', contract: browser }, + { name: 'SDK', contract: sdk }, +])('provider quiescence: $name', ({ contract }) => { + const quiesced = { type: 'provider_quiesced', ...jobBinding }; + + it.each([{}, { tabId: tab.tabId }, { tabId: 0 }, { tabId: Number.MAX_SAFE_INTEGER }])( + 'preserves the exact binding and supplied or omitted tab: %j', + tabFields => { + const frame = { ...quiesced, ...tabFields }; + expect(contract.browserProviderOutboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webOutboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + } + ); + + it.each([ + { tabId: null }, + { tabId: -1 }, + { tabId: 1.5 }, + { tabId: Number.MAX_SAFE_INTEGER + 1 }, + { tabId: '7' }, + { tabId: true }, + { tabId: [] }, + { tabId: {} }, + ])('rejects invalid supplied quiescence tabs: %j', fields => { + const frame = { ...quiesced, ...fields }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it.each([ + { providerId: undefined }, + { providerId: handle.jobId }, + { browserTaskId: undefined }, + { browserTaskId: handle.jobId }, + { jobId: undefined }, + { jobId: handle.providerId }, + { invocationId: undefined }, + { invocationId: handle.jobId }, + { generation: undefined }, + { generation: null }, + { generation: 0 }, + { generation: -1 }, + { generation: 1.5 }, + { generation: Number.MAX_SAFE_INTEGER + 1 }, + { generation: '1' }, + ])('rejects invalid or missing quiescence bindings: %j', fields => { + for (const tabFields of [{}, { tabId: tab.tabId }]) { + const frame = JSON.parse(JSON.stringify({ ...quiesced, ...tabFields, ...fields })); + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + }); + + it.each([ + { connectionId: 'foreign-socket' }, + { recovery }, + { tabClosed: true }, + { locksDrained: true }, + ])('rejects socket or recovery authority on quiescence: %j', fields => { + for (const tabFields of [{}, { tabId: tab.tabId }]) { + const frame = { ...quiesced, ...tabFields, ...fields }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + }); + + it.each([{ tab: undefined }, { tab: { ...tab, tabId: undefined } }])( + 'keeps approved tabs required outside quiescence: %j', + ({ tab }) => { + for (const frame of [ + { + type: 'provider_approval', + ...jobBinding, + approval: { decision: 'approved', tab }, + }, + { type: 'provider_result', ...jobBinding, tab, result: completed }, + ]) { + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + } + ); +}); + +describe.each([ + { name: 'relay', contract: browser }, + { name: 'SDK', contract: sdk }, +])('provider recovery registration: $name', ({ contract }) => { + it.each([ + recovery, + { ...recovery, tabId: tab.tabId }, + { ...recovery, tabId: 0 }, + { ...recovery, tabId: Number.MAX_SAFE_INTEGER }, + { ...recovery, invocationId: expiredInvocationId }, + { ...recovery, invocationId: expiredInvocationId, tabId: tab.tabId }, + ])('preserves recovery identity and tab omission: %j', recovery => { + const frame = { ...registration, generation: 1, recovery }; + expect(contract.browserProviderOutboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webOutboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + }); + + it.each([ + { tabClosed: undefined }, + { tabClosed: false }, + { tabClosed: 'true' }, + { tabClosed: 1 }, + { locksDrained: undefined }, + { locksDrained: false }, + { locksDrained: 'true' }, + { locksDrained: 1 }, + { invocationId: undefined }, + { invocationId: null }, + { invocationId: 7 }, + { invocationId: '' }, + { invocationId: handle.jobId }, + { invocationId: `b1.0.${'a'.repeat(64)}` }, + { invocationId: `b1.01.${'a'.repeat(64)}` }, + { invocationId: `b1.8640000000000001.${'a'.repeat(64)}` }, + { invocationId: `b1.9007199254740992.${'a'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'A'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'a'.repeat(63)}` }, + { owner }, + { extra: true }, + ])('rejects unsafe or malformed recovery with and without a tab: %j', fields => { + for (const tabFields of [{}, { tabId: tab.tabId }]) { + const frame = { + ...registration, + generation: 1, + recovery: { ...recovery, ...tabFields, ...fields }, + }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + } + }); + + it.each([ + { tabId: null }, + { tabId: -1 }, + { tabId: 1.5 }, + { tabId: Number.MAX_SAFE_INTEGER + 1 }, + { tabId: '7' }, + { tabId: true }, + { tabId: [] }, + { tabId: {} }, + ])('rejects invalid supplied recovery tabs: %j', fields => { + const frame = { ...registration, generation: 1, recovery: { ...recovery, ...fields } }; + expect(contract.browserProviderOutboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webOutboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); +}); + +describe.each([ + { name: 'relay', contract: browser }, + { name: 'SDK', contract: sdk }, +])('read-only provider status: $name', ({ contract }) => { + it('keeps absent fences omitted from old status frames', () => { + for (const frame of [providerStatusResult, emptyProviderStatusResult]) { + expect(contract.browserProviderInboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webInboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + } + }); + + it.each([ + { invocationId: handle.invocationId }, + unresolvedFence, + { ...unresolvedFence, tabId: 0 }, + { ...unresolvedFence, tabId: Number.MAX_SAFE_INTEGER }, + { invocationId: expiredInvocationId }, + { ...unresolvedFence, invocationId: expiredInvocationId }, + ])('preserves a compact fence without retained jobs: %j', unresolvedFence => { + const frame = { ...emptyProviderStatusResult, unresolvedFence }; + expect(contract.browserProviderInboundMessageSchema.parse(frame)).toStrictEqual(frame); + expect(contract.webInboundWithBrowserMessageSchema.parse(frame)).toStrictEqual(frame); + }); + + it.each([ + { unresolvedFence: null }, + { unresolvedFence: false }, + { unresolvedFence: 0 }, + { unresolvedFence: 'fence' }, + { unresolvedFence: [] }, + { unresolvedFence: {} }, + { unresolvedFence: { tabId: tab.tabId } }, + ])('rejects malformed fence objects: %j', fields => { + const frame = { ...providerStatusResult, ...fields }; + expect(contract.browserProviderInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it.each([ + { invocationId: undefined }, + { invocationId: null }, + { invocationId: 7 }, + { invocationId: '' }, + { invocationId: handle.jobId }, + { invocationId: `b1.0.${'a'.repeat(64)}` }, + { invocationId: `b1.01.${'a'.repeat(64)}` }, + { invocationId: `b1.8640000000000001.${'a'.repeat(64)}` }, + { invocationId: `b1.9007199254740992.${'a'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'A'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'a'.repeat(63)}` }, + { tabId: null }, + { tabId: -1 }, + { tabId: 1.5 }, + { tabId: Number.MAX_SAFE_INTEGER + 1 }, + { tabId: '7' }, + { tabId: true }, + ])('rejects invalid fence fields: %j', fields => { + const frame = { ...providerStatusResult, unresolvedFence: { ...unresolvedFence, ...fields } }; + expect(contract.browserProviderInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it.each([ + { parentSessionId: owner.parentSessionId }, + { parentProof: owner.parentProof }, + { providerProof: registration.providerProof }, + { connectionId: 'private-route' }, + { generation: 1 }, + { goal: invoke.goal }, + { result: completed }, + { tabClosed: true }, + { locksDrained: true }, + { extra: true }, + ])('rejects private or unknown fence fields: %j', fields => { + const frame = { ...providerStatusResult, unresolvedFence: { ...unresolvedFence, ...fields } }; + expect(contract.browserProviderInboundMessageSchema.safeParse(frame).success).toBe(false); + expect(contract.webInboundWithBrowserMessageSchema.safeParse(frame).success).toBe(false); + }); + + it('keeps fence discovery out of execution frames and job snapshots', () => { + for (const frame of providerInbound) { + if (frame.type === 'provider_status_result') continue; + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...frame, unresolvedFence }) + .success + ).toBe(false); + } + expect(contract.browserJobSnapshotSchema.safeParse({ ...job, unresolvedFence }).success).toBe( + false + ); + }); + + it('keeps historical generations separate from execution snapshots', () => { + expect(contract.browserProviderInboundMessageSchema.parse(providerStatusResult)).toEqual( + providerStatusResult + ); + for (const generation of [1, 2]) { + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + ...providerStatusResult, + type: 'provider_snapshot', + generation, + }).success + ).toBe(false); + } + }); + + it.each([ + { requestId: undefined }, + { requestId: 'invalid' }, + { providerId: undefined }, + { providerId: handle.jobId }, + { providerProof: undefined }, + { providerProof: 'd'.repeat(63) }, + { providerProof: 'D'.repeat(64) }, + { cursor: handle.providerId }, + { cursor: '' }, + ])('rejects malformed status requests: %j', fields => { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...providerStatusRequest, + ...fields, + }).success + ).toBe(false); + }); + + it.each([ + { requestId: undefined }, + { requestId: 'invalid' }, + { providerId: undefined }, + { providerId: handle.jobId }, + { jobs: undefined }, + { jobs: null }, + { jobs: [{ ...job, generation: undefined }] }, + { jobs: [{ ...job, generation: 0 }] }, + { jobs: [{ ...finishedJob, result: undefined }] }, + { jobs: [{ ...finishedJob, result: { ...completed, jobId: interruptedHandle.jobId } }] }, + { nextCursor: handle.providerId }, + { nextCursor: '' }, + ])('rejects malformed provider history: %j', fields => { + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...providerStatusResult, ...fields }) + .success + ).toBe(false); + }); + + it.each([ + { generation: 1 }, + { enabled: true }, + { leaseExpiresAt: '2026-08-28T00:00:15.000Z' }, + { approval: { decision: 'approved', tab } }, + { + recovery: { + invocationId: handle.invocationId, + tabId: tab.tabId, + tabClosed: true, + locksDrained: true, + }, + }, + ])('rejects authority fields on status frames: %j', fields => { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...providerStatusRequest, + ...fields, + }).success + ).toBe(false); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...providerStatusResult, ...fields }) + .success + ).toBe(false); + }); + + it('rejects otherwise valid history from another provider', () => { + const providerId = 'bp_00000000-0000-4000-8000-000000000006'; + const foreignJob = { ...finishedJob, providerId, result: { ...completed, providerId } }; + expect(contract.browserJobSnapshotSchema.parse(foreignJob)).toEqual(foreignJob); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + ...providerStatusResult, + jobs: [...providerStatusResult.jobs, foreignJob], + }).success + ).toBe(false); + }); + + it('redacts status proofs and attacker-controlled keys from parse errors', () => { + const secret = 'private-status-proof-must-not-appear'; + const cases = [ + { + schema: contract.browserProviderOutboundMessageSchema, + frame: { ...providerStatusRequest, providerProof: secret }, + }, + { + schema: contract.webOutboundWithBrowserMessageSchema, + frame: { ...providerStatusRequest, [secret]: true }, + }, + { + schema: contract.browserProviderInboundMessageSchema, + frame: { ...providerStatusResult, [secret]: true }, + }, + { + schema: contract.webInboundWithBrowserMessageSchema, + frame: { ...providerStatusResult, providerProof: secret }, + }, + ]; + for (const { schema, frame } of cases) { + const parsed = schema.safeParse(frame); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error('Invalid status frame was accepted'); + expect(parsed.error.message).not.toContain(secret); + expect(JSON.stringify(parsed.error)).not.toContain(secret); + } + }); + + it('accepts 25 historical jobs with a fence but rejects a 26th job', () => { + const page = { + ...providerStatusResult, + unresolvedFence, + jobs: Array.from({ length: 25 }, () => job), + }; + expect(contract.browserProviderInboundMessageSchema.parse(page)).toEqual(page); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...page, jobs: [...page.jobs, job] }) + .success + ).toBe(false); + }); + + it('bounds historical pages by serialized UTF-8 bytes', () => { + const largeJob = { + ...finishedJob, + result: { ...completed, summary: '\u00e9'.repeat(16384) }, + }; + const page = { ...providerStatusResult, jobs: Array.from({ length: 3 }, () => largeJob) }; + expect(contract.browserProviderInboundMessageSchema.parse(page)).toEqual(page); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + ...page, + jobs: [...page.jobs, largeJob], + }).success + ).toBe(false); + }); + + it('counts compact fences in the existing frame byte limit', () => { + const largeJob = { + ...finishedJob, + result: { ...completed, summary: '\u00e9'.repeat(16384) }, + }; + const lastJob = { ...finishedJob, result: { ...completed, summary: '' } }; + const page = { + ...providerStatusResult, + unresolvedFence, + jobs: [largeJob, largeJob, largeJob, lastJob], + }; + lastJob.result.summary = 'x'.repeat( + 128 * 1024 - 1 - Buffer.byteLength(JSON.stringify(page), 'utf8') + ); + expect(contract.browserProviderInboundMessageSchema.parse(page)).toEqual(page); + lastJob.result.summary += 'x'; + expect(contract.browserProviderInboundMessageSchema.safeParse(page).success).toBe(false); + const unfencedPage = { ...providerStatusResult, jobs: page.jobs }; + expect(contract.browserProviderInboundMessageSchema.parse(unfencedPage)).toEqual(unfencedPage); + }); +}); + +const modelArguments = [ + { operation: 'list' }, + { operation: 'run', provider_id: handle.providerId, goal: invoke.goal }, + { + operation: 'run', + provider_id: handle.providerId, + goal: invoke.goal, + browser_task_id: handle.browserTaskId, + }, + ...(['status', 'cancel'] as const).flatMap(operation => [ + { operation, browser_task_id: handle.browserTaskId }, + { operation, browser_task_id: handle.browserTaskId, job_id: handle.jobId }, + ]), + { operation: 'recover' }, +]; + +describe.each([ + { name: 'relay', contract: browser }, + { name: 'SDK', contract: sdk }, +])('browser jobs v1: $name', ({ contract }) => { + const directions = [ + { name: 'CLI requests', schema: contract.browserRequestSchema, frames: cliRequests }, + { + name: 'CLI replies', + schema: contract.browserCLIInboundMessageSchema, + frames: [...cliResponses, ...cliEvents], + }, + { + name: 'provider outbound', + schema: contract.browserProviderOutboundMessageSchema, + frames: providerOutbound, + }, + { + name: 'provider inbound', + schema: contract.browserProviderInboundMessageSchema, + frames: providerInbound, + }, + ]; + + it.each(directions)( + 'round-trips canonical $name only in the correct direction', + ({ schema, frames }) => { + for (const frame of frames) { + expect(schema.parse(JSON.parse(JSON.stringify(frame)))).toEqual(frame); + expect(schema.safeParse({ ...frame, extra: true }).success).toBe(false); + for (const other of directions) { + if (other.schema !== schema) expect(other.schema.safeParse(frame).success).toBe(false); + } + for (const legacy of [ + CLIOutboundMessageSchema, + CLIInboundMessageSchema, + WebOutboundMessageSchema, + WebInboundMessageSchema, + sdk.webInboundMessageSchema, + ]) { + expect(legacy.safeParse(frame).success).toBe(false); + } + } + } + ); + + it.each(modelArguments)('accepts the model operation matrix: %j', args => { + expect(contract.browserTaskArgumentsSchema.parse(args)).toEqual(args); + }); + + it.each([ + 'owner', + 'parentSessionId', + 'parentProof', + 'providerProof', + 'userId', + 'connectionId', + 'invocationId', + 'sessionID', + 'messageID', + 'callID', + ])('rejects model-selected authority: %s', field => { + for (const args of modelArguments) { + expect( + contract.browserTaskArgumentsSchema.safeParse({ ...args, [field]: 'untrusted' }).success + ).toBe(false); + } + }); + + it('requires a conversation for status and cancel, even with an exact job ID', () => { + for (const operation of ['status', 'cancel']) { + expect( + contract.browserTaskArgumentsSchema.safeParse({ operation, job_id: handle.jobId }).success + ).toBe(false); + expect( + contract.browserRequestSchema.safeParse({ + type: 'browser_request', + requestId, + operation, + owner, + jobId: handle.jobId, + }).success + ).toBe(false); + expect( + contract.browserRequestSchema.safeParse({ + type: 'browser_request', + requestId, + operation, + owner, + browserTaskId: handle.jobId, + }).success + ).toBe(false); + } + }); + + it('requires a run target and goal, and keeps recover lookup-only', () => { + for (const args of [ + { operation: 'run', goal: invoke.goal }, + { operation: 'run', provider_id: handle.providerId }, + { operation: 'invoke', provider_id: handle.providerId, goal: invoke.goal }, + { operation: 'list', provider_id: handle.providerId }, + ]) + expect(contract.browserTaskArgumentsSchema.safeParse(args).success).toBe(false); + const recover = { + type: 'browser_request', + requestId, + operation: 'recover', + owner, + invocationId: handle.invocationId, + }; + for (const extra of [ + { goal: invoke.goal }, + { providerId: handle.providerId }, + { jobId: handle.jobId }, + { browserTaskId: handle.browserTaskId }, + ]) { + expect(contract.browserRequestSchema.safeParse({ ...recover, ...extra }).success).toBe(false); + } + for (const extra of [ + { goal: invoke.goal }, + { provider_id: handle.providerId }, + { browser_task_id: handle.browserTaskId }, + { job_id: handle.jobId }, + ]) { + expect( + contract.browserTaskArgumentsSchema.safeParse({ operation: 'recover', ...extra }).success + ).toBe(false); + } + expect(contract.browserRequestSchema.safeParse({ ...recover, owner: undefined }).success).toBe( + false + ); + expect( + contract.browserRequestSchema.safeParse({ ...recover, invocationId: undefined }).success + ).toBe(false); + }); + + it('keeps proofs on owned requests, registration, and provider status', () => { + for (const frame of [...cliResponses, ...cliEvents]) { + for (const extra of [ + { owner }, + { parentProof: owner.parentProof }, + { providerProof: registration.providerProof }, + ]) { + expect( + contract.browserCLIInboundMessageSchema.safeParse({ ...frame, ...extra }).success + ).toBe(false); + } + } + for (const frame of providerInbound) { + expect( + contract.browserProviderInboundMessageSchema.safeParse({ ...frame, owner }).success + ).toBe(false); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + ...frame, + providerProof: registration.providerProof, + }).success + ).toBe(false); + } + for (const frame of providerOutbound) { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ ...frame, owner }).success + ).toBe(false); + if (frame.type !== 'provider_register' && frame.type !== 'provider_status') { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...frame, + providerProof: registration.providerProof, + }).success + ).toBe(false); + } + } + expect( + contract.browserRequestSchema.safeParse({ + ...invoke, + providerProof: registration.providerProof, + }).success + ).toBe(false); + expect( + contract.browserRequestSchema.safeParse({ + type: 'browser_request', + requestId, + operation: 'list', + owner, + }).success + ).toBe(false); + expect( + contract.browserRequestSchema.safeParse({ + ...invoke, + owner: { parentSessionId: owner.parentSessionId }, + }).success + ).toBe(false); + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...registration, + providerProof: undefined, + }).success + ).toBe(false); + expect( + contract.browserResponseSchema.safeParse({ + type: 'browser_response', + requestId, + response: { + kind: 'providers', + providers: [{ ...provider, providerProof: registration.providerProof }], + }, + }).success + ).toBe(false); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + type: 'provider_job', + job: { ...job, owner }, + goal: invoke.goal, + ownerLabel: 'Parent chat', + }).success + ).toBe(false); + expect( + contract.browserEventSchema.safeParse({ + type: 'browser_event', + requestId, + event: 'result', + result: { ...completed, parentProof: owner.parentProof }, + }).success + ).toBe(false); + }); + + it('redacts invalid proof values and attacker-controlled key names from errors', () => { + const secret = 'private-proof-must-not-appear'; + const cases = [ + { + schema: contract.browserRequestSchema, + frame: { ...invoke, owner: { ...owner, parentProof: secret, [secret]: true } }, + }, + { + schema: contract.browserProviderOutboundMessageSchema, + frame: { ...registration, providerProof: secret, [secret]: true }, + }, + { + schema: contract.browserTaskArgumentsSchema, + frame: { operation: 'recover', [secret]: true }, + }, + { + schema: contract.webOutboundWithBrowserMessageSchema, + frame: { ...registration, providerProof: secret, [secret]: true }, + }, + { + schema: contract.webInboundWithBrowserMessageSchema, + frame: { ...providerInbound[0], [secret]: true }, + }, + ]; + for (const { schema, frame } of cases) { + const parsed = schema.safeParse(frame); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error('Invalid proof-bearing input was accepted'); + expect(parsed.error.message).not.toContain(secret); + expect(JSON.stringify(parsed.error)).not.toContain(secret); + } + }); + + it.each([ + { requestId: 'request-1' }, + { requestId: '' }, + { providerId: handle.browserTaskId }, + { browserTaskId: handle.jobId }, + { providerId: 'bp_not-a-uuid' }, + { invocationId: `b1.0.${'a'.repeat(64)}` }, + { invocationId: `b1.01787875200000.${'a'.repeat(64)}` }, + { invocationId: `b1.8640000000000001.${'a'.repeat(64)}` }, + { invocationId: `b1.9007199254740992.${'a'.repeat(64)}` }, + { invocationId: `b1.1787875200000.${'A'.repeat(64)}` }, + { owner: { ...owner, parentSessionId: 'parent' } }, + { owner: { ...owner, connectionId: 'untrusted' } }, + ])('rejects malformed request identities: %j', fields => { + expect(contract.browserRequestSchema.safeParse({ ...invoke, ...fields }).success).toBe(false); + }); + + it('requires correlation IDs and separates acknowledgements from progress and results', () => { + for (const frame of cliRequests) + expect( + contract.browserRequestSchema.safeParse({ ...frame, requestId: undefined }).success + ).toBe(false); + for (const frame of [...cliResponses, ...cliEvents]) + expect( + contract.browserCLIInboundMessageSchema.safeParse({ ...frame, requestId: undefined }) + .success + ).toBe(false); + expect( + contract.browserResponseSchema.safeParse({ + type: 'browser_response', + requestId, + response: { kind: 'ack', operation: 'cancel', ...handle, result: completed }, + }).success + ).toBe(false); + expect( + contract.browserEventSchema.safeParse({ + type: 'browser_event', + requestId, + event: 'progress', + job: finishedJob, + }).success + ).toBe(false); + expect( + contract.browserEventSchema.safeParse({ + type: 'browser_event', + requestId, + event: 'result', + result: { ...completed, status: 'running' }, + }).success + ).toBe(false); + }); + + const statusResults = { + queued: null, + awaiting_approval: null, + running: null, + succeeded: completed, + failed: { ...completed, status: 'failed', reason: 'runner_failed', effectsUncertain: false }, + cancelled: { ...completed, status: 'cancelled', reason: 'cancelled', effectsUncertain: false }, + interrupted: { + ...completed, + status: 'interrupted', + reason: 'effects_uncertain', + effectsUncertain: true, + }, + timed_out: { + ...completed, + status: 'timed_out', + reason: 'execution_timeout', + effectsUncertain: true, + }, + } satisfies Record< + browser.BrowserJobSnapshot['status'], + browser.BrowserResult | null + > satisfies Record; + + describe('queue metadata', () => { + const queuedJob = { ...job, status: 'queued' } as const; + + it.each([ + {}, + { ownerLabel: owner.parentSessionId }, + { queuePosition: 1 }, + { queuePosition: 100 }, + { ownerLabel: owner.parentSessionId, queuePosition: 50 }, + ])('preserves optional fields without inventing missing metadata: %j', fields => { + const snapshot = { ...queuedJob, ...fields }; + expect(contract.browserJobSnapshotSchema.parse(snapshot)).toStrictEqual(snapshot); + }); + + it.each([ + { queuePosition: 0 }, + { queuePosition: -1 }, + { queuePosition: 101 }, + { queuePosition: 1.5 }, + { queuePosition: '1' }, + { queuePosition: null }, + { queuePosition: true }, + { queuePosition: [] }, + { queuePosition: {} }, + { queuePosition: NaN }, + { queuePosition: Infinity }, + ])('rejects malformed queue positions: %j', fields => { + expect(contract.browserJobSnapshotSchema.safeParse({ ...queuedJob, ...fields }).success).toBe( + false + ); + }); + + it.each([ + { ownerLabel: 'x', accepted: true }, + { ownerLabel: 'x'.repeat(128), accepted: true }, + { ownerLabel: '\u00e9'.repeat(64), accepted: true }, + { ownerLabel: '', accepted: false }, + { ownerLabel: 'x'.repeat(129), accepted: false }, + { ownerLabel: '\u00e9'.repeat(65), accepted: false }, + { ownerLabel: null, accepted: false }, + { ownerLabel: 1, accepted: false }, + { ownerLabel: false, accepted: false }, + { ownerLabel: [], accepted: false }, + { ownerLabel: {}, accepted: false }, + ])('matches the existing dispatch owner-label bounds: %j', ({ ownerLabel, accepted }) => { + expect( + contract.browserJobSnapshotSchema.safeParse({ ...queuedJob, ownerLabel }).success + ).toBe(accepted); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + type: 'provider_job', + job, + goal: invoke.goal, + ownerLabel, + }).success + ).toBe(accepted); + }); + + it.each(Object.entries(statusResults).filter(([status]) => status !== 'queued'))( + 'preserves legacy %s snapshots and labels but rejects stale queue positions', + (status, result) => { + const snapshot = { + ...job, + status, + ...(status === 'running' ? { approvedTab: tab } : {}), + ...(result ? { result } : {}), + }; + expect(contract.browserJobSnapshotSchema.parse(snapshot)).toStrictEqual(snapshot); + const labeled = { ...snapshot, ownerLabel: owner.parentSessionId }; + expect(contract.browserJobSnapshotSchema.parse(labeled)).toStrictEqual(labeled); + expect( + contract.browserJobSnapshotSchema.safeParse({ ...labeled, queuePosition: 1 }).success + ).toBe(false); + } + ); + + it.each([ + { owner }, + { parentSessionId: owner.parentSessionId }, + { parentProof: owner.parentProof }, + { providerProof: registration.providerProof }, + { connectionId: 'private-route' }, + { capabilities: { browserJobsV1: true } }, + { goal: invoke.goal }, + { recovery }, + { leaseExpiresAt: '2026-08-28T00:00:15.000Z' }, + ])('keeps private data and authority out of labeled snapshots: %j', fields => { + expect( + contract.browserJobSnapshotSchema.safeParse({ + ...queuedJob, + ownerLabel: owner.parentSessionId, + queuePosition: 1, + ...fields, + }).success + ).toBe(false); + }); + + it.each([{ ownerLabel: owner.parentSessionId }, { queuePosition: 1 }])( + 'keeps projection metadata out of requests and immutable results: %j', + fields => { + for (const args of modelArguments) { + expect( + contract.browserTaskArgumentsSchema.safeParse({ ...args, ...fields }).success + ).toBe(false); + } + for (const frame of cliRequests) { + expect(contract.browserRequestSchema.safeParse({ ...frame, ...fields }).success).toBe( + false + ); + } + for (const frame of providerOutbound) { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ ...frame, ...fields }).success + ).toBe(false); + } + expect(contract.browserResultSchema.safeParse({ ...completed, ...fields }).success).toBe( + false + ); + } + ); + }); + + it.each(Object.entries(statusResults))( + 'enforces the observable result contract for %s', + (status, result) => { + const snapshot = { + ...job, + status, + ...(status === 'running' ? { approvedTab: tab } : {}), + ...(result ? { result } : {}), + }; + expect(contract.browserJobSnapshotSchema.parse(snapshot)).toEqual(snapshot); + if (result) { + expect( + contract.browserEventSchema.parse({ + type: 'browser_event', + requestId, + event: 'result', + result, + }) + ).toEqual({ type: 'browser_event', requestId, event: 'result', result }); + expect( + contract.browserJobSnapshotSchema.safeParse({ ...snapshot, result: undefined }).success + ).toBe(false); + } else { + expect( + contract.browserEventSchema.parse({ + type: 'browser_event', + requestId, + event: 'progress', + job: snapshot, + }) + ).toEqual({ type: 'browser_event', requestId, event: 'progress', job: snapshot }); + expect( + contract.browserJobSnapshotSchema.safeParse({ ...snapshot, result: completed }).success + ).toBe(false); + } + } + ); + + it('rejects unknown states, false success, empty evidence, and mismatched job results', () => { + expect(contract.browserJobSnapshotSchema.safeParse({ ...job, status: 'idle' }).success).toBe( + false + ); + expect( + contract.browserResultSchema.safeParse({ ...completed, effectsUncertain: true }).success + ).toBe(false); + expect( + contract.browserResultSchema.safeParse({ ...completed, reason: 'runner_failed' }).success + ).toBe(false); + expect(contract.browserResultSchema.safeParse({ ...completed, status: 'failed' }).success).toBe( + false + ); + expect(contract.browserResultSchema.safeParse({ ...completed, evidence: [{}] }).success).toBe( + false + ); + expect( + contract.browserResultSchema.safeParse({ + ...completed, + evidence: [{ text: 'Observed', screenshot: 'data:image/png;base64,AAAA' }], + }).success + ).toBe(false); + for (const field of ['providerId', 'browserTaskId', 'jobId', 'invocationId'] as const) { + const result = { ...completed, [field]: handle[field].replace(/.$/, '5') }; + expect(contract.browserJobSnapshotSchema.safeParse({ ...finishedJob, result }).success).toBe( + false + ); + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + type: 'provider_result', + ...jobBinding, + tab, + result, + }).success + ).toBe(false); + } + expect( + contract.browserJobSnapshotSchema.safeParse({ ...finishedJob, status: 'failed' }).success + ).toBe(false); + }); + + it.each([ + 'approval_denied', + 'permission_denied', + 'invocation_expired', + 'invocation_conflict', + 'conversation_busy', + 'capacity_exceeded', + 'tab_lost', + 'provider_lost', + 'provider_unavailable', + 'queue_timeout', + 'approval_timeout', + 'execution_timeout', + 'lease_expired', + 'effects_uncertain', + 'cancelled', + 'runner_failed', + 'unsupported', + 'invalid_request', + 'owner_mismatch', + 'not_found', + ])('retains finite error reason %s', code => { + const frame = { + type: 'browser_response', + requestId, + response: { kind: 'error', code, message: 'Browser request rejected', retryable: false }, + }; + expect(contract.browserResponseSchema.parse(frame)).toEqual(frame); + expect( + contract.browserResponseSchema.safeParse({ + ...frame, + response: { ...frame.response, code: `${code}_unknown` }, + }).success + ).toBe(false); + }); + + it('binds approval and cancellation to an invocation and generation, not parent authority', () => { + const approval = { + type: 'provider_approval', + ...jobBinding, + approval: { decision: 'approved', tab }, + }; + const cancel = { type: 'provider_cancel', ...jobBinding }; + for (const frame of [approval, cancel]) { + for (const field of Object.keys(jobBinding)) + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ ...frame, [field]: undefined }) + .success + ).toBe(false); + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ ...frame, generation: 0 }).success + ).toBe(false); + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ ...frame, owner }).success + ).toBe(false); + } + for (const fields of [ + { tabId: -1 }, + { tabId: 1.5 }, + { title: undefined }, + { url: 'not-a-url' }, + { effectiveMode: 'automatic' }, + { extra: true }, + ]) { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...approval, + approval: { decision: 'approved', tab: { ...tab, ...fields } }, + }).success + ).toBe(false); + } + expect(contract.browserJobSnapshotSchema.safeParse({ ...job, status: 'running' }).success).toBe( + false + ); + for (const status of ['queued', 'awaiting_approval']) + expect( + contract.browserJobSnapshotSchema.safeParse({ ...job, status, approvedTab: tab }).success + ).toBe(false); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + type: 'provider_job', + job: { ...job, status: 'running', approvedTab: tab }, + goal: invoke.goal, + ownerLabel: 'Parent chat', + }).success + ).toBe(false); + for (const fields of [ + { generation: 2 }, + { providerId: handle.providerId.replace(/.$/, '5') }, + ]) { + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + type: 'provider_snapshot', + ...binding, + jobs: [{ ...job, ...fields }], + }).success + ).toBe(false); + } + }); + + it('rejects invalid provider registration generations', () => { + for (const generation of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ ...registration, generation }) + .success + ).toBe(false); + } + }); + + it.each([ + { createdAt: '2026-08-28' }, + { createdAt: '2026-08-28T00:00:00Z' }, + { createdAt: '2026-02-30T00:00:00.000Z' }, + { createdAt: '2026-08-28T00:00:00.000+01:00' }, + { expiresAt: '2026-08-27T00:00:00.000Z' }, + { payloadFingerprint: 'not-a-digest' }, + { deadlines: { queue: '2026-08-27T00:00:00.000Z' } }, + { deadlines: { queue: '2026-09-04T00:00:00.001Z' } }, + { deadlines: { ...job.deadlines, execution: '2026-09-04T00:00:00.001Z' } }, + { deadlines: { ...job.deadlines, lease: 'invalid' } }, + { deadlines: { ...job.deadlines, extra: true } }, + ])('rejects malformed metadata and out-of-retention deadlines: %j', fields => { + expect(contract.browserJobSnapshotSchema.safeParse({ ...job, ...fields }).success).toBe(false); + }); + + it('applies UTF-8 byte limits instead of JavaScript string lengths', () => { + const goal = '\u00e9'.repeat(8192); + expect(contract.browserRequestSchema.parse({ ...invoke, goal })).toMatchObject({ goal }); + expect( + contract.browserTaskArgumentsSchema.parse({ + operation: 'run', + provider_id: handle.providerId, + goal, + }) + ).toMatchObject({ goal }); + expect(contract.browserRequestSchema.safeParse({ ...invoke, goal: `${goal}a` }).success).toBe( + false + ); + expect( + contract.browserTaskArgumentsSchema.safeParse({ + operation: 'run', + provider_id: handle.providerId, + goal: `${goal}a`, + }).success + ).toBe(false); + expect(contract.browserRequestSchema.safeParse({ ...invoke, goal: '' }).success).toBe(false); + expect( + contract.browserProviderOutboundMessageSchema.safeParse({ + ...registration, + label: '\u00e9'.repeat(65), + }).success + ).toBe(false); + expect( + contract.browserResultSchema.safeParse({ ...completed, summary: '\u00e9'.repeat(16385) }) + .success + ).toBe(false); + expect( + contract.browserResultSchema.safeParse({ + ...completed, + evidence: [{ text: '\u00e9'.repeat(4097) }], + }).success + ).toBe(false); + expect( + contract.browserResultSchema.safeParse({ + ...completed, + evidence: [{ title: '\u00e9'.repeat(513) }], + }).success + ).toBe(false); + expect( + contract.browserResultSchema.safeParse({ + ...completed, + evidence: [{ url: `https://example.com/${'\u00e9'.repeat(4096)}` }], + }).success + ).toBe(false); + expect( + contract.browserResponseSchema.safeParse({ + type: 'browser_response', + requestId, + response: { + kind: 'error', + code: 'invalid_request', + message: '\u00e9'.repeat(513), + retryable: false, + }, + }).success + ).toBe(false); + }); + + it('bounds serialized results at 64 KiB and complete frames below 128 KiB', () => { + const evidence = [ + { text: 'x'.repeat(8192) }, + { text: 'x'.repeat(8192) }, + { text: 'x'.repeat(8192) }, + { text: '' }, + ]; + const result = { ...completed, summary: 'x'.repeat(32768), evidence }; + evidence[3].text = 'x'.repeat(65536 - Buffer.byteLength(JSON.stringify(result), 'utf8')); + expect(contract.browserResultSchema.parse(result)).toEqual(result); + expect( + contract.browserResultSchema.safeParse({ + ...result, + summary: `${result.summary.slice(1)}\u00e9`, + }).success + ).toBe(false); + const snapshot = { ...finishedJob, result }; + expect( + contract.browserProviderInboundMessageSchema.parse({ + type: 'provider_snapshot', + ...binding, + jobs: [snapshot], + }) + ).toMatchObject({ jobs: [snapshot] }); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + type: 'provider_snapshot', + ...binding, + jobs: [snapshot, snapshot], + }).success + ).toBe(false); + expect( + contract.browserResultSchema.safeParse({ + ...completed, + evidence: Array.from({ length: 33 }, () => ({ text: 'Observed' })), + }).success + ).toBe(false); + }); + + it('bounds discovery pages, snapshot pages, and queue depth', () => { + const response = { + type: 'browser_response', + requestId, + response: { kind: 'providers', providers: Array.from({ length: 25 }, () => provider) }, + }; + expect(contract.browserResponseSchema.parse(response)).toEqual(response); + expect( + contract.browserResponseSchema.safeParse({ + ...response, + response: { ...response.response, providers: [...response.response.providers, provider] }, + }).success + ).toBe(false); + expect( + contract.browserResponseSchema.safeParse({ + ...response, + response: { ...response.response, providers: [{ ...provider, queueDepth: 101 }] }, + }).success + ).toBe(false); + expect( + contract.browserProviderInboundMessageSchema.safeParse({ + type: 'provider_snapshot', + ...binding, + jobs: Array.from({ length: 26 }, () => job), + }).success + ).toBe(false); + }); +}); + +describe('browser opt-in compatibility', () => { + it('adds new frames only through explicitly selected composite parsers', () => { + for (const frame of cliRequests) + expect(browser.cliOutboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + for (const frame of [...cliResponses, ...cliEvents]) + expect(browser.cliInboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + for (const frame of providerOutbound) + expect(browser.webOutboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + for (const frame of providerInbound) + expect(browser.webInboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + expect(browser.cliOutboundWithBrowserMessageSchema.safeParse(registration).success).toBe(false); + expect(browser.webOutboundWithBrowserMessageSchema.safeParse(invoke).success).toBe(false); + expect(browser.webInboundWithBrowserMessageSchema.safeParse(cliEvents[0]).success).toBe(false); + expect(browser.cliInboundWithBrowserMessageSchema.safeParse(providerInbound[0]).success).toBe( + false + ); + }); + + it.each([ + { capabilities: undefined, supported: false }, + { capabilities: {}, supported: false }, + { capabilities: { browserJobsV1: false }, supported: false }, + { capabilities: { browserJobsV1: true }, supported: true }, + ])( + 'normalizes negotiation without changing legacy envelopes: %j', + ({ capabilities, supported }) => { + const cases = [ + { schema: CLIOutboundMessageSchema, frame: { type: 'heartbeat', sessions: [] } }, + { schema: CLIInboundMessageSchema, frame: { type: 'heartbeat_ack' } }, + { schema: WebOutboundMessageSchema, frame: { type: 'ping', nonce: 'legacy-nonce' } }, + { schema: WebInboundMessageSchema, frame: { type: 'pong', nonce: 'legacy-nonce' } }, + ]; + for (const { schema, frame } of cases) { + const input = { ...frame, ...(capabilities === undefined ? {} : { capabilities }) }; + const parsed = schema.parse(input); + expect(parsed).toEqual(input); + const advertised = 'capabilities' in parsed ? parsed.capabilities : undefined; + expect(browser.normalizedBrowserCapabilitiesSchema.parse(advertised)).toEqual({ + browserJobsV1: supported, + }); + expect(sdk.normalizedBrowserCapabilitiesSchema.parse(advertised)).toEqual({ + browserJobsV1: supported, + }); + expect(schema.safeParse({ ...frame, capabilities: { browserJobsV1: 'yes' } }).success).toBe( + false + ); + } + } + ); + + // Frozen pre-browser callbacks must remain assignable to the parser output. + type LegacyCliInbound = + | { type: 'subscribe' | 'unsubscribe'; sessionId: string } + | { type: 'command'; id: string; command: string } + | { type: 'system'; event: string; data?: unknown } + | { type: 'heartbeat_ack' }; + type LegacyWebInbound = + | { type: 'event'; sessionId: string; event: string; data?: unknown } + | { type: 'system'; event: string; data?: unknown } + | { type: 'response'; id: string; result?: unknown; error?: unknown } + | { type: 'pong'; nonce: string }; + const cliCallback: (message: browser.CLIInboundMessage) => string = ( + message: LegacyCliInbound + ) => { + switch (message.type) { + case 'subscribe': + case 'unsubscribe': + return `${message.type}:${message.sessionId}`; + case 'command': + return `${message.command}:${message.id}`; + case 'system': + return message.event; + case 'heartbeat_ack': + return 'alive'; + } + }; + const webCallback: (message: browser.WebInboundMessage) => unknown = ( + message: LegacyWebInbound + ) => { + switch (message.type) { + case 'event': + return `${message.sessionId}:${message.event}`; + case 'system': + return message.event; + case 'response': + return message.error ?? message.result; + case 'pong': + return message.nonce; + } + }; + + it('preserves legacy callback types and delivered values', () => { + const cliExamples = [ + { + frame: { type: 'subscribe', sessionId: 'legacy-session' }, + value: 'subscribe:legacy-session', + }, + { + frame: { type: 'unsubscribe', sessionId: 'legacy-session' }, + value: 'unsubscribe:legacy-session', + }, + { + frame: { type: 'command', id: 'legacy-request', command: 'list_sessions', data: null }, + value: 'list_sessions:legacy-request', + }, + { frame: { type: 'system', event: 'web.connected', data: {} }, value: 'web.connected' }, + { frame: { type: 'heartbeat_ack' }, value: 'alive' }, + ]; + const webExamples = [ + { + frame: { type: 'event', sessionId: 'legacy-session', event: 'message.updated', data: {} }, + value: 'legacy-session:message.updated', + }, + { frame: { type: 'system', event: 'cli.connected', data: {} }, value: 'cli.connected' }, + { + frame: { type: 'response', id: 'legacy-request', result: { ok: true } }, + value: { ok: true }, + }, + { frame: { type: 'response', id: 'legacy-request', error: 'not found' }, value: 'not found' }, + { frame: { type: 'pong', nonce: 'legacy-nonce' }, value: 'legacy-nonce' }, + ]; + for (const { frame, value } of cliExamples) { + expect(cliCallback(CLIInboundMessageSchema.parse(frame))).toEqual(value); + expect(browser.cliInboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + } + for (const { frame, value } of webExamples) { + expect(webCallback(WebInboundMessageSchema.parse(frame))).toEqual(value); + expect(browser.webInboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + } + for (const frame of [ + { type: 'heartbeat', sessions: [] }, + { type: 'event', sessionId: 'legacy-session', event: 'message.updated', data: {} }, + { type: 'response', id: 'legacy-request', error: { arbitrary: ['legacy'] } }, + ]) { + expect(browser.cliOutboundWithBrowserMessageSchema.parse(frame)).toEqual(frame); + } + expect( + WebOutboundMessageSchema.parse({ + type: 'subscribe', + sessionId: 'legacy-session', + extra: true, + }) + ).toEqual({ type: 'subscribe', sessionId: 'legacy-session' }); + }); +}); const validSessionId = 'ses_12345678901234567890123456'; diff --git a/services/session-ingest/src/types/user-connection-protocol.ts b/services/session-ingest/src/types/user-connection-protocol.ts index be6c711d8f..f803014f38 100644 --- a/services/session-ingest/src/types/user-connection-protocol.ts +++ b/services/session-ingest/src/types/user-connection-protocol.ts @@ -33,6 +33,8 @@ export const CLIOutboundMessageSchema = z.discriminatedUnion('type', [ // Old form is absent sessionClone; treat missing as incapable until // every shipped CLI advertises it. sessionClone: z.boolean().optional(), + // Old heartbeats omit browserJobsV1: normalize to unsupported until all old clients retire. + browserJobsV1: z.boolean().optional(), }) .optional(), // Optional identity of the spawning CLI process. Absent on legacy CLIs @@ -110,6 +112,8 @@ export const CLIInboundMessageSchema = z.discriminatedUnion('type', [ }), z.object({ type: z.literal('heartbeat_ack'), + // Old acknowledgements omit capabilities: normalize to unsupported until all old relays retire. + capabilities: z.object({ browserJobsV1: z.boolean().optional() }).optional(), }), ]); @@ -141,6 +145,8 @@ export const WebOutboundMessageSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('ping'), nonce: z.string(), + // Old web peers omit capabilities: normalize to unsupported until all old peers retire. + capabilities: z.object({ browserJobsV1: z.boolean().optional() }).optional(), }), ]); @@ -232,6 +238,8 @@ export const WebInboundMessageSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('pong'), nonce: z.string(), + // Old web peers omit capabilities: normalize to unsupported until all old peers retire. + capabilities: z.object({ browserJobsV1: z.boolean().optional() }).optional(), }), ]); @@ -246,3 +254,526 @@ export type SessionRowEventPayload = z.infer; export type SessionDeletedPayload = z.infer; export type SessionEventPayload = z.infer; + +// -- Negotiated browser jobs v1 ----------------------------------------------- +// Keep this contract aligned with packages/cloud-agent-sdk/src/schemas.ts and +// the CLI remote-protocol.ts copy. Legacy parsers above intentionally stay narrow. + +export const BROWSER_GOAL_MAX_BYTES = 16 * 1024; +export const BROWSER_RESULT_MAX_BYTES = 64 * 1024; +export const BROWSER_FRAME_MAX_BYTES = 128 * 1024; +export const BROWSER_PAGE_SIZE = 25; + +export const browserCapabilitiesSchema = z.object({ browserJobsV1: z.boolean().optional() }); +export const normalizedBrowserCapabilitiesSchema = browserCapabilitiesSchema + .optional() + // Old peers omit capabilities or browserJobsV1. Keep this fallback until all old peers retire. + .transform(capabilities => ({ browserJobsV1: capabilities?.browserJobsV1 ?? false })); +export type BrowserCapabilities = z.infer; + +function browserText(maxBytes: number) { + return z + .string() + .max(maxBytes) + .refine(value => new TextEncoder().encode(value).byteLength <= maxBytes, { + message: 'Text exceeds the UTF-8 byte limit', + }); +} + +// Do not propagate Zod issues from proof-bearing inputs: even unknown key names +// can contain secrets. Consumers must not enable Zod's reportInput option. +function browserBoundary(schema: T) { + return z.unknown().transform((input, context): z.output => { + const parsed = schema.safeParse(input); + if ( + parsed.success && + new TextEncoder().encode(JSON.stringify(parsed.data)).byteLength < BROWSER_FRAME_MAX_BYTES + ) { + return parsed.data; + } + context.addIssue({ code: 'custom', message: 'Invalid browser message' }); + return z.NEVER; + }); +} + +export const browserProviderIdSchema = z.templateLiteral(['bp_', z.uuid()]); +export const browserTaskIdSchema = z.templateLiteral(['bt_', z.uuid()]); +export const browserJobIdSchema = z.templateLiteral(['bj_', z.uuid()]); +export const browserInvocationIdSchema = z + .string() + .regex(/^b1\.[1-9][0-9]{0,15}\.[a-f0-9]{64}$/) + .refine( + value => { + const createdAt = Number(value.split('.')[1]); + return Number.isSafeInteger(createdAt) && createdAt <= 8_640_000_000_000_000; + }, + { message: 'Invalid invocation timestamp' } + ); +const browserRequestIdSchema = z.uuid(); +const browserTimestampSchema = z.iso.datetime({ precision: 3 }); +const browserFingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/); +const browserProofSchema = z.string().regex(/^[a-f0-9]{64}$/); +const browserGoalSchema = browserText(BROWSER_GOAL_MAX_BYTES).min(1); +const browserGenerationSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER); +const browserTabIdSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const browserOwnerSchema = z.strictObject({ + parentSessionId: browserText(128).regex(/^ses_[A-Za-z0-9_-]+$/), + parentProof: browserProofSchema, +}); + +export const browserJobStatusSchema = z.enum([ + 'queued', + 'awaiting_approval', + 'running', + 'succeeded', + 'failed', + 'cancelled', + 'interrupted', + 'timed_out', +]); +export const browserTerminalStatusSchema = browserJobStatusSchema.exclude([ + 'queued', + 'awaiting_approval', + 'running', +]); +export const browserFailureReasonSchema = z.enum([ + 'approval_denied', + 'permission_denied', + 'invocation_expired', + 'invocation_conflict', + 'conversation_busy', + 'capacity_exceeded', + 'tab_lost', + 'provider_lost', + 'provider_unavailable', + 'queue_timeout', + 'approval_timeout', + 'execution_timeout', + 'lease_expired', + 'effects_uncertain', + 'cancelled', + 'runner_failed', + 'unsupported', + 'invalid_request', + 'owner_mismatch', + 'not_found', +]); +export const browserReasonCodeSchema = z.enum(['completed', ...browserFailureReasonSchema.options]); + +const browserHandleShape = { + providerId: browserProviderIdSchema, + browserTaskId: browserTaskIdSchema, + jobId: browserJobIdSchema, + invocationId: browserInvocationIdSchema, +}; +export const browserJobHandleSchema = z.strictObject(browserHandleShape); +export type BrowserJobHandle = z.infer; +const browserBindingShape = { + providerId: browserProviderIdSchema, + generation: browserGenerationSchema, +}; +const browserJobBindingShape = { ...browserHandleShape, generation: browserGenerationSchema }; + +export const browserApprovedTabSchema = z.strictObject({ + tabId: browserTabIdSchema, + title: browserText(1024), + url: browserText(8192).url(), + effectiveMode: z.enum(['safe', 'dangerous']), +}); +export const browserDeadlinesSchema = z.strictObject({ + queue: browserTimestampSchema, + approval: browserTimestampSchema.optional(), + execution: browserTimestampSchema.optional(), + lease: browserTimestampSchema.optional(), +}); +const browserJobMetadataShape = { + ...browserJobBindingShape, + payloadFingerprint: browserFingerprintSchema, + createdAt: browserTimestampSchema, + expiresAt: browserTimestampSchema, + deadlines: browserDeadlinesSchema, +}; +const browserEvidenceSchema = z + .strictObject({ + text: browserText(8192).min(1).optional(), + title: browserText(1024).min(1).optional(), + url: browserText(8192).url().optional(), + }) + .refine(evidence => Object.keys(evidence).length > 0, { + message: 'Evidence must contain an observation', + }); +const browserResultShape = { + ...browserHandleShape, + summary: browserText(32 * 1024).min(1), + evidence: z.array(browserEvidenceSchema).max(32), +}; +export const browserResultSchema = z + .discriminatedUnion('status', [ + z.strictObject({ + ...browserResultShape, + status: z.literal('succeeded'), + reason: z.literal('completed'), + effectsUncertain: z.literal(false), + }), + z.strictObject({ + ...browserResultShape, + status: browserTerminalStatusSchema.exclude(['succeeded']), + reason: browserFailureReasonSchema, + effectsUncertain: z.boolean(), + }), + ]) + .refine( + result => + new TextEncoder().encode(JSON.stringify(result)).byteLength <= BROWSER_RESULT_MAX_BYTES, + { + message: 'Result exceeds the serialized UTF-8 byte limit', + } + ); +export type BrowserResult = z.infer; + +function sameBrowserJob(left: BrowserJobHandle, right: BrowserJobHandle) { + return ( + left.providerId === right.providerId && + left.browserTaskId === right.browserTaskId && + left.jobId === right.jobId && + left.invocationId === right.invocationId + ); +} + +export const browserJobSnapshotSchema = z + .strictObject({ + ...browserJobMetadataShape, + status: browserJobStatusSchema, + // Optional display metadata; legacy snapshots omit it. + ownerLabel: browserText(128).min(1).optional(), + queuePosition: z.number().int().min(1).max(100).optional(), + approvedTab: browserApprovedTabSchema.optional(), + result: browserResultSchema.optional(), + }) + .superRefine((job, context) => { + if (job.queuePosition !== undefined && job.status !== 'queued') { + context.addIssue({ code: 'custom', message: 'Queue position requires a queued job' }); + } + const terminal = browserTerminalStatusSchema.safeParse(job.status).success; + if ( + terminal !== (job.result !== undefined) || + (job.result && (job.status !== job.result.status || !sameBrowserJob(job, job.result))) + ) { + context.addIssue({ code: 'custom', message: 'Result must match the terminal job' }); + } + if ( + (job.status === 'running' && !job.approvedTab) || + ((job.status === 'queued' || job.status === 'awaiting_approval') && job.approvedTab) + ) { + context.addIssue({ code: 'custom', message: 'Tab approval must match the job phase' }); + } + const createdAt = Date.parse(job.createdAt); + const expiresAt = Date.parse(job.expiresAt); + if ( + createdAt > expiresAt || + Object.values(job.deadlines).some( + deadline => + deadline !== undefined && + (Date.parse(deadline) < createdAt || Date.parse(deadline) > expiresAt) + ) + ) { + context.addIssue({ code: 'custom', message: 'Deadlines must stay within job retention' }); + } + }); +export type BrowserJobSnapshot = z.infer; + +// Model arguments never select parent, invocation, proof, user, or socket authority. +export const browserTaskArgumentsSchema = browserBoundary( + z.discriminatedUnion('operation', [ + z.strictObject({ operation: z.literal('list') }), + z.strictObject({ + operation: z.literal('run'), + provider_id: browserProviderIdSchema, + goal: browserGoalSchema, + browser_task_id: browserTaskIdSchema.optional(), + }), + z.strictObject({ + operation: z.literal('status'), + browser_task_id: browserTaskIdSchema, + job_id: browserJobIdSchema.optional(), + }), + z.strictObject({ + operation: z.literal('cancel'), + browser_task_id: browserTaskIdSchema, + job_id: browserJobIdSchema.optional(), + }), + z.strictObject({ operation: z.literal('recover') }), + ]) +); +export type BrowserTaskArguments = z.infer; + +const browserRequestShape = { + type: z.literal('browser_request'), + requestId: browserRequestIdSchema, +}; +// An absent jobId selects only this conversation's latest job, after owner verification. +const browserOwnedLookupShape = { + owner: browserOwnerSchema, + browserTaskId: browserTaskIdSchema, + jobId: browserJobIdSchema.optional(), +}; +// Only authenticated, negotiated CLI sockets can submit these requests. Recover +// looks up a persisted invocation; it cannot carry a new goal or choose a provider. +export const browserRequestSchema = browserBoundary( + z.discriminatedUnion('operation', [ + z.strictObject({ + ...browserRequestShape, + operation: z.literal('list'), + cursor: browserProviderIdSchema.optional(), + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('invoke'), + owner: browserOwnerSchema, + providerId: browserProviderIdSchema, + browserTaskId: browserTaskIdSchema.optional(), + invocationId: browserInvocationIdSchema, + goal: browserGoalSchema, + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('status'), + ...browserOwnedLookupShape, + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('cancel'), + ...browserOwnedLookupShape, + }), + z.strictObject({ + ...browserRequestShape, + operation: z.literal('recover'), + owner: browserOwnerSchema, + invocationId: browserInvocationIdSchema, + }), + ]) +); +export type BrowserRequest = z.infer; + +export const browserProviderDescriptorSchema = z.strictObject({ + providerId: browserProviderIdSchema, + label: browserText(128).min(1), + availability: z.enum(['available', 'busy', 'unavailable']), + queueDepth: z.number().int().min(0).max(100), +}); +export const browserResponseSchema = browserBoundary( + z.strictObject({ + type: z.literal('browser_response'), + requestId: browserRequestIdSchema, + response: z.discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('providers'), + providers: z.array(browserProviderDescriptorSchema).max(BROWSER_PAGE_SIZE), + nextCursor: browserProviderIdSchema.optional(), + }), + // An acknowledgement is not progress or a terminal result, including cancel. + z.strictObject({ + kind: z.literal('ack'), + operation: z.enum(['invoke', 'cancel']), + ...browserHandleShape, + }), + z.strictObject({ kind: z.literal('status'), job: browserJobSnapshotSchema }), + z.strictObject({ kind: z.literal('recovered'), job: browserJobSnapshotSchema }), + z.strictObject({ kind: z.literal('not_found'), invocationId: browserInvocationIdSchema }), + z.strictObject({ + kind: z.literal('error'), + code: browserFailureReasonSchema, + message: browserText(1024).min(1), + retryable: z.boolean(), + }), + ]), + }) +); +export type BrowserResponse = z.infer; +export const browserEventSchema = browserBoundary( + z.discriminatedUnion('event', [ + z.strictObject({ + type: z.literal('browser_event'), + requestId: browserRequestIdSchema, + event: z.literal('progress'), + job: browserJobSnapshotSchema.refine( + job => !browserTerminalStatusSchema.safeParse(job.status).success, + { + message: 'Progress cannot contain a terminal result', + } + ), + }), + z.strictObject({ + type: z.literal('browser_event'), + requestId: browserRequestIdSchema, + event: z.literal('result'), + result: browserResultSchema, + }), + ]) +); +export type BrowserEvent = z.infer; +export const browserCLIInboundMessageSchema = z.union([browserResponseSchema, browserEventSchema]); + +// The registration proof stays on the authenticated provider-to-relay boundary. +// Generation zero means first registration; other values name the last grant. +// The relay allocates the next generation and binds it to the actual socket. +export const browserProviderOutboundMessageSchema = browserBoundary( + z + .discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('provider_register'), + requestId: browserRequestIdSchema, + providerId: browserProviderIdSchema, + generation: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + providerProof: browserProofSchema, + label: browserText(128).min(1), + enabled: z.literal(true), + recovery: z + .strictObject({ + invocationId: browserInvocationIdSchema, + tabId: browserTabIdSchema.optional(), + tabClosed: z.literal(true), + locksDrained: z.literal(true), + }) + .optional(), + }), + // Read-only history requires proof, not registration or a generation grant. + z.strictObject({ + type: z.literal('provider_status'), + requestId: browserRequestIdSchema, + providerId: browserProviderIdSchema, + providerProof: browserProofSchema, + cursor: browserJobIdSchema.optional(), + }), + z.strictObject({ + type: z.literal('provider_heartbeat'), + requestId: browserRequestIdSchema, + ...browserBindingShape, + cursor: browserJobIdSchema.optional(), + }), + z.strictObject({ + type: z.literal('provider_approval'), + ...browserJobBindingShape, + approval: z.discriminatedUnion('decision', [ + z.strictObject({ decision: z.literal('approved'), tab: browserApprovedTabSchema }), + z.strictObject({ decision: z.literal('denied'), reason: z.literal('approval_denied') }), + ]), + }), + z.strictObject({ + type: z.literal('provider_result'), + ...browserJobBindingShape, + tab: browserApprovedTabSchema, + result: browserResultSchema, + }), + z.strictObject({ + type: z.literal('provider_quiesced'), + ...browserJobBindingShape, + tabId: browserTabIdSchema.optional(), + }), + z.strictObject({ + type: z.literal('provider_unavailable'), + ...browserBindingShape, + reason: browserFailureReasonSchema, + effectsUncertain: z.boolean(), + }), + // Provider Stop targets this profile's exact job, not a client-selected parent. + z.strictObject({ type: z.literal('provider_cancel'), ...browserJobBindingShape }), + ]) + .refine( + message => message.type !== 'provider_result' || sameBrowserJob(message, message.result), + { + message: 'Provider result must match the job', + } + ) +); +export type BrowserProviderOutboundMessage = z.infer; + +// Provider frames never target ordinary web subscribers. Execution frames require +// a registered socket; status results require a proof-authorized request. +// A snapshot is reconciliation data, not permission to execute. +export const browserProviderInboundMessageSchema = browserBoundary( + z + .discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('provider_job'), + job: browserJobSnapshotSchema.refine(job => job.status === 'awaiting_approval', { + message: 'Dispatch requires tab approval', + }), + goal: browserGoalSchema, + ownerLabel: browserText(128).min(1), + // Old dispatches omit intent. Consumers must treat absence as unknown + // and reject execution, never infer permission to start a new conversation. + conversationMode: z.enum(['new', 'continue']).optional(), + }), + z.strictObject({ + type: z.literal('provider_job_cancel'), + ...browserJobBindingShape, + reason: browserFailureReasonSchema, + }), + z.strictObject({ + type: z.literal('provider_snapshot'), + ...browserBindingShape, + requestId: browserRequestIdSchema.optional(), + jobs: z.array(browserJobSnapshotSchema).max(BROWSER_PAGE_SIZE), + nextCursor: browserJobIdSchema.optional(), + }), + // History grants no execution, lease, approval, or recovery authority. + z + .strictObject({ + type: z.literal('provider_status_result'), + requestId: browserRequestIdSchema, + providerId: browserProviderIdSchema, + // Old responses omit this field; retained identities can outlive job history. + unresolvedFence: z + .strictObject({ + invocationId: browserInvocationIdSchema, + tabId: browserTabIdSchema.optional(), + }) + .optional(), + jobs: z.array(browserJobSnapshotSchema).max(BROWSER_PAGE_SIZE), + nextCursor: browserJobIdSchema.optional(), + }) + .refine(message => message.jobs.every(job => job.providerId === message.providerId), { + message: 'History must match the requested provider', + }), + z.strictObject({ + type: z.literal('provider_lease_ack'), + ...browserBindingShape, + requestId: browserRequestIdSchema, + leaseExpiresAt: browserTimestampSchema, + }), + ]) + .refine( + message => + message.type !== 'provider_snapshot' || + message.jobs.every( + job => job.providerId === message.providerId && job.generation === message.generation + ), + { + message: 'Snapshot must match the registered provider', + } + ) +); +export type BrowserProviderInboundMessage = z.infer; + +// Opt-in consumers adopt these separately; the legacy parser exports never widen. +export const cliOutboundWithBrowserMessageSchema = z.union([ + CLIOutboundMessageSchema, + browserRequestSchema, +]); +export const cliInboundWithBrowserMessageSchema = z.union([ + CLIInboundMessageSchema, + browserCLIInboundMessageSchema, +]); +export const webOutboundWithBrowserMessageSchema = z.union([ + WebOutboundMessageSchema, + browserProviderOutboundMessageSchema, +]); +export const webInboundWithBrowserMessageSchema = z.union([ + WebInboundMessageSchema, + browserProviderInboundMessageSchema, +]); +export type CLIOutboundWithBrowserMessage = z.infer; +export type CLIInboundWithBrowserMessage = z.infer; +export type WebOutboundWithBrowserMessage = z.infer; +export type WebInboundWithBrowserMessage = z.infer;